Files
nick-doc/01 - Architecture/Scanner Architecture.md

12 KiB
Raw Blame History

title, tags, created
title tags created
Scanner Architecture
architecture
scanner
payment
2026-05-30

Scanner Architecture

AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via 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. Scanner 0.1.8 also supports direct-address EVM ERC-20 balance reads and balance watches for non-smart-contract payment rails.


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 finality from the chain API (Tron, TON)
  • Deliver a signed webhook to the backend callback URL when confirmed
  • Retry failed webhook deliveries
  • Expire stale pending intents on a configurable TTL
  • Read an EVM ERC-20 balance on demand for a backend-supplied address/token
  • Watch an EVM address/token pair for balance changes, decay polling cadence over time, and stop after backend cancellation or 7-day expiry

2. 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 math   │
│  webhook.go    — delivery, HMAC signing, retry         │
│  balance.go    — EVM ERC-20 balanceOf(address) reads   │
│  balance_watch.go — balance_watches state + webhooks   │
└─────────────────────────────────────────────────────────┘

3. Chain worker model

All three chain types implement the Worker interface:

type Worker interface {
    start()
    stop()
    getHead(ctx context.Context) (int64, error)
}

One worker goroutine is spawned per chain marked "verified": true in supported-chains.json. Workers are selected by chainType:

chainType Worker struct API used
evm (default) ChainWorker JSON-RPC 2.0 (eth_getLogs, eth_blockNumber)
tron TronChainWorker TronGrid REST (/v1/contracts/{contract}/events)
ton TonChainWorker TonCenter v3 REST (/jetton/transfers)

Workers poll on POLL_INTERVAL_SEC (default 15 s). On first run, each worker starts scanning from the current chain head minus a small buffer (10 blocks for EVM, 24 h for Tron/TON).


4. EVM scanning detail

for each tick:
  head   = eth_blockNumber
  from   = max(checkpoint  ReorgBuffer(), 0)
  chunks = split [from..head] into 2000-block ranges
  for each chunk:
    logs = eth_getLogs(proxyAddress, EventTopic, from, to)
    for each log:
      topicRef = Topics[1]  (keccak256 of paymentReference — pre-indexed)
      intent   = DB lookup by topicRef WHERE status='pending'
      validate(log.Data, intent)  ← token + destination + amount check
      confirmIntentPending()      ← status → 'confirming'
    saveCheckpoint(to)
  checkConfirmations():
    for each confirming intent:
      confs = head - blockNumber + 1
      if confs >= required: finalizeIntent(capped at required) + deliverWebhook()

Reorg protection: ReorgBuffer() re-scans 3 × confirmationThreshold blocks before the checkpoint (clamped 20500). This catches any log that appeared in a block that was later reorganised off the canonical chain.

Event signature: TransferWithReferenceAndFee keccak256 = 0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3


5. Tron scanning detail

TronGrid does not expose a fee-proxy contract. Each intent is assigned a unique HD-derived destination address. The scanner watches TRC20 Transfer events on the USDT contract and matches by to address.

  • Checkpoint: block timestamp in milliseconds (last_scanned_block column)
  • TronGrid addresses arrive as 41xxxx hex (21 bytes); normalized to 0x (20 bytes EVM style)
  • Tron transactions reported by TronGrid are already confirmed; status goes directly to confirmed (no multi-block wait)
  • Pagination follows meta.links.next until empty

6. TON scanning detail

TON uses TonCenter v3. Per-intent polling: for each pending TON intent, a separate HTTP call fetches incoming Jetton transfers to that destination since the checkpoint.

  • Checkpoint: Unix timestamp in seconds
  • TON addresses are base64url (EQ…/UQ…) — case-sensitive, never lowercased
  • proxyAddress = USDT Jetton master address (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs)
  • TonCenter returns only finalized transactions; status goes directly to confirmed
  • Lag is reported in seconds, not blocks
  • Known scaling limitation: O(pending intents) API calls per scan cycle

7. 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 jump directly to confirmed.
  • webhook_failed intents are retried every WEBHOOK_RETRY_HOURS (default 6 h) and on POST /admin/webhooks/retry.
  • Startup reconciliation: on startup, confirmed intents with webhook_delivered_at IS NULL and created in the last 7 days have their webhook re-delivered. This recovers from a crash between finalizeIntent and deliverWebhook.

8. Direct balance watch model

Direct-address payments are a separate rail from scanner intents. They do not require ERC20FeeProxy, paymentReference, or a smart-contract event. The backend gives the scanner a public address, token, callback URL, and HMAC secret.

