Files
nick-doc/04 - Flows/Escrow Flow.md
Siavash Sameni 4cf5c49274 docs(audit): align documentation with post-remediation backend reality
- Update data model enums to match backend models
- Update API reference auth requirements
- Add dispute module references and warning blocks
- Add 2026-05-24 audit remediation callout to Overview
- Generate task breakdowns and audit artifacts
- Add doc alignment report (.taskmaster/reports/)
2026-05-24 11:16:29 +04:00

10 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Escrow Flow
flow
escrow
payment
state-machine
Payment
PurchaseRequest
POST /api/payment/release/:paymentId
POST /api/payment/refund/:paymentId

Escrow Flow

The escrow is not a separate smart contract — it is a state machine on the Payment document combined with a custodial wallet (the platform-controlled BSC address NEXT_PUBLIC_ESCROW_WALLET_ADDRESS). Funds sit at that wallet once SHKeeper / Web3 verification completes, and are released to the seller or refunded to the buyer based on order outcome.

Actors

  • System — the backend, on receiving pay-in confirmation.
  • Buyer — confirms delivery to authorise release; can open a dispute to block release.
  • Seller — recipient of release.
  • Admin — resolves disputes and signs payout transactions when manual control is required.
  • MongoDBpayments document holds the canonical escrowState.

Escrow state machine (Payment.escrowState)

Enum from Payment.ts:112-115: funded | releasable | released | refunded | releasing | failed | cancelled | partial.

stateDiagram-v2
    [*] --> Pending: Payment.status="pending"\nescrowState=undefined
    Pending --> Partial: webhook PARTIAL\nescrowState="partial"
    Pending --> Funded: webhook PAID/OVERPAID\nor on-chain verify success\nescrowState="funded"
    Partial --> Funded: top-up reaches threshold
    Funded --> Releasable: buyer confirms delivery\n(or auto-release timer)
    Releasable --> Releasing: admin/system initiates payout\n[[Payout Flow]]
    Releasing --> Released: payout tx confirmed\nescrowState="released"
    Releasing --> Failed: payout tx reverted\nescrowState="failed"
    Funded --> Refunded: dispute resolution = refund\nescrowState="refunded"
    Funded --> Refunded: order cancelled\npre-shipment
    Pending --> Cancelled: webhook EXPIRED/CANCELLED
escrowState="cancelled"
    Failed --> Releasing: admin retries
    Released --> [*]
    Refunded --> [*]
    Cancelled --> [*]

Payment.status mirrors a coarser business state:

  • pending → invoice issued, awaiting funds.
  • processing → SHKeeper sees partial / confirmations in progress.
  • confirmed → fully credited (intermediate; sometimes skipped).
  • completed → escrow funded and onward.
  • failed, cancelled, refunded → terminal.

Step-by-step narrative

1. Funding

  • Triggered by either Payment Flow - SHKeeper (webhook PAID/OVERPAID) or Payment Flow - DePay & Web3 (verified eth_getTransactionReceipt).
  • Backend sets Payment.status = "completed" and Payment.escrowState = "funded" (shkeeperWebhook.ts:388-391, shkeeperService.ts:600-602).
  • Cascade: PurchaseRequest.statuspayment, then processing once the seller acknowledges; SellerOffer.statusaccepted; chat created.
  • Funds physically sit at the custodial wallet — SHKeeper's per-invoice deposit address (auto-swept to the merchant wallet) or directly at the escrow wallet in the Web3 path.

2. Holding

  • While escrowState === "funded" and the order is in processing / delivery, the funds are inert. No interest accrues; no on-chain action happens.
  • The buyer cannot withdraw; the seller cannot collect. Only an admin/system action moves it forward.
  • Visible in admin dashboard: GET /api/payment/admin/funded?status=funded (or similar — see admin payment view in frontend/src/sections/payment/view/payment-list-admin-view.tsx).

3. Releasing (happy path)

  • Trigger options:
    • Buyer confirms delivery via the delivery-code flow (Delivery Confirmation Flow).
    • Auto-release timer elapses (configurable; today a manual or scheduled job — PurchaseRequestService exposes status transitions through to completed).
    • Admin manual release from the admin payment detail view.
  • The system marks Payment.escrowState = "releasable" (intermediate).
  • shkeeperPayoutService.createPayoutTask (or a manual EVM admin signature via admin-wallet-payout.tsx) starts the on-chain transfer to the seller's verified wallet address. State flips to releasing.
  • On confirmation: confirmAdminTx(paymentId, txHash, 'release') (shkeeperService.ts:628-647) sets:
    • Payment.status = 'completed'
    • Payment.escrowState = 'released'
    • Payment.blockchain.transactionHash = <payout tx hash>
  • Cascade: PurchaseRequest.statusseller_paid then completed.

