- Activity Log: new entry for AMN Pay Scanner implementation - Environment Variables: document AMN_SCANNER_URL, AMN_SCANNER_WEBHOOK_SECRET, AMN_SCANNER_DEFAULT - PRD status table: mark all components implemented
19 KiB
PRD — Retire Request Network: AMN Pay Scanner (Standalone Microservice)
Status: Implemented — 2026-05-29 Task: #13 Priority: High Effort estimate: ~4–6 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)
Component Status Go scanner service ✅ Done — scanner/*.go, builds, tests passBackend adapter ( amnPayAdapter.ts)✅ Done — implements PaymentProviderAdapterWebhook receiver ( amnScannerWebhookRoutes.ts)✅ Done — HMAC verify + PaymentCoordinator delegation Provider config ( providerConfig.ts)✅ Done — amn.scannerenabled whenAMN_SCANNER_URLsetAdmin scanner status proxy ✅ Done — GET /api/admin/scanner/statusFrontend lag column ✅ Done — color-coded chips in networks list Docker compose (dev + prod) ✅ Done — amn-scannerservice with healthcheckEnv vars (backend + scanner) ✅ Done — AMN_SCANNER_URL,AMN_SCANNER_WEBHOOK_SECRET,AMN_SCANNER_DEFAULTCross-language reference test ✅ Done — Go computePaymentReference= TScomputeOnChainPaymentReferenceLive end-to-end probe ⏳ Pending — requires deployed scanner + real on-chain payment
Context
The platform currently uses Request Network (RN) as a payment infrastructure middleware. RN's API is called to:
- Create a payment request (returns a
requestId+saltused to derive an on-chainpaymentReference) - Provide a hosted checkout page (already bypassed by our in-house checkout)
- 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:
- Accepts payment intents —
POST /intentswith{ chainId, tokenAddress, destination, amount, paymentReference?, callbackUrl }. Returns{ intentId, paymentReference, checkoutBlock }. - Generates paymentReference locally if not provided —
last8bytes(keccak256(intentId + salt + destination)), identical to RN's formula. - Scans chains — one async loop per chain,
eth_getLogsonERC20FeeProxyforTransferWithReferenceAndFeeevents matching pending intents. - Delivers webhook —
POST callbackUrlwith{ intentId, paymentReference, txHash, blockNumber, amount, token, chainId, status: "confirmed" }once confirmations ≥ threshold. - Exposes status API —
GET /intents/:intentIdreturns current state. - Exposes health/admin API —
GET /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/jsonall 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-rsoralloy) - 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 (CreatePayInIntentInput → PayInIntentResult) 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 initiation —
paymentReferencegenerated 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
ERC20FeeProxycontracts; no new smart contract risk - Cost reduction — removes RN subscription; RPC cost only (~$0–50/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 - 500on 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 (~500–800 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_SECRETleaks, an attacker can forge confirmations. Mitigated by: secret stored in deploy vault, not in source; webhook handler also checksintentIdexists 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 (seconds–minutes, opaque) | Scanner poll interval (15–30s, deterministic) |
| Cost | RN subscription + RPC | RPC only ($0–50/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 | ~4–6 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
POST /api/payment/intents(or/request-network/intents) returns a checkout block within 300ms with no call to RN's API.paymentReferenceis generated by the scanner and stored on the Payment record before the response is returned.- Scanner detects a
TransferWithReferenceAndFeeevent on BSC within two poll cycles (≤30s) of the transaction being mined. - Matched payment is marked
confirmedonceconfirmations >= threshold; socket event is emitted to the buyer. - Scanner resumes from
lastScannedBlockafter restart; no event is processed twice (idempotent ontxHash + logIndex). - Webhook payload is rejected if
X-AMN-Signaturedoes not match. - Existing RN-originated payments continue to be detected via RN webhook during the parallel-run window.
GET /api/admin/rn/scanner/statusreturns per-chain lag; admin networks page shows lag column.REQUEST_NETWORK_API_KEYcan 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-alpine → alpine: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 |
CREATE — POST /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 2000–5000 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