---
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 | Done — `directBalancePaymentService.ts` |
| 2 | Check-on-click API path and tests | Done — `POST /api/payment/amn-scanner/direct-balance/check/:paymentId`; `POST /api/payment/request-network/intents` with `rail: "direct_balance"` |
| 3 | Watch-mode lifecycle, webhook decision, and stop cleanup | In progress — webhook delegates to `processDirectBalanceWebhook`; watch creation deferred to Phase 3 |
| 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?
