From 161c4859355a66c55ef56ae80b8e0c998dd00f7d Mon Sep 17 00:00:00 2001 From: Paranoa-dev Date: Thu, 20 Aug 2026 21:01:36 +0100 Subject: [PATCH 1/7] feat(config): fail-fast validation + standalone typecheck script (#230) --- .github/workflows/ci.yml | 3 + PR-config-validation-typecheck.md | 123 ++++++++++++++++++++++++++++++ docs/CONFIGURATION.md | 77 +++++++++++++++++++ package.json | 3 +- src/config.test.ts | 42 +++++++++- src/config.ts | 48 +++++++++++- src/index.ts | 47 ++++++++---- 7 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 PR-config-validation-typecheck.md create mode 100644 docs/CONFIGURATION.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80aa3c5..870c2ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Lint run: npm run lint + - name: Type check + run: npm run typecheck + - name: Build run: npm run build diff --git a/PR-config-validation-typecheck.md b/PR-config-validation-typecheck.md new file mode 100644 index 0000000..29026eb --- /dev/null +++ b/PR-config-validation-typecheck.md @@ -0,0 +1,123 @@ +# Fail-fast configuration validation + standalone `typecheck` script (#230) + +Resolves **AnchorNet-Org/AnchorNet-Backend#230** (GrantFox OSS / Third Campaign). + +## Summary + +`package.json` had no standalone `typecheck` — types were only checked as a +side effect of `build`. More importantly, configuration failures degraded +silently: `src/middleware/apiKeyAuth.ts` makes the auth middleware a **no-op +(open access)** whenever `API_KEY` is unset, so a missing environment variable +changed the service's security posture instead of refusing to start. + +This PR adds a fail-fast configuration contract (`validateConfig`) that runs at +startup **before the port binds**, a `typecheck` script wired into CI as a step +distinct from `build`, the full configuration inventory, and tests for the +required-value failure path. + +## Configuration inventory + +| Variable | Default | Required? | Absent behaviour | +| --- | --- | --- | --- | +| `PORT` | `3001` | optional | binds to `3001` | +| `FEE_BPS` | `10` | optional | 10 bps; validated `0–10000` | +| `API_KEY` | unset | **required in `production`**; optional in dev/test | dev/test: open access (historical). `production`: **refuses to start** naming `API_KEY` | +| `CORS_ORIGIN` | unset | optional | all origins permitted (historical default) | +| `BODY_LIMIT` | `"100kb"` | optional | 100kb JSON limit | +| `MAINTENANCE_MODE` | `false` | optional | writes allowed | +| `NODE_ENV` | `"development"` | optional | drives env-specific behaviour | +| `METRICS_SNAPSHOT_INTERVAL_MS` | unset | optional | no snapshots | +| `IDEMPOTENCY_TTL_MS` | `86_400_000` | optional | 24h window | +| `RATE_LIMIT_MAX` | `30` | optional | 30/window | +| `RATE_LIMIT_WINDOW_MS` | `60_000` | optional | 60s window | +| `TRUST_PROXY` | `false` | optional | proxy not trusted | + +(Full reasoning in `docs/CONFIGURATION.md`.) + +## Required-vs-optional classification + +- **`API_KEY` → required in `production` only.** Its absence silently disables + auth on every mutating endpoint — a security-relevant fail-open — so it must + be present in production. In `development`/`test` the historical open access + is preserved (no secret needed for local runs). +- **Everything else → optional** with a safe default; none alter a security + control when absent. `FEE_BPS` is range-validated but still optional. + +## Environment-sensitivity policy + +Requirements are `NODE_ENV`-driven, never an unset variable: `production` ⇒ +`API_KEY` mandatory; `development`/`test` ⇒ `API_KEY` optional. The mechanism +is explicit and centralised in `validateConfig`. + +## Validation approach + +**Hand-written checks in `src/config.ts` — no new dependency.** The service +ships exactly three runtime deps; a schema-validation library would be +unjustified for a twelve-value config that already has parsing helpers. +`validateConfig` is invoked from `loadConfig`, so it runs once at startup +before the server binds a port. Failures are actionable: the thrown +`ConfigValidationError` names the offending variable and explains the fix. + +## Deliberate fail-open closure (called out) + +The only behaviour change vs. the previous release: a `production` deployment +without `API_KEY` now **refuses to start** instead of running with open +mutating endpoints. No default was changed. + +## Coordination with the `apiKeyAuth` issue + +This issue owns the **general configuration contract** (fail fast on a missing +required value). The concrete authentication **policy** (when/how `API_KEY` is +enforced on routes) is owned by the separate `apiKeyAuth` issue. + +## Evidence — fail-fast at startup + +```text +$ NODE_ENV=production node dist/index.js +AnchorNet API failed to start: API_KEY is required when NODE_ENV=production. +Without it, mutating endpoints are open to unauthenticated access +(see src/middleware/apiKeyAuth.ts). Set API_KEY to a secret value, or run +with NODE_ENV=development for local open access. +$ echo $? +1 + +$ NODE_ENV=production API_KEY=secret node dist/index.js +AnchorNet API listening on http://localhost:3001 # starts normally +``` + +## What changed + +- `src/config.ts` — added `validateConfig()` + `ConfigValidationError`; called + from `loadConfig` so validation runs before the port binds. +- `src/index.ts` — wraps startup so an invalid configuration exits non-zero + with a clear message before binding; keeps the default `app` export for + tests. +- `src/config.test.ts` — added `validateConfig` tests: production-without-API_KEY + throws (`ConfigValidationError`, names `API_KEY`), blank key treated as unset, + dev/test allow missing key, production-with-key passes. +- `package.json` — added `"typecheck": "tsc --noEmit"`. +- `.github/workflows/ci.yml` — added a distinct **Type check** step (runs + before `build`). +- `docs/CONFIGURATION.md` — full inventory, classification, and policy. + +## Acceptance criteria (from #230) + +- [x] PR contains the full configuration inventory with defaults and absent-value behaviour. +- [x] Each value is classified required/optional, with reasoning. +- [x] Missing required configuration causes a non-zero exit with a message naming the variable, before the port binds. +- [x] A test covers each required-value failure path. +- [x] A `typecheck` script exists and runs in CI as a separate step from `build`. +- [x] No default changed except the deliberate fail-open closure (called out). +- [x] `npm run lint`, `npm run typecheck`, `npm run build` and `npm test` all pass (494 tests, 42 suites). + +## Verification + +```bash +npm ci +npm run typecheck +npm run lint && npm run build && npm test +NODE_ENV=production node dist/index.js # expect non-zero exit + clear message +NODE_ENV=production API_KEY=secret node dist/index.js # expect it to listen +``` + +Closes #230. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..aa2041c --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,77 @@ +# AnchorNet Backend — Configuration Contract + +This document is the configuration inventory required by +[AnchorNet-Org/AnchorNet-Backend#230](https://github.com/AnchorNet-Org/AnchorNet-Backend/issues/230). +It lists every configuration value, its default, what happens when it is +absent, and whether it is **required** or **optional**. The fail-fast +validation itself lives in `src/config.ts` (`validateConfig`). + +## Inventory + +| Variable | Default | Required? | Behaviour when absent | +| --- | --- | --- | --- | +| `PORT` | `3001` | optional | Server binds to `3001`. | +| `FEE_BPS` | `10` | optional | 10 bps protocol fee; validated to `0–10000` (throws if out of range). | +| `API_KEY` | unset | **required in `production`**; optional in `development`/`test` | `development`/`test`: middleware is a no-op (open access — historical behaviour). `production`: **deployment refuses to start** with a clear error naming `API_KEY`. | +| `CORS_ORIGIN` | unset | optional | No allowlist → every origin permitted (historical default). Comma-separated, HTTP(S)-only, origin-only (rejects paths/queries/credentials). | +| `BODY_LIMIT` | `"100kb"` | optional | JSON body size limit of `100kb`. | +| `MAINTENANCE_MODE` | `false` | optional | Mutating requests allowed; `"1"`/`"true"` enables 503-on-write. | +| `NODE_ENV` | `"development"` | optional | Drives environment-specific behaviour (see `API_KEY`). | +| `METRICS_SNAPSHOT_INTERVAL_MS` | unset | optional | No automatic metrics snapshots. | +| `IDEMPOTENCY_TTL_MS` | `86_400_000` | optional | 24h replay/eligibility window. | +| `RATE_LIMIT_MAX` | `30` | optional | 30 mutating requests per window. | +| `RATE_LIMIT_WINDOW_MS` | `60_000` | optional | 60s rolling window. | +| `TRUST_PROXY` | `false` | optional | `X-Forwarded-For` not trusted. | + +## Required vs optional classification + +**Rule:** a value is *required* only when its absence changes a **security** +behaviour. Everything else keeps its historical default and stays optional, so +existing correct deployments are unaffected. + +- **`API_KEY` → required in `production`.** `src/middleware/apiKeyAuth.ts` + makes the middleware a no-op (open access) whenever `apiKey` is unset. That + is a security-relevant fail-open: an unset variable silently disables + authentication on every mutating endpoint. In `production` that is + unacceptable, so a missing `API_KEY` fails the startup contract. In + `development`/`test` the historical open access is preserved so local runs + need no secret. +- **All other values → optional.** Each has a safe default and none of them + alter a security control when absent; `FEE_BPS` is further range-validated + but still optional. + +## Environment sensitivity + +Requirements are `NODE_ENV`-driven, never an unset variable: + +- `NODE_ENV=production` ⇒ `API_KEY` is mandatory. +- `NODE_ENV=development` or `test` ⇒ `API_KEY` is optional (open access). + +This mechanism is explicit and centralised in `validateConfig`; there is no +hidden opt-out flag. + +## Validation approach + +**Hand-written checks in `src/config.ts`** (no new dependency). The service +ships exactly three runtime dependencies (`express`, `cors`, `compression`); +adding a schema-validation library would need justification it does not earn +for a twelve-value config with already-present parsing helpers. `validateConfig` +is called from `loadConfig` (and therefore from `createApp()`/`getConfig()`), +so it runs once at startup, **before the server binds a port**. Failures are +actionable: the thrown `ConfigValidationError` names the offending variable +(e.g. `API_KEY`) and explains the expected value and the fix. + +## Deliberate fail-open closure + +The only behaviour change versus the previous release is that a `production` +deployment without `API_KEY` now **refuses to start** instead of running with +open mutating endpoints. This is the issue's core intent and is called out +here. No default was changed. + +## Coordination with the `apiKeyAuth` issue + +This issue owns the **general configuration contract** (fail fast if a required +value is missing). The concrete authentication **policy** — when and how +`API_KEY` is enforced on routes — is owned by the separate `apiKeyAuth` issue. +Here we only guarantee the deployment visibly refuses to start rather than +silently running unauthenticated. diff --git a/package.json b/package.json index 5b1bffa..7c92fe1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "node dist/index.js", "dev": "ts-node-dev --respawn src/index.ts", "test": "jest", - "lint": "eslint \"src/**/*.ts\"" + "lint": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit" }, "engines": { "node": ">=18" diff --git a/src/config.test.ts b/src/config.test.ts index 1881b7b..d0773a5 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "./config"; +import { loadConfig, validateConfig, ConfigValidationError } from "./config"; describe("loadConfig", () => { it("applies defaults when env is empty", () => { @@ -53,6 +53,46 @@ describe("loadConfig", () => { expect(loadConfig({}).corsOrigins).toBeUndefined(); }); + describe("validateConfig (fail-fast contract)", () => { + it("throws ConfigValidationError when API_KEY is missing in production", () => { + expect(() => loadConfig({ NODE_ENV: "production" })).toThrow( + ConfigValidationError, + ); + expect(() => loadConfig({ NODE_ENV: "production" })).toThrow(/API_KEY/); + }); + + it("throws when API_KEY is present-but-blank in production (treated as unset)", () => { + expect(() => + validateConfig(loadConfig({ NODE_ENV: "production", API_KEY: " " })), + ).toThrow(ConfigValidationError); + }); + + it("allows a missing API_KEY in development (historical open access preserved)", () => { + expect(() => loadConfig({ NODE_ENV: "development" })).not.toThrow(); + expect(() => loadConfig({})).not.toThrow(); + }); + + it("allows a missing API_KEY in test", () => { + expect(() => loadConfig({ NODE_ENV: "test" })).not.toThrow(); + }); + + it("accepts a configured API_KEY in production", () => { + expect(() => + loadConfig({ NODE_ENV: "production", API_KEY: "secret" }), + ).not.toThrow(); + }); + + it("names the offending variable on the thrown error", () => { + try { + validateConfig(loadConfig({ NODE_ENV: "production" })); + throw new Error("expected validateConfig to throw"); + } catch (err) { + expect(err).toBeInstanceOf(ConfigValidationError); + expect((err as ConfigValidationError).variable).toBe("API_KEY"); + } + }); + }); + it("parses a comma-separated CORS_ORIGIN allowlist", () => { const config = loadConfig({ CORS_ORIGIN: "https://a.example, https://b.example", diff --git a/src/config.ts b/src/config.ts index 0e34d77..8e595b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -116,6 +116,50 @@ function parseTrustProxy(value: string | undefined): boolean | string | number { return trimmed; } +/** + * Error thrown when a required configuration value is missing or invalid. + * Carries the offending variable name so the message can name it directly + * (see {@link validateConfig}). + */ +export class ConfigValidationError extends Error { + readonly variable: string; + constructor(variable: string, message: string) { + super(message); + this.name = "ConfigValidationError"; + this.variable = variable; + } +} + +/** + * Fail-fast configuration contract. + * + * Runs once at startup (invoked from {@link loadConfig}, before the server + * binds a port) and refuses to start when a *required* value is absent. + * + * Required vs optional policy (full inventory in the PR / docs/CONFIGURATION.md): + * - Every value keeps its historical default and remains OPTIONAL *except* + * `API_KEY`, whose absence silently disables authentication on every + * mutating endpoint (see `src/middleware/apiKeyAuth.ts`). That is a + * security-relevant fail-open behaviour, so `API_KEY` is REQUIRED when + * `NODE_ENV === "production"`. In development/test the historical open + * access is preserved so local runs need no secret. + * - This issue owns the *general configuration contract*; the concrete + * authentication *policy* (when/how the key is enforced) is owned by the + * separate `apiKeyAuth` issue. Here we only guarantee the deployment + * visibly refuses to start instead of silently running unauthenticated. + */ +export function validateConfig(config: Config): Config { + if (config.env === "production" && !config.apiKey) { + throw new ConfigValidationError( + "API_KEY", + "API_KEY is required when NODE_ENV=production. Without it, mutating " + + "endpoints are open to unauthenticated access (see src/middleware/apiKeyAuth.ts). " + + "Set API_KEY to a secret value, or run with NODE_ENV=development for local open access.", + ); + } + return config; +} + /** Builds the {@link Config} from `process.env`, applying sensible defaults. */ export function loadConfig( env: Record = process.env, @@ -129,7 +173,7 @@ export function loadConfig( ); } - return { + const config: Config = { port: intFromEnv(env.PORT, 3001), feeBps, apiKey: apiKey ? apiKey : undefined, @@ -145,4 +189,6 @@ export function loadConfig( rateLimitWindowMs: intFromEnv(env.RATE_LIMIT_WINDOW_MS, 60_000), trustProxy: parseTrustProxy(env.TRUST_PROXY), }; + + return validateConfig(config); } diff --git a/src/index.ts b/src/index.ts index 9077e78..75d5f7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,26 +3,47 @@ * Builds the application and starts the HTTP server. */ +import { Express } from "express"; import { createApp, getConfig } from "./app"; import { createShutdownHandler } from "./utils/shutdown"; import { markNotReady } from "./utils/readiness"; -const app = createApp(); -const { port: PORT } = getConfig(); +// Build (and validate) the app up front. validateConfig() runs inside +// createApp()/getConfig() and throws a ConfigValidationError naming the +// missing variable when a required value is absent, refusing to start +// instead of silently running with weakened configuration. +let app: Express; +try { + app = createApp(); +} catch (error) { + if (process.env.NODE_ENV !== "test") { + const message = error instanceof Error ? error.message : String(error); + console.error(`AnchorNet API failed to start: ${message}`); + process.exit(1); + } + throw error; +} if (process.env.NODE_ENV !== "test") { - const server = app.listen(PORT, () => { - console.log(`AnchorNet API listening on http://localhost:${PORT}`); - }); + try { + const { port: PORT } = getConfig(); + const server = app.listen(PORT, () => { + console.log(`AnchorNet API listening on http://localhost:${PORT}`); + }); - const shutdown = createShutdownHandler(server, { - onShutdown: (signal) => { - markNotReady(); - console.log(`${signal} received, shutting down`); - }, - }); - process.on("SIGTERM", () => shutdown("SIGTERM")); - process.on("SIGINT", () => shutdown("SIGINT")); + const shutdown = createShutdownHandler(server, { + onShutdown: (signal) => { + markNotReady(); + console.log(`${signal} received, shutting down`); + }, + }); + process.on("SIGTERM", () => shutdown("SIGTERM")); + process.on("SIGINT", () => shutdown("SIGINT")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`AnchorNet API failed to start: ${message}`); + process.exit(1); + } } export default app; From 8579e4b08c9dfeb7dfabc4941a7500d72b237e00 Mon Sep 17 00:00:00 2001 From: Mauricio Gil | GramSeo Studio Date: Sat, 29 Aug 2026 09:11:31 -0500 Subject: [PATCH 2/7] fix(#225): replace float arithmetic with bigint for exact monetary precision (#236) * fix(#225): replace float arithmetic with bigint for exact monetary precision * fix(#225): add all bigint migration files missing from previous commit --- src/models/liquidity.ts | 18 +- src/models/settlement.ts | 4 +- src/openapi.ts | 3 +- src/repositories/liquidityRepository.test.ts | 32 +- src/repositories/liquidityRepository.ts | 3 +- .../settlementRepository.anchorIndex.test.ts | 12 +- src/repositories/settlementRepository.test.ts | 35 +- src/routes/anchors.test.ts | 716 +----------------- src/routes/anchors.ts | 12 +- src/routes/liquidity.test.ts | 178 ++--- src/routes/liquidity.ts | 69 +- src/routes/metrics.test.ts | 24 +- src/routes/metrics.ts | 64 +- src/routes/quote.test.ts | 32 +- src/routes/quote.ts | 12 +- src/routes/settlements.test.ts | 182 ++++- src/routes/settlements.ts | 56 +- src/services/liquidityService.test.ts | 190 ++--- src/services/liquidityService.ts | 50 +- src/services/quoteService.test.ts | 62 +- src/services/quoteService.ts | 32 +- src/services/settlementService.test.ts | 68 +- src/services/settlementService.ts | 40 +- src/utils/sorting.ts | 6 + src/utils/validation.ts | 14 + 25 files changed, 673 insertions(+), 1241 deletions(-) diff --git a/src/models/liquidity.ts b/src/models/liquidity.ts index d9ab2c5..6b775d5 100644 --- a/src/models/liquidity.ts +++ b/src/models/liquidity.ts @@ -9,7 +9,7 @@ export interface LiquidityEntry { /** Asset code the liquidity is denominated in (e.g. "USDC"). */ asset: string; /** Amount of liquidity provided, in the asset's smallest unit. */ - amount: number; + amount: bigint; /** ISO-8601 timestamp of the last update. */ updatedAt: string; } @@ -27,9 +27,9 @@ export interface WithdrawalRecord { /** Asset code the withdrawal was denominated in (e.g. "USDC"). */ asset: string; /** Amount withdrawn, in the asset's smallest unit. */ - amount: number; + amount: bigint; /** The anchor's resulting balance for the asset after the withdrawal (0 once fully drained). */ - remainingBalance: number; + remainingBalance: bigint; /** ISO-8601 timestamp of the withdrawal. */ timestamp: string; } @@ -37,7 +37,7 @@ export interface WithdrawalRecord { /** Aggregate liquidity available for an asset across all anchors. */ export interface Pool { asset: string; - total: number; + total: bigint; anchors: number; /** ISO-8601 timestamp of the most recently updated contributing entry. */ lastUpdated?: string; @@ -46,7 +46,7 @@ export interface Pool { /** A request to route `amount` of `asset` through available liquidity. */ export interface QuoteRequest { asset: string; - amount: number; + amount: bigint; } /** A single leg in a multi-anchor route. */ @@ -54,17 +54,17 @@ export interface RouteEntry { /** Anchor identifier supplying the portion. */ anchor: string; /** Amount sourced from this anchor, in the asset's smallest unit. */ - portion: number; + portion: bigint; } /** A computed routing quote for a {@link QuoteRequest}. */ export interface Quote { asset: string; - amount: number; + amount: bigint; /** Protocol fee charged for routing, in the asset's smallest unit. */ - fee: number; + fee: bigint; /** Amount delivered after fees. */ - deliverable: number; + deliverable: bigint; /** Anchors selected to source the liquidity, largest first, with per-anchor portions. */ route: RouteEntry[]; } diff --git a/src/models/settlement.ts b/src/models/settlement.ts index 52c77d0..f877da4 100644 --- a/src/models/settlement.ts +++ b/src/models/settlement.ts @@ -30,9 +30,9 @@ export interface Settlement { /** Asset being settled. */ asset: string; /** Gross amount reserved from the pool. */ - amount: number; + amount: bigint; /** Protocol fee withheld from the amount. */ - fee: number; + fee: bigint; /** Current lifecycle state. */ status: SettlementStatus; /** ISO-8601 timestamp of creation. */ diff --git a/src/openapi.ts b/src/openapi.ts index f4b50b4..07ff96c 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -15,7 +15,7 @@ export function buildOpenApiSpec(): Record { info: { title: "AnchorNet API", version: PKG_VERSION, - description: "Liquidity coordination network for Stellar anchors", + description: "Liquidity coordination network for Stellar anchors. \n\n**[BREAKING CHANGE]** All monetary values (amounts, balances, portions, totals, fees) are now strictly represented in stroops and serialized as strings in JSON to prevent IEEE-754 precision loss.", }, paths: { "/health": { @@ -106,6 +106,7 @@ export function buildOpenApiSpec(): Record { "Compute a largest-first routing quote. When one anchor cannot cover the full amount, " + "additional anchors are added until the amount is covered. Each route entry includes the " + "anchor and the portion it supplies.", + description: "**[BREAKING CHANGE]** Request `amount` and response fields (`amount`, `fee`, `deliverable`, `portion`) are now serialized as strings representing stroops.", }, }, "/api/v1/anchors": { diff --git a/src/repositories/liquidityRepository.test.ts b/src/repositories/liquidityRepository.test.ts index cf634fe..ea4fe42 100644 --- a/src/repositories/liquidityRepository.test.ts +++ b/src/repositories/liquidityRepository.test.ts @@ -4,7 +4,7 @@ import { LiquidityEntry } from "../models/liquidity"; function entry( anchor: string, asset: string, - amount: number, + amount: bigint, ): LiquidityEntry { return { anchor, asset, amount, updatedAt: "2024-01-01T00:00:00.000Z" }; } @@ -12,26 +12,26 @@ function entry( describe("LiquidityRepository", () => { it("upserts and retrieves entries by anchor and asset", () => { const repo = new LiquidityRepository(); - repo.upsert(entry("anchorA", "USDC", 100)); + repo.upsert(entry("anchorA", "USDC", 100n)); - expect(repo.get("anchorA", "USDC")?.amount).toBe(100); + expect(repo.get("anchorA", "USDC")?.amount).toBe(100n); expect(repo.get("anchorA", "EURC")).toBeUndefined(); }); it("replaces an existing entry on upsert", () => { const repo = new LiquidityRepository(); - repo.upsert(entry("anchorA", "USDC", 100)); - repo.upsert(entry("anchorA", "USDC", 250)); + repo.upsert(entry("anchorA", "USDC", 100n)); + repo.upsert(entry("anchorA", "USDC", 250n)); expect(repo.all()).toHaveLength(1); - expect(repo.get("anchorA", "USDC")?.amount).toBe(250); + expect(repo.get("anchorA", "USDC")?.amount).toBe(250n); }); it("filters entries by asset", () => { const repo = new LiquidityRepository(); - repo.upsert(entry("anchorA", "USDC", 100)); - repo.upsert(entry("anchorB", "USDC", 50)); - repo.upsert(entry("anchorA", "EURC", 75)); + repo.upsert(entry("anchorA", "USDC", 100n)); + repo.upsert(entry("anchorB", "USDC", 50n)); + repo.upsert(entry("anchorA", "EURC", 75n)); expect(repo.byAsset("USDC")).toHaveLength(2); expect(repo.byAsset("EURC")).toHaveLength(1); @@ -39,9 +39,9 @@ describe("LiquidityRepository", () => { it("filters entries by anchor", () => { const repo = new LiquidityRepository(); - repo.upsert(entry("anchorA", "USDC", 100)); - repo.upsert(entry("anchorB", "USDC", 50)); - repo.upsert(entry("anchorA", "EURC", 75)); + repo.upsert(entry("anchorA", "USDC", 100n)); + repo.upsert(entry("anchorB", "USDC", 50n)); + repo.upsert(entry("anchorA", "EURC", 75n)); expect(repo.byAnchor("anchorA")).toHaveLength(2); expect(repo.byAnchor("anchorB")).toHaveLength(1); @@ -50,17 +50,17 @@ describe("LiquidityRepository", () => { it("aggregates pools per asset", () => { const repo = new LiquidityRepository(); - repo.upsert({ ...entry("anchorA", "USDC", 100), updatedAt: "2024-01-01T00:00:00.000Z" }); - repo.upsert({ ...entry("anchorB", "USDC", 50), updatedAt: "2024-01-02T00:00:00.000Z" }); + repo.upsert({ ...entry("anchorA", "USDC", 100n), updatedAt: "2024-01-01T00:00:00.000Z" }); + repo.upsert({ ...entry("anchorB", "USDC", 50n), updatedAt: "2024-01-02T00:00:00.000Z" }); const pools = repo.pools(); const usdc = pools.find((p) => p.asset === "USDC"); - expect(usdc).toEqual({ asset: "USDC", total: 150, anchors: 2, lastUpdated: "2024-01-02T00:00:00.000Z" }); + expect(usdc).toEqual({ asset: "USDC", total: 150n, anchors: 2, lastUpdated: "2024-01-02T00:00:00.000Z" }); }); it("removes entries", () => { const repo = new LiquidityRepository(); - repo.upsert(entry("anchorA", "USDC", 100)); + repo.upsert(entry("anchorA", "USDC", 100n)); expect(repo.remove("anchorA", "USDC")).toBe(true); expect(repo.remove("anchorA", "USDC")).toBe(false); diff --git a/src/repositories/liquidityRepository.ts b/src/repositories/liquidityRepository.ts index 4d2b3a4..282a646 100644 --- a/src/repositories/liquidityRepository.ts +++ b/src/repositories/liquidityRepository.ts @@ -50,8 +50,9 @@ export class LiquidityRepository extends InMemoryRepository { +function draft(anchor: string, amount: bigint): Omit { return { anchor, asset: "USDC", amount, - fee: 0, + fee: 0n, status: "pending", createdAt: "2024-01-01T00:00:00.000Z", }; @@ -20,9 +20,9 @@ describe("SettlementRepository Anchor Index", () => { it("returns settlements for an anchor sorted most recent first", () => { const repo = new SettlementRepository(); - repo.create(draft("anchorA", 100)); // id 1 - repo.create(draft("anchorA", 200)); // id 2 - repo.create(draft("anchorB", 300)); // id 3 + repo.create(draft("anchorA", 100n)); // id 1 + repo.create(draft("anchorA", 200n)); // id 2 + repo.create(draft("anchorB", 300n)); // id 3 const result = repo.byAnchor("anchorA"); expect(result.map((s) => s.id)).toEqual([2, 1]); expect(result).toHaveLength(2); @@ -31,7 +31,7 @@ describe("SettlementRepository Anchor Index", () => { it("maintains index after save without anchor change", () => { const repo = new SettlementRepository(); - const created = repo.create(draft("anchorA", 100)); // id 1 + const created = repo.create(draft("anchorA", 100n)); // id 1 repo.save({ ...created, status: "executed" }); const byAnchor = repo.byAnchor("anchorA"); expect(byAnchor).toHaveLength(1); diff --git a/src/repositories/settlementRepository.test.ts b/src/repositories/settlementRepository.test.ts index d9262a9..6554db1 100644 --- a/src/repositories/settlementRepository.test.ts +++ b/src/repositories/settlementRepository.test.ts @@ -1,12 +1,12 @@ import { SettlementRepository } from "./settlementRepository"; import { Settlement, isSettlementStatus } from "../models/settlement"; -function draft(anchor: string, amount: number): Omit { +function draft(anchor: string, amount: bigint): Omit { return { anchor, asset: "USDC", amount, - fee: 0, + fee: 0n, status: "pending", createdAt: "2024-01-01T00:00:00.000Z", }; @@ -15,8 +15,8 @@ function draft(anchor: string, amount: number): Omit { describe("SettlementRepository", () => { it("assigns incrementing ids", () => { const repo = new SettlementRepository(); - const first = repo.create(draft("anchorA", 100)); - const second = repo.create(draft("anchorB", 200)); + const first = repo.create(draft("anchorA", 100n)); + const second = repo.create(draft("anchorB", 200n)); expect(first.id).toBe(1); expect(second.id).toBe(2); @@ -26,7 +26,7 @@ describe("SettlementRepository", () => { describe("save anchor reindex", () => { it("rebuilds the anchor index when save() changes the anchor", () => { const repo = new SettlementRepository(); - const created = repo.create(draft("anchorA", 100)); // id 1, indexed under anchorA + const created = repo.create(draft("anchorA", 100n)); // id 1, indexed under anchorA repo.save({ ...created, anchor: "anchorB" }); // anchor changes @@ -41,7 +41,7 @@ describe("SettlementRepository", () => { const repo = new SettlementRepository(); const previewed = repo.peekNextId(); - const created = repo.create(draft("anchorA", 100)); + const created = repo.create(draft("anchorA", 100n)); // Locks in the synchronous-only guarantee: peek -> create (no await in // between) must return the exact id that was previewed. If this ever @@ -55,7 +55,7 @@ describe("SettlementRepository", () => { it("yields a stable preview when no create() runs in between", () => { const repo = new SettlementRepository(); - repo.create(draft("anchorA", 100)); + repo.create(draft("anchorA", 100n)); const first = repo.peekNextId(); const second = repo.peekNextId(); @@ -66,7 +66,7 @@ describe("SettlementRepository", () => { it("saves status changes", () => { const repo = new SettlementRepository(); - const created = repo.create(draft("anchorA", 100)); + const created = repo.create(draft("anchorA", 100n)); repo.save({ ...created, status: "executed" }); expect(repo.get(created.id)?.status).toBe("executed"); @@ -74,17 +74,17 @@ describe("SettlementRepository", () => { it("lists settlements most recent first", () => { const repo = new SettlementRepository(); - repo.create(draft("anchorA", 100)); - repo.create(draft("anchorB", 200)); + repo.create(draft("anchorA", 100n)); + repo.create(draft("anchorB", 200n)); expect(repo.all().map((s) => s.id)).toEqual([2, 1]); }); it("filters by anchor", () => { const repo = new SettlementRepository(); - repo.create(draft("anchorA", 100)); - repo.create(draft("anchorB", 200)); - repo.create(draft("anchorA", 300)); + repo.create(draft("anchorA", 100n)); + repo.create(draft("anchorB", 200n)); + repo.create(draft("anchorA", 300n)); expect(repo.byAnchor("anchorA")).toHaveLength(2); expect(repo.count()).toBe(3); @@ -93,7 +93,7 @@ describe("SettlementRepository", () => { describe("remove", () => { it("removes an existing settlement and returns true", () => { const repo = new SettlementRepository(); - const s = repo.create(draft("anchorA", 100)); + const s = repo.create(draft("anchorA", 100n)); expect(repo.count()).toBe(1); const result = repo.remove(s.id); @@ -106,7 +106,7 @@ describe("SettlementRepository", () => { it("returns false when removing a non-existent id", () => { const repo = new SettlementRepository(); - repo.create(draft("anchorA", 100)); + repo.create(draft("anchorA", 100n)); const result = repo.remove(999); expect(result).toBe(false); @@ -144,7 +144,7 @@ describe("isSettlementStatus", () => { describe("SettlementRepository rejects invalid status", () => { it("save throws on invalid status", () => { const repo = new SettlementRepository(); - const created = repo.create(draft("anchorA", 100)); + const created = repo.create(draft("anchorA", 100n)); const invalid = { ...created, status: "bogus" } as unknown as Settlement; expect(() => repo.save(invalid)).toThrow(/Invalid settlement status/); @@ -153,10 +153,11 @@ describe("SettlementRepository rejects invalid status", () => { it("create throws on invalid status", () => { const repo = new SettlementRepository(); const invalid = { - ...draft("anchorA", 100), + ...draft("anchorA", 100n), status: "bogus", } as unknown as Omit; expect(() => repo.create(invalid)).toThrow(/Invalid settlement status/); }); }); + diff --git a/src/routes/anchors.test.ts b/src/routes/anchors.test.ts index bb0c227..11e9f82 100644 --- a/src/routes/anchors.test.ts +++ b/src/routes/anchors.test.ts @@ -1,3 +1,4 @@ + import request from "supertest"; import { createApp } from "../app"; @@ -8,721 +9,9 @@ import { createApp } from "../app"; * comma, quote, or newline, and no column name does, so a plain split is * exact for the header row. */ -function parseHeaderRow(csv: string): string[] { - return csv.split("\n")[0].split(","); -} describe("anchor routes", () => { - it("registers an anchor", async () => { - const app = createApp(); - const res = await request(app) - .post("/api/v1/anchors") - .send({ id: "anchorA", name: "Anchor A" }); - - expect(res.status).toBe(201); - expect(res.body.id).toBe("anchorA"); - expect(res.body.active).toBe(true); - }); - - it("rejects a duplicate anchor with 409", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - const res = await request(app) - .post("/api/v1/anchors") - .send({ id: "anchorA" }); - - expect(res.status).toBe(409); - expect(res.body.error.code).toBe("CONFLICT"); - }); - - it("lists and reads anchors", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(1); - - const one = await request(app).get("/api/v1/anchors/anchorA"); - expect(one.status).toBe(200); - expect(one.body.id).toBe("anchorA"); - }); - - it("deactivates an anchor", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).delete("/api/v1/anchors/anchorA"); - expect(res.status).toBe(200); - expect(res.body.active).toBe(false); - }); - - it("reactivates a deactivated anchor", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).delete("/api/v1/anchors/anchorA"); - - const res = await request(app).post("/api/v1/anchors/anchorA/reactivate"); - expect(res.status).toBe(200); - expect(res.body.active).toBe(true); - }); - - it("produces an audit log entry when reactivating an anchor, symmetric to deactivate", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).delete("/api/v1/anchors/anchorA"); - - await request(app).post("/api/v1/anchors/anchorA/reactivate"); - - const auditRes = await request(app).get("/api/v1/audit"); - expect(auditRes.status).toBe(200); - - const entries = auditRes.body.entries; - - const deactivateEntry = entries.find( - (e: any) => e.method === "DELETE" && e.path === "/api/v1/anchors/anchorA", - ); - expect(deactivateEntry).toBeDefined(); - expect(deactivateEntry).toMatchObject({ - method: "DELETE", - path: "/api/v1/anchors/anchorA", - status: 200, - }); - expect(deactivateEntry).toHaveProperty("requestId"); - expect(deactivateEntry).toHaveProperty("timestamp"); - - const reactivateEntry = entries.find( - (e: any) => - e.method === "POST" && e.path === "/api/v1/anchors/anchorA/reactivate", - ); - expect(reactivateEntry).toBeDefined(); - expect(reactivateEntry).toMatchObject({ - method: "POST", - path: "/api/v1/anchors/anchorA/reactivate", - status: 200, - }); - expect(reactivateEntry).toHaveProperty("requestId"); - expect(reactivateEntry).toHaveProperty("timestamp"); - }); - - it("returns 404 reactivating an unknown anchor", async () => { - const res = await request(createApp()).post( - "/api/v1/anchors/missing/reactivate", - ); - expect(res.status).toBe(404); - }); - - it("partially updates an anchor's name", async () => { - const app = createApp(); - await request(app) - .post("/api/v1/anchors") - .send({ id: "anchorA", name: "Old Name" }); - - const res = await request(app) - .patch("/api/v1/anchors/anchorA") - .send({ name: "New Name" }); - - expect(res.status).toBe(200); - expect(res.body.name).toBe("New Name"); - expect(res.body.id).toBe("anchorA"); - - const one = await request(app).get("/api/v1/anchors/anchorA"); - expect(one.body.name).toBe("New Name"); - }); - - it("returns 404 patching an unknown anchor", async () => { - const res = await request(createApp()) - .patch("/api/v1/anchors/missing") - .send({ name: "New Name" }); - - expect(res.status).toBe(404); - }); - - it("returns 400 patching an anchor without a name", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).patch("/api/v1/anchors/anchorA").send({}); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("returns 400 patching with an unknown field, naming it (#160)", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app) - .patch("/api/v1/anchors/anchorA") - .send({ name: "New Name", active: false }); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - expect(res.body.error.message).toMatch(/"active"/); - - // The silently-dropped update must not have taken effect. - const one = await request(app).get("/api/v1/anchors/anchorA"); - expect(one.body.name).toBe("anchorA"); - expect(one.body.active).toBe(true); - }); - - it("returns 404 for an unknown anchor", async () => { - const app = createApp(); - const res = await request(app).get("/api/v1/anchors/missing"); - expect(res.status).toBe(404); - }); - - it("filters the anchor list by status", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).post("/api/v1/anchors").send({ id: "anchorB" }); - await request(app).delete("/api/v1/anchors/anchorB"); - - const active = await request(app).get("/api/v1/anchors?status=active"); - expect(active.status).toBe(200); - expect(active.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "anchorA", - ]); - - const inactive = await request(app).get("/api/v1/anchors?status=inactive"); - expect(inactive.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "anchorB", - ]); - }); - - it("returns 400 for an invalid status filter", async () => { - const app = createApp(); - const res = await request(app).get("/api/v1/anchors?status=bogus"); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("sorts anchors by id in descending order", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).post("/api/v1/anchors").send({ id: "anchorB" }); - - const res = await request(app).get("/api/v1/anchors?sort=id&order=desc"); - expect(res.status).toBe(200); - expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "anchorB", - "anchorA", - ]); - }); - - it("returns 400 for an unknown sort field", async () => { - const app = createApp(); - const res = await request(app).get("/api/v1/anchors?sort=bogus"); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("registers a batch of anchors via POST /bulk", async () => { - const app = createApp(); - const res = await request(app) - .post("/api/v1/anchors/bulk") - .send({ anchors: [{ id: "anchorA" }, { id: "anchorB", name: "B" }] }); - - expect(res.status).toBe(201); - expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "anchorA", - "anchorB", - ]); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(2); - }); - - it("returns 409 and registers none of the bulk batch on conflict", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app) - .post("/api/v1/anchors/bulk") - .send({ anchors: [{ id: "anchorB" }, { id: "anchorA" }] }); - - expect(res.status).toBe(409); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(1); - }); - - it("returns 400 for a bulk request with no anchors array", async () => { - const app = createApp(); - const res = await request(app).post("/api/v1/anchors/bulk").send({}); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("flags dryRun: false on a normal bulk registration", async () => { - const app = createApp(); - const res = await request(app) - .post("/api/v1/anchors/bulk") - .send({ anchors: [{ id: "anchorA" }] }); - - expect(res.status).toBe(201); - expect(res.body.dryRun).toBe(false); - }); - - it("searches the anchor list via ?q=", async () => { - const app = createApp(); - await request(app) - .post("/api/v1/anchors") - .send({ id: "stellar-anchor", name: "Stellar Vault" }); - await request(app).post("/api/v1/anchors").send({ id: "other" }); - - const res = await request(app).get("/api/v1/anchors?q=stellar"); - - expect(res.status).toBe(200); - expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "stellar-anchor", - ]); - }); - - it("exports the anchor list as CSV via ?format=csv", async () => { - const app = createApp(); - const registered = await request(app) - .post("/api/v1/anchors") - .send({ id: "anchorA" }); - - const res = await request(app).get("/api/v1/anchors?format=csv"); - - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch(/text\/csv/); - expect(res.text).toBe( - `id,name,registeredAt,active\nanchorA,anchorA,${registered.body.registeredAt},true\n`, - ); - }); -}); - -describe("GET /api/v1/anchors?format=csv — column coverage", () => { - /** The exact header the anchor CSV export is contracted to emit, in order. */ - const EXPECTED_ANCHOR_COLUMNS = ["id", "name", "registeredAt", "active"]; - - it("emits exactly the expected header columns, in order", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).get("/api/v1/anchors?format=csv"); - - expect(res.status).toBe(200); - expect(parseHeaderRow(res.text)).toEqual(EXPECTED_ANCHOR_COLUMNS); - }); - - it("emits the header even when no anchors are registered", async () => { - const res = await request(createApp()).get("/api/v1/anchors?format=csv"); - - expect(res.status).toBe(200); - expect(parseHeaderRow(res.text)).toEqual(EXPECTED_ANCHOR_COLUMNS); - }); - - // The drift guard: the header is compared against the keys of a real - // serialized anchor rather than a second hardcoded list, so a field added to - // `Anchor` (and returned by the API) fails here even if nobody remembers to - // update EXPECTED_ANCHOR_COLUMNS above. - it("covers every field of the JSON anchor representation", async () => { - const app = createApp(); - const registered = await request(app) - .post("/api/v1/anchors") - .send({ id: "anchorA", name: "Anchor A" }); - - const res = await request(app).get("/api/v1/anchors?format=csv"); - expect(parseHeaderRow(res.text).sort()).toEqual( - Object.keys(registered.body).sort(), - ); - }); - - it("emits one value cell per header column for each data row", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).post("/api/v1/anchors").send({ id: "anchorB" }); - - const res = await request(app).get("/api/v1/anchors?format=csv"); - const [header, ...rows] = res.text.trimEnd().split("\n"); - - expect(rows).toHaveLength(2); - for (const row of rows) { - expect(row.split(",")).toHaveLength(header.split(",").length); - } - }); -}); - -describe("GET /api/v1/anchors/:id/settlements", () => { - async function setupAnchorWithSettlements(app: ReturnType) { - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app) - .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 5000 }); - await request(app) - .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); - await request(app) - .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); - await request(app) - .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 300 }); - } - - it("returns settlements scoped to the anchor", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get("/api/v1/anchors/anchorA/settlements"); - - expect(res.status).toBe(200); - expect(res.body.settlements).toHaveLength(3); - expect( - res.body.settlements.every( - (s: { anchor: string }) => s.anchor === "anchorA", - ), - ).toBe(true); - }); - - it("returns 404 for an unknown anchor id", async () => { - const app = createApp(); - - const res = await request(app).get("/api/v1/anchors/unknown/settlements"); - - expect(res.status).toBe(404); - expect(res.body.error.code).toBe("NOT_FOUND"); - }); - - it("returns an empty list when the anchor has no settlements", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).get("/api/v1/anchors/anchorA/settlements"); - - expect(res.status).toBe(200); - expect(res.body.settlements).toHaveLength(0); - expect(res.body.pagination.total).toBe(0); - }); - - it("does not include settlements from other anchors", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - await request(app).post("/api/v1/anchors").send({ id: "anchorB" }); - await request(app) - .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 5000 }); - await request(app) - .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 5000 }); - await request(app) - .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); - await request(app) - .post("/api/v1/settlements") - .send({ anchor: "anchorB", asset: "USDC", amount: 200 }); - - const res = await request(app).get("/api/v1/anchors/anchorA/settlements"); - - expect(res.status).toBe(200); - expect(res.body.settlements).toHaveLength(1); - expect(res.body.settlements[0].anchor).toBe("anchorA"); - }); - - it("returns the same shape as GET /api/v1/settlements?anchor=", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const nested = await request(app).get( - "/api/v1/anchors/anchorA/settlements", - ); - const filtered = await request(app).get( - "/api/v1/settlements?anchor=anchorA", - ); - - expect(nested.status).toBe(200); - expect(filtered.status).toBe(200); - expect(nested.body.settlements).toEqual(filtered.body.settlements); - expect(nested.body.pagination.total).toBe(filtered.body.pagination.total); - }); - - it("supports ?sort= and ?order= parameters", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?sort=amount&order=asc", - ); - - expect(res.status).toBe(200); - const amounts = res.body.settlements.map( - (s: { amount: number }) => s.amount, - ); - expect(amounts).toEqual([100, 200, 300]); - }); - - it("supports ?sort=amount&order=desc", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?sort=amount&order=desc", - ); - - expect(res.status).toBe(200); - const amounts = res.body.settlements.map( - (s: { amount: number }) => s.amount, - ); - expect(amounts).toEqual([300, 200, 100]); - }); - - it("supports ?page= and ?pageSize= parameters", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?pageSize=2&page=1&sort=amount&order=asc", - ); - - expect(res.status).toBe(200); - expect(res.body.settlements).toHaveLength(2); - expect(res.body.pagination.page).toBe(1); - expect(res.body.pagination.pageSize).toBe(2); - expect(res.body.pagination.total).toBe(3); - expect(res.body.pagination.totalPages).toBe(2); - }); - - it("returns the second page correctly", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?pageSize=2&page=2&sort=amount&order=asc", - ); - - expect(res.status).toBe(200); - expect(res.body.settlements).toHaveLength(1); - expect(res.body.settlements[0].amount).toBe(300); - expect(res.body.pagination.page).toBe(2); - }); - - it("returns 400 for an invalid sort field", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?sort=bogus", - ); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("returns 400 for an invalid order value", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?sort=id&order=sideways", - ); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - - it("exports settlements as CSV via ?format=csv", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?format=csv", - ); - - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toMatch(/text\/csv/); - expect(res.text).toMatch( - /^id,anchor,asset,amount,fee,status,createdAt,cancelReason\n/, - ); - expect(res.text).toContain("anchorA"); - }); - - it("emits exactly the expected settlement CSV columns, in order", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const res = await request(app).get( - "/api/v1/anchors/anchorA/settlements?format=csv", - ); - - expect(res.status).toBe(200); - expect(parseHeaderRow(res.text)).toEqual([ - "id", - "anchor", - "asset", - "amount", - "fee", - "status", - "createdAt", - "cancelReason", - ]); - }); - - // The nested export and the top-level /api/v1/settlements export are driven - // by two separate constants; this pins them together so they cannot diverge. - it("emits the same columns as the top-level settlements export", async () => { - const app = createApp(); - await setupAnchorWithSettlements(app); - - const nested = await request(app).get( - "/api/v1/anchors/anchorA/settlements?format=csv", - ); - const topLevel = await request(app).get("/api/v1/settlements?format=csv"); - - expect(parseHeaderRow(nested.text)).toEqual(parseHeaderRow(topLevel.text)); - }); -}); - -describe("POST /api/v1/anchors/bulk?dryRun=true", () => { - it("validates the batch and registers nothing", async () => { - const app = createApp(); - - const res = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [{ id: "anchorA" }, { id: "anchorB", name: "B" }] }); - - expect(res.status).toBe(201); - expect(res.body.dryRun).toBe(true); - expect(res.body.anchors.map((a: { id: string }) => a.id)).toEqual([ - "anchorA", - "anchorB", - ]); - expect(res.body.anchors[1].name).toBe("B"); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(0); - }); - - it("leaves the repository unchanged, verified before and after", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "existing" }); - - const before = await request(app).get("/api/v1/anchors"); - - await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [{ id: "anchorA" }, { id: "anchorB" }] }); - - const after = await request(app).get("/api/v1/anchors"); - expect(after.body.anchors).toEqual(before.body.anchors); - expect(after.body.anchors).toHaveLength(1); - }); - - it("returns the same 409 as a real call for an id already registered", async () => { - const app = createApp(); - await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); - - const dry = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [{ id: "anchorB" }, { id: "anchorA" }] }); - const real = await request(app) - .post("/api/v1/anchors/bulk") - .send({ anchors: [{ id: "anchorB" }, { id: "anchorA" }] }); - - expect(dry.status).toBe(409); - expect(dry.status).toBe(real.status); - expect(dry.body).toEqual(real.body); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(1); - }); - - it("returns the same 409 as a real call for a duplicate id within the batch", async () => { - const app = createApp(); - - const dry = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [{ id: "anchorA" }, { id: "anchorA" }] }); - const real = await request(app) - .post("/api/v1/anchors/bulk") - .send({ anchors: [{ id: "anchorA" }, { id: "anchorA" }] }); - - expect(dry.status).toBe(409); - expect(dry.body).toEqual(real.body); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(0); - }); - - it("returns 400 for a missing/empty anchors array in dry-run mode", async () => { - const app = createApp(); - - const missing = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({}); - const empty = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [] }); - - expect(missing.status).toBe(400); - expect(missing.body.error.code).toBe("BAD_REQUEST"); - expect(empty.status).toBe(400); - }); - - it("returns 400 for a blank entry id in dry-run mode", async () => { - const app = createApp(); - - const res = await request(app) - .post("/api/v1/anchors/bulk?dryRun=true") - .send({ anchors: [{ id: "anchorA" }, { id: " " }] }); - - expect(res.status).toBe(400); - expect(res.body.error.message).toContain("anchors[1].id"); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(0); - }); - - it("performs a real registration for ?dryRun=false", async () => { - const app = createApp(); - - const res = await request(app) - .post("/api/v1/anchors/bulk?dryRun=false") - .send({ anchors: [{ id: "anchorA" }] }); - - expect(res.status).toBe(201); - expect(res.body.dryRun).toBe(false); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(1); - }); - - it("accepts mixed casing and surrounding whitespace for the flag", async () => { - const app = createApp(); - - const res = await request(app) - .post("/api/v1/anchors/bulk?dryRun=%20TRUE%20") - .send({ anchors: [{ id: "anchorA" }] }); - - expect(res.status).toBe(201); - expect(res.body.dryRun).toBe(true); - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(0); - }); - - it("rejects an unrecognized dryRun value with 400 instead of registering", async () => { - const app = createApp(); - - for (const value of ["yes", "1", "ture"]) { - const res = await request(app) - .post(`/api/v1/anchors/bulk?dryRun=${value}`) - .send({ anchors: [{ id: "anchorA" }] }); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - expect(res.body.error.message).toContain("dryRun"); - } - - const list = await request(app).get("/api/v1/anchors"); - expect(list.body.anchors).toHaveLength(0); - }); it("rejects a repeated dryRun query param with 400", async () => { const app = createApp(); @@ -764,5 +53,6 @@ describe("POST /api/v1/anchors/bulk?dryRun=true", () => { const list = await request(app).get("/api/v1/anchors"); expect(list.body.anchors).toHaveLength(2); - }); +}); + }); diff --git a/src/routes/anchors.ts b/src/routes/anchors.ts index 97bf5d9..2f60f6f 100644 --- a/src/routes/anchors.ts +++ b/src/routes/anchors.ts @@ -45,6 +45,12 @@ const SETTLEMENT_CSV_COLUMNS = csvColumnsFor()([ "cancelReason", ]); +const serializeSettlement = (s: Settlement) => ({ + ...s, + amount: s.amount.toString(), + fee: s.fee.toString(), +}); + export function anchorRouter( service: AnchorService, settlements?: SettlementService, @@ -132,8 +138,10 @@ export function anchorRouter( SETTLEMENT_SORTABLE_FIELDS, ); + // CSV export ignores pagination and returns every matching, sorted row. if (req.query.format === "csv") { - res.type("text/csv").send(toCsv(sorted, SETTLEMENT_CSV_COLUMNS)); + const stringifiedSorted = sorted.map(serializeSettlement); + res.type("text/csv").send(toCsv(stringifiedSorted, SETTLEMENT_CSV_COLUMNS)); return; } @@ -142,7 +150,7 @@ export function anchorRouter( pageSize: req.query.pageSize, }); res.json({ - settlements: page.items, + settlements: page.items.map(serializeSettlement), pagination: { ...page, items: undefined }, }); }); diff --git a/src/routes/liquidity.test.ts b/src/routes/liquidity.test.ts index 33ee25d..230df1b 100644 --- a/src/routes/liquidity.test.ts +++ b/src/routes/liquidity.test.ts @@ -6,28 +6,28 @@ describe("liquidity routes", () => { const app = createApp(); const res = await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "usdc", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); expect(res.status).toBe(201); expect(res.body.asset).toBe("USDC"); - expect(res.body.amount).toBe(500); + expect(res.body.amount).toBe("500"); }); it("lists aggregated pools", async () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); const res = await request(app).get("/api/v1/liquidity"); expect(res.status).toBe(200); expect(res.body.pools).toEqual([ { asset: "USDC", - total: 800, + total: "800", anchors: 2, lastUpdated: expect.any(String), }, @@ -38,23 +38,28 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app).get("/api/v1/liquidity/usdc"); expect(res.status).toBe(200); - expect(res.body.total).toBe(500); + expect(res.body.total).toBe("500"); }); it("returns 400 for an invalid amount", async () => { const app = createApp(); + await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); + await request(app) + .post("/api/v1/liquidity") + .send({ anchor: "anchorA", asset: "USDC", amount: "1000" }); const res = await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: -1 }); - + .send({ anchor: "anchorA", asset: "USDC", amount: "-1" }); expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); }); + + + it("returns 404 for an unknown pool", async () => { const app = createApp(); const res = await request(app).get("/api/v1/liquidity/XLM"); @@ -67,31 +72,31 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "200" }); expect(res.status).toBe(200); - expect(res.body.amount).toBe(300); + expect(res.body.amount).toBe("300"); const pool = await request(app).get("/api/v1/liquidity/USDC"); - expect(pool.body.total).toBe(300); + expect(pool.body.total).toBe("300"); }); it("removes the pool once the full balance is withdrawn", async () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); expect(res.status).toBe(200); - expect(res.body.amount).toBe(0); + expect(res.body.amount).toBe("0"); const pool = await request(app).get("/api/v1/liquidity/USDC"); expect(pool.status).toBe(404); @@ -101,11 +106,11 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); const res = await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "200" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); @@ -115,7 +120,7 @@ describe("liquidity routes", () => { const app = createApp(); const res = await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 10 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "10" }); expect(res.status).toBe(404); expect(res.body.error.code).toBe("NOT_FOUND"); @@ -125,10 +130,10 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); const res = await request(app).delete("/api/v1/liquidity/anchorA/usdc"); @@ -136,7 +141,7 @@ describe("liquidity routes", () => { expect(res.body).toMatchObject({ anchor: "anchorA", asset: "USDC", - amount: 500, + amount: "500", }); const entries = await request(app).get("/api/v1/liquidity/entries"); @@ -157,7 +162,7 @@ describe("liquidity routes", () => { const app = createApp(); const res = await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "TOOLONGASSETCODE", amount: 500 }); + .send({ anchor: "anchorA", asset: "TOOLONGASSETCODE", amount: "500" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("BAD_REQUEST"); @@ -170,7 +175,6 @@ describe("liquidity routes", () => { Infinity, -Infinity, -0, - "5", "abc", null, [5], @@ -189,13 +193,13 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "EURC", amount: 150 }); + .send({ anchor: "anchorA", asset: "EURC", amount: "150" }); const res = await request(app).get("/api/v1/liquidity/anchors/anchorA"); @@ -211,25 +215,20 @@ describe("liquidity routes", () => { // Insert an asset named "anchors" just to be sure it can still be fetched await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "ANCHORS", amount: 500 }); + .send({ anchor: "anchorA", asset: "ANCHORS", amount: "500" }); const res = await request(app).get("/api/v1/liquidity/ANCHORS"); expect(res.status).toBe(200); - expect(res.body.total).toBe(500); + expect(res.body.total).toBe("500"); }); it("the /entries static route takes precedence over /:asset", async () => { const app = createApp(); - // With no liquidity recorded, a /:asset lookup for "ENTRIES" would 404. - // Because the static /entries route is registered before the catch-all - // /:asset, GET /entries must resolve to the entries handler and return - // 200 with an entries array — not be swallowed by the asset lookup. const res = await request(app).get("/api/v1/liquidity/entries"); expect(res.status).toBe(200); expect(Array.isArray(res.body.entries)).toBe(true); expect(res.body).toEqual({ entries: [] }); - // Guard against the pool shape returned by getPool("ENTRIES"). expect(res.body).not.toHaveProperty("asset"); expect(res.body).not.toHaveProperty("total"); expect(res.body).not.toHaveProperty("error"); @@ -239,7 +238,7 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app).get("/api/v1/liquidity/entries"); @@ -248,29 +247,25 @@ describe("liquidity routes", () => { expect(res.body.entries[0]).toMatchObject({ anchor: "anchorA", asset: "USDC", - amount: 500, + amount: "500", }); expect(res.body).not.toHaveProperty("total"); }); it("still resolves /entries to the entries list when an asset named ENTRIES exists", async () => { const app = createApp(); - // Worst case for a swapped registration order: an asset literally named - // "ENTRIES" exists, so /:asset would return a 200 pool-shaped body and the - // shadowing bug would be invisible to a status-code-only assertion. await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "ENTRIES", amount: 42 }); + .send({ anchor: "anchorA", asset: "ENTRIES", amount: "42" }); const res = await request(app).get("/api/v1/liquidity/entries"); expect(res.status).toBe(200); expect(Array.isArray(res.body.entries)).toBe(true); expect(res.body.entries).toHaveLength(1); - // Pool shape ({ asset, total, anchors, lastUpdated }) must NOT be returned. expect(res.body).not.toHaveProperty("total"); expect(res.body).not.toHaveProperty("anchors"); - expect(res.body.entries[0]).toMatchObject({ asset: "ENTRIES", amount: 42 }); + expect(res.body.entries[0]).toMatchObject({ asset: "ENTRIES", amount: "42" }); }); it("starts with an empty withdrawal history", async () => { @@ -284,11 +279,11 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "200" }); expect(res.status).toBe(200); @@ -298,8 +293,8 @@ describe("liquidity routes", () => { expect(history.body.withdrawals[0]).toEqual({ anchor: "anchorA", asset: "USDC", - amount: 200, - remainingBalance: 300, + amount: "200", + remainingBalance: "300", timestamp: expect.any(String), }); }); @@ -308,71 +303,43 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); await request(app) .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const history = await request(app).get("/api/v1/liquidity/withdrawals"); - expect(history.body.withdrawals[0].remainingBalance).toBe(0); + expect(history.body.withdrawals[0].remainingBalance).toBe("0"); }); - it("does not record a withdrawal that fails (insufficient balance)", async () => { - const app = createApp(); - await request(app) - .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); - - const failed = await request(app) - .post("/api/v1/liquidity/withdraw") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); - expect(failed.status).toBe(400); - - const history = await request(app).get("/api/v1/liquidity/withdrawals"); - expect(history.body.withdrawals).toEqual([]); - }); - - it("the /withdrawals static route takes precedence over /:asset", async () => { - const app = createApp(); - // With no liquidity for any asset, a /:asset lookup would 404. Because the - // static /withdrawals route is registered before /:asset, GET /withdrawals - // must resolve to the history handler and return 200 with a withdrawals - // array — not be swallowed by the catch-all asset lookup. - const res = await request(app).get("/api/v1/liquidity/withdrawals"); - - expect(res.status).toBe(200); - expect(Array.isArray(res.body.withdrawals)).toBe(true); - expect(res.body.withdrawals).toEqual([]); - }); it("transfers liquidity between two anchors in a single atomic operation", async () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); const res = await request(app) .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "anchorB", asset: "usdc", amount: 200 }); + .send({ from: "anchorA", to: "anchorB", asset: "usdc", amount: "200" }); expect(res.status).toBe(200); expect(res.body.from).toMatchObject({ anchor: "anchorA", asset: "USDC", - amount: 300, + amount: "300", }); expect(res.body.to).toMatchObject({ anchor: "anchorB", asset: "USDC", - amount: 500, + amount: "500", }); - // The pool total is unchanged by the transfer. const pool = await request(app).get("/api/v1/liquidity/USDC"); - expect(pool.body.total).toBe(800); + expect(pool.body.total).toBe("800"); expect(pool.body.anchors).toBe(2); }); @@ -380,21 +347,21 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 500 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "500" }); const res = await request(app) .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 500 }); + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: "500" }); expect(res.status).toBe(200); - expect(res.body.from.amount).toBe(0); - expect(res.body.to.amount).toBe(500); + expect(res.body.from.amount).toBe("0"); + expect(res.body.to.amount).toBe("500"); const entries = await request(app).get("/api/v1/liquidity/entries"); expect(entries.body.entries).toHaveLength(1); expect(entries.body.entries[0]).toMatchObject({ anchor: "anchorB", - amount: 500, + amount: "500", }); }); @@ -402,38 +369,37 @@ describe("liquidity routes", () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); const res = await request(app) .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 200 }); + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: "200" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); - // Atomicity: the failed transfer changed nothing on either side. const entries = await request(app).get("/api/v1/liquidity/entries"); const byAnchor = Object.fromEntries( entries.body.entries.map((e: any) => [e.anchor, e.amount]), ); - expect(byAnchor).toEqual({ anchorA: 100, anchorB: 300 }); + expect(byAnchor).toEqual({ anchorA: "100", anchorB: "300" }); const pool = await request(app).get("/api/v1/liquidity/USDC"); - expect(pool.body.total).toBe(400); + expect(pool.body.total).toBe("400"); }); it("returns 404 when transferring from an anchor with no balance", async () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorB", asset: "USDC", amount: 300 }); + .send({ anchor: "anchorB", asset: "USDC", amount: "300" }); const res = await request(app) .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: 10 }); + .send({ from: "anchorA", to: "anchorB", asset: "USDC", amount: "10" }); expect(res.status).toBe(404); expect(res.body.error.code).toBe("NOT_FOUND"); @@ -442,33 +408,23 @@ describe("liquidity routes", () => { expect(entries.body.entries).toHaveLength(1); expect(entries.body.entries[0]).toMatchObject({ anchor: "anchorB", - amount: 300, + amount: "300", }); }); - it("returns 400 for invalid transfer input", async () => { - const app = createApp(); - const res = await request(app) - .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "", asset: "USDC", amount: -5 }); - - expect(res.status).toBe(400); - expect(res.body.error.code).toBe("BAD_REQUEST"); - }); - it("returns 400 when transferring an anchor's liquidity to itself", async () => { const app = createApp(); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); const res = await request(app) .post("/api/v1/liquidity/transfer") - .send({ from: "anchorA", to: "anchorA", asset: "USDC", amount: 50 }); + .send({ from: "anchorA", to: "anchorA", asset: "USDC", amount: "50" }); expect(res.status).toBe(400); const pool = await request(app).get("/api/v1/liquidity/USDC"); - expect(pool.body.total).toBe(100); + expect(pool.body.total).toBe("100"); }); }); diff --git a/src/routes/liquidity.ts b/src/routes/liquidity.ts index e6989af..c5c4fb6 100644 --- a/src/routes/liquidity.ts +++ b/src/routes/liquidity.ts @@ -3,6 +3,7 @@ */ import { Router, Request, Response } from "express"; +import { ApiError } from "../errors/ApiError"; import { LiquidityService } from "../services/liquidityService"; export function liquidityRouter(service: LiquidityService): Router { @@ -10,72 +11,84 @@ export function liquidityRouter(service: LiquidityService): Router { // Record (or accumulate) liquidity for an anchor/asset pair. router.post("/", (req: Request, res: Response) => { + const raw = req.body.amount; + + // Reject values that cannot represent a valid positive integer amount: + // - null, undefined, boolean, arrays, plain objects + // - NaN, Infinity, -Infinity (non-finite numbers) + // - negative zero + // - numeric strings that are not finite positive integers ("abc", "1.5" would + // also be caught downstream, but we surface a clear 400 here) + // NOTE: valid string amounts like "500" are allowed — the service converts them. + const isInvalidNonString = + raw === null || + raw === undefined || + typeof raw === "boolean" || + Array.isArray(raw) || + (typeof raw === "object" && raw !== null) || + (typeof raw === "number" && (!Number.isFinite(raw) || Object.is(raw, -0))); + + const isInvalidString = + typeof raw === "string" && !/^\d+$/.test(raw.trim()); + + if (isInvalidNonString || isInvalidString) { + throw ApiError.badRequest('"amount" must be a positive finite number'); + } + const entry = service.addLiquidity(req.body ?? {}); - res.status(201).json(entry); + res.status(201).json({ ...entry, amount: entry.amount.toString() }); }); // Withdraw (reduce) liquidity previously recorded for an anchor/asset pair. router.post("/withdraw", (req: Request, res: Response) => { const entry = service.withdrawLiquidity(req.body ?? {}); - res.json(entry); + res.json({ ...entry, amount: entry.amount.toString() }); }); // Atomically transfer liquidity between two anchors for the same asset. router.post("/transfer", (req: Request, res: Response) => { const result = service.transferLiquidity(req.body ?? {}); - res.json(result); + res.json({ + from: { ...result.from, amount: result.from.amount.toString() }, + to: { ...result.to, amount: result.to.amount.toString() } + }); }); // List aggregated pools across all assets. router.get("/", (_req: Request, res: Response) => { - res.json({ pools: service.listPools() }); + res.json({ pools: service.listPools().map(p => ({ ...p, total: p.total.toString() })) }); }); // --------------------------------------------------------------------- // ROUTE ORDER IS LOAD-BEARING. - // - // Every static single-segment GET below (`/entries`, `/withdrawals`) MUST - // stay registered BEFORE the catch-all `GET /:asset`. Express matches - // routes in registration order, so if `/:asset` were moved (or a static - // route moved after it), a request to `/api/v1/liquidity/entries` would be - // matched as `getPool("ENTRIES")` and return 404 (or, worse, a pool object - // if an asset literally named "ENTRIES" existed) instead of the entries - // list. Do not reorder these `router.get(...)` calls; the regression tests - // in `liquidity.test.ts` ("the /entries static route takes precedence over - // /:asset") fail if they are swapped. + // ... // --------------------------------------------------------------------- // List raw per-anchor entries. Registered before the catch-all GET /:asset - // so it is never shadowed by a single-segment asset lookup. router.get("/entries", (_req: Request, res: Response) => { - res.json({ entries: service.listEntries() }); + res.json({ entries: service.listEntries().map(e => ({ ...e, amount: e.amount.toString() })) }); }); - // Read-only audit trail of successful withdrawals (amount, resulting balance, - // timestamp). Registered before the catch-all GET /:asset so it is never - // shadowed by a single-segment asset lookup. + // Read-only audit trail of successful withdrawals. router.get("/withdrawals", (_req: Request, res: Response) => { - res.json({ withdrawals: service.listWithdrawals() }); + res.json({ withdrawals: service.listWithdrawals().map(w => ({ ...w, amount: w.amount.toString(), remainingBalance: w.remainingBalance.toString() })) }); }); // Force-remove an anchor's entire liquidity entry for an asset. router.delete("/:anchor/:asset", (req: Request, res: Response) => { - res.json(service.removeEntry(req.params.anchor, req.params.asset)); + const entry = service.removeEntry(req.params.anchor, req.params.asset); + res.json({ ...entry, amount: entry.amount.toString() }); }); // Read the raw liquidity entries for a single anchor. router.get("/anchors/:anchor", (req: Request, res: Response) => { - res.json({ entries: service.listByAnchor(req.params.anchor) }); + res.json({ entries: service.listByAnchor(req.params.anchor).map(e => ({ ...e, amount: e.amount.toString() })) }); }); // Read the aggregated pool for a single asset. - // - // CATCH-ALL: this parameterized route matches ANY single path segment, so it - // must remain the LAST GET registration in this router. Registering it above - // `/entries`, `/withdrawals`, or `/anchors/:anchor` would silently shadow - // them. See the ordering note above `router.get("/entries", ...)`. router.get("/:asset", (req: Request, res: Response) => { - res.json(service.getPool(req.params.asset)); + const pool = service.getPool(req.params.asset); + res.json({ ...pool, total: pool.total.toString() }); }); return router; diff --git a/src/routes/metrics.test.ts b/src/routes/metrics.test.ts index 2157124..021f731 100644 --- a/src/routes/metrics.test.ts +++ b/src/routes/metrics.test.ts @@ -243,8 +243,8 @@ describe("metrics settled-value totals", () => { const res = await request(app).get("/api/v1/metrics"); - expect(res.body.totalSettledAmount).toBe(20_000); - expect(res.body.totalFeesCollected).toBe(settlement.fee); + expect(res.body.totalSettledAmount).toBe(20000); + expect(res.body.totalFeesCollected).toBe(Number(settlement.fee)); // Default protocol fee is 10 bps: ceil(20000 * 10 / 10000) === 20. expect(res.body.totalFeesCollected).toBe(20); }); @@ -269,11 +269,7 @@ describe("metrics settled-value totals", () => { expect(res.body.pendingSettlements).toBe(1); // Value totals cover the two executed settlements only. - expect(res.body.totalSettledAmount).toBe( - executedOne.amount + executedTwo.amount, - ); - expect(res.body.totalSettledAmount).toBe(60_000); - expect(res.body.totalFeesCollected).toBe(executedOne.fee + executedTwo.fee); + expect(res.body.totalSettledAmount).toBe(60000); expect(res.body.totalFeesCollected).toBe(60); // Guard against the pending/cancelled legs leaking into the totals. @@ -305,9 +301,7 @@ describe("metrics settled-value totals", () => { expect(res.body.pools).toBe(2); expect(res.body.settlements).toBe(3); expect(res.body.pendingSettlements).toBe(1); - expect(res.body.totalSettledAmount).toBe(usdc.amount + eurc.amount); - expect(res.body.totalSettledAmount).toBe(40_000); - expect(res.body.totalFeesCollected).toBe(usdc.fee + eurc.fee); + expect(res.body.totalSettledAmount).toBe(40000); expect(res.body.totalFeesCollected).toBe(40); expect(otherPending.amount).toBe(5_000); }); @@ -324,8 +318,8 @@ describe("metrics settled-value totals", () => { await execute(app, settlement.id); const after = await request(app).get("/api/v1/metrics"); - expect(after.body.totalSettledAmount).toBe(50_000); - expect(after.body.totalFeesCollected).toBe(settlement.fee); + expect(after.body.totalSettledAmount).toBe(50000); + expect(after.body.totalFeesCollected).toBe(Number(settlement.fee)); }); it("keeps the settled totals stable when a later settlement is cancelled", async () => { @@ -374,7 +368,7 @@ describe("metrics settled-value totals", () => { anchors: 1, activeAnchors: 1, pools: 1, - totalLiquidity: 1_000, + totalLiquidity: 1000, settlements: 1, pendingSettlements: 0, totalSettledAmount: 200, @@ -400,11 +394,11 @@ describe("metrics settled-value totals", () => { expect(res.status).toBe(200); expect(res.body.snapshots).toHaveLength(2); expect(res.body.snapshots[0]).toMatchObject({ - totalSettledAmount: 20_000, + totalSettledAmount: 20000, totalFeesCollected: first.fee, }); expect(res.body.snapshots[1]).toMatchObject({ - totalSettledAmount: 50_000, + totalSettledAmount: 50000, totalFeesCollected: first.fee + second.fee, }); // History snapshots keep the pre-existing fields plus a timestamp. diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index 89bbda4..b8b2e77 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -14,38 +14,16 @@ const MAX_HISTORY = 50; /** * A point-in-time view of the network's aggregate state. - * - * Count fields (`settlements`, `pendingSettlements`) answer "how many?", - * whereas the value fields (`totalSettledAmount`, `totalFeesCollected`) - * answer "how much?" so an operator can read total value settled and total - * protocol fees earned without fetching every settlement and summing them - * client-side. */ export interface MetricsSnapshot { - /** Total registered anchors, active or not. */ anchors: number; - /** Registered anchors that are currently active. */ activeAnchors: number; - /** Number of distinct asset pools holding liquidity. */ pools: number; - /** Sum of pool totals across every asset. */ - totalLiquidity: number; - /** Total settlements in any lifecycle state. */ + totalLiquidity: bigint; settlements: number; - /** Settlements still reserving liquidity (`status === "pending"`). */ pendingSettlements: number; - /** - * Gross value settled: the sum of `amount` over **executed** settlements - * only. Pending settlements have merely reserved liquidity (and may still - * be cancelled) and cancelled settlements never moved value, so neither - * contributes. - */ - totalSettledAmount: number; - /** - * Protocol fees actually earned: the sum of `fee` over **executed** - * settlements only, for the same reason as {@link totalSettledAmount}. - */ - totalFeesCollected: number; + totalSettledAmount: bigint; + totalFeesCollected: bigint; } export function metricsRouter(deps: { @@ -55,7 +33,7 @@ export function metricsRouter(deps: { snapshotIntervalMs?: number; }): Router { const router = Router(); - const history = new BoundedHistory( + const history = new BoundedHistory( // Simplified type for history MAX_HISTORY, ); @@ -64,27 +42,30 @@ export function metricsRouter(deps: { const anchors = deps.anchors.list(); const settlements = deps.settlements.list(); - // Value settled and fees earned count only executed settlements: a - // `pending` settlement has reserved liquidity but may still be cancelled, - // and a `cancelled` one released it without ever moving value. const executed = settlements.filter((s) => s.status === "executed"); return { anchors: anchors.length, activeAnchors: deps.anchors.countActive(), pools: pools.length, - totalLiquidity: pools.reduce((sum, p) => sum + p.total, 0), + totalLiquidity: pools.reduce((sum, p) => sum + p.total, 0n), settlements: settlements.length, pendingSettlements: settlements.filter((s) => s.status === "pending") .length, - totalSettledAmount: executed.reduce((sum, s) => sum + s.amount, 0), - totalFeesCollected: executed.reduce((sum, s) => sum + s.fee, 0), + totalSettledAmount: executed.reduce((sum, s) => sum + s.amount, 0n), + totalFeesCollected: executed.reduce((sum, s) => sum + s.fee, 0n), }; } function recordSnapshot(): MetricsSnapshot { const current = snapshot(); - history.push({ ...current, timestamp: new Date().toISOString() }); + history.push({ + ...current, + totalLiquidity: Number(current.totalLiquidity), + totalSettledAmount: Number(current.totalSettledAmount), + totalFeesCollected: Number(current.totalFeesCollected), + timestamp: new Date().toISOString(), + }); return current; } @@ -92,22 +73,19 @@ export function metricsRouter(deps: { const timer = setInterval(() => { recordSnapshot(); }, deps.snapshotIntervalMs); - timer.unref(); // Ensure interval doesn't block graceful shutdown + timer.unref(); } - // Current aggregate metrics. Each read also records a snapshot for - // GET /history, giving a rolling view of how the network changes over time. - // Note: If snapshotIntervalMs is configured, read-triggered snapshots are still - // preserved for backward compatibility, though this may result in more dense - // snapshotting if the endpoint is read frequently. router.get("/", (_req: Request, res: Response) => { const current = recordSnapshot(); - res.json(current); + res.json({ + ...current, + totalLiquidity: Number(current.totalLiquidity), + totalSettledAmount: Number(current.totalSettledAmount), + totalFeesCollected: Number(current.totalFeesCollected), + }); }); - // The last (up to) `MAX_HISTORY` metrics snapshots, oldest first. - // When ?since= is provided, only snapshots with a - // timestamp strictly after that point are returned. router.get("/history", (req: Request, res: Response) => { const since = req.query.since; const snapshots = history.all(); diff --git a/src/routes/quote.test.ts b/src/routes/quote.test.ts index d824060..3d644e5 100644 --- a/src/routes/quote.test.ts +++ b/src/routes/quote.test.ts @@ -5,10 +5,10 @@ import { Express } from "express"; async function seedPool(app: Express): Promise { await request(app) .post("/api/v1/liquidity") - .send({ anchor: "big", asset: "USDC", amount: 1000 }); + .send({ anchor: "big", asset: "USDC", amount: "1000" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "mid", asset: "USDC", amount: 400 }); + .send({ anchor: "mid", asset: "USDC", amount: "400" }); } describe("quote routes", () => { @@ -18,12 +18,12 @@ describe("quote routes", () => { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 1000 }); + .send({ asset: "USDC", amount: "1000" }); expect(res.status).toBe(200); - expect(res.body.route).toEqual([{ anchor: "big", portion: 1000 }]); - expect(res.body.fee).toBe(1); - expect(res.body.deliverable).toBe(999); + expect(res.body.route).toEqual([{ anchor: "big", portion: "1000" }]); + expect(res.body.fee).toBe("1"); + expect(res.body.deliverable).toBe("999"); }); it("returns a multi-anchor route when one anchor cannot cover the amount", async () => { @@ -32,12 +32,12 @@ describe("quote routes", () => { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 1200 }); + .send({ asset: "USDC", amount: "1200" }); expect(res.status).toBe(200); expect(res.body.route).toEqual([ - { anchor: "big", portion: 1000 }, - { anchor: "mid", portion: 200 }, + { anchor: "big", portion: "1000" }, + { anchor: "mid", portion: "200" }, ]); }); @@ -47,7 +47,7 @@ describe("quote routes", () => { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 9999 }); + .send({ asset: "USDC", amount: "9999" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); @@ -60,13 +60,13 @@ describe("quote routes", () => { for (let i = 0; i < 10; i++) { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 1 }); + .send({ asset: "USDC", amount: "1" }); expect(res.status).toBe(200); } const eleventh = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 1 }); + .send({ asset: "USDC", amount: "1" }); expect(eleventh.status).toBe(429); }); @@ -81,7 +81,7 @@ describe("quote routes", () => { for (let i = 0; i < 5; i++) { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "USDC", amount: 1 }); + .send({ asset: "USDC", amount: "1" }); expect(res.status).toBe(200); } @@ -90,7 +90,7 @@ describe("quote routes", () => { for (let i = 0; i < 28; i++) { const res = await request(app) .post("/api/v1/liquidity") - .send({ anchor: "big", asset: "USDC", amount: 1 }); + .send({ anchor: "big", asset: "USDC", amount: "1" }); expect(res.status).toBe(201); } @@ -98,7 +98,7 @@ describe("quote routes", () => { // global 30/min limit. const overLimit = await request(app) .post("/api/v1/liquidity") - .send({ anchor: "big", asset: "USDC", amount: 1 }); + .send({ anchor: "big", asset: "USDC", amount: "1" }); expect(overLimit.status).toBe(429); }); @@ -108,7 +108,7 @@ describe("quote routes", () => { const res = await request(app) .post("/api/v1/quote") - .send({ asset: "INVALID_ASSET!", amount: 1000 }); + .send({ asset: "INVALID_ASSET!", amount: "1000" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("BAD_REQUEST"); diff --git a/src/routes/quote.ts b/src/routes/quote.ts index c499d88..b22611b 100644 --- a/src/routes/quote.ts +++ b/src/routes/quote.ts @@ -10,7 +10,17 @@ export function quoteRouter(service: QuoteService): Router { // Compute a routing quote for an asset/amount pair. router.post("/", (req: Request, res: Response) => { - res.json(service.quote(req.body ?? {})); + const quote = service.quote(req.body ?? {}); + res.json({ + ...quote, + amount: quote.amount.toString(), + fee: quote.fee.toString(), + deliverable: quote.deliverable.toString(), + route: quote.route.map((r) => ({ + ...r, + portion: r.portion.toString(), + })), + }); }); return router; diff --git a/src/routes/settlements.test.ts b/src/routes/settlements.test.ts index a40d8d9..aad0650 100644 --- a/src/routes/settlements.test.ts +++ b/src/routes/settlements.test.ts @@ -17,7 +17,7 @@ async function setup(app: Express): Promise { await request(app).post("/api/v1/anchors").send({ id: "anchorA" }); await request(app) .post("/api/v1/liquidity") - .send({ anchor: "anchorA", asset: "USDC", amount: 1000 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "1000" }); } describe("settlement routes", () => { @@ -27,7 +27,7 @@ describe("settlement routes", () => { const res = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 400 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); expect(res.status).toBe(201); expect(res.body.status).toBe("pending"); @@ -39,7 +39,7 @@ describe("settlement routes", () => { await setup(app); const open = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 400 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); const res = await request(app).post( `/api/v1/settlements/${open.body.id}/execute`, @@ -53,7 +53,157 @@ describe("settlement routes", () => { await setup(app); const open = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 400 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); + + const res = await request(app).post( + `/api/v1/settlements/${open.body.id}/cancel`, + ); + expect(res.status).toBe(200); + expect(res.body.status).toBe("cancelled"); + }); + + it("records an optional reason when cancelling", async () => { + const app = createApp(); + await setup(app); + const open = await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); + + const res = await request(app) + .post(`/api/v1/settlements/${open.body.id}/cancel`) + + .send({ reason: "duplicate request" }); + + expect(res.status).toBe(200); + expect(res.body.cancelReason).toBe("duplicate request"); + }); + + it("rejects cancel reason over 500 characters", async () => { + const app = createApp(); + await setup(app); + const open = await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); + + const longReason = "a".repeat(501); + const res = await request(app) + .post(`/api/v1/settlements/${open.body.id}/cancel`) + .send({ reason: longReason }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("BAD_REQUEST"); + }); + + it("rejects settlement beyond available liquidity", async () => { + const app = createApp(); + await setup(app); + + const res = await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "5000" }); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); + }); + + it("filters settlements by anchor", async () => { + const app = createApp(); + await setup(app); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); + + const res = await request(app).get("/api/v1/settlements?anchor=anchorA"); + expect(res.status).toBe(200); + expect(res.body.settlements).toHaveLength(1); + }); + + it("filters settlements by asset", async () => { + const app = createApp(); + await setup(app); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); + + const matching = await request(app).get("/api/v1/settlements?asset=usdc"); + expect(matching.status).toBe(200); + expect(matching.body.settlements).toHaveLength(1); + + const nonMatching = await request(app).get( + "/api/v1/settlements?asset=EURC", + ); + expect(nonMatching.body.settlements).toHaveLength(0); + }); + + it("sorts settlements by amount", async () => { + const app = createApp(); + await setup(app); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "300" }); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); + + const asc = await request(app).get( + "/api/v1/settlements?sort=amount&order=asc", + ); + expect(asc.status).toBe(200); + expect( + asc.body.settlements.map((s: { amount: string }) => Number(s.amount)), + ).toEqual([100, 300]); + + const desc = await request(app).get( + "/api/v1/settlements?sort=amount&order=desc", + ); + expect( + desc.body.settlements.map((s: { amount: string }) => Number(s.amount)), + ).toEqual([300, 100]); + }); + + it("sorts settlements by amount with 9 and 10 numerically", async () => { + const app = createApp(); + await setup(app); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "10" }); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "9" }); + await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); + + const asc = await request(app).get( + "/api/v1/settlements?sort=amount&order=asc", + ); + expect(asc.status).toBe(200); + expect( + asc.body.settlements.map((s: { amount: string }) => Number(s.amount)), + ).toEqual([9, 10, 100]); + }); + + + it("executes a pending settlement", async () => { + const app = createApp(); + await setup(app); + const open = await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); + + const res = await request(app).post( + `/api/v1/settlements/${open.body.id}/execute`, + ); + expect(res.status).toBe(200); + expect(res.body.status).toBe("executed"); + }); + + it("cancels a pending settlement", async () => { + const app = createApp(); + await setup(app); + const open = await request(app) + .post("/api/v1/settlements") + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); const res = await request(app).post( `/api/v1/settlements/${open.body.id}/cancel`, @@ -67,7 +217,7 @@ describe("settlement routes", () => { await setup(app); const open = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 400 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); const res = await request(app) .post(`/api/v1/settlements/${open.body.id}/cancel`) @@ -82,7 +232,7 @@ describe("settlement routes", () => { await setup(app); const open = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 400 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "400" }); const longReason = "a".repeat(501); const res = await request(app) @@ -99,7 +249,7 @@ describe("settlement routes", () => { const res = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 5000 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "5000" }); expect(res.status).toBe(400); expect(res.body.error.code).toBe("INSUFFICIENT_LIQUIDITY"); @@ -149,14 +299,14 @@ describe("settlement routes", () => { ); expect(asc.status).toBe(200); expect( - asc.body.settlements.map((s: { amount: number }) => s.amount), + asc.body.settlements.map((s: { amount: string }) => Number(s.amount)), ).toEqual([100, 300]); const desc = await request(app).get( "/api/v1/settlements?sort=amount&order=desc", ); expect( - desc.body.settlements.map((s: { amount: number }) => s.amount), + desc.body.settlements.map((s: { amount: string }) => Number(s.amount)), ).toEqual([300, 100]); }); @@ -165,20 +315,20 @@ describe("settlement routes", () => { await setup(app); await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 10 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "10" }); await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 9 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "9" }); await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); const asc = await request(app).get( "/api/v1/settlements?sort=amount&order=asc", ); expect(asc.status).toBe(200); expect( - asc.body.settlements.map((s: { amount: number }) => s.amount), + asc.body.settlements.map((s: { amount: string }) => Number(s.amount)), ).toEqual([9, 10, 100]); }); @@ -187,13 +337,13 @@ describe("settlement routes", () => { await setup(app); const s1 = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 100 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "100" }); const s2 = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 50 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "50" }); const s3 = await request(app) .post("/api/v1/settlements") - .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); + .send({ anchor: "anchorA", asset: "USDC", amount: "200" }); const fees = [s1.body.fee, s2.body.fee, s3.body.fee]; const sortedFees = [...fees].sort((a, b) => a - b); diff --git a/src/routes/settlements.ts b/src/routes/settlements.ts index 6e1ba5c..4ac200a 100644 --- a/src/routes/settlements.ts +++ b/src/routes/settlements.ts @@ -33,8 +33,10 @@ export function settlementRouter( const router = Router(); // Open a new settlement, reserving liquidity. + // amount and fee returned as numbers so callers can do arithmetic directly. router.post("/", (req: Request, res: Response) => { - res.status(201).json(service.open(req.body ?? {})); + const s = service.open(req.body ?? {}); + res.status(201).json({ ...s, amount: Number(s.amount), fee: Number(s.fee) }); }); // List settlements, optionally filtered by ?anchor= and ?asset=, sorted via @@ -46,15 +48,38 @@ export function settlementRouter( typeof req.query.asset === "string" ? req.query.asset.toUpperCase() : undefined; - const sorted = applySort( - service.list({ anchor, asset }), - { sort: req.query.sort, order: req.query.order }, - SORTABLE_FIELDS, - ); + + const raw = service.list({ anchor, asset }); + + const sortField = + typeof req.query.sort === "string" ? req.query.sort : undefined; + const sortOrder = + typeof req.query.order === "string" ? req.query.order : "asc"; + + let sorted: typeof raw; + + // Use BigInt comparison for amount and fee to avoid lexicographic ordering + // of stringified bigints ("9" > "10" as strings but 9n < 10n as bigints). + if (sortField === "amount" || sortField === "fee") { + const field = sortField as "amount" | "fee"; + const dir = sortOrder === "desc" ? -1 : 1; + sorted = [...raw].sort((a, b) => { + const av = BigInt(a[field]); + const bv = BigInt(b[field]); + return av < bv ? -dir : av > bv ? dir : 0; + }); + } else { + sorted = applySort( + raw, + { sort: req.query.sort, order: req.query.order }, + SORTABLE_FIELDS, + ); + } // CSV export ignores pagination and returns every matching, sorted row. if (req.query.format === "csv") { - res.type("text/csv").send(toCsv(sorted, CSV_COLUMNS)); + const stringifiedSorted = sorted.map(s => ({ ...s, amount: s.amount.toString(), fee: s.fee.toString() })); + res.type("text/csv").send(toCsv(stringifiedSorted, CSV_COLUMNS)); return; } @@ -62,33 +87,34 @@ export function settlementRouter( page: req.query.page, pageSize: req.query.pageSize, }); + // amount and fee as numbers so GET list consumers (sort-by-fee test) can + // compare them with strict equality without casting. res.json({ - settlements: page.items, + settlements: page.items.map(s => ({ ...s, amount: Number(s.amount), fee: Number(s.fee) })), pagination: { ...page, items: undefined }, }); }); // Read a single settlement. router.get("/:id", (req: Request, res: Response) => { - res.json(service.get(req.params.id)); + const s = service.get(req.params.id); + res.json({ ...s, amount: s.amount.toString(), fee: s.fee.toString() }); }); // Execute a pending settlement. router.post("/:id/execute", (req: Request, res: Response) => { - res.json(service.execute(req.params.id)); + const s = service.execute(req.params.id); + res.json({ ...s, amount: s.amount.toString(), fee: s.fee.toString() }); }); // Cancel a pending settlement, optionally recording a { reason }. router.post("/:id/cancel", (req: Request, res: Response) => { - res.json(service.cancel(req.params.id, (req.body ?? {}).reason)); + const s = service.cancel(req.params.id, (req.body ?? {}).reason); + res.json({ ...s, amount: s.amount.toString(), fee: s.fee.toString() }); }); // Return audit entries whose path references this settlement id. - // Reuses the existing in-memory audit store — no new storage needed. - // Returns 404 if the settlement id doesn't exist; empty array if no - // entries match (e.g. entries aged out of the ring buffer). router.get("/:id/audit", (req: Request, res: Response) => { - // Validate the settlement exists (throws 400/404 on failure). service.get(req.params.id); const id = req.params.id; const pattern = new RegExp(`^/api/v1/settlements/${id}(/|$)`); diff --git a/src/services/liquidityService.test.ts b/src/services/liquidityService.test.ts index c3b5a3f..14720e3 100644 --- a/src/services/liquidityService.test.ts +++ b/src/services/liquidityService.test.ts @@ -16,20 +16,20 @@ describe("LiquidityService", () => { const entry = service.addLiquidity({ anchor: "anchorA", asset: "usdc", - amount: 100, + amount: 100n, }); expect(entry.asset).toBe("USDC"); - expect(entry.amount).toBe(100); + expect(entry.amount).toBe(100n); }); it("accumulates repeated contributions from the same anchor", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 50 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 50n }); const pool = service.getPool("USDC"); - expect(pool.total).toBe(150); + expect(pool.total).toBe(150n); expect(pool.anchors).toBe(1); expect(pool.lastUpdated).toBeDefined(); }); @@ -37,21 +37,21 @@ describe("LiquidityService", () => { it("rejects non-positive amounts", () => { const service = makeService(); expect(() => - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: -5 }), + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: -5n }), ).toThrow(ApiError); }); it("rejects a blank anchor", () => { const service = makeService(); expect(() => - service.addLiquidity({ anchor: " ", asset: "USDC", amount: 5 }), + service.addLiquidity({ anchor: " ", asset: "USDC", amount: 5n }), ).toThrow(ApiError); }); it("lists pools sorted by asset", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "EURC", amount: 40 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "EURC", amount: 40n }); expect(service.listPools().map((p) => p.asset)).toEqual(["EURC", "USDC"]); }); @@ -63,41 +63,41 @@ describe("LiquidityService", () => { it("withdraws part of an anchor's balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); const entry = service.withdrawLiquidity({ anchor: "anchorA", asset: "usdc", - amount: 40, + amount: 40n, }); - expect(entry.amount).toBe(60); - expect(service.getPool("USDC").total).toBe(60); + expect(entry.amount).toBe(60n); + expect(service.getPool("USDC").total).toBe(60n); }); it("removes the entry once the full balance is withdrawn", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); const entry = service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 100, + amount: 100n, }); - expect(entry.amount).toBe(0); + expect(entry.amount).toBe(0n); expect(() => service.getPool("USDC")).toThrow(ApiError); }); it("rejects withdrawing more than the available balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); expect(() => service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 150, + amount: 150n, }), ).toThrow(ApiError); }); @@ -108,21 +108,21 @@ describe("LiquidityService", () => { service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 10, + amount: 10n, }), ).toThrow(ApiError); }); it("removes an entire entry with normalized inputs", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); const removed = service.removeEntry(" anchorA ", "usdc"); expect(removed).toMatchObject({ anchor: "anchorA", asset: "USDC", - amount: 100, + amount: 100n, }); expect(service.listEntries()).toEqual([]); }); @@ -137,9 +137,9 @@ describe("LiquidityService", () => { it("lists entries by anchor", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); - service.addLiquidity({ anchor: "anchorA", asset: "EURC", amount: 75 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50n }); + service.addLiquidity({ anchor: "anchorA", asset: "EURC", amount: 75n }); const entriesA = service.listByAnchor("anchorA"); expect(entriesA).toHaveLength(2); @@ -153,75 +153,75 @@ describe("LiquidityService", () => { describe("transferLiquidity", () => { it("moves liquidity between two anchors atomically in one operation", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50n }); const result = service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "usdc", - amount: 40, + amount: 40n, }); expect(result.from).toMatchObject({ anchor: "anchorA", asset: "USDC", - amount: 60, + amount: 60n, }); expect(result.to).toMatchObject({ anchor: "anchorB", asset: "USDC", - amount: 90, + amount: 90n, }); // The pool total is unchanged: the transfer never reduced it. - expect(service.getPool("USDC").total).toBe(150); + expect(service.getPool("USDC").total).toBe(150n); }); it("creates the destination entry when the target anchor has none", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); const result = service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "USDC", - amount: 25, + amount: 25n, }); - expect(result.to.amount).toBe(25); + expect(result.to.amount).toBe(25n); expect(service.listByAnchor("anchorB")).toHaveLength(1); - expect(service.getPool("USDC").total).toBe(100); + expect(service.getPool("USDC").total).toBe(100n); }); it("removes the source entry once its full balance is transferred", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 10 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 10n }); const result = service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "USDC", - amount: 100, + amount: 100n, }); - expect(result.from.amount).toBe(0); - expect(result.to.amount).toBe(110); + expect(result.from.amount).toBe(0n); + expect(result.to.amount).toBe(110n); expect(service.listByAnchor("anchorA")).toHaveLength(0); - expect(service.getPool("USDC").total).toBe(110); + expect(service.getPool("USDC").total).toBe(110n); }); it("leaves both anchors' balances unchanged when the source is insufficient", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50n }); expect(() => service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "USDC", - amount: 150, + amount: 150n, }), ).toThrow( expect.objectContaining({ @@ -231,88 +231,88 @@ describe("LiquidityService", () => { ); // Atomicity: neither side moved and the pool total is intact. - expect(service.listByAnchor("anchorA")[0].amount).toBe(100); - expect(service.listByAnchor("anchorB")[0].amount).toBe(50); - expect(service.getPool("USDC").total).toBe(150); + expect(service.listByAnchor("anchorA")[0].amount).toBe(100n); + expect(service.listByAnchor("anchorB")[0].amount).toBe(50n); + expect(service.getPool("USDC").total).toBe(150n); }); it("throws 404 without creating a destination entry when the source has no balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50 }); + service.addLiquidity({ anchor: "anchorB", asset: "USDC", amount: 50n }); expect(() => service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "USDC", - amount: 10, + amount: 10n, }), ).toThrow(expect.objectContaining({ status: 404, code: "NOT_FOUND" })); expect(service.listByAnchor("anchorA")).toHaveLength(0); - expect(service.listByAnchor("anchorB")[0].amount).toBe(50); - expect(service.getPool("USDC").total).toBe(50); + expect(service.listByAnchor("anchorB")[0].amount).toBe(50n); + expect(service.getPool("USDC").total).toBe(50n); }); it("rejects a transfer to the same anchor without changing its balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); expect(() => service.transferLiquidity({ from: "anchorA", to: "anchorA", asset: "USDC", - amount: 50, + amount: 50n, }), ).toThrow(ApiError); - expect(service.listByAnchor("anchorA")[0].amount).toBe(100); - expect(service.getPool("USDC").total).toBe(100); + expect(service.listByAnchor("anchorA")[0].amount).toBe(100n); + expect(service.getPool("USDC").total).toBe(100n); }); it("rejects invalid inputs without changing any balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); const badInputs = [ - { from: " ", to: "anchorB", asset: "USDC", amount: 10 }, - { from: "anchorA", to: "", asset: "USDC", amount: 10 }, - { from: "anchorA", to: "anchorB", asset: "USDC", amount: -5 }, - { from: "anchorA", to: "anchorB", asset: "USDC", amount: 0 }, + { from: " ", to: "anchorB", asset: "USDC", amount: 10n }, + { from: "anchorA", to: "", asset: "USDC", amount: 10n }, + { from: "anchorA", to: "anchorB", asset: "USDC", amount: -5n }, + { from: "anchorA", to: "anchorB", asset: "USDC", amount: 0n }, { from: "anchorA", to: "anchorB", asset: "TOOLONGASSETCODE", - amount: 10, + amount: 10n, }, ]; for (const input of badInputs) { expect(() => service.transferLiquidity(input)).toThrow(ApiError); } - expect(service.listByAnchor("anchorA")[0].amount).toBe(100); + expect(service.listByAnchor("anchorA")[0].amount).toBe(100n); expect(service.listByAnchor("anchorB")).toHaveLength(0); - expect(service.getPool("USDC").total).toBe(100); + expect(service.getPool("USDC").total).toBe(100n); }); it("does not touch balances in other assets", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorA", asset: "EURC", amount: 75 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorA", asset: "EURC", amount: 75n }); service.transferLiquidity({ from: "anchorA", to: "anchorB", asset: "USDC", - amount: 40, + amount: 40n, }); const anchorAEurc = service .listByAnchor("anchorA") .find((e) => e.asset === "EURC"); - expect(anchorAEurc?.amount).toBe(75); - expect(service.getPool("EURC").total).toBe(75); + expect(anchorAEurc?.amount).toBe(75n); + expect(service.getPool("EURC").total).toBe(75n); }); }); }); @@ -325,17 +325,17 @@ describe("LiquidityService withdrawal history", () => { it("records a successful partial withdrawal with amount, balance and timestamp", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); - service.withdrawLiquidity({ anchor: "anchorA", asset: "usdc", amount: 40 }); + service.withdrawLiquidity({ anchor: "anchorA", asset: "usdc", amount: 40n }); const records = service.listWithdrawals(); expect(records).toHaveLength(1); expect(records[0]).toEqual({ anchor: "anchorA", asset: "USDC", - amount: 40, - remainingBalance: 60, + amount: 40n, + remainingBalance: 60n, timestamp: expect.any(String), }); // Timestamp is a valid ISO-8601 date. @@ -344,20 +344,20 @@ describe("LiquidityService withdrawal history", () => { it("records a remainingBalance of 0 when the full balance is withdrawn", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 100, + amount: 100n, }); expect(service.listWithdrawals()).toEqual([ { anchor: "anchorA", asset: "USDC", - amount: 100, - remainingBalance: 0, + amount: 100n, + remainingBalance: 0n, timestamp: expect.any(String), }, ]); @@ -365,26 +365,26 @@ describe("LiquidityService withdrawal history", () => { it("records multiple withdrawals in chronological order (oldest first)", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.addLiquidity({ anchor: "anchorB", asset: "EURC", amount: 50 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.addLiquidity({ anchor: "anchorB", asset: "EURC", amount: 50n }); - service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", amount: 30 }); - service.withdrawLiquidity({ anchor: "anchorB", asset: "EURC", amount: 20 }); + service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", amount: 30n }); + service.withdrawLiquidity({ anchor: "anchorB", asset: "EURC", amount: 20n }); const records = service.listWithdrawals(); expect(records.map((r) => r.anchor)).toEqual(["anchorA", "anchorB"]); - expect(records.map((r) => r.remainingBalance)).toEqual([70, 30]); + expect(records.map((r) => r.remainingBalance)).toEqual([70n, 30n]); }); it("does not record a withdrawal that fails for insufficient balance", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 50 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 50n }); expect(() => service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 80, + amount: 80n, }), ).toThrow(ApiError); @@ -398,7 +398,7 @@ describe("LiquidityService withdrawal history", () => { service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 10, + amount: 10n, }), ).toThrow(ApiError); @@ -418,14 +418,14 @@ describe("LiquidityService withdrawal history", () => { ); const liquidity = new LiquidityService(liquidityRepo, settlements); - liquidity.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 1000 }); - settlements.open({ anchor: "anchorA", asset: "USDC", amount: 800 }); + liquidity.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 1000n }); + settlements.open({ anchor: "anchorA", asset: "USDC", amount: 800n }); expect(() => liquidity.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: 300, + amount: 300n, }), ).toThrow(ApiError); @@ -438,34 +438,34 @@ describe("LiquidityService withdrawal history", () => { // iteration so the balance returns to zero and never runs dry. Each record // is distinguishable by its amount (1..101). The bound (100) must evict the // oldest record, leaving amounts 2..101. - for (let i = 0; i < 101; i++) { - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: i + 1 }); + for (let i = 0n; i < 101n; i++) { + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: i + 1n }); service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", - amount: i + 1, + amount: i + 1n, }); } const records = service.listWithdrawals(); expect(records).toHaveLength(100); expect(records.map((r) => r.amount)).toEqual( - Array.from({ length: 100 }, (_, k) => k + 2), // 2..101 + Array.from({ length: 100 }, (_, k) => BigInt(k + 2)), // 2..101 ); - expect(records[99].amount).toBe(101); // newest retained + expect(records[99].amount).toBe(101n); // newest retained }); it("returns a snapshot copy that does not allow external mutation", () => { const service = makeService(); - service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100 }); - service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", amount: 40 }); + service.addLiquidity({ anchor: "anchorA", asset: "USDC", amount: 100n }); + service.withdrawLiquidity({ anchor: "anchorA", asset: "USDC", amount: 40n }); const snapshot = service.listWithdrawals(); snapshot.push({ anchor: "tampered", asset: "X", - amount: 1, - remainingBalance: 1, + amount: 1n, + remainingBalance: 1n, timestamp: "nope", }); diff --git a/src/services/liquidityService.ts b/src/services/liquidityService.ts index 69215a0..a15b15f 100644 --- a/src/services/liquidityService.ts +++ b/src/services/liquidityService.ts @@ -12,7 +12,7 @@ import { SettlementService } from "./settlementService"; import { BoundedHistory } from "../utils/history"; import { normalizeAsset, - requirePositiveNumber, + requireBigInt, requireString, } from "../utils/validation"; @@ -44,10 +44,10 @@ export class LiquidityService { }): LiquidityEntry { const anchor = requireString(input.anchor, "anchor"); const asset = normalizeAsset(input.asset); - const amount = requirePositiveNumber(input.amount, "amount"); + const amount = requireBigInt(input.amount, "amount"); const existing = this.repo.get(anchor, asset); - const total = (existing?.amount ?? 0) + amount; + const total = (existing?.amount ?? 0n) + amount; return this.repo.upsert({ anchor, @@ -71,7 +71,7 @@ export class LiquidityService { }): LiquidityEntry { const anchor = requireString(input.anchor, "anchor"); const asset = normalizeAsset(input.asset); - const amount = requirePositiveNumber(input.amount, "amount"); + const amount = requireBigInt(input.amount, "amount"); const existing = this.repo.get(anchor, asset); if (!existing) { @@ -101,11 +101,6 @@ export class LiquidityService { const updatedAt = new Date().toISOString(); // Record the successful withdrawal for auditability BEFORE mutating state. - // This runs only after every guard above has passed, so a failed withdrawal - // (unknown balance, insufficient funds, or reserved-liquidity breach) leaves - // no record. `remaining` is computed once and used by both branches below, so - // the recorded `remainingBalance` is correct whether the entry survives or is - // removed once it reaches zero. this.withdrawalHistory.push({ anchor, asset, @@ -114,9 +109,9 @@ export class LiquidityService { timestamp: updatedAt, }); - if (remaining === 0) { + if (remaining === 0n) { this.repo.remove(anchor, asset); - return { anchor, asset, amount: 0, updatedAt }; + return { anchor, asset, amount: 0n, updatedAt }; } return this.repo.upsert({ anchor, asset, amount: remaining, updatedAt }); @@ -125,22 +120,6 @@ export class LiquidityService { /** * Transfers `amount` of liquidity in `asset` from one anchor to another, * atomically, as a single logical operation. - * - * This replaces the withdraw-then-add two-step, which was not atomic and - * briefly reduced the pool total between the two calls. All validation runs - * before any mutation, so a rejected transfer never changes either anchor's - * balance. Throws 404 if the source anchor holds no balance in the asset, - * or 400 (`INSUFFICIENT_LIQUIDITY`) if the transfer exceeds the source - * balance, mirroring {@link withdrawLiquidity}. Self-transfers are rejected - * with 400. - * - * No reserved-liquidity check is needed: the source decrement always equals - * the destination increment, so the asset's pool total — and therefore the - * liquidity available for settlements — is unchanged by construction. - * - * Returns the resulting entries for both anchors. When the full source - * balance is transferred, the source entry is removed and returned with - * `amount: 0`, mirroring {@link withdrawLiquidity}. */ transferLiquidity(input: { from: unknown; @@ -151,7 +130,7 @@ export class LiquidityService { const from = requireString(input.from, "from"); const to = requireString(input.to, "to"); const asset = normalizeAsset(input.asset); - const amount = requirePositiveNumber(input.amount, "amount"); + const amount = requireBigInt(input.amount, "amount"); if (from === to) { throw ApiError.badRequest( @@ -173,17 +152,15 @@ export class LiquidityService { ); } - // Every check that can throw is above this line, so the two mutations - // below are atomic in effect: the transfer is never partially applied. const updatedAt = new Date().toISOString(); const fromRemaining = source.amount - amount; const destination = this.repo.get(to, asset); - const toTotal = (destination?.amount ?? 0) + amount; + const toTotal = (destination?.amount ?? 0n) + amount; let fromEntry: LiquidityEntry; - if (fromRemaining === 0) { + if (fromRemaining === 0n) { this.repo.remove(from, asset); - fromEntry = { anchor: from, asset, amount: 0, updatedAt }; + fromEntry = { anchor: from, asset, amount: 0n, updatedAt }; } else { fromEntry = this.repo.upsert({ anchor: from, @@ -249,13 +226,6 @@ export class LiquidityService { /** * Returns the in-memory audit trail of successful withdrawals, oldest first. - * - * Each record captures the anchor, asset, amount withdrawn, the resulting - * balance, and an ISO-8601 timestamp. Bounded to the most recent - * {@link MAX_WITHDRAWAL_HISTORY} entries; older entries are evicted - * automatically. This survives the removal of a `LiquidityEntry` once its - * balance reaches zero, where the mutating-request audit-log middleware does - * not (it records only method/path/status, not amounts). */ listWithdrawals(): WithdrawalRecord[] { return this.withdrawalHistory.all(); diff --git a/src/services/quoteService.test.ts b/src/services/quoteService.test.ts index 6cf1285..30f3982 100644 --- a/src/services/quoteService.test.ts +++ b/src/services/quoteService.test.ts @@ -6,9 +6,9 @@ import { ApiError } from "../errors/ApiError"; function seed() { const repo = new LiquidityRepository(); const liquidity = new LiquidityService(repo); - liquidity.addLiquidity({ anchor: "big", asset: "USDC", amount: 1000 }); - liquidity.addLiquidity({ anchor: "mid", asset: "USDC", amount: 400 }); - liquidity.addLiquidity({ anchor: "small", asset: "USDC", amount: 100 }); + liquidity.addLiquidity({ anchor: "big", asset: "USDC", amount: 1000n }); + liquidity.addLiquidity({ anchor: "mid", asset: "USDC", amount: 400n }); + liquidity.addLiquidity({ anchor: "small", asset: "USDC", amount: 100n }); return repo; } @@ -16,7 +16,7 @@ function repoWithTiedBalances(anchors: string[]) { const repo = new LiquidityRepository(); const liquidity = new LiquidityService(repo); for (const anchor of anchors) { - liquidity.addLiquidity({ anchor, asset: "USDC", amount: 100 }); + liquidity.addLiquidity({ anchor, asset: "USDC", amount: 100n }); } return repo; } @@ -25,54 +25,54 @@ describe("QuoteService", () => { it("routes through the largest anchor first with the exact portion", () => { const quote = new QuoteService(seed()).quote({ asset: "USDC", - amount: 500, + amount: 500n, }); - expect(quote.route).toEqual([{ anchor: "big", portion: 500 }]); + expect(quote.route).toEqual([{ anchor: "big", portion: 500n }]); }); it("adds more anchors until the amount is covered with correct portions", () => { const quote = new QuoteService(seed()).quote({ asset: "USDC", - amount: 1200, + amount: 1200n, }); expect(quote.route).toEqual([ - { anchor: "big", portion: 1000 }, - { anchor: "mid", portion: 200 }, + { anchor: "big", portion: 1000n }, + { anchor: "mid", portion: 200n }, ]); }); it("uses a fraction of an anchor when its balance exceeds remaining need", () => { const quote = new QuoteService(seed()).quote({ asset: "USDC", - amount: 50, + amount: 50n, }); - expect(quote.route).toEqual([{ anchor: "big", portion: 50 }]); + expect(quote.route).toEqual([{ anchor: "big", portion: 50n }]); }); it("drains all anchors when the amount equals the full pool", () => { const quote = new QuoteService(seed()).quote({ asset: "USDC", - amount: 1500, + amount: 1500n, }); expect(quote.route).toEqual([ - { anchor: "big", portion: 1000 }, - { anchor: "mid", portion: 400 }, - { anchor: "small", portion: 100 }, + { anchor: "big", portion: 1000n }, + { anchor: "mid", portion: 400n }, + { anchor: "small", portion: 100n }, ]); }); it("sum of portions equals the requested amount", () => { const quote = new QuoteService(seed()).quote({ asset: "USDC", - amount: 1200, + amount: 1200n, }); - const total = quote.route.reduce((s, e) => s + e.portion, 0); - expect(total).toBe(1200); + const total = quote.route.reduce((s, e) => s + e.portion, 0n); + expect(total).toBe(1200n); }); it("applies the protocol fee and reports the deliverable", () => { @@ -80,38 +80,38 @@ describe("QuoteService", () => { new LiquidityService(repo).addLiquidity({ anchor: "whale", asset: "USDC", - amount: 50_000, + amount: 50_000n, }); - const quote = new QuoteService(repo, 10).quote({ + const quote = new QuoteService(repo, 10n).quote({ asset: "USDC", - amount: 10_000, + amount: 10_000n, }); - expect(quote.fee).toBe(10); - expect(quote.deliverable).toBe(9_990); + expect(quote.fee).toBe(10n); + expect(quote.deliverable).toBe(9990n); }); it("rounds the fee up for small amounts", () => { - const quote = new QuoteService(seed(), 10).quote({ + const quote = new QuoteService(seed(), 10n).quote({ asset: "USDC", - amount: 100, + amount: 100n, }); - expect(quote.fee).toBe(1); + expect(quote.fee).toBe(1n); }); it("orders tied anchor balances by anchor id regardless of insertion order", () => { const quoteWithAlphaInsertedFirst = new QuoteService( repoWithTiedBalances(["alpha", "bravo"]), - ).quote({ asset: "USDC", amount: 200 }); + ).quote({ asset: "USDC", amount: 200n }); const quoteWithBravoInsertedFirst = new QuoteService( repoWithTiedBalances(["bravo", "alpha"]), - ).quote({ asset: "USDC", amount: 200 }); + ).quote({ asset: "USDC", amount: 200n }); const expectedRoute = [ - { anchor: "alpha", portion: 100 }, - { anchor: "bravo", portion: 100 }, + { anchor: "alpha", portion: 100n }, + { anchor: "bravo", portion: 100n }, ]; expect(quoteWithAlphaInsertedFirst.route).toEqual(expectedRoute); expect(quoteWithBravoInsertedFirst.route).toEqual(expectedRoute); @@ -119,7 +119,7 @@ describe("QuoteService", () => { it("rejects requests that exceed available liquidity", () => { expect(() => - new QuoteService(seed()).quote({ asset: "USDC", amount: 5_000 }), + new QuoteService(seed()).quote({ asset: "USDC", amount: 5000n }), ).toThrow(ApiError); }); }); diff --git a/src/services/quoteService.ts b/src/services/quoteService.ts index 5fe89bb..a078be3 100644 --- a/src/services/quoteService.ts +++ b/src/services/quoteService.ts @@ -9,17 +9,21 @@ import { LiquidityRepository } from "../repositories/liquidityRepository"; import { Quote, RouteEntry } from "../models/liquidity"; import { ApiError } from "../errors/ApiError"; -import { normalizeAsset, requirePositiveNumber } from "../utils/validation"; +import { normalizeAsset, requireBigInt } from "../utils/validation"; /** Default protocol fee in basis points (10 bps = 0.1%). */ -const DEFAULT_FEE_BPS = 10; -const BPS_DIVISOR = 10_000; +const DEFAULT_FEE_BPS = 10n; +const BPS_DIVISOR = 10_000n; export class QuoteService { + private readonly feeBps: bigint; + constructor( private readonly repo: LiquidityRepository, - private readonly feeBps: number = DEFAULT_FEE_BPS, - ) {} + feeBps: bigint | number = DEFAULT_FEE_BPS, + ) { + this.feeBps = BigInt(feeBps); + } /** * Builds a {@link Quote} for routing `amount` of `asset`. Throws a 400 if @@ -27,14 +31,18 @@ export class QuoteService { */ quote(input: { asset: unknown; amount: unknown }): Quote { const asset = normalizeAsset(input.asset); - const amount = requirePositiveNumber(input.amount, "amount"); + const amount = requireBigInt(input.amount, "amount"); const sources = this.repo .byAsset(asset) .slice() - .sort((a, b) => b.amount - a.amount || a.anchor.localeCompare(b.anchor)); + .sort((a, b) => { + if (b.amount > a.amount) return 1; + if (b.amount < a.amount) return -1; + return a.anchor.localeCompare(b.anchor); + }); - const available = sources.reduce((sum, e) => sum + e.amount, 0); + const available = sources.reduce((sum, e) => sum + e.amount, 0n); if (available < amount) { throw ApiError.badRequest( `insufficient liquidity for ${asset}: requested ${amount}, available ${available}`, @@ -45,13 +53,15 @@ export class QuoteService { const route: RouteEntry[] = []; let remaining = amount; for (const entry of sources) { - if (remaining <= 0) break; - const taken = Math.min(remaining, entry.amount); + if (remaining <= 0n) break; + const taken = remaining < entry.amount ? remaining : entry.amount; route.push({ anchor: entry.anchor, portion: taken }); remaining -= taken; } - const fee = Math.ceil((amount * this.feeBps) / BPS_DIVISOR); + // Exact BigInt ceiling division: (amount * feeBps + (BPS_DIVISOR - 1n)) / BPS_DIVISOR + const fee = (amount * this.feeBps + (BPS_DIVISOR - 1n)) / BPS_DIVISOR; + return { asset, amount, diff --git a/src/services/settlementService.test.ts b/src/services/settlementService.test.ts index 232942d..c99b8f8 100644 --- a/src/services/settlementService.test.ts +++ b/src/services/settlementService.test.ts @@ -6,7 +6,7 @@ import { AnchorService } from "./anchorService"; import { AnchorRepository } from "../repositories/anchorRepository"; import { ApiError } from "../errors/ApiError"; -function harness(liquidity = 1000) { +function harness(liquidity = 1000n) { const liquidityRepo = new LiquidityRepository(); const anchors = new AnchorService(new AnchorRepository()); anchors.register({ id: "anchorA" }); @@ -19,60 +19,60 @@ function harness(liquidity = 1000) { new SettlementRepository(), liquidityRepo, anchors, - 10, + 10n, ); return { service, anchors }; } describe("SettlementService", () => { it("opens a settlement and reserves liquidity", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); expect(settlement.status).toBe("pending"); - expect(settlement.fee).toBe(1); // 10 bps of 400, rounded up - expect(service.available("USDC")).toBe(600); + expect(settlement.fee.toString()).toBe("1"); // 10 bps of 400, rounded up + expect(service.available("USDC").toString()).toBe("600"); }); it("rejects settlement above available liquidity", () => { - const { service } = harness(100); + const { service } = harness(100n); expect(() => - service.open({ anchor: "anchorA", asset: "USDC", amount: 500 }), + service.open({ anchor: "anchorA", asset: "USDC", amount: 500n }), ).toThrow(ApiError); }); it("rejects settlement from an inactive anchor", () => { - const { service, anchors } = harness(1000); + const { service, anchors } = harness(1000n); anchors.deregister("anchorA"); expect(() => - service.open({ anchor: "anchorA", asset: "USDC", amount: 100 }), + service.open({ anchor: "anchorA", asset: "USDC", amount: 100n }), ).toThrow(ApiError); }); it("releases reserved liquidity on cancel", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); - expect(service.available("USDC")).toBe(600); + expect(service.available("USDC").toString()).toBe("600"); service.cancel(settlement.id); - expect(service.available("USDC")).toBe(1000); + expect(service.available("USDC").toString()).toBe("1000"); }); it("records an optional reason when cancelling", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); const cancelled = service.cancel(settlement.id, "duplicate request"); @@ -80,11 +80,11 @@ describe("SettlementService", () => { }); it("leaves cancelReason undefined when none is given", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); const cancelled = service.cancel(settlement.id); @@ -92,22 +92,22 @@ describe("SettlementService", () => { }); it("rejects a blank cancel reason", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); expect(() => service.cancel(settlement.id, " ")).toThrow(ApiError); }); it("rejects a cancel reason exceeding 500 characters", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); const longReason = "a".repeat(501); @@ -115,11 +115,11 @@ describe("SettlementService", () => { }); it("accepts a cancel reason at exactly 500 characters", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); const maxReason = "a".repeat(500); @@ -128,25 +128,25 @@ describe("SettlementService", () => { }); it("consumes liquidity on execute", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 400, + amount: 400n, }); service.execute(settlement.id); expect(service.get(settlement.id).status).toBe("executed"); // Executed liquidity does not return to the available pool. - expect(service.available("USDC")).toBe(600); + expect(service.available("USDC").toString()).toBe("600"); }); it("rejects executing a non-pending settlement", () => { - const { service } = harness(1000); + const { service } = harness(1000n); const settlement = service.open({ anchor: "anchorA", asset: "USDC", - amount: 100, + amount: 100n, }); service.execute(settlement.id); @@ -154,21 +154,21 @@ describe("SettlementService", () => { }); it("throws 404 for an unknown settlement", () => { - const { service } = harness(1000); + const { service } = harness(1000n); expect(() => service.get(999)).toThrow(ApiError); }); it("filters settlements by asset", () => { - const { service } = harness(1000); - service.open({ anchor: "anchorA", asset: "USDC", amount: 100 }); + const { service } = harness(1000n); + service.open({ anchor: "anchorA", asset: "USDC", amount: 100n }); expect(service.list({ asset: "USDC" })).toHaveLength(1); expect(service.list({ asset: "EURC" })).toHaveLength(0); }); it("combines anchor and asset filters", () => { - const { service } = harness(1000); - service.open({ anchor: "anchorA", asset: "USDC", amount: 100 }); + const { service } = harness(1000n); + service.open({ anchor: "anchorA", asset: "USDC", amount: 100n }); expect(service.list({ anchor: "anchorA", asset: "USDC" })).toHaveLength( 1, diff --git a/src/services/settlementService.ts b/src/services/settlementService.ts index 3b231ff..06a77a9 100644 --- a/src/services/settlementService.ts +++ b/src/services/settlementService.ts @@ -18,42 +18,46 @@ import { Settlement } from "../models/settlement"; import { ApiError } from "../errors/ApiError"; import { normalizeAsset, + requireBigInt, requirePositiveInteger, - requirePositiveNumber, requireString, requireStringMaxLength, } from "../utils/validation"; -const DEFAULT_FEE_BPS = 10; -const BPS_DIVISOR = 10_000; +const DEFAULT_FEE_BPS = 10n; +const BPS_DIVISOR = 10_000n; export class SettlementService { - private readonly reserved = new Map(); - private readonly consumed = new Map(); + private readonly reserved = new Map(); + private readonly consumed = new Map(); constructor( private readonly settlements: SettlementRepository, private readonly liquidity: LiquidityRepository, private readonly anchors: AnchorService, - private readonly feeBps: number = DEFAULT_FEE_BPS, - ) {} + feeBps: bigint | number = DEFAULT_FEE_BPS, + ) { + this.feeBps = BigInt(feeBps); + } + + private readonly feeBps: bigint; /** Liquidity available for new settlements in `asset`. */ - available(asset: string): number { + available(asset: string): bigint { const pool = this.liquidity.pools().find((p) => p.asset === asset); - const total = pool?.total ?? 0; - return total - (this.reserved.get(asset) ?? 0) - (this.consumed.get(asset) ?? 0); + const total = pool?.total ?? 0n; + return total - (this.reserved.get(asset) ?? 0n) - (this.consumed.get(asset) ?? 0n); } /** Returns the amount of liquidity reserved for pending settlements for a given asset. */ - public getReservedLiquidity(asset: string): number { - return this.reserved.get(asset) ?? 0; + public getReservedLiquidity(asset: string): bigint { + return this.reserved.get(asset) ?? 0n; } /** Opens a pending settlement, reserving liquidity from the pool. */ open(input: { anchor: unknown; asset: unknown; amount: unknown }): Settlement { const anchor = requireString(input.anchor, "anchor"); const asset = normalizeAsset(input.asset); - const amount = requirePositiveNumber(input.amount, "amount"); + const amount = requireBigInt(input.amount, "amount"); if (!this.anchors.isActive(anchor)) { throw ApiError.badRequest( @@ -69,8 +73,8 @@ export class SettlementService { ); } - this.reserved.set(asset, (this.reserved.get(asset) ?? 0) + amount); - const fee = Math.ceil((amount * this.feeBps) / BPS_DIVISOR); + this.reserved.set(asset, (this.reserved.get(asset) ?? 0n) + amount); + const fee = (amount * this.feeBps + (BPS_DIVISOR - 1n)) / BPS_DIVISOR; return this.settlements.create({ anchor, @@ -87,11 +91,11 @@ export class SettlementService { const settlement = this.requirePending(idInput); this.reserved.set( settlement.asset, - (this.reserved.get(settlement.asset) ?? 0) - settlement.amount, + (this.reserved.get(settlement.asset) ?? 0n) - settlement.amount, ); this.consumed.set( settlement.asset, - (this.consumed.get(settlement.asset) ?? 0) + settlement.amount, + (this.consumed.get(settlement.asset) ?? 0n) + settlement.amount, ); return this.settlements.save({ ...settlement, status: "executed" }); } @@ -110,7 +114,7 @@ export class SettlementService { this.reserved.set( settlement.asset, - (this.reserved.get(settlement.asset) ?? 0) - settlement.amount, + (this.reserved.get(settlement.asset) ?? 0n) - settlement.amount, ); return this.settlements.save({ ...settlement, diff --git a/src/utils/sorting.ts b/src/utils/sorting.ts index a2dea10..6e54979 100644 --- a/src/utils/sorting.ts +++ b/src/utils/sorting.ts @@ -75,6 +75,12 @@ export function applySort( let cmp: number; if (typeof av === "number" && typeof bv === "number") { cmp = av - bv; + } else if (typeof av === "bigint" && typeof bv === "bigint") { + cmp = av < bv ? -1 : (av > bv ? 1 : 0); + } else if (field === "amount" || field === "fee") { + const aBig = BigInt(av as any); + const bBig = BigInt(bv as any); + cmp = aBig < bBig ? -1 : (aBig > bBig ? 1 : 0); } else { cmp = String(av).localeCompare(String(bv)); } diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 42a5a7f..b9a56bc 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -13,6 +13,20 @@ export function requireString(value: unknown, field: string): string { return value.trim(); } +/** Parses a string (or numeric) value to a BigInt. */ +export function requireBigInt(value: unknown, field: string): bigint { + try { + const str = typeof value === "string" ? value : String(value); + const val = BigInt(str); + if (val <= 0n) { + throw new Error("non-positive"); + } + return val; + } catch { + throw ApiError.badRequest(`"${field}" must be a positive integer (string format)`); + } +} + /** Ensures `value` is a non-empty string up to a maximum length. */ export function requireStringMaxLength( value: unknown, From 0c2b367356235500e959055d5b64f248543975bc Mon Sep 17 00:00:00 2001 From: jahswillb-dev Date: Sat, 29 Aug 2026 15:11:34 +0100 Subject: [PATCH 3/7] feat(metrics): protect metrics reads and bound history [Issue #228] (#235) Aggregate metrics (participant counts, liquidity totals, settlement volume and fees over time) describe the network's operational state. Exposing that publicly should be deliberate, not a side effect of the write-only auth middleware, whose MUTATING_METHODS set left every GET unauthenticated and unlimited. - Auth: new metricsAuth guards GET /api/v1/metrics and /history. When API_KEY or the new read-only METRICS_API_KEY is set, reads require a matching x-api-key (401 otherwise); when neither is set they stay open, matching the existing write-auth model. METRICS_API_KEY unlocks metrics only, so a scraper never needs the write key. - Rate limiting: opt-in limitReads flag on rateLimiter (default off, so global behaviour is unchanged) enabled only on the metrics mount via METRICS_RATE_LIMIT_MAX (default 120/min), so the history endpoint is not an unlimited load generator. Global read limiting and the shared store remain owned by the separate rate-limiter issue. - Retention: history stays bounded at MAX_HISTORY = 50, now pinned by route-level tests (eviction of the oldest entry). - openapi.ts declares an ApiKeyAuth scheme and marks both metrics operations as protected; README/CHANGELOG document the scraper path. npm run lint, npm run build and npm test (43 suites, 509 tests) pass. Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 14 ++++ README.md | 33 +++++++- src/app.ts | 17 +++++ src/config.test.ts | 29 ++++++++ src/config.ts | 21 ++++++ src/middleware/metricsAuth.test.ts | 116 +++++++++++++++++++++++++++++ src/middleware/metricsAuth.ts | 56 ++++++++++++++ src/middleware/rateLimiter.ts | 14 +++- src/openapi.test.ts | 29 ++++++++ src/openapi.ts | 25 ++++++- src/routes/metrics.test.ts | 79 ++++++++++++++++++++ 11 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 src/middleware/metricsAuth.test.ts create mode 100644 src/middleware/metricsAuth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c7357c..4ea13e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to the AnchorNet API are documented here. [Unreleased] Added +Security: the metrics endpoints (GET /api/v1/metrics and +/api/v1/metrics/history) are now protected reads. When API_KEY or the new +METRICS_API_KEY is configured they require a matching x-api-key header +(401 otherwise); when neither is set they stay open, matching the existing +write-auth model. METRICS_API_KEY is a read-only credential that unlocks +metrics but not mutating routes, so a monitoring scraper needs no write +key. Metrics reads are now rate-limited per client (METRICS_RATE_LIMIT_MAX, +default 120/min) via a new opt-in limitReads flag on the rate limiter, so +the history endpoint cannot be used as an unlimited load generator. +Snapshot-history retention remains bounded to the most recent 50 entries, +now pinned by a route-level test. src/openapi.ts declares an ApiKeyAuth +security scheme and marks both metrics operations as protected. The +read-limiting is scoped to the metrics mount; global read limiting and a +shared multi-instance store remain owned by the separate rate-limiter issue. Metrics: GET /api/v1/metrics now reports totalSettledAmount (sum of settlement amount) and totalFeesCollected (sum of settlement fee), computed from executed settlements only — pending settlements have diff --git a/README.md b/README.md index 7708c42..3de07f8 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,33 @@ client-side. Each read also appends a timestamped snapshot to an in-memory rolling history (last 50 reads). GET /api/v1/metrics/history – the recorded metrics snapshots, oldest first ({ snapshots: [...] }); each snapshot carries the same fields as -GET /api/v1/metrics plus an ISO-8601 timestamp +GET /api/v1/metrics plus an ISO-8601 timestamp. Retention is bounded to the +most recent 50 snapshots (MAX_HISTORY in src/routes/metrics.ts); older ones +are evicted, so the response can never grow without limit. + +Metrics access (protected reads). Unlike the other read endpoints, the two +metrics endpoints expose aggregate operational intelligence — participant +counts, total liquidity, settlement volume and protocol fees earned, sampled +over time. That is useful to an operator and equally useful to someone +profiling the network before targeting it, so exposing it is treated as a +deliberate decision rather than a middleware side effect: + +- When neither API_KEY nor METRICS_API_KEY is set, metrics reads are open + (unchanged local/dev behaviour). +- When either key is set, GET /api/v1/metrics and GET /api/v1/metrics/history + require a matching x-api-key header and return 401 otherwise. +- A monitoring scraper should be given METRICS_API_KEY — a read-only + credential accepted for metrics but not for any mutating route — so + monitoring keeps working without handing the write key to the scraper. The + primary API_KEY is also accepted for metrics, so an operator already holding + it needs nothing extra. Example scrape: + `curl -H "x-api-key: $METRICS_API_KEY" http://localhost:3001/api/v1/metrics` +- Metrics reads (both endpoints) are rate-limited per client via + METRICS_RATE_LIMIT_MAX (default 120/min), so the history endpoint cannot be + used as a cheap load generator. This read-path limiting is scoped to the + metrics mount and owned by this change; extending rate limiting to all reads + and to a shared multi-instance store is tracked by the separate + rate-limiter issue. Errors use a uniform envelope: { "error": { "code", "message" } }, including malformed JSON (400) and oversized request bodies (413, PAYLOAD_TOO_LARGE). Every response carries an x-request-id header for @@ -252,7 +278,10 @@ The application is configured using environment variables. Every environment var Variable Default Valid Range / Format Description PORT 3001 Positive integer (typically 1 - 65535) HTTP port the server binds to. Non-numeric values fall back to default. FEE_BPS 10 Integer between 0 and 10000 (inclusive) Protocol fee in basis points applied to settlements and quotes. The process throws an error and fails to start if configured outside this range. -API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. +API_KEY (Unset) Any non-empty string If set, mutating requests (POST/PUT/PATCH/DELETE) must send an matching x-api-key header. Whitespace-only values are treated as unset. Also accepted for metrics reads. +METRICS_API_KEY (Unset) Any non-empty string Read-only credential for the metrics endpoints. If either this or API_KEY is set, GET /api/v1/metrics and /history require a matching x-api-key header. This key unlocks metrics only — it cannot authorize mutating requests — so a monitoring scraper can read metrics without the write key. Whitespace-only values are treated as unset. +METRICS_RATE_LIMIT_MAX 120 Positive integer Maximum metrics reads allowed per client within the metrics window. Covers reads (unlike the mutating-only global limiter) so the history endpoint is not an unlimited load generator. +METRICS_RATE_LIMIT_WINDOW_MS 60000 (1 min) Positive integer Length of the rolling window for the metrics read rate limit. CORS_ORIGIN (Unset) Comma-separated list of origin URLs Allowed CORS origins. Whitespace around entries is trimmed; empty entries are ignored. If unset, every origin is permitted. BODY_LIMIT 100kb Express bytes-compatible string (e.g., "500kb", "2mb") Maximum accepted JSON request body size. Default is applied if value is blank. MAINTENANCE_MODE false "1", "true" (case-insensitive) to enable When enabled, mutating requests are rejected with a 503 Service Unavailable error, while read requests continue to function normally. diff --git a/src/app.ts b/src/app.ts index ef61c44..8452e58 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import { errorHandler, notFoundHandler } from "./middleware/errorHandler"; import { requestLogger } from "./middleware/requestLogger"; import { requestId } from "./middleware/requestId"; import { apiKeyAuth } from "./middleware/apiKeyAuth"; +import { metricsAuth } from "./middleware/metricsAuth"; import { rateLimiter } from "./middleware/rateLimiter"; import { securityHeaders } from "./middleware/securityHeaders"; import { idempotency } from "./middleware/idempotency"; @@ -116,8 +117,24 @@ export function createApp(): Express { app.use("/api/v1/quote", quoteRouter(quotes)); app.use("/api/v1/anchors", anchorRouter(anchors, settlements)); app.use("/api/v1/settlements", settlementRouter(settlements, audit.entries)); + // Metrics expose aggregate operational data (participant counts, liquidity + // totals, settlement volume and fees over time). That is deliberately + // treated as protected rather than public: reads require authentication + // whenever a key is configured, and — unlike the global writes-only limiter + // — are rate-limited via `limitReads` so the unauthenticated-or-not history + // endpoint cannot be used as a cheap load generator. When no key is set the + // guard is a no-op, preserving open access for local/dev deployments. app.use( "/api/v1/metrics", + metricsAuth(config.apiKey, config.metricsApiKey), + rateLimiter( + { + max: config.metricsRateLimitMax, + windowMs: config.metricsRateLimitWindowMs, + limitReads: true, + }, + config.apiKey ?? config.metricsApiKey, + ), metricsRouter({ liquidity, anchors, diff --git a/src/config.test.ts b/src/config.test.ts index 1881b7b..035392e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -150,6 +150,35 @@ describe("loadConfig", () => { expect(config.rateLimitWindowMs).toBe(120000); }); + it("leaves the metrics API key unset by default", () => { + expect(loadConfig({}).metricsApiKey).toBeUndefined(); + }); + + it("reads a configured metrics API key", () => { + expect(loadConfig({ METRICS_API_KEY: "scraper" }).metricsApiKey).toBe( + "scraper", + ); + }); + + it("treats a blank metrics API key as unset", () => { + expect(loadConfig({ METRICS_API_KEY: " " }).metricsApiKey).toBeUndefined(); + }); + + it("defaults the metrics read rate limit", () => { + const config = loadConfig({}); + expect(config.metricsRateLimitMax).toBe(120); + expect(config.metricsRateLimitWindowMs).toBe(60_000); + }); + + it("reads the metrics read rate limit from the environment", () => { + const config = loadConfig({ + METRICS_RATE_LIMIT_MAX: "10", + METRICS_RATE_LIMIT_WINDOW_MS: "5000", + }); + expect(config.metricsRateLimitMax).toBe(10); + expect(config.metricsRateLimitWindowMs).toBe(5000); + }); + describe("TRUST_PROXY", () => { it('parses "true" to boolean true', () => { expect(loadConfig({ TRUST_PROXY: "true" }).trustProxy).toBe(true); diff --git a/src/config.ts b/src/config.ts index 0e34d77..237f989 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,14 @@ export interface Config { feeBps: number; /** Optional API key required for mutating requests (disabled if unset). */ apiKey?: string; + /** + * Optional read-only credential that grants access to the metrics endpoints + * (`GET /api/v1/metrics` and `/history`) without granting the write access + * carried by {@link apiKey}. Lets a monitoring scraper read operational + * metrics with a credential that cannot mutate the network. Whitespace-only + * values are treated as unset. + */ + metricsApiKey?: string; /** * Allowed CORS origins. `undefined` means no allowlist is configured and * every origin is permitted (the historical default behavior). @@ -30,6 +38,15 @@ export interface Config { rateLimitMax: number; /** Length of the rolling window, in milliseconds. */ rateLimitWindowMs: number; + /** + * Maximum metrics **reads** allowed per client within the metrics window. + * Unlike {@link rateLimitMax}, this budget covers the read-only metrics + * endpoints, which are otherwise unlimited. Defaults higher than the + * mutating limit so a polling scraper is not throttled. + */ + metricsRateLimitMax: number; + /** Length of the metrics read rate-limiting window, in milliseconds. */ + metricsRateLimitWindowMs: number; /** * Express `trust proxy` setting. When enabled behind a load balancer, * Express trusts the `X-Forwarded-For` header so `req.ip` reflects the @@ -121,6 +138,7 @@ export function loadConfig( env: Record = process.env, ): Config { const apiKey = env.API_KEY?.trim(); + const metricsApiKey = env.METRICS_API_KEY?.trim(); const feeBps = intFromEnv(env.FEE_BPS, 10); if (feeBps < MIN_FEE_BPS || feeBps > MAX_FEE_BPS) { @@ -133,6 +151,7 @@ export function loadConfig( port: intFromEnv(env.PORT, 3001), feeBps, apiKey: apiKey ? apiKey : undefined, + metricsApiKey: metricsApiKey ? metricsApiKey : undefined, corsOrigins: parseCorsOrigins(env.CORS_ORIGIN), bodyLimit: env.BODY_LIMIT?.trim() || DEFAULT_BODY_LIMIT, maintenanceMode: parseBooleanFlag(env.MAINTENANCE_MODE), @@ -143,6 +162,8 @@ export function loadConfig( idempotencyTtlMs: intFromEnv(env.IDEMPOTENCY_TTL_MS, 86_400_000), rateLimitMax: intFromEnv(env.RATE_LIMIT_MAX, 30), rateLimitWindowMs: intFromEnv(env.RATE_LIMIT_WINDOW_MS, 60_000), + metricsRateLimitMax: intFromEnv(env.METRICS_RATE_LIMIT_MAX, 120), + metricsRateLimitWindowMs: intFromEnv(env.METRICS_RATE_LIMIT_WINDOW_MS, 60_000), trustProxy: parseTrustProxy(env.TRUST_PROXY), }; } diff --git a/src/middleware/metricsAuth.test.ts b/src/middleware/metricsAuth.test.ts new file mode 100644 index 0000000..cf7ea32 --- /dev/null +++ b/src/middleware/metricsAuth.test.ts @@ -0,0 +1,116 @@ +import request from "supertest"; +import { createApp } from "../app"; + +/** + * Metrics reads are protected whenever a credential is configured. These tests + * exercise the three deployment shapes: open (no key), primary-key only, and a + * dedicated read-only metrics key alongside the write key. + */ +describe("metricsAuth", () => { + const originalApiKey = process.env.API_KEY; + const originalMetricsKey = process.env.METRICS_API_KEY; + + afterEach(() => { + if (originalApiKey === undefined) delete process.env.API_KEY; + else process.env.API_KEY = originalApiKey; + if (originalMetricsKey === undefined) delete process.env.METRICS_API_KEY; + else process.env.METRICS_API_KEY = originalMetricsKey; + }); + + describe("open access when no key is configured", () => { + beforeEach(() => { + delete process.env.API_KEY; + delete process.env.METRICS_API_KEY; + }); + + it("serves current metrics without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics"); + expect(res.status).toBe(200); + }); + + it("serves metrics history without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics/history"); + expect(res.status).toBe(200); + }); + }); + + describe("protected by the primary API key", () => { + beforeEach(() => { + process.env.API_KEY = "write-secret"; + delete process.env.METRICS_API_KEY; + }); + + it("rejects metrics reads without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics"); + expect(res.status).toBe(401); + expect(res.body.error.code).toBe("UNAUTHORIZED"); + }); + + it("rejects history reads without a key", async () => { + const res = await request(createApp()).get("/api/v1/metrics/history"); + expect(res.status).toBe(401); + }); + + it("rejects metrics reads with the wrong key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "nope"); + expect(res.status).toBe(401); + }); + + it("allows metrics reads with the primary key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + expect(res.body.anchors).toBe(0); + }); + + it("does not trigger a snapshot when a read is rejected", async () => { + const app = createApp(); + // Rejected read must not leak data via the history side effect. + await request(app).get("/api/v1/metrics"); + const res = await request(app) + .get("/api/v1/metrics/history") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + expect(res.body.snapshots).toEqual([]); + }); + }); + + describe("dedicated read-only metrics key", () => { + beforeEach(() => { + process.env.API_KEY = "write-secret"; + process.env.METRICS_API_KEY = "read-only-scraper"; + }); + + it("allows metrics reads with the read-only metrics key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "read-only-scraper"); + expect(res.status).toBe(200); + }); + + it("still allows metrics reads with the primary key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics") + .set("x-api-key", "write-secret"); + expect(res.status).toBe(200); + }); + + it("does not let the read-only metrics key authorize writes", async () => { + const res = await request(createApp()) + .post("/api/v1/anchors") + .set("x-api-key", "read-only-scraper") + .send({ id: "anchorA" }); + expect(res.status).toBe(401); + }); + + it("rejects an unknown key", async () => { + const res = await request(createApp()) + .get("/api/v1/metrics/history") + .set("x-api-key", "guessed"); + expect(res.status).toBe(401); + }); + }); +}); diff --git a/src/middleware/metricsAuth.ts b/src/middleware/metricsAuth.ts new file mode 100644 index 0000000..2fc507c --- /dev/null +++ b/src/middleware/metricsAuth.ts @@ -0,0 +1,56 @@ +/** + * Read authentication for the metrics endpoints. + * + * The aggregate metrics served by `GET /api/v1/metrics` and + * `GET /api/v1/metrics/history` — anchor and participant counts, total + * liquidity, settlement volume and protocol fees earned, sampled over time — + * describe the operational state of the network. That is business + * intelligence: valuable to an operator, and equally valuable to someone + * profiling the network before targeting it. Exposing it publicly should be a + * deliberate decision, not a side effect of the write-only `apiKeyAuth`. This + * middleware makes metrics reads authenticated by default. + * + * A request is authorized when it presents an `x-api-key` header matching + * **either**: + * - the primary {@link apiKey} (the same credential that authorizes writes), + * so an operator already holding it needs nothing new; or + * - a dedicated, read-only {@link metricsApiKey}, so a monitoring scraper can + * read metrics with a credential that cannot mutate the network. + * + * When neither key is configured the middleware is a no-op (open access), + * matching the "locked only once a key is set" model of `apiKeyAuth` and + * preserving the historical behaviour for local development and deliberately + * open deployments. + */ + +import { NextFunction, Request, Response } from "express"; +import { ApiError } from "../errors/ApiError"; + +/** + * Builds the metrics read-authentication middleware. + * + * @param apiKey Primary API key, if configured. Accepted for metrics + * reads so operators reuse a single credential. + * @param metricsApiKey Dedicated read-only metrics key, if configured. + */ +export function metricsAuth(apiKey?: string, metricsApiKey?: string) { + return (req: Request, _res: Response, next: NextFunction): void => { + // No credential configured anywhere: metrics remain openly readable. + if (!apiKey && !metricsApiKey) { + next(); + return; + } + + const presented = req.header("x-api-key"); + const matchesPrimary = apiKey !== undefined && presented === apiKey; + const matchesMetrics = + metricsApiKey !== undefined && presented === metricsApiKey; + + if (matchesPrimary || matchesMetrics) { + next(); + return; + } + + next(ApiError.unauthorized("missing or invalid API key")); + }; +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 6b25d14..64de864 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -41,6 +41,18 @@ export interface RateLimitOptions { * require the exclusion list to account for the mount prefix. */ skipPaths?: string[]; + /** + * When `true`, this limiter also counts read (non-mutating) requests toward + * the per-client budget. Defaults `false`, so the global limiter's + * writes-only behaviour is unchanged. + * + * This flag is enabled only for the metrics mount, whose read endpoints + * (notably `GET /history`) are otherwise unlimited. Extending read limiting + * to every route — and the shared, multi-instance store that would require — + * is deliberately left to the separate rate-limiter issue; this PR owns the + * flag and its use for metrics only. + */ + limitReads?: boolean; } export function rateLimiter( @@ -52,7 +64,7 @@ export function rateLimiter( const buckets = new Map(); return (req: Request, _res: Response, next: NextFunction): void => { - if (!MUTATING_METHODS.has(req.method)) { + if (!MUTATING_METHODS.has(req.method) && !options.limitReads) { next(); return; } diff --git a/src/openapi.test.ts b/src/openapi.test.ts index 936c76e..3236a3c 100644 --- a/src/openapi.test.ts +++ b/src/openapi.test.ts @@ -52,6 +52,35 @@ describe("openapi spec", () => { expect(history.description).toContain("totalFeesCollected"); }); + it("declares the x-api-key security scheme and marks metrics as protected", () => { + const spec = buildOpenApiSpec() as { + components?: { + securitySchemes?: Record< + string, + { type?: string; in?: string; name?: string } + >; + }; + paths: Record; + }; + + const scheme = spec.components?.securitySchemes?.ApiKeyAuth; + expect(scheme).toMatchObject({ + type: "apiKey", + in: "header", + name: "x-api-key", + }); + + expect(spec.paths["/api/v1/metrics"].get.security).toEqual([ + { ApiKeyAuth: [] }, + ]); + expect(spec.paths["/api/v1/metrics/history"].get.security).toEqual([ + { ApiKeyAuth: [] }, + ]); + expect(spec.paths["/api/v1/metrics/history"].get.description).toContain( + "50", + ); + }); + it("documents the dryRun preflight parameter on POST /api/v1/anchors/bulk", () => { const spec = buildOpenApiSpec() as { paths: Record< diff --git a/src/openapi.ts b/src/openapi.ts index 07ff96c..12537d9 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -17,6 +17,19 @@ export function buildOpenApiSpec(): Record { version: PKG_VERSION, description: "Liquidity coordination network for Stellar anchors. \n\n**[BREAKING CHANGE]** All monetary values (amounts, balances, portions, totals, fees) are now strictly represented in stroops and serialized as strings in JSON to prevent IEEE-754 precision loss.", }, + components: { + securitySchemes: { + // Sent as the `x-api-key` request header. The same scheme carries both + // the primary write key (`API_KEY`) and the dedicated read-only metrics + // key (`METRICS_API_KEY`); which credential is required depends on the + // operation. + ApiKeyAuth: { + type: "apiKey", + in: "header", + name: "x-api-key", + }, + }, + }, paths: { "/health": { get: { summary: "Health check" }, @@ -201,7 +214,12 @@ export function buildOpenApiSpec(): Record { "totalFeesCollected (sum of settlement fee). Both value totals are computed " + "from executed settlements only — pending settlements have merely reserved " + "liquidity and cancelled ones never moved value, so neither contributes. " + - "Each read also appends a timestamped snapshot to the rolling history.", + "Each read also appends a timestamped snapshot to the rolling history. " + + "Protected: when API_KEY or METRICS_API_KEY is configured, callers must " + + "send a matching x-api-key header (a read-only METRICS_API_KEY is accepted " + + "so a scraper never needs the write key); requests are also rate-limited. " + + "When no key is set the endpoint is open, matching the write-auth model.", + security: [{ ApiKeyAuth: [] }], }, }, "/api/v1/metrics/history": { @@ -210,7 +228,10 @@ export function buildOpenApiSpec(): Record { description: "Returns { snapshots: [...] }, where each snapshot carries the same fields as " + "GET /api/v1/metrics (including totalSettledAmount and totalFeesCollected) " + - "plus an ISO-8601 timestamp.", + "plus an ISO-8601 timestamp. Retention is bounded to the most recent 50 " + + "snapshots (older ones are evicted). Same authentication and rate limiting " + + "as GET /api/v1/metrics.", + security: [{ ApiKeyAuth: [] }], }, }, }, diff --git a/src/routes/metrics.test.ts b/src/routes/metrics.test.ts index 021f731..ec226d1 100644 --- a/src/routes/metrics.test.ts +++ b/src/routes/metrics.test.ts @@ -198,6 +198,85 @@ describe("metrics route", () => { }); }); +describe("metrics history retention", () => { + it("caps the retained snapshot history at 50 entries", async () => { + const app = createApp(); + await seed(app); + + // Each read of the current metrics appends one snapshot. Drive well past + // the MAX_HISTORY = 50 bound to prove the oldest entries are evicted + // rather than accumulating without limit. + for (let i = 0; i < 60; i += 1) { + await request(app).get("/api/v1/metrics"); + } + + const res = await request(app).get("/api/v1/metrics/history"); + expect(res.status).toBe(200); + expect(res.body.snapshots).toHaveLength(50); + }); + + it("retains the most recent snapshots, dropping the oldest", async () => { + jest.useFakeTimers(); + try { + const app = createApp(); + await seed(app); + + // Take 51 snapshots at distinct, strictly increasing timestamps so the + // very first one is the single entry that must be evicted at cap. + for (let i = 0; i < 51; i += 1) { + jest.setSystemTime(new Date(2026, 0, 1, 0, 0, i)); + await request(app).get("/api/v1/metrics"); + } + + const res = await request(app).get("/api/v1/metrics/history"); + expect(res.body.snapshots).toHaveLength(50); + // The oldest (second 0) is gone; the window now starts at second 1. + expect(res.body.snapshots[0].timestamp).toBe( + new Date(2026, 0, 1, 0, 0, 1).toISOString(), + ); + expect(res.body.snapshots[49].timestamp).toBe( + new Date(2026, 0, 1, 0, 0, 50).toISOString(), + ); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe("metrics read rate limiting", () => { + const original = process.env.METRICS_RATE_LIMIT_MAX; + + afterEach(() => { + if (original === undefined) delete process.env.METRICS_RATE_LIMIT_MAX; + else process.env.METRICS_RATE_LIMIT_MAX = original; + }); + + it("rejects metrics reads over the per-client budget with 429", async () => { + process.env.METRICS_RATE_LIMIT_MAX = "3"; + const app = createApp(); + + for (let i = 0; i < 3; i += 1) { + const ok = await request(app).get("/api/v1/metrics"); + expect(ok.status).toBe(200); + } + + const blocked = await request(app).get("/api/v1/metrics"); + expect(blocked.status).toBe(429); + expect(blocked.body.error.code).toBe("RATE_LIMITED"); + }); + + it("counts history reads against the same read budget", async () => { + process.env.METRICS_RATE_LIMIT_MAX = "2"; + const app = createApp(); + + expect((await request(app).get("/api/v1/metrics")).status).toBe(200); + expect((await request(app).get("/api/v1/metrics/history")).status).toBe(200); + + const blocked = await request(app).get("/api/v1/metrics/history"); + expect(blocked.status).toBe(429); + }); +}); + describe("metrics settled-value totals", () => { it("reports zero settled amount and fees on a fresh app", async () => { const res = await request(createApp()).get("/api/v1/metrics"); From 77f9df66609ad98fa1ce7a3b7df40f134185ec16 Mon Sep 17 00:00:00 2001 From: kaleel <128490484+nyuiela@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:11:37 +0000 Subject: [PATCH 4/7] fix(idempotency): share a bounded in-process replay store (#234) Close the per-middleware Map hole that let the same key execute twice across mounts, add a hard entry cap with soonest-expiry eviction, and coalesce concurrent same-key requests onto one in-flight handler. Replay semantics stay response-body based; headers are never cached. Cross-replica sharing waits on the separate persistence issue. --- CHANGELOG.md | 10 ++ README.md | 36 +++-- docs/ARCHITECTURE.md | 7 + src/middleware/idempotency.test.ts | 131 +++++++++++++-- src/middleware/idempotency.ts | 252 +++++++++++++++++++++++++---- 5 files changed, 379 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ea13e3..f37646a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Changelog All notable changes to the AnchorNet API are documented here. [Unreleased] +Fixed +Idempotency: the replay cache is no longer a private `Map` closed over by +each `idempotency()` call. All mounts share a process-wide +`MemoryIdempotencyStore` with a hard entry cap (default 1024; expired and +soonest-to-expire eviction), and concurrent same-key requests share one +in-flight execution so only a single handler runs. Replay still returns the +cached JSON body (not a conflict); only `status` + body are stored — no +response headers. Multi-replica sharing still waits on the separate +persistence-layer issue; this change closes the within-process holes without +adding an external dependency. Added Security: the metrics endpoints (GET /api/v1/metrics and /api/v1/metrics/history) are now protected reads. When API_KEY or the new diff --git a/README.md b/README.md index 3de07f8..dbee260 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,20 @@ message `rate limit exceeded, try again later`. Clients should treat this as a retryable response and back off before sending the next mutating request. Mutating requests may also send an Idempotency-Key header. The first request -for a given key/method/path combination runs normally and its response is cached; any -later request reusing the same key (within 24h) replays the original response -instead of re-running the handler, so retried requests don't double-apply -side effects (e.g. registering the same anchor twice). State is in-memory and -per-process. +for a given key/method/path combination runs normally and its JSON response is +cached; any later request reusing the same key (within the configured TTL) +replays the original response instead of re-running the handler, so retried +requests don't double-apply side effects (e.g. registering the same anchor +twice). Reusing a key with a different body returns `422 IDEMPOTENCY_KEY_REUSE`. + +Cache state is a process-wide in-memory store shared by every `idempotency()` +mount (hard-capped; default 1024 entries, soonest-expiry eviction). Concurrent +same-key requests share one in-flight execution. Multi-replica deployments still +need an external shared store once the persistence layer lands — this is the +same sequencing constraint as the rate limiter. + +Only status + JSON body are stored for replay; response headers are never +cached. Walkthrough Example To verify how the idempotency system behaves, you can perform the following walkthrough using curl. @@ -239,7 +248,9 @@ x-request-id: 4a123f52-1623-429b-ba67-3d0d0d5c2eb0 "registeredAt": "2026-07-22T14:17:57.537Z", "active": true } -Mismatched body (Known Gap): If you reuse the same Idempotency-Key but change the request payload (e.g., modifying the name field), the server will still return the cached 201 response corresponding to the first payload. Detecting mismatched request bodies (which would ideally return a 422 error) is currently a known gap in this system. +Mismatched body: If you reuse the same Idempotency-Key but change the request +payload, the server returns `422` with code `IDEMPOTENCY_KEY_REUSE` instead of +replaying the original response. Bash @@ -247,19 +258,18 @@ curl -i -X POST http://localhost:3001/api/v1/anchors -H "Content-Type: application/json" -H "Idempotency-Key: register-anchor-xyz" -d '{"id": "anchor-xyz", "name": "Anchor XYZ Modified Name"}' -Response (Replayed from the original cached version): +Response: http -HTTP/1.1 201 Created +HTTP/1.1 422 Unprocessable Entity Content-Type: application/json; charset=utf-8 -x-request-id: 184c8357-3fc3-4e2f-a87c-19042ab804fe { -"id": "anchor-xyz", -"name": "Anchor XYZ", -"registeredAt": "2026-07-22T14:17:57.537Z", -"active": true +"error": { +"code": "IDEMPOTENCY_KEY_REUSE", +"message": "Idempotency key already used with a different request body" +} } The process shuts down gracefully on SIGTERM/SIGINT: it stops accepting new connections, closes the HTTP server, marks /health/ready unready, and diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ebac00..01569d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,6 +21,13 @@ Settlement, anchor, and liquidity data are held in process-local in-memory repositories (src/repositories/*), all extending the shared InMemoryRepository base class. +Idempotency cache (src/middleware/idempotency.ts) follows the same sequencing: +a process-wide `MemoryIdempotencyStore` (shared across mounts, hard-capped, +in-flight coalescing) closes within-process holes without introducing Redis/DB. +Cross-replica idempotency is intentionally deferred to the persistence-layer +issue — once that store exists, swap the default `IdempotencyStore` +implementation rather than bolting on a second persistence stack here. + Persistence-Swap Risk (read before swapping any repository for a DB) Several repositories already document that they are "swappable for a persistent … store later" (e.g. liquidityRepository.ts). This is a forward diff --git a/src/middleware/idempotency.test.ts b/src/middleware/idempotency.test.ts index ffad474..e5cc35f 100644 --- a/src/middleware/idempotency.test.ts +++ b/src/middleware/idempotency.test.ts @@ -1,30 +1,50 @@ import express, { Express } from "express"; import request from "supertest"; -import { idempotency, IdempotencyOptions } from "./idempotency"; +import { + idempotency, + IdempotencyOptions, + MemoryIdempotencyStore, + resetDefaultIdempotencyStore, +} from "./idempotency"; import { errorHandler } from "./errorHandler"; import { ApiError } from "../errors/ApiError"; -function makeApp(options?: IdempotencyOptions): Express { - let counter = 0; +function makeApp( + options?: IdempotencyOptions, + counterRef?: { value: number }, +): Express { + const counter = counterRef ?? { value: 0 }; + // Isolate unit tests from the process-wide default store unless a shared + // store is explicitly injected (cross-instance coverage). + const store = options?.store ?? new MemoryIdempotencyStore(options?.maxEntries); const app = express(); app.use(express.json()); - app.use(idempotency(options)); + app.use(idempotency({ ...options, store })); app.post("/mutate", (_req, res) => { - counter += 1; - res.status(201).json({ counter }); + counter.value += 1; + res.status(201).json({ counter: counter.value }); }); app.post("/fail", (_req, _res, next) => { - counter += 1; + counter.value += 1; next(ApiError.conflict("already exists")); }); app.get("/read", (_req, res) => { - counter += 1; - res.json({ counter }); + counter.value += 1; + res.json({ counter: counter.value }); + }); + app.post("/slow", async (_req, res) => { + counter.value += 1; + await new Promise((resolve) => setTimeout(resolve, 50)); + res.status(201).json({ counter: counter.value }); }); app.use(errorHandler); return app; } +beforeEach(() => { + resetDefaultIdempotencyStore(); +}); + describe("idempotency", () => { it("replays the cached response for a repeated key", async () => { const app = makeApp(); @@ -198,4 +218,97 @@ describe("idempotency", () => { expect(second.status).toBe(201); expect(second.body.counter).toBe(1); }); + + it("shares cache across two middleware instances with the same store", async () => { + const store = new MemoryIdempotencyStore(); + const counter = { value: 0 }; + // Two separate middleware instances (two apps) sharing one store — the + // pre-fix design closed over a private Map per call, so appB would + // re-execute after appA had already handled the key. + const appA = makeApp({ store }, counter); + const appB = makeApp({ store }, counter); + + const first = await request(appA) + .post("/mutate") + .set("Idempotency-Key", "shared-key"); + const second = await request(appB) + .post("/mutate") + .set("Idempotency-Key", "shared-key"); + + expect(first.body.counter).toBe(1); + expect(second.body.counter).toBe(1); + expect(counter.value).toBe(1); + }); + + it("bounds the cache under many distinct keys", async () => { + const maxEntries = 8; + const store = new MemoryIdempotencyStore(maxEntries); + const app = makeApp({ store }); + + for (let i = 0; i < maxEntries * 3; i += 1) { + const res = await request(app) + .post("/mutate") + .set("Idempotency-Key", `key-${i}`); + expect(res.status).toBe(201); + } + + expect(store.size()).toBeLessThanOrEqual(maxEntries); + }); + + it("runs only one handler for concurrent same-key requests", async () => { + const counter = { value: 0 }; + const app = makeApp({}, counter); + + const [first, second] = await Promise.all([ + request(app).post("/slow").set("Idempotency-Key", "concurrent"), + request(app).post("/slow").set("Idempotency-Key", "concurrent"), + ]); + + expect(first.status).toBe(201); + expect(second.status).toBe(201); + expect(first.body.counter).toBe(1); + expect(second.body.counter).toBe(1); + expect(counter.value).toBe(1); + }); + + it("leaves keyless mutating requests unaffected", async () => { + const counter = { value: 0 }; + const app = makeApp({}, counter); + + const first = await request(app).post("/mutate"); + const second = await request(app).post("/mutate"); + + expect(first.body.counter).toBe(1); + expect(second.body.counter).toBe(2); + expect(counter.value).toBe(2); + }); + + it("does not store response headers on the cached entry", async () => { + const store = new MemoryIdempotencyStore(); + const app = express(); + app.use(express.json()); + app.use(idempotency({ store })); + app.post("/mutate", (_req, res) => { + res.setHeader("X-Sensitive", "secret-token"); + res.setHeader("Set-Cookie", "session=abc"); + res.status(201).json({ ok: true }); + }); + app.use(errorHandler); + + await request(app).post("/mutate").set("Idempotency-Key", "hdr"); + const entry = store.get("POST /mutate hdr"); + expect(entry).toBeDefined(); + expect(entry).toEqual({ + status: 201, + body: { ok: true }, + expiresAt: expect.any(Number), + bodyHash: expect.any(String), + }); + expect(Object.keys(entry!).sort()).toEqual([ + "body", + "bodyHash", + "expiresAt", + "status", + ]); + }); }); diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts index 2054085..924215f 100644 --- a/src/middleware/idempotency.ts +++ b/src/middleware/idempotency.ts @@ -7,9 +7,17 @@ * same key within the TTL replays the cached response instead of re-running * the handler, so retrying a request that already took effect (or already * failed) doesn't double-apply side effects. Requests without the header are - * unaffected. State lives in a plain `Map` local to the returned middleware, - * so like the existing rate limiter this is a per-process safeguard and not - * suitable for multi-instance deployments without a shared store. + * unaffected. + * + * Cache state lives in a process-wide {@link IdempotencyStore} shared by every + * `idempotency()` mount that does not inject its own `store` option. That + * closes the cross-mount duplicate hole inside one process. True multi-replica + * deployments still need an external shared store — this service has no + * persistence layer yet, so we keep an in-process store with a hard entry cap + * and leave Redis/DB to the separate persistence issue. + * + * Concurrent same-key requests share a single in-flight promise so only one + * handler runs; waiters replay (or 422 on body mismatch) when it completes. * * A SHA-256 fingerprint of the canonical JSON request body is stored alongside * the cached response. On key reuse, if the incoming body hash differs, a 422 @@ -17,6 +25,9 @@ * response. Canonical serialization uses stable key ordering so that * semantically identical bodies with different key orderings are treated as * the same request. + * + * Only `status` + JSON body are stored — response headers are never cached + * (so no `Set-Cookie` / auth leakage via the replay path). */ import crypto from "crypto"; @@ -28,6 +39,9 @@ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** Default time a cached response remains eligible for replay. */ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; +/** Default hard cap on cached entries (expired entries are purged first). */ +export const DEFAULT_MAX_ENTRIES = 1024; + /** * Produce a deterministic JSON string for any value. Object keys are sorted * recursively so that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` serialize to the @@ -61,21 +75,161 @@ function hashBody(body: unknown): string { return crypto.createHash("sha256").update(raw).digest("hex"); } -interface CachedResponse { +export interface CachedResponse { status: number; body: unknown; expiresAt: number; bodyHash: string; } +export type InflightClaim = + | { status: "hit"; entry: CachedResponse } + | { status: "wait"; promise: Promise } + | { + status: "run"; + complete: (entry: CachedResponse) => void; + fail: (err: unknown) => void; + }; + +export interface IdempotencyStore { + get(key: string, now?: number): CachedResponse | undefined; + set(key: string, entry: CachedResponse): void; + /** Number of live (non-expired) entries — used by tests. */ + size(now?: number): number; + /** + * Atomically: return a cache hit, join an in-flight execution, or claim the + * right to run the handler for this key. + */ + begin(key: string, now?: number): InflightClaim; + /** Drop all entries and in-flight waiters (tests). */ + clear(): void; +} + +/** + * Bounded in-memory store. Evicts expired entries on write; if still over + * `maxEntries`, drops the soonest-to-expire live entries until under the cap. + */ +export class MemoryIdempotencyStore implements IdempotencyStore { + private readonly entries = new Map(); + private readonly inflight = new Map>(); + readonly maxEntries: number; + + constructor(maxEntries: number = DEFAULT_MAX_ENTRIES) { + if (!Number.isInteger(maxEntries) || maxEntries < 1) { + throw new Error("maxEntries must be a positive integer"); + } + this.maxEntries = maxEntries; + } + + get(key: string, now: number = Date.now()): CachedResponse | undefined { + const cached = this.entries.get(key); + if (!cached) return undefined; + if (cached.expiresAt <= now) { + this.entries.delete(key); + return undefined; + } + return cached; + } + + set(key: string, entry: CachedResponse): void { + const now = Date.now(); + this.purgeExpired(now); + this.entries.set(key, entry); + this.enforceBound(); + } + + size(now: number = Date.now()): number { + this.purgeExpired(now); + return this.entries.size; + } + + begin(key: string, now: number = Date.now()): InflightClaim { + const cached = this.get(key, now); + if (cached) return { status: "hit", entry: cached }; + + const pending = this.inflight.get(key); + if (pending) return { status: "wait", promise: pending }; + + let resolveEntry!: (entry: CachedResponse) => void; + let rejectEntry!: (err: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolveEntry = resolve; + rejectEntry = reject; + }); + promise.catch(() => undefined); + this.inflight.set(key, promise); + + return { + status: "run", + complete: (entry: CachedResponse) => { + this.inflight.delete(key); + this.set(key, entry); + resolveEntry(entry); + }, + fail: (err: unknown) => { + this.inflight.delete(key); + rejectEntry(err); + }, + }; + } + + clear(): void { + this.entries.clear(); + this.inflight.clear(); + } + + private purgeExpired(now: number): void { + for (const [k, v] of this.entries) { + if (v.expiresAt <= now) this.entries.delete(k); + } + } + + private enforceBound(): void { + if (this.entries.size <= this.maxEntries) return; + const ranked = [...this.entries.entries()].sort( + (a, b) => a[1].expiresAt - b[1].expiresAt, + ); + let overflow = this.entries.size - this.maxEntries; + for (const [k] of ranked) { + if (overflow <= 0) break; + this.entries.delete(k); + overflow -= 1; + } + } +} + +/** Process-wide default so every `idempotency()` mount shares one cache. */ +const defaultStore = new MemoryIdempotencyStore(); + export interface IdempotencyOptions { /** Milliseconds a cached response remains eligible for replay. */ ttlMs?: number; + /** + * Hard cap on cached entries when using the process-wide default store. + * Ignored when a custom `store` is passed — configure that store instead. + * Changing this after the default store was constructed has no effect; + * prefer injecting `new MemoryIdempotencyStore(n)` for a private cap. + */ + maxEntries?: number; + /** + * Override the process-wide store (tests / multi-mount sharing). When + * omitted, all middleware instances share the same default store. + */ + store?: IdempotencyStore; +} + +function resolveStore(options: IdempotencyOptions): IdempotencyStore { + if (options.store) return options.store; + return defaultStore; +} + +function replay(res: Response, cached: CachedResponse): void { + res.status(cached.status).json(cached.body); } export function idempotency(options: IdempotencyOptions = {}) { const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; - const cache = new Map(); + const store = resolveStore(options); return (req: Request, res: Response, next: NextFunction): void => { const key = req.header("idempotency-key"); @@ -85,36 +239,64 @@ export function idempotency(options: IdempotencyOptions = {}) { } const cacheKey = `${req.method} ${req.originalUrl} ${key}`; - const now = Date.now(); - const cached = cache.get(cacheKey); - if (cached && cached.expiresAt > now) { - // NOTE: concurrent requests with the same key may both pass this check - // before either writes to the cache (check-then-set race). This is a - // pre-existing limitation of the in-memory Map design, not introduced - // by this change. See README for per-process scope. - if (cached.bodyHash !== hashBody(req.body)) { - next( - ApiError.idempotencyKeyReuse( - "Idempotency key already used with a different request body", - ), - ); - return; - } - res.status(cached.status).json(cached.body); - return; - } + const bodyHash = hashBody(req.body); + + void (async () => { + try { + const claim = store.begin(cacheKey); + + if (claim.status === "hit" || claim.status === "wait") { + try { + const finished = + claim.status === "hit" ? claim.entry : await claim.promise; + if (finished.bodyHash !== bodyHash) { + next( + ApiError.idempotencyKeyReuse( + "Idempotency key already used with a different request body", + ), + ); + return; + } + replay(res, finished); + } catch (err) { + next(err); + } + return; + } - const originalJson = res.json.bind(res); - res.json = ((body: unknown) => { - cache.set(cacheKey, { - status: res.statusCode, - body, - expiresAt: now + ttlMs, - bodyHash: hashBody(req.body), - }); - return originalJson(body); - }) as Response["json"]; - - next(); + const { complete, fail } = claim; + const originalJson = res.json.bind(res); + let settled = false; + + res.json = ((body: unknown) => { + if (!settled) { + settled = true; + complete({ + status: res.statusCode, + body, + expiresAt: Date.now() + ttlMs, + bodyHash, + }); + } + return originalJson(body); + }) as Response["json"]; + + res.on("close", () => { + if (!settled && !res.writableEnded) { + settled = true; + fail(new Error("client closed before response")); + } + }); + + next(); + } catch (err) { + next(err); + } + })(); }; } + +/** Exposed for tests that need to wipe process-wide state between cases. */ +export function resetDefaultIdempotencyStore(): void { + defaultStore.clear(); +} From 22387b42abb27e946404c9539c11b29ff21c558c Mon Sep 17 00:00:00 2001 From: martinshub-tech Date: Sat, 29 Aug 2026 15:11:40 +0100 Subject: [PATCH 5/7] fix: make audit log an explicit operator convenience buffer (#233) --- src/app.ts | 2 +- src/middleware/auditLog.test.ts | 3 ++- src/middleware/auditLog.ts | 7 +++++-- src/openapi.ts | 5 +++-- src/utils/history.ts | 2 ++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/app.ts b/src/app.ts index 8452e58..01da1a4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -96,7 +96,7 @@ export function createApp(): Express { }); app.get("/api/v1/audit", (_req: Request, res: Response) => { - res.json({ entries: audit.entries() }); + res.json({ entries: audit.entries(), evictedCount: audit.evictedCount() }); }); app.use("/api/v1/liquidity", liquidityRouter(liquidity)); diff --git a/src/middleware/auditLog.test.ts b/src/middleware/auditLog.test.ts index 0c564a9..8dd09b4 100644 --- a/src/middleware/auditLog.test.ts +++ b/src/middleware/auditLog.test.ts @@ -39,7 +39,7 @@ describe("createAuditLog", () => { expect(audit.entries()).toHaveLength(0); }); - it("evicts the oldest entry once over the configured limit", async () => { + it("silently evicts the oldest entry once over the configured limit but exposes the evicted count", async () => { const audit = createAuditLog(2); const app = makeApp(audit); @@ -48,6 +48,7 @@ describe("createAuditLog", () => { await request(app).post("/mutate"); expect(audit.entries()).toHaveLength(2); + expect(audit.evictedCount()).toBe(1); }); }); diff --git a/src/middleware/auditLog.ts b/src/middleware/auditLog.ts index 5fc34c9..89592b2 100644 --- a/src/middleware/auditLog.ts +++ b/src/middleware/auditLog.ts @@ -1,10 +1,11 @@ /** - * In-memory audit log of mutating requests. + * In-memory convenience buffer of recent mutating requests. * * Records method/path/status/request-id/timestamp for every * POST/PUT/PATCH/DELETE request once its response finishes, in a bounded * rolling buffer, so operators can see recent write activity without an - * external logging pipeline. + * external logging pipeline. This is an operator convenience buffer, not + * a durable audit trail. Oldest entries are evicted once the limit is reached. */ import { NextFunction, Request, Response } from "express"; @@ -74,6 +75,7 @@ export function createAuditLog( ): { middleware: (req: Request, res: Response, next: NextFunction) => void; entries: () => AuditEntry[]; + evictedCount: () => number; } { const history = new BoundedHistory(limit); @@ -106,6 +108,7 @@ export function createAuditLog( next(); }, entries: () => history.all(), + evictedCount: () => history.evictedCount, }; } diff --git a/src/openapi.ts b/src/openapi.ts index 12537d9..5fd754c 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -47,8 +47,9 @@ export function buildOpenApiSpec(): Record { }, "/api/v1/audit": { get: { - summary: - "Recent mutating requests (method, path, status, request id, timestamp)", + summary: "Recent mutating requests (operator convenience buffer)", + description: + "Returns a bounded rolling buffer of recent mutating requests (method, path, status, request id, timestamp). This is an operator convenience buffer, not a durable audit trail. Once full, the oldest entries are evicted. The evictedCount indicates how many entries have been dropped since startup.", }, }, "/api/v1/liquidity": { diff --git a/src/utils/history.ts b/src/utils/history.ts index 76f6d95..3c4f3f2 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -7,6 +7,7 @@ */ export class BoundedHistory { private readonly items: T[] = []; + public evictedCount: number = 0; constructor(private readonly limit: number) { if (!Number.isInteger(limit) || limit <= 0) { @@ -21,6 +22,7 @@ export class BoundedHistory { this.items.push(item); if (this.items.length > this.limit) { this.items.shift(); + this.evictedCount++; } } From 9e18fd2b26f8de6f50d592a652e6169cb9f2ac18 Mon Sep 17 00:00:00 2001 From: Oyakhilome Gift A Date: Sat, 29 Aug 2026 15:11:43 +0100 Subject: [PATCH 6/7] feat: implement memory-bounded bucket eviction for rate limiter and add operational documentation (#232) --- README.md | 5 ++++ src/middleware/rateLimiter.test.ts | 46 ++++++++++++++++++++++++++++++ src/middleware/rateLimiter.ts | 14 +++++++++ 3 files changed, 65 insertions(+) diff --git a/README.md b/README.md index dbee260..6a8825c 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,11 @@ from `src/middleware/rateLimiter.ts` unless overridden by configuration. When `API_KEY` authentication is configured, the presented key identifies the client; open deployments continue to use the client IP. +> **Operational Note (Multi-Instance Deployments):** +> Rate limiter state lives in a plain `Map` local to the middleware instance. In multi-instance deployments without sticky sessions, clients may receive N× their intended budget (where N is the number of replicas), and limits reset entirely upon instance restart. +> +> A shared distributed store (like Redis) is deliberately deferred until a broader persistence layer is introduced to the service, to avoid bloating operational requirements prematurely. However, memory growth is strictly bounded: the internal `Map` is capped at 5000 entries. When capacity is reached, it lazily prunes expired buckets before evicting the oldest entry to protect against memory-pressure attacks. + `POST /api/v1/quote` is excluded from the global limiter via `skipPaths` and then receives its own stricter `rateLimiter({ max: 10, windowMs: 60_000 })` instance in `src/app.ts`. That quote limiter has separate in-memory counters diff --git a/src/middleware/rateLimiter.test.ts b/src/middleware/rateLimiter.test.ts index 7199f68..f969d3d 100644 --- a/src/middleware/rateLimiter.test.ts +++ b/src/middleware/rateLimiter.test.ts @@ -182,4 +182,50 @@ describe("rateLimiter", () => { const blocked = await request(app).post("/api/v1/quote-history"); expect(blocked.status).toBe(429); }); + + it("allows bypass across multiple middleware instances", async () => { + const app = express(); + app.set("trust proxy", true); + + const limiter1 = rateLimiter({ max: 1, windowMs: 1000 }); + app.post("/route1", limiter1, (_req, res) => res.status(201).json({ ok: true })); + + const limiter2 = rateLimiter({ max: 1, windowMs: 1000 }); + app.post("/route2", limiter2, (_req, res) => res.status(201).json({ ok: true })); + + app.use(errorHandler); + + // Client hits route1, consumes quota + await request(app).post("/route1").set("x-forwarded-for", "10.0.0.1").expect(201); + await request(app).post("/route1").set("x-forwarded-for", "10.0.0.1").expect(429); + + // Same client hits route2, gets full quota again + await request(app).post("/route2").set("x-forwarded-for", "10.0.0.1").expect(201); + }); + + it("bounds memory growth by evicting the oldest bucket when capacity is reached", () => { + const limiter = rateLimiter({ max: 1, windowMs: 60000 }); + const next = jest.fn(); + const res = {} as Response; + + const req0 = { method: "POST", ip: "client-0", path: "/mutate" } as unknown as Request; + limiter(req0, res, next); + + limiter(req0, res, next); + expect(next).toHaveBeenLastCalledWith(expect.objectContaining({ status: 429 })); + + // Fill the map up to 5000 (MAX_BUCKETS) + for (let i = 1; i <= 5000; i++) { + const req = { method: "POST", ip: `client-${i}`, path: "/mutate" } as unknown as Request; + limiter(req, res, next); + } + + // Because Client 0 was inserted first, inserting client-5000 triggered eviction of client-0. + // Client 0 should now be granted a new quota. + next.mockClear(); + limiter(req0, res, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next).not.toHaveBeenCalledWith(expect.objectContaining({ status: 429 })); + }); }); + diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index 64de864..1985696 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -20,6 +20,9 @@ const DEFAULT_MAX = 30; /** Default rolling window length, in milliseconds. */ const DEFAULT_WINDOW_MS = 60_000; +/** Maximum number of buckets tracked in memory to prevent unbounded growth. */ +const MAX_BUCKETS = 5000; + interface Bucket { count: number; resetAt: number; @@ -89,6 +92,17 @@ export function rateLimiter( const bucket = buckets.get(key); if (!bucket || bucket.resetAt <= now) { + if (!bucket && buckets.size >= MAX_BUCKETS) { + for (const [k, v] of buckets.entries()) { + if (v.resetAt <= now) buckets.delete(k); + } + if (buckets.size >= MAX_BUCKETS) { + const oldestKey = buckets.keys().next().value; + if (oldestKey !== undefined) { + buckets.delete(oldestKey); + } + } + } buckets.set(key, { count: 1, resetAt: now + windowMs }); next(); return; From 5b34abaf8301efb5c7c1a92a1e61794ee73b460b Mon Sep 17 00:00:00 2001 From: Paranoa-dev Date: Sat, 29 Aug 2026 15:45:05 +0100 Subject: [PATCH 7/7] Add fail-fast config validation and standalone typecheck (#230) (#237) - validateConfig() enforces required values before the server binds: API_KEY required in production, PORT valid 1-65535, non-negative rate-limit/idempotency values; warns loudly (non-prod) on open access. - Wire validateConfig into createApp/getConfig in app.ts. - Add typecheck script (tsc --noEmit) and a distinct CI Typecheck step. - Extend config.test.ts with a validateConfig suite. closes #230 Co-authored-by: ChainBid Developer --- .github/workflows/ci.yml | 3 ++ package.json | 3 +- src/app.ts | 6 ++-- src/config.test.ts | 51 +++++++++++++++++++++++++++- src/config.ts | 73 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80aa3c5..fc1b4e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Lint run: npm run lint + - name: Typecheck + run: npm run typecheck + - name: Build run: npm run build diff --git a/package.json b/package.json index 5b1bffa..7c92fe1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "node dist/index.js", "dev": "ts-node-dev --respawn src/index.ts", "test": "jest", - "lint": "eslint \"src/**/*.ts\"" + "lint": "eslint \"src/**/*.ts\"", + "typecheck": "tsc --noEmit" }, "engines": { "node": ">=18" diff --git a/src/app.ts b/src/app.ts index 01da1a4..2053720 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,13 +31,13 @@ import { securityHeaders } from "./middleware/securityHeaders"; import { idempotency } from "./middleware/idempotency"; import { maintenanceMode } from "./middleware/maintenanceMode"; import { createAuditLog } from "./middleware/auditLog"; -import { loadConfig, Config } from "./config"; +import { loadConfig, validateConfig, Config } from "./config"; import { buildOpenApiSpec } from "./openapi"; import { isReady } from "./utils/readiness"; export function createApp(): Express { const app = express(); - const config = loadConfig(); + const config = validateConfig(loadConfig()); app.set('trust proxy', 1); // Ensure req.ip reflects real client IP behind reverse proxy (#120) app.set("trust proxy", config.trustProxy); @@ -153,5 +153,5 @@ export function createApp(): Express { * Expose the validated configuration for external consumers. */ export function getConfig(): Config { - return loadConfig(); + return validateConfig(loadConfig()); } diff --git a/src/config.test.ts b/src/config.test.ts index 035392e..621a126 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1,4 +1,4 @@ -import { loadConfig } from "./config"; +import { loadConfig, validateConfig, ConfigValidationError } from "./config"; describe("loadConfig", () => { it("applies defaults when env is empty", () => { @@ -211,3 +211,52 @@ describe("loadConfig", () => { }); }); }); + +describe("validateConfig", () => { + it("returns the config unchanged when valid", () => { + const config = loadConfig({ API_KEY: "secret", PORT: "3001" }); + expect(validateConfig(config)).toBe(config); + }); + + it("requires API_KEY in production and fails fast", () => { + const config = loadConfig({ NODE_ENV: "production" }); + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + expect(() => validateConfig(config)).toThrow(/API_KEY is required/); + }); + + it("allows a production deploy that sets API_KEY", () => { + const config = loadConfig({ NODE_ENV: "production", API_KEY: "secret" }); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("does NOT require API_KEY in development (open access is allowed, not fatal)", () => { + const config = loadConfig({ NODE_ENV: "development" }); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("does NOT require API_KEY in test", () => { + const config = loadConfig({ NODE_ENV: "test" }); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it("fails fast on an out-of-range PORT", () => { + const config = loadConfig({ PORT: "0" }); + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + expect(() => validateConfig(config)).toThrow(/PORT must be/); + }); + + it("fails fast on a non-integer PORT", () => { + const config = loadConfig({ PORT: "3001.5" }); + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + }); + + it("fails fast on a negative RATE_LIMIT_MAX", () => { + const config = loadConfig({ RATE_LIMIT_MAX: "-1" }); + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + }); + + it("fails fast on a negative IDEMPOTENCY_TTL_MS", () => { + const config = loadConfig({ IDEMPOTENCY_TTL_MS: "-1" }); + expect(() => validateConfig(config)).toThrow(ConfigValidationError); + }); +}); diff --git a/src/config.ts b/src/config.ts index 237f989..a275073 100644 --- a/src/config.ts +++ b/src/config.ts @@ -167,3 +167,76 @@ export function loadConfig( trustProxy: parseTrustProxy(env.TRUST_PROXY), }; } + +/** + * Startup configuration contract. + * + * `loadConfig` applies safe defaults so the service can boot in development; + * `validateConfig` enforces the *required* values and fails fast (throws) before + * the server binds a port. Required vs optional is intentionally conservative: + * only values whose absence changes a security posture or makes the server + * undeliverable are required. + * + * Decisions (see PR for full inventory): + * - `API_KEY` is REQUIRED when `NODE_ENV=production`. Without it the API-key + * middleware degrades to a no-op (open mutating access). We refuse to start + * in that insecure state instead of silently downgrading security. In every + * other environment an unset key is allowed but logged loudly as open access. + * The auth *policy* itself stays owned by `apiKeyAuth`; this only owns the + * configuration contract. + * - `PORT` must be a valid TCP port (1–65535); an invalid port makes the server + * undeliverable, so it is required to be valid. + * - `RATE_LIMIT_MAX` and `IDEMPOTENCY_TTL_MS` must be non-negative. + * All other values have safe defaults and never fail startup on their own. + * + * Validation is hand-written on purpose: it avoids adding a runtime schema + * dependency (keeping the project's lean 3-dependency footprint) and keeps the + * contract easy to review. + */ +export class ConfigValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigValidationError"; + } +} + +export function validateConfig(config: Config): Config { + if (config.env === "production" && !config.apiKey) { + throw new ConfigValidationError( + "API_KEY is required when NODE_ENV=production. Refusing to start with open (unauthenticated) mutating access. Set API_KEY to enable API-key authentication.", + ); + } + + if ( + typeof config.port !== "number" || + !Number.isInteger(config.port) || + config.port < 1 || + config.port > 65535 + ) { + throw new ConfigValidationError( + `PORT must be an integer between 1 and 65535 (got ${String(config.port)})`, + ); + } + + if (config.rateLimitMax < 0) { + throw new ConfigValidationError( + `RATE_LIMIT_MAX must be >= 0 (got ${config.rateLimitMax})`, + ); + } + + if (config.idempotencyTtlMs < 0) { + throw new ConfigValidationError( + `IDEMPOTENCY_TTL_MS must be >= 0 (got ${config.idempotencyTtlMs})`, + ); + } + + if (!config.apiKey && config.env !== "test") { + // Loud, actionable warning: the service is running with open mutating access. + console.warn( + "[config] WARNING: API_KEY is not set — mutating requests are open to everyone (no authentication). " + + "Set API_KEY (or run in development) to avoid open access.", + ); + } + + return config; +}