4. Refunding (dispute / cancellation)

  • Trigger: dispute resolution with action: 'refund' or pre-shipment cancellation.
  • Backend builds the refund tx via buildAdminSignedTxPayload(paymentId, 'refund') (shkeeperService.ts:614-626) — destination is payment.blockchain.sender (the buyer's verified wallet).
  • Admin signs and broadcasts (currently a manual step in the admin UI).
  • On confirmation: confirmAdminTx(paymentId, txHash, 'refund') sets:
    • Payment.status = 'refunded'
    • Payment.escrowState = 'refunded'
  • Cascade: PurchaseRequest.statuscancelled (or remains in dispute-resolved state).

5. Failed payout

  • If the payout tx reverts (insufficient gas, contract pause, wrong address), escrowState = 'failed'. Admin can retry by initiating a fresh payout.

Sequence diagram (release path)

sequenceDiagram
    autonumber
    actor B as Buyer
    actor A as Admin
    participant FE as Frontend
    participant BE as Backend
    participant DB as MongoDB
    participant SK as SHKeeper Payout API
    participant BC as BSC

    B->>FE: Enter delivery code (or auto-timer fires)
    FE->>BE: POST /api/marketplace/purchase-requests/:id/confirm-delivery
    BE->>DB: PurchaseRequest.status="delivered"\nPayment.escrowState="releasable"
    BE-->>FE: ok
    A->>FE: Click "Release" in admin
    FE->>BE: POST /api/payment/shkeeper/payout
    BE->>DB: Payment.escrowState="releasing"
    BE->>SK: createPayoutTask({recipient, amount})
    SK->>BC: signed payout tx
    BC-->>SK: confirmed
    SK->>BE: payout webhook / poll
    BE->>BE: confirmAdminTx(paymentId, txHash, "release")
    BE->>DB: Payment.escrowState="released"\nPurchaseRequest.status="completed"

Sequence diagram (refund path)

sequenceDiagram
    autonumber
    actor A as Admin
    participant BE as Backend
    participant DB as MongoDB
    participant BC as BSC
    actor B as Buyer

    A->>BE: Dispute resolved with action="refund"
    BE->>BE: buildAdminSignedTxPayload(paymentId, "refund")
    BE-->>A: { to:buyerWallet, amount, token, network }
    A->>BC: sign + broadcast tx
    BC-->>A: txHash
    A->>BE: confirmAdminTx(paymentId, txHash, "refund")
    BE->>DB: Payment.status="refunded"\nescrowState="refunded"
    BE->>B: notifyRefundCompleted

API calls

Method Endpoint Purpose
POST /api/payment/admin/release/:paymentId Initiate release
POST /api/payment/admin/refund/:paymentId Initiate refund
POST /api/payment/admin/confirm-tx/:paymentId Admin marks the signed tx confirmed
GET /api/payment/:paymentId/status Polled by both parties

Database writes

  • payments: status, escrowState, blockchain.transactionHash, completedAt, metadata.* are mutated as the state progresses.
  • purchaserequests: status cascades (payment → processing → delivery → delivered → confirming → seller_paid → completed).
  • notifications: created on each terminal state.

Socket events emitted

  • purchase-request-update status-changed on every cascading status flip.
  • payment-status (planned/admin) — admin dashboard real-time feed.

Side effects

  • Custodial risk — the escrow wallet's private key sits with the platform. Lose it → lose all in-flight escrows. Operational controls: hardware wallet, multi-sig, cold storage of the recovery seed.
  • No on-chain escrow contract — there is no Solidity escrow today. Migration toward a smart-contract escrow (e.g. OpenZeppelin's Escrow.sol pattern) would remove custodial trust at the cost of higher complexity and gas.

Error / edge cases

  • Buyer never confirms delivery → today requires admin intervention. An auto-release timer (e.g. 7 days after delivered) is a recommended addition.
  • Seller's wallet address invalid → payout tx fails or sends to a black hole. Validate recipientAddress shape (^0x[0-9a-fA-F]{40}$) before signing (shkeeperPayoutService.ts:62-64 checks .startsWith('0x')).
  • Partial payment (PARTIAL) → escrow remains in pending/partial; release blocked until full payment arrives.
  • Overpaid → currently treated as completed/funded; the surplus is not auto-refunded.
  • Concurrent release + refund → blocked by PaymentCoordinator serialisation; whichever fires first wins, the other is rejected.
  • Payout fails on chain → state stays in releasing until admin re-runs; consider auto-retry with exponential backoff.
  • Disputed paymentescrowState is not auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add a disputed boolean or escrowState='disputed' to make this more obvious.

[!warning] Single custodial wallet = single point of failure Centralising all in-flight escrow at one BSC address is the platform's largest operational risk. Use a multi-sig (Gnosis Safe) for the escrow wallet, store one key in HSM, and require two admin signatures for any payout > a threshold.

[!tip] Recovering inconsistent state If Payment.escrowState looks stale (e.g. released but no on-chain tx hash), inspect with Payment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } }) and reconcile via the SHKeeper invoice or the fix-transaction-hashes.js script.

Linked flows

Source files

  • Backend: backend/src/models/Payment.ts:96-145 (status + escrowState enums)
  • Backend: backend/src/services/payment/shkeeper/shkeeperService.ts:600-647
  • Backend: backend/src/services/payment/shkeeper/shkeeperWebhook.ts:387-411
  • Backend: backend/src/services/payment/paymentCoordinator.ts
  • Frontend: frontend/src/sections/payment/view/payment-list-admin-view.tsx
  • Frontend: frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx