diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bcb9cf..8c7357c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,17 @@ Changelog All notable changes to the AnchorNet API are documented here. [Unreleased] -Changed -CSV exports: the CSV_COLUMNS constants in routes/anchors.ts and -routes/settlements.ts are now derived from keyof Anchor / keyof Settlement via a new csvColumnsFor type-level helper in utils/csv.ts. -A model field with no matching column (or a column naming a field that does -not exist) is now a compile error naming the offending field, instead of a -silently truncated export. Added -Tests: header-row coverage tests for GET /api/v1/anchors?format=csv, -GET /api/v1/settlements?format=csv, and the nested -GET /api/v1/anchors/:id/settlements?format=csv, asserting the exact column -list and order, cross-checking the header against the keys of a real API -response, and pinning the nested settlement export to the top-level one. +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 +merely reserved liquidity and may still be cancelled, and cancelled ones +never moved value, so neither contributes. Operators can now read total +value settled and total protocol fees earned without fetching every +settlement and summing client-side. The fields are purely additive: the +existing anchors, activeAnchors, pools, totalLiquidity, +settlements and pendingSettlements fields are unchanged, and the same +totals appear in GET /api/v1/metrics/history snapshots. [0.9.0] Added Operations: GET /api/v1/audit — the most recent mutating requests diff --git a/README.md b/README.md index c7f18f3..d555b07 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,19 @@ POST /api/v1/settlements/:id/execute – execute a pending settlement POST /api/v1/settlements/:id/cancel – cancel and release reserved liquidity; accepts an optional { reason } recorded on the settlement Metrics -GET /api/v1/metrics – aggregate counts (anchors, pools, liquidity, -settlements). Each read also appends a timestamped snapshot to an in-memory +GET /api/v1/metrics – aggregate counts (anchors, activeAnchors, pools, +totalLiquidity, settlements, pendingSettlements) plus settled-value totals: +totalSettledAmount (sum of settlement amount) and totalFeesCollected (sum +of settlement fee). Both value totals are computed from executed +settlements only — a pending settlement has merely reserved liquidity and +may still be cancelled, and a cancelled settlement never moved value, so +neither contributes. This lets an operator read total value settled and +total protocol fees earned without fetching every settlement and summing +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: [...] }) +({ snapshots: [...] }); each snapshot carries the same fields as +GET /api/v1/metrics plus an ISO-8601 timestamp 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 diff --git a/src/openapi.test.ts b/src/openapi.test.ts index 6f1e13e..a758a65 100644 --- a/src/openapi.test.ts +++ b/src/openapi.test.ts @@ -36,6 +36,21 @@ describe("openapi spec", () => { ).toBeDefined(); }); + it("documents the settled-value totals on GET /api/v1/metrics", () => { + const spec = buildOpenApiSpec() as { + paths: Record; + }; + + const metrics = spec.paths["/api/v1/metrics"].get; + expect(metrics.description).toContain("totalSettledAmount"); + expect(metrics.description).toContain("totalFeesCollected"); + expect(metrics.description).toContain("executed settlements only"); + + const history = spec.paths["/api/v1/metrics/history"].get; + expect(history.description).toContain("totalSettledAmount"); + expect(history.description).toContain("totalFeesCollected"); + }); + 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 1ca2735..799c363 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -173,15 +173,24 @@ export function buildOpenApiSpec(): Record { }, }, "/api/v1/metrics": { - get: { summary: "Aggregate network metrics" }, + get: { + summary: "Aggregate network metrics", + description: + "Returns anchors, activeAnchors, pools, totalLiquidity, settlements and " + + "pendingSettlements, plus totalSettledAmount (sum of settlement amount) and " + + "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.", + }, }, "/api/v1/metrics/history": { get: { summary: "Recent aggregate metrics snapshots, oldest first", description: - "Returns the buffered metrics history. Pass ?since= " + - "to return only snapshots with timestamp values strictly after that point.", - parameters: ["since"], + "Returns { snapshots: [...] }, where each snapshot carries the same fields as " + + "GET /api/v1/metrics (including totalSettledAmount and totalFeesCollected) " + + "plus an ISO-8601 timestamp.", }, }, }, diff --git a/src/routes/metrics.test.ts b/src/routes/metrics.test.ts index e99b461..2157124 100644 --- a/src/routes/metrics.test.ts +++ b/src/routes/metrics.test.ts @@ -12,6 +12,49 @@ async function seed(app: Express): Promise { .send({ anchor: "anchorA", asset: "USDC", amount: 200 }); } +/** Registers an anchor and funds it with `amount` of `asset`. */ +async function fund( + app: Express, + anchor: string, + asset: string, + amount: number, +): Promise { + await request(app).post("/api/v1/anchors").send({ id: anchor }); + await request(app).post("/api/v1/liquidity").send({ anchor, asset, amount }); +} + +interface OpenedSettlement { + id: number; + amount: number; + fee: number; +} + +/** Opens a settlement and returns its id/amount/fee as assigned by the API. */ +async function open( + app: Express, + anchor: string, + asset: string, + amount: number, +): Promise { + const res = await request(app) + .post("/api/v1/settlements") + .send({ anchor, asset, amount }); + expect(res.status).toBe(201); + return { id: res.body.id, amount: res.body.amount, fee: res.body.fee }; +} + +async function execute(app: Express, id: number): Promise { + const res = await request(app).post(`/api/v1/settlements/${id}/execute`); + expect(res.status).toBe(200); + expect(res.body.status).toBe("executed"); +} + +async function cancel(app: Express, id: number): Promise { + const res = await request(app).post(`/api/v1/settlements/${id}/cancel`); + expect(res.status).toBe(200); + expect(res.body.status).toBe("cancelled"); +} + describe("metrics route", () => { it("reports zeroed metrics on a fresh app", async () => { const res = await request(createApp()).get("/api/v1/metrics"); @@ -154,3 +197,241 @@ describe("metrics route", () => { } }); }); + +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"); + + expect(res.status).toBe(200); + expect(res.body.totalSettledAmount).toBe(0); + expect(res.body.totalFeesCollected).toBe(0); + }); + + it("excludes pending settlements from the settled totals", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const pending = await open(app, "anchorA", "USDC", 10_000); + expect(pending.fee).toBeGreaterThan(0); + + const res = await request(app).get("/api/v1/metrics"); + + expect(res.body.settlements).toBe(1); + expect(res.body.pendingSettlements).toBe(1); + expect(res.body.totalSettledAmount).toBe(0); + expect(res.body.totalFeesCollected).toBe(0); + }); + + it("excludes cancelled settlements from the settled totals", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const cancelled = await open(app, "anchorA", "USDC", 30_000); + await cancel(app, cancelled.id); + + const res = await request(app).get("/api/v1/metrics"); + + expect(res.body.settlements).toBe(1); + expect(res.body.pendingSettlements).toBe(0); + expect(res.body.totalSettledAmount).toBe(0); + expect(res.body.totalFeesCollected).toBe(0); + }); + + it("counts an executed settlement's amount and fee", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const settlement = await open(app, "anchorA", "USDC", 20_000); + await execute(app, settlement.id); + + const res = await request(app).get("/api/v1/metrics"); + + expect(res.body.totalSettledAmount).toBe(20_000); + expect(res.body.totalFeesCollected).toBe(settlement.fee); + // Default protocol fee is 10 bps: ceil(20000 * 10 / 10000) === 20. + expect(res.body.totalFeesCollected).toBe(20); + }); + + it("counts only executed settlements across a pending/executed/cancelled mix", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + + const pending = await open(app, "anchorA", "USDC", 10_000); + const executedOne = await open(app, "anchorA", "USDC", 20_000); + const cancelled = await open(app, "anchorA", "USDC", 30_000); + const executedTwo = await open(app, "anchorA", "USDC", 40_000); + + await execute(app, executedOne.id); + await cancel(app, cancelled.id); + await execute(app, executedTwo.id); + + const res = await request(app).get("/api/v1/metrics"); + + // Counts still cover every settlement regardless of status. + expect(res.body.settlements).toBe(4); + 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.totalFeesCollected).toBe(60); + + // Guard against the pending/cancelled legs leaking into the totals. + expect(res.body.totalSettledAmount).not.toBe( + pending.amount + + executedOne.amount + + cancelled.amount + + executedTwo.amount, + ); + expect(res.body.totalFeesCollected).not.toBe( + pending.fee + executedOne.fee + cancelled.fee + executedTwo.fee, + ); + }); + + it("sums executed settlements across multiple anchors and assets", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + await fund(app, "anchorB", "EURC", 100_000); + + const usdc = await open(app, "anchorA", "USDC", 25_000); + const eurc = await open(app, "anchorB", "EURC", 15_000); + const otherPending = await open(app, "anchorB", "EURC", 5_000); + await execute(app, usdc.id); + await execute(app, eurc.id); + + const res = await request(app).get("/api/v1/metrics"); + + expect(res.body.anchors).toBe(2); + 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.totalFeesCollected).toBe(40); + expect(otherPending.amount).toBe(5_000); + }); + + it("moves a settlement's value into the totals only once it is executed", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const settlement = await open(app, "anchorA", "USDC", 50_000); + + const before = await request(app).get("/api/v1/metrics"); + expect(before.body.totalSettledAmount).toBe(0); + expect(before.body.totalFeesCollected).toBe(0); + + 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); + }); + + it("keeps the settled totals stable when a later settlement is cancelled", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const executed = await open(app, "anchorA", "USDC", 20_000); + await execute(app, executed.id); + + const afterExecute = await request(app).get("/api/v1/metrics"); + + const doomed = await open(app, "anchorA", "USDC", 20_000); + await cancel(app, doomed.id); + + const afterCancel = await request(app).get("/api/v1/metrics"); + + expect(afterCancel.body.totalSettledAmount).toBe( + afterExecute.body.totalSettledAmount, + ); + expect(afterCancel.body.totalFeesCollected).toBe( + afterExecute.body.totalFeesCollected, + ); + expect(afterCancel.body.settlements).toBe(2); + }); + + it("keeps the existing metrics fields unchanged alongside the new totals", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 1_000); + const settlement = await open(app, "anchorA", "USDC", 200); + await execute(app, settlement.id); + + const res = await request(app).get("/api/v1/metrics"); + + // Backward compatibility: the pre-existing shape is preserved verbatim and + // the two value totals are purely additive. + expect(Object.keys(res.body).sort()).toEqual([ + "activeAnchors", + "anchors", + "pendingSettlements", + "pools", + "settlements", + "totalFeesCollected", + "totalLiquidity", + "totalSettledAmount", + ]); + expect(res.body).toEqual({ + anchors: 1, + activeAnchors: 1, + pools: 1, + totalLiquidity: 1_000, + settlements: 1, + pendingSettlements: 0, + totalSettledAmount: 200, + totalFeesCollected: settlement.fee, + }); + }); + + it("includes the settled totals in recorded history snapshots", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const first = await open(app, "anchorA", "USDC", 20_000); + await execute(app, first.id); + + await request(app).get("/api/v1/metrics"); + + const second = await open(app, "anchorA", "USDC", 30_000); + await execute(app, second.id); + + 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(2); + expect(res.body.snapshots[0]).toMatchObject({ + totalSettledAmount: 20_000, + totalFeesCollected: first.fee, + }); + expect(res.body.snapshots[1]).toMatchObject({ + totalSettledAmount: 50_000, + totalFeesCollected: first.fee + second.fee, + }); + // History snapshots keep the pre-existing fields plus a timestamp. + expect(Object.keys(res.body.snapshots[1]).sort()).toEqual([ + "activeAnchors", + "anchors", + "pendingSettlements", + "pools", + "settlements", + "timestamp", + "totalFeesCollected", + "totalLiquidity", + "totalSettledAmount", + ]); + }); + + it("reports numeric (never string or null) settled totals", async () => { + const app = createApp(); + await fund(app, "anchorA", "USDC", 100_000); + const settlement = await open(app, "anchorA", "USDC", 20_000); + await execute(app, settlement.id); + + const res = await request(app).get("/api/v1/metrics"); + + expect(typeof res.body.totalSettledAmount).toBe("number"); + expect(typeof res.body.totalFeesCollected).toBe("number"); + expect(Number.isFinite(res.body.totalSettledAmount)).toBe(true); + expect(Number.isFinite(res.body.totalFeesCollected)).toBe(true); + }); +}); diff --git a/src/routes/metrics.ts b/src/routes/metrics.ts index 8514647..89bbda4 100644 --- a/src/routes/metrics.ts +++ b/src/routes/metrics.ts @@ -12,13 +12,40 @@ import { BoundedHistory } from "../utils/history"; /** Maximum number of metrics snapshots retained for `GET /history`. */ const MAX_HISTORY = 50; -interface MetricsSnapshot { +/** + * 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. */ 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; } export function metricsRouter(deps: { @@ -37,6 +64,11 @@ 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(), @@ -45,6 +77,8 @@ export function metricsRouter(deps: { 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), }; }