Skip to content

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

Open
JemimahEkong wants to merge 1 commit into
Miracle656:mainfrom
JemimahEkong:feat/w085-fee-quote-ledger
Open

JemimahEkong wants to merge 1 commit 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.

This branch has not been deployed

No deployments
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