Files
nick-doc/PRD - Direct Address Token Payments via Scanner Balance Watches.md
Siavash Sameni c98c31dc24 docs: sync documentation with latest codebase state (merged)
- Update Activity Log with 108 missing commits (48 backend + 60 frontend)
- Update version references: backend v2.8.79, frontend v2.8.94
- Update migration count: 18 migrations (0000-0017)
- Update Telegram Mini App Flow to v2.8.94
- Update Payment Flow - Scanner to 2026-06-05
- Update all architectural and database references
- Add MongoDB removal handoff document with updated versions

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-05 07:51:00 +04:00

12 KiB

title, tags, created
title tags created
PRD - Direct Address Token Payments via Scanner Balance Watches
prd
payment
backend
scanner
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:

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:

  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:

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:

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?