307 lines
12 KiB
Markdown
307 lines
12 KiB
Markdown
---
|
|
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?
|