docs: sync from backend 22ae0bd — scanner balance watches
This commit is contained in:
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user