- 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>
31 KiB
title, tags, version, created, updated
| title | tags | version | created | updated | |||||
|---|---|---|---|---|---|---|---|---|---|
| AMN Pay Scanner |
|
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_CHAINSRepo: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:
- Deploying an in-house
ERC20FeeProxycontract on each EVM chain under our own control - Polling RPC endpoints directly — no RN nodes, no RN SDK
- Deriving payment references in-house using the same keccak256 formula the proxy contract expects
- Delivering signed webhooks using a backend-controlled HMAC secret
- 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 toconfirmed. - Startup reconciliation: on startup, any
confirmedintent withwebhook_delivered_at IS NULLcreated within the last 7 days has its webhook re-delivered. This recovers from crashes betweenfinalizeIntentanddeliverWebhook. webhook_failedintents are retried everyWEBHOOK_RETRY_HOURS(default 6 h) and immediately onPOST /admin/webhooks/retry.
3. Supported Chains
Note
Chains marked
verified: falseinsupported-chains.jsondo not start a worker goroutine at runtime. Force-enable specific chain IDs without a rebuild by settingSCANNER_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 at0x0DfbEe…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
saltis a 32-byte random hex string generated at intent creation time to prevent reference collisions.destinationis the EVM treasury/seller wallet address, always lowercased before hashing.- Both
paymentReferenceandtopicRefare written to theintentstable 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
- Worker calls
eth_getLogsfor the proxy contract address +TransferWithReferenceAndFeeevent topic in 2 000-block chunks. - For each log, extract
Topics[1](thetopicRef). - Query DB:
WHERE topic_ref = ? AND status = 'pending'. - On match: decode
log.Datato extracttokenAddress,amount,destination,feeAmount. Validate all four against the intent record. - Update status to
confirming; recordtxHash,blockNumber,logIndex. - 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=Transferfiltered to the intent's destination address. - Match criterion:
to == destination AND amount >= intent.Amount. - TronGrid only surfaces already-confirmed transactions — status jumps directly to
confirmedwith noconfirmingintermediate state. - Addresses from TronGrid arrive in
41xxxx(21-byte hex) format. The worker normalizes them to0x-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.nextuntil 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_changedX-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
# 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 (~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
// 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.decimalscomes fromtokens.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. |