- Update backend, frontend, scanner, deployment, amanat-assist service docs - Update System Overview, Scanner Architecture, Telegram Mini App flow - Update 10 - Services/README.md - Add Tenant data model, Tenant API reference, Tenant Storefront Flow - Add Multi-Shop Branch Project Scan (2026-06-10) - Add tenant.md service doc - Append activity log entry - Reflects archived/search/stats route fix and new E2E test suite Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
12 KiB
Markdown
182 lines
12 KiB
Markdown
---
|
||
title: Scanner Architecture
|
||
tags: [architecture, scanner, payment]
|
||
created: 2026-05-30
|
||
updated: 2026-06-12
|
||
---
|
||
|
||
# Scanner Architecture
|
||
|
||
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via signed webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON.
|
||
|
||
> [!info]
|
||
> Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs.
|
||
>
|
||
> For operational how-it-works detail (API, webhook payloads, config vars, direct balance checks) see [[scanner]] in 10 - Services.
|
||
|
||
---
|
||
|
||
## 1. Responsibilities
|
||
|
||
- Accept payment **intents** from the backend (`POST /intents`)
|
||
- Watch the relevant chain for matching on-chain transfers
|
||
- Track confirmation depth (EVM) or rely on API-reported finality (Tron, TON)
|
||
- Deliver a signed webhook to the backend callback URL when confirmed
|
||
- Retry failed webhook deliveries with exponential back-off
|
||
- Expire stale pending intents on a configurable TTL
|
||
- Read an EVM ERC-20 balance on demand (`POST /balances/check`)
|
||
- Watch an EVM address/token pair for balance changes with age-decayed polling cadence (`POST /balance-watches`); checks every 5 min for the first 24 h, then 10 → 20 → 40 min as the watch ages; watches expire after 7 days
|
||
|
||
---
|
||
|
||
## 2. Supported chains
|
||
|
||
Chains are defined in `supported-chains.json`. A worker is spawned only for chains with `"verified": true` (or listed in `SCANNER_ENABLED_CHAINS`).
|
||
|
||
| Chain | Chain ID | Type | Proxy / contract address | Conf. threshold | `verified` |
|
||
|---|---|---|---|---|---|
|
||
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks | **true** |
|
||
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks | **true** |
|
||
| BNB Smart Chain Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | **true** (testnet) |
|
||
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks | false |
|
||
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | false |
|
||
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | false |
|
||
| Tron Mainnet | 728126428 | Tron | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` (USDT TRC20 contract) | 200 (API-confirmed) | false |
|
||
| TON Mainnet | 1100 | TON | `EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs` (USDT Jetton master) | 120 (API-finalized) | false |
|
||
|
||
> [!note] Proxy address variations
|
||
> Ethereum mainnet uses the v0.1.0 proxy (`0x370DE...`); a v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Base uses a non-canonical CREATE2 address (`0x189219...`). All other EVM chains use the canonical v0.2.0 address (`0x0DfbEe...`). The memory note [[RN proxy addresses per chain]] has background on why CREATE2 canonical-address claims should not be trusted without verification.
|
||
>
|
||
> Tron and TON have no fee-proxy contract. The `proxyAddress` field for those chains holds the token contract address used to filter Transfer events (Tron) or Jetton transfers (TON).
|
||
|
||
To enable a disabled chain without a rebuild: set `SCANNER_ENABLED_CHAINS=56,1,42161` (overrides the JSON `verified` flags).
|
||
|
||
---
|
||
|
||
## 3. Architecture decisions
|
||
|
||
### Why a standalone Go service
|
||
|
||
The scanner runs a tight polling loop that needs to hold open TCP connections to multiple RPC endpoints, manage per-chain checkpoints, and retry webhook delivery independently of backend restarts. A dedicated process with its own SQLite state is simpler and more reliable than embedding this into the Node.js backend.
|
||
|
||
### Why SQLite
|
||
|
||
Single-node deployment. WAL mode gives concurrent reads during writes. The state set is small (one row per intent, one checkpoint per chain). No operational overhead of a separate DB process inside the container.
|
||
|
||
### Two payment rails
|
||
|
||
The scanner supports two fundamentally different payment models:
|
||
|
||
1. **Proxy-contract rail (EVM)**: funds flow through `ERC20FeeProxy`; the scanner matches by `paymentReference` embedded in the contract event. No unique destination address required; the reference is the discriminator.
|
||
2. **Direct-address rail (Tron, TON, and EVM balance-watch)**: each payment gets a unique HD-derived destination address. The scanner matches by `to` address and validates amount. This is the only model available on Tron and TON because no fee-proxy contract exists there.
|
||
|
||
### Confirmation thresholds
|
||
|
||
EVM confirmation depths are conservative to handle reorgs:
|
||
|
||
- **BSC (200)**: BSC has had historical reorg incidents; 200 blocks (~10 min) provides a practical safety margin.
|
||
- **ETH (50)**: ~10 min at 12 s/block; Ethereum finality is probabilistic post-merge but 50 blocks is well past economic finality.
|
||
- **Arbitrum (2400)**: Arbitrum uses optimistic rollup; 2400 blocks (~54 min) covers the challenge window.
|
||
- **Polygon (300)**: polygon reorgs have occurred at depth >100; 300 blocks gives headroom.
|
||
- **Base (300)**: Base is an OP Stack chain; same rationale as Polygon.
|
||
|
||
Tron and TON do not use block-depth confirmation — TronGrid and TonCenter only surface confirmed/finalized transactions, so status goes directly to `confirmed`. The scanner reports the chain's acceptance floor (200 / 120) in the webhook for backend use.
|
||
|
||
### Reorg protection (EVM)
|
||
|
||
The EVM worker re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20–500) on every tick. This `ReorgBuffer()` ensures that a log in a block that was reorganised off the canonical chain will be re-evaluated when the chain reorganises. The window is wide enough to cover any realistic reorg depth for the chains the scanner targets.
|
||
|
||
### Startup reconciliation
|
||
|
||
On startup, `confirmed` intents with `webhook_delivered_at IS NULL` created within the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook` without requiring a manual retry trigger.
|
||
|
||
---
|
||
|
||
## 4. Component map
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ scanner binary │
|
||
│ │
|
||
│ main.go │
|
||
│ ├── loadConfig() config.go │
|
||
│ ├── initDB() intent.go (SQLite schema) │
|
||
│ ├── startup reconcile intent.go │
|
||
│ ├── newServer() api.go │
|
||
│ │ └── startWorkers() api.go │
|
||
│ │ ├── ChainWorker chain.go (EVM) │
|
||
│ │ ├── TronChainWorker tron_chain.go (Tron) │
|
||
│ │ └── TonChainWorker ton_chain.go (TON) │
|
||
│ ├── HTTP routes api.go / main.go │
|
||
│ ├── intent TTL expiry main.go + intent.go │
|
||
│ ├── webhook retry loop main.go + webhook.go │
|
||
│ └── BalanceWatchScheduler balance_watch.go │
|
||
│ │
|
||
│ reference.go — payment reference / topic hash │
|
||
│ webhook.go — delivery, HMAC signing, retry │
|
||
│ balance.go — EVM ERC-20 balanceOf reads │
|
||
│ balance_watch.go — balance_watches state + webhooks │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
One worker goroutine is spawned per active chain. All three chain types implement a common `Worker` interface (`start()`, `stop()`, `getHead()`). Workers poll on `POLL_INTERVAL_SEC` (default 15 s).
|
||
|
||
---
|
||
|
||
## 5. Backend integration points
|
||
|
||
| Direction | Endpoint | When |
|
||
|---|---|---|
|
||
| Backend → Scanner | `POST /intents` | New payment initiated; returns `checkoutBlock` with `paymentReference` and proxy address |
|
||
| Backend → Scanner | `GET /intents/{id}` | Poll intent status (optional; webhook is primary) |
|
||
| Scanner → Backend | `POST <callbackUrl>` | Payment confirmed; signed with `X-AMN-Signature` HMAC-SHA256 |
|
||
| Backend → Scanner | `POST /balances/check` | Synchronous ERC-20 balance read (direct-address rail) |
|
||
| Backend → Scanner | `POST /balance-watches` | Start async balance watch (direct-address rail) |
|
||
| Backend → Scanner | `GET /balance-watches/{id}` | Get balance-watch status |
|
||
| Scanner → Backend | `POST <callbackUrl>` | Balance changed; `eventType: balance_changed` in body |
|
||
| Backend → Scanner | `DELETE /balance-watches/{id}` or `POST /balance-watches/{id}/stop` | Stop watch after payment accepted or cancelled |
|
||
| Backend → Scanner | `GET /scanner/status` | Chain lag + pending counts (ops/monitoring) |
|
||
| Backend → Scanner | `POST /admin/webhooks/retry` | Force re-delivery of `webhook_failed` intents |
|
||
|
||
All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>`. Webhooks are HMAC-SHA256 signed; backend must verify `X-AMN-Signature` before crediting any payment.
|
||
|
||
The `amn.scanner` backend provider wires intent creation, webhook receipt, and balance-watch lifecycle. See memory note [[amn scanner pay-in wiring + env]] for the 6 required env vars and the dispatcher registration.
|
||
|
||
---
|
||
|
||
## 6. Intent lifecycle
|
||
|
||
```
|
||
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
|
||
│ │ │
|
||
│ │ (deep reorg / TTL) │ (all retries fail)
|
||
└───────────────────────┴──────────► expired webhook_failed
|
||
```
|
||
|
||
- **Tron / TON** skip `confirming` and go directly to `confirmed` (API only surfaces finalized txns).
|
||
- `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
|
||
- Retry schedule on first delivery attempt: 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
|
||
|
||
---
|
||
|
||
## 7. Security model
|
||
|
||
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
|
||
- Unset `SCANNER_API_KEY` logs a warning and allows all requests — local dev only.
|
||
- Webhooks signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
|
||
- `callbackSecret` stored in DB but excluded from all JSON responses (`json:"-"`).
|
||
- Request bodies limited to 64 KB.
|
||
- `SCANNER_CALLBACK_ALLOWED_HOSTS` env var restricts allowed webhook target hosts (SSRF guard).
|
||
|
||
---
|
||
|
||
## 8. Known limitations and open items
|
||
|
||
| Item | Detail |
|
||
|---|---|
|
||
| TON O(n) API calls | Per-intent polling — one TonCenter v3 call per pending TON intent per scan cycle. Fine at low volume; needs batching for scale. |
|
||
| Direct balance reads: EVM only | `POST /balances/check` and `POST /balance-watches` only support EVM ERC-20 (`eth_call` balanceOf). Tron/TON balance reads are future scope. |
|
||
| Arbitrum / Polygon / Base / Tron / TON disabled | `"verified": false` in `supported-chains.json`. Enable via `SCANNER_ENABLED_CHAINS` env var without a code change or rebuild. |
|
||
| Ethereum proxy version | Chain 1 uses the v0.1.0 proxy (`0x370DE...`). A v0.2.0 proxy is also deployed on ETH but checkout still uses the v0.1.0 ABI. Upgrading requires a coordinated frontend change. |
|
||
| BSC Testnet tokens | Test USDT on BSC Testnet: `0x109F54Dab34426D5477986b0460aE5dFBA65f022` (has public `mint()`). Faucet: `testnet.bnbchain.org/faucet-smart`. |
|