15 KiB
title, tags, created
| title | tags | created | |||
|---|---|---|---|---|---|
| Payment Flow - Scanner (In-House) |
|
2026-05-30 |
Payment Flow — AMN Pay Scanner (In-House)
Last updated: 2026-06-06 — documented frontend/backend
2.8.118BSC Testnet checkout UI support.
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
See also: Scanner Architecture, Scanner API, PRD - Direct Address Token Payments via Scanner Balance Watches
1. High-level sequence
Buyer Backend Scanner Chain
│ │ │ │
│ initiate payment │ │ │
│────────────────────►│ │ │
│ │ POST /intents │ │
│ │───────────────────►│ │
│ │ 200 checkoutBlock │ │
│ │◄───────────────────│ │
│ checkoutBlock │ │ │
│◄────────────────────│ │ │
│ │ │ │
│ sign + submit tx ──────────────────────────────────────►│
│ │ │ (polling) │
│ │ │◄────────────────│
│ │ │ log matched │
│ │ │ confirmations… │
│ │◄───────────────────│ │
│ │ POST callbackUrl │ │
│ │ (webhook) │ │
│ │ │ │
│ payment confirmed │ │ │
│◄────────────────────│ │ │
2. Step-by-step
Step 1 — Backend creates an intent
When the buyer chooses a payment method (e.g. USDT on BSC), the backend calls:
POST http://scanner:8080/intents
Authorization: Bearer <SCANNER_API_KEY>
{
"intentId": "<payment._id>",
"chainId": 56,
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
"destination": "0xSellerWalletAddress",
"amount": "10000000000000000000",
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
"callbackSecret": "<per-intent HMAC secret stored in payment doc>",
"confirmations": 200
}
The scanner responds with a checkoutBlock that the backend passes to the frontend.
BSC Testnet test rail
For dev end-to-end testing, backend and scanner must keep the chain 97 registry in sync:
| Field | Value |
|---|---|
| Chain id | 97 |
| Network aliases | bsc-testnet, bnb-testnet, bsctest, bsc_testnet, binance-testnet, bnbt, numeric string 97 |
| RPC fallback | https://bsc-testnet-rpc.publicnode.com |
| USDT test token | 0x109F54Dab34426D5477986b0460aE5dFBA65f022 |
| USDC test token | 0x64544969ed7EBf5f083679233325356EbE738930 |
| Token decimals | 18 for USDT and USDC |
| Default confirmation floor | 5 |
Backend 2.8.116+ uses this same token address in both the Request Network/scanner intent registry and the legacy BSCTransactionVerifier path. This matters because /api/payment/request-network/intents resolves the buyer's selected chain/token before asking scanner to watch, while older wallet-direct verification endpoints call BSCTransactionVerifier.verifyTransfer() directly. A mismatch between these two registries will either create scanner intents for a token that the buyer does not pay, or verify the wrong ERC-20 contract after payment.
Backend 2.8.116+ also passes an explicit scannerContext (paymentId, chainId, tokenSymbol, tokenAddress, destination) into AMN scanner intent registration. This prevents PG-only or partially hydrated payment reads from falling back to the global merchant reference and creating mainnet/default-style scanner intents such as undefined-c56-USDC.
If a live dev stack still waits for 200 confirmations on chain 97, check the admin runtime setting confirmation_threshold:97. Built-in default is now 5, but a previously persisted admin value above the floor still wins until updated.
Step 2 — Frontend shows checkout
The checkoutBlock contains everything the frontend needs to build the ERC20FeeProxy.transferWithReferenceAndFee calldata:
| Field | Used for |
|---|---|
proxyAddress |
contract to call |
tokenAddress |
ERC20 token |
destination |
_to param |
paymentReference |
_paymentReference param (8-byte reference) |
amountWei |
_amount param |
feeAmount |
_feeAmount param (always "0" currently) |
feeAddress |
_feeAddress param (always dead address) |
For Tron/TON the buyer sends a plain TRC20/Jetton transfer to destination; there is no proxy contract.
Frontend 2.8.118+ includes BSC Testnet (97) in the Wagmi chain config, so wallet switching can target the same chain id returned in the scanner checkout block. The checkout summary renders BSC Testnet (97), shows the exact tokenAddress supplied by the backend (for dev tUSDT this is 0x109F54Dab34426D5477986b0460aE5dFBA65f022), and sends chain 97 address/tx links to testnet.bscscan.com instead of mainnet BscScan.
Step 3 — Buyer submits transaction
The buyer signs and broadcasts the transaction using their wallet. The scanner independently monitors the chain and does not require the transaction hash.
Step 4 — Scanner detects and confirms
EVM path:
eth_getLogsreturns aTransferWithReferenceAndFeelog matchingtopicRefvalidateLogMatchesIntentverifies token address, destination, and amount- Intent moves to
confirming; scanner waits for N blocks - Once
confirmationsRequiredblocks have been built on top, intent moves toconfirmed. The scanner stores and reports the accepted threshold count, not an ever-growing live count.
Tron path:
- TronGrid
Transferevent matchesdestination(EVM-hex normalized) - Amount validated ≥ intent amount
- Intent goes directly to
confirmed(TronGrid returns only confirmed txs)
TON path:
- TonCenter Jetton transfer matches
destination(exact base64url) andjetton_master_address - Amount validated ≥ intent amount
- Intent goes directly to
confirmed
Step 5 — Webhook delivery
The scanner POSTs to callbackUrl with:
{
"intentId": "...",
"paymentReference": "0x...",
"txHash": "0x...",
"blockNumber": 39000010,
"amount": "10000000000000000000",
"token": "0x55d...",
"chainId": 56,
"confirmations": 200,
"status": "confirmed"
}
Header X-AMN-Signature = HMAC-SHA256(body, callbackSecret).
The backend verifies the signature, matches the intentId to a Payment record, and marks it paid. Backend 2.6.82+ treats scanner status: "confirmed" as final enough to run Transaction Safety Provider checks and persist blockchain.confirmations. The stored confirmation count comes from verifier evidence first, then the webhook payload, then the configured per-chain threshold fallback, but settled counts are capped at the accepted threshold so the UI can show values like 200+ instead of chasing the live chain height forever.
Step 6 — Backend acknowledges
Backend returns a 2xx response. Scanner records webhook_delivered_at and the intent lifecycle ends.
3. Direct-address payment mode
Scanner 0.1.8 adds a non-smart-contract rail for cases where the buyer transfers tokens directly to a backend-assigned address instead of calling ERC20FeeProxy.
This rail is currently EVM ERC-20 only. Tron/TON direct balance reads are future scope.
Mode A — synchronous balance check
Buyer Backend Scanner EVM RPC
│ │ │ │
│ open checkout │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ address + amount │ │ │
│◄────────────────────│ │ │
│ direct token transfer ──────────────────────────────────────►│
│ click "I paid" │ │ │
│────────────────────►│ │ │
│ │ POST /balances/check │
│ │───────────────────►│ eth_call balanceOf │
│ │◄───────────────────│◄────────────────── │
│ payment accepted if delta >= expected amount │
Backend responsibilities:
- Allocate or select the payment address.
- Call scanner
POST /balances/checkto store a base-unitbaselineBalance. - Show the address/token/amount to the buyer.
- When buyer clicks "I paid", call
POST /balances/checkagain. - Compare
(currentBalance - baselineBalance)to the expected base-unit amount. - Persist evidence and run the normal payment/ledger transition only after chain, token, address, and amount checks pass.
Mode B — balance watch
Backend Scanner EVM RPC
│ POST /balance-watches │ │
│──────────────────────►│ initial balanceOf │
│◄──────────────────────│ │
│ │ every 5m, then 10/20/40m
│ │───────────────────►│
│ │ balance changed │
│◄──────────────────────│ signed webhook │
│ payment accepted │ │
│ DELETE /balance-watches/{watchId} │
│──────────────────────►│ status=stopped │
Backend 2.8.60 exposes scanner helper functions in amnPayAdapter.ts:
| Helper | Scanner endpoint |
|---|---|
checkScannerTokenBalance |
POST /balances/check |
createScannerBalanceWatch |
POST /balance-watches |
stopScannerBalanceWatch |
DELETE /balance-watches/{watchId} |
Backend 2.8.60 also accepts signed balance_changed scanner webhooks on the existing AMN scanner webhook route. The current webhook handler records amnScannerBalanceWatch metadata and returns 202; it does not yet mark the payment funded on balance change alone. The product decision rule still needs to be implemented by the backend work described in the PRD.
Recommended watch ID shape: <paymentId>-balance-c<chainId>-<TOKEN>. The webhook handler maps this back to the payment ID prefix.
Scanner cadence:
| Age | Interval |
|---|---|
| First 24h | 5 min |
| 24–48h | 10 min |
| 48–72h | 20 min |
| 72h–7d | 40 min |
| After 7d | expired |
Backend must stop a watch when payment is accepted, cancelled, manually resolved, or no longer relevant.
4. Failure paths
Webhook delivery failure
If the backend returns non-2xx or is unreachable, the scanner retries:
attempt 1: after 5 s
attempt 2: after 30 s
attempt 3: after 2 min
attempt 4: after 10 min
attempt 5: after 1 h
→ status = webhook_failed
webhook_failed intents are retried every WEBHOOK_RETRY_HOURS (default 6 h) and on POST /admin/webhooks/retry.
On startup the scanner reconciles any confirmed intents with webhook_delivered_at IS NULL (crash recovery).
Intent expiry
Intents in pending or confirming status older than INTENT_TTL_HOURS (default 24 h) are moved to expired by a background ticker running every hour.
confirming intents can get stuck if a transaction is deep-reorganised and never re-included; the TTL frees the destination address for reuse.
Amount underpayment
Transfers where the on-chain amount is less than intent.Amount are silently skipped. The intent remains pending until the TTL.
Wrong token or destination
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as REJECT and skipped. The intent remains pending.
Balance watch change but payment not complete
balance_changed means the watched balance changed; it is not a final paid signal by itself. Backend must reject or keep waiting when:
deltais less than the expected payment amount.- The balance decreased or moved by an unrelated amount.
- The watch address/token/chain do not match the payment metadata.
- The payment was already completed, cancelled, refunded, or superseded.
5. Key differences from Request Network integration
| Dimension | Request Network | AMN Pay Scanner |
|---|---|---|
| Dependency | RN SDK + API | None (direct RPC) |
| Payment reference | RN-generated | Internal HMAC derivation |
| EVM matching | By reference hash (RN) | By Topics[1] / topicRef (indexed) |
| Tron | Not supported | TRC20 Transfer events via TronGrid |
| TON | Not supported | Jetton transfers via TonCenter v3 |
| Confirmations | RN handled | Per-chain configurable |
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
| State store | External (RN cloud) | Internal SQLite |
| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail |