diff --git a/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md b/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md index 05ff979..8437415 100644 --- a/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md +++ b/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md @@ -1,21 +1,23 @@ # PRD: Wallet, Multichain, Confirmations, AML, Trezor -> Status: **Draft — 2026-05-28** -> Author: nick + claude (after in-house RN checkout shipped on dev 2.6.38/2.6.41) +> Status: **Living — last edit 2026-05-28 (after Task #7 core shipped in backend/frontend 2.6.42)** +> Author: nick + claude > 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`. --- -## 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 -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 -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. +### Goal (achieved on the backend) +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 @@ -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. - 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: `
@eip155:#:`. 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. -### Open questions to settle before code -1. **Key custody model.** Options: - - **Deterministic HD wallet** rooted at one Amanat master seed; derive per-Payment path (e.g. `m/44'/60'/0'/`). 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) +### Decisions taken during implementation +These were open questions in the original draft; the shipped implementation locked them in. + +| Question | Decision | +|---|---| +| 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). | +| 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. | +| Derivation index allocation | Monotonic counter in a `counters` Mongo collection (`{_id: 'derived_destination_index', seq: }`) updated atomically via `findByIdAndUpdate { $inc: { seq: 1 } }`. No re-derivation, no race window. | +| 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?" - - 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. + - 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. +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. +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. -### Scope -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. +### What landed in 2.6.42 +- **Backend (`backend/src/services/payment/wallets/`):** + - `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. + - `sweepService.ts` — pluggable signer abstraction (`build-only` / `hot-key`), ERC-20 `balanceOf` reads via Alchemy/public RPC, sweep orchestration, and an interval-based cron. + - `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`. +- **Backend integrations:** + - `models/DerivedDestination.ts` — Mongo model with sweep status + history. Indexed on `(buyerId, sellerOfferId, chainId)` unique. + - `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/`. 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). - 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 -1. Two payments from the same buyer to two different sellers land on two different addresses on-chain. -2. RN's webhook fires correctly for both, regardless of the destination divergence. -3. Sweep runs idempotently — re-running it on an already-swept address is a no-op. -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. +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. (Pending C.) +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. +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.) ---