Merge remote-tracking branch 'origin/main'

# Conflicts:
#	09 - Audits/Activity Log.md
This commit is contained in:
moojttaba
2026-06-04 20:41:00 +03:30
9 changed files with 866 additions and 13 deletions

View File

@@ -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 |
|---|---|
| 024 h | 5 min |
| 2448 h | 10 min |
| 4872 h | 20 min |
| 72 h7 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 <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.
- 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=<watchId>` and `X-AMN-Event-Type=balance_changed`.

View 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 |
| `24h48h` | 10 min |
| `48h72h` | 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

View File

@@ -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

View File

@@ -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.
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_<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
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"
}
```

View File

@@ -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: `<paymentId>-balance-c<chainId>-<TOKEN>`. The webhook handler maps this back to the payment ID prefix.
Scanner cadence:
| Age | Interval |
|---|---|
| First 24h | 5 min |
| 2448h | 10 min |
| 4872h | 20 min |
| 72h7d | 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 |

View File

@@ -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

View File

@@ -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/<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

View File

@@ -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.57v2.8.59)
**Commits:** `a8ae1e3` (v2.8.57), `6dc3918` (v2.8.58), `9bafbbb` (v2.8.59) — frontend only; backend stays at v2.8.56

View File

@@ -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?