Files
nick-doc/10 - Services/scanner.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

31 KiB
Raw Permalink Blame History

title, tags, version, created, updated
title tags version created updated
AMN Pay Scanner
service
scanner
payment
go
blockchain
0.1.10 2026-06-08 2026-06-12

AMN Pay Scanner

[!info] Version: 0.1.10 — Go 1.25 — Status: production (BSC + Ethereum + BSC Testnet), other chains staged behind SCANNER_ENABLED_CHAINS Repo: scanner/ within the escrow monorepo. Cross-ref: Scanner Architecture | Scanner API


1. Overview

AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events across EVM chains, Tron, and TON, and notifies the backend via signed webhook when a payment is confirmed.

What it replaces

The platform previously relied on Request Network as its payment infrastructure layer. That dependency introduced:

  • An external smart-contract registry whose canonical proxy addresses differ per chain and cannot be trusted without on-chain verification (see memory note on RN proxy addresses)
  • A closed RN event/webhook pipeline that the backend had no control over
  • A hard SDK coupling between the backend and RN's versioned contracts
  • Inability to support Tron or TON (not in RN's network)

AMN Pay Scanner replaces this entirely by:

  1. Deploying an in-house ERC20FeeProxy contract on each EVM chain under our own control
  2. Polling RPC endpoints directly — no RN nodes, no RN SDK
  3. Deriving payment references in-house using the same keccak256 formula the proxy contract expects
  4. Delivering signed webhooks using a backend-controlled HMAC secret
  5. Supporting direct-address rails (Tron, TON, manual EVM) where no proxy contract is needed

Current status

Chain Status
BNB Smart Chain (56) Production
Ethereum Mainnet (1) Production
BSC Testnet (97) Production (testnet)
Arbitrum One (42161) Staged — verified: false
Polygon (137) Staged — verified: false
Base (8453) Staged — verified: false
Tron Mainnet (728126428) Staged — verified: false
TON Mainnet (1100) Staged — verified: false

2. How It Works

Step-by-step flow

Backend                     Scanner                        Chain
  │                            │                             │
  │  POST /intents             │                             │
  │  {chainId, token, amount,  │                             │
  │   destination, callbackUrl}│                             │
  ├──────────────────────────► │                             │
  │                            │ persist intent (SQLite)     │
  │                            │ derive paymentReference     │
  │                            │ compute topicRef (EVM)      │
  │  {intentId,                │                             │
  │   paymentReference,        │                             │
  │   checkoutBlock}           │                             │
  ◄──────────────────────────── │                             │
  │                            │                             │
  │  (frontend builds tx using │                             │
  │   proxyAddress +           │                             │
  │   paymentReference)        │                             │
  │                            │    poll eth_getLogs         │
  │                            ├────────────────────────────►│
  │                            │    logs []                  │
  │                            ◄────────────────────────────┤
  │                            │ match Topics[1] → topicRef  │
  │                            │ validate token+amount+dest  │
  │                            │ status → confirming         │
  │                            │                             │
  │                            │  (wait confirmationThreshold│
  │                            │   blocks / finality signal) │
  │                            │                             │
  │  POST callbackUrl          │                             │
  │  X-AMN-Signature: ...      │                             │
  │  {intentId, txHash,        │                             │
  │   status:"confirmed", ...} │                             │
  ◄──────────────────────────── │                             │
  │  200 OK                    │                             │
  ├──────────────────────────► │                             │
  │                            │ record webhookDeliveredAt   │

Intent status lifecycle

pending ──(tx seen)──► confirming ──(depth reached)──► confirmed ──(webhook ok)──► [done]
   │                        │                               │
   │                        │ (deep reorg / TTL)            │ (all retries fail)
   └────────────────────────┴──────────────► expired   webhook_failed
  • Tron / TON skip confirming — their chain APIs only surface already-finalized transactions. Status jumps directly to confirmed.
  • Startup reconciliation: on startup, any confirmed intent with webhook_delivered_at IS NULL created within the last 7 days has its webhook re-delivered. This recovers from crashes between finalizeIntent and deliverWebhook.
  • webhook_failed intents are retried every WEBHOOK_RETRY_HOURS (default 6 h) and immediately on POST /admin/webhooks/retry.

3. Supported Chains

Note

Chains marked verified: false in supported-chains.json do not start a worker goroutine at runtime. Force-enable specific chain IDs without a rebuild by setting SCANNER_ENABLED_CHAINS=56,1,42161.

Chain Chain ID Type Proxy Address Confirmation Depth Active by Default
BNB Smart Chain 56 EVM 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 200 blocks (~10 min) yes
Ethereum Mainnet 1 EVM 0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C 50 blocks (~10 min) yes
BSC Testnet 97 EVM 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 5 blocks yes (testnet)
Arbitrum One 42161 EVM 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 2400 blocks (~54 min) no
Polygon 137 EVM 0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9 300 blocks no
Base 8453 EVM 0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814 300 blocks no
Tron Mainnet 728126428 Tron TRC20 USDT contract TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t TronGrid confirmed (~200 reported) no
TON Mainnet 1100 TON USDT Jetton master EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs TonCenter finalized (~120 reported) no

[!warning] Chain-specific notes

  • Ethereum: uses the v0.1.0 proxy ABI at 0x370DE27…. A v0.2.0-next proxy is also deployed at 0x0DfbEe… on ETH but checkout uses the v0.1.0 ABI — do not swap addresses.
  • Arbitrum: 2400-block threshold covers the optimistic rollup challenge window (~54 min at ~1.3 s/block).
  • Base: proxy address 0x1892196… is non-canonical — it differs from the RN CREATE2 expected address for this chain. Verify on-chain before enabling in production.
  • Tron: no fee-proxy contract exists on Tron. Matching is by unique HD-derived destination address, not payment reference.
  • TON: lag is reported in seconds, not blocks. Per-intent polling is O(pending intents) TonCenter calls per cycle.

4. Architecture Diagram

┌──────────────────────────────────────────────────────────────────────┐
│                         scanner binary                               │
│                                                                      │
│  ┌──────────────────┐   ┌────────────────────────────────────────┐  │
│  │    HTTP API       │   │             Worker Pool                │  │
│  │    (api.go)       │   │                                        │  │
│  │                   │   │  ┌────────────────┐  eth_getLogs /    │  │
│  │  POST /intents    │   │  │  ChainWorker   ├─► eth_blockNumber  │  │
│  │  GET  /intents    │   │  │  (EVM × N)     │  (JSON-RPC)       │  │
│  │  DELETE /intents  │   │  └────────────────┘                   │  │
│  │  POST /balances   │   │  ┌────────────────┐  TronGrid REST    │  │
│  │        /check     │   │  │ TronChain-     ├─► /v1/contracts/  │  │
│  │  POST /balance-   │   │  │ Worker         │   {addr}/events   │  │
│  │        watches    │   │  └────────────────┘                   │  │
│  │  GET /balance-    │   │  ┌────────────────┐  TonCenter v3     │  │
│  │        watches/id │   │  │ TonChain-      ├─► /jetton/        │  │
│  │  DEL /balance-    │   │  │ Worker         │   transfers       │  │
│  │        watches/id │   │  └────────────────┘                   │  │
│  │  GET /scanner/    │   └─────────────┬──────────────────────── ┘  │
│  │       status      │                 │ match / confirm             │
│  │  POST /admin/     │                 ▼                             │
│  │   webhooks/retry  │   ┌────────────────────────────────────────┐  │
│  └────────┬──────────┘   │           SQLite (WAL mode)            │  │
│           │              │  intents · checkpoints · balance_watches│  │
│           │              └───────────────┬────────────────────────┘  │
│           │                              │                            │
│           ▼                              ▼                            │
│  ┌─────────────────┐   ┌────────────────────────────────────────┐   │
│  │ BalanceWatch-   │   │  webhook.go                            │   │
│  │ Scheduler       │   │  HMAC-SHA256 sign → POST callbackUrl   │   │
│  │ (balance_       │   │  retry: 5s → 30s → 2m → 10m → 1h      │   │
│  │  watch.go)      │   │                          → webhook_failed│  │
│  └─────────────────┘   └────────────────────────────────────────┘   │
│                                                                      │
│  Background loops (main.go):                                         │
│    • intent TTL expiry (INTENT_TTL_HOURS)                            │
│    • webhook retry loop (WEBHOOK_RETRY_HOURS)                        │
│    • startup reconciliation (confirmed intents, no delivery)         │
└──────────────────────────────────────────────────────────────────────┘

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. API Routes

All endpoints except /health require Authorization: Bearer <SCANNER_API_KEY>. Request bodies are capped at 64 KB.

Method Path Auth Purpose
GET /health none Liveness probe — returns {"status":"ok"}
GET /scanner/status Bearer Chain lag, last scanned block, pending intent count, active balance-watch count per chain
POST /intents Bearer Register a payment intent; returns intentId, paymentReference, checkoutBlock
GET /intents/{id} Bearer Fetch full intent record including current status and tx details
DELETE /intents/{id} Bearer Cancel a pending intent (sets status to expired)
POST /balances/check Bearer Read current ERC-20 balance for an address+token on a given EVM chain
POST /balance-watches Bearer Start an async balance-change watch on an EVM address/token pair
GET /balance-watches/{id} Bearer Fetch balance-watch status, current balance, and check schedule
DELETE /balance-watches/{id} Bearer Stop a balance watch (also: POST /balance-watches/{id}/stop)
POST /admin/webhooks/retry Bearer Force immediate retry of all webhook_failed intents

Full request/response schemas: Scanner API


6. Payment Reference Derivation (EVM)

The ERC20FeeProxy contract indexes payments by a bytes8 reference. The scanner derives it deterministically so the scan loop needs only one indexed DB lookup per log.

# Step 1 — derive the bytes8 payment reference
input            = lower(intentId) + lower(salt) + lower(destination)
paymentReference = last8Bytes(keccak256(input))     ← bytes8, stored as 16 hex chars

# Step 2 — derive the EVM log topic index key
topicRef         = keccak256(paymentReferenceBytes)  ← bytes32, 64 hex chars
                   ↑ this is Topics[1] in every TransferWithReferenceAndFee log
  • salt is a 32-byte random hex string generated at intent creation time to prevent reference collisions.
  • destination is the EVM treasury/seller wallet address, always lowercased before hashing.
  • Both paymentReference and topicRef are written to the intents table at creation time.
  • The scan inner loop executes SELECT * FROM intents WHERE topic_ref = ? AND status = 'pending' — O(1) with index regardless of how many pending intents exist.

Event signature used as Topics[0] filter:

TransferWithReferenceAndFee
  keccak256 = 0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3

7. EVM / Tron / TON Matching Logic

EVM

  1. Worker calls eth_getLogs for the proxy contract address + TransferWithReferenceAndFee event topic in 2 000-block chunks.
  2. For each log, extract Topics[1] (the topicRef).
  3. Query DB: WHERE topic_ref = ? AND status = 'pending'.
  4. On match: decode log.Data to extract tokenAddress, amount, destination, feeAmount. Validate all four against the intent record.
  5. Update status to confirming; record txHash, blockNumber, logIndex.
  6. On subsequent polls: if chainHead - blockNumber + 1 >= confirmationsRequired, finalize and deliver webhook.

Reorg protection: the EVM checkpoint is rewound by 3 × confirmationThreshold blocks (clamped 20500) on every tick. Any log from a reorganized block will be re-fetched and re-matched. The unique index on (tx_hash, log_index) prevents double-confirmation if the same log is matched on two consecutive ticks.

Tron

  • No proxy contract on Tron. Each intent receives a unique HD-derived destination address.
  • Worker polls TronGrid /v1/contracts/{usdtTrc20}/events?event_name=Transfer filtered to the intent's destination address.
  • Match criterion: to == destination AND amount >= intent.Amount.
  • TronGrid only surfaces already-confirmed transactions — status jumps directly to confirmed with no confirming intermediate state.
  • Addresses from TronGrid arrive in 41xxxx (21-byte hex) format. The worker normalizes them to 0x-prefixed 20-byte EVM format for storage and comparison.
  • Checkpoint is stored as a millisecond Unix timestamp in last_scanned_block.
  • Pagination follows meta.links.next until nil.

TON

  • Also uses per-intent unique destination addresses (no proxy contract).
  • Worker calls TonCenter v3 /jetton/transfers?account={destination}&jetton_master={usdtJetton}&start_utime={checkpoint} for each pending TON intent individually.
  • Match criterion: destination == intent.Destination AND amount >= intent.Amount.
  • TonCenter returns only finalized transactions — status jumps directly to confirmed.
  • TON addresses are base64url (EQ…/UQ…), case-sensitive. They must never be lowercased.
  • Checkpoint stored as Unix seconds. Lag reported in seconds, not blocks.
  • Scaling note: each pending TON intent triggers one HTTP round-trip per scan cycle. High concurrency will exhaust TonCenter rate limits.

8. Webhook Payload

Payment confirmed (intent webhook)

Posted to callbackUrl when an intent reaches confirmed status:

{
  "intentId": "018f1a2b-3c4d-7e8f-9a0b-c1d2e3f4a5b6",
  "paymentReference": "0xa1b2c3d4e5f60718",
  "txHash": "0x4a3b2c1d...",
  "blockNumber": 39000010,
  "confirmations": 200,
  "amount": "10000000000000000000",
  "token": "0x55d398326f99059fF775485246999027B3197955",
  "chainId": 56,
  "status": "confirmed"
}

Headers:

  • X-AMN-Signature: hex(HMAC-SHA256(rawBody, callbackSecret))

The confirmations value is capped at the chain acceptance threshold once confirmed. The scanner does not keep incrementing after the payment is safe to credit.

Retry schedule on delivery failure: 5 s → 30 s → 2 min → 10 min → 1 h → webhook_failed

After exhausting retries the intent status becomes webhook_failed. Recovery: POST /admin/webhooks/retry or wait for the WEBHOOK_RETRY_HOURS background sweep (default 6 h).

Balance changed (balance-watch webhook)

Posted to the watch's callbackUrl when a balance delta is detected:

{
  "eventType": "balance_changed",
  "watchId": "payment-123-c56-USDT",
  "chainId": 56,
  "chainType": "evm",
  "address": "0xabc...",
  "tokenAddress": "0x55d398326f99059fF775485246999027B3197955",
  "tokenSymbol": "USDT",
  "decimals": 18,
  "previousBalance": "0",
  "currentBalance": "10000000000000000000",
  "delta": "10000000000000000000",
  "changeCount": 1,
  "checkedAt": "2026-06-08T12:00:00Z",
  "status": "balance_changed"
}

Additional headers:

  • X-AMN-Delivery-ID: <watchId>
  • X-AMN-Event-Type: balance_changed
  • X-AMN-Signature: hex(HMAC-SHA256(rawBody, callbackSecret))

The scanner only advances current_balance in the DB after a successful (2xx) delivery. A down backend will get the same notification on the next scheduled check.

Watch polling cadence (age-decayed):

  • First 24 hours: every 5 minutes
  • 2448 hours: every 10 minutes
  • 4896 hours: every 20 minutes
  • 96+ hours: every 40 minutes
  • Hard expiry: 7 days after creation

9. SQLite DB Schema

Database at DB_PATH (default ./scanner.db; Docker: /data/scanner.db). WAL mode with 5 000 ms busy timeout. Connection pool capped at 1 to serialize writes.

intents

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 smallest unit (wei / TRC-20 units / nanoton)
payment_reference TEXT 8-byte hex — EVM proxy rail only
topic_ref TEXT keccak256(paymentReferenceBytes) — EVM scan index
status TEXT pending / confirming / confirmed / expired / webhook_failed
callback_url TEXT Backend webhook endpoint
callback_secret TEXT HMAC key — excluded from all JSON responses (json:"-")
confirmations_required INTEGER Set to chain acceptance floor at intent creation
tx_hash TEXT NULL Set once the transaction is seen on-chain
log_index INTEGER NULL Log position within tx (EVM only)
block_number INTEGER NULL Block number (EVM); ms timestamp (Tron); unix seconds (TON)
confirmations INTEGER Depth while confirming; capped at threshold after confirmation
salt TEXT 32-byte random hex used in reference derivation
webhook_delivered_at TEXT NULL RFC3339 timestamp of first successful delivery
created_at / updated_at DATETIME UTC

Indexes: (status), (chain_id, status), (payment_reference), (topic_ref). Unique index: (tx_hash, log_index) WHERE tx_hash IS NOT NULL — prevents double-confirmation.

checkpoints

Column Notes
chain_id PK Numeric chain ID
last_scanned_block Block number (EVM), ms timestamp (Tron), unix seconds (TON)
updated_at UTC

balance_watches

Column Type Notes
watch_id TEXT PK Caller-supplied idempotency key
chain_id / chain_type INTEGER / TEXT Currently EVM only
token_address / token_symbol TEXT ERC-20 contract + optional registry symbol
decimals INTEGER Token decimals for display
address TEXT Watched holder address
baseline_balance TEXT Base-unit balance at watch creation
current_balance TEXT Last successfully delivered balance
status TEXT watching / stopped / expired
callback_url / callback_secret TEXT Signed webhook destination + HMAC key
last_checked_at / next_check_at DATETIME Scheduler state
change_count / last_notified_at INTEGER / DATETIME Notification audit
expires_at DATETIME Hard stop 7 days after creation
created_at / updated_at DATETIME UTC

Indexes: (status, next_check_at) for due-scan queries; (chain_id, status) for status reporting.


10. Configuration

All configuration via environment variables. Copy .env.example before first run.

Variable Default Required Notes
PORT 8080 no HTTP listen port
DB_PATH ./scanner.db no SQLite file path. Docker: mount /data, set /data/scanner.db
CHAINS_JSON_PATH ./supported-chains.json no Chain registry JSON file
TOKENS_JSON_PATH ./tokens.json no Token registry for symbol/decimals metadata
SCANNER_API_KEY (none) yes (prod) Shared secret for Bearer auth. Generate: openssl rand -hex 32. Unset = all requests allowed (dev only)
POLL_INTERVAL_SEC 15 no Chain polling interval in seconds
INTENT_TTL_HOURS 24 no Expire pending intents after N hours. 0 = disabled
WEBHOOK_RETRY_HOURS 6 no Background re-delivery interval for webhook_failed intents. 0 = disabled
BALANCE_WATCH_TICK_SEC 60 no How often the scheduler checks for due balance watches
BALANCE_WATCH_BATCH_SIZE 50 no Max due watches processed per tick
RPC_BSC chain config no Override BSC JSON-RPC URL
RPC_ARB chain config no Override Arbitrum JSON-RPC URL
RPC_ETH chain config no Override Ethereum JSON-RPC URL
RPC_POLYGON chain config no Override Polygon JSON-RPC URL
RPC_BASE chain config no Override Base JSON-RPC URL
TRONGRID_API_KEY (none) strongly recommended Free tier is severely rate-limited; required for real Tron traffic
TONCENTER_API_KEY (none) recommended Rate-limited without key
SCANNER_ENABLED_CHAINS JSON verified flags no Comma-separated chain IDs to activate, overriding verified field. E.g. 56,1
SCANNER_CALLBACK_ALLOWED_HOSTS (none) prod recommended Comma-separated hosts/IPs allowed as callbackUrl targets (SSRF guard)

11. Docker Deployment

# Build
docker build -t amn-scanner .

# Run (standalone)
docker run -d \
  --name amn-scanner \
  --network shared-web \
  -p 8080:8080 \
  -v /opt/arcane/data/projects/escrow-dev/scanner-data:/data \
  --env-file .env \
  -e DB_PATH=/data/scanner.db \
  amn-scanner

Dev server (89.58.32.32)

The scanner is part of the escrow-dev Arcane project. The dev stack builds images locally — it does not pull from any registry.

# 1. Copy changed scanner source files
scp -i ~/CascadeProjects/wzp scanner/*.go root@89.58.32.32:/tmp/escrow-backend-build/scanner/

# 2. Rebuild image on server (~23 min)
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
  "cd /tmp/escrow-backend-build/scanner && \
   docker build -t amn-scanner-local:dev . && \
   cd /opt/arcane/data/projects/escrow-dev && \
   docker compose up -d scanner"

Health check: curl http://amn-scanner:8080/health (internal) or via the Caddyfile vhost.

Health probe

GET /health
→ {"status":"ok"}

12. Integration with the Backend

The backend wires the scanner through the amn.scanner provider. See memory note amn_scanner_payin_wiring for full service/dispatch registration and the 6 required env vars.

Registering a payment intent

// src/services/amnScanner/...
const resp = await fetch(`${SCANNER_URL}/intents`, {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${SCANNER_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    intentId: payment._id.toString(),      // MongoDB ObjectId string
    chainId: 56,
    tokenAddress: '0x55d398326f99059fF775485246999027B3197955',  // USDT BSC
    destination: sellerWalletAddress,
    amount: amountInWei,                   // base-10 string, smallest unit
    callbackUrl: `${BACKEND_URL}/api/payment/amn-scanner/webhook`,
    callbackSecret: process.env.SCANNER_CALLBACK_SECRET,
  }),
});
const { intentId, paymentReference, checkoutBlock } = await resp.json();
// Store intentId in the payment record
// Pass checkoutBlock to the frontend for transaction construction

The checkoutBlock response contains everything the frontend needs to call ERC20FeeProxy.transferWithReferenceAndFee():

{
  "destination": "0x...",
  "tokenAddress": "0x55d398326f99059fF775485246999027B3197955",
  "tokenSymbol": "USDT",
  "decimals": 18,
  "chainId": 56,
  "proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
  "paymentReference": "0xa1b2c3d4e5f60718",
  "feeAmount": "0",
  "feeAddress": "0x0000000000000000000000000000000000000000",
  "amountWei": "10000000000000000000"
}

[!note] Token decimals Read token decimals on-chain, not from an internal registry. The scanner's checkoutBlock.decimals comes from tokens.json, which may lag registry updates.

Receiving the webhook callback

// POST /api/payment/amn-scanner/webhook
app.post('/api/payment/amn-scanner/webhook', async (req, res) => {
  const signature = req.headers['x-amn-signature'];
  const expected  = hmacSha256Hex(req.rawBody, process.env.SCANNER_CALLBACK_SECRET);
  if (!timingSafeEqual(signature, expected)) return res.status(401).end();

  const { intentId, status, txHash, amount, chainId } = req.body;
  if (status !== 'confirmed') return res.status(200).end(); // ignore non-terminal

  await paymentService.markConfirmed({ intentId, txHash, amount, chainId });
  res.status(200).end();
});

[!warning] Always scope by provider The backend must always scope payment lookups to provider: "amn.scanner". Sweeping all pending payments will destroy records from other providers (RN, manual). See memory note on payment cleanup + provider filter.

Using direct balance checks (non-proxy flows)

// Synchronous balance read (manual payment flow)
const { balance } = await scannerClient.post('/balances/check', {
  chainId: 56,
  address: sellerWalletAddress,
  token: 'USDT',
});
// Store baseline, then re-check when buyer clicks "I paid"

// Async balance watch
await scannerClient.post('/balance-watches', {
  watchId: `payment-${paymentId}-c56-USDT`,
  chainId: 56,
  address: sellerWalletAddress,
  token: 'USDT',
  callbackUrl: `${BACKEND_URL}/api/payment/amn-scanner/webhook`,
  callbackSecret: process.env.SCANNER_CALLBACK_SECRET,
  baselineBalance: '0',
});

// Stop watch after payment resolved
await scannerClient.delete(`/balance-watches/payment-${paymentId}-c56-USDT`);

Backend environment variables

SCANNER_URL=http://amn-scanner:8080
SCANNER_API_KEY=<same value as scanner SCANNER_API_KEY>
SCANNER_CALLBACK_SECRET=<shared HMAC key, same value used in callbackSecret field>

13. Known Limitations / Open Items

# Area Description
1 TON scaling O(pending intents) TonCenter API calls per scan cycle. Becomes a bottleneck above ~50 concurrent TON intents. Future fix: batch by address or use a streaming/webhook API.
2 Tron/TON balance watches POST /balances/check and POST /balance-watches only support EVM ERC-20 (eth_call balanceOf). Tron TRC-20 and TON Jetton balance reads are future scope.
3 Non-EVM chains unverified Arbitrum, Polygon, Base, Tron, TON are "verified": false — workers do not start by default. Needs production testing and SCANNER_ENABLED_CHAINS opt-in before enabling for real traffic.
4 Base proxy address non-canonical 0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814 differs from the RN CREATE2 expected address for Base. Must be verified on-chain before enabling Base in production.
5 Ethereum proxy version Chain 1 uses the v0.1.0 proxy at 0x370DE27…. A v0.2.0 proxy is also deployed on ETH. The two have different ABI signatures — do not silently swap addresses.
6 Single SQLite instance The embedded SQLite database is not horizontally scalable. Running multiple scanner replicas would require coordination or migration to a shared DB (Postgres). Acceptable for current load.
7 No native-token support Only ERC-20, TRC-20, and Jetton (TON) transfers are scanned. Native token payments (BNB, ETH, TRX, TON coin) are not supported.
8 Single-seller only AMN Scanner pay-in supports single-seller flow. Multi-seller cart payments and cross-chain routing are not implemented.
9 No webhook key rotation HMAC-SHA256 with a pre-shared callbackSecret. There is no key rotation mechanism — changing the secret requires re-registering intents.
10 No EVM testnet for ETH/Polygon/Base Only BSC Testnet (97) has a testnet entry in supported-chains.json. Testing on Ethereum Sepolia or Polygon Amoy requires manually adding chain entries.
11 Arbitrum threshold latency The 2400-block Arbitrum threshold (~54 min) is deliberately conservative for the optimistic rollup challenge window. This makes Arbitrum slow for real-time escrow use.