diff --git a/01 - Architecture/Scanner Architecture.md b/01 - Architecture/Scanner Architecture.md index 97b7e67..2a28ba2 100644 --- a/01 - Architecture/Scanner Architecture.md +++ b/01 - Architecture/Scanner Architecture.md @@ -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. > [!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 - Retry failed webhook deliveries - Expire stale pending intents on a configurable TTL +- Read an EVM ERC-20 balance on demand for a backend-supplied address/token +- Watch an EVM address/token pair for balance changes, decay polling cadence over time, and stop after backend cancellation or 7-day expiry --- @@ -41,10 +43,13 @@ AMN Pay Scanner is a standalone Go microservice that watches on-chain payment ev │ │ └── TonChainWorker ton_chain.go (TON) │ │ ├── HTTP routes api.go / main.go │ │ ├── intent TTL expiry main.go + intent.go │ -│ └── webhook retry loop main.go + webhook.go │ +│ ├── webhook retry loop main.go + webhook.go │ +│ └── BalanceWatchScheduler balance_watch.go │ │ │ │ reference.go — payment reference / topic hash math │ │ webhook.go — delivery, HMAC signing, retry │ +│ balance.go — EVM ERC-20 balanceOf(address) reads │ +│ balance_watch.go — balance_watches state + webhooks │ └─────────────────────────────────────────────────────────┘ ``` @@ -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))) @@ -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 @@ -188,12 +218,33 @@ Unique index on `(tx_hash, log_index)` prevents duplicate intent confirmation. | `chain_id` | PK | | `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) | +**`balance_watches`** — one row per direct-address balance watch + +| Column | Type | Notes | +|---|---|---| +| `watch_id` | TEXT PK | caller-supplied or scanner-generated idempotency key | +| `chain_id` / `chain_type` | INTEGER / TEXT | currently EVM only for direct balance reads | +| `token_address` / `token_symbol` | TEXT | ERC-20 contract and optional registry symbol | +| `decimals` | INTEGER | registry decimals for display/metadata | +| `address` | TEXT | watched holder address | +| `baseline_balance` | TEXT | base-unit balance at backend baseline | +| `current_balance` | TEXT | last delivered/current scanner balance | +| `status` | TEXT | `watching` / `stopped` / `expired` | +| `callback_url` / `callback_secret` | TEXT | signed webhook destination | +| `last_checked_at` / `next_check_at` | DATETIME | scheduler state | +| `change_count` / `last_notified_at` | INTEGER / DATETIME | notification audit fields | +| `expires_at` | DATETIME | hard stop after 7 days | +| `created_at` / `updated_at` | DATETIME | UTC timestamps | + +Indexes: `(status, next_check_at)` for due scans and `(chain_id, status)` for status reporting. + --- -## 10. Security model +## 11. Security model - All non-health endpoints require `Authorization: Bearer ` (constant-time compare). - If `SCANNER_API_KEY` is unset the server logs a warning and allows all requests — intended for local dev only. - Webhooks are signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`. - The `callbackSecret` is stored in the DB but excluded from all JSON responses (`json:"-"` tag). - Request bodies are limited to 64 KB. +- Balance-watch callbacks use the same HMAC header plus `X-AMN-Delivery-ID=` and `X-AMN-Event-Type=balance_changed`. diff --git a/02 - Data Models/ScannerBalanceWatch.md b/02 - Data Models/ScannerBalanceWatch.md new file mode 100644 index 0000000..040a7c6 --- /dev/null +++ b/02 - Data Models/ScannerBalanceWatch.md @@ -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 `-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 diff --git a/02 - Data Models/ScannerIntent.md b/02 - Data Models/ScannerIntent.md index 4cb5d9b..2184ac1 100644 --- a/02 - Data Models/ScannerIntent.md +++ b/02 - Data Models/ScannerIntent.md @@ -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 API](../03%20-%20API%20Reference/Scanner%20API.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 diff --git a/03 - API Reference/Scanner API.md b/03 - API Reference/Scanner API.md index 2d65cdf..90baaff 100644 --- a/03 - API Reference/Scanner API.md +++ b/03 - API Reference/Scanner API.md @@ -10,6 +10,8 @@ HTTP API exposed by the AMN Pay Scanner microservice. Default port: `8080`. All endpoints except `/health` require `Authorization: Bearer ` 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` --- @@ -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_` | +| `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 Returns scan progress for all verified chains. @@ -126,7 +261,8 @@ Returns scan progress for all verified chains. "lastScannedBlock": 39000000, "chainHead": 39000015, "lag": 15, - "pendingIntents": 3 + "pendingIntents": 3, + "activeBalanceWatches": 2 }, { "chainId": 728126428, @@ -135,7 +271,8 @@ Returns scan progress for all verified chains. "lastScannedBlock": 1748500000000, "chainHead": 1748500015000, "lag": 15000, - "pendingIntents": 1 + "pendingIntents": 1, + "activeBalanceWatches": 0 }, { "chainId": 1100, @@ -144,7 +281,8 @@ Returns scan progress for all verified chains. "lastScannedBlock": 1748500000, "chainHead": 1748500015, "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(); ``` +### 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 @@ -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. + +### 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" +} +``` diff --git a/04 - Flows/Payment Flow - Scanner.md b/04 - Flows/Payment Flow - Scanner.md index 1313abc..703a12a 100644 --- a/04 - Flows/Payment Flow - Scanner.md +++ b/04 - Flows/Payment Flow - Scanner.md @@ -6,11 +6,11 @@ created: 2026-05-30 # 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. -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: `-balance-c-`. 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 @@ -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`. +### 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 | |---|---|---| @@ -180,3 +267,4 @@ The EVM log decoder validates all three fields (token, destination, amount). Mis | Confirmations | RN handled | Per-chain configurable | | Webhook | RN webhook → backend adapter | Scanner → backend directly | | State store | External (RN cloud) | Internal SQLite | +| Direct address payments | Not supported | EVM ERC-20 balance check/watch rail | diff --git a/07 - Development/Environment Variables.md b/07 - Development/Environment Variables.md index daa2054..da2ae56 100644 --- a/07 - Development/Environment Variables.md +++ b/07 - Development/Environment Variables.md @@ -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) | 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_SCANNER_URL= +AMN_SCANNER_API_KEY= AMN_SCANNER_WEBHOOK_SECRET= AMN_SCANNER_DEFAULT=false diff --git a/08 - Operations/Scanner Operations.md b/08 - Operations/Scanner Operations.md index ce071d9..bcc0ffb 100644 --- a/08 - Operations/Scanner Operations.md +++ b/08 - Operations/Scanner Operations.md @@ -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 | | `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) | +| `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 | | `TONCENTER_API_KEY` | _(none)_ | recommended | TonCenter API key | | `RPC_BSC` | _(chain config)_ | no | Override BSC RPC URL (chain 56) | @@ -92,6 +94,7 @@ curl -H "Authorization: Bearer $SCANNER_API_KEY" \ Check: - `lag` — should be near 0 for healthy chains (blocks behind for EVM, seconds for TON) - `pendingIntents` — number of unresolved intents per chain +- `activeBalanceWatches` — number of direct-address watches in `watching` status per chain - `lastScannedBlock` — should advance each poll ### 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 | | `[scanner] reconciling confirmed intents` | Startup crash recovery in progress | | `[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 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. +### 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/ + +curl -X POST -H "Authorization: Bearer $SCANNER_API_KEY" \ + http://localhost:8080/balance-watches//stop +``` + +Backend should stop a watch after payment acceptance, cancellation, manual resolution, or when the payment is no longer payable. + --- ## 10. CI/CD notes diff --git a/09 - Audits/Activity Log.md b/09 - Audits/Activity Log.md index 4603890..76a2c19 100644 --- a/09 - Audits/Activity Log.md +++ b/09 - Audits/Activity Log.md @@ -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) **Commits:** `a8ae1e3` (v2.8.57), `6dc3918` (v2.8.58), `9bafbbb` (v2.8.59) — frontend only; backend stays at v2.8.56 diff --git a/PRD - Direct Address Token Payments via Scanner Balance Watches.md b/PRD - Direct Address Token Payments via Scanner Balance Watches.md new file mode 100644 index 0000000..7bad7aa --- /dev/null +++ b/PRD - Direct Address Token Payments via Scanner Balance Watches.md @@ -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`: `-balance-c-`. + +### 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 `-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?