--- title: Escrow Flow tags: [flow, escrow, payment, state-machine] related_models: ["[[Payment]]", "[[PurchaseRequest]]"] related_apis: ["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. - **MongoDB** — `payments` document holds the canonical `escrowState`. ## Escrow state machine (`Payment.escrowState`) Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed`. ```mermaid 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 Failed --> Releasing: admin retries Released --> [*] Refunded --> [*] ``` `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.status` → `payment`, then `processing` once the seller acknowledges; `SellerOffer.status` → `accepted`; 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 = ` - Cascade: `PurchaseRequest.status` → `seller_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.status` → `cancelled` (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) ```mermaid 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) ```mermaid 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 payment** → `escrowState` 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 - [[Payment Flow - SHKeeper]] — funds the escrow. - [[Payment Flow - DePay & Web3]] — alternative funding path. - [[Delivery Confirmation Flow]] — triggers release. - [[Dispute Flow]] — can divert to refund. - [[Payout Flow]] — executes the release transfer. ## 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`