Skip to content

Non-Custodial Partner Program — aggregated partner statistics endpoints - #4587

Open
joshuakrueger-dfx wants to merge 2 commits into
developfrom
feat/partner-statistics-endpoints
Open

Non-Custodial Partner Program — aggregated partner statistics endpoints#4587
joshuakrueger-dfx wants to merge 2 commits into
developfrom
feat/partner-statistics-endpoints

Conversation

@joshuakrueger-dfx

@joshuakrueger-dfx joshuakrueger-dfx commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

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/payments serves the same CLIENT_COMPANY role up to 1000 individual transactions
per 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/partner and GET /v1/statistic/partner/timeline, scoped to the wallet in the
company JWT. There is deliberately no walletId parameter — a partner cannot express a request
for 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/payments already serves per-transaction detail to the same
CLIENT_COMPANY role, up to 1000 rows per call with free date filters. What this PR adds is a route
that 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:

  • Block suppression on the period totals. If any direction falls below the threshold, the whole
    group is suppressed — members, total and derived average. No per-cell nulling, because
    total − visible would recover the hidden member exactly.
  • Timeline thresholds per direction, not on the bucket sum. A bucket of {buy: 5, sell: 1}
    previously passed and disclosed the single sell in the clear.
  • Periods snap to UTC day boundaries with a minimum span of one day, so two windows differing by
    hours cannot isolate a single transaction.
  • The threshold binds to distinct users as well as transactions. Five trades by one person used
    to pass every gate.
  • Query budget per wallet. The shared RateLimitGuard keys by IP prefix and bypasses known and
    Azure 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:

  1. Period differencing. [Jan 1, Jan 31) and [Jan 1, Jan 30) are both far above the threshold
    and 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. activeUsers overlaps across periods and is not — but newUsers counts
    user.created in the window and is additive, so it differences exactly, down to a single
    registration.
  2. Breakdown rows against the totals. Rows under the threshold are removed from the breakdown
    while the direction totals stay visible, and both come from the same query. totals − visible rows returns the removed row. Closing this means removing further rows until the withheld mass
    itself clears the threshold, which costs visibility in exactly the breakdown a partner reads.
  3. Running counters. allTime, referral and registeredUsers are not period-bound. Two polls
    seconds 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.
  4. Granularity differencing. Complementary suppression picks the smallest filled bucket of the
    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=Week returns that week visibly, and
    week total − visible days of that week is the withheld day. Nothing couples the two answers,
    and constraining that would mean holding query state across requests.
  5. Breakdown against block-suppressed totals — inside a single response. Totals suppression and
    breakdown suppression run independently. fiatCurrencies, blockchains and paymentMethods are
    merged across directions, while assets carries a direction field. With one direction below
    the threshold the totals group is nulled, but the merged rows stay visible, and
    Σ merged − Σ assets of the other directions is the withheld direction's volume — no second
    request 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=true is set, and counts in memory per instance — so the effective ceiling is
120/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 sell below the threshold,
total − buy − swap yields null, not the hidden value.

Correctness fixes found in review

  • referral.creditOpen used partnerRefCredit − paidRefCredit. paidRefCredit covers both pots;
    the three sibling sites use refCredit + partnerRefCredit − paidRefCredit. The old formula
    understated the figure and could go negative.
  • Timeline bucketing mixed UTC period snapping with local date-part keys. Off UTC this shifted every
    bucket by a day. Truncation is now bound to UTC in SQL and the bucket keys follow.
  • map.set on bucket keys silently dropped a bucket on collision; it accumulates now.
  • Date ranges are half-open, so consecutive windows no longer double-count a boundary transaction.

Second review round

  • Four unreachable branches removed. An isNaN check behind a parseDate that already throws;
    a spanDays < 1 rejection that the day snap plus the from >= to check already make impossible;
    a || 1 fallback whose condition cannot hold (isUnderThreshold is false for 0, so a suppressed
    group 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.suppressedBucketsmeta.suppressedCount. On the timeline the field really did count
    buckets; 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.
  • The coverage pin moved to the load-bearing files. Only partner-statistic.enum.ts was 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

  • Full suite on tree 43663c5 (identical to this revision 7ed1216): 371 suites (362 passed, 9 skipped),
    7068 tests (6861 passed, 207 skipped), 0 failures
    . lint empty, format:check clean, tsc --noEmit clean, build
    clean, 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_PG is 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 needs SEPOLIA_RPC_URL,
    which CI does not set. The partner integration spec was additionally run against a real
    Postgres 17 with TZ=UTC on this revision: 5 of 5 green. CodeQL flagged three critical
    type-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.
  • Statistic specs run green under four process timezones — UTC, Europe/Zurich, Pacific/Auckland,
    America/Los_Angeles. Before the fix, Europe/Zurich produced 4 buckets where 3 were expected.
  • Mutation probes, not just deletion probes. Two of them previously proved the tests measured
    nothing: neutralising mergeNamedRows failed 0 of 32, and dropping the wallet scope from a
    single query failed 0 of 18 because the scope assertion used some instead of every. The
    someevery change did not actually close the second one — the assertion filtered the clauses
    by walletId before checking them, so a removed clause vanished from the list and the check passed
    vacuously 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 = 1 in 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 creditOpen formula and the per-direction timeline threshold. Second round, each probe
    anchored to a uniquely matched line and reverted afterwards: bucket-collision accumulation
    +== (1 of 26 red), the same mutation in mergeNamedRows (1 of 26), ISO-week
    alignment day === 0 ? 6 : day - 1day - 1 (1 of 26), the bucket person-floor
    Math.maxMath.min (1 of 29), and the guard's fail-closed throw replaced by the old IP
    fallback (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.
  • Third round — the suppression itself was untested in the response path. Five mutations that
    removed k-anonymity from the delivered payload each failed 0 of 26: neutralising the breakdown
    suppression, neutralising the timeline suppression, passing the internal users field through in
    either payload, and serving newUsers unsuppressed. The suite stayed green while the endpoints
    would 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.
  • The real-Postgres integration spec calls the actual service methodsgetStatistics,
    getTimeline, aggregateByDirection — against real rows, following the repo's existing
    MIGRATION_TEST_PG convention. It previously re-implemented the query builders by hand, so a
    broken join in the real chain would have left it green while it claimed "real Postgres".

For reviewers

  • TZ was pinned nowhere in the repo; this PR sets ENV TZ=UTC in the runtime image and logs the
    detected zone at start. It only warns — the deploy start command lives outside this repo and may
    set TZ itself, so a hard abort would risk the deploy for a premise nothing here can verify. Two
    existing 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.
  • No index is added, and that is a decision with a number behind it. The new aggregates read
    buy_crypto (130,180 rows) and buy_fiat (68,862 rows) — queried against production on
    2026-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.
  • Wallet scope lives in SQL for the aggregates, which is new for this kind of query. Scoping a
    single-row lookup in SQL already exists (kyc.service.ts filters wallet: { id: walletId }); what
    is 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.ts batching at 100). Measured against production, that does not carry an aggregate endpoint: Cake has
    126,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.
  • One call fans out to concurrent queries. Concurrency is bounded by a per-request semaphore
    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.
  • Suppression protects against reconstruction across day-aligned windows. It is not a formal
    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.

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.
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.

1 participant