Two backend usage modes are supported:

  • Synchronous check mode: backend calls POST /balances/check when an address is allocated to the buyer and again when the buyer clicks "I paid". The backend compares the current base-unit balance to its stored baseline and target amount.
  • Watch mode: backend calls POST /balance-watches; the scanner stores a row in balance_watches, reads the initial balance, and checks the same address/token periodically. If the balance changes, scanner sends a signed balance_changed webhook. Backend stops the watch with DELETE /balance-watches/{watchId} once the payment is accepted, cancelled, or otherwise resolved.

Cadence is age-based:

Watch age Next check interval
024 h 5 min
2448 h 10 min
4872 h 20 min
72 h7 d 40 min
7 d+ status becomes expired

The scanner only advances current_balance after a changed-balance webhook is delivered successfully. If the backend is down or returns non-2xx, the same change is retried on the next scheduled due check.

Current implementation scope: EVM ERC-20 balanceOf(address) via JSON-RPC eth_call. Tron/TON balance reads are future scope.


9. Payment reference math (EVM)

paymentReference  = last8Bytes(keccak256(lower(intentId + salt + destination)))
topicRef (index)  = keccak256(paymentReferenceBytes)

The ERC20FeeProxy indexes paymentReference so Topics[1] in the log is topicRef, not the raw reference. The DB stores topic_ref pre-computed per intent so the scan loop is a single indexed SQL lookup instead of O(n) hashing.


10. Database schema (SQLite WAL)

Three main tables:

intents — one row per payment intent

Column Type Notes
intent_id TEXT PK caller-supplied UUID
chain_id INTEGER numeric chain ID
chain_type TEXT evm / tron / ton
token_address TEXT EVM/Tron: lowercase 0x hex; TON: base64url
destination TEXT receiving address
amount TEXT base-10 wei / token smallest unit
payment_reference TEXT 8-byte hex (EVM only)
topic_ref TEXT keccak256 of paymentReference (EVM index)
status TEXT pending / confirming / confirmed / expired / webhook_failed
callback_url TEXT backend webhook endpoint
callback_secret TEXT HMAC key (not returned in GET)
confirmations_required INTEGER from chain config or caller override, floored at the chain acceptance threshold
tx_hash TEXT NULL transaction hash once seen
log_index INTEGER NULL log position within tx (EVM)
block_number INTEGER NULL block / timestamp when seen
confirmations INTEGER current depth while confirming; capped at the accepted threshold after confirmation
salt TEXT 32-byte random hex for reference derivation
webhook_delivered_at TEXT NULL RFC3339 timestamp of successful delivery
created_at / updated_at DATETIME

Unique index on (tx_hash, log_index) prevents duplicate intent confirmation.

checkpoints — one row per chain, tracks scan progress

Column Notes
chain_id PK
last_scanned_block block number (EVM), ms timestamp (Tron), unix seconds (TON)

balance_watches — one row per direct-address balance watch

Column Type Notes
watch_id TEXT PK caller-supplied or scanner-generated idempotency key
chain_id / chain_type INTEGER / TEXT currently EVM only for direct balance reads
token_address / token_symbol TEXT ERC-20 contract and optional registry symbol
decimals INTEGER registry decimals for display/metadata
address TEXT watched holder address
baseline_balance TEXT base-unit balance at backend baseline
current_balance TEXT last delivered/current scanner balance
status TEXT watching / stopped / expired
callback_url / callback_secret TEXT signed webhook destination
last_checked_at / next_check_at DATETIME scheduler state
change_count / last_notified_at INTEGER / DATETIME notification audit fields
expires_at DATETIME hard stop after 7 days
created_at / updated_at DATETIME UTC timestamps

Indexes: (status, next_check_at) for due scans and (chain_id, status) for status reporting.


11. Security model

  • All non-health endpoints require Authorization: Bearer <SCANNER_API_KEY> (constant-time compare).
  • If SCANNER_API_KEY is unset the server logs a warning and allows all requests — intended for local dev only.
  • Webhooks are signed with HMAC-SHA256: X-AMN-Signature: hex(hmac(body, callbackSecret)).
  • The callbackSecret is stored in the DB but excluded from all JSON responses (json:"-" tag).
  • Request bodies are limited to 64 KB.
  • Balance-watch callbacks use the same HMAC header plus X-AMN-Delivery-ID=<watchId> and X-AMN-Event-Type=balance_changed.