Files
nick-doc/PRD - Retire Request Network — In-House Payment Scanner.md

18 KiB
Raw Blame History

PRD — Retire Request Network: AMN Pay Scanner (Standalone Microservice)

Status: Ready for implementation Task: #13 (new) Priority: High Effort estimate: ~46 days (Rust/Go scanner service + Node.js adapter swap) Depends on: Task #8 (done), Task #9 (confirmation thresholds), Task #11 (Trezor sweep — parallel, not blocking)


Context

The platform currently uses Request Network (RN) as a payment infrastructure middleware. RN's API is called to:

  1. Create a payment request (returns a requestId + salt used to derive an on-chain paymentReference)
  2. Provide a hosted checkout page (already bypassed by our in-house checkout)
  3. Deliver a webhook callback when payment is detected on-chain

The underlying payment mechanism — the ERC20FeeProxy smart contract — is not proprietary to RN. It is open-source, deployed on all five supported chains (BSC, Arbitrum, Ethereum, Polygon, Base), and already integrated into our in-house checkout. We call it directly today for payment execution.

RN's critical constraint: it validates the payment destination against the registered merchant wallet (0x05E280...). This makes it impossible to route payments to HD-derived per-(buyer, seller) addresses through RN's API.

This PRD describes replacing RN with AMN Pay Scanner — a standalone, lightweight microservice that exposes the same provider interface as RN but without the destination restriction and without any external dependency.


Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                        Node.js Backend                          │
│                                                                 │
│  POST /api/payment/intents                                      │
│    → amnPayAdapter.createIntent(...)                            │
│         ↓  HTTP POST /intents                                   │
│  ┌──────────────────────────────┐                               │
│  │      AMN Pay Scanner         │  ← replaces RN API            │
│  │   (Rust or Go binary)        │                               │
│  │                              │                               │
│  │  • generates salt + ref      │                               │
│  │  • eth_getLogs per chain     │                               │
│  │  • any destination address   │                               │
│  │  • webhooks backend on match │                               │
│  └──────────────────────────────┘                               │
│         ↑  POST /webhook (confirmed)                            │
│    → payment.status = 'confirmed'                               │
│    → emit socket event to buyer                                 │
└─────────────────────────────────────────────────────────────────┘

The Node.js backend treats AMN Pay Scanner as just another payment provider. The existing adapter pattern (requestNetworkAdapter.ts) is replaced with amnPayAdapter.ts. Everything upstream (checkout UI, payment model, status machine, socket events) is unchanged.


What AMN Pay Scanner Is

A standalone HTTP service that:

  1. Accepts payment intentsPOST /intents with { chainId, tokenAddress, destination, amount, paymentReference?, callbackUrl }. Returns { intentId, paymentReference, checkoutBlock }.
  2. Generates paymentReference locally if not provided — last8bytes(keccak256(intentId + salt + destination)), identical to RN's formula.
  3. Scans chains — one async loop per chain, eth_getLogs on ERC20FeeProxy for TransferWithReferenceAndFee events matching pending intents.
  4. Delivers webhookPOST callbackUrl with { intentId, paymentReference, txHash, blockNumber, amount, token, chainId, status: "confirmed" } once confirmations ≥ threshold.
  5. Exposes status APIGET /intents/:intentId returns current state.
  6. Exposes health/admin APIGET /health, GET /scanner/status (per-chain: lastScannedBlock, chainHead, lag, pendingCount).

It holds its own state (SQLite or embedded key-value store — no MongoDB dependency). It is stateless from the backend's perspective: if it restarts, it replays from its checkpoint.


Language Choice: Rust or Go

Both are appropriate. The choice affects implementation speed vs long-term performance.

Go

  • Faster to write for a networked service (goroutines, net/http, encoding/json all stdlib)
  • Easier for a generalist agent to implement correctly
  • Good RPC JSON libraries available
  • Produces a small static binary (~5MB)
  • Recommended for first version

Rust

  • Marginally faster at sustained high-throughput block scanning (thousands of chains, millions of events)
  • Steeper implementation effort, especially async (tokio, ethers-rs or alloy)
  • Better choice if the service becomes a platform product
  • Recommended if scaling beyond 5 chains or productizing

