Files
nick-doc/04 - Flows/Payment Flow - SHKeeper.md
Siavash Sameni 7a616744f4 docs: complete code-reality alignment for remaining docs + reconcile issue set
Remaining docs updated to match code (the docs that the first pass had not covered):
- Flows: Chat, Referral, Rating, Registration, Google OAuth, Negotiation, Payout,
  Trezor Safekeeping — corrected endpoints, socket events, status enums, auth gaps
- API Reference: User API, Trezor API — admin route prefix/verb/status corrections,
  added undocumented endpoints (ton-proof challenge, profile email verify,
  GET /trezor/account, POST /trezor/verify-operation)
- Data Models: Chat, Notification, Payment, PointTransaction, User — corrected
  enums (PaymentProvider, escrowState, PointTransaction.type, User.status),
  90-day notification TTL, soft-delete semantics, wallet fields

Trezor "zero frontend" finding (audit C31/C32) corrected as STALE:
- Verified current code HAS a full frontend Trezor implementation (admin/trezor
  page, TrezorSettingsView, trezorConnector via @trezor/connect-web,
  TrezorSignDialog, actions/trezor.ts building the {message,signature} object)
- Fixed Trezor Safekeeping Flow doc (removed false "no frontend" warnings)
- Reclassified ISSUE-012 as invalid/superseded with explanation

Issue set reconciled to a single canonical numbering (ISSUE-001..054):
- Adopted the comprehensive 51-issue set (long-slug, fully indexed)
- Removed 35 superseded short-slug duplicates from the first pass
- Removed a duplicate ISSUE-046 file
- Added 3 issues the 51-set lacked: ISSUE-052 (completed-not-counted-in-stats),
  ISSUE-053 (axios 401-only interceptor), ISSUE-054 (rate limiter counts all attempts)
- Regenerated Issues Index: 53 open (14 critical, 39 major) + 1 invalid

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:15:02 +04:00

22 KiB
Raw Permalink Blame History

title, tags, related_models, related_apis
title tags related_models related_apis
Payment Flow - SHKeeper
flow
payment
shkeeper
crypto
escrow
webhook
Payment
PurchaseRequest
SellerOffer
POST /api/payment/shkeeper/intents
POST /api/payment/shkeeper/webhook
POST /api/payment/:id/release
POST /api/payment/:id/refund

Last updated: 2026-05-29 — aligned with code (see Doc vs Code Audit Report)

[!caution] Audit — 2026-05-29 This document was reviewed against the live codebase. 3 corrections applied: (1) the non-existent HTTP polling endpoint has been removed (status updates arrive via socket only), (2) the release/refund/confirm paths have been corrected to remove the erroneous /shkeeper/ segment, and (3) the intent-creation endpoint corrected from /shkeeper/create to /shkeeper/intents and parallel stats/export paths documented.

Payment Flow — SHKeeper (Crypto Pay-In)

[!warning] Historical migration document This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is PRD - Request Network In-House Checkout, and the current escrow/custody model is Escrow Flow plus PRD - Decentralized Custody and Smart-Contract Escrow Roadmap.

End-to-end crypto pay-in via the self-hosted SHKeeper gateway at pay.amn.gg. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.

Supported assets

Pulled from env: SHKEEPER_NETWORKS and SHKEEPER_ALLOWED_TOKENS (shkeeperService.ts:97-98).

  • Networks: bsc, ethereum (default in code).
  • Tokens: USDT, USDC (default). The endpoint URL is built as https://pay.amn.gg/api/v1/{NETWORK_PREFIX}-{TOKEN}/payment_request (shkeeperService.ts:413-417).
    • BSC → BNB-USDT, BNB-USDC (i.e. BEP-20).
    • Ethereum → ETH-USDT, ETH-USDC (ERC-20).
  • TRC-20 (USDT-TRC20) and native BTC are mentioned in the task brief but not currently wired in shkeeperService.ts — only BSC/ETH variants are produced from the code path. Verify SHKeeper-side configuration if those are required.

