docs: add sub-project service docs + sync vault 2026-06-08

Add 10 - Services/ docs for all sub-projects: backend, frontend, scanner,
deployment (new), update amanat-assist. Update Scanner Architecture,
Telegram Mini App flow, and Activity Log. Add payment safety edge cases.
This commit is contained in:
Siavash Sameni
2026-06-08 16:22:52 +04:00
parent 181e8e9c2f
commit 67244223ec
13 changed files with 2734 additions and 311 deletions

View File

@@ -2,31 +2,95 @@
title: Scanner Architecture title: Scanner Architecture
tags: [architecture, scanner, payment] tags: [architecture, scanner, payment]
created: 2026-05-30 created: 2026-05-30
updated: 2026-06-08
--- ---
# Scanner Architecture # Scanner Architecture
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON. AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via signed webhook when a payment is confirmed. It replaces the Request Network integration with an in-house polling scanner that supports EVM chains, Tron, and TON.
> [!info] > [!info]
> Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs. Scanner `0.1.8` also supports direct-address EVM ERC-20 balance reads and balance watches for non-smart-contract payment rails. > Repo: `scanner/` within the escrow monorepo. Binary: `scanner`. Written in Go 1.25. SQLite (WAL mode) for state. No external dependencies beyond the chain APIs.
>
> For operational how-it-works detail (API, webhook payloads, config vars, direct balance checks) see [[scanner]] in 10 - Services.
--- ---
## 1. Responsibilities ## 1. Responsibilities
- Accept payment **intents** from the backend (POST /intents) - Accept payment **intents** from the backend (`POST /intents`)
- Watch the relevant chain for matching on-chain transfers - Watch the relevant chain for matching on-chain transfers
- Track confirmation depth (EVM) or rely on finality from the chain API (Tron, TON) - Track confirmation depth (EVM) or rely on API-reported finality (Tron, TON)
- Deliver a signed webhook to the backend callback URL when confirmed - Deliver a signed webhook to the backend callback URL when confirmed
- Retry failed webhook deliveries - Retry failed webhook deliveries with exponential back-off
- Expire stale pending intents on a configurable TTL - Expire stale pending intents on a configurable TTL
- Read an EVM ERC-20 balance on demand for a backend-supplied address/token - Read an EVM ERC-20 balance on demand (`POST /balances/check`)
- Watch an EVM address/token pair for balance changes, decay polling cadence over time, and stop after backend cancellation or 7-day expiry - Watch an EVM address/token pair for balance changes with age-decayed polling cadence (`POST /balance-watches`)
--- ---
## 2. Component map ## 2. Supported chains
Chains are defined in `supported-chains.json`. A worker is spawned only for chains with `"verified": true` (or listed in `SCANNER_ENABLED_CHAINS`).
| Chain | Chain ID | Type | Proxy address | Conf. threshold | Active by default |
|---|---|---|---|---|---|
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks | Yes |
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks | Yes |
| BSC Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | Yes (testnet) |
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks | No |
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | No |
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | No |
| Tron Mainnet | 728126428 | Tron | `TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t` (USDT TRC20) | 200 (API-confirmed) | No |
| TON Mainnet | 1100 | TON | `EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs` (USDT Jetton master) | 120 (API-finalized) | No |
> [!note] Proxy address variations
> Ethereum mainnet uses a v0.1.0 proxy (`0x370DE...`). Base uses a non-canonical CREATE2 address (`0x189219...`). All other EVM chains use the canonical v0.2.0 address (`0x0DfbEe...`). The memory note [[RN proxy addresses per chain]] has background on why CREATE2 canonical-address claims should not be trusted without verification.
To enable a disabled chain without a rebuild: set `SCANNER_ENABLED_CHAINS=56,1,42161` (overrides the JSON `verified` flags).
---
## 3. Architecture decisions
### Why a standalone Go service
The scanner runs a tight polling loop that needs to hold open TCP connections to multiple RPC endpoints, manage per-chain checkpoints, and retry webhook delivery independently of backend restarts. A dedicated process with its own SQLite state is simpler and more reliable than embedding this into the Node.js backend.
### Why SQLite
Single-node deployment. WAL mode gives concurrent reads during writes. The state set is small (one row per intent, one checkpoint per chain). No operational overhead of a separate DB process inside the container.
### Two payment rails
The scanner supports two fundamentally different payment models:
1. **Proxy-contract rail (EVM)**: funds flow through `ERC20FeeProxy`; the scanner matches by `paymentReference` embedded in the contract event. No unique destination address required; the reference is the discriminator.
2. **Direct-address rail (Tron, TON, and EVM balance-watch)**: each payment gets a unique HD-derived destination address. The scanner matches by `to` address and validates amount. This is the only model available on Tron and TON because no fee-proxy contract exists there.
### Confirmation thresholds
EVM confirmation depths are conservative to handle reorgs:
- **BSC (200)**: BSC has had historical reorg incidents; 200 blocks (~10 min) provides a practical safety margin.
- **ETH (50)**: ~10 min at 12 s/block; Ethereum finality is probabilistic post-merge but 50 blocks is well past economic finality.
- **Arbitrum (2400)**: Arbitrum uses optimistic rollup; 2400 blocks (~54 min) covers the challenge window.
- **Polygon (300)**: polygon reorgs have occurred at depth >100; 300 blocks gives headroom.
- **Base (300)**: Base is an OP Stack chain; same rationale as Polygon.
Tron and TON do not use block-depth confirmation — TronGrid and TonCenter only surface confirmed/finalized transactions, so status goes directly to `confirmed`. The scanner reports the chain's acceptance floor (200 / 120) in the webhook for backend use.
### Reorg protection (EVM)
The EVM worker re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20500) on every tick. This `ReorgBuffer()` ensures that a log in a block that was reorganised off the canonical chain will be re-evaluated when the chain reorganises. The window is wide enough to cover any realistic reorg depth for the chains the scanner targets.
### Startup reconciliation
On startup, `confirmed` intents with `webhook_delivered_at IS NULL` created within the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook` without requiring a manual retry trigger.
---
## 4. Component map
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
@@ -46,91 +110,38 @@ AMN Pay Scanner is a standalone Go microservice that watches on-chain payment ev
│ ├── webhook retry loop main.go + webhook.go │ │ ├── webhook retry loop main.go + webhook.go │
│ └── BalanceWatchScheduler balance_watch.go │ │ └── BalanceWatchScheduler balance_watch.go │
│ │ │ │
│ reference.go — payment reference / topic hash math │ reference.go — payment reference / topic hash
│ webhook.go — delivery, HMAC signing, retry │ │ webhook.go — delivery, HMAC signing, retry │
│ balance.go — EVM ERC-20 balanceOf(address) reads │ │ balance.go — EVM ERC-20 balanceOf reads
│ balance_watch.go — balance_watches state + webhooks │ │ balance_watch.go — balance_watches state + webhooks │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
One worker goroutine is spawned per active chain. All three chain types implement a common `Worker` interface (`start()`, `stop()`, `getHead()`). Workers poll on `POLL_INTERVAL_SEC` (default 15 s).
--- ---
## 3. Chain worker model ## 5. Backend integration points
All three chain types implement the `Worker` interface: | Direction | Endpoint | When |
```go
type Worker interface {
start()
stop()
getHead(ctx context.Context) (int64, error)
}
```
One worker goroutine is spawned per chain marked `"verified": true` in `supported-chains.json`. Workers are selected by `chainType`:
| chainType | Worker struct | API used |
|---|---|---| |---|---|---|
| `evm` (default) | `ChainWorker` | JSON-RPC 2.0 (`eth_getLogs`, `eth_blockNumber`) | | Backend → Scanner | `POST /intents` | New payment initiated; returns `checkoutBlock` with `paymentReference` and proxy address |
| `tron` | `TronChainWorker` | TronGrid REST (`/v1/contracts/{contract}/events`) | | Backend → Scanner | `GET /intents/{id}` | Poll intent status (optional; webhook is primary) |
| `ton` | `TonChainWorker` | TonCenter v3 REST (`/jetton/transfers`) | | Scanner → Backend | `POST <callbackUrl>` | Payment confirmed; signed with `X-AMN-Signature` HMAC-SHA256 |
| Backend → Scanner | `POST /balances/check` | Synchronous ERC-20 balance read (direct-address rail) |
| Backend → Scanner | `POST /balance-watches` | Start async balance watch (direct-address rail) |
| Scanner → Backend | `POST <callbackUrl>` | Balance changed; `X-AMN-Event-Type: balance_changed` |
| Backend → Scanner | `DELETE /balance-watches/{id}` | Stop watch after payment accepted or cancelled |
| Backend → Scanner | `GET /scanner/status` | Chain lag + pending counts (ops/monitoring) |
| Backend → Scanner | `POST /admin/webhooks/retry` | Force re-delivery of `webhook_failed` intents |
Workers poll on `POLL_INTERVAL_SEC` (default 15 s). On first run, each worker starts scanning from the current chain head minus a small buffer (10 blocks for EVM, 24 h for Tron/TON). All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>`. Webhooks are HMAC-SHA256 signed; backend must verify `X-AMN-Signature` before crediting any payment.
The `amn.scanner` backend provider wires intent creation, webhook receipt, and balance-watch lifecycle. See memory note [[amn scanner pay-in wiring + env]] for the 6 required env vars and the dispatcher registration.
--- ---
## 4. EVM scanning detail ## 6. Intent lifecycle
```
for each tick:
head = eth_blockNumber
from = max(checkpoint ReorgBuffer(), 0)
chunks = split [from..head] into 2000-block ranges
for each chunk:
logs = eth_getLogs(proxyAddress, EventTopic, from, to)
for each log:
topicRef = Topics[1] (keccak256 of paymentReference — pre-indexed)
intent = DB lookup by topicRef WHERE status='pending'
validate(log.Data, intent) ← token + destination + amount check
confirmIntentPending() ← status → 'confirming'
saveCheckpoint(to)
checkConfirmations():
for each confirming intent:
confs = head - blockNumber + 1
if confs >= required: finalizeIntent(capped at required) + deliverWebhook()
```
**Reorg protection**: `ReorgBuffer()` re-scans `3 × confirmationThreshold` blocks before the checkpoint (clamped 20500). This catches any log that appeared in a block that was later reorganised off the canonical chain.
**Event signature**: `TransferWithReferenceAndFee` keccak256 = `0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3`
---
## 5. Tron scanning detail
TronGrid does not expose a fee-proxy contract. Each intent is assigned a unique HD-derived destination address. The scanner watches TRC20 `Transfer` events on the USDT contract and matches by `to` address.
- Checkpoint: block timestamp in milliseconds (`last_scanned_block` column)
- TronGrid addresses arrive as `41xxxx` hex (21 bytes); normalized to `0x` (20 bytes EVM style)
- Tron transactions reported by TronGrid are already confirmed; status goes directly to `confirmed` (no multi-block wait)
- Pagination follows `meta.links.next` until empty
---
## 6. TON scanning detail
TON uses TonCenter v3. Per-intent polling: for each pending TON intent, a separate HTTP call fetches incoming Jetton transfers to that destination since the checkpoint.
- Checkpoint: Unix timestamp in seconds
- TON addresses are base64url (`EQ…`/`UQ…`) — case-sensitive, never lowercased
- `proxyAddress` = USDT Jetton master address (`EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs`)
- TonCenter returns only finalized transactions; status goes directly to `confirmed`
- Lag is reported in seconds, not blocks
- Known scaling limitation: O(pending intents) API calls per scan cycle
---
## 7. Intent lifecycle
``` ```
pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done] pending ──(tx seen)──► confirming ──(enough blocks)──► confirmed ──(webhook ok)──► [done]
@@ -139,112 +150,28 @@ pending ──(tx seen)──► confirming ──(enough blocks)──► confi
└───────────────────────┴──────────► expired webhook_failed └───────────────────────┴──────────► expired webhook_failed
``` ```
- **Tron / TON** skip `confirming` and jump directly to `confirmed`. - **Tron / TON** skip `confirming` and go directly to `confirmed` (API only surfaces finalized txns).
- `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`. - `webhook_failed` intents are retried every `WEBHOOK_RETRY_HOURS` (default 6 h) and on `POST /admin/webhooks/retry`.
- **Startup reconciliation**: on startup, `confirmed` intents with `webhook_delivered_at IS NULL` and created in the last 7 days have their webhook re-delivered. This recovers from a crash between `finalizeIntent` and `deliverWebhook`. - Retry schedule on first delivery attempt: 5 s → 30 s → 2 min → 10 min → 1 h → `webhook_failed`.
--- ---
## 8. Direct balance watch model ## 7. Security model
Direct-address payments are a separate rail from scanner intents. They do not require `ERC20FeeProxy`, `paymentReference`, or a smart-contract event. The backend gives the scanner a public address, token, callback URL, and HMAC secret.
Two backend usage modes are supported:
- **Synchronous check mode**: backend calls `POST /balances/check` when an address is allocated to the buyer and again when the buyer clicks "I paid". The backend compares the current base-unit balance to its stored baseline and target amount.
- **Watch mode**: backend calls `POST /balance-watches`; the scanner stores a row in `balance_watches`, reads the initial balance, and checks the same address/token periodically. If the balance changes, scanner sends a signed `balance_changed` webhook. Backend stops the watch with `DELETE /balance-watches/{watchId}` once the payment is accepted, cancelled, or otherwise resolved.
Cadence is age-based:
| Watch age | Next check interval |
|---|---|
| 024 h | 5 min |
| 2448 h | 10 min |
| 4872 h | 20 min |
| 72 h7 d | 40 min |
| 7 d+ | status becomes `expired` |
The scanner only advances `current_balance` after a changed-balance webhook is delivered successfully. If the backend is down or returns non-2xx, the same change is retried on the next scheduled due check.
Current implementation scope: EVM ERC-20 `balanceOf(address)` via JSON-RPC `eth_call`. Tron/TON balance reads are future scope.
---
## 9. Payment reference math (EVM)
```
paymentReference = last8Bytes(keccak256(lower(intentId + salt + destination)))
topicRef (index) = keccak256(paymentReferenceBytes)
```
The ERC20FeeProxy indexes `paymentReference` so `Topics[1]` in the log is `topicRef`, not the raw reference. The DB stores `topic_ref` pre-computed per intent so the scan loop is a single indexed SQL lookup instead of O(n) hashing.
---
## 10. Database schema (SQLite WAL)
Three main tables:
**`intents`** — one row per payment intent
| Column | Type | Notes |
|---|---|---|
| `intent_id` | TEXT PK | caller-supplied UUID |
| `chain_id` | INTEGER | numeric chain ID |
| `chain_type` | TEXT | `evm` / `tron` / `ton` |
| `token_address` | TEXT | EVM/Tron: lowercase 0x hex; TON: base64url |
| `destination` | TEXT | receiving address |
| `amount` | TEXT | base-10 wei / token smallest unit |
| `payment_reference` | TEXT | 8-byte hex (EVM only) |
| `topic_ref` | TEXT | keccak256 of paymentReference (EVM index) |
| `status` | TEXT | `pending` / `confirming` / `confirmed` / `expired` / `webhook_failed` |
| `callback_url` | TEXT | backend webhook endpoint |
| `callback_secret` | TEXT | HMAC key (not returned in GET) |
| `confirmations_required` | INTEGER | from chain config or caller override, floored at the chain acceptance threshold |
| `tx_hash` | TEXT NULL | transaction hash once seen |
| `log_index` | INTEGER NULL | log position within tx (EVM) |
| `block_number` | INTEGER NULL | block / timestamp when seen |
| `confirmations` | INTEGER | current depth while confirming; capped at the accepted threshold after confirmation |
| `salt` | TEXT | 32-byte random hex for reference derivation |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp of successful delivery |
| `created_at` / `updated_at` | DATETIME | |
Unique index on `(tx_hash, log_index)` prevents duplicate intent confirmation.
**`checkpoints`** — one row per chain, tracks scan progress
| Column | Notes |
|---|---|
| `chain_id` | PK |
| `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) |
**`balance_watches`** — one row per direct-address balance watch
| Column | Type | Notes |
|---|---|---|
| `watch_id` | TEXT PK | caller-supplied or scanner-generated idempotency key |
| `chain_id` / `chain_type` | INTEGER / TEXT | currently EVM only for direct balance reads |
| `token_address` / `token_symbol` | TEXT | ERC-20 contract and optional registry symbol |
| `decimals` | INTEGER | registry decimals for display/metadata |
| `address` | TEXT | watched holder address |
| `baseline_balance` | TEXT | base-unit balance at backend baseline |
| `current_balance` | TEXT | last delivered/current scanner balance |
| `status` | TEXT | `watching` / `stopped` / `expired` |
| `callback_url` / `callback_secret` | TEXT | signed webhook destination |
| `last_checked_at` / `next_check_at` | DATETIME | scheduler state |
| `change_count` / `last_notified_at` | INTEGER / DATETIME | notification audit fields |
| `expires_at` | DATETIME | hard stop after 7 days |
| `created_at` / `updated_at` | DATETIME | UTC timestamps |
Indexes: `(status, next_check_at)` for due scans and `(chain_id, status)` for status reporting.
---
## 11. Security model
- All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare). - All non-health endpoints require `Authorization: Bearer <SCANNER_API_KEY>` (constant-time compare).
- If `SCANNER_API_KEY` is unset the server logs a warning and allows all requests — intended for local dev only. - Unset `SCANNER_API_KEY` logs a warning and allows all requests — local dev only.
- Webhooks are signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`. - Webhooks signed with HMAC-SHA256: `X-AMN-Signature: hex(hmac(body, callbackSecret))`.
- The `callbackSecret` is stored in the DB but excluded from all JSON responses (`json:"-"` tag). - `callbackSecret` stored in DB but excluded from all JSON responses (`json:"-"`).
- Request bodies are limited to 64 KB. - Request bodies limited to 64 KB.
- Balance-watch callbacks use the same HMAC header plus `X-AMN-Delivery-ID=<watchId>` and `X-AMN-Event-Type=balance_changed`. - `SCANNER_CALLBACK_ALLOWED_HOSTS` env var restricts allowed webhook target hosts (SSRF guard).
---
## 8. Known limitations and open items
| Item | Detail |
|---|---|
| TON O(n) API calls | Per-intent polling — one TonCenter call per pending TON intent per scan cycle. Fine at low volume; needs batching for scale. |
| Direct balance reads: EVM only | `POST /balances/check` and `POST /balance-watches` only support EVM ERC-20 (`eth_call` balanceOf). Tron/TON balance reads are future scope. |
| Arbitrum / Polygon / Base / Tron / TON disabled | `verified: false` in `supported-chains.json`. Enable via `SCANNER_ENABLED_CHAINS` env var without a code change. |
| Ethereum proxy version | Chain 1 uses the v0.1.0 proxy (`0x370DE...`). A v0.2.0 proxy is also deployed but checkout still uses the v0.1.0 ABI. |

View File

