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