Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { get: { description?: string } }>;
};

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<
Expand Down
17 changes: 13 additions & 4 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,24 @@ export function buildOpenApiSpec(): Record<string, unknown> {
},
},
"/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=<ISO-8601 timestamp> " +
"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.",
},
},
},
Expand Down
281 changes: 281 additions & 0 deletions src/routes/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,49 @@ async function seed(app: Express): Promise<void> {
.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<void> {
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<OpenedSettlement> {
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<void> {
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<void> {
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");
Expand Down Expand Up @@ -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);
});
});
Loading
Loading