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