Actors

  • Buyer — pays.
  • Seller — passive in this flow; gets notified on success.
  • Frontend — checkout components under frontend/src/sections/request/components/buyer-steps/step-3-components/ and frontend/src/sections/payment/.
  • BackendshkeeperService.createPayInIntent (backend/src/services/payment/shkeeper/shkeeperService.ts:48-533) and shkeeperWebhook.handleShkeeperWebhook (backend/src/services/payment/shkeeper/shkeeperWebhook.ts).
  • SHKeeper gateway (https://pay.amn.gg) — issues per-payment deposit addresses, watches the chain, sends webhooks.
  • Blockchain — BSC / Ethereum.
  • PaymentCoordinator (backend/src/services/payment/paymentCoordinator.ts) — serialises concurrent payment-status updates from multiple sources (webhook, wallet monitor, manual confirm).
  • MongoDBpayments, purchaserequests, selleroffers, chats, notifications.
  • RedispaymentRedisService (wallet-address cache, 2 h TTL).
  • Socket.IOpayment-created, payment-update, template-checkout-payment-confirmed, seller-offer-update, purchase-request-update.

Preconditions

  • Buyer has selected an offer (or is using the template-checkout shortcut).
  • Backend env: SHKEEPER_API_URL, SHKEEPER_API_KEY, SHKEEPER_WEBHOOK_SECRET, API_URL (for the callback URL).
  • Redis is reachable (graceful degradation if not — see app.ts:361-367).
  • SHKeeper has wallets provisioned for each crypto_name.

Payment state machine

stateDiagram-v2
    [*] --> pending: createPayInIntent\n(Payment.status="pending")
    pending --> pending_partial: webhook PARTIAL\nescrowState="partial"
    pending --> completed: webhook PAID/OVERPAID\nescrowState="funded"
    pending --> failed: webhook EXPIRED/CANCELLED\nescrowState="cancelled"
    pending_partial --> completed: top-up arrives, total ≥ amount
    pending_partial --> failed: expires
    completed --> released: admin release → seller payout\n[[Payout Flow]]
    completed --> refunded: dispute resolution → buyer refund
    refunded --> [*]
    released --> [*]
    failed --> [*]

Step-by-step narrative

Phase 1 — Create intent

  1. Buyer clicks "Pay" on the chosen offer (/dashboard/buyer/requests/{id} → step-3-select-and-pay).
  2. Frontend POSTs POST /api/payment/shkeeper/intents with { purchaseRequestId, sellerOfferId, amount, token?, network? }.
  3. Backend createPayInIntent:
    • Validates ObjectIds (shkeeperService.ts:55-71). Special path for template checkout (string IDs starting with template-checkout-).
    • Duplicate-guard #1 (:75-116): if there is already an active Payment (status ∈ {pending, processing}) for the same {purchaseRequestId, sellerOfferId, buyerId}, the existing record is reused — same paymentUrl, no new wallet allocation.
    • Duplicate-guard #2 (template checkout) (:131-198): if a recent completed/confirmed payment exists for the same buyer + template session, reuse it; otherwise dedupe pending records within the last 5 minutes.
    • Upsert (:218-249): atomic Payment.findOneAndUpdate(filter, {$setOnInsert: {...}}, {upsert: true, new: true}) keyed by {buyerId, purchaseRequestId, provider, direction:'in', status:'pending'} — prevents race-condition duplicates.
    • Sets Payment.providerPaymentId = externalId. For template checkouts, externalId = template-{ts}-{rand}; otherwise it's the Payment._id.
    • Wallet cache lookup (:421-450): paymentRedisService.getCachedWallet(cacheKey) — if a wallet was allocated for the same (amount, token, network, requestId) within the last 2 h, reuse it. Avoids hammering SHKeeper for the same checkout.
    • Call SHKeeper API (:453-475): POST https://pay.amn.gg/api/v1/{cryptoName}/payment_request with header X-Shkeeper-Api-Key. Body: { external_id, fiat: 'USD', amount: '12.34', callback_url: ${API_URL}/api/payment/shkeeper/webhook }. The HTTP call is wrapped by shkeeperFetch (shkeeperHealthCheck.ts) which trips the breaker on repeated failures.
    • Persist response (:484-503): updates Payment.metadata.{shkeeperInvoiceId, shkeeperData, cryptoName, walletAddress}; caches the wallet for 2 h; calls walletMonitor.addWallet(...) so the on-chain watcher can confirm independently (belt-and-braces against missed webhooks).
  4. Returns { paymentId, paymentUrl, shkeeperInvoiceId, walletAddress, amount, exchangeRate, displayName, cryptoName }.
  5. Emits payment-created globally via emitGlobalEvent (shkeeperService.ts:277-287) so the admin dashboard sees the new pending payment in real time.

Phase 2 — Buyer pays

  1. Frontend renders a QR code for ${walletAddress}?amount=${amount}&token=... and shows the exchange-rate-locked USDT amount, recalculate-after timer (recalculate_after from SHKeeper, typically 15 min), and a copy-to-clipboard button.
  2. Buyer scans with MetaMask / Trust Wallet / Binance App and sends the on-chain transfer.
  3. SHKeeper polls the chain, detects the deposit. When confirmations reach the threshold it marks the invoice PAID (or OVERPAID if the buyer sent extra).

Phase 3 — Webhook

  1. SHKeeper POSTs to ${API_URL}/api/payment/shkeeper/webhook with the ShkeeperWebhookPayload shape (shkeeperWebhook.ts:14-37):
    {
      external_id, crypto: "BNB-USDT", addr, fiat: "USD",
      balance_fiat, balance_crypto, paid: true,
      status: "PAID" | "PENDING" | "EXPIRED" | "CANCELLED" | "PARTIAL" | "OVERPAID",
      transactions: [{ txid, date, amount_crypto, amount_fiat, trigger: true, ... }],
      fee_percent, fee_fixed, fee_policy, overpaid_fiat
    }
    
  2. Signature verification (shkeeperWebhook.ts:84-120): HMAC-SHA256 of the raw body with SHKEEPER_WEBHOOK_SECRET, header x-shkeeper-signature (also accepts x-signature, signature, x-hub-signature, x-hub-signature-256). Mismatch → 401 in production, allowed in dev. Length-mismatched signature → 401 (avoids timingSafeEqual crash).
  3. Fallback auth (:122-141): if no signature header but env requires it, the route accepts X-Shkeeper-Api-Key matching SHKEEPER_API_KEY. Otherwise returns 202 to stop SHKeeper retries even if rejected (idempotency principle: always 2xx unless the request itself is mangled).
  4. DB reconnect (:143-155): if Mongoose is disconnected, attempt reconnection. On failure → 202 OK to avoid retry loop, log for investigation.
  5. Payment lookup: Payment.findOne({ providerPaymentId: payload.external_id }). If not found and the external_id looks like a template checkout, hand off to handleTemplateCheckoutWebhook (templateCheckoutWebhook.ts). Otherwise → 202 OK with a rate-limited log.
  6. Duplicate-webhook detection (:249-296): if metadata.shkeeperStatus, balance_fiat, paid are identical to the previous webhook and less than 10 seconds have passed → return 202 (idempotent). Logged once per minute per payment.
  7. Map SHKeeper status → internal status (:387-411):
    SHKeeper Internal status escrowState
    PAID completed funded
    OVERPAID completed funded
    EXPIRED, CANCELLED failed cancelled
    PARTIAL pending partial
    PENDING pending
  8. Extract transactionHash (:311-385) — prefers the transaction with trigger === true, then falls back to the latest, then to fetching from SHKeeper's invoice endpoint if the webhook somehow arrived without transactions.
  9. PaymentCoordinator (:482-507) — coordinatePaymentUpdate returns false if another worker already started processing this state change, otherwise executePaymentUpdate writes the new status/escrowState/txHash atomically with metadata.
  10. Cascade on PAID/OVERPAID (:543-714):
    • Load PurchaseRequest and SellerOffer for this payment.
    • Mark winning offer accepted: selectedOffer.status = 'accepted'; save().
    • Reject all other offers: SellerOffer.updateMany({ purchaseRequestId, _id: { $ne: sellerOfferId } }, { status: 'rejected' }).
    • Promote request: status = 'payment'; selectedOfferId = sellerOfferId.
    • Create direct chat (chatService.createChat, see Chat Flow).
    • Notifications: notifyPaymentConfirmed (to both parties), notifyOfferAccepted (winner), notifyRequestStatusChanged (received_offers → payment).
    • Socket fan-out: seller-offer-update 'payment-completed' to winner, 'offer-rejected' to losers (each carries offerId, reason).
  11. Cleanup: simpleAutoWebhook.removePayment(external_id) stops the simple polling fallback.
  12. Always respond 202 Accepted (SHKeeper retries on non-2xx). 200 would cause infinite retries because SHKeeper expects 202 per its convention.

Phase 4 — Frontend reaction

  1. The buyer's checkout page subscribes to socket events (payment-update, template-checkout-payment-confirmed). When the status flips to completed, the UI transitions to "Payment received" and unlocks the next buyer step (await delivery).

    [!warning] No HTTP polling endpoint — socket events only GET /api/payment/shkeeper/status/:paymentId does not exist — there is no polling route in shkeeperRoutes.ts. Status transitions must be observed via Socket.IO events (payment-update, template-checkout-payment-confirmed). Any frontend code path that polls this URL will always receive 404. Remove HTTP polling and rely solely on the socket subscription.

  2. The seller's dashboard receives seller-offer-update payment-completed and surfaces the green "Order paid — start preparing" banner.

Sequence diagram

sequenceDiagram
    autonumber
    actor B as Buyer
    participant FE as Frontend
    participant BE as Backend
    participant R as Redis
    participant SK as SHKeeper (pay.amn.gg)
    participant BC as Blockchain
    participant DB as MongoDB
    participant IO as Socket.IO
    actor S as Seller

    B->>FE: Choose offer, click "Pay"
    FE->>BE: POST /api/payment/shkeeper/intents
    BE->>DB: dedupe / upsert Payment(status:"pending")
    BE->>R: getCachedWallet(amount, token, network, requestId)
    alt cache hit
        R-->>BE: cached wallet
    else cache miss
        BE->>SK: POST /api/v1/{cryptoName}/payment_request\nX-Shkeeper-Api-Key
        SK-->>BE: { id, wallet, amount, exchange_rate, ... }
        BE->>R: setCachedWallet (TTL 2h)
        BE->>BE: walletMonitor.addWallet (chain watcher)
    end
    BE->>IO: emit 'payment-created' (admin)
    BE-->>FE: { paymentId, walletAddress, amount, QR-ready data }
    FE-->>B: Render QR + countdown + copy address
    B->>BC: Send USDT/USDC to walletAddress
    BC-->>SK: deposit confirmed
    SK->>BE: POST /api/payment/shkeeper/webhook\nx-shkeeper-signature
    BE->>BE: HMAC verify
    BE->>DB: Payment.findOne({providerPaymentId})
    BE->>BE: duplicate-webhook check
    BE->>BE: PaymentCoordinator.coordinate + execute
    BE->>DB: Payment.status="completed"\nescrowState="funded"\nblockchain.transactionHash=...
    BE->>DB: SellerOffer.status="accepted" (others "rejected")
    BE->>DB: PurchaseRequest.status="payment", selectedOfferId
    BE->>DB: Chat.create (buyer + winning seller)
    BE->>IO: emit seller-{winner} 'payment-completed'
    BE->>IO: emit seller-{loser_i} 'offer-rejected'
    BE-->>SK: 202 OK
    IO-->>FE: payment-update / status updated
    IO-->>S: dashboard updates
    FE-->>B: "Payment received ✓"

API calls

Method Endpoint Purpose Auth Source
POST /api/payment/shkeeper/intents Create pay-in intent Bearer JWT (buyer) shkeeperRoutes.tsshkeeperService.createPayInIntent
POST /api/payment/shkeeper/webhook Receive SHKeeper webhook HMAC / API key shkeeperWebhook.handleShkeeperWebhook
POST /api/payment/:id/release Release escrow to seller Bearer JWT paymentRoutes.ts
POST /api/payment/:id/release/confirm Confirm escrow release Bearer JWT paymentRoutes.ts
POST /api/payment/:id/refund Refund to buyer Bearer JWT paymentRoutes.ts
POST /api/payment/:id/refund/confirm Confirm buyer refund Bearer JWT paymentRoutes.ts
POST /api/payment/payments/:id/fetch-tx Manual transaction lookup Bearer JWT paymentRoutes.ts
GET /api/payment/payments/stats Payment statistics (admin-gated strict) Bearer JWT + admin role paymentRoutes.ts
GET /api/payment/stats Payment statistics (no admin guard) Bearer JWT paymentRoutes.ts
GET /api/payment/shkeeper/status/:paymentId 404 — does not exist. Use socket events instead.

[!note] Two parallel stats paths Two separate stats endpoints exist with different auth levels:

  • GET /api/payment/payments/stats — admin-gated (strict role check); intended for admin dashboard.
  • GET /api/payment/stats — authenticated but no admin guard; accessible to any logged-in user. Similarly, export endpoints exist at two paths with different auth levels. Confirm which is appropriate for each consumer before wiring the frontend.

[!warning] Release/refund path correction Previously documented paths included a /shkeeper/ segment that does not exist in the router:

  • POST /api/payment/shkeeper/:id/release → correct: POST /api/payment/:id/release
  • POST /api/payment/shkeeper/:id/release/confirm → correct: POST /api/payment/:id/release/confirm
  • POST /api/payment/shkeeper/:id/refund → correct: POST /api/payment/:id/refund
  • POST /api/payment/shkeeper/:id/refund/confirm → correct: POST /api/payment/:id/refund/confirm

The /shkeeper/ infix never existed on release/refund routes. These are generic payment lifecycle endpoints shared across all providers.

Database writes

  • payments: insert on intent creation (status: 'pending'); update on each webhook (status, escrowState, blockchain.transactionHash, metadata).
  • payments (unique index uniq_pending_shkeeper_by_buyer_session, see Payment.ts:181-188): partial unique on {buyerId, purchaseRequestId, provider:'shkeeper', direction:'in', status:'pending'} prevents duplicate pending pay-ins.
  • selleroffers: status flipped (accepted / rejected) by the webhook cascade.
  • purchaserequests: statuspayment, selectedOfferId set.
  • chats: a new direct chat (or reuse) — find-or-create via ChatService.createChat.
  • notifications: 2N entries depending on parties.

Socket events emitted

  • payment-created (global) — broadcast on intent creation.
  • payment-update — status change notifications to the buyer's checkout page.
  • template-checkout-payment-confirmed — for template checkout flows.
  • seller-offer-update with eventType: 'payment-completed' → winning seller.
  • seller-offer-update with eventType: 'offer-rejected' → each losing seller.
  • purchase-request-update with eventType: 'status-changed' (via PurchaseRequestService) → request-{id}.
  • new-notification → both buyer and seller.

Side effects

  • Redis: walletCache:{amount}_{token}_{network}_{requestId} set for 2 hours.
  • Wallet monitor: walletMonitor.addWallet(addr, amount, paymentId, token, network) — the on-chain watcher polls BSC/ETH RPCs directly; if SHKeeper's webhook is lost, the monitor still flips the payment to completed. This is the redundancy mechanism noted in the comments.
  • simpleAutoWebhook (shkeeperSimpleAuto.ts): a poll-based fallback that asks SHKeeper for invoice status; cleaned up once a real webhook arrives.
  • Webhook stats: webhookStats.recordWebhook(...) updates an in-memory ring buffer surfaced via webhookStats.ts admin endpoint.

Error / edge cases

  • Duplicate intent submission → reuse the existing pending payment (no new wallet). UX-safe.
  • SHKeeper API unreachableshkeeperFetch (with circuit breaker) throws; controller returns a demo fallback URL (shkeeperService.ts:520-532). In production this is observed as a Sentry error.
  • Webhook signature mismatch in prod401, SHKeeper retries — usually the secret has rotated; fix env and they catch up.
  • Webhook missing both signature and API key in prod202 OK (no-op) to prevent retry storm.
  • DB disconnected during webhook → reconnect; on failure 202 OK + log (consider DLQ).
  • PARTIAL payment → state held as pending/partial; further deposits to the same address are aggregated by SHKeeper and a new webhook arrives.
  • OVERPAID → treated as completed/funded; the overage stays with the platform unless an admin manually refunds (no automatic refund of overpayment today).
  • EXPIREDfailed/cancelled. Buyer can re-initiate; the duplicate-guard will create a fresh intent because the old one is no longer pending.
  • External_id not found202 with rate-limited log; common for orphaned webhooks from old tests.
  • Webhook arrives twice within 10 s with same data → idempotency skip → 202.
  • PaymentCoordinator deferral202 with a "coordinator skipped update" log; the in-flight worker will finish the state change.
  • Wallet address reuse — cached for 2 h means two parallel checkouts for the same (amount, token, network, requestId) share one address; whichever pays first wins (acceptable since the duplicate-guard reuses the same Payment doc anyway).
  • crypto_name mismatch — only BNB-* and ETH-* are produced; for TRC-20, additional logic is needed in shkeeperService.ts:415.

[!warning] Webhook returns 202 even on errors The handler always responds 2xx to avoid SHKeeper's retry storm — even for unknown payments, signature failures (in non-production paths), DB errors, and unexpected exceptions. Operationally this means failed-to-process webhooks are silently swallowed unless someone tails the logs. Hook the catch-all into Sentry severity = error and alert on webhookStats.errorCount.

[!tip] Manual reconciliation Use fix-transaction-hashes.js at repo root to backfill blockchain.transactionHash for payments where the webhook arrived without transactions. See Payout Flow for the parallel payout-side script usage.

Linked flows

Source files

  • Backend: backend/src/services/payment/shkeeper/shkeeperService.ts (intent creation, ~650 lines)
  • Backend: backend/src/services/payment/shkeeper/shkeeperWebhook.ts (webhook handler, ~750 lines)
  • Backend: backend/src/services/payment/shkeeper/shkeeperHealthCheck.ts (circuit breaker)
  • Backend: backend/src/services/payment/shkeeper/shkeeperRoutes.ts
  • Backend: backend/src/services/payment/paymentCoordinator.ts
  • Backend: backend/src/services/payment/cleanupPendingPayments.ts (periodic GC)
  • Backend: backend/src/services/blockchain/walletMonitor.ts (chain watcher)
  • Backend: backend/src/services/redis/paymentRedisService.ts (wallet cache)
  • Backend: backend/src/models/Payment.ts
  • Frontend: frontend/src/sections/request/components/buyer-steps/step-3-components/step-3-payment.tsx
  • Frontend: frontend/src/sections/payment/