Files
nick-doc/01 - Architecture/Scanner Architecture.md
Siavash Sameni e52ffce48a docs: sync vault with codebase state (2026-06-12)
- 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>
2026-06-12 11:42:18 +04:00

182 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 20500) 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`. |