docs(prd): update Task #7 to reflect what shipped in 2.6.42 + scoped remaining work for kimi

Rewrote §1 of the Wallet/Multichain/Confirmations/AML/Trezor PRD:
- Status promoted from 'Draft' to 'Living', §1 marked 🟡 In progress.
- Decisions taken table captures the locked-in choices (per-(buyer,
  sellerOffer, chainId) keying with reuse, monotonic counter,
  cron-based sweep, build-only signer default).
- 'What landed in 2.6.42' section enumerates the actual files +
  endpoints + env vars so kimi has concrete reference points.
- 'Remaining work for Task #7' table breaks the remainder into six
  discrete items (A..F) each with file paths and notes:
  A — cart-aware buyer UX (the big one)
  B — unit tests
  C — live divergent-destination probe
  D — optional auto-start cron on boot
  E — possible recordSweep accumulation bug to verify+fix
  F — API Reference doc updates
- Acceptance criteria annotated with / per item.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-05-28 16:42:15 +04:00
parent d80892dbaa
commit 21627b7e71

View File

@@ -1,21 +1,23 @@
# PRD: Wallet, Multichain, Confirmations, AML, Trezor # PRD: Wallet, Multichain, Confirmations, AML, Trezor
> Status: **Draft — 2026-05-28** > Status: **Living — last edit 2026-05-28 (after Task #7 core shipped in backend/frontend 2.6.42)**
> Author: nick + claude (after in-house RN checkout shipped on dev 2.6.38/2.6.41) > Author: nick + claude
> Owner: backend (payments) + frontend (admin UI + checkout) > Owner: backend (payments) + frontend (admin UI + checkout)
> Related: `PRD - Request Network In-House Checkout.md`, `01 - Architecture/Request Network Integration Constraints.md` > Related: `PRD - Request Network In-House Checkout.md`, `01 - Architecture/Request Network Integration Constraints.md`, `08 - Operations/Handoff - Request Network In-House Checkout - 2026-05-28.md`
Five follow-ups to the in-house Request Network checkout. They are sized so a single contributor can pick up any one of them in isolation. Each is its own Taskmaster top-level task — see `#7…#11`. Five follow-ups to the in-house Request Network checkout. They are sized so a single contributor can pick up any one of them in isolation. Each is its own Taskmaster top-level task — see `#7…#11`.
--- ---
## 1. Per-Payment ephemeral destination wallets — Task #7 ## 1. Per-(buyer, sellerOffer) ephemeral destination wallets — Task #7
### Status: 🟡 In progress — backend + admin UI landed in 2.6.42, remaining work below.
### Problem ### 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. The in-house checkout used to send *all* RN-routed payments to one Amanat-controlled wallet (env: `REQUEST_NETWORK_MERCHANT_REFERENCE`). That wallet was shared across every buyer, every seller, every offer an audit nightmare and a single point of compromise.
### Goal ### Goal (achieved on the backend)
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. For each `Payment` record (which 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 ### Key clarification: cart with multiple sellers
@@ -25,48 +27,73 @@ A buyer's cart can contain items from multiple sellers. **That's already modeled
- 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. - 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. - 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) ### 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. - 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.
- Each `POST /v2/secure-payments` request *can* pass a different `destinationId`. RN doesn't reject divergent destinations across requests from the same client. - Each `POST /v2/secure-payments` request *can* pass a different `destinationId`. RN doesn't reject divergent destinations across requests from the same client. **Acceptance probe still pending — see remaining work.**
- `paymentReference` is derived per request (`last8Bytes(keccak256(requestId+salt+destination))`), so different destinations naturally produce different on-chain refs. The webhook listener keys on the ref + tx hash. - `paymentReference` is derived per request (`last8Bytes(keccak256(requestId+salt+destination))`), so different destinations naturally produce different on-chain refs. The webhook listener keys on the ref + tx hash.
### Open questions to settle before code ### Decisions taken during implementation
1. **Key custody model.** Options: These were open questions in the original draft; the shipped implementation locked them in.
- **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. | Question | Decision |
- **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. | Key custody model | **HD wallet, derived addresses, reused per `(buyer, sellerOffer, chainId)` triple**. Backend holds only an xpub (`DERIVED_DESTINATION_XPUB`). The xpriv / master seed lives in KMS or Trezor (Task #11). |
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. | Granularity | **Per `(buyer, sellerOffer, chainId)` triple**, reused for repeat payments to the same offer (one address can fund and then be re-funded later — sweeps happen out-of-band). The PRD draft considered single-use rotation; reuse won for simpler audit and to avoid bloating the derivation tree. |
3. **Multi-seller cart UX.** The buyer signs N approve+pay pairs in sequence (one per Payment). Frontend MUST surface this clearly: | Derivation index allocation | Monotonic counter in a `counters` Mongo collection (`{_id: 'derived_destination_index', seq: <int>}`) updated atomically via `findByIdAndUpdate { $inc: { seq: 1 } }`. No re-derivation, no race window. |
- "You're paying 2 sellers for this order. Please confirm 4 transactions in your wallet." (2 approves + 2 pays) | Sweep strategy | **Cron-based** by default (`DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000`, i.e. 5 min) **plus manual admin trigger**. Both go through the same `sweepService` and the same Transaction Safety Provider checks. Auto-start on backend boot is not wired yet — admins start the cron via `POST /api/payment/derived-destinations/cron/start`. |
| Signing | **`DERIVED_DESTINATION_SWEEP_SIGNER=build-only`** in prod — the backend builds the sweep tx but doesn't sign it (Trezor flow in Task #11 will). For local dev, `DERIVED_DESTINATION_SWEEP_SIGNER=hot-key` plus `DERIVED_DESTINATION_XPRIV` lets the backend sign — DO NOT USE IN PROD. |
### Still open
1. **Multi-seller cart UX** — not built. Today's frontend assumes 1 Payment per checkout page. The PRD copy from the original draft still applies:
- "You're paying 2 sellers for this order. Please confirm 4 transactions in your wallet."
- Progress indicator: "Paid 1 of 2 sellers — continue?" - 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). - Mid-cart abandonment: the sellers who already received funds are settled; the rest stay pending. No special backend work — the existing Payment lifecycle covers it.
- Out of scope for v1: an atomic splitter contract that fans out in one buyer tx. 2. **Cold-payment recovery** — out of scope for v1. Buyer never types an address into our UI, so the only way funds land on a derived address without a Payment record is RN's webhook arriving for a payment we don't recognise. Manual support recovery is acceptable.
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. 3. **Live "RN accepts divergent destinations" probe** — has to happen on dev with two real paid intents to two different derived addresses, with the webhook firing correctly for both. Until this probe passes, treat the divergent-destination capability as an unverified assumption.
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 ### What landed in 2.6.42
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. - **Backend (`backend/src/services/payment/wallets/`):**
2. New `Setting` doc with key `rn_derivation_next_idx` tracking the monotonic derivation counter (atomic `findOneAndUpdate $inc`). - `derivedDestinations.ts``getDestinationFor({ buyerId, sellerOfferId, chainId })` returning `{ address, derivationPath, derivationIndex, chainId }`. Idempotent: checks for an existing row first; on E11000 from a race, re-reads the racer's row. Validates the env xpub rejects xpriv / tprv prefixes. Exposes `resolveExpectedRecipientForPayment` for the Transaction Safety Provider's verification path.
3. Migration on `Payment` schema to add `metadata.derivedDestination` (address + derivation path + chainId snapshot). - `sweepService.ts` — pluggable signer abstraction (`build-only` / `hot-key`), ERC-20 `balanceOf` reads via Alchemy/public RPC, sweep orchestration, and an interval-based cron.
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`). - `derivedDestinationRoutes.ts` — admin-only REST: `GET /` (list with status/chain/address filters and pagination), `POST /sweep` (sweep all eligible), `POST /:id/sweep` (per-row), `GET /config/health`, `POST /cron/start`, `POST /cron/stop`, `GET /cron/status`.
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`. - **Backend integrations:**
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. - `models/DerivedDestination.ts` — Mongo model with sweep status + history. Indexed on `(buyerId, sellerOfferId, chainId)` unique.
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. - `models/Payment.ts``metadata.derivedDestination` snapshot field.
- `adapters/types.ts``CreatePayInIntentInput.merchantReference` for per-payment override.
- `requestNetwork/contract.ts``buildRequestNetworkMerchantReference` accepts `input.merchantReference` as highest priority.
- `requestNetwork/merchantReference.ts``buildMerchantReference()` inverse of the parser.
- `requestNetwork/requestNetworkPayInService.ts` — calls `getDestinationFor`, builds the per-payment merchant reference via `buildMerchantReference`, persists `metadata.derivedDestination` on the Payment, passes the override into `adapterResult` via `input.merchantReference`.
- `requestNetwork/inHouseCheckout.ts` — accepts `destinationOverride`; the on-chain-paymentReference compute-fallback now uses the actual recipient (was reading `parsed.recipient` — hidden bug because RN's response provides the ref directly, but the fallback path was broken for derived destinations).
- `safety/transactionSafetyProvider.ts``resolveExpectedRecipient` checks `payment.metadata.derivedDestination.address` first, then legacy fallback chain.
- `app.ts` — mounts `/api/payment/derived-destinations`.
- **Frontend (`frontend/src/sections/admin/derived-destinations/`, `frontend/src/app/dashboard/admin/derived-destinations/`):**
- List view with filters (status, chain, address search), pagination, sweep-all, cron start/stop.
- Per-row UI: address with copy + BscScan link, status chip, derivation path, balance, sweep count, last sweep tx link, per-row sweep action.
- **Env additions** (see `backend/.env.example`): `DERIVED_DESTINATION_XPUB` (required), `DERIVED_DESTINATION_XPRIV` (dev only), `DERIVED_DESTINATION_BASE_PATH=m/44'/60'/0'`, `DERIVED_DESTINATION_CHAIN_ID=56`, `DERIVED_DESTINATION_SWEEP_SIGNER=build-only`, `DERIVED_DESTINATION_MIN_SWEEP_AMOUNT=0`, `DERIVED_DESTINATION_SWEEP_INTERVAL_MS=300000`.
### Non-goals ### Remaining work for Task #7 (kimi)
| # | What | Where | Notes |
|---|------|-------|-------|
| A | **Cart-aware buyer UX** on the in-house checkout page. | `frontend/src/sections/payment/checkout/rn-in-house-checkout-view.tsx` (and `provider-payment.tsx` for the entry flow). | Today the button calls `createRequestNetworkIntent` once per cart and pushes to `/checkout/request-network/<paymentId>`. For multi-seller carts, the entry needs to: (a) walk each `sellerOfferId` in the cart and create N intents sequentially, (b) stash all N intent responses in `sessionStorage`, (c) navigate to a new wrapper page or extend the current page to iterate through them. Surface a clear header ("N approvals required from 2 sellers") and per-Payment progress. Mid-cart abandonment must leave the already-paid Payments settled and the rest in `pending`. |
| B | **Unit tests** for the new modules. | `backend/__tests__/derived-destinations.test.ts` + `backend/__tests__/sweep-service.test.ts`. | Minimum: `getDestinationFor` idempotency, E11000 race fallback, xpub rejection of xpriv/tprv, `deriveAddressAtIndex` determinism, `recordSweep` idempotency (re-running on a swept row is a no-op — currently `$setOnInsert` on `totalSwept` looks suspicious for an `$inc` style accumulation; please verify it actually accumulates and add a test). |
| C | **Live divergent-destination probe** on dev. | Manual test, no code. | Run two paid intents on the in-house page to two different `sellerOfferId`s (so two different derived addresses), confirm both `TransferWithReferenceAndFee` events fire, both webhooks land, and both Payments transition to `completed`. Record the tx hashes in the handoff doc. |
| D | **Auto-start the sweep cron on boot** (optional but recommended). | `backend/src/app.ts` after the route mount, behind an env flag like `DERIVED_DESTINATION_SWEEP_AUTOSTART=true`. | Today admin has to click "start cron" after each redeploy. |
| E | **Fix `recordSweep` accumulation** (if test in B confirms the bug). | `backend/src/services/payment/wallets/derivedDestinations.ts`. | The current shape uses `$setOnInsert: { totalSwept: amount }` inside `findByIdAndUpdate`, which only writes on first insert. For an existing row this means `totalSwept` never advances. Probably wants `$inc: { totalSwept: amount }` (with `totalSwept` as a string-encoded bigint or normalized to a Decimal128 field). |
| F | **Update Activity Log + API Reference doc** in `nick-doc/` with the new admin endpoints. | `nick-doc/03 - API Reference/...`, `nick-doc/00 - Overview/Activity Log.md` if it exists. | Endpoints: `GET /api/payment/derived-destinations`, `POST /api/payment/derived-destinations/sweep`, `POST /api/payment/derived-destinations/:id/sweep`, `GET /api/payment/derived-destinations/config/health`, `POST /api/payment/derived-destinations/cron/start`, `POST /api/payment/derived-destinations/cron/stop`, `GET /api/payment/derived-destinations/cron/status`. All admin-only. |
### Non-goals (carried forward unchanged)
- Multi-chain destinations (covered in Task #8). - Multi-chain destinations (covered in Task #8).
- Buyer-side ephemeral keys (covered in `Request Network Integration Constraints.md` §3, separate PRD). - Buyer-side ephemeral keys (covered in `Request Network Integration Constraints.md` §3, separate PRD).
- Hardware-wallet-signed sweeps (covered in Task #11). - Hardware-wallet-signed sweeps (covered in Task #11 — task #7 ships the `build-only` plumbing that Task #11 plugs into).
### Acceptance criteria ### Acceptance criteria
1. Two payments from the same buyer to two different sellers land on two different addresses on-chain. 1. Two payments from the same buyer to two different sellers land on two different addresses on-chain. (Backend logic shipped; verify live in remaining work item C.)
2. RN's webhook fires correctly for both, regardless of the destination divergence. 2. RN's webhook fires correctly for both, regardless of the destination divergence. (Pending C.)
3. Sweep runs idempotently — re-running it on an already-swept address is a no-op. 3. Sweep runs idempotently — re-running it on an already-swept address is a no-op. (Needs test from item B.)
4. Admin UI shows the address, its balance, last sweep tx (link to BscScan), and current ownership status. 4. Admin UI shows the address, its balance, last sweep tx (link to BscScan), and current ownership status.
5. Master seed never leaves the KMS/secret store. Backend reads derivation paths only; signs sweep txes via KMS API. 5. Master seed never leaves the KMS/secret store. Backend reads derivation paths from xpub only; production signing path is `build-only` (Task #11 Trezor). Dev hot-key is documented as dev-only.
6. ⏳ Multi-seller cart UX completes N approve+pay pairs sequentially with clear progress UI. (Pending A.)
--- ---