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

12 KiB
Raw Blame History

title, tags, created, updated
title tags created updated
Scanner Architecture
architecture
scanner
payment
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 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.