Files
nick-doc/.taskmaster/docs/prd-request-network-in-house-checkout.md
Siavash Sameni 0060b16912 docs: ship in-house RN checkout, scope 5 follow-up tasks (#7-11)
In-house Request Network checkout went fully end-to-end on dev today.
A real 0.01 USDC payment flowed through wallet connect -> approve ->
ERC20FeeProxy.transferFromWithReferenceAndFee -> RN webhook ->
TransactionSafetyProvider -> Payment.status=completed -> page success
state. Tx 0x494c77a29161b5100d8e0b1ac675f1822955d0bb3633ecdbfafb886f84f2f320.

Docs:
- New PRD: Wallet, Multichain, Confirmations, AML, Trezor
  (5 follow-ups, each sized for an independent contributor)
- Updated PRD: Request Network In-House Checkout (phases 0..3 done,
  phase 4 partial, phases 5-6 not started)
- Updated handoff: deployed versions, what is working end-to-end,
  follow-up tasks index

Taskmaster: 5 new top-level tasks (#7..#11) covering ephemeral
destination wallets, multichain proxy registry + USDC/USDT, runtime
confirmation thresholds, optional seller-paid AML screening, and
Trezor signing for admin actions. Tasks are scoped fine-grained so
each is independent enough for kimi to pick up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:50:24 +04:00

281 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PRD: Request Network In-House Checkout
**Status:** Draft — updated after 2026-05-28 dev webhook probe
**Date:** 2026-05-27
**Related:** `01 - Architecture/Request Network Integration Constraints.md` §1; `PRD - Request Network Migration and Funds Management.md`
**Owner:** Backend payments + Frontend
---
## 1. Problem
Buyers paying through Request Network (RN) are redirected from Amanat to RN's hosted page at `pay.request.network/?token=…`. That page has three concrete problems:
1. **Rabby is not supported.** A meaningful portion of our user base pays from Rabby. The hosted page does not detect it (no EIP-6963 enumeration, hard-coded MetaMask/WalletConnect/Coinbase). For those users, checkout fails before a transaction is even attempted.
2. **The page is brand-divergent.** Buyers leave the Amanat domain mid-flow, which is a trust and conversion cost we can avoid.
3. **The page relies on RN's choice of infrastructure.** Observed in our own probe: their UI wraps the payment in Safe smart accounts + ERC-4337 + a Pimlico paymaster, hitting public BSC RPCs that already rate-limited us once ("RPC endpoint returned too many errors"). We have no control to fix that.
The core RN protocol is sound. The hosted UI on top of it is the problem.
### 2026-05-28 reality update
The first dev BSC probe did not fail because RN stayed silent. It failed because Amanat dropped the provider callback:
- Test transaction: `0x3a23febd9abd43d7e0851c1ea86c4ceaf08c11098852cb0425fa074e9c88350b`.
- On-chain result: successful BSC USDC transfer to Amanat's configured destination wallet.
- RN webhook result: RN called `POST /api/payment/request-network/webhook` on `dev.amn.gg` four times from `34.34.233.192`.
- Backend result: nginx/backend returned `404` because the handler did not correlate all Request Network identifiers stored on the `Payment` record.
- Frontend result: the callback page stayed on the processing state and later hit `429` from 3-second polling.
The pre-implementation gate therefore changes from "prove RN calls us" to "deploy and smoke-test the webhook correlation repair, callback polling repair, and transaction-safety gate before another paid probe".
## 2. Goal
Replace the redirect to RN's hosted page with an Amanat-rendered checkout that:
- Connects any EVM wallet (Rabby, MetaMask, OKX, Trust, WalletConnect — anything wagmi's `injected()` connector enumerates).
- Builds and submits the exact same on-chain calls RN's hosted page makes, so RN's existing webhook fires unchanged.
- Stays on `dev.amn.gg` (or prod equivalent) for the entire flow.
- Falls back gracefully to the hosted page for buyers who explicitly request it.
Settlement, webhook handling, and the backend `Payment` lifecycle do not change. Only the buyer-facing checkout surface changes.
Webhook delivery alone is not a sufficient trust signal. Amanat must run a **Transaction Safety Provider** gate before marking escrow funded. The initial provider checks: transaction hash present, configured confirmation depth, transfer recipient/token/amount match on-chain evidence, and future AML/sanctions provider approval.
## 3. Non-goals
- Per-seller multi-chain configuration (covered in `Request Network Integration Constraints.md` §2 — separate PRD).
- Per-`(buyer, merchant)` ephemeral wallets / wallet abstraction layer (§3 — separate PRD).
- Replacing RN entirely with our own chain watcher (§4 — depends on this PRD's outcome).
- Gasless / sponsored transactions for the buyer. Buyer pays own gas via their EOA. We can revisit paymaster integration in a follow-up.
## 4. Background: what RN's protocol actually requires
Cold-inspected from a real `$12` BSC payment on RN's hosted page:
| Item | Value |
|---|---|
| Proxy contract (BSC, and same address on most EVMs via deterministic CREATE2) | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` |
| Function | `transferFromWithReferenceAndFee(address token, address to, uint256 amount, bytes reference, uint256 feeAmount, address feeAddress)` |
| Selector | `0xc219a14d` |
| Emitted event | `TransferWithReferenceAndFee(token, to, amount, reference, feeAmount, feeAddress)` |
| `paymentReference` derivation | `last8Bytes(keccak256(lowercase(requestId + salt + destinationAddress)))` |
| Fee config seen on RN's UI | `feeAmount = 0`, `feeAddress = 0x…dEaD` |
RN's webhook listens for `TransferWithReferenceAndFee`. If we emit it ourselves with the correct `paymentReference`, the webhook fires. The hosted UI is a UI-only convenience — not a settlement requirement.
## 5. Proposed flow (buyer perspective)
1. Buyer clicks "Pay with Request Network" on the request page.
2. Frontend calls existing `POST /payment/request-network/intents` (unchanged endpoint, expanded response — see §6).
3. Frontend navigates to new in-house page: `/checkout/request-network/:paymentId`.
4. Page shows: amount, token, chain, recipient (truncated, copyable), countdown timer.
5. "Connect wallet" — wagmi `injected()` + `metaMask()` (already configured). Rabby works automatically via EIP-6963.
6. Once connected, page checks the buyer's wallet is on the correct chain. If not, prompts `switchChain` (wagmi).
7. Page also checks current allowance. If `allowance(buyer, RN_PROXY) >= amount`, skip approve.
8. Buyer signs **transaction 1: `USDC.approve(RN_PROXY, amount)`**. UI shows "Approving…" → "Approved".
9. Buyer signs **transaction 2: `RN_PROXY.transferFromWithReferenceAndFee(token, dest, amount, ref, 0, 0x…dEaD)`**. UI shows "Submitting payment…" → "Confirmed on-chain".
10. Page waits for backend `payment-confirmed` socket event (already emitted on RN webhook → `Payment.status='paid'`). Shows success state.
11. Redirect to existing payment-success route.
Escape hatches surfaced as secondary links on the page:
- **"Continue on Request Network's hosted page"** → falls through to `securePaymentUrl` (unchanged behavior).
- **"Copy payment details"** → exposes destination, amount, token, chain, paymentReference for manual entry into any wallet.
## 6. Backend changes
### 6.1 Intent response payload
Expand `PayInIntentResult` for RN. Today it returns `paymentUrl`, `paymentId`, `providerPaymentId`, `amount`, `config.networks`, `config.allowedTokens`, `raw`. Add:
```ts
inHouseCheckout: {
destination: '0x05E280d7f3cA954f37afA8B1E4d2a51D167c573e',
tokenAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d',
tokenSymbol: 'USDC',
decimals: 18, // BSC USDC quirk; varies per token+chain
chainId: 56,
proxyAddress: '0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9',
paymentReference: '0xa826e248f5de5463', // 8-byte hex, computed from RN's requestId + salt
feeAmount: '0',
feeAddress: '0x000000000000000000000000000000000000dEaD',
amountWei: '12000000000000000000', // pre-multiplied by decimals, frontend doesn't compute
}
```
### 6.2 Sources for each field
| Field | Source |
|---|---|
| `destination`, `tokenAddress`, `chainId` | Parse `REQUEST_NETWORK_MERCHANT_REFERENCE` env (`address@eip155:chainId#ref:tokenAddr`). Today done once at startup; extract into a helper. |
| `decimals` | Token registry lookup. Hardcode the few we care about (USDC/USDT × BSC/ETH/Arb/Polygon) in a constant map; fall back to on-chain `decimals()` if missing. |
| `proxyAddress` | Constant per chain. Use canonical `0x0DfbEe14…0aC9` for all EVMs unless RN deploys a non-canonical address (open question — see §10). |
| `paymentReference` | Computed from RN's `/v2/secure-payments` response. `salt` is in their `raw` payload. Use `keccak256` + slice-to-8-bytes. |
| `feeAmount`, `feeAddress` | Constants matching what RN's hosted UI submits. Verify via cold inspection on each new chain we enable. |
| `amountWei` | `BigInt(amount) * 10n ** BigInt(decimals)`. Done backend-side to avoid frontend rounding bugs. |
### 6.3 Files touched
- `backend/src/services/payment/requestNetwork/contract.ts` — extend `PayInIntentResult` (or a new `inHouseCheckout` sub-object).
- `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts` — populate the new fields.
- `backend/src/services/payment/requestNetwork/merchantReference.ts` (new) — parser for the `address@chain#ref:token` format. Currently this is implicit in env lookups; promote to a named helper.
- `backend/src/services/payment/requestNetwork/tokens.ts` (new) — `{ chainId, tokenAddr } → { symbol, decimals }` lookup.
- `backend/src/services/payment/requestNetwork/paymentReference.ts` (new) — `computePaymentReference(requestId, salt, destination)`.
- Unit tests for each new helper.
### 6.4 Webhook handler
The route remains `/api/payment/request-network/webhook`, but the handler must be hardened before more paid probes:
- Correlate against every RN identifier we persist: `providerPaymentId`, `metadata.requestNetworkRequestId`, `metadata.requestNetworkPaymentReference`, and nested `metadata.requestNetworkData` ids/references.
- Reject unsigned or test-header callbacks unless explicit test mode is enabled.
- Store raw webhook evidence on the `Payment` record for support/replay.
- Call the Transaction Safety Provider before marking `Payment.status='completed'` / `escrowState='funded'`.
- Return a successful pending response when safety is not yet satisfied, so the delivery is not lost but escrow is not credited.
## 7. Frontend changes
### 7.1 New page
`frontend/src/pages/checkout/request-network/[paymentId].tsx` (or whatever the router convention is).
State machine (one component, reducer or zustand):
```
idle
→ connecting (wallet not connected)
→ wrong-chain (connected, chain mismatch)
→ ready (correct chain, allowance unknown)
→ checking-allowance
→ needs-approve → approving → approve-confirming → ready-to-pay
→ ready-to-pay
→ paying → pay-confirming → confirmed
→ error (with retry)
→ expired (timer ran out)
```
### 7.2 Reusable parts from SHKeeper
`frontend/src/web3/components/manual-payment.tsx` already implements: address-with-QR, copy-to-clipboard, countdown timer, localStorage persistence per request, socket-driven status update. Reuse the layout chrome. The wallet-interaction half is new.
### 7.3 Wagmi calls
Two contracts. The ERC-20 ABI is already in viem/wagmi. RN proxy ABI is a single function — define it inline.
```ts
const RN_PROXY_ABI = [{
inputs: [
{ name: 'tokenAddress', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'paymentReference', type: 'bytes' },
{ name: 'feeAmount', type: 'uint256' },
{ name: 'feeAddress', type: 'address' },
],
name: 'transferFromWithReferenceAndFee',
outputs: [], stateMutability: 'nonpayable', type: 'function',
}] as const;
```
Hooks used:
- `useAccount`, `useChainId`, `useSwitchChain`
- `useReadContract` for `allowance`
- `useWriteContract` + `useWaitForTransactionReceipt` for approve and proxy call
### 7.4 Routing change
Today, `createRequestNetworkIntent` consumers do `window.location = response.paymentUrl`. Replace with `router.push('/checkout/request-network/' + response.paymentId)`. Keep `response.paymentUrl` available so the in-house page can render the escape-hatch link.
### 7.5 Files touched
- `frontend/src/pages/checkout/request-network/[paymentId].tsx` — new page.
- `frontend/src/web3/components/rn-in-house-checkout.tsx` — new component holding the state machine.
- `frontend/src/web3/contracts/rn-fee-proxy.ts` — ABI + address-per-chain constants.
- `frontend/src/sections/request/components/buyer-steps/…` — change the existing RN button handler from `window.location` to `router.push`.
- Tests/Storybook for the new component.
## 8. Acceptance criteria
1. A buyer using **Rabby** can complete an RN payment end-to-end on `dev.amn.gg` without leaving the domain.
2. The two on-chain transactions emit a `TransferWithReferenceAndFee` event with the same `paymentReference` RN's hosted page would have produced.
3. RN's existing webhook fires on the event and the `Payment` doc transitions to `completed` / `escrowState='funded'` after safety approval.
4. If buyer connects on the wrong chain, they see a one-click "Switch network" CTA.
5. If buyer already has sufficient allowance, the approve step is skipped.
6. If the buyer abandons the page, returning to it restores their state from localStorage and resumes from the correct step.
7. "Continue on Request Network's hosted page" link is visible on the in-house page and works exactly like today's redirect.
8. Page handles tx failure (user reject, insufficient gas, RPC error) with a clear retry path that does not corrupt the `Payment` doc.
9. Existing non-RN payment flows (SHKeeper, others) are untouched.
## 9. Out of scope (explicit non-decisions)
- Multi-chain at checkout (BSC + Arb + ETH together) — needs the per-seller `acceptedChains` config; separate PRD.
- WalletConnect integration — currently disabled in `web3/config.ts` for SSR reasons; not regressed but not added either.
- Migration plan to remove RN entirely — kept as `§4` open option in the architecture doc.
- Mobile Mini App rendering of the checkout (different surface — handled in Task 5.4).
## 10. Open questions for review
These are the items we should discuss with the second developer before starting implementation.
1. **Proxy address universality.** Is `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` the RN ERC20FeeProxy address on **every** chain we plan to support (BSC, Arb, ETH mainnet, Polygon, Base)? We confirmed BSC + Arbitrum from RN's payments-subgraph repo. The other chains need explicit confirmation — preferably an on-chain probe per chain.
2. **API pricing for hosted-UI vs API-only.** RN's pricing model may assume hosted-UI usage. If we use them as a notification primitive only, are we still in their pricing terms? Worth a direct question to RN account management.
3. **Approval UX.** RN's hosted UI uses `approve(spender, MAX_UINT256)` for gas efficiency. Some Amanat users have voiced concern in past audits about unbounded approvals. Options:
- Mirror RN: `MAX_UINT256` (best UX, repeat payments don't need re-approve).
- Exact-amount: `approve(spender, amount)`. Slightly worse UX (every payment requires an approve), better security posture.
- Recommend: exact-amount by default, opt-in "remember this approval" for power users. Discuss.
4. **Tokens with non-standard ERC-20 behavior.** USDT on Ethereum mainnet famously requires `approve(0)` before changing a non-zero allowance. We need to handle this if we add USDT-ETH later. Not relevant on day one (BSC config is USDC), but worth flagging now.
5. **Cancel and timeout semantics.** Today the `Payment` doc transitions on the webhook. If the buyer signs the approve but bails on the proxy call, the doc sits in `pending` forever. Options:
- 30-minute TTL on the `pending` doc (existing partial unique index would let a re-attempt succeed).
- "Cancel intent" button on the in-house page that calls a new backend endpoint to mark the doc `cancelled`.
- Both. Discuss.
6. **Manual reconciliation.** If a buyer pays the correct amount to the correct destination but bypasses the proxy (e.g., raw `transfer`), no event fires and no webhook arrives. They'll think they paid but our system won't know. Today RN's hosted UI prevents this; our in-house UI inherits the same risk only if we add a "raw transfer" escape hatch — which we won't. But: should we proactively detect orphan transfers (chain watcher) and surface them in support? Out of scope for v1.
7. **Feature flag and rollout.** Ship behind a flag and A/B against the current redirect for one week of dev usage? Or hard-cut once acceptance criteria pass? Recommend A/B for at least one full release cycle.
8. **Telemetry.** What events do we instrument so we can prove the in-house page outperforms the redirect on conversion? Suggested: `rn_checkout_opened`, `rn_wallet_connected`, `rn_approve_signed`, `rn_pay_signed`, `rn_pay_confirmed`, `rn_pay_failed{reason}`, `rn_escape_to_hosted_clicked`.
## 11. Risks
- **Webhook handling failure.** RN delivered the 2026-05-28 dev webhook, but Amanat returned `404`. A deployed correlation fix and smoke test are now the load-bearing gate before another paid probe.
- **Webhook durability.** The main app is too unstable to be the only callback landing zone. Put a Cloudflare Worker in front of Request Network webhooks to durably store raw delivery evidence, forward to the backend, and replay after outages.
- **False-positive payment credit.** A signed provider event must not be enough to fund escrow. Transaction Safety Provider gates completion on on-chain evidence, confirmation depth, transfer matching, and future AML/sanctions checks.
- **Chain-specific divergences.** RN may have deployed non-canonical proxy contracts on some chains. We mitigate by hard-coding per-chain proxy addresses and treating "unknown chain → fall back to hosted page" as the safe default.
- **Wallet support drift.** EIP-6963 is mature but not universal. We rely on wagmi's `injected()` connector enumeration; if a wallet doesn't implement EIP-6963, it falls into the generic `window.ethereum` slot. Acceptable.
- **Buyer signs approve, then we change ABI before they sign the proxy call.** Not really a risk if both txs are in the same session, but worth a smoke test on a long-lived session.
## 12. Implementation phases
Roughly two weeks of checkout work, now gated on confirmation reliability repair.
| Phase | Work | Gate |
|---|---|---|
| 0 — Probe | Fire one real dev BSC payment and inspect nginx/backend logs | Completed: RN webhook reached nginx/backend; app returned `404` |
| 0A — Confirmation repair | Deploy correlation fix, callback polling fix, signed-webhook smoke test, and Transaction Safety Provider | Unsigned/test callbacks rejected unless explicitly enabled; real webhook can find the `Payment` |
| 1 — Backend | Expand intent response with `inHouseCheckout` fields; add helpers and tests | Existing RN smoke test still passes; new unit tests pass |
| 2 — Frontend skeleton | New page + state machine + wallet-connect; no on-chain calls yet, mocked confirmations | Local clickthrough on `dev.amn.gg` with Rabby connected |
| 3 — On-chain wiring | Real `approve` + `transferFromWithReferenceAndFee` calls; allowance check; chain-switch | One real end-to-end payment on dev BSC, webhook fires, `Payment.status='completed'`, safety approved |
| 4 — Hardening | Error handling, timer, persistence, telemetry, escape-hatch link | Acceptance criteria 19 demonstrably pass |
| 5 — Durable ingress | Cloudflare Worker receives RN webhooks, stores delivery records, forwards to backend, supports replay | Backend outage no longer loses webhook evidence |
| 6 — Rollout | Behind feature flag, A/B in dev for one cycle, then prod | Conversion telemetry; no regressions in non-RN flows |
## 13. Durable webhook ingress roadmap
Add a Cloudflare Worker as the public Request Network webhook target:
1. Receive the raw RN webhook body, headers, request id, delivery id, source IP, and timestamp.
2. Verify the RN signature at the edge if raw-body secret handling is straightforward; otherwise store first and let the backend perform canonical verification.
3. Write an immutable delivery record to durable storage. Candidate stack: Cloudflare Queues for handoff plus D1/R2/KV for indexed replay metadata and raw payload retention.
4. Forward to the primary backend and optionally a secondary endpoint.
5. Return `2xx` only after durable enqueue/store succeeds.
6. Provide operator replay by delivery id, payment reference, request id, or time window.
The Worker is only ingress/evidence buffering. The backend remains the trust boundary for signature verification, idempotency, Transaction Safety Provider checks, ledger updates, and marketplace state transitions.
## 14. References
- Cold-inspected transaction (real `$12` BSC payment on RN hosted page): in conversation history, dated 2026-05-27.
- RN ERC20FeeProxy spec: <https://github.com/RequestNetwork/requestNetwork/blob/master/packages/advanced-logic/specs/payment-network-erc20-fee-proxy-contract-0.1.0.md>
- Arbitrum proxy deployment: <https://github.com/RequestNetwork/payments-subgraph/blob/main/subgraph.arbitrum-one.yaml>
- Architecture constraints doc: `01 - Architecture/Request Network Integration Constraints.md`
- Previous RN work: `PRD - Request Network Migration and Funds Management.md`