Decision for this PRD: Go for v1. Rewrite to Rust if/when volume justifies it. The API surface is small; rewriting is low cost.


Service API

POST /intents

Request:
{
  "intentId": "6847abc123...",        // caller-provided idempotency key (MongoDB Payment _id)
  "chainId": 56,
  "tokenAddress": "0x55d398...",      // USDT on BSC
  "destination": "0xDERIVED...",      // any address — no restriction
  "amount": "10000000000000000000",   // wei
  "callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
  "callbackSecret": "hmac_secret",    // used to sign webhook payload
  "confirmations": 12                 // optional override; defaults to chain threshold
}

Response:
{
  "intentId": "6847abc123...",
  "paymentReference": "0x1a2b3c4d5e6f7a8b",
  "checkoutBlock": {
    "destination": "0xDERIVED...",
    "tokenAddress": "0x55d398...",
    "tokenSymbol": "USDT",
    "decimals": 18,
    "chainId": 56,
    "proxyAddress": "0x0DfbEe...",
    "paymentReference": "0x1a2b3c4d5e6f7a8b",
    "feeAmount": "0",
    "feeAddress": "0x000000000000000000000000000000000000dEaD",
    "amountWei": "10000000000000000000"
  }
}

GET /intents/:intentId

{
  "intentId": "...",
  "status": "pending" | "confirmed" | "expired",
  "paymentReference": "0x...",
  "txHash": "0x..." | null,
  "blockNumber": 12345678 | null,
  "confirmations": 3,
  "requiredConfirmations": 12
}

POST /webhook (outbound — scanner → backend)

{
  "intentId": "6847abc123...",
  "paymentReference": "0x1a2b3c4d5e6f7a8b",
  "txHash": "0xabc...",
  "blockNumber": 39105482,
  "amount": "10000000000000000000",
  "token": "0x55d398...",
  "chainId": 56,
  "status": "confirmed"
}
// Header: X-AMN-Signature: hmac_sha256(body, callbackSecret)

GET /scanner/status

{
  "chains": [
    { "chainId": 56, "name": "BSC", "lastScannedBlock": 39105490, "chainHead": 39105492, "lag": 2, "pendingIntents": 1 },
    { "chainId": 42161, "name": "Arbitrum", "lastScannedBlock": 185234101, "chainHead": 185234104, "lag": 3, "pendingIntents": 0 }
  ]
}

Node.js Backend Changes

New adapter: amnPayAdapter.ts

Replaces requestNetworkAdapter.ts. Calls AMN_SCANNER_URL (env var) instead of the RN API. The adapter interface (CreatePayInIntentInputPayInIntentResult) is identical — the rest of the backend is untouched.

New webhook receiver: POST /api/payment/amn-scanner/webhook

Replaces the existing RN webhook handler. Verifies X-AMN-Signature, finds the Payment by intentId, advances status to confirmed, emits socket event. ~50 lines, same pattern as existing RN webhook handler.

Adapter swap: providerConfig.ts

// Before:
provider: 'request.network'

// After (new env var):
provider: process.env.AMN_SCANNER_URL ? 'amn.scanner' : 'request.network'

Allows parallel run: RN stays active for existing in-flight payments; new intents go to the scanner.

Environment variables

