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
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# The OpenAPI spec is generated with LF line endings and compared byte-for-byte
# against the generator in src/__tests__/openapiCoverage.test.ts. Without this,
# a Windows checkout (core.autocrlf=true) turned every line into CRLF and the
# comparison failed until someone happened to regenerate the file — so the same
# commit passed or failed depending on whose machine it ran on.
openapi.json text eol=lf
docs/openapi.json text eol=lf
87 changes: 87 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4676,6 +4676,93 @@
}
}
},
"/fees": {
"get": {
"summary": "Veil platform fee",
"description": "What the wallet should charge on a send, and where to pay it, per asset. The fee is collected on-chain in the same transaction as the send; this only tells the client the amount, so it can change without shipping an app. Unauthenticated and served with `Cache-Control: no-store`, because a stale fee is one the user was shown but did not agree to. `enabled: false` means no fee is configured today.",
"parameters": [
{
"schema": {
"type": "string",
"nullable": true,
"description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.",
"example": "25"
},
"required": false,
"description": "The amount being sent, in whole units of the asset. When given and a percentage fee is configured, each fee is computed for it; without it each fee is the floor. Optional because shipped clients predate it.",
"name": "amount",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "False when no fee is configured, so a client can tell a deliberate waiver from a backend too old to know about fees."
},
"fees": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"amount": {
"type": "string",
"description": "What to charge, in whole units of the fee asset, as a decimal string.",
"example": "0.25"
},
"assetCode": {
"type": "string",
"example": "USDC"
},
"assetIssuer": {
"type": "string",
"nullable": true,
"description": "Null for native XLM."
},
"destination": {
"type": "string",
"description": "The classic account the fee is paid to."
},
"percent": {
"type": "string",
"description": "Percent of the sent amount, when the fee scales. Absent when it is flat."
},
"min": {
"type": "string",
"description": "Floor; present only alongside percent."
},
"max": {
"type": "string",
"description": "Cap; present only alongside percent."
}
},
"required": [
"amount",
"assetCode",
"assetIssuer",
"destination"
]
},
"description": "Keyed by asset code. Always charged in the asset being sent."
}
},
"required": [
"enabled",
"fees"
]
}
}
}
}
}
}
},
"/transfers.csv": {
"get": {
"summary": "Export all transfers as CSV",
Expand Down
32 changes: 31 additions & 1 deletion src/__tests__/openapiCoverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ import { buildOpenApiDocument } from "../openapi/build";
* walking the app — `GET /accounts/:address/balance` and the provider
* callback `POST /webhooks/linq`.
*
* It went red again (W082) when `GET /fees` shipped without a spec entry. It
* also appeared to flap, but the route walk is deterministic — ten runs in a
* row agree. What changed between runs was the environment, not the route table:
* - `openapi.json` on a Windows checkout had CRLF line endings, so the
* byte comparison below failed until someone ran `npm run docs:openapi`
* (which writes LF), then failed again after the next checkout;
* - before that, it read the gitignored `docs/openapi.json`, which existed
* only on machines that had run the generator.
* Both are fixed; see the last test and `.gitattributes`.
*
* Speed: nearly all of this suite's time is loading the app (the Stellar SDK,
* Apollo, Prisma), not the check — walking the routes and building the spec is
* ~100ms. ts-jest used to type-check that whole import graph on every cold
* run, which CI already does in `npm run typecheck`; `tsconfig.jest.json` now
* sets `isolatedModules` so tests are transpiled, not checked twice.
*
* How the app's route table is collected: express stacks every `app.use` and
* `app.get` as a Layer. A Layer with a `route` is a method route; a Layer
* whose `handle` has its own `stack` is a mounted router. Express 4 keeps the
Expand Down Expand Up @@ -151,6 +167,11 @@ describe("OpenAPI route coverage (W085)", () => {
expect(keys).toContain("get /summary/:address");
expect(keys).toContain("get /accounts/:address/balance");
expect(keys).toContain("get /transfers.csv");
expect(keys).toContain("get /fees");
});

it("documents /fees", () => {
expect(spec.paths["/fees"]?.get).toBeDefined();
});

it("documents /tokens", () => {
Expand Down Expand Up @@ -192,7 +213,16 @@ describe("OpenAPI route coverage (W085)", () => {
// every clean clone, CI included, before this test could assert anything.
// Nothing is lost: the same loop in build.ts writes both from one
// `document`, so the docs copy cannot drift independently of this one.
const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8");
//
// Line endings are normalised: they are not part of the spec, and a
// Windows checkout with core.autocrlf rewrote all of them, so this failed
// or passed on the same commit depending on whether the file had been
// regenerated locally since checkout. `.gitattributes` pins the file to LF
// as well; this keeps the assertion about content either way.
const committed = readFileSync(path.resolve(process.cwd(), "openapi.json"), "utf8").replace(
/\r\n/g,
"\n",
);
const generated = JSON.stringify(spec, null, 2) + "\n";

expect(committed).toBe(generated);
Expand Down
18 changes: 18 additions & 0 deletions src/openapi/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
offrampErrorResponseSchema,
ngnCustomerStatusQuerySchema,
ngnCustomerStatusResponseSchema,
feesQuerySchema,
feesResponseSchema,
} from "./schemas";

const registry = new OpenAPIRegistry();
Expand Down Expand Up @@ -397,6 +399,22 @@ registry.registerPath({
},
});

registry.registerPath({
method: "get",
path: "/fees",
summary: "Veil platform fee",
description:
"What the wallet should charge on a send, and where to pay it, per asset. The fee " +
"is collected on-chain in the same transaction as the send; this only tells the " +
"client the amount, so it can change without shipping an app. Unauthenticated and " +
"served with `Cache-Control: no-store`, because a stale fee is one the user was " +
"shown but did not agree to. `enabled: false` means no fee is configured today.",
request: { query: feesQuerySchema },
responses: {
200: { description: "OK", content: { "application/json": { schema: feesResponseSchema } } },
},
});

const transferExportResponses = {
200: {
description: "File download of the full matching transfer set (no pagination).",
Expand Down
36 changes: 36 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,39 @@ export const ngnCustomerStatusResponseSchema = z.object({
status: z.string().optional(),
});


// ─── GET /fees ──────────────────────────────────────────────────────────────

export const feesQuerySchema = z.object({
amount: optionalQueryString(
"The amount being sent, in whole units of the asset. When given and a percentage " +
"fee is configured, each fee is computed for it; without it each fee is the " +
"floor. Optional because shipped clients predate it.",
).openapi({ example: "25" }),
});

const platformFeeSchema = z.object({
amount: z.string().openapi({
description: "What to charge, in whole units of the fee asset, as a decimal string.",
example: "0.25",
}),
assetCode: z.string().openapi({ example: "USDC" }),
assetIssuer: z.string().nullable().openapi({ description: "Null for native XLM." }),
destination: z.string().openapi({ description: "The classic account the fee is paid to." }),
percent: z.string().optional().openapi({
description: "Percent of the sent amount, when the fee scales. Absent when it is flat.",
}),
min: z.string().optional().openapi({ description: "Floor; present only alongside percent." }),
max: z.string().optional().openapi({ description: "Cap; present only alongside percent." }),
});

export const feesResponseSchema = z.object({
enabled: z.boolean().openapi({
description:
"False when no fee is configured, so a client can tell a deliberate waiver " +
"from a backend too old to know about fees.",
}),
fees: z.record(z.string(), platformFeeSchema).openapi({
description: "Keyed by asset code. Always charged in the asset being sent.",
}),
});
7 changes: 6 additions & 1 deletion tsconfig.jest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"noEmit": false
"noEmit": false,
// Transpile only. Type errors in src/ and the tests are caught by
// `npm run typecheck` (tsconfig.test.json), which CI runs before `npm test`;
// ts-jest checking the same graph again roughly doubled a cold run of any
// suite that imports the whole app (openapiCoverage: 23s → 13s).
"isolatedModules": true
}
}
Loading