Skip to content

Non-Custodial Partner Program — gate the partner payments API behind an explicit wallet flag - #4508

Open
joshuakrueger-dfx wants to merge 2 commits into
developfrom
feat/partner-payments-api-gate
Open

Non-Custodial Partner Program — gate the partner payments API behind an explicit wallet flag#4508
joshuakrueger-dfx wants to merge 2 commits into
developfrom
feat/partner-payments-api-gate

Conversation

@joshuakrueger-dfx

Copy link
Copy Markdown
Collaborator

Why

GET /kyc/client/payments and GET /kyc/client/users/:id/payments return TransactionDetailDto,
which carries customer IBANs (sourceAccount / targetAccount), user addresses, on-chain hashes,
amounts and rates.

Access was gated only by RoleGuard(CLIENT_COMPANY). That role is granted to every wallet that has
an address on company login, so setting an address on a partner wallet silently opened the full
transaction dump for its users — without anyone deciding it. This is the first part of the
Non-Custodial Partner Program: onboarding a partner must not implicitly hand out customer data.

Historical note for reviewers (deliberately not in the code): the guard was widened from
KYC_CLIENT_COMPANY to CLIENT_COMPANY in c20190a (#2939) for a specific non-KYC-client
partner. That is why the backfill below keys on address, not on isKycClient — an
isKycClient backfill would by construction revoke exactly that partner.

What

  • New column wallet.paymentsApiEnabled (boolean, NOT NULL DEFAULT false), checked fail-closed in
    both payment paths before any transaction is loaded.
  • Backfill WHERE "address" IS NOT NULL: every wallet that can authenticate as a company today
    keeps exactly the access it has. Newly created wallets start closed, so a newly set address no
    longer opens the payments door by itself.
  • The webhook push path honours the same flag for WebhookType.PAYMENT, so the flag closes both
    doors to the same data. KYC webhook types are unaffected.
  • The pull path now applies the same consent rule the push path already applied.
  • WalletService invalidates the wallet cache after create/update, so an emergency revocation takes
    effect immediately instead of after up to five minutes.
  • The flag is part of WalletDto and the GS debug allowlist, so ops can both set and inspect it.
  • Denials are logged; 403 is excluded from the app log otherwise.

Behaviour change for existing partners

The pull path previously returned every user's transactions regardless of consent. It now applies
the same rule as the webhook path, so partners receive fewer rows than before — only users whose
consent state permits delivery. This aligns the two channels, which previously disagreed about the
same data.

Verification

  • Full suite in four shards: 335 suites, 6115 tests, 0 failures.
  • npm run lint empty, npm run format:check clean.
  • Migration verified against a real Postgres (MIGRATION_TEST_PG, serial): up → down → up with
    fixtures covering both dimensions (address × isKycClient), asserting the full expected row set.
  • down() writes the per-wallet flag state to log before dropping the column, inside the
    migration transaction, so a rollback cannot silently discard operational decisions. The Postgres
    test flips one wallet's flag away from the backfill value and asserts the audit row reflects that
    value, not the derived one.
  • Mutation probes (not just deletion probes): backfill address IS NOT NULLWHERE true fails
    2/3; audit insert removed fails 2/3; audit storing the derived value fails 2/3; flag check removed
    from isValidForWebhook fails 2/13; consent filter always true fails 4/15; fail-closed check
    === true!== false fails 2/28; entity default flipped fails 1/13.

Out of scope

  • getByIdOrName keys its cache without the requested relations, so a wallet cached by one caller
    can be served to another without users loaded. Pre-existing, not touched here.
  • getAllKycData remains ungated — a different dataset and a separate decision.

GET /v1/kyc/client/payments returns TransactionDetailDto / PaymentWebhookData
with customer IBANs, user addresses, on-chain hashes, amounts and rates. The
per-user path /kyc/client/users/:id/payments is additional depth defense
behind KYC_CLIENT_COMPANY and is gated by the same service flag.

Access on the list endpoint was only RoleGuard(CLIENT_COMPANY). That role is
granted to every wallet with an address on company login, so setting an
address on a partner wallet silently opened the full transaction dump.

Add wallet.paymentsApiEnabled (boolean, NOT NULL DEFAULT false) and check it
fail-closed on both payment pull paths and on PAYMENT webhooks (KYC webhook
types unchanged). Ops enable/disable via admin PUT /wallet/:id; create/update
invalidate the wallet cache so an emergency disable is not delayed by the
5-minute cache. The flag is on the GS debug allowlist for support.

Backfill: SET paymentsApiEnabled = true WHERE address IS NOT NULL.

Why not isKycClient: #2939 (c20190a) introduced CLIENT_COMPANY and widened
GET /kyc/client/payments to that role — effective for wallets with
isKycClient = false. Backfilling isKycClient = true would exclude exactly
those partners. Every wallet that can company-sign-in today (address set)
keeps its access; new wallets start false so a newly set address does not
open payments by itself.

Behaviour change for existing partners (pull path): the list endpoints now
apply the same payment webhook consent rule as the push path
(webhookConfig.payment True / ConsentOnly / WalletOnly, with consented=false
for the own-wallet channel and consented=true when the wallet is on the
user's kycClientList). Users that neither channel admits drop out of the
result. Partners that previously listed all wallet users regardless of
webhookConfig will see fewer rows after deploy.
@joshuakrueger-dfx
joshuakrueger-dfx marked this pull request as ready for review July 30, 2026 13:23
The previous gate change filtered GET /kyc/client/payments (and the
per-user path) to the same webhook consent set as the push path. That
was not the intended behaviour: partners should still see the same row
set as before the PR, but without bank accounts, wallet addresses or
on-chain identifiers in the response.

Pull path:
- Remove the consent filter from getAllPayments / getAllUserPayments.
  paymentsApiEnabled remains the only new access gate on pull.
- Introduce PartnerPaymentDto (OmitType of PaymentWebhookData) and
  strip identifying fields only on the pull responses. Webhook payloads
  stay on the full PaymentWebhookData / TransactionDetailDto.

Breaking change for existing partners on the pull endpoints:
- Same transactions as before the gate PR (subject to paymentsApiEnabled).
- Response no longer includes: sourceAccount, targetAccount, inputTxId,
  inputTxUrl, outputTxId, outputTxUrl, depositAddress, chargebackTarget,
  chargebackTxId, chargebackTxUrl, networkStartTx.
- Amounts, assets, fees, status, type, timestamps and dfxReference remain.

Webhook PAYMENT push is unchanged (still full payload + its own consent
and paymentsApiEnabled checks).
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