docs: sync from backend 22ae0bd — scanner balance watches

This commit is contained in:
Siavash Sameni
2026-06-03 21:23:50 +04:00
parent 4b1d8ea36d
commit 9dcdb420fc
9 changed files with 866 additions and 13 deletions

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"
}
```