Merge remote-tracking branch 'origin/main'
# Conflicts: # 09 - Audits/Activity Log.md
This commit is contained in:
@@ -9,7 +9,7 @@ created: 2026-05-30
|
|||||||
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.
|
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]
|
> [!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.
|
> 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,6 +21,8 @@ AMN Pay Scanner is a standalone Go microservice that watches on-chain payment ev
|
|||||||
- Deliver a signed webhook to the backend callback URL when confirmed
|
- Deliver a signed webhook to the backend callback URL when confirmed
|
||||||
- Retry failed webhook deliveries
|
- Retry failed webhook deliveries
|
||||||
- Expire stale pending intents on a configurable TTL
|
- 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,10 +43,13 @@ AMN Pay Scanner is a standalone Go microservice that watches on-chain payment ev
|
|||||||
│ │ └── TonChainWorker ton_chain.go (TON) │
|
│ │ └── TonChainWorker ton_chain.go (TON) │
|
||||||
│ ├── HTTP routes api.go / main.go │
|
│ ├── HTTP routes api.go / main.go │
|
||||||
│ ├── intent TTL expiry main.go + intent.go │
|
│ ├── intent TTL expiry main.go + intent.go │
|
||||||
│ └── webhook retry loop main.go + webhook.go │
|
│ ├── webhook retry loop main.go + webhook.go │
|
||||||
|
│ └── BalanceWatchScheduler balance_watch.go │
|
||||||
│ │
|
│ │
|
||||||
│ reference.go — payment reference / topic hash math │
|
│ reference.go — payment reference / topic hash math │
|
||||||
│ webhook.go — delivery, HMAC signing, retry │
|
│ webhook.go — delivery, HMAC signing, retry │
|
||||||
|
│ balance.go — EVM ERC-20 balanceOf(address) reads │
|
||||||
|
│ balance_watch.go — balance_watches state + webhooks │
|
||||||
└─────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,7 +145,32 @@ pending ──(tx seen)──► confirming ──(enough blocks)──► confi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Payment reference math (EVM)
|
## 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 |
|
||||||
|
|---|---|
|
||||||
|
| 0–24 h | 5 min |
|
||||||
|
| 24–48 h | 10 min |
|
||||||
|
| 48–72 h | 20 min |
|
||||||
|
| 72 h–7 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)))
|
paymentReference = last8Bytes(keccak256(lower(intentId + salt + destination)))
|
||||||
@@ -151,9 +181,9 @@ The ERC20FeeProxy indexes `paymentReference` so `Topics[1]` in the log is `topic
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Database schema (SQLite WAL)
|
## 10. Database schema (SQLite WAL)
|
||||||
|
|
||||||
Two tables:
|
Three main tables:
|
||||||
|
|
||||||
**`intents`** — one row per payment intent
|
**`intents`** — one row per payment intent
|
||||||
|
|
||||||
@@ -188,12 +218,33 @@ Unique index on `(tx_hash, log_index)` prevents duplicate intent confirmation.
|
|||||||
| `chain_id` | PK |
|
| `chain_id` | PK |
|
||||||
| `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) |
|
| `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Security model
|
## 11. Security model
|
||||||
|
|
||||||
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
|
- 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.
|
- 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))`.
|
- 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).
|
- The `callbackSecret` is stored in the DB but excluded from all JSON responses (`json:"-"` tag).
|
||||||
- Request bodies are limited to 64 KB.
|
- 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`.
|
||||||
|
|||||||
130
02 - Data Models/ScannerBalanceWatch.md
Normal file
130
02 - Data Models/ScannerBalanceWatch.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
title: ScannerBalanceWatch (Scanner DB model)
|
||||||
|
tags: [data-model, scanner, payment]
|
||||||
|
created: 2026-06-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# ScannerBalanceWatch
|
||||||
|
|
||||||
|
SQLite row in the AMN Pay Scanner's `balance_watches` table. One row represents a direct-address token balance watch requested by the backend for non-smart-contract payment detection.
|
||||||
|
|
||||||
|
This is scanner-internal state. It is not a Mongoose model and lives in the scanner SQLite database (`/data/scanner.db`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE balance_watches (
|
||||||
|
watch_id TEXT PRIMARY KEY,
|
||||||
|
chain_id INTEGER NOT NULL,
|
||||||
|
chain_type TEXT NOT NULL DEFAULT 'evm',
|
||||||
|
token_address TEXT NOT NULL,
|
||||||
|
token_symbol TEXT,
|
||||||
|
decimals INTEGER NOT NULL DEFAULT 0,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
baseline_balance TEXT NOT NULL,
|
||||||
|
current_balance TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'watching',
|
||||||
|
callback_url TEXT NOT NULL,
|
||||||
|
callback_secret TEXT NOT NULL,
|
||||||
|
last_checked_at DATETIME,
|
||||||
|
next_check_at DATETIME NOT NULL,
|
||||||
|
change_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_notified_at DATETIME,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_balance_watches_status_next
|
||||||
|
ON balance_watches(status, next_check_at);
|
||||||
|
|
||||||
|
CREATE INDEX idx_balance_watches_chain_status
|
||||||
|
ON balance_watches(chain_id, status);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `watch_id` | TEXT PK | Caller-supplied or scanner-generated idempotency key. Backend should use a payment-scoped value such as `<paymentId>-balance-c56-USDT` when it wants webhook correlation by prefix. |
|
||||||
|
| `chain_id` | INTEGER | Numeric EVM chain ID. Direct balance reads currently support EVM ERC-20 only. |
|
||||||
|
| `chain_type` | TEXT | Currently `evm` for balance watches. Kept for future Tron/TON support. |
|
||||||
|
| `token_address` | TEXT | ERC-20 token contract address, normalized to lowercase `0x` hex. |
|
||||||
|
| `token_symbol` | TEXT NULL | Optional token symbol resolved from `tokens.json`. |
|
||||||
|
| `decimals` | INTEGER | Token decimals resolved from registry when available. |
|
||||||
|
| `address` | TEXT | Watched holder address, normalized to lowercase `0x` hex. |
|
||||||
|
| `baseline_balance` | TEXT | Base-unit integer string captured or supplied when the watch is created. |
|
||||||
|
| `current_balance` | TEXT | Last scanner-accepted base-unit balance. For changed balances, this advances only after webhook delivery succeeds. |
|
||||||
|
| `status` | TEXT | Watch lifecycle state. |
|
||||||
|
| `callback_url` | TEXT | Backend webhook endpoint. Validated with the same callback URL guard as scanner intents. |
|
||||||
|
| `callback_secret` | TEXT | HMAC-SHA256 key for the balance watch webhook signature. Never returned in API responses. |
|
||||||
|
| `last_checked_at` | DATETIME NULL | Last time the scanner attempted a balance read. |
|
||||||
|
| `next_check_at` | DATETIME | Scheduler due time. |
|
||||||
|
| `change_count` | INTEGER | Count of successfully delivered balance-change notifications. |
|
||||||
|
| `last_notified_at` | DATETIME NULL | Time of the last successful balance-change notification. |
|
||||||
|
| `expires_at` | DATETIME | Hard stop timestamp, currently 7 days after creation. |
|
||||||
|
| `created_at` / `updated_at` | DATETIME | UTC timestamps. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status values
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|---|---|
|
||||||
|
| `watching` | Scheduler polls the address/token when `next_check_at` is due. |
|
||||||
|
| `stopped` | Backend explicitly stopped the watch after payment success, cancellation, or manual resolution. |
|
||||||
|
| `expired` | Scanner stopped the watch automatically after 7 days. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Polling cadence
|
||||||
|
|
||||||
|
| Age from `created_at` | Interval |
|
||||||
|
|---|---|
|
||||||
|
| `< 24h` | 5 min |
|
||||||
|
| `24h–48h` | 10 min |
|
||||||
|
| `48h–72h` | 20 min |
|
||||||
|
| `> 72h` | 40 min until expiry |
|
||||||
|
|
||||||
|
`BALANCE_WATCH_TICK_SEC` controls how often the scheduler queries for due watches. `BALANCE_WATCH_BATCH_SIZE` controls how many due watches are processed per tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook semantics
|
||||||
|
|
||||||
|
When `current_balance` changes, scanner sends:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventType": "balance_changed",
|
||||||
|
"watchId": "6840fabc-balance-c56-USDT",
|
||||||
|
"chainId": 56,
|
||||||
|
"chainType": "evm",
|
||||||
|
"address": "0x...",
|
||||||
|
"tokenAddress": "0x...",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"previousBalance": "25000000000000000000",
|
||||||
|
"currentBalance": "35000000000000000000",
|
||||||
|
"delta": "10000000000000000000",
|
||||||
|
"changeCount": 1,
|
||||||
|
"checkedAt": "2026-06-03T10:05:00Z",
|
||||||
|
"status": "balance_changed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend must not treat this webhook alone as final escrow funding. It should compare `delta` or `(currentBalance - baselineBalance)` to the expected amount, apply token/chain/address checks, persist evidence, and stop the watch when the payment is accepted or cancelled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
|
||||||
|
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
|
||||||
|
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
|
||||||
|
- [ScannerIntent](ScannerIntent.md)
|
||||||
|
- [Payment](Payment.md) — backend MongoDB/DTO model that stores payment metadata
|
||||||
@@ -111,4 +111,5 @@ A backfill pass recomputes `topic_ref` for existing EVM intents that had it as N
|
|||||||
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
|
- [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md)
|
||||||
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
|
- [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
|
||||||
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
|
- [Payment Flow - Scanner](../04%20-%20Flows/Payment%20Flow%20-%20Scanner.md)
|
||||||
|
- [ScannerBalanceWatch](ScannerBalanceWatch.md) — direct-address balance-watch model for non-smart-contract payment rails
|
||||||
- [Payment](Payment.md) — the backend MongoDB model that triggers intent creation
|
- [Payment](Payment.md) — the backend MongoDB model that triggers intent creation
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`.
|
|||||||
|
|
||||||
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>` when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
|
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>` when the key is configured in the environment (production). In dev mode (key not set) all requests are allowed.
|
||||||
|
|
||||||
|
Scanner `0.1.8` adds direct-address EVM ERC-20 balance checks and balance watches for non-smart-contract payment rails. Tron/TON direct balance watches are future scope; their existing intent scanners still work through `/intents`.
|
||||||
|
|
||||||
Base URL (dev): `http://localhost:8080`
|
Base URL (dev): `http://localhost:8080`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -110,6 +112,139 @@ Fetch the current state of a payment intent.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## POST /balances/check
|
||||||
|
|
||||||
|
Read the current ERC-20 token balance for a public EVM address. The backend uses this for direct-address payment rails, including an initial baseline read when the address is shown to the buyer and a second read when the buyer clicks "I paid".
|
||||||
|
|
||||||
|
**Request body** (`application/json`):
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `chainId` | integer | yes | EVM chain ID configured in `supported-chains.json` |
|
||||||
|
| `address` | string | yes | Holder address to read |
|
||||||
|
| `tokenAddress` | string | conditional | ERC-20 contract address. Required unless `token`/`tokenSymbol` resolves in `tokens.json` |
|
||||||
|
| `token` | string | conditional | Token symbol alias, e.g. `USDT`; same meaning as `tokenSymbol` |
|
||||||
|
| `tokenSymbol` | string | conditional | Token symbol alias, e.g. `USDT` |
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chainId": 56,
|
||||||
|
"address": "0x1111111111111111111111111111111111111111",
|
||||||
|
"token": "USDT"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chainId": 56,
|
||||||
|
"chainType": "evm",
|
||||||
|
"address": "0x1111111111111111111111111111111111111111",
|
||||||
|
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"balance": "25000000000000000000",
|
||||||
|
"checkedAt": "2026-06-03T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`balance` is a base-unit integer string. It is not formatted into human token units.
|
||||||
|
|
||||||
|
**Error cases:**
|
||||||
|
|
||||||
|
| Status | Body | Cause |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | `{"error":"chainId is required"}` | Missing chain |
|
||||||
|
| 400 | `{"error":"balance checks are currently supported for evm chains only"}` | Non-EVM chain |
|
||||||
|
| 400 | `{"error":"tokenAddress or token is required"}` | No token selector |
|
||||||
|
| 400 | `{"error":"unsupported token USDT on chainId 999"}` | Token symbol not registered |
|
||||||
|
| 502 | `{"error":"balance check failed: ..."}` | RPC read failed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /balance-watches
|
||||||
|
|
||||||
|
Create or replay a direct-address balance watch. A watch stores the current token balance and polls for changes. When the balance changes, the scanner sends a signed `balance_changed` webhook to `callbackUrl`.
|
||||||
|
|
||||||
|
**Request body** (`application/json`):
|
||||||
|
|
||||||
|
| Field | Type | Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `watchId` | string | no | Caller-supplied idempotency key. If omitted, scanner generates `bw_<hex>` |
|
||||||
|
| `chainId` | integer | yes | EVM chain ID |
|
||||||
|
| `address` | string | yes | Address to watch |
|
||||||
|
| `tokenAddress` | string | conditional | ERC-20 contract address unless `token`/`tokenSymbol` resolves |
|
||||||
|
| `token` / `tokenSymbol` | string | conditional | Token symbol from `tokens.json` |
|
||||||
|
| `callbackUrl` | string | yes | Backend webhook URL |
|
||||||
|
| `callbackSecret` | string | yes | HMAC key for `X-AMN-Signature` |
|
||||||
|
| `baselineBalance` | string | no | Optional base-unit integer baseline. If omitted, scanner uses the initial balance read |
|
||||||
|
|
||||||
|
**Example request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"watchId": "6840fabc-balance-c56-USDT",
|
||||||
|
"chainId": 56,
|
||||||
|
"address": "0x1111111111111111111111111111111111111111",
|
||||||
|
"token": "USDT",
|
||||||
|
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
|
||||||
|
"callbackSecret": "abc123...",
|
||||||
|
"baselineBalance": "25000000000000000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response `200 OK`:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"watch": {
|
||||||
|
"watchId": "6840fabc-balance-c56-USDT",
|
||||||
|
"chainId": 56,
|
||||||
|
"chainType": "evm",
|
||||||
|
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"address": "0x1111111111111111111111111111111111111111",
|
||||||
|
"baselineBalance": "25000000000000000000",
|
||||||
|
"currentBalance": "25000000000000000000",
|
||||||
|
"status": "watching",
|
||||||
|
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
|
||||||
|
"nextCheckAt": "2026-06-03T10:05:00Z",
|
||||||
|
"changeCount": 0,
|
||||||
|
"expiresAt": "2026-06-10T10:00:00Z",
|
||||||
|
"createdAt": "2026-06-03T10:00:00Z",
|
||||||
|
"updatedAt": "2026-06-03T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Idempotency**: Reusing the same `watchId` with the same chain, address, token, and callback returns the existing watch. Reusing it with different parameters returns `409`.
|
||||||
|
|
||||||
|
**Cadence**: checks every 5 minutes during the first 24 hours, then 10 minutes until 48 hours, 20 minutes until 72 hours, and 40 minutes until the watch expires after 7 days.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /balance-watches/{watchId}
|
||||||
|
|
||||||
|
Fetch the current watch state. `callbackSecret` is excluded from the response.
|
||||||
|
|
||||||
|
**Response `200 OK`:** `{ "watch": BalanceWatch }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DELETE /balance-watches/{watchId}
|
||||||
|
|
||||||
|
Stop a watch after the backend accepts, cancels, or times out the payment. Stopped watches are not polled.
|
||||||
|
|
||||||
|
`POST /balance-watches/{watchId}/stop` is accepted as an equivalent stop command.
|
||||||
|
|
||||||
|
**Response `200 OK`:** `{ "watch": BalanceWatch }` with `status: "stopped"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## GET /scanner/status
|
## GET /scanner/status
|
||||||
|
|
||||||
Returns scan progress for all verified chains.
|
Returns scan progress for all verified chains.
|
||||||
@@ -126,7 +261,8 @@ Returns scan progress for all verified chains.
|
|||||||
"lastScannedBlock": 39000000,
|
"lastScannedBlock": 39000000,
|
||||||
"chainHead": 39000015,
|
"chainHead": 39000015,
|
||||||
"lag": 15,
|
"lag": 15,
|
||||||
"pendingIntents": 3
|
"pendingIntents": 3,
|
||||||
|
"activeBalanceWatches": 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"chainId": 728126428,
|
"chainId": 728126428,
|
||||||
@@ -135,7 +271,8 @@ Returns scan progress for all verified chains.
|
|||||||
"lastScannedBlock": 1748500000000,
|
"lastScannedBlock": 1748500000000,
|
||||||
"chainHead": 1748500015000,
|
"chainHead": 1748500015000,
|
||||||
"lag": 15000,
|
"lag": 15000,
|
||||||
"pendingIntents": 1
|
"pendingIntents": 1,
|
||||||
|
"activeBalanceWatches": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"chainId": 1100,
|
"chainId": 1100,
|
||||||
@@ -144,7 +281,8 @@ Returns scan progress for all verified chains.
|
|||||||
"lastScannedBlock": 1748500000,
|
"lastScannedBlock": 1748500000,
|
||||||
"chainHead": 1748500015,
|
"chainHead": 1748500015,
|
||||||
"lag": 15,
|
"lag": 15,
|
||||||
"pendingIntents": 0
|
"pendingIntents": 0,
|
||||||
|
"activeBalanceWatches": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -222,6 +360,42 @@ const expected = createHmac('sha256', callbackSecret).update(rawBody).digest('he
|
|||||||
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
|
if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Balance watch webhook
|
||||||
|
|
||||||
|
When a balance watch observes a changed balance, the scanner POSTs to the watch `callbackUrl`.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
|
||||||
|
| Header | Value |
|
||||||
|
|---|---|
|
||||||
|
| `Content-Type` | `application/json` |
|
||||||
|
| `X-AMN-Signature` | `hex(HMAC-SHA256(body, callbackSecret))` |
|
||||||
|
| `X-AMN-Delivery-ID` | watchId |
|
||||||
|
| `X-AMN-Event-Type` | `balance_changed` |
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"eventType": "balance_changed",
|
||||||
|
"watchId": "6840fabc-balance-c56-USDT",
|
||||||
|
"chainId": 56,
|
||||||
|
"chainType": "evm",
|
||||||
|
"address": "0x1111111111111111111111111111111111111111",
|
||||||
|
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"previousBalance": "25000000000000000000",
|
||||||
|
"currentBalance": "35000000000000000000",
|
||||||
|
"delta": "10000000000000000000",
|
||||||
|
"changeCount": 1,
|
||||||
|
"checkedAt": "2026-06-03T10:05:00Z",
|
||||||
|
"status": "balance_changed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The scanner retries the changed-balance webhook inside the same due-check pass with short backoffs. If delivery still fails, it does not advance `currentBalance`; the same change is retried on the next scheduled due check.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data models
|
## Data models
|
||||||
@@ -252,3 +426,28 @@ if (!timingSafeEqual(Buffer.from(received), Buffer.from(expected))) reject();
|
|||||||
```
|
```
|
||||||
|
|
||||||
Note: `callbackUrl` and `callbackSecret` are present in the DB but `callbackSecret` is always omitted from API responses.
|
Note: `callbackUrl` and `callbackSecret` are present in the DB but `callbackSecret` is always omitted from API responses.
|
||||||
|
|
||||||
|
### BalanceWatch object
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"watchId": "string",
|
||||||
|
"chainId": 56,
|
||||||
|
"chainType": "evm",
|
||||||
|
"tokenAddress": "0x...",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"address": "0x...",
|
||||||
|
"baselineBalance": "25000000000000000000",
|
||||||
|
"currentBalance": "25000000000000000000",
|
||||||
|
"status": "watching | stopped | expired",
|
||||||
|
"callbackUrl": "https://api.amn.gg/api/payment/amn-scanner/webhook",
|
||||||
|
"lastCheckedAt": null,
|
||||||
|
"nextCheckAt": "2026-06-03T10:05:00Z",
|
||||||
|
"changeCount": 0,
|
||||||
|
"lastNotifiedAt": null,
|
||||||
|
"expiresAt": "2026-06-10T10:00:00Z",
|
||||||
|
"createdAt": "2026-06-03T10:00:00Z",
|
||||||
|
"updatedAt": "2026-06-03T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ created: 2026-05-30
|
|||||||
|
|
||||||
# Payment Flow — AMN Pay Scanner (In-House)
|
# Payment Flow — AMN Pay Scanner (In-House)
|
||||||
|
|
||||||
> **Last updated:** 2026-05-31 — documented backend `2.6.82` / scanner `0.1.7` capped accepted confirmation floors.
|
> **Last updated:** 2026-06-03 — documented backend/frontend `2.8.60` and scanner `0.1.8` direct-address balance checks and balance watches.
|
||||||
|
|
||||||
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
|
End-to-end payment flow using the in-house AMN Pay Scanner, replacing the Request Network integration. The scanner is a separate microservice; the backend talks to it over an internal HTTP API.
|
||||||
|
|
||||||
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md)
|
See also: [Scanner Architecture](../01%20-%20Architecture/Scanner%20Architecture.md), [Scanner API](../03%20-%20API%20Reference/Scanner%20API.md), [PRD - Direct Address Token Payments via Scanner Balance Watches](../PRD%20-%20Direct%20Address%20Token%20Payments%20via%20Scanner%20Balance%20Watches.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,7 +133,85 @@ Backend returns a 2xx response. Scanner records `webhook_delivered_at` and the i
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Failure paths
|
## 3. Direct-address payment mode
|
||||||
|
|
||||||
|
Scanner `0.1.8` adds a non-smart-contract rail for cases where the buyer transfers tokens directly to a backend-assigned address instead of calling `ERC20FeeProxy`.
|
||||||
|
|
||||||
|
This rail is currently EVM ERC-20 only. Tron/TON direct balance reads are future scope.
|
||||||
|
|
||||||
|
### Mode A — synchronous balance check
|
||||||
|
|
||||||
|
```
|
||||||
|
Buyer Backend Scanner EVM RPC
|
||||||
|
│ │ │ │
|
||||||
|
│ open checkout │ │ │
|
||||||
|
│────────────────────►│ │ │
|
||||||
|
│ │ POST /balances/check │
|
||||||
|
│ │───────────────────►│ eth_call balanceOf │
|
||||||
|
│ │◄───────────────────│◄────────────────── │
|
||||||
|
│ address + amount │ │ │
|
||||||
|
│◄────────────────────│ │ │
|
||||||
|
│ direct token transfer ──────────────────────────────────────►│
|
||||||
|
│ click "I paid" │ │ │
|
||||||
|
│────────────────────►│ │ │
|
||||||
|
│ │ POST /balances/check │
|
||||||
|
│ │───────────────────►│ eth_call balanceOf │
|
||||||
|
│ │◄───────────────────│◄────────────────── │
|
||||||
|
│ payment accepted if delta >= expected amount │
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend responsibilities:
|
||||||
|
|
||||||
|
1. Allocate or select the payment address.
|
||||||
|
2. Call scanner `POST /balances/check` to store a base-unit `baselineBalance`.
|
||||||
|
3. Show the address/token/amount to the buyer.
|
||||||
|
4. When buyer clicks "I paid", call `POST /balances/check` again.
|
||||||
|
5. Compare `(currentBalance - baselineBalance)` to the expected base-unit amount.
|
||||||
|
6. Persist evidence and run the normal payment/ledger transition only after chain, token, address, and amount checks pass.
|
||||||
|
|
||||||
|
### Mode B — balance watch
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend Scanner EVM RPC
|
||||||
|
│ POST /balance-watches │ │
|
||||||
|
│──────────────────────►│ initial balanceOf │
|
||||||
|
│◄──────────────────────│ │
|
||||||
|
│ │ every 5m, then 10/20/40m
|
||||||
|
│ │───────────────────►│
|
||||||
|
│ │ balance changed │
|
||||||
|
│◄──────────────────────│ signed webhook │
|
||||||
|
│ payment accepted │ │
|
||||||
|
│ DELETE /balance-watches/{watchId} │
|
||||||
|
│──────────────────────►│ status=stopped │
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend `2.8.60` exposes scanner helper functions in `amnPayAdapter.ts`:
|
||||||
|
|
||||||
|
| Helper | Scanner endpoint |
|
||||||
|
|---|---|
|
||||||
|
| `checkScannerTokenBalance` | `POST /balances/check` |
|
||||||
|
| `createScannerBalanceWatch` | `POST /balance-watches` |
|
||||||
|
| `stopScannerBalanceWatch` | `DELETE /balance-watches/{watchId}` |
|
||||||
|
|
||||||
|
Backend `2.8.60` also accepts signed `balance_changed` scanner webhooks on the existing AMN scanner webhook route. The current webhook handler records `amnScannerBalanceWatch` metadata and returns `202`; it does not yet mark the payment funded on balance change alone. The product decision rule still needs to be implemented by the backend work described in the PRD.
|
||||||
|
|
||||||
|
Recommended watch ID shape: `<paymentId>-balance-c<chainId>-<TOKEN>`. The webhook handler maps this back to the payment ID prefix.
|
||||||
|
|
||||||
|
Scanner cadence:
|
||||||
|
|
||||||
|
| Age | Interval |
|
||||||
|
|---|---|
|
||||||
|
| First 24h | 5 min |
|
||||||
|
| 24–48h | 10 min |
|
||||||
|
| 48–72h | 20 min |
|
||||||
|
| 72h–7d | 40 min |
|
||||||
|
| After 7d | `expired` |
|
||||||
|
|
||||||
|
Backend must stop a watch when payment is accepted, cancelled, manually resolved, or no longer relevant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Failure paths
|
||||||
|
|
||||||
### Webhook delivery failure
|
### Webhook delivery failure
|
||||||
|
|
||||||
@@ -166,9 +244,18 @@ Transfers where the on-chain amount is less than `intent.Amount` are silently sk
|
|||||||
|
|
||||||
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
|
The EVM log decoder validates all three fields (token, destination, amount). Mismatches are logged as `REJECT` and skipped. The intent remains `pending`.
|
||||||
|
|
||||||
|
### Balance watch change but payment not complete
|
||||||
|
|
||||||
|
`balance_changed` means the watched balance changed; it is not a final paid signal by itself. Backend must reject or keep waiting when:
|
||||||
|
|
||||||
|
- `delta` is less than the expected payment amount.
|
||||||
|
- The balance decreased or moved by an unrelated amount.
|
||||||
|
- The watch address/token/chain do not match the payment metadata.
|
||||||
|
- The payment was already completed, cancelled, refunded, or superseded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Key differences from Request Network integration
|
## 5. Key differences from Request Network integration
|
||||||
|
|
||||||
| Dimension | Request Network | AMN Pay Scanner |
|
| Dimension | Request Network | AMN Pay Scanner |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -180,3 +267,4 @@ The EVM log decoder validates all three fields (token, destination, amount). Mis
|
|||||||
| Confirmations | RN handled | Per-chain configurable |
|
| Confirmations | RN handled | Per-chain configurable |
|
||||||
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
|
| Webhook | RN webhook → backend adapter | Scanner → backend directly |
|
||||||
| State store | External (RN cloud) | Internal SQLite |
|
| State store | External (RN cloud) | Internal SQLite |
|
||||||
|
| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail |
|
||||||
|
|||||||
@@ -133,6 +133,33 @@ Request Network is the current primary payment provider. See [[PRD - Request Net
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Payments — AMN Pay Scanner
|
||||||
|
|
||||||
|
Backend scanner settings:
|
||||||
|
|
||||||
|
| Name | Repo | Required | Default | Example | Purpose |
|
||||||
|
|------|------|----------|---------|---------|---------|
|
||||||
|
| `AMN_SCANNER_URL` | backend | required when `amn.scanner` is enabled | — | `http://amn-scanner:8080` | Internal scanner service base URL used by `amnPayAdapter` helpers. |
|
||||||
|
| `AMN_SCANNER_API_KEY` | backend | prod | — | 64 hex chars | Bearer token sent to scanner when scanner `SCANNER_API_KEY` is configured. |
|
||||||
|
| `AMN_SCANNER_WEBHOOK_SECRET` | backend | required when `amn.scanner` is enabled | — | 64 hex chars | Shared HMAC key used to verify scanner intent and balance-watch webhooks. |
|
||||||
|
| `AMN_SCANNER_DEFAULT` | backend | optional | `false` | `true` | Makes AMN scanner the default pay-in provider where provider selection allows it. |
|
||||||
|
|
||||||
|
Scanner service settings:
|
||||||
|
|
||||||
|
| Name | Repo | Required | Default | Example | Purpose |
|
||||||
|
|------|------|----------|---------|---------|---------|
|
||||||
|
| `SCANNER_API_KEY` | scanner | prod | — | 64 hex chars | Bearer token required for all scanner endpoints except `/health`. |
|
||||||
|
| `BALANCE_WATCH_TICK_SEC` | scanner | optional | `60` | `60` | How often the balance-watch scheduler queries for due watches. |
|
||||||
|
| `BALANCE_WATCH_BATCH_SIZE` | scanner | optional | `50` | `50` | Max due balance watches processed per scheduler tick. |
|
||||||
|
| `DB_PATH` | scanner | optional | `./scanner.db` | `/data/scanner.db` | SQLite state path for intents, checkpoints, and balance watches. |
|
||||||
|
| `CHAINS_JSON_PATH` | scanner | optional | `./supported-chains.json` | `/app/supported-chains.json` | Chain registry path. |
|
||||||
|
| `TOKENS_JSON_PATH` | scanner | optional | `./tokens.json` | `/app/tokens.json` | Token registry path used for `token`/`tokenSymbol` balance requests. |
|
||||||
|
| `RPC_BSC` / `RPC_ETH` / `RPC_POLYGON` / `RPC_ARB` / `RPC_BASE` | scanner | optional | chain config | provider URL | EVM RPC overrides used by intent scanners and `balanceOf` reads. |
|
||||||
|
|
||||||
|
Direct-address balance checks and watches currently support EVM ERC-20 only. Backend code should use `checkScannerTokenBalance`, `createScannerBalanceWatch`, and `stopScannerBalanceWatch` from `amnPayAdapter.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Repository Mode Flags (Migration Layer)
|
## Repository Mode Flags (Migration Layer)
|
||||||
|
|
||||||
| Name | Repo | Required | Default | Example | Purpose |
|
| Name | Repo | Required | Default | Example | Purpose |
|
||||||
@@ -363,6 +390,7 @@ SWEEP_GAS_TOP_UP_BNB=0.002
|
|||||||
|
|
||||||
# AMN Pay Scanner (replaces Request Network for pay-in detection)
|
# AMN Pay Scanner (replaces Request Network for pay-in detection)
|
||||||
AMN_SCANNER_URL=
|
AMN_SCANNER_URL=
|
||||||
|
AMN_SCANNER_API_KEY=
|
||||||
AMN_SCANNER_WEBHOOK_SECRET=
|
AMN_SCANNER_WEBHOOK_SECRET=
|
||||||
AMN_SCANNER_DEFAULT=false
|
AMN_SCANNER_DEFAULT=false
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ All configuration via environment variables. See `.env.example` in the scanner r
|
|||||||
| `POLL_INTERVAL_SEC` | `15` | no | Chain poll interval in seconds |
|
| `POLL_INTERVAL_SEC` | `15` | no | Chain poll interval in seconds |
|
||||||
| `INTENT_TTL_HOURS` | `24` | no | Pending/confirming intents older than this are expired (0 = disabled) |
|
| `INTENT_TTL_HOURS` | `24` | no | Pending/confirming intents older than this are expired (0 = disabled) |
|
||||||
| `WEBHOOK_RETRY_HOURS` | `6` | no | Interval between automatic webhook_failed re-delivery passes (0 = disabled) |
|
| `WEBHOOK_RETRY_HOURS` | `6` | no | Interval between automatic webhook_failed re-delivery passes (0 = disabled) |
|
||||||
|
| `BALANCE_WATCH_TICK_SEC` | `60` | no | Scheduler tick for due direct-address balance watches |
|
||||||
|
| `BALANCE_WATCH_BATCH_SIZE` | `50` | no | Max due balance watches processed per scheduler tick |
|
||||||
| `TRONGRID_API_KEY` | _(none)_ | recommended | TronGrid API key; without it rate limits are very low |
|
| `TRONGRID_API_KEY` | _(none)_ | recommended | TronGrid API key; without it rate limits are very low |
|
||||||
| `TONCENTER_API_KEY` | _(none)_ | recommended | TonCenter API key |
|
| `TONCENTER_API_KEY` | _(none)_ | recommended | TonCenter API key |
|
||||||
| `RPC_BSC` | _(chain config)_ | no | Override BSC RPC URL (chain 56) |
|
| `RPC_BSC` | _(chain config)_ | no | Override BSC RPC URL (chain 56) |
|
||||||
@@ -92,6 +94,7 @@ curl -H "Authorization: Bearer $SCANNER_API_KEY" \
|
|||||||
Check:
|
Check:
|
||||||
- `lag` — should be near 0 for healthy chains (blocks behind for EVM, seconds for TON)
|
- `lag` — should be near 0 for healthy chains (blocks behind for EVM, seconds for TON)
|
||||||
- `pendingIntents` — number of unresolved intents per chain
|
- `pendingIntents` — number of unresolved intents per chain
|
||||||
|
- `activeBalanceWatches` — number of direct-address watches in `watching` status per chain
|
||||||
- `lastScannedBlock` — should advance each poll
|
- `lastScannedBlock` — should advance each poll
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
@@ -109,6 +112,11 @@ The scanner uses Go's `log/slog` structured logger with level prefixes. Key log
|
|||||||
| `[webhook] all retries exhausted` | Intent moved to webhook_failed |
|
| `[webhook] all retries exhausted` | Intent moved to webhook_failed |
|
||||||
| `[scanner] reconciling confirmed intents` | Startup crash recovery in progress |
|
| `[scanner] reconciling confirmed intents` | Startup crash recovery in progress |
|
||||||
| `[evm] scanner lag` | Chain lag > 100 blocks (investigate RPC) |
|
| `[evm] scanner lag` | Chain lag > 100 blocks (investigate RPC) |
|
||||||
|
| `[scanner] balance watch scheduler started` | Balance watch polling loop started |
|
||||||
|
| `[api] balance watch created` | Backend registered a direct-address watch |
|
||||||
|
| `[balance-watch] balance read error` | RPC failed while reading a watched balance |
|
||||||
|
| `[balance-watch-webhook] delivered` | Changed-balance webhook POST succeeded |
|
||||||
|
| `[balance-watch-webhook] non-2xx response` | Backend rejected changed-balance webhook; scanner will retry the change later |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,6 +183,15 @@ SELECT chain_id, last_scanned_block, updated_at FROM checkpoints;
|
|||||||
|
|
||||||
# Count by status
|
# Count by status
|
||||||
SELECT status, count(*) FROM intents GROUP BY status;
|
SELECT status, count(*) FROM intents GROUP BY status;
|
||||||
|
|
||||||
|
# Check active direct-address watches
|
||||||
|
SELECT watch_id, chain_id, token_symbol, address, current_balance, next_check_at, expires_at
|
||||||
|
FROM balance_watches
|
||||||
|
WHERE status = 'watching'
|
||||||
|
ORDER BY next_check_at ASC;
|
||||||
|
|
||||||
|
# Count watches by status
|
||||||
|
SELECT status, count(*) FROM balance_watches GROUP BY status;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -206,6 +223,29 @@ SELECT status, count(*) FROM intents GROUP BY status;
|
|||||||
|
|
||||||
The scanner accepts any amount **>=** `intent.Amount`. Overpayments are not flagged. Underpayments result in the intent staying pending until TTL expiry.
|
The scanner accepts any amount **>=** `intent.Amount`. Overpayments are not flagged. Underpayments result in the intent staying pending until TTL expiry.
|
||||||
|
|
||||||
|
### Direct balance watch is not firing
|
||||||
|
|
||||||
|
1. Confirm the target chain is EVM. Scanner `0.1.8` direct balance checks use ERC-20 `balanceOf(address)` and do not yet support Tron/TON balance reads.
|
||||||
|
2. Check `/scanner/status` for `activeBalanceWatches` on the expected chain.
|
||||||
|
3. Inspect `balance_watches.next_check_at`; if it is in the future, the scheduler is waiting according to the decay cadence.
|
||||||
|
4. Check logs for `[balance-watch] balance read error`; RPC failures reschedule the watch without notifying backend.
|
||||||
|
5. Confirm `callbackUrl` and `callbackSecret` match backend `AMN_SCANNER_WEBHOOK_SECRET`.
|
||||||
|
6. If `[balance-watch-webhook] non-2xx response` appears, inspect backend logs for the AMN scanner webhook route. The scanner keeps `current_balance` unchanged and retries the same balance change on the next due check.
|
||||||
|
|
||||||
|
### Direct balance watch should stop
|
||||||
|
|
||||||
|
Use either stop form:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE -H "Authorization: Bearer $SCANNER_API_KEY" \
|
||||||
|
http://localhost:8080/balance-watches/<watchId>
|
||||||
|
|
||||||
|
curl -X POST -H "Authorization: Bearer $SCANNER_API_KEY" \
|
||||||
|
http://localhost:8080/balance-watches/<watchId>/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend should stop a watch after payment acceptance, cancellation, manual resolution, or when the payment is no longer payable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. CI/CD notes
|
## 10. CI/CD notes
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ entries on top. Maintained by agents per the rule in `../AGENTS.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 2026-06-03 — scanner@ccd96e8, backend@22ae0bd, frontend@10c4292 — direct scanner balance checks and balance watches
|
||||||
|
|
||||||
|
**Commits:** scanner `ccd96e8` (tag `v0.1.8`), backend `22ae0bd` (tag `v2.8.60`), frontend `10c4292` (tag `v2.8.60`)
|
||||||
|
**Touched:** scanner: `balance.go`, `balance_watch.go`, `balance_test.go`, `api.go`, `config.go`, `intent.go`, `main.go`, `README.md`, `VERSION`; backend: `src/services/payment/adapters/amnPayAdapter.ts`, `src/routes/amnScannerWebhookRoutes.ts`, `package.json`, `package-lock.json`; frontend: `package.json` version metadata only.
|
||||||
|
**Why:** Add scanner primitives for non-smart-contract/direct-address token payments: synchronous ERC-20 balance checks, persisted balance watches with 5/10/20/40-minute cadence decay, 7-day expiry, signed `balance_changed` callbacks, and backend adapter/webhook plumbing. Backend still needs the product decision layer that turns validated balance deltas into funded payments; documented in the new direct-address PRD.
|
||||||
|
**Verification:** scanner `GOCACHE=/private/tmp/codex-go-cache go test -count=1 ./...`; backend `npm run typecheck`; frontend not built because only version metadata changed.
|
||||||
|
**Linked docs updated:** [[Scanner API]], [[Scanner Architecture]], [[Payment Flow - Scanner]], [[ScannerBalanceWatch]], [[Scanner Operations]], [[Environment Variables]], [[PRD - Direct Address Token Payments via Scanner Balance Watches]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 2026-06-03 — frontend@9bafbbb — Telegram Mini App: full in-shell shop, account tab parity, and shopping cart (v2.8.57–v2.8.59)
|
### 2026-06-03 — frontend@9bafbbb — Telegram Mini App: full in-shell shop, account tab parity, and shopping cart (v2.8.57–v2.8.59)
|
||||||
|
|
||||||
**Commits:** `a8ae1e3` (v2.8.57), `6dc3918` (v2.8.58), `9bafbbb` (v2.8.59) — frontend only; backend stays at v2.8.56
|
**Commits:** `a8ae1e3` (v2.8.57), `6dc3918` (v2.8.58), `9bafbbb` (v2.8.59) — frontend only; backend stays at v2.8.56
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
---
|
||||||
|
title: PRD - Direct Address Token Payments via Scanner Balance Watches
|
||||||
|
tags: [prd, payment, backend, scanner]
|
||||||
|
created: 2026-06-03
|
||||||
|
---
|
||||||
|
|
||||||
|
# PRD — Direct Address Token Payments via Scanner Balance Watches
|
||||||
|
|
||||||
|
> Status: **Backend implementation PRD**
|
||||||
|
> Updated: 2026-06-03
|
||||||
|
> Related releases: scanner `0.1.8`, backend/frontend `2.8.60`
|
||||||
|
> Related docs: [[Scanner API]], [[Scanner Architecture]], [[Payment Flow - Scanner]], [[ScannerBalanceWatch]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a backend payment rail where the buyer can pay by sending an ERC-20 token directly to a public address, without calling an escrow/proxy smart contract.
|
||||||
|
|
||||||
|
The scanner now provides the low-level primitives:
|
||||||
|
|
||||||
|
- `POST /balances/check` — read the current EVM ERC-20 balance for an address/token.
|
||||||
|
- `POST /balance-watches` — poll an address/token and send `balance_changed` webhooks.
|
||||||
|
- `DELETE /balance-watches/{watchId}` — stop a watch once the backend no longer needs it.
|
||||||
|
|
||||||
|
Backend `2.8.60` has the first plumbing layer:
|
||||||
|
|
||||||
|
- `checkScannerTokenBalance`, `createScannerBalanceWatch`, and `stopScannerBalanceWatch` helpers in `amnPayAdapter.ts`.
|
||||||
|
- AMN scanner webhook route accepts signed `balance_changed` payloads and stores `metadata.amnScannerBalanceWatch`.
|
||||||
|
|
||||||
|
The remaining backend work is the product decision layer: create direct-address pay-in intents, store baselines, compare balance deltas to expected amounts, transition payments safely, and stop watches at the right time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Some pay-in flows should not require a smart-contract call from the buyer. The buyer should be able to:
|
||||||
|
|
||||||
|
1. Receive a public address and token/chain instruction.
|
||||||
|
2. Send a normal ERC-20 transfer from any wallet or exchange.
|
||||||
|
3. Click "I paid" for an immediate backend check, or let the backend/scanner watch for arrival.
|
||||||
|
4. Have escrow funded only when the backend proves the expected token balance increased enough.
|
||||||
|
|
||||||
|
This is especially useful for exchange withdrawals, simple wallet transfers, and users who cannot or do not want to call `ERC20FeeProxy`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Support direct-address ERC-20 payment detection through scanner balance reads.
|
||||||
|
- Support both backend modes:
|
||||||
|
- **Check-on-click**: read baseline, then read again when buyer clicks "I paid".
|
||||||
|
- **Watch mode**: scanner polls every 5 minutes initially, decays cadence after 24 hours, and expires after 7 days.
|
||||||
|
- Store enough backend evidence to explain why a payment was accepted or rejected.
|
||||||
|
- Keep escrow funding idempotent and safe against duplicate webhooks, unrelated transfers, and underpayments.
|
||||||
|
- Stop scanner watches after success, cancellation, manual resolution, or timeout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Tron/TON direct balance watches. Scanner `0.1.8` supports EVM ERC-20 balance reads only.
|
||||||
|
- Automatic credit from any arbitrary balance increase. Backend must validate amount, chain, token, address, status, and idempotency first.
|
||||||
|
- Replacing the existing smart-contract scanner intent flow. `/intents` remains the primary smart-contract rail.
|
||||||
|
- Sweeping funds from derived addresses. Sweep/settlement remains a separate custody workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User flows
|
||||||
|
|
||||||
|
### Flow A — check-on-click
|
||||||
|
|
||||||
|
1. Buyer chooses direct token payment.
|
||||||
|
2. Backend allocates a payment address and resolves `chainId`, token address, decimals, and expected base-unit amount.
|
||||||
|
3. Backend calls scanner `POST /balances/check` and stores `baselineBalance`.
|
||||||
|
4. Backend returns address/token/amount instructions to the frontend.
|
||||||
|
5. Buyer sends a direct ERC-20 transfer.
|
||||||
|
6. Buyer clicks "I paid".
|
||||||
|
7. Backend calls scanner `POST /balances/check` again.
|
||||||
|
8. Backend accepts only if `currentBalance - baselineBalance >= expectedAmountBaseUnits`.
|
||||||
|
9. Backend persists evidence and transitions payment through the normal funded path.
|
||||||
|
|
||||||
|
### Flow B — watch mode
|
||||||
|
|
||||||
|
1. Buyer chooses direct token payment.
|
||||||
|
2. Backend creates the same baseline and payment instructions.
|
||||||
|
3. Backend calls scanner `POST /balance-watches` with a payment-scoped `watchId`.
|
||||||
|
4. Scanner polls and sends `balance_changed` webhook when balance changes.
|
||||||
|
5. Backend validates the delta and accepts or keeps waiting.
|
||||||
|
6. Backend calls `stopScannerBalanceWatch(watchId)` once the payment is accepted, cancelled, or manually closed.
|
||||||
|
7. If no payment arrives, scanner expires the watch after 7 days. Backend should also mark the payment expired according to its own payment TTL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend requirements
|
||||||
|
|
||||||
|
### 1. Payment metadata
|
||||||
|
|
||||||
|
Add a canonical metadata object for this rail:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
metadata: {
|
||||||
|
amnScannerDirectBalance?: {
|
||||||
|
mode: 'check' | 'watch';
|
||||||
|
watchId?: string;
|
||||||
|
chainId: number;
|
||||||
|
tokenAddress: string;
|
||||||
|
tokenSymbol?: string;
|
||||||
|
decimals: number;
|
||||||
|
address: string;
|
||||||
|
expectedAmount: string; // base-unit integer string
|
||||||
|
baselineBalance: string; // base-unit integer string
|
||||||
|
currentBalance?: string;
|
||||||
|
paidDelta?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastCheckedAt?: string;
|
||||||
|
lastWebhookAt?: string;
|
||||||
|
stoppedAt?: string;
|
||||||
|
stopReason?: 'paid' | 'cancelled' | 'expired' | 'manual';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use base-unit integer strings everywhere. Human-readable amounts are display-only.
|
||||||
|
|
||||||
|
### 2. Service layer
|
||||||
|
|
||||||
|
Create a backend service, for example `directBalancePaymentService`, that owns:
|
||||||
|
|
||||||
|
- Address allocation or selection for the payment.
|
||||||
|
- Token/chain resolution from buyer selection.
|
||||||
|
- Conversion of the human payment amount to base units.
|
||||||
|
- Scanner baseline reads.
|
||||||
|
- Scanner watch creation and stop calls.
|
||||||
|
- Delta validation.
|
||||||
|
- Idempotent payment transition.
|
||||||
|
- Evidence persistence.
|
||||||
|
|
||||||
|
The AMN scanner adapter helpers should remain low-level HTTP clients. Business rules belong in the payment service layer.
|
||||||
|
|
||||||
|
### 3. Create direct payment intent
|
||||||
|
|
||||||
|
Extend the payment intent creation path or add an internal route so the backend can create direct-address payment instructions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider": "amn.scanner",
|
||||||
|
"rail": "direct_balance",
|
||||||
|
"mode": "check",
|
||||||
|
"chainId": 56,
|
||||||
|
"token": "USDT",
|
||||||
|
"amount": "10.00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response should include:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paymentId": "...",
|
||||||
|
"providerPaymentId": "...",
|
||||||
|
"rail": "direct_balance",
|
||||||
|
"mode": "check",
|
||||||
|
"chainId": 56,
|
||||||
|
"tokenAddress": "0x55d398326f99059ff775485246999027b3197955",
|
||||||
|
"tokenSymbol": "USDT",
|
||||||
|
"decimals": 18,
|
||||||
|
"address": "0x...",
|
||||||
|
"amount": "10.00",
|
||||||
|
"amountBaseUnits": "10000000000000000000",
|
||||||
|
"baselineBalance": "25000000000000000000",
|
||||||
|
"expiresAt": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For watch mode, include `watchId` and `nextCheckAt`.
|
||||||
|
|
||||||
|
Recommended `watchId`: `<paymentId>-balance-c<chainId>-<TOKEN>`.
|
||||||
|
|
||||||
|
### 4. "I paid" endpoint
|
||||||
|
|
||||||
|
Add a backend action used by the frontend after the buyer clicks "I paid":
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payments/:paymentId/direct-balance/check
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
1. Load payment and `metadata.amnScannerDirectBalance`.
|
||||||
|
2. Reject if payment is not payable.
|
||||||
|
3. Call `checkScannerTokenBalance`.
|
||||||
|
4. Compute `delta = currentBalance - baselineBalance`.
|
||||||
|
5. If `delta < expectedAmount`, persist last check and return a pending response.
|
||||||
|
6. If `delta >= expectedAmount`, run the same funded transition path used by other providers.
|
||||||
|
7. Stop the watch if one exists.
|
||||||
|
|
||||||
|
### 5. Balance-watch webhook decision
|
||||||
|
|
||||||
|
Backend `2.8.60` records the webhook. The next step is to make the handler delegate to the direct balance payment service:
|
||||||
|
|
||||||
|
1. Verify signature using `AMN_SCANNER_WEBHOOK_SECRET`.
|
||||||
|
2. Resolve payment by `watchId` or `<paymentId>-balance-...` prefix.
|
||||||
|
3. Reject if provider is not `amn.scanner`.
|
||||||
|
4. Compare payload chain/token/address to stored metadata.
|
||||||
|
5. Compute paid delta against stored `baselineBalance`, not only payload `previousBalance`.
|
||||||
|
6. If paid, transition the payment idempotently and stop the watch.
|
||||||
|
7. If underpaid or unrelated, store evidence and keep the watch active.
|
||||||
|
|
||||||
|
The handler should continue to return `202` for accepted-but-not-funded events and `2xx` after successful funded transitions so scanner can advance `current_balance`.
|
||||||
|
|
||||||
|
### 6. Idempotency and payment safety
|
||||||
|
|
||||||
|
Backend must guard:
|
||||||
|
|
||||||
|
- Duplicate webhooks for the same balance change.
|
||||||
|
- Replayed "I paid" clicks.
|
||||||
|
- Payment already completed/cancelled/refunded.
|
||||||
|
- Balance decreases.
|
||||||
|
- Balance increases smaller than expected.
|
||||||
|
- Transfers to the right address but wrong token/chain.
|
||||||
|
- Multiple deposits where the sum since baseline reaches the expected amount.
|
||||||
|
|
||||||
|
The safest rule is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
paid = currentBalance - baselineBalance >= expectedAmountBaseUnits
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles one payment, multiple partial transfers, and scanner webhook retries.
|
||||||
|
|
||||||
|
### 7. Expiry and cleanup
|
||||||
|
|
||||||
|
- Keep the backend payment TTL independent from scanner's 7-day watch expiry.
|
||||||
|
- Stop scanner watches when backend payment state leaves payable states.
|
||||||
|
- Add a scheduled cleanup that stops stale watches for payments that are completed/cancelled/expired but still have `mode='watch'` and no `stoppedAt`.
|
||||||
|
- Preserve old watch metadata for audit.
|
||||||
|
|
||||||
|
### 8. Admin and observability
|
||||||
|
|
||||||
|
Add operator-visible fields:
|
||||||
|
|
||||||
|
- Payment detail: direct balance mode, watched address, token, expected amount, baseline, current balance, paid delta, watch status.
|
||||||
|
- Admin scanner status: include `activeBalanceWatches` from `/scanner/status`.
|
||||||
|
- Logs:
|
||||||
|
- direct balance baseline created
|
||||||
|
- direct balance check pending/paid
|
||||||
|
- balance watch webhook pending/paid/rejected
|
||||||
|
- watch stop success/failure
|
||||||
|
|
||||||
|
### 9. Tests and smoke coverage
|
||||||
|
|
||||||
|
Required backend tests:
|
||||||
|
|
||||||
|
- Adapter helper sends bearer auth and calls `/balances/check`.
|
||||||
|
- Adapter helper creates and stops watches.
|
||||||
|
- Direct balance service accepts `delta >= expectedAmount`.
|
||||||
|
- Direct balance service keeps payment pending for underpayment.
|
||||||
|
- Direct balance service is idempotent after duplicate webhook or duplicate "I paid" click.
|
||||||
|
- Webhook route verifies HMAC and routes `balance_changed` payloads.
|
||||||
|
- Webhook route rejects wrong provider/payment mismatch.
|
||||||
|
- Watch stop is called after funded transition.
|
||||||
|
|
||||||
|
Recommended smoke script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE_URL=http://localhost:5001 bash backend/scripts/smoke/amn-scanner-direct-balance.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke can mock scanner responses at first. A live smoke should be added only when a dev scanner/RPC fixture is available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. Backend can create direct-address payment instructions with a stored scanner baseline.
|
||||||
|
2. Buyer "I paid" triggers a backend balance check and returns pending or paid based on delta.
|
||||||
|
3. Watch mode creates a scanner watch and records `watchId`.
|
||||||
|
4. Signed `balance_changed` webhook can fund the payment only after backend delta validation passes.
|
||||||
|
5. Underpayments remain pending with stored evidence.
|
||||||
|
6. Duplicate webhooks and duplicate "I paid" clicks do not double-credit ledger or rerun settlement.
|
||||||
|
7. Backend stops the scanner watch after payment success/cancel/expiry.
|
||||||
|
8. Admin/operator docs show how to inspect active watches and troubleshoot callback failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation phases
|
||||||
|
|
||||||
|
| Phase | Scope | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 | Scanner primitives and backend low-level adapter/webhook recorder | Done in scanner `0.1.8`, backend `2.8.60` |
|
||||||
|
| 1 | Backend direct-balance service and payment metadata shape | Not started |
|
||||||
|
| 2 | Check-on-click API path and tests | Not started |
|
||||||
|
| 3 | Watch-mode lifecycle, webhook decision, and stop cleanup | Not started |
|
||||||
|
| 4 | Frontend checkout controls and "I paid" action | Not started |
|
||||||
|
| 5 | Smoke tests and admin observability | Not started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. Which address allocator should direct balance payments use first: existing derived destination addresses, seller wallet, or a dedicated platform collection address?
|
||||||
|
2. Should underpayments accumulate indefinitely until payment TTL, or should there be a minimum single-transfer threshold?
|
||||||
|
3. Should overpayments be accepted silently like scanner intents, or flagged for admin review?
|
||||||
|
4. Should the default mode be check-on-click to reduce RPC load, or watch mode for a more automatic buyer experience?
|
||||||
|
5. What backend state should expire first when scanner watches are 7 days but product payment TTL may be shorter?
|
||||||
Reference in New Issue
Block a user