docs(prd): clarify task #7 keying — cart-with-multi-seller, per-Payment derivation

User flagged: a buyer's cart can span multiple sellers, so 'per-(buyer, seller)'
isn't really 1:1. The right framing is per-Payment: Amanat already creates N
Payment records for an N-seller cart (one per sellerOfferId), and each gets
its own derived destination + RN intent + buyer-side approve+pay tx pair.

PRD now explicitly:
- Recommends per-Payment keying (which collapses to per-(buyer, sellerOfferId)
  via the existing uniq_pending_request_network_by_buyer_session index)
- Documents the multi-seller cart UX (N approve+pay pairs in sequence, with
  clear progress indicator, mid-cart abandonment is fine)
- Notes RN's ERC20FeeProxy is single-destination by design (no atomic split
  in v1; future Amanat splitter contract is out of scope)
- Updates open questions to monotonic derivation counter, immediate sweep,
  single-use addresses (no rotation), and cold-payment recovery
- Scope explicitly mentions cart-aware buyer UX as part of task #7

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-05-28 16:05:50 +04:00
parent 0060b16912
commit 31dd475b73
2 changed files with 48 additions and 33 deletions

View File

@@ -9,13 +9,23 @@ Five follow-ups to the in-house Request Network checkout. They are sized so a si
---
## 1. Per-(buyer, seller) ephemeral destination wallets — Task #7
## 1. Per-Payment ephemeral destination wallets — Task #7
### Problem
Today the in-house checkout sends *all* RN-routed payments to one Amanat-controlled wallet (env: `REQUEST_NETWORK_MERCHANT_REFERENCE`). That wallet is shared across every buyer, every seller, every offer. It's both an audit nightmare (no buyer↔settlement linkage at the wallet level) and a single point of compromise.
### Goal
For each `(buyerId, sellerOfferId)` (or `(buyerId, sellerId)` — see open questions), generate a fresh on-chain destination address, persist it on the `Payment` record, and tell Request Network to expect funds on that address. RN's webhook flows unchanged.
For each `Payment` record (which already represents a `(buyerId, sellerOfferId)` pair), generate a fresh on-chain destination address, persist it on the `Payment` record, and tell Request Network to expect funds on that address. RN's webhook flows unchanged.
### Key clarification: cart with multiple sellers
A buyer's cart can contain items from multiple sellers. **That's already modeled in Amanat as N separate `Payment` records, one per `sellerOfferId` in the cart** — see the `uniq_pending_request_network_by_buyer_session` index in `requestNetworkPayInService.ts`. So:
- 1 Payment record = 1 derived destination address = 1 RN intent = 1 buyer-side on-chain transaction.
- A 5-item cart spanning 2 sellers produces 2 Payments, 2 derived addresses, 2 RN intents, and the buyer makes 2 approve+pay tx pairs in sequence.
- RN's `ERC20FeeProxy.transferFromWithReferenceAndFee` is single-destination by design — there's no atomic multi-recipient split. A future v2 could route the cart through an Amanat splitter contract that fans out to per-seller derived addresses in one buyer tx; that's out of scope here.
So the correct keying is **per `Payment._id`**, which collapses to per-`(buyer, sellerOfferId)` because of the existing uniqueness constraint on pending Payments. We do NOT key by `(buyer, seller)` directly — a single seller can have multiple distinct offers a buyer may pay for, and we want each to settle into its own address for audit lineage.
### Hard-known facts (from RN docs we've cold-inspected so far)
- The "destination" in RN is the `destinationId` inside the merchant reference: `<address>@eip155:<chainId>#<contractId>:<tokenAddress>`. RN doesn't bind this to an Amanat-level identity; it's just where the funds end up.
@@ -24,20 +34,27 @@ For each `(buyerId, sellerOfferId)` (or `(buyerId, sellerId)` — see open quest
### Open questions to settle before code
1. **Key custody model.** Options:
- **Deterministic HD wallet** rooted at one Amanat master seed; derive per-`(buyer, seller)` path (e.g. `m/44'/60'/0'/<buyerIdx>/<sellerIdx>`). Keys live in the backend, single seed in KMS/HSM. Sweep is one tx per derived addr.
- **One-shot disposable EOAs**, encrypted and stored in Mongo (or KMS), keyed by `(buyer, seller)`. Sweep then forget.
- **Smart contract per offer** that auto-forwards to the master wallet on receive. Avoids holding keys at all, but costs gas + an extra hop.
- Recommended starting point: HD wallet, with sweep-on-confirmation. Cheapest, most auditable.
2. **Sweep strategy.** Sweep immediately on webhook confirmation, or batch sweep cron'd nightly? Trade gas vs. exposure window. Default: sweep immediately under Transaction Safety Provider approval.
3. **Granularity.** Per `(buyer, seller)`, per `(buyer, seller, offer)`, or per single payment? Per-offer gives clean audit lineage; per-payment is overkill (extra derivations); per-`(buyer, seller)` is reusable across multi-step deals.
4. **Re-use vs. expire.** If a derived address has funds in it after sweep, do we still re-use for the same pair's next payment, or rotate? Re-use = simpler, slight privacy hit.
- **Deterministic HD wallet** rooted at one Amanat master seed; derive per-Payment path (e.g. `m/44'/60'/0'/<paymentSequence>`). Keys live in the backend, single seed in KMS/HSM. Sweep is one tx per derived addr.
- **One-shot disposable EOAs**, encrypted and stored in Mongo (or KMS), keyed by Payment._id. Sweep then forget.
- **Smart contract per Payment** that auto-forwards to the master wallet on receive. Avoids holding keys at all, but costs gas + an extra hop per payment.
- Recommended starting point: HD wallet, with sweep-on-confirmation. Cheapest, most auditable. Derivation index can come from a monotonic counter (`Setting{key:'rn_derivation_next_idx'}`) so we never re-derive an exhausted address.
2. **Sweep strategy.** Sweep immediately on webhook confirmation, or batch sweep cron'd nightly? Trade gas vs. exposure window. Default: sweep immediately under Transaction Safety Provider approval. For BSC USDC gas is cheap enough that immediate is fine; revisit if we add a costly chain.
3. **Multi-seller cart UX.** The buyer signs N approve+pay pairs in sequence (one per Payment). Frontend MUST surface this clearly:
- "You're paying 2 sellers for this order. Please confirm 4 transactions in your wallet." (2 approves + 2 pays)
- Progress indicator: "Paid 1 of 2 sellers — continue?"
- If buyer aborts mid-cart, the sellers who already received funds are settled; the rest stay pending (existing Payment lifecycle handles this).
- Out of scope for v1: an atomic splitter contract that fans out in one buyer tx.
4. **Re-use vs. rotate.** A derived address is single-use by default (one Payment = one address). After sweep, the address is empty and we never reuse it — derivation index marches forward monotonically. This is the cleanest audit story.
5. **Cold-payment recovery.** What if RN reports a payment to the derived address but our backend never created the Payment record (RN paid to a wrong address user inputted manually)? Out of scope for v1: the in-house UI never asks the buyer to type an address. Manual recovery via support.
### Scope
1. New module `backend/src/services/payment/wallets/derivedDestinations.ts` with `getDestinationFor(buyerId, sellerOfferId)` returning `{ address, derivationPath, chainId }`.
2. Migration on `Payment` schema to add `metadata.derivedDestination` (address + derivation path snapshot).
3. RN intent creation calls `getDestinationFor(...)` and overrides the destination half of `REQUEST_NETWORK_MERCHANT_REFERENCE`.
4. Sweep job (cron + manual-trigger admin endpoint) under Transaction Safety Provider gate.
5. Admin UI (table) to view derived destinations, their balances, sweep status, and last sweep tx.
1. New module `backend/src/services/payment/wallets/derivedDestinations.ts` with `getDestinationForPayment(paymentId)` returning `{ address, derivationPath, chainId }`. Idempotent — calling it twice for the same Payment returns the already-allocated address.
2. New `Setting` doc with key `rn_derivation_next_idx` tracking the monotonic derivation counter (atomic `findOneAndUpdate $inc`).
3. Migration on `Payment` schema to add `metadata.derivedDestination` (address + derivation path + chainId snapshot).
4. RN intent creation in `requestNetworkPayInService.ts` calls `getDestinationForPayment(payment._id)` and overrides the destination half of the merchant reference before `createSecurePaymentRequest`. Uses the new `buildMerchantReference` helper (already in `merchantReference.ts`).
5. Sweep job (cron + manual-trigger admin endpoint) under Transaction Safety Provider gate. Sweep target = the master wallet from the env's `REQUEST_NETWORK_MERCHANT_REFERENCE`.
6. Admin UI (table) at `/dashboard/admin/derived-destinations` to view: Payment id, derived address, balance, sweep status, last sweep tx (BscScan link), age, ownership status.
7. Cart-aware buyer UX in the in-house checkout (if a cart spans multiple sellers): clear progress UI, sequential approval flow, recoverable mid-cart abandonment.
### Non-goals
- Multi-chain destinations (covered in Task #8).