Initial commit: nick docs
This commit is contained in:
196
04 - Flows/Escrow Flow.md
Normal file
196
04 - Flows/Escrow Flow.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
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 = <payout tx hash>`
|
||||
- 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`
|
||||
Reference in New Issue
Block a user