Skip to content

feat(fees): record every fee quote with the policy behind it (W085) - #260

Merged
Miracle656 merged 3 commits into
Miracle656:mainfrom
JemimahEkong:feat/w085-fee-quote-ledger
Oct 23, 2026
Merged

Miracle656 merged 3 commits into
Miracle656:mainfrom
JemimahEkong:feat/w085-fee-quote-ledger

Conversation

@JemimahEkong

Copy link
Copy Markdown
Contributor

W085 · Record what the platform fee actually charged

Closes #242

Problem

GET /fees tells a client what to charge and where to send it. The fee then rides on-chain in the user's own transaction, and nothing on this side recorded that it happened. So there was no answer to how much had been charged, in which asset, on which day, or under which policy. Because the fee is now policy (percent, floor and cap) rather than a constant, a misconfiguration would overcharge every user until someone read a block explorer.

What changed

  • FeeQuote, one row per asset per quote. Each row holds the network and asset, and the amount priced against (null for clients that send none). It also holds the fee, the rule that decided it (percent | floor | cap | flat | no_amount), the fee as a percentage of the amount, an alarm flag and the policy id.
  • FeePolicy, keyed by a hash of one asset's policy: mode, percent, floor, cap, flat amount, asset and destination. A policy change is a new row with its own firstSeenAt, and every quote names the policy that priced it. The policy comes from the exact entry the client was sent, not from re-reading environment variables, so the record cannot disagree with what the user was told.
  • Recording runs after the response and never throws. A ledger outage is logged and counted in veil_fee_quote_record_failures_total; it never becomes a failed send for someone mid-payment. Each policy is written once per process, with skipDuplicates covering races between instances.
  • Alarm. A fee above VEIL_FEE_ALARM_PERCENT of its amount (default 10, the rate the fee code's own comments call stark) does three things: it flags the row, increments veil_fee_quote_alarms_total{network,asset} and logs a line. The alarm is raised before the database write, so an outage cannot silence an overcharge.
  • GET /fees/report?from=&to= (UTC dates, inclusive, at most 92 days). Per day and asset it returns the quote count, total fees quoted, alarm count and the policy ids, plus the full policies with firstSeenAt.
    • It also returns collected: what the policies' destination received in that asset, read from the indexed transfers.
    • When the asset's token contract isn't indexed, collected is null with a note, because zero would be false.
    • The route sits behind a bearer token, VEIL_FEE_REPORT_TOKEN, compared in constant time. It answers 503 when the token is unset, so it is never served open by accident.
    • It is documented in the OpenAPI spec as x-internal, and openapi.json is regenerated.
  • .env.example and docker-compose.yml gain the two new variables. The existing compose guard test requires both.

Nothing recorded identifies a person

No IP address, user agent or sender address is stored. The only address anywhere in the ledger is the fee destination, which /fees already publishes. One test pins the exact column set of a quote row. Another sends a request carrying an address, a user agent and X-Forwarded-For, and asserts none of them reaches the database.

Acceptance criteria

  • Every /fees quote that returns a fee is recorded with the policy that produced it. Tested end to end through the route, field for field against what the client was sent.
  • Totals can be reported per asset and per day, both fees quoted and collected from the index.
  • A policy change is visible in the record. Tested: changing VEIL_FEE_PERCENT produces new policy rows, and the quotes before and after name different policies.
  • Nothing recorded identifies a person. See above.

Validation

  • npm test: 763 of 764 passing. The single failure is the pre-existing openapiCoverage test (get /fees undocumented), which is W082's job and is unchanged by this PR. The new /fees/report route is documented, so it does not add to that list.
    • Before this change: 710 of 711 passing, with the same failure.
    • The 53 new tests are in src/__tests__/feeLedger.test.ts.
  • npm run typecheck, npx tsc --noEmit and npm run build all pass.
  • npx prisma validate passes.
  • The report's two raw SQL queries were run against a real Postgres 16, with tables matching Prisma's DDL. They exclude other networks, other destinations and mint events, cut the day boundary at exactly midnight UTC, and sum a maximum-size i128 amount without overflow. Prisma's own engines couldn't be downloaded in my environment, so this exercised the SQL, not the Prisma client.

GET /fees tells a client what to charge, and the fee then rides on-chain in
the user's own transaction. Nothing on this side recorded that it happened,
so there was no answer to what had been charged, in which asset, on which
day, or under which policy — and a misconfigured percent, floor or cap
would overcharge every user until someone read a block explorer.

- FeeQuote: one row per asset per quote — amount priced against, fee, the
  rule that decided it (percent | floor | cap | flat | no_amount), the fee as
  a percent of the amount, an alarm flag, and the policy id. No IP, user
  agent or sender address; the only address is the published destination.
- FeePolicy: keyed by a hash of the policy, so a change is a new row with
  its own firstSeenAt and every quote names the policy that priced it. The
  policy is taken from the entry the client was sent, never re-read from env.
- Recording runs after the response and never throws: a ledger outage is
  logged and counted (veil_fee_quote_record_failures_total), not a failed
  send.
- Alarm: a fee above VEIL_FEE_ALARM_PERCENT (default 10) of its amount flags
  the row, increments veil_fee_quote_alarms_total and logs a line — raised
  before the write, so a database outage cannot silence it.
- GET /fees/report?from=&to=: per UTC day and asset — quotes, fees quoted,
  alarms, policies in force, and what the destination actually received per
  the index (null, with a reason, when the asset is not indexed). Behind
  VEIL_FEE_REPORT_TOKEN; 503 when unset. Documented as x-internal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz
@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@JemimahEkong Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Miracle656

Copy link
Copy Markdown
Owner

Rebase needed — #261 landed, consolidating the two Linq HTTP clients into a single src/linq/http.ts. Anything touching src/linq/client.ts or the old call() in src/linq/onramp.ts will conflict.

What changed, so the rebase is predictable:

  • One client. src/linq/http.ts is now the only module that names LINQ_BASE_URL, the provider host or the X-API-Key header — and there are guard tests asserting that, so a second client cannot grow back.
  • LinqError kept all its fields, including existingCustomerRef, which I added yesterday for Linq's email_already_in_use response.
  • #250 also landed and fixed main's Typecheck & build, so you should inherit a green one.

Also worth knowing: Integration tests is red on main itself — tests/integration/api.test.ts, the Postgres read path, expected undefined to be 2, failing since at least 10-16. Not yours, and not a reason to hold a rebase.

Push and I will pick it up.

…t schema

Conflicts in src/openapi/build.ts and src/openapi/schemas.ts: this
branch added /fees/report while main added /fees (Miracle656#215 area) and the
gated /candles route at the same place. Both kept.

openapi.json regenerated from the merged source in a clean environment
(npm run docs:openapi): identical to main's plus /fees/report.

clients/react-query/src/schema.d.ts regenerated from it. The branch had
changed openapi.json without regenerating the client, which fails the
"Generate, typecheck & test" job (git diff --exit-code src/schema.d.ts).

Verified: typecheck, tsc --noEmit, build; jest 63 suites / 961 tests
(the openapiCoverage failure noted in the PR description is gone);
clients/react-query generate (no diff), typecheck, test, build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR, and it very nearly goes in — one missing piece, and it's the same one that bit #210 yesterday.

The migration is missing

prisma/schema.prisma gains two models, FeePolicy and FeeQuote, and nothing under prisma/migrations/ comes with them. This repo commits its migrations (there are nine now) and neither build (tsc) nor start (node dist/index.js) runs one — there's no render.yaml either. So on a deployed instance those tables simply never exist.

Please generate it with Prisma's own tooling rather than hand-writing the DDL — prisma migrate dev --name add_fee_ledger against a local database. I'd rather not write it for you here: two CREATE TABLEs plus the @db.Decimal(38,7) / @db.Decimal(20,7) columns, the @default(autoincrement()) and the two indexes have to match exactly what Prisma expects, and a hand-written migration that drifts makes every future migrate fail in a way that's much harder to diagnose than this.

Also worth knowing: a schema.prisma change in this repo means running npx prisma generate before typecheck tells you the truth. Without it I saw three phantom errors (Property 'feePolicy' does not exist on type 'PrismaClient') that vanished after regenerating — so if you saw those, they weren't real.

Severity, so you know where this sits

Lower than #210, and that's by your design. The write is fire-and-forget:

// Recorded after the answer is sent, and never awaited: the person is in
// the middle of a payment, and a ledger outage must not become a failed
// send. `recordFeeQuotes` cannot throw.
void recordFeeQuotes(...)

and recordFeeQuotes catches. So a missing table degrades to "no fee is recorded" rather than "/fees breaks" — the endpoint keeps answering and nobody's payment fails. That reasoning is right and the comment earns its place. It does mean, though, that without the migration the feature would silently do nothing in production, which is the quiet failure worth avoiding.

What I liked

  • Content-addressing the policy. id as a hash of the canonical policy JSON means the same configuration is one row however many quotes it prices, and any change to percent, floor, cap, flat or destination is automatically a new row with its own firstSeenAt. That's what makes a historical fee explainable rather than merely logged, and it needs no versioning scheme to maintain.
  • The privacy note is accurate, and I checked it rather than assuming. No IP, no user agent, no sender address; the only address is the fee destination, which /fees already publishes to anyone who asks. On a Nigerian rail under the NDPA that distinction matters, and writing down why the table is safe is better than it merely happening to be.
  • @@index([network, quotedAt]) and @@index([network, alarm, quotedAt]) — the second one means "show me the quotes that tripped the alarm" stays cheap as the table grows, which is the query someone will actually run at 2am.
  • Recording basis (percent / floor / cap / flat / no_amount) rather than just the number, so a fee can be explained without re-deriving it.

Verified on a merge onto current main: clean merge, and after npx prisma generate, tsc --noEmit silent with 1,036 tests passing across 65 suites.

Add the migration and re-request review — I'll merge it straight away.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

…er migration

Brings the branch up to date after Miracle656#261 (one Linq HTTP client in
src/linq/http.ts). This change never touched the Linq clients, so the only
conflict was .env.example, where both sides appended a section: upstream's
NGN rate-limit and runtime-settings blocks are kept, then the fee ledger's.

Adds prisma/migrations/20261022120000_add_fee_ledger so the FeePolicy and
FeeQuote tables are in the migration history alongside the schema (the
schema itself is still applied by the guarded db push). openapi.json,
docs/openapi.json and the react-query schema regenerate with no drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvL6Yz2Zfg7spM1F9DXXtz
@Miracle656
Miracle656 merged commit 64615b2 into Miracle656:main Oct 23, 2026
5 checks passed
@Miracle656

Copy link
Copy Markdown
Owner

Merged to main as 64615b2. You added the migration, which was the only thing standing between this and a merge last time — and it's additive (two new tables), so startup's db push creates them with no manual step.

Everything I liked on the first pass still holds, and I re-checked rather than carrying it forward:

  • Content-addressing the policy is what makes this a ledger rather than a log. One FeePolicy row per distinct configuration however many quotes it priced, and any change to percent, floor, cap, flat or destination becomes a new row with its own firstSeenAt — no versioning scheme to maintain.
  • policyId without a foreign key, so a quote is never refused because its policy row lost a race. That's the right trade for a path a payment sits behind.
  • The write is fire-and-forget and recordFeeQuotes cannot throw, so a ledger outage degrades to "no fee recorded" instead of a failed send.
  • Nothing recorded identifies a person — no IP, no user agent, no sender address; the only address is the fee destination /fees already publishes. On a rail under the NDPA, writing down why the table is safe is better than it happening to be.

Two fixes on the way in:

1. Duplicate schema declarations. #253 landed before this and added ngnCustomerStatusQuerySchema and ngnCustomerStatusResponseSchema to src/openapi/schemas.ts. The conflict resolved as a plain union would have left each declared twice — and a duplicate export const is the kind of thing that compiles, with the last one silently winning. I dropped this branch's copies and kept main's; feeReportQuerySchema and the fee report schemas are genuinely new and stayed.

2. docs/openapi.json was stale. The branch updated the root openapi.json but not the generated copy, so the two disagreed. Regenerated with npm run docs:openapi — the only new path is /fees/report, which is what it should be.

wraith: 80 suites, 1,267 tests passing, typecheck clean, and the openapi coverage guard is green (it's the thing that catches a documented-but-unregistered route, so it's worth naming).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nothing records what the platform fee actually charged

2 participants