12 KiB
title, tags, created
| title | tags | created | ||||
|---|---|---|---|---|---|---|
| PRD - Direct Address Token Payments via Scanner Balance Watches |
|
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/frontend2.8.60Related 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 sendbalance_changedwebhooks.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, andstopScannerBalanceWatchhelpers inamnPayAdapter.ts.- AMN scanner webhook route accepts signed
balance_changedpayloads and storesmetadata.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:
- Receive a public address and token/chain instruction.
- Send a normal ERC-20 transfer from any wallet or exchange.
- Click "I paid" for an immediate backend check, or let the backend/scanner watch for arrival.
- 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.8supports 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.
/intentsremains the primary smart-contract rail. - Sweeping funds from derived addresses. Sweep/settlement remains a separate custody workflow.
User flows
Flow A — check-on-click
- Buyer chooses direct token payment.
- Backend allocates a payment address and resolves
chainId, token address, decimals, and expected base-unit amount. - Backend calls scanner
POST /balances/checkand storesbaselineBalance. - Backend returns address/token/amount instructions to the frontend.
- Buyer sends a direct ERC-20 transfer.
- Buyer clicks "I paid".
- Backend calls scanner
POST /balances/checkagain. - Backend accepts only if
currentBalance - baselineBalance >= expectedAmountBaseUnits. - Backend persists evidence and transitions payment through the normal funded path.
Flow B — watch mode
- Buyer chooses direct token payment.
- Backend creates the same baseline and payment instructions.
- Backend calls scanner
POST /balance-watcheswith a payment-scopedwatchId. - Scanner polls and sends
balance_changedwebhook when balance changes. - Backend validates the delta and accepts or keeps waiting.
- Backend calls
stopScannerBalanceWatch(watchId)once the payment is accepted, cancelled, or manually closed. - 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:
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:
{
"provider": "amn.scanner",
"rail": "direct_balance",
"mode": "check",
"chainId": 56,
"token": "USDT",
"amount": "10.00"
}
Response should include:
{
"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":
POST /api/payments/:paymentId/direct-balance/check
Behavior:
- Load payment and
metadata.amnScannerDirectBalance. - Reject if payment is not payable.
- Call
checkScannerTokenBalance. - Compute
delta = currentBalance - baselineBalance. - If
delta < expectedAmount, persist last check and return a pending response. - If
delta >= expectedAmount, run the same funded transition path used by other providers. - 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:
- Verify signature using
AMN_SCANNER_WEBHOOK_SECRET. - Resolve payment by
watchIdor<paymentId>-balance-...prefix. - Reject if provider is not
amn.scanner. - Compare payload chain/token/address to stored metadata.
- Compute paid delta against stored
baselineBalance, not only payloadpreviousBalance. - If paid, transition the payment idempotently and stop the watch.
- 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:
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 nostoppedAt. - 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
activeBalanceWatchesfrom/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_changedpayloads. - Webhook route rejects wrong provider/payment mismatch.
- Watch stop is called after funded transition.
Recommended smoke script:
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
- Backend can create direct-address payment instructions with a stored scanner baseline.
- Buyer "I paid" triggers a backend balance check and returns pending or paid based on delta.
- Watch mode creates a scanner watch and records
watchId. - Signed
balance_changedwebhook can fund the payment only after backend delta validation passes. - Underpayments remain pending with stored evidence.
- Duplicate webhooks and duplicate "I paid" clicks do not double-credit ledger or rerun settlement.
- Backend stops the scanner watch after payment success/cancel/expiry.
- 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
- Which address allocator should direct balance payments use first: existing derived destination addresses, seller wallet, or a dedicated platform collection address?
- Should underpayments accumulate indefinitely until payment TTL, or should there be a minimum single-transfer threshold?
- Should overpayments be accepted silently like scanner intents, or flagged for admin review?
- Should the default mode be check-on-click to reduce RPC load, or watch mode for a more automatic buyer experience?
- What backend state should expire first when scanner watches are 7 days but product payment TTL may be shorter?