Non-Custodial Partner Program — aggregated partner statistics endpoints - #4587
Open
joshuakrueger-dfx wants to merge 2 commits into
Open
Non-Custodial Partner Program — aggregated partner statistics endpoints#4587joshuakrueger-dfx wants to merge 2 commits into
joshuakrueger-dfx wants to merge 2 commits into
Conversation
Columns such as `created` are `timestamp without time zone`: the Postgres driver serializes a JS Date in the process-local wall-clock and Postgres drops the offset, so a non-UTC process shifts stored values and every day bucket derived from them. Pins `TZ=UTC` in the image and checks the process timezone at boot. The check warns rather than aborts — it must not turn a timezone misconfiguration into a failed rollout — and covers the winter/summer trap by testing the offset year-round, not just at the current date. Boot failures now surface as an explicit "Bootstrap failed" log with exit 1 instead of an unhandled rejection.
`GET /v1/statistic/partner` and `GET /v1/statistic/partner/timeline`, scoped to the wallet in the company JWT. There is deliberately no `walletId` parameter, so a partner cannot express a request for someone else's data, and the lookup fails closed when the JWT carries no wallet. Both routes return aggregates only: volume and transaction counts by direction, active and new users, breakdowns by asset, fiat currency, blockchain and payment method, plus the referral position in EUR. Nothing in the implementation is partner-specific — any wallet gets its own numbers through the same route. Aggregates alone are not anonymous, so suppression blocks the whole group when any direction falls below the threshold (per-cell nulling would let `total - visible` recover the hidden member), applies the timeline threshold per direction rather than to the bucket sum, binds the threshold to distinct users as well as transactions, and snaps periods to UTC day boundaries with a minimum span of one day so two windows differing by hours cannot isolate a single transaction. A dedicated guard budgets queries by wallet id — the shared rate-limit guard keys by IP prefix and bypasses known addresses, which puts no bound on repeated scrapes from one partner JWT. Query parameters are rejected rather than coerced when they arrive as arrays, non-existent calendar days are refused, and query concurrency is bounded.
joshuakrueger-dfx
marked this pull request as ready for review
August 1, 2026 22:24
joshuakrueger-dfx
requested review from
TaprootFreak and
davidleomay
as code owners
August 1, 2026 22:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Not symptom-driven: no incident triggered this — a wallet partner asked for usage numbers, and
the only route that answers today hands out per-transaction detail.
Scale: Cake alone has 126,988 users in production and is the first of several wallet partners;
/v2/kyc/client/paymentsserves the sameCLIENT_COMPANYrole up to 1000 individual transactionsper call, including customer IBANs, with free date filters.
Smaller fix considered: point partners at that existing endpoint and let them aggregate —
insufficient because it makes every partner store per-transaction data about their customers,
including users who never consented to it, purely to compute counts and volumes they can be handed
directly.
Wallet partners integrating DFX have no way to see how their integration performs. The only
partner-facing transaction endpoint returns per-transaction detail — more than a usage dashboard
needs, and more than a partner should hold. This is the interface half of the Non-Custodial Partner
Program; #4508 is the lock that must ship first.
Generic by design: there is no partner-specific code anywhere. Any wallet gets its own numbers
through the same route.
What
GET /v1/statistic/partnerandGET /v1/statistic/partner/timeline, scoped to the wallet in thecompany JWT. There is deliberately no
walletIdparameter — a partner cannot express a requestfor someone else's data. Both return aggregates only: volume and transaction counts by direction,
active and new users, breakdowns by asset, fiat currency, blockchain and payment method, plus the
referral position in EUR.
What the partner gets, and what stays behind
The point of these endpoints is scope, not secrecy. The data itself stays retrievable — DFX keeps
full access, and
GET /v2/kyc/client/paymentsalready serves per-transaction detail to the sameCLIENT_COMPANYrole, up to 1000 rows per call with free date filters. What this PR adds is a routethat answers the dashboard question — volume, counts, users, breakdowns — without the partner
holding per-transaction data at all. A partner integrating against these two endpoints stores
aggregates, not customer transactions.
That framing matters for the paragraphs below: the k-anonymity threshold here reduces what a
low-privilege integration sees. It is not the system's privacy boundary, because the same
credential can reach the detail endpoint directly. Whether that endpoint should stay as open as it
is, is a separate question and deliberately out of scope here.
Anonymity: what it protects, and what it does not
Aggregates alone are not anonymous. Three independent reviews demonstrated, with executed output,
that a suppressed value could be recovered by subtraction. What the endpoints do about it:
group is suppressed — members, total and derived average. No per-cell nulling, because
total − visiblewould recover the hidden member exactly.{buy: 5, sell: 1}previously passed and disclosed the single sell in the clear.
hours cannot isolate a single transaction.
to pass every gate.
RateLimitGuardkeys by IP prefix and bypasses known andAzure addresses, so it puts no bound on repeated scrapes from a single partner JWT. The new guard
keys by the wallet id in the JWT, has no bypass list, and fails closed if the wallet id is absent:
120 requests per hour per wallet on each route.
Five reconstruction paths remain open. This is a decision, not an oversight — each was measured
against the alternative, and against the fact that the same caller can read the underlying
transactions directly. Closing them would cost visibility a partner legitimately needs, to withhold
numbers that are one endpoint away:
[Jan 1, Jan 31)and[Jan 1, Jan 30)are both far above the thresholdand both are served. Their difference is Jan 31 exactly, including a day with a single
transaction that the timeline would have withheld. Volume and transaction counts are additive so
the subtraction is exact.
activeUsersoverlaps across periods and is not — butnewUserscountsuser.createdin the window and is additive, so it differences exactly, down to a singleregistration.
while the direction totals stay visible, and both come from the same query.
totals − visible rowsreturns the removed row. Closing this means removing further rows until the withheld massitself clears the threshold, which costs visibility in exactly the breakdown a partner reads.
allTime,referralandregisteredUsersare not period-bound. Two pollsseconds apart expose a single trade as the delta, for any partner above the threshold. Closing
this means serving them day-aligned, which makes a dashboard's headline number up to 24 hours
stale.
whole response, which for a day-granularity answer usually lands in a different week than the
withheld day. Asking the same period again at
granularity=Weekreturns that week visibly, andweek total − visible days of that weekis the withheld day. Nothing couples the two answers,and constraining that would mean holding query state across requests.
breakdown suppression run independently.
fiatCurrencies,blockchainsandpaymentMethodsaremerged across directions, while
assetscarries adirectionfield. With one direction belowthe threshold the totals group is nulled, but the merged rows stay visible, and
Σ merged − Σ assets of the other directionsis the withheld direction's volume — no secondrequest needed. Closing it means coupling the two suppression stages, which would drop the
breakdown for every partner whose smallest direction is thin.
The budget slows all three; it does not close them. It is also inactive unless
REQUEST_LIMIT_CHECK=trueis set, and counts in memory per instance — so the effective ceiling is120/h times the number of app instances. Stated plainly here because the budget is part of the
argument and should not be read as stronger than it is.
Verified by executed output rather than argument: with
sellbelow the threshold,total − buy − swapyieldsnull, not the hidden value.Correctness fixes found in review
referral.creditOpenusedpartnerRefCredit − paidRefCredit.paidRefCreditcovers both pots;the three sibling sites use
refCredit + partnerRefCredit − paidRefCredit. The old formulaunderstated the figure and could go negative.
bucket by a day. Truncation is now bound to UTC in SQL and the bucket keys follow.
map.seton bucket keys silently dropped a bucket on collision; it accumulates now.Second review round
isNaNcheck behind aparseDatethat already throws;a
spanDays < 1rejection that the day snap plus thefrom >= tocheck already make impossible;a
|| 1fallback whose condition cannot hold (isUnderThresholdis false for 0, so a suppressedgroup always has at least one non-zero member); and a duplicated loop that wrote the same values
twice, together with a thinking-out-loud comment that had no business shipping.
meta.suppressedBuckets→meta.suppressedCount. On the timeline the field really did countbuckets; on the summary it counts suppressed fields and breakdown rows, where there are no buckets
at all. Both routes are new and unconsumed, so the name is corrected before anyone depends on it.
partner-statistic.enum.tswas pinned —42 lines of constants — while the suppression module and the rate-limit guard were unguarded.
Both now sit at 100% on all four metrics and are pinned; the 942-line service is deliberately not,
but the gaps that mattered are closed.
Verification
43663c5(identical to this revision7ed1216): 371 suites (362 passed, 9 skipped),7068 tests (6861 passed, 207 skipped), 0 failures.
lintempty,format:checkclean,tsc --noEmitclean,buildclean, coverage gate green. Measured off-CI, on a machine with the repo's own scripts.
CI has since run green on
7ed1216: 12 of 12 checks, 371 suites, 7068 tests (6965 passed,103 skipped), 0 failures — and the real-Postgres integration spec ran there (shard 2, PASS),
it did not skip. Six of the 8 skipped suites are the real-Postgres specs,
which skip wherever
MIGRATION_TEST_PGis unset; CI sets it on every shard, so those do run there.The other two are unrelated: one is a hard
describe.skip, the other needsSEPOLIA_RPC_URL,which CI does not set. The partner integration spec was additionally run against a real
Postgres 17 with
TZ=UTCon this revision: 5 of 5 green. CodeQL flagged three criticaltype-confusion alerts on the date parser — Express hands over an array when a query parameter is
repeated and the regex guard coerced it silently, so both parsers now reject non-string input
before any pattern matching.
America/Los_Angeles. Before the fix, Europe/Zurich produced 4 buckets where 3 were expected.
nothing: neutralising
mergeNamedRowsfailed 0 of 32, and dropping the wallet scope from asingle query failed 0 of 18 because the scope assertion used
someinstead ofevery. Thesome→everychange did not actually close the second one — the assertion filtered the clausesby
walletIdbefore checking them, so a removed clause vanished from the list and the check passedvacuously for four of the six user-touching queries. Removing the scope from any one of the six now
fails 2 of 56, re-measured on this revision: each of the six sites OR-extended to
… OR 1 = 1in turn, each run red, file restored after every probe. Substitution is covered separately. Both are red now, alongside probes for block suppression, day snapping, the distinct-user threshold,the
creditOpenformula and the per-direction timeline threshold. Second round, each probeanchored to a uniquely matched line and reverted afterwards: bucket-collision accumulation
+=→=(1 of 26 red), the same mutation inmergeNamedRows(1 of 26), ISO-weekalignment
day === 0 ? 6 : day - 1→day - 1(1 of 26), the bucket person-floorMath.max→Math.min(1 of 29), and the guard's fail-closed throw replaced by the old IPfallback (1 of 5). The timeline-collision path and both week/month alignment branches had no
test at all before this round, although the collision fix was already claimed above.
removed k-anonymity from the delivered payload each failed 0 of 26: neutralising the breakdown
suppression, neutralising the timeline suppression, passing the internal
usersfield through ineither payload, and serving
newUsersunsuppressed. The suite stayed green while the endpointswould have shipped every withheld number and the per-row person counts. Each of those five now
fails 1 of 43. Strict date parsing is covered the same way: reverting to the tolerant
new Date(value)fails 3 of 43, exactly the three rejected forms.getStatistics,getTimeline,aggregateByDirection— against real rows, following the repo's existingMIGRATION_TEST_PGconvention. It previously re-implemented the query builders by hand, so abroken join in the real chain would have left it green while it claimed "real Postgres".
For reviewers
TZwas pinned nowhere in the repo; this PR setsENV TZ=UTCin the runtime image and logs thedetected zone at start. It only warns — the deploy start command lives outside this repo and may
set
TZitself, so a hard abort would risk the deploy for a premise nothing here can verify. Twoexisting modules build bucket keys from local date parts on the assumption that "the app and DB
both run UTC"; they are worth a separate look.
buy_crypto(130,180 rows) andbuy_fiat(68,862 rows) — queried against production on2026-07-31, not estimated. At that size a filtered aggregate is milliseconds; an index for a
dashboard call that runs twice an hour would be cost without a case. If either table grows by an
order of magnitude, the question is worth reopening.
single-row lookup in SQL already exists (
kyc.service.tsfilterswallet: { id: walletId }); whatis new is doing it inside aggregate queries over the transaction tables. The partner endpoints that
read many rows load the wallet with its users and pass the ids on (
kyc-client.service.ts,transaction.service.tsbatching at 100). Measured against production, that does not carry an aggregate endpoint: Cake has126,988 users, so the existing pattern would mean 1,270 sequential batches per query and roughly
22,800 round trips per summary call, plus every user row in memory. The SQL scope keeps it at 18
queries and loads no user rows. Every user-touching query carries the clause, and a test now fails
when any one of the six loses it — removal, not just substitution.
every SQL execution passes through, so nested fan-outs share one budget of 4. The previous
per-call-site cap did not compose: four breakdown tasks each opened their own pool of workers and
a single request reached 11 simultaneous queries against a default pool of 10.
query-budget guarantee, and path 5 above means it does not hold within a single response either.
Replaces #4538 — identical tree (
43663c5), history rewritten from fourteen review-round commits onto two: the UTC process change and the endpoints themselves.