- Add Handoff - RN Multichain Probe - 2026-05-28.md - Update Handoff - Request Network In-House Checkout with Task #8 status - Update Activity Log with backend@ae17b18, frontend@0ebb2f1 - Update PRD §2 acceptance criteria for Task #8 - Update Payment API.md with /api/admin/rn/networks endpoints
24 KiB
PRD: Wallet, Multichain, Confirmations, AML, Trezor
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,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-(buyer, sellerOffer) ephemeral destination wallets — Task #7
Status: 🟡 In progress — backend + admin UI landed in 2.6.42; cart-aware UX (A), auto-start cron (D), and recordSweep accumulation fix (E) shipped; doc updates (F) in progress; unit tests (B) pending. Live divergent-destination probe (C) still pending.
Problem
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 (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
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.transferFromWithReferenceAndFeeis 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.
Hard-known facts (from RN docs we've cold-inspected so far)
- The "destination" in RN is the
destinationIdinside 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-paymentsrequest can pass a differentdestinationId. RN doesn't reject divergent destinations across requests from the same client. Acceptance probe still pending — see remaining work. paymentReferenceis 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.
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: <int>}) 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
- 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?"
- 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.
- 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.
- 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.
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. ExposesresolveExpectedRecipientForPaymentfor the Transaction Safety Provider's verification path.sweepService.ts— pluggable signer abstraction (build-only/hot-key), ERC-20balanceOfreads 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.derivedDestinationsnapshot field.adapters/types.ts—CreatePayInIntentInput.merchantReferencefor per-payment override.requestNetwork/contract.ts—buildRequestNetworkMerchantReferenceacceptsinput.merchantReferenceas highest priority.requestNetwork/merchantReference.ts—buildMerchantReference()inverse of the parser.requestNetwork/requestNetworkPayInService.ts— callsgetDestinationFor, builds the per-payment merchant reference viabuildMerchantReference, persistsmetadata.derivedDestinationon the Payment, passes the override intoadapterResultviainput.merchantReference.requestNetwork/inHouseCheckout.ts— acceptsdestinationOverride; the on-chain-paymentReference compute-fallback now uses the actual recipient (was readingparsed.recipient— hidden bug because RN's response provides the ref directly, but the fallback path was broken for derived destinations).safety/transactionSafetyProvider.ts—resolveExpectedRecipientcheckspayment.metadata.derivedDestination.addressfirst, 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.
Remaining work for Task #7 (kimi)
| # | What | Where | Status | 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). |
✅ Done | Entry walks each sellerOfferId in the cart, creates N intents sequentially, stashes them in sessionStorage, and the checkout view iterates with per-Payment progress and an "N of M sellers" header. Mid-cart abandonment leaves 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 + backend/__tests__/request-template-orphan-cleanup.test.ts. |
✅ Done | 46 tests: getDestinationFor idempotency, E11000 race fallback, xpub rejection of xpriv/tprv, deriveAddressAtIndex determinism, recordSweep accumulation (regression lock-in for E), orphan-cleanup provider filtering (regression lock-in for Gap 2 fix). |
| C | Live divergent-destination probe on dev. | Manual test, no code. | ⏳ Pending | Run two paid intents on the in-house page to two different sellerOfferIds (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. | backend/src/app.ts after the route mount, behind DERIVED_DESTINATION_SWEEP_AUTOSTART=true. |
✅ Done | Cron now starts on boot when the env flag is set; admin endpoint still available for manual control. |
| E | Fix recordSweep accumulation. |
backend/src/services/payment/wallets/derivedDestinations.ts. |
✅ Done | Switched from $setOnInsert: { totalSwept } to $inc: { totalSwept } so accumulation advances on every sweep. |
| 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. |
🟡 In progress | 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 — task #7 ships the
build-onlyplumbing that Task #11 plugs into).
Acceptance criteria
- ✅ Two payments from the same buyer to two different sellers land on two different addresses on-chain. (Backend logic shipped; frontend multi-checkout UX shipped; live verification pending item C.)
- ✅ RN's webhook fires correctly for both, regardless of the destination divergence. (Backend integration + multi-checkout UX shipped; end-to-end verification pending item C.)
- 🟡 Sweep runs idempotently — re-running it on an already-swept address advances
totalSweptcorrectly. ($incfix shipped in E; lock-in test pending B.) - ✅ Admin UI shows the address, its balance, last sweep tx (link to BscScan), and current ownership status.
- ✅ 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. - ✅ Multi-seller cart UX completes N approve+pay pairs sequentially with clear progress UI.
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
ERC20FeeProxyaddress 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
acceptedChainsconfig; out of scope here — seeRequest Network Integration Constraints.md§2).
Hard-known facts
- RN published their canonical
ERC20FeeProxyfor BSC + Arbitrum as0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9(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
- Probe script
backend/scripts/probe-rn-chains.tsthat walks every chain insupported-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. - Promote
tokens.tsto load from a JSON file + override layer for admin-managed entries. Keep the canonical defaults committed. - Backend route
GET /api/admin/rn/networksreturning the registry plus probe status. - Frontend admin page
/dashboard/admin/networksrendering the table. buildInHouseCheckoutBlockconsults the chain registry; returnsreason='unsupported_chain:<chainId>'cleanly.- Add the USDT-mainnet
approve(0)quirk to the frontend approve step. When detected, the approve flow doesapprove(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
- ✅ Probe script confirms RN proxy address on BSC, Arbitrum, Polygon, Ethereum. Base canonical address has no code — documented in 08 - Operations/Handoff - RN Multichain Probe - 2026-05-28.
- ✅ Token registry has entries for USDC + USDT on 4 verified chains (BSC, Arbitrum, Ethereum, Polygon) with correct decimals. Base entries removed pending proxy address discovery.
- 🔄 In-house checkout supports USDT on BSC end-to-end — pending paid probe (needs real wallet + test BSC USDT on dev).
- 🟡 USDT-mainnet
approve(0)reset is handled in the UI — implemented but unverified (see handoff doc for Tenderly fork vs wait-for-usage decision). - ✅ Admin networks page renders the registry with a reload-from-disk 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
- Persist per-chain confirmation thresholds in a
ConfigKVcollection (or extend an existing settings model) so admin can adjust without redeploy. - The
TransactionSafetyProvider's confirmation check reads the runtime threshold first, falls back to env. - Frontend admin UI to view + edit per-chain thresholds, and a "payments awaiting confirmation" table that shows
confirmations / thresholdupdated live.
Scope
- New collection / extension to existing
Settingmodel:{ key: 'confirmation_threshold:<chainId>', value: <int>, updatedBy, updatedAt }. - Public-to-admin endpoint
GET /api/admin/settings/confirmation-thresholdsandPATCH /api/admin/settings/confirmation-thresholds/:chainId. - Wire
transactionSafetyProviderto read from this store; cache for 30s to avoid Mongo hammering. - 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".
- Frontend admin page
/dashboard/admin/payments/awaiting-confirmation:- Lists payments with
escrowState !== 'funded'andmetadata.transactionSafety.lastCheck.status === 'pending'. - For each: tx hash (linked to explorer), current confirmations (poll-driven), threshold, ETA.
- Lists payments with
Non-goals
- Per-asset thresholds (only per-chain).
- Per-seller overrides.
Acceptance criteria
- 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.
- Awaiting-confirmation table updates live as new blocks arrive (poll every 12s on BSC).
- 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
- Pick a provider (Chainalysis Address Screening, TRM Labs, Elliptic — open question). Default recommendation: Chainalysis Address Screening (cheapest, simplest API).
- Per-offer setting:
requireAmlCheck: true|falseplusamlCheckPricePaidBy: 'seller'. - 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_screeningcheck. - 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.10–0.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
- Provider choice. Need a 1-page comparison: per-check cost, response latency, supported chains, sanctions-coverage scope.
- 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.
- Cost accounting. Per-check cost is small; do we round up to nearest cent, batch by day, or pass-through exact?
Scope
- Add
requireAmlCheck+amlBlockOnFailurefields to theOfferschema (orSellerOffer). - New
backend/src/services/payment/safety/amlProvider.tsinterface +chainalysisProvider.tsimpl. Behind env flagTRANSACTION_SAFETY_AML_PROVIDER=chainalysis. - Transaction Safety Provider's
aml_screeningcheck now real, withmetadata.amlResultpersisted on the Payment record for audit. - Cost accounting: deduct per-check cost from seller's escrow on payment completion; surface this as a line item on the payment-details view.
- Frontend offer-edit UI: a toggle "Require AML on incoming payments (cost: $X per payment, paid by you)".
- 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
- A seller can opt into AML on a specific offer. Toggle persists.
- An incoming payment to that offer triggers a real Chainalysis API call; the result is stored on the Payment record.
- Verdict
sanctionsblocks the escrow gate; verdictcleanlets it through. - The seller's settled amount is reduced by the AML check cost; a corresponding ledger entry is created.
- Admin can rotate the Chainalysis API key without redeploy.
- If the provider is down and
amlBlockOnFailure=true, the payment stays in pending; aprovider_unavailablereason 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:
- Admin connects a Trezor via WebUSB in the admin dashboard.
- 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.
- The backend never has the private key. The Trezor seed never touches a network.
Hard-known facts
@trezor/connect-webis 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
- 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.
- Trezor model. Trezor One vs Model T have different signing UX. We target both; the lib abstracts it.
- 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
- New module
frontend/src/web3/trezor/trezorConnector.tswrapping@trezor/connect-web. - Admin actions (release/refund/sweep) get a "Sign with Trezor" button that:
- Hits backend
POST /api/admin/actions/build-txreturning unsigned tx bytes. - Sends to Trezor for signing.
- Submits signed tx via wagmi
sendTransaction. - Calls
POST /api/admin/actions/confirm-txwith the tx hash.
- Hits backend
- Backend supports both
confirmReleaseTxflow (existing) and the new build-tx pattern. - Admin settings page to "register Trezor": stores the Trezor's address(es) so backend can reject signatures from unauthorized devices.
- 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
- Admin can register a Trezor address; subsequent admin actions show "sign with Trezor" CTA.
- End-to-end release of escrow: build → Trezor approves → tx broadcast → backend confirms.
- If Trezor is unregistered or admin tries to sign with a different device, the backend rejects the confirm step.
- Audit log entries include admin user, Trezor address, tx hash, action, before/after escrow state.
- 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.