293 lines
15 KiB
Markdown
293 lines
15 KiB
Markdown
---
|
||
title: Payment Flow - Scanner (In-House)
|
||
tags: [flow, scanner, payment]
|
||
created: 2026-05-30
|
||
---
|
||
|
||
# Payment Flow — AMN Pay Scanner (In-House)
|
||
|
||
> **Last updated:** 2026-06-06 — documented frontend/backend `2.8.118` BSC 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](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md), [PRD - Direct Address Token Payments via Scanner Balance Watches](../PRD%20-%20Direct%20Address%20Token%20Payments%20via%20Scanner%20Balance%20Watches.md)
|
||
|
||
---
|
||
|
||
## 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:**
|
||
1. `eth_getLogs` returns a `TransferWithReferenceAndFee` log matching `topicRef`
|
||
2. `validateLogMatchesIntent` verifies token address, destination, and amount
|
||
3. Intent moves to `confirming`; scanner waits for N blocks
|
||
4. Once `confirmationsRequired` blocks have been built on top, intent moves to `confirmed`. The scanner stores and reports the accepted threshold count, not an ever-growing live count.
|
||
|
||
**Tron path:**
|
||
1. TronGrid `Transfer` event matches `destination` (EVM-hex normalized)
|
||
2. Amount validated ≥ intent amount
|
||
3. Intent goes directly to `confirmed` (TronGrid returns only confirmed txs)
|
||
|
||
**TON path:**
|
||
1. TonCenter Jetton transfer matches `destination` (exact base64url) and `jetton_master_address`
|
||
2. Amount validated ≥ intent amount
|
||
3. Intent goes directly to `confirmed`
|
||
|
||
### Step 5 — Webhook delivery
|
||
|
||
The scanner POSTs to `callbackUrl` with:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
1. Allocate or select the payment address.
|
||
2. Call scanner `POST /balances/check` to store a base-unit `baselineBalance`.
|
||
3. Show the address/token/amount to the buyer.
|
||
4. When buyer clicks "I paid", call `POST /balances/check` again.
|
||
5. Compare `(currentBalance - baselineBalance)` to the expected base-unit amount.
|
||
6. 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:
|
||
|
||
- `delta` is 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 |
|