10 KiB
title, tags, related_models, related_apis
| title | tags | related_models | related_apis | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| Escrow Flow |
|
|
|
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 —
paymentsdocument holds the canonicalescrowState.
Escrow state machine (Payment.escrowState)
Enum from Payment.ts:112-115: funded | releasable | released | refunded | releasing | failed.
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→ escrowfundedand 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 (verifiedeth_getTransactionReceipt). - Backend sets
Payment.status = "completed"andPayment.escrowState = "funded"(shkeeperWebhook.ts:388-391,shkeeperService.ts:600-602). - Cascade:
PurchaseRequest.status→payment, thenprocessingonce 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 inprocessing/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 infrontend/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 —
PurchaseRequestServiceexposes status transitions through tocompleted). - Admin manual release from the admin payment detail view.
- The system marks
Payment.escrowState = "releasable"(intermediate). shkeeperPayoutService.createPayoutTask(or a manual EVM admin signature viaadmin-wallet-payout.tsx) starts the on-chain transfer to the seller's verified wallet address. State flips toreleasing.- 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_paidthencompleted.
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 ispayment.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)
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:statuscascades (payment → processing → delivery → delivered → confirming → seller_paid → completed).notifications: created on each terminal state.
Socket events emitted
purchase-request-updatestatus-changedon 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.solpattern) 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
recipientAddressshape (^0x[0-9a-fA-F]{40}$) before signing (shkeeperPayoutService.ts:62-64checks.startsWith('0x')). - Partial payment (
PARTIAL) → escrow remains inpending/partial; release blocked until full payment arrives. - Overpaid → currently treated as
completed/funded; the surplus is not auto-refunded. - Concurrent release + refund → blocked by
PaymentCoordinatorserialisation; whichever fires first wins, the other is rejected. - Payout fails on chain → state stays in
releasinguntil admin re-runs; consider auto-retry with exponential backoff. - Disputed payment →
escrowStateis not auto-changed when a dispute is opened. Admin must explicitly resolve to refund/release. Add adisputedboolean orescrowState='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.escrowStatelooks stale (e.g.releasedbut no on-chain tx hash), inspect withPayment.find({ escrowState: 'released', 'blockchain.transactionHash': { $exists: false } })and reconcile via the SHKeeper invoice or thefix-transaction-hashes.jsscript.
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