@@ -1,20 +1,22 @@
--- ---
title: Telegram Mini App Flow title: Telegram Mini App Flow
tags: [flow, telegram, mini-app, auth, bilingual, RTL, shop, cart] tags: [flow, telegram, mini-app, auth, bilingual, RTL, shop, cart, payment]
related_models: ["[[User]]"] related_models: ["[[User]]"]
related_apis: ["POST /api/auth/telegram", "[[Auth API]]"] related_apis: ["POST /api/auth/telegram", "[[Auth API]]"]
task: "5.4" task: "5.4"
--- ---
> **Last updated:** 2026-06-03 > **Last updated:** 2026-06-08
> **Status:** IN PROGRESS — Task 5.4 (dependencies: 5.1 auth infra, 5.2 Telegram sign-in endpoint) > **Status:** IN PROGRESS — Task 5.4 (dependencies: 5.1 auth infra, 5.2 Telegram sign-in endpoint)
> **Frontend branch:** `integrate-main-into-development` · v2.8.94 > **Frontend branch:** `integrate-main-into-development` · v2.8.94+
> **Entry point:** `src/sections/telegram/` · route `/telegram` > **Entry point:** `src/sections/telegram/` · route `/telegram`
# Telegram Mini App Flow # Telegram Mini App Flow
End-to-end specification for the **Amaneh Telegram Mini App** — a fully self-contained marketplace shell surfaced inside Telegram's in-app browser via the WebApp SDK. Buyers and sellers can browse requests, create new escrow requests, shop seller templates, manage a cart, review offer state, follow payments, and message each other without leaving Telegram. End-to-end specification for the **Amaneh Telegram Mini App** — a fully self-contained marketplace shell surfaced inside Telegram's in-app browser via the WebApp SDK. Buyers and sellers can browse requests, create new escrow requests, shop seller templates, manage a cart, review offer state, follow payments, and message each other without leaving Telegram.
> **Two separate Mini Apps exist on this platform.** This document covers the **main marketplace Mini App** (`amn.gg/telegram`) built inside the primary Next.js frontend. For the AI-assisted request-creation Mini App, see [[amanat-assist]].
--- ---
## 1. Architecture Overview ## 1. Architecture Overview
@@ -26,10 +28,11 @@ Telegram Client
├─ useTelegramLiveContext ← SDK probe + polling ├─ useTelegramLiveContext ← SDK probe + polling
├─ useTelegramLanguage ← EN / FA detection ├─ useTelegramLanguage ← EN / FA detection
├─ useTelegramAutoSignIn ← silent JWT exchange ├─ useTelegramAutoSignIn ← silent JWT exchange
├─ useTelegramMainButton ← native chrome sync ├─ useTelegramMainButton ← native chrome sync (disabled)
├─ useTelegramBackButton ← native chrome sync ├─ useTelegramBackButton ← native chrome sync
├─ useTelegramHaptic ← haptic wrapper ├─ useTelegramHaptic ← haptic wrapper
├─ useTelegramCart ← shared localStorage cart ├─ useTelegramCart ← shared localStorage cart
├─ useTelegramNotifications ← unread badge count
├─ [state: loading] → TelegramLoadingState ├─ [state: loading] → TelegramLoadingState
├─ [state: unsupported] → TelegramUnsupportedState ├─ [state: unsupported] → TelegramUnsupportedState
@@ -38,17 +41,28 @@ Telegram Client
├─ TelegramHeader ├─ TelegramHeader
├─ TelegramTabBar (Home / Shop / Requests / Chat / Account) ├─ TelegramTabBar (Home / Shop / Requests / Chat / Account)
├─ [drilldown] TelegramPaymentView ← highest priority
├─ [drilldown] TelegramChatThreadView
├─ [drilldown] TelegramRequestDetailView
├─ [drilldown] TelegramTemplateDetailView
├─ [drilldown] TelegramSellerShopView
├─ [overlay] TelegramPointsView
├─ [overlay] TelegramSettingsView
├─ [overlay] TelegramAddressesView
├─ [overlay] TelegramCartView
├─ [overlay] TelegramCheckoutView
├─ [overlay] TelegramNotificationsView
├─ [overlay] TelegramNewRequestView
├─ TelegramHomeView ├─ TelegramHomeView
├─ TelegramShopView → TelegramSellerShopView ├─ TelegramShopView → TelegramSellerShopView
├─ TelegramRequestsView → TelegramRequestDetailView ├─ TelegramRequestsView → TelegramRequestDetailView
├─ TelegramChatView → TelegramChatThreadView ├─ TelegramChatView → TelegramChatThreadView
─ TelegramAccountView ─ TelegramAccountView
└─ [overlay] TelegramNewRequestView
└─ [overlay] TelegramNotificationsView
└─ [overlay] TelegramCartView
``` ```
The shell is a **single-page, no-router** design: all navigation (tabs, overlays, detail drilldowns) is pure React state in `TelegramMiniAppView`. `window.location.assign` is only used as a final escape hatch to the full web dashboard. The shell is a **single-page, no-router** design: all navigation (tabs, overlays, detail drilldowns) is pure React state in `TelegramMiniAppView`. `window.location.assign` is used only as a final escape hatch to external URLs. `openTelegramExternalLink` is used for deep links into the web dashboard, which opens inside Telegram's WebView or an external browser depending on the Telegram client.
--- ---
@@ -66,7 +80,27 @@ The shell is a **single-page, no-router** design: all navigation (tabs, overlays
--- ---
## 3. SDK Initialisation & Context Probe ## 3. amanat-assist vs Main Mini App
Two distinct Telegram Mini Apps exist for this platform:
| Property | Main Mini App (this doc) | amanat-assist |
|---|---|---|
| URL | `amn.gg/telegram` | `assist.amn.gg` |
| Bot | AmanehBot | AmanehBot (same) |
| Codebase | `frontend/` (Next.js, `src/sections/telegram/`) | `/amanat-assist` (React + Vite, separate repo) |
| Purpose | Full marketplace shell: browse, buy, sell, chat, manage account | Conversational LLM wizard to create one purchase request |
| LLM | None | Mistral → DeepSeek fallback (via `amanat-llm-proxy` on port 3001) |
| Backend access | Direct calls to `api.amn.gg` | Proxied through `amanat-llm-proxy` which holds the LLM API keys |
| Auth | Telegram `initData``POST /api/auth/telegram` | Same endpoint; also supports web redirect via `?access_token=` |
| Deep links between apps | Main Mini App has "New Request" overlay with an "Open Assist" CTA that navigates `window.location.href` to `assist.amn.gg?access_token=...` | Assist submits the finished request then the user returns to the main app |
| Status | In production | Live at `assist.amn.gg` v1.1.0 |
**Hand-off from main app to assist:** `handleOpenAssist()` in `TelegramMiniAppView` constructs a URL to `https://assist.amn.gg` with `access_token`, `user_json`, `theme`, and `source=miniapp` query params. `window.location.href` is used (not `openLink`) to keep the navigation inside Telegram's WebView rather than opening Safari on iOS.
---
## 4. SDK Initialisation & Context Probe
**File:** `src/utils/telegram-webapp.ts` · `getTelegramContext()` **File:** `src/utils/telegram-webapp.ts` · `getTelegramContext()`
@@ -92,7 +126,7 @@ The function assembles a `TelegramContext` object from:
--- ---
## 4. Shell State Machine ## 5. Shell State Machine
`getTelegramStatus(context, hasWebAccount)` returns one of three states: `getTelegramStatus(context, hasWebAccount)` returns one of three states:
@@ -114,9 +148,9 @@ State transitions occur on:
--- ---
## 5. Authentication Flow ## 6. Authentication Flow
### 5.1 Silent Auto Sign-In ### 6.1 Silent Auto Sign-In
**Hook:** `useTelegramAutoSignIn` · **File:** `hooks/use-telegram-auto-sign-in.ts` **Hook:** `useTelegramAutoSignIn` · **File:** `hooks/use-telegram-auto-sign-in.ts`
@@ -127,7 +161,7 @@ On mount, if `context.isMiniApp && context.initData && !user`:
3. If the backend returns `isNewUser: true`, show `TelegramOnboardingSheet`. 3. If the backend returns `isNewUser: true`, show `TelegramOnboardingSheet`.
4. A `useRef` deduplication guard (`attemptedInitDataRef`) prevents re-runs under React Strict Mode's double-effect behaviour. 4. A `useRef` deduplication guard (`attemptedInitDataRef`) prevents re-runs under React Strict Mode's double-effect behaviour.
### 5.2 Manual Sign-In (Unlinked State) ### 6.2 Manual Sign-In (Unlinked State)
When `initData` is present but auto sign-in failed (or hasn't run yet), `TelegramUnlinkedState` renders: When `initData` is present but auto sign-in failed (or hasn't run yet), `TelegramUnlinkedState` renders:
- **Continue with Telegram** — calls the same `signIn()` function from `useTelegramAutoSignIn`. - **Continue with Telegram** — calls the same `signIn()` function from `useTelegramAutoSignIn`.
@@ -136,47 +170,74 @@ When `initData` is present but auto sign-in failed (or hasn't run yet), `Telegra
When `initData` is absent (accessed via a path that skips Telegram context), only the email/register buttons appear. When `initData` is absent (accessed via a path that skips Telegram context), only the email/register buttons appear.
### 5.3 Backend Endpoint ### 6.3 Backend Endpoint
`POST /api/auth/telegram` — expects `{ initData: string }`. Backend verifies the HMAC using the Telegram bot token, extracts `user` from the payload, upserts a `User` record (`telegramId`, `telegramVerified: true`), and issues a JWT + refresh token. Returns `{ token, refreshToken, isNewUser }`. `POST /api/auth/telegram` — expects `{ initData: string }`. Backend verifies the HMAC using the Telegram bot token, extracts `user` from the payload, upserts a `User` record (`telegramId`, `telegramVerified: true`), and issues a JWT + refresh token. Returns `{ token, refreshToken, isNewUser }`.
Registered at `authRoutes.ts` line 24: `router.post("/telegram", ctrl.telegramAuth.bind(ctrl))` — public route, no auth middleware.
### 6.4 Session Linking (Telegram ↔ Amaneh Account)
The `POST /api/auth/telegram` endpoint both creates and links accounts:
- **New Telegram user, no existing Amanat account:** a new `User` is created with `telegramId` set; `isNewUser: true` is returned and the onboarding sheet is shown.
- **Existing Amanat account with the same `telegramId`:** the existing user is returned; session continues.
- **Existing Amanat account that has never used Telegram:** `telegramId` and `telegramVerified: true` are written onto the existing record (matched by Telegram user id).
After the JWT is issued the standard `checkUserSession()` re-hydrates the React auth context. The Mini App shell reads `user.telegramVerified` and `user.isEmailVerified` from this context to render verification chips in the Account tab.
--- ---
## 6. Navigation Model ## 7. Navigation Model
All navigation is in-shell React state — no Next.js router is involved. All navigation is in-shell React state — no Next.js router is involved.
``` ```
activeTab : 'home' | 'shop' | 'requests' | 'chat' | 'account' activeTab : 'home' | 'shop' | 'requests' | 'chat' | 'account'
overlayScreen : 'new-request' | 'notifications' | 'cart' | null overlayScreen : 'new-request' | 'notifications' | 'cart' | 'checkout'
| 'points' | 'settings' | 'addresses' | null
openConversationId : string | null openConversationId : string | null
openRequestId : string | null openRequestId : string | null
openPaymentRequestId : string | null ← payment drilldown (highest priority)
paymentCheckoutFlow : boolean ← true when reached from shop checkout
openSellerId : string | null openSellerId : string | null
openTemplate : { template, seller } | null
``` ```
**Priority rendering** (first match wins): **Priority rendering** (first match wins):
1. `openConversationId``TelegramChatThreadView` 1. `openPaymentRequestId``TelegramPaymentView` ← new, highest priority
2. `openRequestId``TelegramRequestDetailView` 2. `openConversationId``TelegramChatThreadView`
3. `openSellerId``TelegramSellerShopView` 3. `openRequestId``TelegramRequestDetailView`
4. `overlayScreen === 'cart'``TelegramCartView` 4. `openTemplate``TelegramTemplateDetailView` ← new
5. `overlayScreen === 'notifications'``TelegramNotificationsView` 5. `openSellerId``TelegramSellerShopView`
6. `overlayScreen === 'new-request'``TelegramNewRequestView` 6. `overlayScreen === 'points'``TelegramPointsView` ← new
7. `activeTab` → appropriate tab view 7. `overlayScreen === 'settings'``TelegramSettingsView` ← new
8. `overlayScreen === 'addresses'``TelegramAddressesView` ← new
9. `overlayScreen === 'cart'``TelegramCartView`
10. `overlayScreen === 'checkout'``TelegramCheckoutView` ← new (replaces web handoff)
11. `overlayScreen === 'notifications'``TelegramNotificationsView`
12. `overlayScreen === 'new-request'``TelegramNewRequestView`
13. `activeTab` → appropriate tab view
**Back button** (Telegram native `BackButton`) dismisses in reverse priority order: chat thread → request detail → seller shop → overlay → returns to `home` tab. **Back button** (Telegram native `BackButton`) dismisses in reverse priority order:
- Payment drilldown → if `paymentCheckoutFlow`, steps back to cart; otherwise clears payment state.
- Chat thread → clears `openConversationId`.
- Request detail → clears `openRequestId`.
- Template detail → clears `openTemplate`.
- Seller shop → clears `openSellerId`.
- Overlay (`checkout` steps back to `cart`) → clears `overlayScreen`.
- Non-home tab → returns to `home`.
`BackButton` visibility: shown whenever `state === 'linked'` and either an overlay/drilldown is active, or `activeTab !== 'home'`. `BackButton` visibility: shown whenever `state === 'linked'` and either an overlay/drilldown is active, or `activeTab !== 'home'`.
`MainButton` visibility: hidden while any overlay is open. When visible: `MainButton` visibility: **intentionally disabled** (`isReady: false`) — the native Telegram MainButton cannot use the project font and duplicates in-shell CTAs, so it is kept hidden. All primary actions live inside the shell UI itself.
- **Linked** → "New Request" (opens `overlayScreen = 'new-request'`)
- **Unlinked** → "Sign In" (navigates to the JWT sign-in page)
Both chrome buttons are styled with the amaneh saffron palette (`color: #C2410C`, `text_color: #FFFFFF`) via `setParams` (WebApp SDK >= 6.1). Both chrome buttons retain the amaneh saffron palette (`color: #C2410C`, `text_color: #FFFFFF`) via `setParams` (WebApp SDK >= 6.1) as a fallback should the MainButton ever be re-enabled.
--- ---
## 7. Tab Structure ## 8. Tab Structure
The shell has **five bottom tabs** rendered by `TelegramTabBar`: The shell has **five bottom tabs** rendered by `TelegramTabBar`:
@@ -192,89 +253,115 @@ The shell has **five bottom tabs** rendered by `TelegramTabBar`:
--- ---
## 8. Supported Flows ## 9. Supported Flows
### 8.1 Home Tab ### 9.1 Home Tab
`TelegramHomeView` is the landing screen shown on first open. It contains: `TelegramHomeView` is the landing screen shown on first open. It contains:
- **Welcome banner** (`TelegramWelcomeBanner`): escrow account summary, primary CTA. - **Welcome banner** (`TelegramWelcomeBanner`): escrow account summary, primary CTA.
- **Quick-action cards** (`TelegramQuickActions`): shortcuts to Requests, Payments, Chat. - **Quick-action cards** (`TelegramQuickActions`): shortcuts to Requests, Payments, Chat.
- **Escrow state chips** (`TelegramEscrowStateChips`): legend of status values visible in the platform. - **Escrow state chips** (`TelegramEscrowStateChips`): legend of status values visible in the platform.
- **"New Request" CTA** → opens `overlayScreen = 'new-request'`.
- **"Open Assist" CTA** → calls `handleOpenAssist()` to navigate to `assist.amn.gg` in the same WebView (see section 3).
### 8.2 Shop Tab — Sellers List ### 9.2 Shop Tab — Sellers List
**`TelegramShopView`** (`telegram-shop-view.tsx`): **`TelegramShopView`** (`telegram-shop-view.tsx`):
- Fetches all sellers via `useTelegramShops()` → SWR wrapping `getTemplateSellers()``GET /api/request-templates/sellers`. - Fetches all sellers via `useTelegramShops()` → SWR wrapping `getTemplateSellers()``GET /api/request-templates/sellers`.
- Renders `TelegramShopRow` per seller: avatar, name, rating, template count, sales count. - Renders `TelegramShopRow` per seller: avatar, name, rating, template count, sales count.
- Shows a floating cart badge button in the header when `totalItems > 0`; tap opens `overlayScreen = 'cart'`. - Shows a floating cart badge button (`TelegramCartFab`) in the header when `totalItems > 0`; tap opens `overlayScreen = 'cart'`.
- Tap a seller row → sets `openSellerId` → navigates to `TelegramSellerShopView`. - Tap a seller row → sets `openSellerId` → navigates to `TelegramSellerShopView`.
### 8.3 Shop Tab — Seller Store ### 9.3 Shop Tab — Seller Store
**`TelegramSellerShopView`** (`telegram-seller-shop-view.tsx`): **`TelegramSellerShopView`** (`telegram-seller-shop-view.tsx`):
- Fetches seller + active templates via `useTelegramSellerShop(sellerId)``GET /api/request-templates/sellers/:id`. - Fetches seller + active templates via `useTelegramSellerShop(sellerId)``GET /api/request-templates/sellers/:id`.
- Dark header: seller avatar, name, rating, template count, description. - Dark header: seller avatar, name, rating, template count, description.
- Each template card shows: image, title, 2-line description, budget range, usage count. - Each template card shows: image, title, 2-line description, budget range, usage count.
- **Two actions per template:** - **Two actions per template:**
- **Add to cart / Remove from cart** — toggles item in `useTelegramCart` (localStorage, no API). Button is filled blue when not in cart, outline when added. - **Add to cart / Remove from cart** — toggles item in `useTelegramCart` (localStorage, no API).
- **Order this template** — `<a href>` to `/dashboard/request/from-template?shareableLink=...`. Exits the Mini App to the web dashboard (single-template direct order, bypasses cart). - **View template details** — sets `openTemplate` → navigates to `TelegramTemplateDetailView`.
- Floating "Cart · N templates" sticky button at bottom when `totalItems > 0`; tap calls `onOpenCart()`. - Floating "Cart · N templates" sticky button at bottom when `totalItems > 0`; tap calls `onOpenCart()`.
### 8.4 Shopping Cart Overlay ### 9.4 Shop Tab — Template Detail
**`TelegramTemplateDetailView`** (`telegram-template-detail-view.tsx`):
- Full-screen view of a single template.
- Shows full description, seller info, price, delivery info, usage/capacity counters.
- Add/remove cart action; direct "Order this template" link to `/dashboard/request/from-template?shareableLink=...` (exits to web dashboard).
- Back button returns to the seller store (`openTemplate` cleared, `openSellerId` retained).
### 9.5 Shopping Cart Overlay
**`TelegramCartView`** (`telegram-cart-view.tsx`): **`TelegramCartView`** (`telegram-cart-view.tsx`):
- Rendered as `overlayScreen = 'cart'`; dismissed by Telegram BackButton. - Rendered as `overlayScreen = 'cart'`; dismissed by Telegram BackButton.
- Lists each cart item: image, name, seller name, USDT price × quantity, +/ quantity controls, remove button. - Lists each cart item: image, name, seller name, USDT price × quantity, +/ quantity controls, remove button.
- Subtotal/total in USDT, locale-formatted (`fa-IR` for Persian, `en-US` for English); amounts always `dir="ltr"`. - Subtotal/total in USDT, locale-formatted (`fa-IR` for Persian, `en-US` for English); amounts always `dir="ltr"`.
- **"Continue to payment"** — plain `<a href={paths.shops.checkout}>` link; exits Mini App to web checkout. - **"Continue to payment"** → calls `onCheckout()` which sets `overlayScreen = 'checkout'` (in-shell checkout, replacing the previous web handoff).
**Cart storage (`useTelegramCart`):** **Cart storage (`useTelegramCart`):**
- Reads/writes `localStorage` key **`app-request-template-checkout`** — the same key the web `RequestTemplateCheckoutProvider` reads. This enables the web dashboard checkout to hydrate the same cart.
- Reads/writes `localStorage` key **`app-request-template-checkout`** — the same key the web `RequestTemplateCheckoutProvider` reads. This is the cart handoff mechanism: the cart built in Telegram IS the cart the web checkout page hydrates.
- Dispatches a custom `tg-cart-changed` DOM event on every write; listens on both that event and the native `storage` event so all open tabs stay in sync. - Dispatches a custom `tg-cart-changed` DOM event on every write; listens on both that event and the native `storage` event so all open tabs stay in sync.
- Operations: `addTemplate(template, seller)`, `removeItem(itemId)`, `changeQuantity(itemId, qty)`, `isInCart(templateId)`. - Operations: `addTemplate(template, seller)`, `removeItem(itemId)`, `changeQuantity(itemId, qty)`, `isInCart(templateId)`.
- No API calls — cart is purely client-side until checkout. - No API calls — cart is purely client-side until checkout.
- Cart item model: `id`, `templateId`, `name`, `description`, `price` (from `template.budget.min`), `quantity`, `image`, `sellerId`, `sellerName`, `category`, `shareableLink`, `deliveryInfo`, `maxUsage`, `usageCount`, `remainingCapacity`.
### 8.5 Web Checkout Handoff ### 9.6 In-Shell Checkout Overlay
Destination: `/dashboard/shops/checkout``RequestTemplateCheckoutView` wrapped by `RequestTemplateCheckoutProvider`. **`TelegramCheckoutView`** (`telegram-checkout-view.tsx`):
- Rendered as `overlayScreen = 'checkout'`; BackButton steps back to `overlayScreen = 'cart'`.
- A 3-step stepper running entirely inside the Mini App shell:
- **Step 0 (Cart review):** item list, quantities, totals, discount.
- **Step 1 (Address):** physical address or online delivery email.
- **Step 2 (Payment):** wallet-based payment execution.
- On successful order (`onPlaced(reqId)` callback):
- If a `reqId` is returned, sets `paymentCheckoutFlow = true` and `openPaymentRequestId = reqId` → immediately opens the payment view.
- If no `reqId`, switches `activeTab` to `'requests'`.
- Stock validation clamps or removes items exceeding `remainingCapacity` before payment.
- Integrates with `onManageAddresses()` to open the `addresses` overlay mid-flow.
The provider reads the shared `localStorage` key and hydrates the TMA cart. The checkout is a 3-step stepper: ### 9.7 Payment View (In-Shell)
| Step | Component | Description | **`TelegramPaymentView`** (`telegram-payment-view.tsx`):
|---|---|---| - Highest-priority drilldown (rendered before all other overlays).
| 0 (Cart review) | `RequestTemplateCheckoutCart` | Item list, quantities, remove, totals, discount/shipping | - Loaded for a specific `requestId`. Used from two entry points:
| 1 (Address) | `RequestTemplateCheckoutBillingAddress` | Physical address or online delivery email | - **Shop checkout flow** (`paymentCheckoutFlow = true`): after `TelegramCheckoutView` creates the requests. Shows a 3-step progress header (cart → address → payment).
| 2 (Payment) | `RequestTemplateCheckoutPayment` | Wallet payment + socket confirmation | - **Requests tab** (`paymentCheckoutFlow = false`): buyer taps "Pay" on an existing request. No progress header.
| Complete | `RequestTemplateCheckoutOrderComplete` | Confirmation dialog, cart reset | - Fetches request details via `useTelegramRequest`.
- Fetches offers via `useTelegramOffers`.
- Calls `getPaymentOptions()``GET /api/payment/options` and `createDirectBalanceIntent()``POST /api/payment/direct-balance`.
- Polls `checkDirectBalancePayment()` for confirmation.
- On successful payment: calls `onPaid()` → clears `openPaymentRequestId`, switches to `activeTab = 'requests'`.
- Back button: if `paymentCheckoutFlow`, steps back to `overlayScreen = 'cart'`; otherwise clears the payment state.
Payment execution calls `convertTemplatesToRequests()` to create escrow records, then awaits a `template-checkout-payment-confirmed` socket event. A guard checks `createdRequestIds` is non-empty before advancing (prevents stray global socket events from triggering premature completion). Stock validation clamps or removes items exceeding `remainingCapacity` before payment. ### 9.8 Browse Requests (Requests Tab)
### 8.6 Browse Requests (Requests Tab)
- `TelegramRequestsView` fetches the user's purchase requests via `useTelegramMyRequests` (GET `/api/requests`). - `TelegramRequestsView` fetches the user's purchase requests via `useTelegramMyRequests` (GET `/api/requests`).
- Displays a skeleton loader, then a scrollable list of `TelegramRequestRow` items. - Displays a skeleton loader, then a scrollable list of `TelegramRequestRow` items.
- Each row shows: title, status chip, budget, creation date. - Each row shows: title, status chip, budget, creation date.
- Tap → sets `openRequestId` → renders `TelegramRequestDetailView`. - Tap → sets `openRequestId` → renders `TelegramRequestDetailView`.
### 8.7 Request Detail with Stepper ### 9.9 Request Detail with Stepper and Offers
- `TelegramRequestDetailView` fetches a single request via `useTelegramRequest`. - `TelegramRequestDetailView` fetches a single request via `useTelegramRequest`.
- Renders `TelegramRequestStepper` — a visual timeline of the escrow status flow from `pending_payment``completed`. - Renders `TelegramRequestStepper` — a visual timeline of the escrow status flow from `pending_payment``completed`.
- `determineCurrentStepFromStatus` maps the current `status` to a step index. - `determineCurrentStepFromStatus` maps the current `status` to a step index.
- Also renders: budget, description, creation date, category, urgency. - Also renders: budget, description, creation date, category, urgency.
- **Offer review:** fetches offers via `useTelegramOffers`; renders offer cards with seller info, price, and accept/reject actions.
- **Pay action:** renders a "Pay" button when request is in a payable state → calls `onPay(id)` → sets `openPaymentRequestId`.
- **Web fallback:** "View full details" → `openTelegramExternalLink(context.webApp, path)`.
- **Chat seller:** taps the seller chat icon → calls `onChatSeller(sellerId)``createConversation` + sets `openConversationId`.
- Role-aware: `role` prop is `'seller'` or `'buyer'` based on `user.role`.
- Dates formatted via `toLocaleDateString` with `fa-IR` locale for Persian. - Dates formatted via `toLocaleDateString` with `fa-IR` locale for Persian.
### 8.8 Create New Request ### 9.10 Create New Request
- `TelegramNewRequestView` is a full-screen overlay (not a routed page). - `TelegramNewRequestView` is a full-screen overlay (not a routed page).
- Form fields: title, description, category (fetched from `/api/categories`), budget min/max, urgency. - Form fields: title, description, category (fetched from `/api/categories`), budget min/max, urgency.
- Includes an **"Open Assist"** button that delegates to `handleOpenAssist()` for users who prefer the conversational LLM flow.
- On submit: calls `createPurchaseRequest()` → POST `/api/purchase-requests`. - On submit: calls `createPurchaseRequest()` → POST `/api/purchase-requests`.
- On success: closes overlay, switches `activeTab` to `'requests'`. - On success: closes overlay, switches `activeTab` to `'requests'`.
- `MainButton` is hidden while the overlay is open (submit lives in the form itself).
### 8.9 Chat Tab ### 9.11 Chat Tab
- `TelegramChatView` shows the user's active conversations via `useTelegramConversations`. - `TelegramChatView` shows the user's active conversations via `useTelegramConversations`.
- Includes a Support row that calls `createSupportChat()``POST /api/chat/support`, then opens `TelegramChatThreadView` with the returned conversation ID. - Includes a Support row that calls `createSupportChat()``POST /api/chat/support`, then opens `TelegramChatThreadView` with the returned conversation ID.
@@ -283,22 +370,21 @@ Payment execution calls `convertTemplatesToRequests()` to create escrow records,
- Optimistic send: message appears immediately, confirmed/rolled back on API response. - Optimistic send: message appears immediately, confirmed/rolled back on API response.
- Real-time updates via Socket.IO events; SWR is mutated on `new-notification` and `unread-count-update` events. - Real-time updates via Socket.IO events; SWR is mutated on `new-notification` and `unread-count-update` events.
### 8.10 Account Tab ### 9.12 Account Tab
**`TelegramAccountView`** (`telegram-account-view.tsx`): **`TelegramAccountView`** (`telegram-account-view.tsx`):
The account tab has four sections. All user data is passed as props from the shell (loaded via `useAuthContext()` — no fetch on mount).
**Profile header:** **Profile header:**
- Avatar (from `user.profile.avatar`, falls back to initials), full name, Telegram `@username`, role chip (buyer / seller / admin / resolver / guard). - Avatar (from `user.profile.avatar`, falls back to initials), full name, Telegram `@username`, role chip (buyer / seller / admin / resolver / guard).
- Verification chips: "Telegram Verified" (if `user.telegramVerified`) and "Email Verified" (if `user.isEmailVerified`). - Verification chips: "Telegram Verified" (if `user.telegramVerified`) and "Email Verified" (if `user.isEmailVerified`).
**Preferences section:** **Preferences section:**
- Language toggle (FA / EN, in-shell via `TelegramLanguageToggle`). - Language toggle (FA / EN, in-shell via `TelegramLanguageToggle`).
- General Settings → `/dashboard/account` (web, labeled "Opens in the web dashboard"). - **Settings**opens `overlayScreen = 'settings'` (in-shell `TelegramSettingsView`).
- Wallet → truncated address (`0x1234…abcd`) or "not connected" → `/dashboard/account/wallet` (web). - **Points** → opens `overlayScreen = 'points'` (in-shell `TelegramPointsView`).
- Wallet → truncated address (`0x1234…abcd`) or "not connected" → `/dashboard/account/wallet` (web via `openTelegramExternalLink`).
- Notifications → opens `TelegramNotificationsView` overlay in-shell. - Notifications → opens `TelegramNotificationsView` overlay in-shell.
- Addresses`/dashboard/account/address` (web). - **Addresses** → opens `overlayScreen = 'addresses'` (in-shell `TelegramAddressesView`).
- Passkey → `/dashboard/account/passkey` (web). - Passkey → `/dashboard/account/passkey` (web).
**Help section:** **Help section:**
@@ -308,16 +394,38 @@ The account tab has four sections. All user data is passed as props from the she
**Session section:** **Session section:**
- Sign Out → `TelegramBottomSheet` confirmation dialog → `authSignOut()` + `window.location.assign(paths.auth.jwt.signIn)`. - Sign Out → `TelegramBottomSheet` confirmation dialog → `authSignOut()` + `window.location.assign(paths.auth.jwt.signIn)`.
### 8.11 Notifications Overlay ### 9.13 Settings Overlay
**`TelegramSettingsView`** (`telegram-settings-view.tsx`):
- Rendered as `overlayScreen = 'settings'`.
- Allows editing profile fields (name, bio) in-shell.
- On save: calls `onSaved()` which triggers `checkUserSession()` to refresh the auth context.
### 9.14 Addresses Overlay
**`TelegramAddressesView`** (`telegram-addresses-view.tsx`):
- Rendered as `overlayScreen = 'addresses'`.
- Fetches addresses via `use-telegram-addresses.ts`.
- Used both from the Account tab and as a mid-flow step from `TelegramCheckoutView`.
### 9.15 Points Overlay
**`TelegramPointsView`** (`telegram-points-view.tsx`):
- Rendered as `overlayScreen = 'points'`.
- Fetches user points via `use-telegram-points.ts`.
- Shows points balance and transaction history.
### 9.16 Notifications Overlay
- `TelegramNotificationsView` is rendered as `overlayScreen = 'notifications'`. - `TelegramNotificationsView` is rendered as `overlayScreen = 'notifications'`.
- Fetches via `useTelegramNotifications``getNotifications(userId, 1, 50)``GET /api/notifications?userId=...&page=1&limit=50`. - Fetches via `useTelegramNotifications``getNotifications(userId, 1, 50)``GET /api/notifications?userId=...&page=1&limit=50`.
- Real-time updates: Socket.IO events `new-notification`, `unread-count-update` trigger SWR mutate. - Real-time updates: Socket.IO events `new-notification`, `unread-count-update` trigger SWR mutate.
- "Mark all read" calls `markAllNotificationsAsRead(userId)``PATCH /api/notifications/mark-all-read`. - "Mark all read" calls `markAllNotificationsAsRead(userId)``PATCH /api/notifications/mark-all-read`.
- Unread count is also surfaced in the `TelegramHeader` bell icon badge.
--- ---
## 9. API Calls ## 10. API Calls
| Action | Hook / call | Backend endpoint | | Action | Hook / call | Backend endpoint |
|---|---|---| |---|---|---|
@@ -328,18 +436,26 @@ The account tab has four sections. All user data is passed as props from the she
| My requests | `useTelegramMyRequests` | `GET /api/requests` | | My requests | `useTelegramMyRequests` | `GET /api/requests` |
| Single request | `useTelegramRequest` | `GET /api/purchase-requests/:id` | | Single request | `useTelegramRequest` | `GET /api/purchase-requests/:id` |
| Create request | shell → `createPurchaseRequest()` | `POST /api/purchase-requests` | | Create request | shell → `createPurchaseRequest()` | `POST /api/purchase-requests` |
| Offers for request | `useTelegramOffers``getOffers(requestId)` | `GET /api/marketplace/offers?requestId=...` |
| Payment options | `getPaymentOptions()` | `GET /api/payment/options` |
| Create payment intent | `createDirectBalanceIntent()` | `POST /api/payment/direct-balance` |
| Poll payment status | `checkDirectBalancePayment()` | `GET /api/payment/:id` |
| Update request status | `updateRequestStatus()` | `PATCH /api/marketplace/requests/:id/status` |
| Conversations | `useTelegramConversations` | `GET /api/chat/conversations` | | Conversations | `useTelegramConversations` | `GET /api/chat/conversations` |
| Chat thread | `useTelegramChatThread` | `GET /api/chat/:id` + Socket.IO real-time | | Chat thread | `useTelegramChatThread` | `GET /api/chat/:id` + Socket.IO real-time |
| Support chat | `createSupportChat()` | `POST /api/chat/support` | | Support chat | `createSupportChat()` | `POST /api/chat/support` |
| Direct conversation | `createConversation({ type: 'direct', participantIds })` | `POST /api/chat/conversations` |
| Notifications | `useTelegramNotifications` | `GET /api/notifications?userId=...&page=1&limit=50` | | Notifications | `useTelegramNotifications` | `GET /api/notifications?userId=...&page=1&limit=50` |
| Mark all read | `markAllNotificationsAsRead(userId)` | `PATCH /api/notifications/mark-all-read` | | Mark all read | `markAllNotificationsAsRead(userId)` | `PATCH /api/notifications/mark-all-read` |
| Auth sign-out | `authSignOut()` | JWT sign-out endpoint | | Auth sign-out | `authSignOut()` | JWT sign-out endpoint |
| Addresses | `use-telegram-addresses.ts` | `GET /api/user/addresses` |
| Points | `use-telegram-points.ts` | `GET /api/user/points` |
Cart operations (add/remove/quantity) are **pure localStorage** — no API calls until web checkout. Cart operations (add/remove/quantity) are **pure localStorage** — no API calls until checkout.
--- ---
## 10. Bilingual Support (EN / FA) ## 11. Bilingual Support (EN / FA)
**Language detection priority** (`useTelegramLanguage`): **Language detection priority** (`useTelegramLanguage`):
@@ -378,7 +494,7 @@ All JSX uses `t.<section>.<key>` — no inline strings in components.
--- ---
## 11. Design System ## 12. Design System
**File:** `src/sections/telegram/constants.ts` · `src/sections/telegram/telegram-shell-css.ts` **File:** `src/sections/telegram/constants.ts` · `src/sections/telegram/telegram-shell-css.ts`
@@ -391,7 +507,7 @@ The Mini App has a distinct visual identity (cream/saffron Persian palette) that
| `cream50` | `#FBF6EB` | Page background | | `cream50` | `#FBF6EB` | Page background |
| `ink900` | `#1C1410` | Primary text | | `ink900` | `#1C1410` | Primary text |
| `ink600` | `#6B5D4E` | Secondary text / labels | | `ink600` | `#6B5D4E` | Secondary text / labels |
| `saffron600` | `#C2410C` | Primary action, MainButton | | `saffron600` | `#C2410C` | Primary action |
| `saffron500` | `#D97757` | Hover states | | `saffron500` | `#D97757` | Hover states |
| `pistachio700` | `#3D6B4F` | Success / released states | | `pistachio700` | `#3D6B4F` | Success / released states |
| `pomegranate700` | `#8E2424` | Error / disputed states | | `pomegranate700` | `#8E2424` | Error / disputed states |
@@ -399,15 +515,15 @@ The Mini App has a distinct visual identity (cream/saffron Persian palette) that
**Fonts:** `TG_FONTS` — Source Serif 4 (headings), IBM Plex Sans (body LTR), Vazirmatn (body RTL), IBM Plex Mono (amounts/addresses). **Fonts:** `TG_FONTS` — Source Serif 4 (headings), IBM Plex Sans (body LTR), Vazirmatn (body RTL), IBM Plex Mono (amounts/addresses).
**CSS:** `buildTelegramShellCss()` injects a `<style>` tag at shell root with all class utilities (`.tg-chip`, `.tg-shell`, `.tg-tab-bar`, `.tg-header`, etc.). Theme CSS variables (`--cream-50`, `--ink-900`, etc.) are set on `.tg-shell` root. **CSS:** `buildTelegramShellCss()` injects a `<style>` tag at shell root with all class utilities (`.tg-chip`, `.tg-shell`, `.tg-tab-bar`, `.tg-header`, etc.). Theme CSS variables (`--cream-50`, `--ink-900`, etc.) are set on `.tg-shell` root. Dark mode: `.tg-shell--dark` class toggled from `themeScheme`.
**Safe area:** `getTelegramSafeAreaStyle(safeArea)` maps the Telegram-reported safe area insets to CSS padding using `max(${px}px, env(safe-area-inset-*))` to handle both Telegram-native and iOS/Android safe areas. **Safe area:** `getTelegramSafeAreaStyle(safeArea)` maps the Telegram-reported safe area insets to CSS padding using `max(${px}px, env(safe-area-inset-*))` to handle both Telegram-native and iOS/Android safe areas.
--- ---
## 12. Telegram SDK Usage Patterns ## 13. Telegram SDK Usage Patterns
### 12.1 Safe-Area Inset ### 13.1 Safe-Area Inset
```ts ```ts
// TelegramContext.safeArea = { top, right, bottom, left } (px) // TelegramContext.safeArea = { top, right, bottom, left } (px)
@@ -418,16 +534,16 @@ const topInset = (context.safeArea?.top ?? 0) as number;
All views receive `topInset` / `bottomInset` props and add them as explicit `paddingTop` / `paddingBottom` to avoid content being obscured by the Telegram chrome. All views receive `topInset` / `bottomInset` props and add them as explicit `paddingTop` / `paddingBottom` to avoid content being obscured by the Telegram chrome.
### 12.2 Haptic Feedback ### 13.2 Haptic Feedback
```ts ```ts
// useTelegramHaptic(webApp) → haptic('light' | 'medium') // useTelegramHaptic(webApp) → haptic('light' | 'medium')
webApp?.HapticFeedback?.impactOccurred?.(type) webApp?.HapticFeedback?.impactOccurred?.(type)
``` ```
Used on: tab switches (light), new-request CTA (medium), language toggle (light), back button (light). All calls are wrapped in try/catch — the API may be absent on older clients. Used on: tab switches (light), new-request CTA (medium), language toggle (light), back button (light), payment actions (medium). All calls are wrapped in try/catch — the API may be absent on older clients.
### 12.3 Back Button ### 13.3 Back Button
```ts ```ts
useTelegramBackButton({ webApp, isVisible, onClick }) useTelegramBackButton({ webApp, isVisible, onClick })
@@ -435,22 +551,29 @@ useTelegramBackButton({ webApp, isVisible, onClick })
// Cleanup: offClick() on unmount / visibility change // Cleanup: offClick() on unmount / visibility change
``` ```
### 12.4 Main Button ### 13.4 Main Button
```ts ```ts
useTelegramMainButton({ webApp, isReady, text, onClick }) useTelegramMainButton({ webApp, isReady: false, text: '', onClick: mainButtonAction })
// Calls webApp.MainButton.show() / hide(), setText(), setParams() // isReady is always false — MainButton is intentionally kept hidden.
// Saffron palette: color: '#C2410C', text_color: '#FFFFFF' // The hook is retained so it can be re-enabled without structural changes.
// setParams requires WebApp >= 6.1; silent fallback for older clients
``` ```
### 12.5 Theme Integration ### 13.5 External Links
Telegram's `themeParams` is normalised (both camelCase and snake_case accepted) and injected as CSS custom properties on the shell root (`--telegram-shell-bg`, `--telegram-shell-text`, etc.). The amaneh palette overrides these for the Mini App's own UI, but components can reference them for adaptive behaviours. ```ts
openTelegramExternalLink(context.webApp, path)
// Uses webApp.openLink() for fully external URLs (opens browser).
// Uses window.location.href for same-origin navigation that must stay in WebView.
```
### 13.6 Theme Integration
Telegram's `themeParams` is normalised (both camelCase and snake_case accepted) and injected as CSS custom properties on the shell root. The amaneh palette overrides these for the Mini App's own UI.
--- ---
## 13. Edge Cases ## 14. Edge Cases
| Scenario | Detection | Handling | | Scenario | Detection | Handling |
|---|---|---| |---|---|---|
@@ -467,11 +590,14 @@ Telegram's `themeParams` is normalised (both camelCase and snake_case accepted)
| Persian locale date formatting | `lang === 'fa'` | `toLocaleDateString('fa-IR', ...)` in `formatDate` helper | | Persian locale date formatting | `lang === 'fa'` | `toLocaleDateString('fa-IR', ...)` in `formatDate` helper |
| Cart cross-tab sync | Multiple tabs / Mini App + web | `tg-cart-changed` DOM event + `storage` event both trigger re-render | | Cart cross-tab sync | Multiple tabs / Mini App + web | `tg-cart-changed` DOM event + `storage` event both trigger re-render |
| Template at capacity | `remainingCapacity === 0` at checkout | Stock validation clamps/removes over-capacity items before payment | | Template at capacity | `remainingCapacity === 0` at checkout | Stock validation clamps/removes over-capacity items before payment |
| Stray global socket on checkout | `template-checkout-payment-confirmed` fires unexpectedly | Guard checks `createdRequestIds.length > 0` before advancing to completion step | | Payment from shop checkout | `paymentCheckoutFlow === true` | BackButton steps back to cart; progress header shows 3-step flow |
| Display name resolution | User may have no name set in DB | Falls back to Telegram profile name (`first_name` / `last_name`), then generic label |
| Seller chat from request detail | `onChatSeller(sellerId)` | `createConversation({ type: 'direct', participantIds: [sellerId] })` → opens chat thread in-shell |
| Assist hand-off on iOS | `webApp.openLink()` opens Safari | `window.location.href` used instead to keep navigation in the Telegram WebView |
--- ---
## 14. File Map ## 15. File Map
``` ```
src/ src/
@@ -480,6 +606,7 @@ src/
sections/telegram/ sections/telegram/
constants.ts # TG_PALETTE, TG_FONTS, TG_EASE, status maps constants.ts # TG_PALETTE, TG_FONTS, TG_EASE, status maps
telegram-shell-css.ts # buildTelegramShellCss() — inlined CSS blob telegram-shell-css.ts # buildTelegramShellCss() — inlined CSS blob
avatar-url.ts # avatar URL helper
index.ts # barrel index.ts # barrel
locales/ locales/
types.ts # TelegramDict, TelegramLang, TelegramTabId types.ts # TelegramDict, TelegramLang, TelegramTabId
@@ -490,35 +617,47 @@ src/
use-telegram-live-context.ts # SDK polling use-telegram-live-context.ts # SDK polling
use-telegram-language.ts # EN/FA detection + ?lang= + localStorage persist use-telegram-language.ts # EN/FA detection + ?lang= + localStorage persist
use-telegram-auto-sign-in.ts # initData → JWT exchange use-telegram-auto-sign-in.ts # initData → JWT exchange
use-telegram-main-button.ts # MainButton lifecycle use-telegram-main-button.ts # MainButton lifecycle (kept, isReady=false)
use-telegram-back-button.ts # BackButton lifecycle use-telegram-back-button.ts # BackButton lifecycle
use-telegram-haptic.ts # HapticFeedback wrapper use-telegram-haptic.ts # HapticFeedback wrapper
use-telegram-cart.ts # localStorage cart (shared with web checkout) use-telegram-cart.ts # localStorage cart (shared with web checkout)
use-telegram-theme.ts # dark/light theme detection
use-telegram-realtime.ts # shared Socket.IO real-time helper
use-telegram-shops.ts # GET /api/request-templates/sellers use-telegram-shops.ts # GET /api/request-templates/sellers
use-telegram-seller-shop.ts # GET /api/request-templates/sellers/:id use-telegram-seller-shop.ts # GET /api/request-templates/sellers/:id
use-telegram-sellers.ts # GET /api/marketplace/sellers use-telegram-sellers.ts # GET /api/marketplace/sellers
use-telegram-my-requests.ts # GET /api/requests use-telegram-my-requests.ts # GET /api/requests
use-telegram-request.ts # GET /api/purchase-requests/:id use-telegram-request.ts # GET /api/purchase-requests/:id
use-telegram-offers.ts # GET /api/marketplace/offers?requestId=...
use-telegram-conversations.ts # Chat conversation list use-telegram-conversations.ts # Chat conversation list
use-telegram-chat-thread.ts # Chat thread + optimistic send use-telegram-chat-thread.ts # Chat thread + optimistic send
use-telegram-notifications.ts # GET /api/notifications use-telegram-notifications.ts # GET /api/notifications
use-telegram-addresses.ts # GET /api/user/addresses
use-telegram-points.ts # GET /api/user/points
index.ts index.ts
view/ view/
telegram-mini-app-view.tsx # Shell orchestrator (all state lives here) telegram-mini-app-view.tsx # Shell orchestrator (all state lives here)
telegram-home-view.tsx # Home tab telegram-home-view.tsx # Home tab
telegram-shop-view.tsx # Shop tab — sellers list telegram-shop-view.tsx # Shop tab — sellers list
telegram-seller-shop-view.tsx # Seller store drill-down + cart actions telegram-seller-shop-view.tsx # Seller store drill-down + cart actions
telegram-template-detail-view.tsx # Template full detail + cart/order actions
telegram-cart-view.tsx # Cart overlay telegram-cart-view.tsx # Cart overlay
telegram-checkout-view.tsx # In-shell 3-step checkout overlay
telegram-payment-view.tsx # In-shell payment drilldown
telegram-requests-view.tsx # Requests list tab telegram-requests-view.tsx # Requests list tab
telegram-request-detail-view.tsx # Request drilldown + stepper telegram-request-detail-view.tsx # Request drilldown + stepper + offers
telegram-new-request-view.tsx # New request overlay form telegram-new-request-view.tsx # New request overlay form + Assist CTA
telegram-chat-view.tsx # Chat conversation list tab telegram-chat-view.tsx # Chat conversation list tab
telegram-chat-thread-view.tsx # Chat thread drilldown telegram-chat-thread-view.tsx # Chat thread drilldown
telegram-archived-chats-view.tsx # Archived conversations
telegram-account-view.tsx # Account + preferences + sign-out tab telegram-account-view.tsx # Account + preferences + sign-out tab
telegram-notifications-view.tsx # Notifications overlay telegram-notifications-view.tsx # Notifications overlay
telegram-settings-view.tsx # In-shell profile/settings overlay
telegram-addresses-view.tsx # In-shell address management overlay
telegram-points-view.tsx # In-shell points/loyalty overlay
index.ts index.ts
components/ components/
telegram-header.tsx # AMN logo + subtitle + language toggle telegram-header.tsx # AMN logo + subtitle + language toggle + bell
telegram-tab-bar.tsx # Bottom tab bar (5 tabs) telegram-tab-bar.tsx # Bottom tab bar (5 tabs)
telegram-welcome-banner.tsx # Home: escrow account banner + CTA telegram-welcome-banner.tsx # Home: escrow account banner + CTA
telegram-quick-actions.tsx # Home: action cards (Requests / Payments / Chat) telegram-quick-actions.tsx # Home: action cards (Requests / Payments / Chat)
@@ -528,17 +667,22 @@ src/
telegram-request-stepper.tsx # Detail: visual escrow timeline telegram-request-stepper.tsx # Detail: visual escrow timeline
telegram-list-row.tsx # Generic list row primitive telegram-list-row.tsx # Generic list row primitive
telegram-list-skeleton.tsx # Skeleton loader for lists telegram-list-skeleton.tsx # Skeleton loader for lists
telegram-list-controls.tsx # List sort/filter controls
telegram-chat-row.tsx # Chat: conversation list row telegram-chat-row.tsx # Chat: conversation list row
telegram-chat-bubble.tsx # Chat: message bubble telegram-chat-bubble.tsx # Chat: message bubble
telegram-chat-composer.tsx # Chat: message input telegram-chat-composer.tsx # Chat: message input
telegram-review-prompt.tsx # Post-transaction review prompt
telegram-loading-state.tsx # Loading spinner state telegram-loading-state.tsx # Loading spinner state
telegram-unlinked-state.tsx # Unlinked / sign-in prompt state telegram-unlinked-state.tsx # Unlinked / sign-in prompt state
telegram-unsupported-state.tsx # Not-in-Telegram fallback state telegram-unsupported-state.tsx # Not-in-Telegram fallback state
telegram-onboarding-sheet.tsx # New-user onboarding bottom sheet telegram-onboarding-sheet.tsx # New-user onboarding bottom sheet
telegram-empty-state.tsx # Generic empty list state telegram-empty-state.tsx # Generic empty list state
telegram-language-toggle.tsx # EN | FA header toggle telegram-language-toggle.tsx # EN | FA header toggle
telegram-theme-toggle.tsx # Dark / light theme toggle
telegram-bottom-sheet.tsx # Generic bottom sheet primitive telegram-bottom-sheet.tsx # Generic bottom sheet primitive
telegram-form-field.tsx # Form field + input style helper telegram-form-field.tsx # Form field + input style helper
telegram-cart-fab.tsx # Floating cart badge button
telegram-support-fab.tsx # Floating support chat button
telegram-seal-mark.tsx # SealMark logo component telegram-seal-mark.tsx # SealMark logo component
telegram-icons.tsx # Telegram-scoped icon set telegram-icons.tsx # Telegram-scoped icon set
index.ts index.ts
@@ -546,7 +690,7 @@ src/
--- ---
## 15. Current Implementation Status (v2.8.94) ## 16. Current Implementation Status
| Area | Status | Notes | | Area | Status | Notes |
|---|---|---| |---|---|---|
@@ -556,39 +700,54 @@ src/
| Manual sign-in (unlinked) | Done | Email + create account fallbacks | | Manual sign-in (unlinked) | Done | Email + create account fallbacks |
| Bilingual EN/FA | Done | Full string inventory, RTL layout, Vazirmatn font | | Bilingual EN/FA | Done | Full string inventory, RTL layout, Vazirmatn font |
| Language toggle | Done | Header toggle + localStorage persist | | Language toggle | Done | Header toggle + localStorage persist |
| `?lang=` dev preview param | Done | URL param override added to `useTelegramLanguage` | | `?lang=` dev preview param | Done | URL param override |
| Home tab | Done | Banner + quick actions + state chips | | Dark mode | Done | `.tg-shell--dark` class, `use-telegram-theme` |
| Shop tab — sellers list | Done | API-backed with skeleton + empty states, cart badge | | Home tab | Done | Banner + quick actions + state chips + Assist CTA |
| Shop tab — seller store | Done | Templates list, add/remove cart, direct order link | | Shop tab — sellers list | Done | API-backed with skeleton + empty states, cart FAB |
| Shop tab — seller store | Done | Templates list, add/remove cart, template detail drilldown |
| Template detail drilldown | Done | Full detail, cart/order actions |
| Shopping cart (localStorage) | Done | Shared key with web checkout; cross-tab sync | | Shopping cart (localStorage) | Done | Shared key with web checkout; cross-tab sync |
| Cart overlay | Done | Quantity controls, remove, total, checkout link | | Cart overlay | Done | Quantity controls, remove, total, in-shell checkout CTA |
| Web checkout handoff | Done | localStorage handoff; stock guard; socket guard | | In-shell checkout | Done | 3-step cart→address→payment; replaces web handoff |
| In-shell payment view | Done | Direct balance intent + polling; checkout-flow back-nav |
| Requests list | Done | API-backed with skeleton + empty states | | Requests list | Done | API-backed with skeleton + empty states |
| Request detail + stepper | Done | Status timeline, budget, dates with fa-IR locale | | Request detail + stepper | Done | Status timeline, budget, dates with fa-IR locale |
| New request form | Done | In-shell overlay, category fetch, validation | | Offer review in request detail | Done | Offers fetched via `useTelegramOffers`; accept/reject |
| New request form | Done | In-shell overlay, category fetch, validation, Assist CTA |
| Chat list | Done | API-backed conversation list + support row | | Chat list | Done | API-backed conversation list + support row |
| Chat thread | Done | Messages + optimistic send + Socket.IO real-time | | Chat thread | Done | Messages + optimistic send + Socket.IO real-time |
| Account tab | Done | Profile, preferences, help, web-dashboard links, sign-out | | Direct seller chat | Done | `createConversation` from request detail |
| Account tab | Done | Profile, preferences, help, sign-out |
| Settings overlay | Done | In-shell profile editing |
| Addresses overlay | Done | In-shell address management; reachable from checkout |
| Points overlay | Done | In-shell points/loyalty |
| Notifications overlay | Done | API-backed; Socket.IO real-time; mark-all-read | | Notifications overlay | Done | API-backed; Socket.IO real-time; mark-all-read |
| Telegram chrome (MainButton / BackButton) | Done | Saffron palette, lifecycle hooks | | Notifications unread badge | Done | Bell icon in header |
| Telegram chrome (BackButton) | Done | Full back-stack with checkout flow awareness |
| Telegram MainButton | Disabled | Intentionally hidden (`isReady: false`); hook retained |
| Haptic feedback | Done | All tap interactions | | Haptic feedback | Done | All tap interactions |
| Safe area insets | Done | Normalised from SDK + CSS env() fallback | | Safe area insets | Done | Normalised from SDK + CSS env() fallback |
| Deep link `startapp` context | Partial | Parsed but not yet used to auto-navigate to a request | | amanat-assist integration | Done | "Open Assist" CTA in Home + New Request; window.location hand-off with access_token |
| Bilingual onboarding sheet | Done | Shown on `isNewUser` flag | | Deep link `startapp` routing | Partial | `startParam` parsed; auto-navigation to specific request not yet wired |
| Unsupported / browser fallback | Done | Web dashboard link | | Backend room-scoped Socket.IO | Partial | Global socket broadcast fixed client-side (v2.8.4); server-side room scoping is a follow-up |
| Client matrix QA (iOS/Android/Desktop) | Pending | Needs cross-platform testing pass |
### Open Items ### Open Items
- `startapp` deep link routing: if `context.startParam` matches `req_<id>`, auto-open `TelegramRequestDetailView` on first render. 1. **`startapp` deep link routing:** if `context.startParam` matches `req_<id>`, auto-open `TelegramRequestDetailView` on first render.
- Backend room-scoped Socket.IO for real-time chat updates (global socket event broadcast was fixed client-side in v2.8.4; server-side scoping is a follow-up). 2. **Backend room-scoped Socket.IO:** server-side scoping for real-time chat updates (follow-up from client-side fix in v2.8.4).
3. **Client matrix QA:** iOS Telegram, Android Telegram, Telegram Desktop, and web clients all need a full feature pass.
4. **Review prompt:** `TelegramReviewPrompt` component exists but integration point (post-payment / post-delivery) is TBD.
5. **Archived chats:** `TelegramArchivedChatsView` exists but is not yet surfaced in the navigation.
--- ---
## 16. Related Documents ## 17. Related Documents
- [[amanat-assist]] — the separate AI-driven Mini App for LLM-assisted request creation
- [[PRD - Telegram Mini App Bilingual (EN + FA)]] — bilingual string inventory and RTL layout spec - [[PRD - Telegram Mini App Bilingual (EN + FA)]] — bilingual string inventory and RTL layout spec
- [[PRD - Telegram Phone Number Authentication]] — phone-number auth as a future sign-in path - [[PRD - Telegram Phone Number Authentication]] — phone-number auth as a future sign-in path
- [[Authentication Flow]] — JWT lifecycle shared with the Mini App auth - [[Authentication Flow]] — JWT lifecycle shared with the Mini App auth
- [[Purchase Request Flow]] — escrow state machine surfaced in the stepper - [[Purchase Request Flow]] — escrow state machine surfaced in the stepper
- [[Chat Flow]] — real-time messaging that the Mini App embeds - [[Chat Flow]] — real-time messaging that the Mini App embeds
- [[Request Template Checkout]] — web checkout flow that the Mini App cart hands off to - [[Request Template Checkout]] — web checkout flow; the Mini App now has its own in-shell checkout, but the localStorage cart key is shared

View File

@@ -1948,4 +1948,12 @@ gate. (Buyer step labels already matched the web — no change.)
--- ---
### 2026-06-08 — nick-doc sync — added sub-project service docs and updated core docs
Added 4 new service docs to `10 - Services/`: backend, frontend, scanner, deployment.
Updated amanat-assist.md to latest version. Updated Telegram Mini App flow doc and Scanner Architecture doc.
Added `10 - Services/README.md` index. All docs now reflect current codebase state as of 2026-06-08.
---
<!-- Add new entries above this line. Newest at top. --> <!-- Add new entries above this line. Newest at top. -->

53
10 - Services/README.md Normal file
View File

@@ -0,0 +1,53 @@
# 10 - Services
This section documents each deployable service (sub-project) in the Amanat/Escrow platform. Each article covers the service's purpose, configuration, build process, and operational notes.
See also: [[01 - Architecture]] · [[08 - Operations]] · [[03 - API Reference]]
---
## Service Inventory
| Service | Language / Framework | Status | URL | Doc |
|---|---|---|---|---|
| Backend | Node.js / TypeScript (Express) | Live | `api.dev.amn.gg` | [[backend]] |
| Frontend | Next.js / React / TypeScript | Live | `dev.amn.gg` | [[frontend]] |
| Scanner | Go | Live | internal | [[scanner]] |
| Amanat Assist | Node.js / TypeScript + LLM proxy | Live | `assist.dev.amn.gg` | [[amanat-assist]] |
| Deployment | Docker Compose + Caddy + Watchtower | Live | — | [[deployment]] |
---
## Architecture Overview
```
Browser / Telegram Mini App
infra-caddy (reverse proxy, TLS)
├── dev.amn.gg → [[frontend]] (Next.js SSR)
├── api.dev.amn.gg → [[backend]] (Express REST + WebSocket)
└── assist.dev.amn.gg → [[amanat-assist]] (LLM proxy / Telegram bot)
[[backend]]
├── MongoDB / PostgreSQL (dual-write seam, PG cutover in progress)
├── Redis (sessions, rate-limit, pub-sub)
└── emits payment events
[[scanner]] (Go — watches EVM chains for on-chain payments)
│ webhook callback
└──────────────────▶ [[backend]] /api/payment/callback
```
- All containers share the `shared-web` Docker network managed by [[deployment]].
- [[amanat-assist]] is a separate Telegram Mini App; it calls [[backend]] APIs on behalf of users.
- [[scanner]] is stateless; it probes RPC endpoints and forwards confirmations to the backend.
---
## Related Sections
- [[01 - Architecture]] — system-wide design decisions, data model, and sequence diagrams
- [[03 - API Reference]] — REST endpoints, WebSocket events, auth headers
- [[08 - Operations]] — deployment runbooks, monitoring, secrets management

View File

@@ -246,12 +246,17 @@ trigger: push/manual to main
agent: linux/arm64 (same host as assist.amn.gg) agent: linux/arm64 (same host as assist.amn.gg)
steps: steps:
1. build-frontend: npm ci + npm run build (Vite) 1. build-frontend (node:22-alpine):
2. deploy: - npm ci + npm run build (Vite)
- Bakes VITE_ env vars into the static bundle at build time
2. deploy (docker:27-cli, docker socket volume-mounted — no registry push):
- Copy dist/ to /opt/amanat-assist/dist/ (nginx bind-mount) - Copy dist/ to /opt/amanat-assist/dist/ (nginx bind-mount)
- Rebuild amanat-llm-proxy Docker image in-place - Sync docker-compose.yml to /opt/amanat-assist/
- docker compose up -d --no-deps llm-proxy - Rebuild amanat-llm-proxy Docker image in-place (locally, never pushed)
3. notify: Telegram CI notification - docker compose up -d (recreates llm-proxy container)
3. notify (node:22-alpine):
- Runs scripts/ci/tg-notify.cjs on success or failure
- Uses TG_TOKEN + TG_USERS secrets
``` ```
Nginx picks up new static files from the bind-mount without restart. Nginx picks up new static files from the bind-mount without restart.
@@ -267,7 +272,7 @@ The proxy container is recreated with the new image.
| `MISTRAL_API_KEY` | llm-proxy runtime | Mistral API key (server-side only) | | `MISTRAL_API_KEY` | llm-proxy runtime | Mistral API key (server-side only) |
| `KIMI_API_KEY` | llm-proxy runtime | Optional Kimi API key | | `KIMI_API_KEY` | llm-proxy runtime | Optional Kimi API key |
| `DEEPSEEK_API_KEY` | llm-proxy runtime | Optional DeepSeek API key (auto-fallback) | | `DEEPSEEK_API_KEY` | llm-proxy runtime | Optional DeepSeek API key (auto-fallback) |
| `OPENCODE_PROXY_URL` | llm-proxy runtime | OpenCode local proxy URL | | `OPENCODE_PROXY_URL` | llm-proxy runtime | OpenCode local proxy URL (default `http://127.0.0.1:3456`) |
| `ALLOWED_ORIGINS` | llm-proxy runtime | CORS whitelist (comma-separated) | | `ALLOWED_ORIGINS` | llm-proxy runtime | CORS whitelist (comma-separated) |
| `PORT` | llm-proxy runtime | Port (default 3001) | | `PORT` | llm-proxy runtime | Port (default 3001) |
@@ -294,3 +299,5 @@ See `src/sections/assist/` in the frontend repo for the implementation.
- **Session storage is local only** — history lives in `localStorage`, not synced to backend - **Session storage is local only** — history lives in `localStorage`, not synced to backend
- **Vision model not streaming** — responses may feel slow for image analysis - **Vision model not streaming** — responses may feel slow for image analysis
- **categoryId from vision disabled** — vision returns category names, not ObjectIds; name→ID matching is left to the LLM in the follow-up turn - **categoryId from vision disabled** — vision returns category names, not ObjectIds; name→ID matching is left to the LLM in the follow-up turn
- **llm-proxy is zero-dependency** — `llm-proxy/index.mjs` uses only Node.js built-ins (`http`, native `fetch`); no npm packages. Logs rotate at 10 MB.
- **No registry push** — CI builds the llm-proxy image directly on the host via a docker socket volume mount; `docker pull` will always fail (intentional — image is local-only)

466
10 - Services/backend.md Normal file
View File

@@ -0,0 +1,466 @@
# Backend Service — amn-backend
## 1. Overview
**amn-backend** is the Express 5 / TypeScript API server powering the Amanat escrow marketplace (`dev.amn.gg`). It handles all buyer-seller escrow workflow logic, crypto payment processing across multiple chains and providers, real-time socket events, authentication, admin tooling, and the in-progress Mongo→PostgreSQL migration.
| Field | Value |
|---|---|
| Current version | **2.10.5** |
| Status | Active — production at `dev.amn.gg` |
| Repo | `git@git.tbs.amn.gg:escrow/backend.git` |
| Runtime port | 8083 (production Docker), 8080 (dev Docker), 5001 (dev default) |
| Database | PostgreSQL (Drizzle ORM) — sole persistence layer as of v2.9.12 |
| Node version | 22 (`.nvmrc`) |
PostgreSQL is the sole active database. MongoDB references remain in some env-var config for the dual-write seam during migration, but no Mongo-backed stores remain active in normal operation (all 11 repository domains use Drizzle repos).
---
## 2. Tech Stack
| Layer | Technology |
|---|---|
| Framework | Express 5 (TypeScript) |
| Runtime | Node.js 22 |
| Primary DB | PostgreSQL via Drizzle ORM (`drizzle-orm ^0.45.2`, `pg ^8.21.0`) |
| Migrations | Drizzle Kit (`drizzle-kit ^0.31.1`) — 19 landed SQL migrations |
| Session / Cache | Redis (`ioredis`) with Socket.IO pub-sub adapter |
| Realtime | Socket.IO with Redis adapter (seller/buyer rooms) |
| Auth | JWT (`jsonwebtoken`), Google OAuth, WebAuthn passkeys (`@simplewebauthn/server`), Telegram Mini App initData |
| Crypto payments | Request Network, amn.scanner (in-house), DePay, SHKeeper |
| Rate limiting | In-memory (express-rate-limit) — Redis adapter planned |
| AI integration | OpenAI (listing descriptions, moderation) |
| Email | Nodemailer via Resend SMTP |
| Telegram | Bot webhook + Mini App session + identity linking |
| Security | Helmet, CORS, Cloudflare Turnstile CAPTCHA, HMAC webhook verification |
| Containerization | Docker (Dockerfile.prod, Dockerfile.dev) |
| CI/CD | Woodpecker CI (4 pipelines) |
---
## 3. Directory Structure
```
backend/src/
├── app.ts # Express bootstrap, middleware chain, route registration, graceful shutdown
├── cluster.ts # Node.js cluster mode entry point (multi-core)
├── controllers/ # HTTP request handlers — thin layer, delegate to services
├── db/ # Drizzle/Postgres layer
│ ├── schema/ # Per-table Drizzle schema files + index.ts barrel
│ ├── migrations/ # 19 numbered SQL migration files (00000018)
│ └── repositories/ # Drizzle repos, factory.ts, backfill scripts, verify utilities
├── infrastructure/
│ └── socket/ # Socket.IO server init, room helpers, emit wrappers
├── models/ # Removed — replaced by Drizzle schemas in db/schema/
├── routes/ # Express Router definitions (mounted in app.ts)
│ ├── amnScannerWebhookRoutes.ts
│ ├── blogRoutes.ts
│ ├── disputeRoutes.ts
│ └── pointsRoutes.ts
├── scripts/ # CLI utilities (seed:users, seed:categories, backfill, etc.)
├── seeds/ # Seed data fixtures (Postgres-capable, store-aware, idempotent)
├── services/ # Feature domain services (self-contained per domain)
│ ├── address/ # Address management
│ ├── admin/ # Admin-only operations, AML config, break-glass, data cleanup
│ ├── ai/ # OpenAI integration (descriptions, moderation)
│ ├── auth/ # JWT, OAuth, Passkey, Telegram, password reset
│ ├── blockchain/ # Web3 read/verify helpers
│ ├── blog/ # Posts, categories, comments
│ ├── chat/ # Conversations, messages, attachments
│ ├── config/ # Runtime config service
│ ├── delivery/ # Delivery tracking
│ ├── dispute/ # Dispute lifecycle, evidence, mediator assignment
│ ├── email/ # Nodemailer transport + templates
│ ├── file/ # Multer uploads, MIME validation
│ ├── health/ # Health check endpoint logic
│ ├── marketplace/ # PurchaseRequest, SellerOffer, Template, Shop
│ ├── notification/ # Templates, delivery, mark-as-read
│ ├── payment/ # Payment orchestration + provider adapters + ledger
│ │ ├── adapters/ # Provider-neutral adapter interface + registry
│ │ ├── amnScanner/ # amn.scanner in-house pay-in detection
│ │ ├── ledger/ # Internal funds ledger (available / held / releasable)
│ │ ├── migration/ # Legacy data backfill utilities
│ │ ├── observability/ # Logging and incident controls
│ │ ├── orchestration/ # High-level payment flow coordination
│ │ ├── priceOracle/ # Chainlink + off-chain FX oracle, depeg protection
│ │ ├── reconciliation/ # Webhook + status reconciliation per provider
│ │ ├── request-network/# Request Network routes and webhook signature
│ │ ├── requestNetwork/ # Request Network service logic
│ │ ├── safety/ # Transaction Safety Provider + confirmation thresholds
│ │ ├── tokens/ # On-chain token registry / decimals lookup
│ │ └── wallets/ # Derived destination wallets + sweep orchestration
│ ├── points/ # Loyalty points, levels, redemption
│ ├── redis/ # Redis client, cache helpers, pub-sub
│ ├── telegram/ # Bot webhook, Mini App session, identity linking, notifications
│ ├── trezor/ # Trezor hardware-wallet signing for admin approvals
│ └── user/ # Profile, preferences, addresses
├── shared/
│ ├── config/index.ts # Centralised typed env-var loader
│ ├── middleware/ # authMiddleware, errorHandler, roleGuard, validators
│ ├── types/ # Cross-cutting TypeScript types
│ └── utils/response-handler.ts # Standard success/error response envelope
└── utils/ # Pure utilities (logger, currencyUtils, etc.)
```
Each service folder is self-contained: `<feature>Service.ts`, `<feature>Controller.ts`, `<feature>Routes.ts`, `<feature>Validation.ts`. This design allows future extraction to microservices with minimal coupling.
---
## 4. Key Services / Modules
| Module | Description |
|---|---|
| `services/auth/` | JWT issuance/refresh, Google OAuth, WebAuthn passkeys, Telegram initData verification, password reset |
| `services/marketplace/` | Core escrow domain: PurchaseRequest, SellerOffer, Template, Shop lifecycle |
| `services/payment/` | Payment orchestration, provider adapters, internal ledger, reconciliation |
| `services/payment/ledger/` | Double-spend guard: tracks available / held / releasable balances per payment |
| `services/payment/wallets/` | Derived destination address derivation (xpub) + sweep orchestration |
| `services/payment/priceOracle/` | Chainlink + off-chain FX oracle for multi-currency pricing + stablecoin depeg protection |
| `services/payment/safety/` | Transaction Safety Provider: confirmation thresholds, tx hash and transfer match enforcement |
| `services/payment/amnScanner/` | In-house blockchain scanner webhook adapter (replaces Request Network for pay-in detection) |
| `services/payment/requestNetwork/` | Request Network pay-in routes, webhook signature verification, invoice creation |
| `infrastructure/socket/` | Socket.IO server init, buyer/seller room management, emit helpers |
| `services/redis/` | Redis client wrapper, pub-sub channel helpers, session cache |
| `services/chat/` | Conversations and message threading between buyer and seller |
| `services/dispute/` | Dispute lifecycle: open, evidence, mediator, resolution |
| `services/admin/` | Admin RBAC operations: AML config, break-glass, dispute management, data cleanup |
| `services/telegram/` | Bot webhook handler, Mini App session auth, Telegram identity linking, push notifications |
| `services/trezor/` | Trezor hardware-wallet approval gate for high-value admin actions (break-glass overrideable) |
| `services/notification/` | In-app notification templates, delivery, mark-as-read |
| `services/ai/` | OpenAI integration: AI-assisted listing descriptions and content moderation |
| `services/email/` | Nodemailer transport via Resend SMTP, HTML email templates |
| `services/points/` | Loyalty points engine, tier levels, redemption |
| `services/blog/` | Blog posts, categories, comments |
| `services/file/` | Multer-based file upload handler, MIME validation, upload path management |
| `services/blockchain/` | Low-level Web3 read helpers: balance checks, tx confirmation polling |
| `db/repositories/` | Drizzle ORM repository layer for all 11 domain entities |
| `seeds/` | Idempotent Postgres seed fixtures for users, categories, shops, configs |
| `scripts/` | CLI backfill, migration verify, seeding, and maintenance scripts |
---
## 5. API Surface Summary
All routes are mounted under `/api/*`. See [[03 - API Reference/API Overview]] for the full endpoint reference.
Key route groups:
| Prefix | Domain |
|---|---|
| `/api/auth/*` | Registration, login, OAuth, passkeys, Telegram auth |
| `/api/payment/*` | Payment CRUD, status polling, provider webhooks |
| `/api/payment/request-network/*` | Request Network webhook + invoice endpoints |
| `/api/amn-scanner/*` | amn.scanner webhook receiver |
| `/api/marketplace/*` | Purchase requests, seller offers, templates |
| `/api/chat/*` | Conversations, messages, attachments |
| `/api/dispute/*` | Dispute lifecycle |
| `/api/admin/*` | Admin operations (role-gated) |
| `/api/notification/*` | In-app notifications |
| `/api/blog/*` | Blog posts and comments |
| `/api/points/*` | Loyalty points |
| `/api/user/*` | User profile, preferences |
| `/health` | Docker healthcheck + active store mode listing |
**Rate limits (active):**
| Scope | Limit |
|---|---|
| Auth endpoints | 10 req / 15 min |
| Payment endpoints | 30 req / 15 min |
| AI endpoints | 20 req / 15 min |
| Global | 100 req / 15 min |
| `GET /api/payment/:id` | Exempt (polling route) |
| RN + Telegram webhooks | Exempt from global limiter |
---
## 6. Database
### PostgreSQL (primary — active)
- **ORM:** Drizzle ORM (`drizzle-orm ^0.45.2`)
- **Driver:** `pg ^8.21.0`
- **Migrations:** 19 SQL files under `src/db/migrations/` (00000018), managed by `drizzle-kit`
- **Schemas:** per-table files in `src/db/schema/`, exported via `index.ts` barrel
- **Repositories:** `src/db/repositories/` — one Drizzle repo per domain; `factory.ts` provides DI
- **Connection:** `PG_URL` env var (`postgres://user:pass@host:5432/db`)
- **Migrations run:** `npx drizzle-kit migrate` (or via `drizzle.config.ts`)
### MongoDB (legacy — migration in progress)
MongoDB and Mongoose were removed at the code level as of v2.9.12. The `MONGO_CONNECT_MODE` env var and `*_STORE` vars remain for the dual-write seam but all active domain stores use Drizzle exclusively. Remaining migration work:
- Backfill execution for remaining legacy records
- Per-domain read cutover verification
- Chat domain normalization (current blocker)
- Full runtime coupling severance
See [[PRD - Mongo Retirement (Full Nuke).md]] and [[MIGRATION_TODO.md]] for status.
---
## 7. Auth Model
| Method | Mechanism |
|---|---|
| Password | bcrypt hashed, JWT access + refresh token pair |
| Google OAuth | OAuth 2.0 code flow via `google-auth-library` |
| WebAuthn / Passkeys | `@simplewebauthn/server` — RP ID: `dev.amn.gg` |
| Telegram Mini App | initData HMAC verification (bot token), replay window: 120 s, TTL: 24 h |
| Telegram Bot | Webhook secret token header verification |
| Sessions | Stateless JWT; refresh token stored in Redis |
| CAPTCHA | Cloudflare Turnstile, triggered after 3 failed login attempts from same IP |
**RBAC roles:** `admin`, `buyer`, `seller`, `resolver`, `guard`
`roleGuard(role)` middleware is applied per-route after `authMiddleware`. The admin role unlocks break-glass, AML config, dispute management, and data cleanup endpoints.
**Trezor safekeeping:** when `TREZOR_SAFEKEEPING_REQUIRED=true`, high-value admin actions (release, refund, payout) require a Trezor-signed approval message. Break-glass overrides this for 1 hour and fires a Telegram alarm.
---
## 8. Realtime (Socket.IO)
- **Adapter:** Redis pub-sub (`@socket.io/redis-adapter`) — scales across multiple backend instances
- **Init:** `infrastructure/socket/socketService.ts` — attaches to the HTTP server after Express bootstraps
- **Room model:**
- `buyer:<userId>` — buyer-facing events (payment status, offer updates, cart)
- `seller:<userId>` — seller-facing events (new requests, offer accepted)
- Admin rooms for dispute/notification broadcasts
- **Auth:** Socket handshake verified with JWT before room join
- **Known issue:** Global payment broadcasts previously wiped all users' carts (fixed in frontend v2.8.4 with a provider gate). Backend room-scoping is an open follow-up item.
Key emitted events (non-exhaustive):
| Event | Direction | Description |
|---|---|---|
| `payment:status` | Server → client | Payment state change (pending → confirmed → released) |
| `offer:new` | Server → seller | New purchase request from buyer |
| `offer:accepted` | Server → buyer | Seller accepted the offer |
| `notification:new` | Server → client | In-app notification delivery |
| `dispute:update` | Server → both | Dispute state change |
| `chat:message` | Server → both | New chat message in conversation |
---
## 9. Payment Providers
The payment layer uses a provider-neutral adapter interface (`services/payment/adapters/`). All providers register in the adapter registry. The ledger (`services/payment/ledger/`) enforces double-spend prevention across all providers.
| Provider | Type | Chains | Status |
|---|---|---|---|
| **amn.scanner** | In-house blockchain scanner | ETH, BSC, Base, TON | Active — default for new payments when `AMN_SCANNER_DEFAULT=true` |
| **Request Network** | Decentralized payment protocol | BSC (USDC/USDT) + ETH | Active — legacy in-flight payments; webhook-driven |
| **DePay** | Widget-based crypto payments | Multi-chain | Available via adapter |
| **SHKeeper** | Self-hosted crypto gateway | Bitcoin + EVM | Available via adapter |
### Payment flow
1. Buyer creates intent (`POST /api/payment`) → provider adapter creates invoice / watch address
2. Provider webhook arrives → HMAC-verified → reconciliation service updates ledger
3. Escrow holds funds → seller fulfills → admin/resolver releases or refunds
4. Ledger enforces: held → releasable → released (no double-spend)
### amn.scanner specifics
- Webhook endpoint: `POST /api/amn-scanner/webhook`
- HMAC verification via `AMN_SCANNER_WEBHOOK_SECRET`
- Discriminator field: `payload.event` (not `eventType`) — always check this field
- Provider scoped by `provider: "amn.scanner"` in payment records
- Read token decimals on-chain, not from registry
### Request Network specifics
- Webhook endpoint: `POST /api/payment/request-network/webhook`
- Webhook secret: `REQUEST_NETWORK_WEBHOOK_SECRET`
- Network: BSC mainnet, currency: USDC
- Canonical proxy addresses differ per chain (ETH: `0x370DE2…`, Base: `0x189219…`) — probe before trusting
### Safety layer
- `TRANSACTION_SAFETY_MIN_CONFIRMATIONS=12` (default)
- Requires tx hash match and on-chain transfer match before releasing funds
- AML screening: `none` (default), `ofac` (OFAC SDN list, local, free), or `chainalysis`
### Price oracle / depeg protection
- Providers: Chainlink + off-chain FX (`OFFCHAIN_FX_URL`)
- Chains: ETH (RPC via `CHAINLINK_RPC_1`), BSC (via `CHAINLINK_RPC_56`)
- Depeg hard cap: `DEPEG_HARD_CAP_BPS` (default 500 bps = 5%)
- Oracle max staleness: `ORACLE_MAX_STALENESS_S=120`
- Currently disabled (`ORACLE_QUOTING_ENABLED=false`) — enable after FX feeds are configured
---
## 10. CI/CD (Woodpecker)
Four pipelines in `backend/.woodpecker/`:
### `production.yml` — primary deploy pipeline
Trigger: `push` to `main`/`master` · Platform: `linux/arm64`
| Step | Description |
|---|---|
| `get-version` | Reads `package.json` version, writes `dev-<version>` to `.tags` |
| `typecheck` | `npm ci` + `npm run typecheck` — gates image build on clean TypeScript (cached npm on host) |
| `build-and-deploy` | `docker build -t git.tbs.amn.gg/escrow/backend:dev` locally on the agent, then `docker compose up -d --no-deps --pull never backend` — no registry push, image stays local |
| `notify` | Posts plain-text result to Telegram via `scripts/ci/tg-notify.cjs` (no parse_mode) |
> No registry push on production pipeline — agent is co-located with the stack; pushing large images over Tailscale times out.
### `development.yml` — parked
Trigger: `event: cron` (no cron configured — effectively disabled). Targets legacy `git.manko.yoga` registry and retired Arcane deploy. Use `manual.yml` for manual playground builds.
### `manual.yml` — manual build playground
Trigger: manual. Builds and pushes to `git.tbs.amn.gg/escrow/backend`. Used for testing the pipeline independently.
### `cleanup.yml` — image cleanup
Trigger: scheduled/manual. Removes old image tags from the registry.
**Important CI notes:**
- Always bump `package.json` version before pushing a CI-triggering commit — otherwise the build tag doesn't change and the deployed image may be stale.
- CI green does not guarantee the image was pushed — verify `git.tbs.amn.gg` has the `dev-<version>` tag before trusting the deploy.
- Woodpecker eats `${VAR}` in commands — use `$VAR` or `$$VAR`; prefer plugins over raw curl for notifications.
---
## 11. Local Development Quick-Start
```bash
# Clone and install
git clone git@git.tbs.amn.gg:escrow/backend.git
cd backend
npm install
# Copy environment file
cp .env.example .env.local
# Edit .env.local — set PG_URL, REDIS_URI, JWT_SECRET at minimum
# Start dependencies (Postgres + Redis)
docker compose -f docker-compose.local.yml up -d
# Run DB migrations
npx drizzle-kit migrate
# Start dev server (hot-reload)
npm run dev
# → listens on http://localhost:5001
# OR run in dev Docker
docker compose -f docker-compose.dev.yml up
# → listens on http://localhost:8080
# Seed database
npm run seed:users
npm run seed:categories
```
**Typecheck (required before push):**
```bash
npm run typecheck
```
A pre-push git hook blocks the push on tsc errors. If a parallel agent's mid-refactor tree has errors, use explicit `git add <path>` — never `git add -A`.
**Run tests:**
```bash
npm test
```
Test files live in `__tests__/`.
---
## 12. Environment Variables
| Variable | Description |
|---|---|
| `NODE_ENV` | `production` / `development` / `test` |
| `PORT` | HTTP listen port (default 5001) |
| `TRUST_PROXY_HOPS` | Number of reverse-proxy hops in front of app |
| `FRONTEND_URL` | Allowed CORS origin for frontend |
| `BACKEND_URL` | Public backend base URL |
| `PG_URL` | PostgreSQL connection string |
| `POSTGRES_USER` | Postgres username (Docker init) |
| `POSTGRES_PASSWORD` | Postgres password (Docker init) |
| `POSTGRES_DB` | Postgres database name (Docker init) |
| `MONGO_CONNECT_MODE` | `always` / `never` / `optional` — Mongo connection behavior (legacy) |
| `REDIS_URI` | Redis connection URI |
| `JWT_SECRET` | HS256 signing secret for access tokens |
| `JWT_EXPIRES_IN` | Access token TTL (e.g. `7d`) |
| `REFRESH_TOKEN_EXPIRES_IN` | Refresh token TTL (e.g. `30d`) |
| `ADMIN_EMAIL` | Bootstrap admin account email |
| `ADMIN_PASSWORD` | Bootstrap admin account password |
| `SEED_USERS` | `true` to auto-seed users on dev boot |
| `SEED_PASSWORD_ADMIN` | Admin seed account password |
| `SEED_PASSWORD_SUPPORT` | Support seed account password |
| `SEED_PASSWORD_BUYER` | Buyer seed account password |
| `SEED_PASSWORD_SELLER` | Seller seed account password |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `WEBAUTHN_RP_ID` | WebAuthn relying party ID (e.g. `dev.amn.gg`) |
| `WEBAUTHN_RP_NAME` | WebAuthn relying party display name |
| `WEBAUTHN_RP_ORIGIN` | WebAuthn allowed origin |
| `SMTP_HOST` | SMTP server host |
| `SMTP_PORT` | SMTP server port |
| `SMTP_SECURE` | `true` for TLS |
| `SMTP_USER` | SMTP username |
| `SMTP_PASS` | SMTP password |
| `SMTP_FROM` | From address for outgoing email |
| `RESEND_WEBHOOK_SECRET` | Resend inbound webhook signing secret (`whsec_…`) |
| `TURNSTILE_SECRET_KEY` | Cloudflare Turnstile server-side secret (empty = CAPTCHA disabled) |
| `RATE_LIMIT_WINDOW_MS` | Rate limit window in milliseconds |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window (global) |
| `MAX_FILE_SIZE` | Upload max file size in bytes |
| `UPLOAD_PATH` | Server-side upload directory |
| `PAYMENT_PROVIDER_MODE` | `live` / `test` |
| `PAYMENT_LEDGER_ENFORCEMENT` | `true` to enforce double-spend ledger guard |
| `ESCROW_WALLET_ADDRESS` | Platform escrow wallet address |
| `RECEIVER_WALLET_ADDRESS` | Platform receiver wallet address |
| `REQUEST_NETWORK_ENABLED` | Enable Request Network provider |
| `REQUEST_NETWORK_API_KEY` | Request Network API key |
| `REQUEST_NETWORK_NETWORK` | Target chain (`bsc`, `eth`, etc.) |
| `REQUEST_NETWORK_WEBHOOK_SECRET` | HMAC secret for RN webhook verification |
| `AMN_SCANNER_URL` | amn.scanner service base URL |
| `AMN_SCANNER_WEBHOOK_SECRET` | HMAC secret for scanner webhook verification |
| `AMN_SCANNER_DEFAULT` | `true` to make amn.scanner the default provider |
| `ORACLE_QUOTING_ENABLED` | Enable on-chain oracle pricing + depeg protection |
| `PRICE_ORACLE_PROVIDERS` | Comma-separated oracle providers (`chainlink,offchain_fx`) |
| `ORACLE_MAX_STALENESS_S` | Max oracle data age in seconds |
| `DEPEG_HARD_CAP_BPS` | Stablecoin depeg hard cap in basis points |
| `OFFCHAIN_FX_URL` | Off-chain FX rate source URL (required for IRR/TRY) |
| `CHAINLINK_RPC_1` | Private RPC override for Chainlink on ETH mainnet |
| `CHAINLINK_RPC_56` | Private RPC override for Chainlink on BSC |
| `DERIVED_DESTINATION_XPUB` | xPub for derived payment address derivation |
| `DERIVED_DESTINATION_SWEEP_SIGNER` | Sweep signing mode: `build-only` / `hot-key` / `kms` / `trezor` |
| `DERIVED_DESTINATION_SWEEP_INTERVAL_MS` | Sweep cron interval in ms (0 = disabled) |
| `SWEEP_MASTER_PRIVKEY` | Master sweep wallet private key (gas funder) |
| `TREZOR_SAFEKEEPING_REQUIRED` | `true` to require Trezor approval for admin actions |
| `TRANSACTION_SAFETY_ENABLED` | Enable transaction safety layer |
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | Minimum on-chain confirmations before release |
| `TRANSACTION_SAFETY_AML_PROVIDER` | AML provider: `none` / `ofac` / `chainalysis` |
| `CHAINALYSIS_API_KEY` | Chainalysis API key (when AML provider = chainalysis) |
| `TELEGRAM_BOT_TOKEN` | Telegram bot token |
| `TELEGRAM_WEBHOOK_SECRET_TOKEN` | Telegram webhook secret token header value |
| `TELEGRAM_INITDATA_MAX_AGE_SEC` | Max age for Telegram initData (default 86400 s) |
| `TG_NOTIFY_CHATS` | Comma-separated Telegram chat IDs for CI/admin notifications |
---
## 13. Known Issues / Open Items
| Issue | Status | Notes |
|---|---|---|
| Mongo→PG migration incomplete | In progress | Chat normalization is the current blocker; read cutover and backfill exec pending |
| Backend room-scoping for socket events | Open | Frontend provider gate is in place (v2.8.4); backend should scope payment events to `seller:<id>` rooms to prevent cross-user leakage |
| Rate limit counters are in-memory | Open | Not shared across instances; Redis adapter planned for distributed deployments |
| Oracle quoting disabled | Open | `ORACLE_QUOTING_ENABLED=false`; requires FX feed configuration before enabling |
| amn.scanner multi-seller + multi-chain gap | Open | Current scanner watches one chain; multi-seller and multi-chain support not yet verified |
| Woodpecker development.yml parked | Known | Targets legacy registry; needs repointing to `git.tbs.amn.gg` and new Arcane deploy before re-enabling |
| Trezor safekeeping off by default | By design | `TREZOR_SAFEKEEPING_REQUIRED=false`; must be enabled explicitly in production once admin xpub is registered |
| Request Network canonical proxy addresses | Known | RN's CREATE2 canonical-address claim is false for ETH and Base — probe actual address before trusting |
| JSON assets not copied to dist/ | Fixed (requires postbuild) | `tsc` does not copy `.json` files; explicit `postbuild` copy step required for any `fs.readFileSync` on JSON assets |
| Parallel agent push conflicts | Operational | mojtaba agent pushes to same branches; always `git fetch --rebase` before pushing; expect version-bump conflicts |

620
10 - Services/deployment.md Normal file
View File

@@ -0,0 +1,620 @@
---
title: Deployment
tags: [services, deployment, infrastructure, docker]
---
# Deployment
The `deployment/` sub-project contains all Docker Compose definitions, Caddyfile configurations, Gatus monitoring config, and environment templates for running the Amanat escrow platform. Two compose files exist side-by-side reflecting a legacy setup and the current live stack.
---
## 1. Overview
| File | Status | Host | Notes |
|---|---|---|---|
| `deployment/docker-compose.yml` | Legacy | Any | nginx + traefik_public network, images from `git.manko.yoga` registry |
| `deployment/dev-amn/docker-compose.yml` | **Active** | `89.58.32.32` | shared-web + infra-caddy ingress, images from `git.tbs.amn.gg/escrow` |
The `dev-amn` stack is the authoritative deployment. It runs under Arcane project **devEscrow** (`77c10db2…`) on the ARM64 host at `89.58.32.32`. All operational decisions, env var edits, and container restarts target this stack.
The legacy compose (`deployment/docker-compose.yml`) is kept for historical reference. It uses an nginx sidecar, Traefik labels, and images from the old `git.manko.yoga` registry. Do not deploy from it.
---
## 2. Services
| Service | Image | Internal Port | Role |
|---|---|---|---|
| `backend` | `git.tbs.amn.gg/escrow/backend:dev` | 5001 | Express 5 API + Socket.IO + admin seed |
| `frontend` | `git.tbs.amn.gg/escrow/frontend:dev` | 8083 | Next.js SSR app |
| `refscanner` | `git.tbs.amn.gg/escrow/scanner:dev` | 8080 | In-house AMN payment scanner (SQLite) |
| `postgres` | `postgres:18-alpine` | 5432 | Primary datastore (auth, marketplace, PG stores) |
| `redis` | `redis:8-alpine` | 6379 | Session cache, rate-limit counters, job queues |
| `mongodb` | `mongo:8.0-noble` | 27017 | Legacy datastore — dev boot parity only, retired in prod |
| `gatus` | `twinproduction/gatus:latest` | 8080 (mapped 8084) | Uptime monitoring + Telegram alerting |
> **Note on refscanner:** The in-house scanner (`provider: "amn.scanner"`) persists state in a SQLite file at `/data/scanner.db` inside the container. It does not expose a port on `shared-web`; the backend calls it via the `default` bridge by container alias `refscanner`.
> **Note on mongodb:** The Mongo container is retained for dev stack parity because `MONGODB_URI` is still present in the env. It will be removed once the backend's remaining Mongo reads are migrated to Postgres. See [[mongo-to-pg-migration-guide]] and [[mongo_retirement_status]].
---
## 3. Architecture Diagram
```
Internet (HTTPS 443 / HTTP 80)
┌───────────────────────────┐
│ Cloudflare CDN / Proxy │
│ amn.gg / dev.amn.gg │
└─────────────┬─────────────┘
▼ (origin)
┌─────────────────────────────────────────────────┐
│ Host: 89.58.32.32 │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ infra-caddy (Arcane project "infra") │ │
│ │ ports 80:80, 443:443 on host │ │
│ │ reads Caddyfile at │ │
│ │ /opt/arcane/data/projects/infra/Caddyfile │ │
│ └───┬───────────────────────────┬────────────┘ │
│ │ /api/* /socket.io/* │ /* │
│ │ /uploads/* │ │
│ ▼ ▼ │
│ ┌────────────┐ ┌────────────────┐ │
│ │ backend │ │ frontend │ │
│ │ :5001 │ │ :8083 │ │
│ │ shared-web │ │ shared-web │ │
│ └──┬──┬──┬───┘ └────────────────┘ │
│ │ │ │ │
│ │ │ └──────────────────────┐ │
│ │ │ ▼ │
│ │ │ ┌────────────────────┐ │
│ │ │ │ refscanner │ │
│ │ │ │ :8080 (default │ │
│ │ │ │ bridge only) │ │
│ │ │ └────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ postgres │ │ redis │ │ mongodb │ │
│ │ :5432 │ │ :6379 │ │ :27017 │ │
│ │ (default │ │ (default │ │ (default only,│ │
│ │ only) │ │ only) │ │ legacy) │ │
│ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ gatus :8084 (mapped from :8080) │ │
│ │ monitors dev.amn.gg + amn.gg + external │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Networks:
shared-web ─── external, attached: backend, frontend
default ─── internal bridge: all services
```
---
## 4. Networks
| Network | Type | Services Attached | Purpose |
|---|---|---|---|
| `default` (bridge) | Internal | All services | Container-to-container communication |
| `shared-web` | External (pre-existing) | `backend`, `frontend` | Allows infra-caddy to proxy by container name |
| `traefik_public` | External (legacy only) | nginx, gatus (legacy compose) | Old Traefik-based ingress on `git.manko.yoga` host |
**Key rules:**
- `postgres`, `redis`, `mongodb` are on `default` only — no external exposure.
- `refscanner` is on `default` only; backend reaches it via alias `refscanner:8080`.
- Any new public-facing service must join `shared-web` AND get a Caddyfile block. See [[Shared Infra (89.58.32.32)]] and section 6 below.
- `shared-web` must exist on the host before `docker compose up`. It is created by the `infra` project.
---
## 5. Volumes and Bind Mounts
All data volumes in the `dev-amn` stack use relative bind mounts under `./data/` (resolved to `/opt/arcane/data/projects/escrow-dev/data/` on the server):
| Service | Host Path | Container Path | Notes |
|---|---|---|---|
| `backend` | `./data/uploads` | `/app/uploads` | User-uploaded files |
| `refscanner` | `./data/scanner` | `/data` | SQLite DB at `/data/scanner.db` |
| `postgres` | `./data/postgres` | `/var/lib/postgresql/data` | `PGDATA` subdir workaround: actual data at `./data/postgres/pgdata` |
| `redis` | `./data/redis` | `/data` | Persistence dump |
| `mongodb` | `./data/mongo` | `/data/db` | Legacy; can be deleted once Mongo retired |
| `gatus` | `./gatus/config.yaml` | `/config/config.yaml` (ro) | Monitoring config — part of repo |
**Postgres volume note:** `postgres:18` introduced a version-scoped data directory layout and refuses to init directly into a volume root that already contains files from a different layout. The compose file sets `PGDATA=/var/lib/postgresql/data/pgdata` to place actual data in a subdirectory of the mount, avoiding init conflicts.
**Legacy compose** (`deployment/docker-compose.yml`) uses absolute host paths under `/var/data/escrowDev/` and does not share volumes with the dev-amn stack.
---
## 6. Reverse Proxy (infra-caddy) Integration
Ingress for `89.58.32.32` is handled exclusively by **infra-caddy** — the Caddy container in the Arcane project `infra`. It owns host ports 80 and 443. No service should bind those ports directly.
### Current Caddyfile block (dev.amn.gg)
Located at `/opt/arcane/data/projects/infra/Caddyfile` on the server (and mirrored in `deployment/dev-amn/Caddyfile` for reference):
```
{
email manwe@manko.yoga
auto_https disable_redirects
}
dev.amn.gg {
encode zstd gzip
@backend path /api/* /socket.io/* /uploads/*
reverse_proxy @backend backend:5001
reverse_proxy frontend:8083
}
```
- `auto_https disable_redirects` — Cloudflare proxy sits in front; Caddy should not force HTTP→HTTPS redirects at origin.
- Routes by path prefix: `/api/*`, `/socket.io/*`, `/uploads/*` go to `backend:5001`; everything else to `frontend:8083`.
- Container names are resolved via the `shared-web` network.
### Adding a new public service
1. Add the service to `deployment/dev-amn/docker-compose.yml` with `networks: shared-web: {}`.
2. Edit `/opt/arcane/data/projects/infra/Caddyfile` on the server — add a new vhost block or path matcher.
3. Reload Caddy (no restart needed):
```bash
docker exec infra-caddy caddy reload --config /etc/caddy/Caddyfile
```
4. Verify via `curl -I https://dev.amn.gg/<new-path>`.
---
## 7. Gatus Monitoring
Gatus runs as a sidecar in the `default` network. Config is at `deployment/gatus/config.yaml` (bind-mounted read-only). Alerts are delivered via Telegram.
### Alert policy
| Setting | Value |
|---|---|
| Default failure threshold | 3 consecutive failures |
| Default success threshold | 2 consecutive successes |
| Send on resolved | Yes |
| Alert channel | Telegram (`GATUS_TELEGRAM_BOT_TOKEN` / `GATUS_TELEGRAM_CHAT_ID`) |
### Monitored endpoints
| Name | Group | URL | Interval | Key Conditions |
|---|---|---|---|---|
| `backend-dev-version` | backend-dev | `https://dev.amn.gg/api/version` | 60s | HTTP 200, body.version not empty |
| `backend-dev-health` | backend-dev | `https://dev.amn.gg/api/health` | 30s | HTTP 200, all PG store modes = postgres, redis ok, RN chain+token registry loaded |
| `backend-prod-version` | backend-prod | `https://amn.gg/api/version` | 60s | HTTP 200, body.version not empty (failure-threshold 2) |
| `backend-prod-health` | backend-prod | `https://amn.gg/api/health` | 30s | HTTP 200, db/postgres/redis/RN registries ok (failure-threshold 2) |
| `frontend-dev` | frontend | `https://dev.amn.gg/` | 60s | HTTP 200, response < 3000ms |
| `frontend-prod` | frontend | `https://amn.gg/` | 60s | HTTP 200, response < 3000ms (failure-threshold 2) |
| `rn-api-reachable` | external | `https://api.request.network/v2/health` | 5m | HTTP 200/401/404 (accepts auth errors — just checks reachability) |
| `chainalysis-public-api` | external | `https://public.chainalysis.com/api/v1/address/0x000…` | 5m | HTTP 200 or 404 |
| `bsc-rpc-publicnode` | external | `https://bsc-rpc.publicnode.com` (POST) | 2m | HTTP 200, `result == "0x38"` (BSC mainnet chain ID) |
The `backend-dev-health` check validates that **all 8 domain stores are running on Postgres** (`auth`, `config`, `address`, `category`, `levelConfig`, `shopSettings`, `review`, `notification`). A failure here means a store mode regression or a broken `PG_URL`.
Gatus dashboard is accessible at `:8084` on the host (not publicly proxied by default — access via SSH tunnel or add a Caddyfile block if needed).
---
## 8. Environment Variables
All vars are passed to containers via `.env` at the stack root (`deployment/dev-amn/.env` on the server, `deployment/.env` in the repo as the live dev reference). The file is `chmod 600` and never committed.
### Backend
| Variable | Description | Example / Default |
|---|---|---|
| `NODE_ENV` | Runtime environment | `production` |
| `PORT` | Express listen port | `5001` |
| `TRUST_PROXY` | Express trust-proxy (required behind Caddy) | `true` |
| `DEBUG` | Debug namespaces | _(empty)_ |
| `LOG_LEVEL` | Winston log level | `info` |
#### Database
| Variable | Description | Example |
|---|---|---|
| `MONGODB_URI` | Mongo connection string | `mongodb://admin:pass@mongodb:27017/marketplace?authSource=admin` |
| `MONGO_INITDB_ROOT_USERNAME` | Mongo root user | `admin` |
| `MONGO_INITDB_ROOT_PASSWORD` | Mongo root password | — |
| `MONGO_INITDB_DATABASE` | Mongo init database | `marketplace` |
| `DB_NAME` | Mongo database name used by app | `amn-db` |
| `PG_URL` | Postgres DSN | `postgres://amanat:pass@amanat-postgres:5432/amanat_dev` |
| `POSTGRES_USER` | Postgres superuser | `amanat` |
| `POSTGRES_PASSWORD` | Postgres superuser password | — |
| `POSTGRES_DB` | Postgres database name | `amanat_dev` |
| `AUTO_SEED_ON_START` | Run seed on boot | `true` |
#### Store modes (dual-write seam)
| Variable | Description | Default |
|---|---|---|
| `AUTH_STORE` | Auth domain store backend | `postgres` |
| `CONFIG_STORE` | Config domain | `postgres` |
| `ADDRESS_STORE` | Address domain | `postgres` |
| `CATEGORY_STORE` | Category domain | `postgres` |
| `LEVEL_CONFIG_STORE` | Level config domain | `postgres` |
| `SHOP_SETTINGS_STORE` | Shop settings domain | `postgres` |
| `REVIEW_STORE` | Review domain | `postgres` |
| `NOTIFICATION_STORE` | Notification domain | `postgres` |
#### Auth / Sessions
| Variable | Description |
|---|---|
| `JWT_SECRET` | JWT signing secret |
| `JWT_EXPIRES_IN` | Access token TTL (e.g. `7d`) |
| `REFRESH_TOKEN_EXPIRES_IN` | Refresh token TTL (e.g. `30d`) |
#### Redis
| Variable | Description |
|---|---|
| `REDIS_URI` | Redis connection string (includes password) |
| `REDIS_PASSWORD` | Redis auth password (standalone, if not in URI) |
#### URLs / CORS
| Variable | Description |
|---|---|
| `BASE_URL` | Canonical origin (`https://dev.amn.gg`) |
| `API_URL` | API base URL |
| `FRONTEND_URL` | Frontend origin |
| `BACKEND_URL` | Backend origin |
| `CORS_ORIGIN` | Allowed CORS origin |
#### File uploads
| Variable | Description | Default |
|---|---|---|
| `UPLOAD_PATH` | Upload directory inside container | `/app/uploads` |
| `MAX_FILE_SIZE` | Max upload bytes | `52428800` (50 MB) |
#### Rate limiting
| Variable | Description | Default |
|---|---|---|
| `RATE_LIMIT_WINDOW_MS` | Window for rate limiter | `900000` (15 min) |
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | `100` |
> GET `/api/payment/:id` must bypass `paymentLimiter` — see [[backend_rate_limits]].
#### SMTP
| Variable | Description |
|---|---|
| `SMTP_HOST` | SMTP server hostname |
| `SMTP_PORT` | SMTP port |
| `SMTP_SECURE` | TLS (`true`/`false`) |
| `SMTP_USER` | SMTP username |
| `SMTP_PASS` | SMTP password |
| `SMTP_FROM` | From address |
#### WebAuthn (Passkeys)
| Variable | Description |
|---|---|
| `WEBAUTHN_RP_ID` | Relying party ID (domain) |
| `WEBAUTHN_RP_NAME` | Relying party display name |
| `WEBAUTHN_RP_ORIGIN` | Relying party origin URL |
#### Admin seed
| Variable | Description |
|---|---|
| `ADMIN_EMAIL` | Bootstrap admin email |
| `ADMIN_PASSWORD` | Bootstrap admin password |
| `ADMIN_FIRST_NAME` | Admin first name |
| `ADMIN_LAST_NAME` | Admin last name |
#### Google OAuth
| Variable | Description |
|---|---|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
#### OpenAI
| Variable | Description |
|---|---|
| `OPENAI_API_KEY` | OpenAI API key |
| `OPENAI_DEFAULT_MODEL` | Default model (e.g. `gpt-4`) |
| `OPENAI_MAX_TOKENS` | Max tokens per request |
| `OPENAI_TEMPERATURE` | Sampling temperature |
#### Sentry
| Variable | Description |
|---|---|
| `SENTRY_DSN` | Sentry ingest DSN |
#### Wallets / Blockchain
| Variable | Description |
|---|---|
| `ESCROW_WALLET_ADDRESS` | Platform escrow wallet |
| `BSC_USDT_CONTRACT` | BSC USDT token contract address |
| `ADMIN_PAYOUT_WALLET_ADDRESS` | Admin payout destination |
| `RECEIVER_WALLET_ADDRESS` | Default receiver wallet |
#### DePay
| Variable | Description |
|---|---|
| `DEPAY_INTEGRATION_ID` | DePay integration UUID |
| `DEPAY_WEBHOOK_SECRET` | Webhook verification secret |
| `DEPAY_NETWORKS` | Enabled chains (e.g. `bsc`) |
| `DEPAY_ALLOWED_TOKENS` | Allowed payment tokens |
| `DEPAY_PUBLIC_KEY` | DePay public key (PEM) |
#### SHKeeper
| Variable | Description |
|---|---|
| `SHKEEPER_API_KEY` | SHKeeper API key |
| `SHKEEPER_BASE_URL` | SHKeeper service base URL |
| `SHKEEPER_API_URL` | Payment request endpoint |
| `SHKEEPER_ENVIRONMENT` | `production` or `sandbox` |
| `SHKEEPER_WALLET_ID` | Destination wallet |
| `SHKEEPER_NETWORKS` | Enabled chains |
| `SHKEEPER_ALLOWED_TOKENS` | Allowed tokens |
| `SHKEEPER_FORCE_REAL` | Bypass test mode |
| `SHKEEPER_TOKEN` | Token type (e.g. `USDT`) |
| `SHKEEPER_CALLBACK_SECRET` | Callback verification secret |
| `SHKEEPER_WEBHOOK_SECRET` | Webhook verification secret |
#### Request Network
| Variable | Description |
|---|---|
| `REQUEST_NETWORK_ENABLED` | Enable RN provider |
| `REQUEST_NETWORK_WEBHOOK_SECRET` | Webhook signature secret |
| `REQUEST_NETWORK_WEBHOOK_CALLBACK_URL` | Public callback URL |
| `REQUEST_NETWORK_PAYMENT_CURRENCY` | Currency (e.g. `USDC`) |
| `REQUEST_NETWORK_NETWORK` | Chain (e.g. `bsc`) |
| `REQUEST_NETWORK_MERCHANT_REFERENCE` | Merchant address reference |
| `REQUEST_NETWORK_API_BASE_URL` | RN API root |
| `REQUEST_NETWORK_API_KEY` | RN API key |
| `REQUEST_NETWORK_ORIGIN` | Origin header sent to RN |
| `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | Allow test events (default `false`) |
> RN webhook discriminator is `payload.event` (not `eventType`) — see [[rn_webhook_event_field]].
#### Transaction safety
| Variable | Description | Default |
|---|---|---|
| `TRANSACTION_SAFETY_ENABLED` | Enable on-chain verification | `true` |
| `TRANSACTION_SAFETY_REQUIRE_TX_HASH` | Require tx hash | `true` |
| `TRANSACTION_SAFETY_REQUIRE_TRANSFER_MATCH` | Require transfer match | `true` |
| `TRANSACTION_SAFETY_MIN_CONFIRMATIONS` | Min block confirmations | `12` |
| `TRANSACTION_SAFETY_AML_PROVIDER` | AML provider (`none`, `chainalysis`) | `none` |
#### Payment routing
| Variable | Description |
|---|---|
| `PAYMENT_PROVIDER` | Active provider |
| `PAYMENT_ENABLED_PROVIDERS` | Comma-separated enabled providers |
| `PAYMENT_PROVIDER_MODE` | `live` or `test` |
| `PAYMENT_ROLLBACK_PROVIDER` | Fallback provider |
#### Telegram
| Variable | Description |
|---|---|
| `TELEGRAM_FEATURE_ENABLED` | Enable Telegram integration |
| `TELEGRAM_MINIAPP_ENABLED` | Enable Mini App |
| `TELEGRAM_WEBHOOK_ENABLED` | Enable webhook receiver |
| `TELEGRAM_BOT_TOKEN` | Main bot token |
| `TELEGRAM_WEBHOOK_SECRET_TOKEN` | Webhook secret for validation |
| `TELEGRAM_INITDATA_MAX_AGE_SEC` | Max age for initData |
| `TELEGRAM_INITDATA_REPLAY_WINDOW_MS` | Replay protection window |
| `TELEGRAM_WEBHOOK_REPLAY_WINDOW_MS` | Webhook replay protection window |
| `TELEGRAM_SESSION_TTL_SEC` | Session TTL |
| `TG_NOTIFY_BOT_TOKEN` | Ops/monitoring bot token (amnGG_MonitorBot) |
| `TG_NOTIFY_CHATS` | Comma-separated chat IDs for ops notifications |
#### Pangolin / Newt (VPN mesh — optional)
| Variable | Description |
|---|---|
| `PANGOLIN_ENDPOINT` | Pangolin tunnel endpoint |
| `NEWT_ID` | Newt node ID |
| `NEWT_SECRET` | Newt node secret |
#### Testnet chains
| Variable | Description |
|---|---|
| `ENABLE_TESTNET_CHAINS` | Expose testnet chain configs | Set to `true` in dev-amn compose override |
### Frontend (NEXT_PUBLIC_*)
| Variable | Description |
|---|---|
| `NEXT_PUBLIC_API_URL` | Backend API URL (browser-visible) |
| `NEXT_PUBLIC_SOCKET_URL` | Socket.IO server URL |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | WalletConnect project ID |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | Alchemy mainnet key |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_SEPOLIA` | Alchemy Sepolia key |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_POLYGON` | Alchemy Polygon key |
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | Escrow wallet (shown in UI) |
| `NEXT_PUBLIC_APP_NAME` | App display name |
| `NEXT_PUBLIC_APP_VERSION` | App version string |
| `NEXT_PUBLIC_MAPBOX_API_KEY` | Mapbox key (address autocomplete) |
| `NEXT_PUBLIC_PASSKEY_RP_NAME` | WebAuthn RP name |
| `NEXT_PUBLIC_PASSKEY_RP_ID` | WebAuthn RP ID |
| `NEXT_PUBLIC_PASSKEY_ORIGIN` | WebAuthn origin |
| `NEXT_PUBLIC_BACKEND_URL` | Backend origin (used for direct calls) |
| `NEXT_PUBLIC_DEPAY_INTEGRATION_ID` | DePay integration ID |
| `NEXT_PUBLIC_IS_DEVELOPMENT` | Development flag |
| `NEXT_PUBLIC_ENABLE_DEBUG` | Enable client debug logging |
| `NEXT_PUBLIC_APP_URL` | Canonical app URL |
| `NEXT_PUBLIC_TELEGRAM_BOT_ID` | Telegram bot numeric ID |
| `BUILD_STATIC_EXPORT` | Enable `next export` mode (`false` for SSR) |
### Gatus
| Variable | Description |
|---|---|
| `GATUS_TELEGRAM_BOT_TOKEN` | Telegram bot for alert delivery |
| `GATUS_TELEGRAM_CHAT_ID` | Target chat ID for alerts |
---
## 9. Deploy Workflow
### 9.1 Normal image update (CI-driven)
Woodpecker CI builds `backend` and `frontend` images, pushes tags to `git.tbs.amn.gg/escrow/` on merge to `dev`, then triggers an Arcane GitOps sync which pulls the new image and recreates the container.
```
git push origin dev
└─► Woodpecker build pipeline
└─► docker push git.tbs.amn.gg/escrow/backend:dev
└─► docker push git.tbs.amn.gg/escrow/frontend:dev
└─► arcane-cli gitops sync cf6c9eab… (or watchtower polls)
└─► escrow-backend container restarted with new image
└─► escrow-frontend container restarted with new image
```
> Always bump the version in `package.json` + lock before pushing, otherwise the CI build may not register as a new deploy. See [[version_bump_before_ci]].
### 9.2 Manual deploy (backend hotfix — no registry)
For urgent backend fixes without a full CI cycle, use the local-build pattern (the dev stack has `pull_policy: always` but the override `docker-compose.override.yml` sets `pull_policy: never` for the `escrow-backend-local:dev` image path):
```bash
# 1. Copy changed files to build tree on server
scp -i ~/CascadeProjects/wzp src/services/auth/authRoutes.ts \
root@89.58.32.32:/tmp/escrow-backend-build/src/services/auth/
# 2. Rebuild image on server (~3 min, ARM64)
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /tmp/escrow-backend-build && docker build -f Dockerfile.prod -t escrow-backend-local:dev ."
# 3. Restart the backend container
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d backend"
```
### 9.3 Bringing the stack up/down
```bash
# via Arcane CLI (preferred)
arcane-cli project start devEscrow
arcane-cli project stop devEscrow
# via SSH + docker compose (direct)
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d"
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /opt/arcane/data/projects/escrow-dev && docker compose down"
```
### 9.4 Reloading Caddy after Caddyfile edits
Edit `/opt/arcane/data/projects/infra/Caddyfile` on the server, then:
```bash
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"docker exec infra-caddy caddy reload --config /etc/caddy/Caddyfile"
```
No container restart needed.
### 9.5 Updating env vars
1. Edit `.env` on the server: `/opt/arcane/data/projects/escrow-dev/.env`
2. Restart affected service:
```bash
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /opt/arcane/data/projects/escrow-dev && docker compose up -d backend"
```
Frontend env vars baked at build time (via `NEXT_PUBLIC_*`) require a fresh image rebuild.
### 9.6 Verifying a deploy
```bash
# Check running containers
arcane-cli project status devEscrow
# Check backend version
curl https://dev.amn.gg/api/version
# Check health (all stores + registries)
curl https://dev.amn.gg/api/health | jq .
# Tail backend logs
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"docker logs -f escrow-backend --tail 100"
```
> CI ✓ green does NOT guarantee the new image was pushed to the registry. Always verify `curl /api/version` returns the expected version. See [[woodpecker_silent_build_fail]].
---
## 10. Dev vs Prod Differences
| Aspect | dev-amn (dev.amn.gg) | Prod (amn.gg) |
|---|---|---|
| Compose file | `deployment/dev-amn/docker-compose.yml` | Separate prod stack (not in this repo) |
| Image registry | `git.tbs.amn.gg/escrow` | Same registry, prod tags |
| Image tag | `:dev` | `:latest` or versioned |
| MongoDB | Present (dev parity) | Retired |
| `ENABLE_TESTNET_CHAINS` | `true` (compose override) | Not set / `false` |
| `NODE_ENV` | `production` (same) | `production` |
| `NEXT_PUBLIC_IS_DEVELOPMENT` | `false` | `false` |
| `PAYMENT_PROVIDER_MODE` | `live` | `live` |
| `REQUEST_NETWORK_ALLOW_TEST_WEBHOOKS` | can be `true` for RN testing | `false` |
| Watchtower labels | Present in legacy compose | Prod stack may differ |
| Gatus monitoring | Monitors both dev + prod endpoints | N/A (shared gatus instance) |
| TLS | Cloudflare proxy → Caddy (disable_redirects) | Same |
| Version bump requirement | Required before CI push | Required |
---
## 11. Secret Management
**The `.env` file on the server is the single source of runtime secrets. It is never committed.**
- Location on server: `/opt/arcane/data/projects/escrow-dev/.env`
- Permissions: `chmod 600` owned by root
- Reference template: `deployment/.env` (in repo — contains live dev values, treated as low-sensitivity dev config; rotate before prod use)
- `.gitleaks.toml` in `deployment/` configures secret scanning exclusions for the repo
### Rules
1. Never commit `.env` or any file containing real tokens, passwords, or private keys.
2. Never pass secrets as Dockerfile `ARG`/`ENV` at build time — they appear in image layers. All secrets are runtime-injected via `env_file`.
3. `NEXT_PUBLIC_*` vars are baked into the frontend bundle at build time. Do not place secrets in any `NEXT_PUBLIC_` variable.
4. Wallet addresses (e.g. `ESCROW_WALLET_ADDRESS`) are public on-chain but still kept out of the repo for operational hygiene.
5. For new deployments: copy `deployment/.env` to the server, fill in real values, then `chmod 600`.
6. Gatus bot token and chat ID go into the same `.env` — they are read by the gatus container via `environment:` directives.
7. Telegram bot tokens are high-value secrets — rotate immediately if accidentally pushed.
### Sensitive variable groups
| Group | Variables | Risk if leaked |
|---|---|---|
| JWT | `JWT_SECRET` | Full session forgery |
| DB credentials | `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `MONGO_INITDB_ROOT_PASSWORD` | Database access |
| Payment webhook secrets | `REQUEST_NETWORK_WEBHOOK_SECRET`, `DEPAY_WEBHOOK_SECRET`, `SHKEEPER_CALLBACK_SECRET`, `SHKEEPER_WEBHOOK_SECRET` | Fake payment injection |
| Bot tokens | `TELEGRAM_BOT_TOKEN`, `TG_NOTIFY_BOT_TOKEN` | Bot takeover |
| OAuth secrets | `GOOGLE_CLIENT_SECRET` | OAuth impersonation |
| API keys | `OPENAI_API_KEY`, `REQUEST_NETWORK_API_KEY`, `SHKEEPER_API_KEY` | Billing / data access |
| Sentry DSN | `SENTRY_DSN` | Error data exfiltration |

452
10 - Services/frontend.md Normal file
View File

@@ -0,0 +1,452 @@
---
title: Frontend Service — amn-frontend
tags: [service, frontend, nextjs, react, web3, telegram]
created: 2026-06-08
updated: 2026-06-08
---
# Frontend Service — amn-frontend
## 1. Overview
`amn-frontend` is the primary user-facing application for the Amanat (AMN) escrow marketplace. It serves buyers, sellers, and admins through a unified Next.js 16 App Router application with a Persian-first (RTL) UI.
| Field | Value |
|---|---|
| Package name | `amn-frontend` |
| Version | **2.10.5** |
| Status | Active — deployed on `dev.amn.gg` |
| Framework | Next.js 16 (App Router + Turbopack), React 19, TypeScript strict |
| Dev port | `8083` (both local and Docker) |
| Package manager | `yarn@1.22.22` |
| Node requirement | `>=20` (host runs v26.0.0) |
| Repo | `git@git.manko.yoga:222/nick/frontend.git` |
The app covers the full escrow lifecycle: request creation, multi-seller offer collection, negotiation, on-chain payment (BSC/ETH/Base), delivery confirmation, dispute handling, loyalty points, and a Telegram Mini App shell for mobile-native access.
---
## 2. Tech Stack
### Core
| Layer | Library / Version | Notes |
|---|---|---|
| Framework | `next@^16.1.1` | App Router, Turbopack dev, standalone output |
| UI runtime | `react@^19.1.0` + `react-dom@^19.1.0` | |
| Language | TypeScript `^6.0.3` strict | `noEmit` check required before push |
| Component library | `@mui/material@^9.0.1` | MUI v9 + `@mui/lab`, `@mui/x-data-grid`, `@mui/x-date-pickers`, `@mui/x-tree-view` |
| Styling | `@emotion/react` + `@emotion/styled` + `stylis-plugin-rtl` | RTL support via stylis |
| Animation | `framer-motion@^12.13.0` | |
| Icon system | `@iconify/react@^6.0.0` | |
### Data Fetching & State
| Layer | Library | Notes |
|---|---|---|
| Server-state cache | `@tanstack/react-query@^5.83.0` | Primary async state manager |
| Lightweight fetch | `swr@^2.3.3` | Used in some hooks alongside RQ |
| HTTP client | `axios@^1.11.0` | Centralized instance with interceptors in `src/lib/axios.ts` |
| Forms | `react-hook-form@^7.77.0` + `zod@^4.0.10` + `@hookform/resolvers@^5.0.1` | |
| Real-time | `socket.io-client@^4.8.1` | `src/contexts/` socket context; used for request/offer/chat events |
### Web3
| Layer | Library | Notes |
|---|---|---|
| Wallet connection | `wagmi@^2.19.5` | Primary Web3 state manager |
| EVM low-level | `viem@^2.31.7` | ABI encoding, RPC calls |
| Compat layer | `ethers@^6.15.0` | Legacy compatibility |
| Chain indexing | `alchemy-sdk@^3.6.1` | Mainnet / Sepolia / Polygon queries |
| TON wallet | `@tonconnect/ui-react@^2.4.4` + `@ton/core@^0.63.1` | TON Connect in Telegram Mini App |
| Hardware wallet | `@trezor/connect-web@^9.7.3` | Trezor signing flow |
### Internationalization & Localisation
| Layer | Library | Notes |
|---|---|---|
| i18n engine | `i18next@^26.3.0` + `react-i18next@^17.0.8` | |
| Language detection | `i18next-browser-languagedetector@^8.1.0` | |
| Lazy loading | `i18next-resources-to-backend@^1.2.1` | |
| Persian date | `date-fns-jalali@^4.1.0-0` | Jalali calendar date formatting |
| RTL styling | `stylis-plugin-rtl@^2.1.1` | Emotion cache flips properties for RTL |
### Observability & Testing
| Layer | Library | Notes |
|---|---|---|
| Error tracking | `@sentry/nextjs@^10.22.0` | Configured in `src/instrumentation.ts` |
| Unit tests | `jest@^30.4.2` + `@testing-library/react@^16.3.0` | |
| E2E tests | `@playwright/test@^1.56.1` | `e2e/` directory; performance spec included |
| Notifications | `notistack@^3.0.2` + `sonner@^2.0.3` | Toast system |
### Editor & Rich Content
| Layer | Library | Notes |
|---|---|---|
| Rich text | `@tiptap/react@^3.23.6` + extensions | Code blocks, links, images, alignment, underline |
| Markdown render | `react-markdown@10.1.0` + rehype plugins | With GFM, syntax highlight, sanitization |
| Maps | `mapbox-gl@^3.12.0` + `react-map-gl@^8.0.4` | Address / delivery location picker |
| Charts | `react-apexcharts@^2.1.0` | Dashboard KPI charts |
| Carousels | `embla-carousel-react@8.6.0` | Auto-scroll and autoplay plugins |
---
## 3. App Router Page Structure
The Next.js App Router root is `src/app/`. Pages are thin wrappers that import a View component from `src/sections/<feature>/view/`. No business logic lives in `page.tsx` files.
### Top-level routes
| Route segment | Type | Purpose |
|---|---|---|
| `/` | Public | Landing / marketing page |
| `/api/health` | API route | Health check endpoint |
| `/api/llm` | API route | LLM proxy (amanat-assist integration) |
| `/auth/jwt/*` | Auth | Sign-in, sign-up, verify email, reset password, update password |
| `/checkout/` | Protected | Checkout flow entry (redirects to payment) |
| `/dashboard/` | Protected | Main authenticated shell (see sub-routes below) |
| `/design-preview/` | Dev | Component / theme preview (non-production) |
| `/error/` | Public | Global error page |
| `/payment/` | Protected | Payment status / callback landing |
| `/post/[slug]` | Public | Blog / post reader |
| `/shop/[seller]/[id]` | Public | Public seller shop and product view |
| `/telegram/` | Mini App | Telegram Mini App shell (dedicated layout, see §7) |
| `not-found.tsx` | Public | 404 page |
### Dashboard sub-routes (`/dashboard/*`)
All dashboard routes are wrapped in `AuthGuard` + `EmailVerificationGuard`.
| Sub-route | Purpose |
|---|---|
| `account/` | Profile, avatar, address book, notification prefs, passkey, wallet linking |
| `admin/` | Admin control panel |
| `assist/` | AI assistant chat (amanat-assist integration) |
| `chat/` | Real-time escrow negotiation chat |
| `disputes/` | Dispute hub — raise, view, respond |
| `payment/` | Payment history and detail view |
| `points/` | Loyalty hub — transaction log, referral tracking, level tiers |
| `post/` | Admin blog editor (Tiptap) |
| `request/` | Buyer purchase request management (create, track, accept offer) |
| `request-template/` | Seller request templates management |
| `seller/` | Seller profile and analytics |
| `shop-settings/` | Seller shop configuration (name, policies, payment rails) |
| `shops/` | Browse shops / checkout within dashboard scope |
| `user/` | Admin user management |
---
## 4. Key Sections & Features
### Marketplace
- `src/sections/shop-settings/` — seller configures shop, accepted payment chains/tokens, delivery policy.
- `src/sections/request/` — core escrow lifecycle feature. Status flow:
```
pending_payment → pending → active → received_offers → in_negotiation
→ payment → processing → delivery → delivered → confirming → seller_paid → completed
(or cancelled at most stages)
```
- Shared status/urgency color and label maps live in `src/sections/request/constants.ts`. Do not redefine per-view; use `getStatusColor / getStatusLabel / getUrgencyColor / getUrgencyLabel`.
- Role-based views (buyer / seller / admin) dispatched from `role-based-<feature>-view.tsx` components.
### Escrow Flow
The escrow flow spans multiple sections:
1. **Buyer** creates a purchase request (`/dashboard/request/new`) — wizard in `src/sections/request/components/steps/`.
2. **Sellers** receive notifications via Socket.io and submit offers (`received_offers` state).
3. **Negotiation** phase: real-time chat (`/dashboard/chat/`) with offer counter-proposals.
4. **Payment**: buyer pays on-chain (BSC primary, ETH/Base/Polygon/Arbitrum supported). Funds held in escrow wallet (`NEXT_PUBLIC_ESCROW_WALLET_ADDRESS`). USDT is the primary escrow currency; BSC USDT uses 18 decimals (non-standard — handled in `src/utils/currencyUtils.ts`).
5. **Delivery & confirmation**: seller marks delivered, buyer confirms → `confirming → seller_paid → completed`.
6. **Disputes**: either party can raise at `src/sections/dispute/`.
### Dashboard & Admin
- Overview tiles with ApexCharts KPI cards.
- Admin panel: user management, shop review, dispute arbitration, blog post management.
- Points / loyalty system: transaction ledger, referral tracking, tier levels at `src/sections/points/`.
- AI assist panel: embedded `amanat-assist` chat at `/dashboard/assist/`.
### Telegram Mini App
See §7 for full detail.
---
## 5. State Management
The app uses a layered approach — no single global store:
| Layer | Tool | Scope |
|---|---|---|
| Server state & cache | `@tanstack/react-query` | All API calls — fetching, mutations, invalidation |
| Supplementary fetch | `swr` | Some lightweight hooks |
| Local component state | `React.useState` / `useReducer` | Component-local UI state |
| Cross-tree shared state | React Context | Socket connection (`src/contexts/`), Auth (`src/auth/context/`), Web3, Settings drawer, Localization |
| Form state | `react-hook-form` | All form instances, with `zod` schemas as resolvers |
| Settings (theme/locale) | Context + `localStorage` | Theme mode, layout direction, color preset, font — managed by `src/settings/` |
There is no Zustand or Redux in the dependency tree. Global state is passed via Context providers stacked in `src/app/layout.tsx`.
Key contexts:
- `SocketContext` (`src/contexts/`) — wraps `socket.io-client`, exposes live event subscriptions.
- `AuthContext` (`src/auth/context/`) — JWT session, user object, sign-in/out actions.
- `Web3Context` / wagmi `WagmiProvider` (`src/web3/context/`) — wallet connection, chain switching.
- `SettingsContext` (`src/settings/`) — UI preferences (RTL, color scheme, font).
- `LocalizationProvider` (`src/locales/`) — i18next + MUI date picker locale.
---
## 6. Internationalization
The app is RTL-first with Persian (Farsi) as the primary production language.
| Aspect | Implementation |
|---|---|
| Engine | `i18next` + `react-i18next` |
| Supported languages | `fa` (Persian), `ar` (Arabic), `en` (English), `fr` (French), `cn` (Chinese), `vi` (Vietnamese) |
| Translation files | `src/locales/langs/<lang>/*.json` — split by feature namespace |
| RTL flip | `stylis-plugin-rtl` applied to the Emotion cache — physical CSS properties (margin-left, padding-right, etc.) are automatically mirrored |
| LTR islands | Inline `dir="ltr"` on elements containing URLs, wallet addresses, token amounts, or other inherently LTR content |
| Persian calendar | `date-fns-jalali` for Jalali date formatting; MUI date pickers use the Jalali locale adapter |
| Direction state | Controlled via `SettingsContext` — users can toggle in the settings drawer |
| Config | `src/locales/locales-config.ts` + `src/locales/i18n-provider.tsx` |
Language detection priority: URL `?lng=` param → browser `Accept-Language` → localStorage fallback → `fa`.
---
## 7. Telegram Mini App Integration
The Telegram Mini App (TMA) is a first-class feature with a dedicated route segment, layout, and 40+ purpose-built components.
### Loading & Auth Flow
- The TMA loads via Telegram's `webApp.openWebApp()` into the `/telegram/` route.
- The root layout at `src/app/telegram/layout.tsx` provides a minimal provider stack:
- `TonConnectUIProvider` (TON wallet)
- No standard app shell (no top nav, no side drawer) — uses native Telegram chrome instead.
- User identity: `window.Telegram.WebApp.initData` is parsed by `src/utils/telegram-webapp.ts` (a custom wrapper around the `window.Telegram` global — **no `@twa-dev` or `@telegram-apps` SDK package** is used).
- Auth is linked to the existing JWT session: on first open the app prompts the user to connect their AMN account (onboarding sheet). Subsequent opens re-use the stored token.
### Key File Locations
| Path | Purpose |
|---|---|
| `src/app/telegram/layout.tsx` | TMA root layout — minimal providers |
| `src/app/telegram/page.tsx` | TMA entry point |
| `src/utils/telegram-webapp.ts` | Custom `window.Telegram.WebApp` wrapper / SDK util |
| `src/sections/telegram/` | All TMA feature code |
| `src/sections/telegram/view/` | ~18 view components (one per TMA screen) |
| `src/sections/telegram/components/` | ~28 TMA-specific UI primitives |
| `src/sections/telegram/hooks/` | TMA-scoped hooks including `use-telegram-live-context` |
| `src/sections/telegram/telegram-shell-css.ts` | Native Telegram shell CSS variables integration |
### TMA Views
| View file | Screen |
|---|---|
| `telegram-mini-app-view.tsx` | Main shell / router (23 KB — primary orchestrator) |
| `telegram-home-view.tsx` | Home tab |
| `telegram-shop-view.tsx` | Shop list |
| `telegram-seller-shop-view.tsx` | Individual seller shop products |
| `telegram-cart-view.tsx` | Cart |
| `telegram-checkout-view.tsx` | Checkout |
| `telegram-payment-view.tsx` | Payment status |
| `telegram-requests-view.tsx` | Buyer requests list |
| `telegram-request-detail-view.tsx` | Request detail + offer management (31 KB) |
| `telegram-new-request-view.tsx` | New request wizard |
| `telegram-template-detail-view.tsx` | Seller template detail |
| `telegram-chat-view.tsx` | In-app chat thread list |
| `telegram-chat-thread-view.tsx` | Single chat thread |
| `telegram-archived-chats-view.tsx` | Archived chats |
| `telegram-account-view.tsx` | Account settings (18 KB) |
| `telegram-addresses-view.tsx` | Address book (15 KB) |
| `telegram-points-view.tsx` | Loyalty points |
| `telegram-notifications-view.tsx` | Notification centre |
| `telegram-settings-view.tsx` | App settings (14 KB) |
### TMA-specific Components
Key primitives: `telegram-chat-row`, `telegram-request-stepper`, `telegram-onboarding-sheet`, `telegram-tab-bar`, `telegram-header`, `telegram-quick-actions`, `telegram-cart-fab`, `telegram-support-fab`, `telegram-welcome-banner`, `telegram-unlinked-state`, `telegram-unsupported-state`.
### TON Wallet in TMA
TON Connect (`@tonconnect/ui-react`, `@ton/core`) is active only inside the TMA layout. BSC payments via wagmi/viem are also available in-TMA but TON is the preferred rail for Telegram users.
---
## 8. Web3 Integration
All Web3 code lives under `src/web3/`.
### Architecture
```
src/web3/
├── config.ts # WEB3_CONFIG — chains, WalletConnect project ID
├── index.ts # Public barrel
├── types.ts # Shared Web3 types
├── utils.ts # Misc helpers
├── payment-rails.ts # Chain+token routing logic
├── decentralizedPayment.ts # Core payment execution (16 KB)
├── web3Service.ts # Service layer (9 KB)
├── paymentBackendService.ts # Backend sync after on-chain tx (12 KB)
├── tonconnect-provider.tsx # TonConnect provider wrapper
├── context/ # Web3Context provider
├── contracts/ # ABI definitions
├── hooks/
│ ├── use-web3-wagmi.ts # wagmi-based wallet + tx hooks (5.5 KB)
│ ├── use-alchemy.ts # Alchemy SDK hooks — balance, tx history (3.8 KB)
│ ├── use-chainlink.ts # Chainlink price feed hooks (2.6 KB)
│ └── use-web3-context.ts # Context consumer hook
├── services/ # Additional service modules
├── trezor/ # Trezor Connect integration
└── utils/ # Chain-specific utilities
```
### Supported Chains
Declared in `WEB3_CONFIG.supportedChains`: **BSC** (default, lowest fees), Base, Polygon, Arbitrum, Ethereum.
Primary escrow payments run on BSC. BSC USDT is 18 decimals (non-standard; handled in `src/utils/currencyUtils.ts` — do not hardcode decimals).
### Wallet Support
| Wallet | Integration |
|---|---|
| MetaMask / injected | wagmi `injected()` connector |
| WalletConnect | wagmi WalletConnect connector (`NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`) |
| Trezor | `@trezor/connect-web` in `src/web3/trezor/` |
| TON wallet | `@tonconnect/ui-react` (TMA only) |
### Oracle / Price Feeds
- Chainlink price feeds via `use-chainlink.ts` — used for USDT/USD peg monitoring.
- Alchemy SDK (`alchemy-sdk`) for on-chain data queries (balances, tx receipts).
- Depeg protection feature in development — see `nick-doc/` oracle depeg protection design doc.
---
## 9. CI/CD
### Pipeline
CI is managed by Woodpecker at `frontend/.woodpecker/production.yml`.
**Trigger:** push to `main` or `master` branch.
**Agent:** `platform: linux/arm64` (netcup agent on `89.58.32.32`).
**Steps:**
| Step | Image | Action |
|---|---|---|
| `get-version` | `node:22-alpine` | Reads `package.json` version → writes `dev-<version>` to `.tags` |
| `build-and-deploy` | `docker:27-cli` | `docker build -t git.tbs.amn.gg/escrow/frontend:dev .` then `docker compose up -d --no-deps --pull never frontend` against `/opt/escrow-dev/docker-compose.yml` |
| `notify` | `node:22-alpine` | Posts Telegram notification (success or failure) via `scripts/ci/tg-notify.cjs` using `TG_TOKEN` + `TG_USERS` secrets |
**Important CI notes:**
- The image is built locally on the host — it does **not** pull from a registry. `docker-compose.override.yml` sets `pull_policy: never`.
- Turbopack is dev-only. The production build uses standard `next build` (webpack).
- `next build` runs a strict TypeScript type-check. The build fails on type errors.
- **Always bump `package.json` version before pushing** to `main`/`master`. Docker tags use `dev-<version>`. Reusing the same version overwrites the previous image tag and breaks rollback.
- A CI green check does not guarantee the image was pushed to the registry. Verify the registry tag manually if deployment seems stale.
### Docker Build
- Output mode: `standalone` (set in `next.config.js`).
- Start command: `PORT=8083 node .next/standalone/server.js`.
- Post-build: `cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone/` (required for static assets with standalone output).
- Build cache: `--mount=type=cache` for apk, yarn, and `.next/cache` — incremental Next.js rebuilds on unchanged packages.
---
## 10. Local Development Quick-Start
```bash
# Prerequisites: Node >=20, yarn 1.22.22
cd frontend/
# Install dependencies
yarn install
# Copy env file and fill in values
cp .env.local.example .env.local
# Edit .env.local — see §11 for required vars
# Start dev server (Turbopack, port 8083)
yarn dev
# Alternative: webpack (slower, more compatible)
yarn dev:webpack
# Type check (must pass before push)
npx tsc --noEmit --ignoreDeprecations 6.0
# Lint
yarn lint
yarn lint:fix
# Unit tests
yarn test
# E2E tests (requires running app)
yarn playwright:install
yarn test:e2e
# Production build (validates types + builds)
yarn build
# Run standalone production build
yarn start
```
**Note:** The dev server binds to `http://localhost:8083`. When proxied via infra-caddy on the dev server, it maps to `https://dev.amn.gg`.
---
## 11. Environment Variables
All public vars are prefixed `NEXT_PUBLIC_` and baked into the client bundle at build time.
| Variable | Required | Description |
|---|---|---|
| `NEXT_PUBLIC_APP_NAME` | Yes | Application display name (e.g. `Amanat`) |
| `NEXT_PUBLIC_APP_VERSION` | Yes | App version string — should match `package.json` version |
| `NEXT_PUBLIC_BACKEND_URL` | Yes | Base URL for backend API (e.g. `https://api.dev.amn.gg`) |
| `NEXT_PUBLIC_API_URL` | Yes | API endpoint root (often same as `BACKEND_URL` + `/api`) |
| `NEXT_PUBLIC_SOCKET_URL` | No | Socket.IO server URL — falls back to `NEXT_PUBLIC_BACKEND_URL` |
| `NEXT_PUBLIC_ESCROW_WALLET_ADDRESS` | Yes | On-chain escrow holding wallet address |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | Yes | WalletConnect Cloud project ID |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_MAINNET` | Yes | Alchemy API key for Ethereum mainnet |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_SEPOLIA` | Yes | Alchemy API key for Sepolia testnet |
| `NEXT_PUBLIC_ALCHEMY_API_KEY_POLYGON` | Yes | Alchemy API key for Polygon |
| `NEXT_PUBLIC_MAPBOX_API_KEY` | No | Mapbox GL token for map components |
| `NEXT_PUBLIC_ASSETS_DIR` | No | Custom assets base URL — defaults to empty (local `/public`) |
| `BUILD_STATIC_EXPORT` | No | Set `true` to enable static export mode |
| `NODE_ENV` | Auto | Set by Next.js (`development` / `production`) |
Actual values for the dev deployment are stored in `~/.agentSecrets/escrow/CLAUDE.md` (not in the repo).
---
## 12. Known Issues / Open Items
| # | Issue | Status |
|---|---|---|
| 1 | **Socket room scoping** — global payment socket broadcasts previously wiped every user's cart. A provider gate was added in frontend `v2.8.4` to filter by provider. Backend-side room scoping is still an open follow-up. | Open |
| 2 | **Backend rate limiter on GET /payment/:id** — `paymentLimiter` (30 req/15 min) applies to the payment status poll endpoint. Results in 429 during rapid callback polling, leaving payments stuck in "processing". Fix is on backend side but frontend polling interval could be increased as a mitigation. | Open (backend fix pending) |
| 3 | **Offer rejection UI** — "all sellers stuck at step 4" was a UI-only bug; backend rejects and notifies correctly. Telegram seller step must use `mojtaba`'s `StepContext` (introduced in `fe v2.9.13`). | Resolved in v2.9.13 |
| 4 | **Cart wipe regression risk** — any new global socket event handler must be scoped by `provider:` to avoid touching RN or other payment records. | Ongoing convention |
| 5 | **Performance is network-bound** — Mongo API profiling shows 300800 ms response times due to WAN RTT (~235 ms). Server-side processing is 312 ms. Frontend-side CDN / edge caching is the recommended fix; DB migration will not help. | Open |
| 6 | **Oracle depeg protection** — server-side oracle quoting for multi-currency pricing + stablecoin depeg protection is designed and approved. Build starts on a new dev branch. | In progress |
| 7 | **Multi-chain for amn.scanner** — the in-house scanner pay-in path is not yet multi-chain; verify scanner watches mainnet addresses before enabling multi-chain selection in the UI for scanner provider. | Open |
| 8 | **Parallel agent pushes** — a second agent (moojttaba) pushes to the same branches. Always `git fetch --rebase` before pushing. Version-bump conflicts are expected. | Ongoing |
| 9 | **Tiptap / rich text in TMA** — the Tiptap editor is desktop-optimised; its usability on mobile Telegram is untested at scale. | Not verified |
| 10 | **`design-preview/` route** — present in the app router but should be excluded or protected in production builds. | Low priority |

513
10 - Services/scanner.md Normal file
View File

@@ -0,0 +1,513 @@
---
title: AMN Pay Scanner
tags: [service, scanner, payment, go, blockchain]
version: 0.1.10
created: 2026-06-08
---
# AMN Pay Scanner
> [!info]
> Version: **0.1.10** — Go 1.25 — Status: **production (BSC + Ethereum + BSC Testnet)**, other chains staged
> Repo: `scanner/` within the escrow monorepo.
> Cross-ref: [[Scanner Architecture]] | [[Scanner API]]
---
## 1. Overview
AMN Pay Scanner is a standalone Go microservice that watches on-chain payment events and notifies the backend via signed webhook when a payment is confirmed.
### What it replaces
Previously, the AMN escrow platform relied on **Request Network** as the payment infrastructure layer. Request Network introduced:
- An external smart-contract dependency (`ERC20FeeProxy`) on RN's deployment schedule
- A closed fee-proxy address registry that differs per chain and is not reliably canonical (see memory note on RN proxy addresses)
- A separate webhook/event pipeline managed by RN's infrastructure
- A hard coupling between the backend and RN's SDK
AMN Pay Scanner removes all of these by:
1. Deploying the same `ERC20FeeProxy` contract under our own control
2. Polling RPC endpoints directly (no RN nodes)
3. Deriving payment references in-house using the same keccak256 formula as the proxy contract
4. Delivering webhooks signed with a backend-controlled HMAC secret
The scanner also supports **direct-address payment rails** (Tron, TON, and manual EVM flows) where no proxy contract is involved at all.
---
## 2. How It Works
### Step-by-step flow
```
Backend Scanner Chain
│ │ │
│ POST /intents │ │
│ {chainId, token, amount, │ │
│ destination, callbackUrl}│ │
├──────────────────────────► │ │
│ │ persist intent (SQLite) │
│ {intentId, │ derive paymentReference │
│ paymentReference, │ compute topicRef (EVM) │
│ checkoutBlock} │ │
◄──────────────────────────── │ │
│ │ │
│ (frontend builds tx using │ │
│ proxyAddress + │ │
│ paymentReference) │ │
│ │ poll eth_getLogs │
│ ├────────────────────────────►│
│ │ logs [] │
│ ◄────────────────────────────┤
│ │ match Topics[1] → topicRef │
│ │ validate token+amount+dest │
│ │ status → confirming │
│ │ │
│ │ (wait confirmationThreshold│
│ │ blocks / finality signal) │
│ │ │
│ POST callbackUrl │ │
│ {intentId, txHash, │ │
│ status:"confirmed", ...} │ │
◄──────────────────────────── │ │
│ 200 OK │ │
├──────────────────────────► │ │
│ │ status → confirmed │
│ │ record webhookDeliveredAt │
```
### Intent status lifecycle
```
pending ──(tx seen)──► confirming ──(depth reached)──► confirmed ──(webhook ok)──► [done]
│ │ │
│ │ (deep reorg / TTL) │ (all retries fail)
└────────────────────────┴──────────────► expired webhook_failed
```
- **Tron / TON** skip `confirming` — their API only returns finalized events, so the status jumps directly to `confirmed`.
- **Startup reconciliation**: on startup, any `confirmed` intent with `webhook_delivered_at IS NULL` created in the last 7 days has its webhook re-delivered. This covers crashes between finalization and delivery.
- **`webhook_failed`** intents are retried on `WEBHOOK_RETRY_HOURS` schedule (default 6 h) and immediately via `POST /admin/webhooks/retry`.
---
## 3. Supported Chains
> Chains marked **verified: false** in `supported-chains.json` do NOT start a worker goroutine at runtime. Override with `SCANNER_ENABLED_CHAINS` env var to force-enable specific chain IDs without a code change.
| Chain | Chain ID | Type | Proxy Address | Confirmation Depth | Verified |
|---|---|---|---|---|---|
| BNB Smart Chain | 56 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 200 blocks | **yes** |
| Ethereum Mainnet | 1 | EVM | `0x370DE27fdb7D1Ff1e1BaA7D11c5820a324Cf623C` | 50 blocks | **yes** |
| BSC Testnet | 97 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 5 blocks | **yes** (testnet) |
| Arbitrum One | 42161 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 2400 blocks | no |
| Polygon | 137 | EVM | `0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9` | 300 blocks | no |
| Base | 8453 | EVM | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` | 300 blocks | no |
| Tron Mainnet | 728126428 | Tron | TRC20 USDT contract (`TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t`) | TronGrid confirmed (~200 reported) | no |
| TON Mainnet | 1100 | TON | USDT Jetton master (`EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs`) | TonCenter finalized (~120 reported) | no |
> [!warning] Chain notes
> - **Ethereum**: uses the older v0.1.0 proxy ABI at `0x370DE27…`. A v0.2.0-next proxy is also deployed at `0x0DfbEe…` on ETH but the scanner checkout uses the v0.1.0 ABI — do not swap addresses silently.
> - **Base**: proxy address is non-canonical (differs from the CREATE2 expected address per RN smart-contracts artifact v0.2.0). See memory note on RN proxy addresses.
> - **Tron**: no fee-proxy contract exists. Matching is by unique destination address, not payment reference.
> - **TON**: lag is reported in **seconds** (not blocks); per-intent polling is O(pending intents) API calls per cycle — known scaling concern.
---
## 4. Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────┐
│ scanner binary │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────┐ │
│ │ HTTP API │ │ Worker Pool │ │
│ │ (api.go) │ │ │ │
│ │ │ │ ┌──────────────┐ eth_getLogs / eth_ │ │
│ │ POST /intents│ │ │ ChainWorker │─► blockNumber (JSON-RPC│ │
│ │ GET /intents│ │ │ (EVM×N) │ per chain) │ │
│ │ /balances │ │ └──────────────┘ │ │
│ │ /balance- │ │ ┌──────────────┐ TronGrid REST API │ │
│ │ watches │ │ │ TronChain- │─► /v1/contracts/events │ │
│ │ /scanner/ │ │ │ Worker │ │ │
│ │ status │ │ └──────────────┘ │ │
│ │ /admin/ │ │ ┌──────────────┐ TonCenter v3 REST │ │
│ │ webhooks/ │ │ │ TonChain- │─► /jetton/transfers │ │
│ │ retry │ │ │ Worker │ │ │
│ └──────┬──────┘ │ └──────────────┘ │ │
│ │ └─────────────┬────────────────────────────┘ │
│ │ │ match / confirm │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SQLite (WAL) │ │
│ │ intents · checkpoints · balance_watches │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌─────────────────────────────────────┐ │
│ │BalanceWatch- │ │ webhook.go │ │
│ │Scheduler │ │ HMAC-SHA256 sign → POST callbackUrl│ │
│ │(balance.go) │ │ retry: 5s→30s→2m→10m→1h→failed │ │
│ └────────────────┘ └─────────────────────────────────────┘ │
│ │
│ Background loops (main.go): │
│ • intent TTL expiry (INTENT_TTL_HOURS) │
│ • webhook retry loop (WEBHOOK_RETRY_HOURS) │
│ • startup reconciliation (confirmed, no delivery) │
└──────────────────────────────────────────────────────────────────┘
```
---
## 5. API Routes
All endpoints except `/health` require `Authorization: Bearer <SCANNER_API_KEY>`. Request bodies are capped at 64 KB.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| `GET` | `/health` | none | Liveness probe — returns `{"status":"ok"}` |
| `GET` | `/scanner/status` | Bearer | Chain lag, last scanned block, pending intent counts per chain |
| `POST` | `/intents` | Bearer | Register a payment intent; returns `intentId`, `paymentReference`, `checkoutBlock` |
| `GET` | `/intents/{id}` | Bearer | Fetch full intent record including current status and tx details |
| `DELETE` | `/intents/{id}` | Bearer | Cancel a pending intent (sets status to `expired`) |
| `POST` | `/balances/check` | Bearer | Read current ERC-20 balance for an address+token on a given chain |
| `POST` | `/balance-watches` | Bearer | Start a balance-change watch on an EVM address/token |
| `GET` | `/balance-watches/{id}` | Bearer | Fetch balance-watch status and current balance |
| `DELETE` | `/balance-watches/{id}` | Bearer | Stop a balance watch |
| `POST` | `/admin/webhooks/retry` | Bearer | Force immediate retry of all `webhook_failed` intents |
Full request/response schemas: [[Scanner API]]
---
## 6. Payment Reference Derivation (EVM)
The ERC20FeeProxy contract indexes payments by a `bytes8` reference. The scanner derives it deterministically so the worker's scan loop only needs a single indexed DB lookup per log.
```
# Step 1: build raw reference
input = lower(intentId) + lower(salt) + lower(destination)
paymentReference = last8Bytes(keccak256(input)) ← bytes8, 16 hex chars
# Step 2: build EVM log index key
topicRef = keccak256(paymentReferenceBytes) ← bytes32, 64 hex chars
↑ this is Topics[1] in the emitted log
```
- `salt` is a 32-byte random hex string generated at intent creation time.
- `destination` is the EVM address of the AMN treasury / seller wallet, lowercased.
- Both `paymentReference` and `topicRef` are stored in the `intents` table at creation time. The scan loop performs `SELECT * FROM intents WHERE topic_ref = ? AND status = 'pending'` — O(1) with index, regardless of how many pending intents exist.
**Event signature** (used as `Topics[0]` filter):
```
TransferWithReferenceAndFee → keccak256 = 0xbc8b749850bdc0c5e4a49d50f87ec40dca2acdb70a84e7c6823ccdc7b3b3d4b3
```
---
## 7. EVM / Tron / TON Matching Logic
### EVM
1. Worker fetches `eth_getLogs` for the proxy contract address + `TransferWithReferenceAndFee` event topic in 2000-block chunks.
2. For each log, extract `Topics[1]` (= `topicRef`).
3. Query DB: `WHERE topic_ref = ? AND status = 'pending'`.
4. On match: decode `log.Data` to extract `tokenAddress`, `amount`, `destination`, `feeAmount`. Validate all four against the intent record.
5. Update status to `confirming`, record `txHash`, `blockNumber`, `logIndex`.
6. On next poll: check `head - blockNumber + 1 >= confirmationsRequired`. When met, finalize and deliver webhook.
**Reorg protection**: the checkpoint is rewound by `3 × confirmationThreshold` blocks (clamped to 20500) on each poll. Any log from a recently reorganized block will be re-fetched and re-matched.
### Tron
- No proxy contract — each intent receives a unique HD-derived destination address.
- Worker polls TronGrid `/v1/contracts/{usdtContract}/events?event_name=Transfer` filtered to the intent's destination address.
- Match criterion: `to == destination AND amount >= intent.Amount`.
- TronGrid returns only already-confirmed transactions. No multi-block wait — status jumps directly to `confirmed`.
- Addresses from TronGrid arrive as `41xxxx` (21-byte hex). The worker normalizes these to `0x`-prefixed 20-byte EVM style for storage and comparison.
- Checkpoint stored as a millisecond Unix timestamp in `last_scanned_block`.
- Pagination follows `meta.links.next` until nil.
### TON
- Also uses per-intent unique destination addresses (no proxy contract).
- Worker calls TonCenter v3 `/jetton/transfers?account={destination}&jetton_master={usdtJetton}&start_utime={checkpoint}` for each pending TON intent.
- Match criterion: `destination == intent.Destination AND amount >= intent.Amount`.
- TonCenter returns only finalized transactions — status jumps directly to `confirmed`.
- TON addresses are base64url (`EQ…`/`UQ…`), case-sensitive. Never lowercased.
- Checkpoint stored as Unix seconds.
- Lag is reported in seconds, not blocks.
- **Scaling note**: each pending TON intent triggers one HTTP round-trip per scan cycle. High concurrency will exhaust TonCenter rate limits.
---
## 8. Webhook Payload
### Payment confirmed (intent webhook)
Posted to `callbackUrl` on intent confirmation:
```json
{
"intentId": "018f1a2b-3c4d-7e8f-9a0b-c1d2e3f4a5b6",
"paymentReference": "0xa1b2c3d4e5f60718",
"txHash": "0x4a3b2c1d...",
"blockNumber": 39000010,
"confirmations": 200,
"amount": "10000000000000000000",
"token": "0x55d398326f99059fF775485246999027B3197955",
"chainId": 56,
"status": "confirmed"
}
```
Header: `X-AMN-Signature: hex(HMAC-SHA256(rawBody, callbackSecret))`
The `confirmations` value is **capped** at the chain's acceptance threshold once confirmed. The scanner does not continue incrementing after the payment is safe to credit.
**Retry schedule on delivery failure**: `5 s → 30 s → 2 min → 10 min → 1 h → webhook_failed`
After exhausting retries the intent is set to `webhook_failed`. Manual recovery: `POST /admin/webhooks/retry` or wait for the `WEBHOOK_RETRY_HOURS` background sweep.
### Balance changed (balance-watch webhook)
Posted to the watch's `callbackUrl` when balance delta is detected:
```json
{
"eventType": "balance_changed",
"watchId": "payment-123-c56-USDT",
"chainId": 56,
"chainType": "evm",
"address": "0xabc...",
"tokenAddress": "0x55d398326f99059fF775485246999027B3197955",
"tokenSymbol": "USDT",
"decimals": 18,
"previousBalance": "0",
"currentBalance": "10000000000000000000",
"delta": "10000000000000000000",
"changeCount": 1,
"checkedAt": "2026-06-08T12:00:00Z",
"status": "balance_changed"
}
```
Additional headers: `X-AMN-Delivery-ID: <watchId>`, `X-AMN-Event-Type: balance_changed`
The scanner only advances `current_balance` after a successful (2xx) delivery, so a down backend will retry on the next scheduled check.
---
## 9. SQLite DB Schema
Database at `DB_PATH` (default `./scanner.db`; Docker: `/data/scanner.db`). WAL mode enabled, busy timeout 5 000 ms.
### `intents`
| Column | Type | Notes |
|---|---|---|
| `intent_id` | TEXT PK | caller-supplied UUID |
| `chain_id` | INTEGER | numeric chain ID |
| `chain_type` | TEXT | `evm` / `tron` / `ton` |
| `token_address` | TEXT | EVM/Tron: lowercase `0x` hex; TON: base64url |
| `destination` | TEXT | receiving address |
| `amount` | TEXT | base-10 wei / token smallest unit |
| `payment_reference` | TEXT | 8-byte hex — EVM only |
| `topic_ref` | TEXT | keccak256 of paymentReference — scan index for EVM |
| `status` | TEXT | `pending` / `confirming` / `confirmed` / `expired` / `webhook_failed` |
| `callback_url` | TEXT | backend webhook endpoint |
| `callback_secret` | TEXT | HMAC key — excluded from all JSON responses |
| `confirmations_required` | INTEGER | floored at the chain acceptance threshold |
| `tx_hash` | TEXT NULL | set once the transaction is seen on-chain |
| `log_index` | INTEGER NULL | log position within tx (EVM only) |
| `block_number` | INTEGER NULL | block when seen (EVM); ms timestamp (Tron); unix s (TON) |
| `confirmations` | INTEGER | depth while confirming; capped at threshold after confirmation |
| `salt` | TEXT | 32-byte random hex used in reference derivation |
| `webhook_delivered_at` | TEXT NULL | RFC3339 timestamp of first successful delivery |
| `created_at` / `updated_at` | DATETIME | UTC |
Unique index on `(tx_hash, log_index)` prevents double-confirmation.
### `checkpoints`
| Column | Notes |
|---|---|
| `chain_id` PK | |
| `last_scanned_block` | block number (EVM), ms timestamp (Tron), unix seconds (TON) |
### `balance_watches`
| Column | Type | Notes |
|---|---|---|
| `watch_id` | TEXT PK | caller-supplied idempotency key |
| `chain_id` / `chain_type` | INTEGER / TEXT | currently EVM only |
| `token_address` / `token_symbol` | TEXT | ERC-20 contract + optional registry symbol |
| `decimals` | INTEGER | registry decimals for display |
| `address` | TEXT | watched holder address |
| `baseline_balance` | TEXT | base-unit balance at watch creation |
| `current_balance` | TEXT | last successfully delivered balance |
| `status` | TEXT | `watching` / `stopped` / `expired` |
| `callback_url` / `callback_secret` | TEXT | signed webhook destination |
| `last_checked_at` / `next_check_at` | DATETIME | scheduler state |
| `change_count` / `last_notified_at` | INTEGER / DATETIME | notification audit |
| `expires_at` | DATETIME | hard stop after 7 days |
| `created_at` / `updated_at` | DATETIME | UTC |
Indexes: `(status, next_check_at)` for due-scan queries; `(chain_id, status)` for status reporting.
---
## 10. Configuration
All configuration via environment variables. Copy `.env.example` and populate before first run.
| Variable | Default | Required | Notes |
|---|---|---|---|
| `PORT` | `8080` | no | HTTP listen port |
| `DB_PATH` | `./scanner.db` | no | SQLite file path. Docker: mount `/data` and set `/data/scanner.db` |
| `CHAINS_JSON_PATH` | `./supported-chains.json` | no | Chain registry file |
| `TOKENS_JSON_PATH` | `./tokens.json` | no | Token registry for symbol/decimals metadata |
| `SCANNER_API_KEY` | _(none)_ | **yes (prod)** | Shared secret for Bearer auth. Generate: `openssl rand -hex 32` |
| `POLL_INTERVAL_SEC` | `15` | no | Chain polling interval in seconds |
| `INTENT_TTL_HOURS` | `24` | no | Expire pending intents after N hours. `0` = disabled |
| `WEBHOOK_RETRY_HOURS` | `6` | no | Background re-delivery interval for `webhook_failed` intents. `0` = disabled |
| `BALANCE_WATCH_TICK_SEC` | `60` | no | How often the scheduler checks for due balance watches |
| `BALANCE_WATCH_BATCH_SIZE` | `50` | no | Max due watches processed per tick |
| `RPC_BSC` | chain config | no | Override BSC JSON-RPC URL |
| `RPC_ARB` | chain config | no | Override Arbitrum JSON-RPC URL |
| `RPC_ETH` | chain config | no | Override Ethereum JSON-RPC URL |
| `RPC_POLYGON` | chain config | no | Override Polygon JSON-RPC URL |
| `RPC_BASE` | chain config | no | Override Base JSON-RPC URL |
| `TRONGRID_API_KEY` | _(none)_ | recommended | Free tier is very low; required for any real Tron traffic |
| `TONCENTER_API_KEY` | _(none)_ | recommended | Rate-limited without key |
| `SCANNER_ENABLED_CHAINS` | JSON `verified` flags | no | Comma-separated chain IDs to enable, overriding `verified`. E.g. `56,1` |
| `SCANNER_CALLBACK_ALLOWED_HOSTS` | _(none)_ | prod recommended | Comma-separated hosts for SSRF guard on `callbackUrl` targets |
---
## 11. Docker Deployment
```bash
# Build
docker build -t amn-scanner .
# Run
docker run -d \
--name amn-scanner \
--network shared-web \
-p 8080:8080 \
-v /opt/arcane/data/projects/escrow-dev/scanner-data:/data \
--env-file .env \
-e DB_PATH=/data/scanner.db \
amn-scanner
```
**On the dev server** (`89.58.32.32`): the scanner is part of the `escrow-dev` Arcane project. Images are built locally from source at `/tmp/escrow-backend-build/` — the dev stack does **not** pull from any registry.
```bash
# Copy changed scanner source files
scp -i ~/CascadeProjects/wzp scanner/*.go root@89.58.32.32:/tmp/escrow-backend-build/scanner/
# Rebuild + restart (on server)
ssh -i ~/CascadeProjects/wzp root@89.58.32.32 \
"cd /tmp/escrow-backend-build/scanner && docker build -t amn-scanner-local:dev . && \
cd /opt/arcane/data/projects/escrow-dev && docker compose up -d scanner"
```
Health check URL (via infra-caddy): check project Caddyfile for the current vhost. Direct internal: `http://amn-scanner:8080/health`.
---
## 12. Integration with the Backend
### Registering a payment intent
```typescript
// backend: src/services/amnScanner/...
const resp = await fetch(`${SCANNER_URL}/intents`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${SCANNER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
intentId: payment._id.toString(), // MongoDB ObjectId string
chainId: 56,
tokenAddress: '0x55d398326f99059fF775485246999027B3197955', // USDT BSC
destination: sellerWalletAddress,
amount: amountInWei, // base-10 string
callbackUrl: `${BACKEND_URL}/api/payment/amn-scanner/webhook`,
callbackSecret: process.env.SCANNER_CALLBACK_SECRET,
}),
});
const { intentId, paymentReference, checkoutBlock } = await resp.json();
// store intentId + checkoutBlock in the payment record
// pass checkoutBlock to the frontend for transaction construction
```
The `checkoutBlock` contains everything the frontend needs to call the `ERC20FeeProxy.transferWithReferenceAndFee()` function:
```json
{
"destination": "0x...",
"tokenAddress": "0x55d...",
"tokenSymbol": "USDT",
"decimals": 18,
"chainId": 56,
"proxyAddress": "0x0DfbEe143b42B41eFC5A6F87bFD1fFC78c2f0aC9",
"paymentReference": "0xa1b2c3d4e5f60718",
"feeAmount": "0",
"feeAddress": "0x0000000000000000000000000000000000000000",
"amountWei": "10000000000000000000"
}
```
### Receiving the webhook callback
```typescript
// POST /api/payment/amn-scanner/webhook
app.post('/api/payment/amn-scanner/webhook', async (req, res) => {
const signature = req.headers['x-amn-signature'];
const expected = hmacSha256Hex(req.rawBody, process.env.SCANNER_CALLBACK_SECRET);
if (!timingSafeEqual(signature, expected)) return res.status(401).end();
const { intentId, status, txHash, amount, chainId } = req.body;
if (status !== 'confirmed') return res.status(200).end(); // ignore non-confirmed
await paymentService.markConfirmed({ intentId, txHash, amount, chainId });
res.status(200).end();
});
```
> [!warning]
> The backend must always scope payment lookups by `provider: "amn.scanner"`. Sweeping all pending payments to mark them confirmed/failed will destroy records from other providers (RN, manual). See memory note on payment cleanup + provider filter.
### Backend env vars required
```
SCANNER_URL=http://amn-scanner:8080
SCANNER_API_KEY=<same value as scanner SCANNER_API_KEY>
SCANNER_CALLBACK_SECRET=<same value as scanner intent callbackSecret>
```
See memory note: [[amn_scanner_payin_wiring]] for full wiring details and token-decimal notes.
---
## 13. Known Limitations / Open Items
| # | Area | Description |
|---|---|---|
| 1 | **TON scaling** | O(pending intents) TonCenter API calls per scan cycle. Becomes a bottleneck above ~50 concurrent TON intents. Future fix: batch by address or use a streaming API. |
| 2 | **Tron/TON balance watches** | `POST /balances/check` and `POST /balance-watches` currently only support EVM ERC-20 reads. Tron TRC-20 and TON Jetton balance reads are future scope. |
| 3 | **Non-EVM chains unverified** | Arbitrum, Polygon, Base, Tron, TON are `"verified": false` — workers do not start by default. Needs production testing + `SCANNER_ENABLED_CHAINS` opt-in before enabling for real traffic. |
| 4 | **Base proxy address** | `0x1892196E80C4c17ea5100Da765Ab48c1fE2Fb814` is non-canonical (differs from RN's CREATE2 expected address for that chain). Verify before enabling Base in production. |
| 5 | **Ethereum proxy version** | Ethereum uses the v0.1.0 proxy at `0x370DE27…`. A v0.2.0 proxy is also deployed on ETH. The two have different ABI signatures — do not silently swap addresses. |
| 6 | **Single SQLite instance** | The embedded SQLite database is not horizontally scalable. Running multiple scanner replicas would require coordination or migration to a shared DB. Acceptable for current load. |
| 7 | **No native-token support** | Only ERC-20/TRC-20/Jetton transfers are scanned. Native token (BNB, ETH, TRX, TON coin) payments are not supported. |
| 8 | **Multi-seller / multi-chain** | AMN Scanner pay-in supports single-seller flow only. Multi-seller cart payments and cross-chain routing are not implemented. |
| 9 | **Webhook signature algorithm** | HMAC-SHA256 with a pre-shared secret. There is no key rotation mechanism — changing `callbackSecret` requires intent re-registration. |
| 10 | **No EVM testnet for ETH/Polygon/Base** | Only BSC Testnet (97) has a testnet entry in `supported-chains.json`. Developers testing on Ethereum Sepolia or Polygon Amoy need to add chain entries manually. |

View File

@@ -0,0 +1,206 @@
---
title: Payment Safety Edge Cases
tags: [testing, payment, safety, aml, scanner, edge-cases]
created: 2026-06-08
---
# Payment Safety Edge Cases
Automated tests live in `backend/__tests__/payment-edge-cases.test.ts` (38 tests, all passing).
This document records the design rationale, current system behaviour, and remaining gaps for each
of the five payment edge-case families.
## Edge Case Families
### 1 · Blacklisted / OFAC-Sanctioned Sender Wallet
**How the system works**
The OFAC SDN list is downloaded from US Treasury once per 24 h and cached locally. Address
screening runs when the seller has opted in (`requireAmlCheck=true` on the seller offer). For
on-chain (BSC Verifier) payments the buyer address comes from the ERC-20 Transfer log `from` field.
For direct-balance (AMN Scanner) payments the buyer address must be stored in
`amnScannerDirectBalance.buyerAddress` at intent-creation time.
**Implemented behaviour**
| Scenario | Result |
|---|---|
| OFAC-listed address + seller opted in | `block=true`, `reason=aml_sanctions` |
| OFAC-listed address + seller NOT opted in | `block=false`, `reason=aml_not_required` |
| OFAC provider unreachable + `amlBlockOnFailure=true` | `block=true` (fail-closed) |
| OFAC provider unreachable + `amlBlockOnFailure=false` | `block=false`, `providerUnavailable=true` (fail-open) |
| No buyer address (direct-balance, no tx hash) | `block=false`, `reason=no_buyer_address_to_screen` |
| Direct-balance + `buyerAddress` stored + sanctioned | `block=true` via `fundDirectBalancePayment` AML gate |
| BTC / XMR addresses in SDN XML | Ignored (only EVM `0x…` addresses parsed) |
**Remaining gaps**
- AML is opt-in per seller. A sanctioned address can pay any seller with `requireAmlCheck=false`.
Platform-level mandatory screening could be added via a `PLATFORM_AML_REQUIRED=1` env flag.
- SDN list can be up to 24 h stale.
**Relevant code**
- `src/services/payment/safety/ofacProvider.ts` — SDN download, parse, cache
- `src/services/payment/safety/amlScreeningService.ts``screenPaymentForAml()`
- `src/services/payment/amnScanner/directBalancePaymentService.ts``fundDirectBalancePayment()` AML gate
---
### 2 · Overpayment and Underpayment
**How the system works**
On-chain (BSC Verifier): amount ≥ expected → success; amount < expected → `insufficient_amount`.
Direct-balance (webhook): delta ≥ expected → `funded=true`; delta < expected → `funded=false`.
Overpay excess is accepted silently and remains locked at the derived destination address.
**Implemented behaviour**
| Scenario | Result |
|---|---|
| On-chain overpay (15 USDT vs 10 expected) | `success=true`, `actualAmount` returned |
| On-chain exact match | `success=true` |
| On-chain underpay by 1 wei | `failureReason=insufficient_amount`, both amounts returned |
| Direct-balance overpay | `funded=true`, `reason=paid` |
| Direct-balance exact match | `funded=true` |
| Direct-balance underpay | `funded=false`, `reason=underpaid:delta=…,expected=…` **+ `payment-underpaid` socket event** with `shortfall` |
| Direct-balance zero balance | `funded=false` |
**Remaining gaps**
- No dedicated `underpaid` payment status in the DB — payment stays `pending` until expiry.
- Overpay excess locked at derived address with no automated recovery.
**Relevant code**
- `src/services/payment/decentralizedPaymentService.ts``BSCTransactionVerifier.verifyTransfer()`
- `src/services/payment/amnScanner/directBalancePaymentService.ts``processDirectBalanceWebhook()`
---
### 3 · Native Coin (ETH / BNB) Sent Instead of ERC-20
**How the system works**
Native coin transfers emit no ERC-20 Transfer log. On-chain verification sees no Transfer events.
The AMN scanner watches a specific ERC-20 token balance; native coin does not affect it.
**Implemented behaviour**
| Scenario | Result |
|---|---|
| On-chain tx succeeded, empty logs | `failureReason=wrong_asset`**distinguishable from transfer_not_found** |
| On-chain tx with only Approval log | `failureReason=transfer_not_found` |
| Direct-balance webhook, ERC-20 balance unchanged | `funded=false` — scanner unaware of native coin arrival |
| Direct-balance API check, native baseline captured | `warning=native_coin_detected:delta=N` when native balance increased |
**Remaining gaps**
- The direct-balance **webhook** path cannot detect native coin because the scanner only reports
ERC-20 balance changes. A native-coin watch could be added to the scanner service separately.
- Native coin locked at derived address — no automated sweep path.
**`wrong_asset` vs `transfer_not_found`**
| failureReason | Meaning |
|---|---|
| `wrong_asset` | Tx succeeded on-chain with zero logs — almost certainly native coin was sent |
| `transfer_not_found` | Tx succeeded but logs exist yet none match token/recipient — likely wrong token or misconfigured tx |
**Relevant code**
- `decentralizedPaymentService.ts:verifyTransfer()``receipt.logs.length === 0``wrong_asset`
- `directBalancePaymentService.ts:createDirectBalancePayIntent()` — captures `nativeBaselineBalance`
- `directBalancePaymentService.ts:checkDirectBalancePayment()` — compares native balance to baseline
---
### 4 · Wrong ERC-20 Token Sent to Deposit Address
**How the system works**
On-chain: BSC Verifier detects token/recipient mismatches explicitly. Direct-balance webhook: the
scanner reports the token address in the payload; mismatches are caught at intake.
**Implemented behaviour**
| Scenario | Result |
|---|---|
| On-chain: USDC sent when USDT expected | `failureReason=wrong_token`, `actualToken` populated |
| On-chain: right token, wrong recipient | `failureReason=wrong_recipient`, `actualRecipient` populated |
| On-chain: completely random token + address | `failureReason=transfer_not_found` |
| Direct-balance webhook: wrong `tokenAddress` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
| Direct-balance webhook: wrong `chainId` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
| Direct-balance webhook: wrong `address` | `funded=false`, `reason=address-token-mismatch` **+ `payment-wrong-token` socket event** |
**Remaining gaps**
- Wrong-token funds are locked at the derived address — no automated recovery.
**Relevant code**
- `decentralizedPaymentService.ts``parseTransferLogs()`, `verifyTransfer()` wrong-token detection
- `directBalancePaymentService.ts:processDirectBalanceWebhook()` — mismatch emits `payment-wrong-token`
---
### 5 · Smart-Contract Sender
**How the system works**
The ERC-20 Transfer log `from` field (topics[1]) is the immediate sender. Smart contracts
(DEX routers, multisigs, mixers) appear here. The system has no built-in EOA detection — only
OFAC-listed contract addresses are blocked.
**Implemented behaviour**
| Scenario | Result |
|---|---|
| Contract address in Transfer log | Surfaced as `evidence.from`, passed to AML |
| OFAC-listed contract + seller opted in | `block=true` |
| Unlisted contract (e.g. mixer) + not on OFAC | `block=false`**gap** |
| Gnosis Safe (legitimate multisig) | `block=false` — indistinguishable from mixer by address alone |
| `requireEoaSender=true` + contract bytecode | `failureReason=contract_sender`, `success=false` |
| `requireEoaSender=true` + EOA (empty bytecode) | `success=true` as normal |
| `requireEoaSender` not set (default) | Contract sender passes — backward-compatible |
**Enabling EOA enforcement**
Set env flag at any scope (per-service or globally):
```
TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1
```
This is wired to all 5 `verifyTransfer` call sites:
- `transactionSafetyProvider.ts` (webhook safety evaluation)
- `paymentController.ts` (new + re-verify web3 payment paths)
- `paymentRoutes.ts`
- `marketplace/routes.ts`
**Remaining gaps**
- No bytecode check in `screenPaymentForAml()` itself — the AML layer cannot gate on sender type.
- No seller-level `requireEoaSender` flag — currently only a platform-wide env toggle.
- Gnosis Safe and unlisted mixers remain indistinguishable at address level. A known-multisig
factory allowlist would be required for principled permitting.
**Relevant code**
- `decentralizedPaymentService.ts:verifyTransfer()``requireEoaSender``eth_getCode``contract_sender`
- `transactionSafetyProvider.ts` — reads `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER`
---
## Test File Reference
`backend/__tests__/payment-edge-cases.test.ts`
| Section | Tests | Status |
|---|---|---|
| 1 · Blacklisted / OFAC wallet | 10 | ✓ all pass |
| 2 · Overpayment and underpayment | 8 | ✓ all pass |
| 3 · Native coin (ETH/BNB) | 4 | ✓ all pass |
| 4 · Wrong ERC-20 token | 7 | ✓ all pass |
| 5 · Smart-contract sender | 9 | ✓ all pass |
| **Total** | **38** | **✓** |
Tests labelled `GAP ·` document known system limitations that pass because they describe current
(undesired) behaviour. Tests labelled `FIXED ·` confirm a gap was mitigated.
Run:
```bash
cd backend && node_modules/.bin/jest __tests__/payment-edge-cases.test.ts --no-coverage
```

View File

@@ -25,9 +25,14 @@ have an automation owner, a smoke command, and a UAT checklist.
| ESCROW-E2E-001 | Buyer creates request, multiple sellers bid, buyer accepts, pays, seller delivers, buyer confirms | P0 | Live tested on dev, two rounds | [[Escrow Marketplace E2E Procedure]] | | ESCROW-E2E-001 | Buyer creates request, multiple sellers bid, buyer accepts, pays, seller delivers, buyer confirms | P0 | Live tested on dev, two rounds | [[Escrow Marketplace E2E Procedure]] |
| PAY-SCAN-001 | BSC Testnet tUSDT direct-balance scanner confirms funded wallet transfer | P0 | Live tested on dev | [[Scanner BSC Testnet Payment Procedure]] | | PAY-SCAN-001 | BSC Testnet tUSDT direct-balance scanner confirms funded wallet transfer | P0 | Live tested on dev | [[Scanner BSC Testnet Payment Procedure]] |
| PAY-SCAN-002 | Scanner/backend/frontend token registry consistency for chain 97 | P0 | Smoke covered | [[Smoke and Regression Procedure]] | | PAY-SCAN-002 | Scanner/backend/frontend token registry consistency for chain 97 | P0 | Smoke covered | [[Smoke and Regression Procedure]] |
| PAY-SCAN-003 | Wrong token contract does not confirm payment | P0 | Designed, needs automated negative test | [[Scanner BSC Testnet Payment Procedure#Negative scenarios]] | | PAY-SCAN-003 | Wrong token contract does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
| PAY-SCAN-004 | Underpayment does not confirm payment | P0 | Designed, needs automated negative test | [[Scanner BSC Testnet Payment Procedure#Negative scenarios]] | | PAY-SCAN-004 | Underpayment does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §2`) | [[Payment Safety Edge Cases#2 · Overpayment and Underpayment]] |
| PAY-SCAN-005 | Wrong destination does not confirm payment | P0 | Designed, needs automated negative test | [[Scanner BSC Testnet Payment Procedure#Negative scenarios]] | | PAY-SCAN-005 | Wrong destination does not confirm payment | P0 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
| PAY-SAFE-001 | OFAC-sanctioned wallet blocked when seller opts in | P0 | **Automated** (`payment-edge-cases.test.ts §1`) | [[Payment Safety Edge Cases#1 · Blacklisted / OFAC-Sanctioned Sender Wallet]] |
| PAY-SAFE-002 | Native coin (ETH/BNB) returns `wrong_asset` instead of `transfer_not_found` | P1 | **Automated** (`payment-edge-cases.test.ts §3`) | [[Payment Safety Edge Cases#3 · Native Coin (ETH / BNB) Sent Instead of ERC-20]] |
| PAY-SAFE-003 | Smart-contract sender blocked when `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1` | P1 | **Automated** (`payment-edge-cases.test.ts §5`) | [[Payment Safety Edge Cases#5 · Smart-Contract Sender]] |
| PAY-SAFE-004 | Underpaid direct-balance emits `payment-underpaid` event with shortfall | P1 | **Automated** (`payment-edge-cases.test.ts §2`) | [[Payment Safety Edge Cases#2 · Overpayment and Underpayment]] |
| PAY-SAFE-005 | Wrong-token direct-balance emits `payment-wrong-token` event | P1 | **Automated** (`payment-edge-cases.test.ts §4`) | [[Payment Safety Edge Cases#4 · Wrong ERC-20 Token Sent to Deposit Address]] |
| DELIVERY-001 | Seller delivery advances request to `delivery` | P0 | Live tested on dev | [[Escrow Marketplace E2E Procedure]] | | DELIVERY-001 | Seller delivery advances request to `delivery` | P0 | Live tested on dev | [[Escrow Marketplace E2E Procedure]] |
| DELIVERY-002 | Buyer delivery confirmation advances request to `delivered` | P0 | Live tested after `2.8.117` id fix | [[Escrow Marketplace E2E Procedure]] | | DELIVERY-002 | Buyer delivery confirmation advances request to `delivered` | P0 | Live tested after `2.8.117` id fix | [[Escrow Marketplace E2E Procedure]] |
| DELIVERY-003 | Non-buyer cannot confirm delivery | P0 | Designed, should be regression test | [[Testing Expansion Backlog]] | | DELIVERY-003 | Non-buyer cannot confirm delivery | P0 | Designed, should be regression test | [[Testing Expansion Backlog]] |

View File

@@ -65,17 +65,23 @@ Tests needed:
## P0 - Payment Negative Tests ## P0 - Payment Negative Tests
Automate: **Automated as of 2026-06-08** (`backend/__tests__/payment-edge-cases.test.ts`, 38 tests):
See [[Payment Safety Edge Cases]] for full detail.
- wrong token; - wrong token (on-chain `wrong_token` + direct-balance `address-token-mismatch` + `payment-wrong-token` event)
- wrong chain; - wrong chain (direct-balance `address-token-mismatch`)
- wrong destination; - wrong destination (direct-balance `address-token-mismatch`)
- underpayment; - underpayment (`insufficient_amount` on-chain; `underpaid` direct-balance + `payment-underpaid` event)
- duplicate payment; - ✅ native coin sent instead of ERC-20 (`wrong_asset` on-chain; stays pending in direct-balance webhook)
- ✅ OFAC-sanctioned sender blocked (opt-in per seller; direct-balance `fundDirectBalancePayment` AML gate)
- ✅ smart-contract sender blocked via `TRANSACTION_SAFETY_REQUIRE_EOA_SENDER=1`
Still needs automation:
- duplicate payment (double-credit guard);
- late payment after cancelled/expired intent; - late payment after cancelled/expired intent;
- payment with no gas; - payment with no gas;
- scanner unavailable during payment; - scanner unavailable during payment;
- scanner webhook signature invalid; - scanner webhook signature invalid (partially covered by `amn-pay-adapter-webhook-signature.test.ts`);
- balance check baseline missing or stale. - balance check baseline missing or stale.
## P0 - Authorization and ID Boundaries ## P0 - Authorization and ID Boundaries

View File

@@ -19,6 +19,7 @@ seller, payment scanner, delivery, payout, dispute, and deployment behavior.
|---|---| |---|---|
| [[Test Environment and Data]] | Environments, accounts, wallets, tokens, and secret-handling rules. | | [[Test Environment and Data]] | Environments, accounts, wallets, tokens, and secret-handling rules. |
| [[Test Scenario Catalog]] | Canonical scenarios we have designed or need to extend. | | [[Test Scenario Catalog]] | Canonical scenarios we have designed or need to extend. |
| [[Payment Safety Edge Cases]] | Five payment edge-case families (OFAC, underpay, native coin, wrong token, contract sender) — design rationale, current behaviour, gaps, and 38-test suite reference. |
| [[Escrow Marketplace E2E Procedure]] | Buyer/seller/request/bid/delivery procedure, including the current two-round flow. | | [[Escrow Marketplace E2E Procedure]] | Buyer/seller/request/bid/delivery procedure, including the current two-round flow. |
| [[Scanner BSC Testnet Payment Procedure]] | BSC Testnet tUSDT scanner payment procedure and failure modes. | | [[Scanner BSC Testnet Payment Procedure]] | BSC Testnet tUSDT scanner payment procedure and failure modes. |
| [[Notification Assertion Procedure]] | Required notification checks after every E2E business step. | | [[Notification Assertion Procedure]] | Required notification checks after every E2E business step. |