- 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>
12 KiB
title, tags, created, updated
| title | tags | created | updated | |||
|---|---|---|---|---|---|---|
| Scanner Architecture |
|
2026-05-30 | 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
proxyAddressfield 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:
- Proxy-contract rail (EVM): funds flow through
ERC20FeeProxy; the scanner matches bypaymentReferenceembedded in the contract event. No unique destination address required; the reference is the discriminator. - Direct-address rail (Tron, TON, and EVM balance-watch): each payment gets a unique HD-derived destination address. The scanner matches by
toaddress 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
confirmingand go directly toconfirmed(API only surfaces finalized txns). webhook_failedintents are retried everyWEBHOOK_RETRY_HOURS(default 6 h) and onPOST /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_KEYlogs a warning and allows all requests — local dev only. - Webhooks signed with HMAC-SHA256:
X-AMN-Signature: hex(hmac(body, callbackSecret)). callbackSecretstored in DB but excluded from all JSON responses (json:"-").- Request bodies limited to 64 KB.
SCANNER_CALLBACK_ALLOWED_HOSTSenv 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. |