Variable Where Purpose
AMN_SCANNER_URL Backend .env Base URL of the scanner service (e.g. http://amn-scanner:8080)
AMN_SCANNER_WEBHOOK_SECRET Backend .env HMAC secret for incoming webhook verification
AMN_SCANNER_CHAINS Scanner config JSON array of chain configs (loaded from supportedChains.json)
AMN_SCANNER_RPC_* Scanner config Per-chain RPC URL overrides

Destination: master wallet vs derived addresses

With AMN Pay Scanner, both options are available from day one — there is no registered-wallet restriction.

Option A — Master wallet (conservative, ship immediately): destination = 0x05E280.... Funds land in single wallet. No sweep needed. derivedDestination stays as metadata. Zero operational change.

Option B — Derived HD addresses (full custody separation): destination = derivedDestination.address per (buyer, sellerOffer) pair. Funds land in unique addresses. Requires Trezor-signed sweep (Task #11). Full per-buyer custody separation.

Recommendation: ship with Option A; flip to Option B after Task #11 (Trezor) is merged — it is a one-line change in amnPayAdapter.ts.


Benefits

  • No RN API dependency — no API key, no RN rate limits, no RN downtime affecting checkouts
  • Any destination address — removes the core RN constraint; derived HD addresses become real payment destinations
  • Faster checkout initiationpaymentReference generated in the scanner in microseconds; no external HTTP round-trip on the critical path
  • Separation of concerns — scanner is a dedicated process; a crash does not affect the Node.js backend's availability
  • Language-level performance — Go/Rust binary uses far less memory than a Node.js process for sustained polling; more appropriate for an always-on IO-bound loop
  • Portable — scanner can be deployed alongside the backend or on a separate host; scales independently
  • Reuses audited contracts — same ERC20FeeProxy contracts; no new smart contract risk
  • Cost reduction — removes RN subscription; RPC cost only (~$050/month at current volume)
  • Full observability — every block, every log, every match is logged locally; no black-box

Risks

  • Additional service to operate — one more Docker container, one more process to monitor, one more thing that can be misconfigured. Mitigated by: restart: always, health endpoint, admin dashboard scanner-lag column.
  • Scanner downtime = delayed confirmation — payments are not marked confirmed until the scanner catches up. Mitigated by: checkpoint resume (replays from lastScannedBlock - 500 on startup), alerting on lag > 60s.
  • RPC reliability — flaky RPC causes missed blocks. Mitigated by: two RPCs per chain with automatic fallback (already in supportedChains.json).
  • Block reorganisations — shallow reorgs can temporarily confirm a payment that is later invalidated. Mitigated by: confirmation threshold (12 blocks on BSC ≈ 36s).
  • New codebase to maintain — a Go/Rust service adds a new language to the stack. Mitigated by: the service is small (~500800 lines), well-scoped, and unlikely to change frequently after initial ship.
  • Existing in-flight RN payments — payments created before cutover have RN-generated references; only the RN webhook can detect them. Mitigated by: parallel-run window (both providers active); drain RN-pending payments before removing RN adapter.
  • HMAC secret rotation — if AMN_SCANNER_WEBHOOK_SECRET leaks, an attacker can forge confirmations. Mitigated by: secret stored in deploy vault, not in source; webhook handler also checks intentId exists in DB before marking confirmed.

Neutral Assessment

Dimension RN-hosted AMN Pay Scanner
External dependency RN API + RN webhook RPC providers (two per chain, already present)
Destination restriction Registered merchant wallet only Any EVM address
Confirmation latency RN webhook (secondsminutes, opaque) Scanner poll interval (1530s, deterministic)
Cost RN subscription + RPC RPC only ($050/month)
Operational complexity Low — RN runs it Medium — one extra Docker container
Languages in stack Node.js only Node.js + Go (or Rust)
Custody flexibility None Full (any address, incl. derived wallets)
Auditability RN-side logs only Full local logs: every block, every match
Smart contract risk RN's contracts (audited) Same contracts — unchanged
Dev effort to ship Zero ~46 days

The primary driver for choosing AMN Pay Scanner over the embedded-in-Node approach (previous version of this PRD) is process isolation: an always-on block-scanning loop is a better fit for a compiled binary than for the Node.js event loop, and a crash in the scanner cannot take down the API server.


Acceptance Criteria

  1. POST /api/payment/intents (or /request-network/intents) returns a checkout block within 300ms with no call to RN's API.
  2. paymentReference is generated by the scanner and stored on the Payment record before the response is returned.
  3. Scanner detects a TransferWithReferenceAndFee event on BSC within two poll cycles (≤30s) of the transaction being mined.
  4. Matched payment is marked confirmed once confirmations >= threshold; socket event is emitted to the buyer.
  5. Scanner resumes from lastScannedBlock after restart; no event is processed twice (idempotent on txHash + logIndex).
  6. Webhook payload is rejected if X-AMN-Signature does not match.
  7. Existing RN-originated payments continue to be detected via RN webhook during the parallel-run window.
  8. GET /api/admin/rn/scanner/status returns per-chain lag; admin networks page shows lag column.
  9. REQUEST_NETWORK_API_KEY can be removed from env without breaking startup.

Files to Create / Modify

Scanner service (new repo or scanner/ directory in monorepo)

File Purpose
scanner/main.go Entry point: load config, start chain workers, start HTTP server
scanner/intent.go Intent store: create, lookup, update status (SQLite via database/sql)
scanner/chain.go Per-chain scanner loop: eth_getLogs, match, confirm, webhook delivery
scanner/reference.go computePaymentReference(intentId, salt, destination) — mirrors paymentReference.ts formula
scanner/webhook.go Outbound webhook POST with HMAC signing and retry
scanner/api.go HTTP handlers: POST /intents, GET /intents/:id, GET /scanner/status, GET /health
scanner/config.go Load chains from supported-chains.json (copy of backend's file)
scanner/Dockerfile Multi-stage build: golang:1.22-alpinealpine:3.19
scanner/supported-chains.json Copy (or symlink) of backend's supportedChains.json

Node.js backend

File Change
backend/src/services/payment/adapters/amnPayAdapter.ts CREATE — HTTP client for AMN Pay Scanner; same interface as requestNetworkAdapter.ts
backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts Add branch: if provider is amn.scanner, call amnPayAdapter instead of requestNetworkAdapter
backend/src/routes/amnScannerWebhookRoutes.ts CREATEPOST /api/payment/amn-scanner/webhook; verify HMAC, update payment status
backend/src/app.ts Mount amnScannerWebhookRoutes; add GET /api/admin/rn/scanner/status proxy
backend/src/services/payment/providerConfig.ts Register amn.scanner as a provider; read AMN_SCANNER_URL env var
frontend/src/sections/admin/networks/networks-list-view.tsx Add scanner lag column per chain

Deployment

File Change
docker-compose.dev.yml Add amn-scanner service: build from ./scanner, port 8080, env from .env
docker-compose.production.yml Same
.env.example (backend) Add AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET
scanner/.env.example PORT, CHAINS_JSON_PATH, RPC_BSC, RPC_ARB, RPC_ETH, RPC_POLYGON, RPC_BASE, DB_PATH

Implementation Notes for Agent (Kimi)

paymentReference formula (Go)

// Mirrors backend/src/services/payment/requestNetwork/paymentReference.ts
func computePaymentReference(intentId, salt, destination string) string {
    combined := strings.ToLower(intentId + salt + destination)
    hash := crypto.Keccak256([]byte(combined))
    // last 8 bytes = last 16 hex chars
    return "0x" + hex.EncodeToString(hash[24:])
}

TransferWithReferenceAndFee event topic

0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3

The paymentReference is the 4th topic (indexed bytes parameter). Read it from log.Topics[1] (after the event signature topic). It is ABI-encoded as a keccak256 hash of the bytes value when indexed — verify against a known BSC transaction before trusting.

eth_getLogs chunking

Most chains cap at 20005000 blocks per call. Chunk in 2000-block windows:

for from := checkpoint; from <= head; from += 2000 {
    to := min(from+1999, head)
    logs := ethGetLogs(rpc, proxyAddr, from, to)
    process(logs)
    saveCheckpoint(chainId, to)
}

Webhook retry

On HTTP error or non-2xx response, retry with exponential backoff: 5s, 30s, 2min, 10min, 1h. After 5 failures, mark intent webhook_failed and log — do not lose the confirmed state.

Idempotency

Index on (txHash, logIndex) in the intent store. On duplicate, skip processing — do not re-deliver the webhook.

Scanner lag alert

If chainHead - lastScannedBlock > 100 (about 5 minutes on BSC), log at WARN level. The Node.js backend's admin status route can surface this to Telegram notifications.


Out of Scope for This PRD

  • Trezor-signed sweep (Task #11) — required to make derived addresses as real destinations viable end-to-end
  • AML screening on matched payments (Task #10)
  • Per-chain confirmation threshold admin UI (Task #9) — scanner reads thresholds from config; the Node.js admin UI is Task #9
  • TON / non-EVM chain support — scanner is EVM-only
  • Productizing the scanner as a standalone SaaS — this PRD is for internal use only