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:
@@ -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.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user