Files
nick-doc/PRD - Wallet, Multichain, Confirmations, AML, Trezor.md
Siavash Sameni 31dd475b73 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>
2026-05-28 16:05:50 +04:00

19 KiB
Raw Blame History

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) Owner: backend (payments) + frontend (admin UI + checkout) Related: PRD - Request Network In-House Checkout.md, 01 - Architecture/Request Network Integration Constraints.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

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 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.
  • Each POST /v2/secure-payments request can pass a different destinationId. RN doesn't reject divergent destinations across requests from the same client.
  • 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'/<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 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).
  • Buyer-side ephemeral keys (covered in Request Network Integration Constraints.md §3, separate PRD).
  • Hardware-wallet-signed sweeps (covered in Task #11).

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.

2. Multi-chain RN proxy registry + USDC/USDT support — Task #8

Problem

backend/src/services/payment/requestNetwork/proxyAddresses.ts and tokens.ts currently hardcode BSC USDC plus a handful of token entries. The in-house checkout will fall through to "in-house checkout not available" the moment a buyer wants to pay on Arbitrum/Polygon/Ethereum, or wants USDT instead of USDC on the same chain.

Goal

Verified-from-chain registry of:

  • RN's ERC20FeeProxy address per supported chain.
  • USDC + USDT contract address + decimals per chain.

Plus an admin "supported networks" UI that:

  • Lists each chain + token combo with its current status (verified-on-chain / probe failed / disabled).
  • Shows which chains a given seller has whitelisted (depends on per-seller acceptedChains config; out of scope here — see Request Network Integration Constraints.md §2).

Hard-known facts

  • RN published their canonical ERC20FeeProxy for BSC + Arbitrum as 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 (CREATE2 deterministic). Same address on Ethereum mainnet, Polygon, Base per the deterministic deployment claim — needs verifying.
  • BSC USDC has 18 decimals (Binance-Peg). Mainnet/Arb/Polygon USDC have 6.
  • USDT on Ethereum requires approve(0) before a non-zero re-approve. Other chains' USDT don't.

Scope

  1. Probe script backend/scripts/probe-rn-chains.ts that walks every chain in supported-chains.json, calls a known view fn on the candidate proxy address, and confirms it's a real RN proxy (not just bytes). Emits a report.
  2. Promote tokens.ts to load from a JSON file + override layer for admin-managed entries. Keep the canonical defaults committed.
  3. Backend route GET /api/admin/rn/networks returning the registry plus probe status.
  4. Frontend admin page /dashboard/admin/networks rendering the table.
  5. buildInHouseCheckoutBlock consults the chain registry; returns reason='unsupported_chain:<chainId>' cleanly.
  6. Add the USDT-mainnet approve(0) quirk to the frontend approve step. When detected, the approve flow does approve(spender, 0)approve(spender, amount).

Non-goals

  • Letting a seller pick which chain a given buyer can use (separate PRD, §2 in constraints doc).
  • Cross-chain bridging.

Acceptance criteria

  1. Probe script run on dev confirms RN proxy address on at least BSC, Arbitrum, Polygon, Ethereum, Base. Differences are documented.
  2. Token registry has entries for USDC + USDT on those 5 chains, with correct decimals.
  3. In-house checkout supports USDT on BSC end-to-end (a paid probe).
  4. USDT-mainnet approve(0) reset is handled in the UI when needed.
  5. Admin networks page renders the registry with a per-row "probe again" button.

3. Confirmation-counting + admin threshold UI — Task #9

Problem

The Transaction Safety Provider already reads TRANSACTION_SAFETY_MIN_CONFIRMATIONS from env (default 12). That number is global, baked into env, only changeable by re-deploy. We want it tunable per-chain at runtime by an admin, with a clear UI showing each in-flight payment's current confirmation depth against the threshold.

Goal

  1. Persist per-chain confirmation thresholds in a ConfigKV collection (or extend an existing settings model) so admin can adjust without redeploy.
  2. The TransactionSafetyProvider's confirmation check reads the runtime threshold first, falls back to env.
  3. Frontend admin UI to view + edit per-chain thresholds, and a "payments awaiting confirmation" table that shows confirmations / threshold updated live.

Scope

  1. New collection / extension to existing Setting model: { key: 'confirmation_threshold:<chainId>', value: <int>, updatedBy, updatedAt }.
  2. Public-to-admin endpoint GET /api/admin/settings/confirmation-thresholds and PATCH /api/admin/settings/confirmation-thresholds/:chainId.
  3. Wire transactionSafetyProvider to read from this store; cache for 30s to avoid Mongo hammering.
  4. Frontend admin page /dashboard/admin/confirmation-thresholds:
    • Table of chains, current threshold, recommended default (e.g. 12 for BSC).
    • Edit-in-place with a confirm dialog.
    • Audit-log style "changed by X on Y at Z".
  5. Frontend admin page /dashboard/admin/payments/awaiting-confirmation:
    • Lists payments with escrowState !== 'funded' and metadata.transactionSafety.lastCheck.status === 'pending'.
    • For each: tx hash (linked to explorer), current confirmations (poll-driven), threshold, ETA.

Non-goals

  • Per-asset thresholds (only per-chain).
  • Per-seller overrides.

Acceptance criteria

  1. Admin can lower BSC's threshold from 12 to 3 on the live dev stack without a redeploy and a new webhook fires the safety gate with the new value within 30s.
  2. Awaiting-confirmation table updates live as new blocks arrive (poll every 12s on BSC).
  3. Audit log records every threshold change with admin user, before/after, timestamp.

4. Optional AML screening (seller-paid) — Task #10

Problem

The Transaction Safety Provider has an evaluateAmlPlaceholder() stub that currently returns status: skipped. We want a real AML pass that the seller can opt into per-offer, with the seller covering the per-check API cost.

Goal

  1. Pick a provider (Chainalysis Address Screening, TRM Labs, Elliptic — open question). Default recommendation: Chainalysis Address Screening (cheapest, simplest API).
  2. Per-offer setting: requireAmlCheck: true|false plus amlCheckPricePaidBy: 'seller'.
  3. When AML required, the in-house checkout webhook hits the provider with the buyer's source address. Result feeds into Transaction Safety Provider as a real aml_screening check.
  4. Seller's account is debited for the per-check cost from their Amanat balance (or the payment is partially withheld for the cost).

Hard-known facts

  • AML providers charge per-check (USD 0.100.50 typical). Some charge a flat monthly minimum.
  • Chainalysis returns categories like sanctions, darknet_marketplace, mixer. Provider doesn't return PII.
  • Most providers have rate limits (~50 rps).

Open questions

  1. Provider choice. Need a 1-page comparison: per-check cost, response latency, supported chains, sanctions-coverage scope.
  2. Failure mode. If the AML API is down, do we pass-through (let the payment complete) or fail-closed (block)? Recommended: fail-closed only when seller explicitly opted in and enabled "block-on-provider-failure". Otherwise warn + log.
  3. Cost accounting. Per-check cost is small; do we round up to nearest cent, batch by day, or pass-through exact?

Scope

  1. Add requireAmlCheck + amlBlockOnFailure fields to the Offer schema (or SellerOffer).
  2. New backend/src/services/payment/safety/amlProvider.ts interface + chainalysisProvider.ts impl. Behind env flag TRANSACTION_SAFETY_AML_PROVIDER=chainalysis.
  3. Transaction Safety Provider's aml_screening check now real, with metadata.amlResult persisted on the Payment record for audit.
  4. Cost accounting: deduct per-check cost from seller's escrow on payment completion; surface this as a line item on the payment-details view.
  5. Frontend offer-edit UI: a toggle "Require AML on incoming payments (cost: $X per payment, paid by you)".
  6. Frontend admin UI for global AML provider configuration (provider, API key, per-chain enabled/disabled).

Non-goals

  • Buyer-side AML (we screen the buyer's source address, not the seller's identity).
  • Custom AML rules / scoring beyond the provider's verdict.

Acceptance criteria

  1. A seller can opt into AML on a specific offer. Toggle persists.
  2. An incoming payment to that offer triggers a real Chainalysis API call; the result is stored on the Payment record.
  3. Verdict sanctions blocks the escrow gate; verdict clean lets it through.
  4. The seller's settled amount is reduced by the AML check cost; a corresponding ledger entry is created.
  5. Admin can rotate the Chainalysis API key without redeploy.
  6. If the provider is down and amlBlockOnFailure=true, the payment stays in pending; a provider_unavailable reason surfaces in the admin dashboard.

5. Trezor support for admin signing — Task #11

Problem

Today, admin actions that require signing (escrow release, refund, sweep of derived destinations once Task #7 ships) run from a hot-key in the backend env (ADMIN_PRIVATE_KEY or similar). That key is a single-point-of-compromise for all custodial funds.

Goal

Replace the hot-key flow with a Trezor-mediated browser flow:

  1. Admin connects a Trezor via WebUSB in the admin dashboard.
  2. Admin approves an action in the UI; the unsigned transaction is built backend-side, sent to the browser, signed by the Trezor, broadcast from the browser.
  3. The backend never has the private key. The Trezor seed never touches a network.

Hard-known facts

  • @trezor/connect-web is the maintained library for Trezor in browser. EIP-1193-compatible adapter exists.
  • Trezor supports EVM signing for any chain; chain ID is part of the tx.
  • WebUSB is Chromium-only. Firefox users need the Trezor Bridge native helper.

Open questions

  1. Multi-admin. If two admins both have a Trezor configured, do they both need to sign (m-of-n), or any one of them? Default: any one of them; m-of-n is out of scope here.
  2. Trezor model. Trezor One vs Model T have different signing UX. We target both; the lib abstracts it.
  3. Fallback. What if Trezor is unavailable when an urgent release is needed? Default: a "break-glass" hot-key path that admin can flip on for a 1-hour window, alarms blast Telegram.

Scope

  1. New module frontend/src/web3/trezor/trezorConnector.ts wrapping @trezor/connect-web.
  2. Admin actions (release/refund/sweep) get a "Sign with Trezor" button that:
    • Hits backend POST /api/admin/actions/build-tx returning unsigned tx bytes.
    • Sends to Trezor for signing.
    • Submits signed tx via wagmi sendTransaction.
    • Calls POST /api/admin/actions/confirm-tx with the tx hash.
  3. Backend supports both confirmReleaseTx flow (existing) and the new build-tx pattern.
  4. Admin settings page to "register Trezor": stores the Trezor's address(es) so backend can reject signatures from unauthorized devices.
  5. Audit log on every Trezor-signed action.

Non-goals

  • Multi-sig contracts (Safe etc.) — separate decision.
  • Buyer-side Trezor (buyer already uses their own wallet via wagmi injected()).
  • Mobile Trezor flow (desktop only for v1).

Acceptance criteria

  1. Admin can register a Trezor address; subsequent admin actions show "sign with Trezor" CTA.
  2. End-to-end release of escrow: build → Trezor approves → tx broadcast → backend confirms.
  3. If Trezor is unregistered or admin tries to sign with a different device, the backend rejects the confirm step.
  4. Audit log entries include admin user, Trezor address, tx hash, action, before/after escrow state.
  5. Break-glass hot-key path requires an explicit admin toggle, expires after 1h, fires a Telegram alarm.

Shared dependencies / order-of-operations

  • Task #8 (multichain) and Task #9 (confirmations) are independent and can land in parallel.
  • Task #7 (ephemeral wallets) depends on Task #11 (Trezor) only for the sweep step — the address-generation half can ship first, sweep can land later with hot-key, then migrate to Trezor when #11 is done.
  • Task #10 (AML) depends on nothing from the other four. It plugs into the existing Transaction Safety Provider.
  • Task #11 (Trezor) is self-contained.

Tasks were sized for one experienced contributor to take any single one end-to-end without needing the others to land first. The integration glue (UI placement, navigation, telemetry) is left for the maintainer.