--- title: AMN Pay Scanner tags: [service, scanner, payment, go, blockchain] version: 0.1.10 created: 2026-06-08 updated: 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 `. 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 20–500) 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: ```json { "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: ```json { "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: ` - `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 - 24–48 hours: every 10 minutes - 48–96 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 ```bash # 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. ```bash # 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 (~2–3 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 ```typescript // 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()`: ```json { "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 ```typescript // 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) ```typescript // 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= SCANNER_CALLBACK_SECRET= ``` --- ## 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. |