--- title: Escrow Flow tags: [flow, escrow, payment, state-machine, custody] related_models: ["[[Payment]]", "[[PurchaseRequest]]", "[[Funds Ledger and Escrow State Machine Specification]]"] related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/refund", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund/confirm"] --- > [!warning] Audit — 2026-05-29 > This document was corrected against the live codebase. Key changes: `POST /api/disputes/:id/resolve` clarified as Dispute-document-only — it does NOT move escrow funds; route shadowing between the two dispute routers documented; `confirm-delivery` authorization gap flagged. # Escrow Flow The current escrow is a **hybrid custody system**, not a custom Solidity escrow contract. Buyer funds move on-chain through Request Network-compatible wallet transactions. The backend verifies the payment through signed Request Network webhooks/reconciliation plus the Transaction Safety Provider, records state in `Payment`, and records money movement in the internal funds ledger. Release/refund/sweep actions are still administered by the platform, with optional Trezor proof today and a recommended move to Safe multisig custody in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]]. ## Actors - **Buyer** -- pays from their wallet and confirms delivery. - **Seller** -- fulfills the order and receives release. - **Admin / mediator** -- resolves disputes and initiates release/refund when manual action is required. - **Custody signer** -- Trezor today when enabled; target state is Safe multisig owners. - **Request Network** -- emits payment evidence through signed webhooks and status APIs. - **Transaction Safety Provider** -- verifies tx hash, confirmations, recipient, token, amount, and optional AML decision before funds are credited. - **MongoDB** -- stores `Payment`, `FundsLedgerEntry`, `Dispute`, and `PurchaseRequest` state. ## Current State Model `Payment.status` remains the coarse provider/business state: - `pending` - `processing` - `confirmed` - `completed` - `failed` - `cancelled` - `refunded` `Payment.escrowState` currently supports: - `funded` - `releasable` - `releasing` - `released` - `refunded` - `failed` - `cancelled` - `partial` The current model also has `Payment.disputed`, `disputeHoldReason`, and `holdUntil`. The canonical target state machine in [[Funds Ledger and Escrow State Machine Specification]] adds explicit `DISPUTED`, `REFUNDING`, and normalized uppercase enums. Treat that spec as the destination; this page describes the live hybrid implementation. ```mermaid stateDiagram-v2 [*] --> Pending : payment intent created Pending --> Processing : funds detected / webhook received Pending --> Cancelled : intent expired or buyer cancels Processing --> Funded : Transaction Safety Provider approved Processing --> Failed : verification rejected Funded --> Releasable : delivery confirmed / release authorized Funded --> DisputeHold : dispute opened Releasable --> DisputeHold : dispute opened before payout DisputeHold --> Funded : dispute rejected / no financial action DisputeHold --> Releasable : resolved for seller DisputeHold --> Refunding : resolved for buyer Releasable --> Releasing : release instruction built Releasing --> Released : tx hash confirmed Releasing --> Failed : payout failed Refunding --> Refunded : refund tx hash confirmed Refunding --> Failed : refund failed Failed --> Releasing : admin retries release Failed --> Refunding : admin retries refund Released --> [*] Refunded --> [*] Cancelled --> [*] ``` ## Step-by-step Narrative ### 1. Funding 1. Buyer accepts a seller offer and starts Request Network checkout. 2. Backend creates a `Payment` and Request Network intent through `requestNetworkPayInService.ts`. 3. When configured, `getDestinationFor({ buyerId, sellerOfferId, chainId })` assigns a per-payment derived destination and stores it in `payment.metadata.derivedDestination`. 4. Frontend renders the in-house checkout block and the buyer signs RN-compatible on-chain transactions from their wallet. 5. Request Network webhook or reconciliation reports payment evidence. 6. The Transaction Safety Provider verifies: - transaction hash exists, - chain confirmations meet the runtime/env threshold, - token, recipient, and amount match, - AML/sanctions provider result when configured. 7. Only after safety approval does the backend mark the payment funded and append ledger entries. ### 2. Holding While escrow is funded, funds are represented in two places: - **On chain:** in the derived destination or custody wallet until swept/released/refunded. - **In app accounting:** in `FundsLedgerEntry` rows and `Payment.escrowState`. Release/refund eligibility must be derived from ledger availability, not raw mutable `Payment.status` alone. In production the roadmap requires `PAYMENT_LEDGER_ENFORCEMENT=true` before custody decentralization. ### 3. Release Release is triggered by delivery confirmation, auto-release policy, or dispute resolution for the seller. 1. Admin calls `POST /api/payment/:id/release`. 2. Backend loads the payment and validates ledger availability when enforcement is enabled. 3. Backend builds a provider payment instruction. 4. Custody signer executes the transaction: - current optional control: Trezor proof when `TREZOR_SAFEKEEPING_REQUIRED=true`; - roadmap control: Safe multisig transaction proposal/execution. 5. Admin confirms with `POST /api/payment/:id/release/confirm` and tx hash. 6. Backend validates Trezor proof when required, confirms adapter state, and appends a `release` ledger entry. ### 4. Refund Refund follows the same instruction/confirmation pattern as release, but destination is the buyer/refund wallet and ledger entry type is `refund`. Refund can be triggered by dispute resolution for the buyer, pre-fulfillment cancellation, or an admin/manual recovery flow. A refund during an active dispute must be an explicit resolution path, not an accidental bypass. ### 5. Dispute Hold Opening a dispute now has backend support through `releaseHoldService.ts`: it sets hold fields on the related purchase request and payments, and release/refund gates consult those holds. Remaining alignment work: - migrate from legacy dispute status enum to the canonical spec, - make financial side effects automatic from final dispute resolution, - ensure every release/refund path calls the same policy service, - record immutable audit entries for dispute resolution and custody execution. ### 6. Dispute Resolution and Escrow Funds > [!warning] Two different handlers share the same path — they do different things > > There are **two dispute routers** both mounted at `/api/disputes`. This creates route shadowing: > > | Handler | What it does | > |---|---| > | Dashboard dispute router: `POST /api/disputes/:id/resolve` | Updates the **Dispute document only** — changes dispute status, records resolution notes, etc. **Does NOT touch escrow funds.** | > | releaseHold router: `POST /api/disputes/:purchaseRequestId/resolve` | Unblocks escrow — removes the dispute hold from the `Payment` and `PurchaseRequest`, making the escrow eligible for release or refund. | > > Because the dashboard router is mounted first, a `POST /api/disputes/{id}/resolve` request will be handled by the dashboard router's `POST /:id/resolve` handler if the supplied ID matches a dispute document ID. If the intent is to unblock escrow funds, the correct target is the releaseHold router, but route registration order means the dashboard router intercepts the call first. This is a **route shadowing bug** — both routers claim the same URL pattern and the outcome depends entirely on registration order. > > In practice: calling `POST /api/disputes/:id/resolve` alone is **not sufficient to release or refund escrow**. The escrow unblock is only guaranteed when the releaseHold handler is reached. Verify router mount order in `backend/src/services/dispute/` before relying on either path in automation or admin tooling. ### 7. Delivery Confirmation Authorization Gap > [!warning] ⚠️ Known authorization gap — `confirm-delivery` > > The `PATCH /api/marketplace/purchase-requests/:id/confirm-delivery` endpoint has **no authorization guard**. Any authenticated user (not just the buyer who owns the request) can call this endpoint and advance the purchase request status to `delivered`. This is a known gap and should be remediated by adding an ownership check (`req.user._id === purchaseRequest.buyerId`) before processing the status transition. ## Sequence Diagram - Funding ```mermaid sequenceDiagram autonumber actor B as Buyer participant FE as Frontend participant BE as Backend participant RN as Request Network participant BC as EVM Chain participant DB as MongoDB B->>FE: Start Request Network checkout FE->>BE: POST /api/payment/request-network/intents BE->>DB: Payment.create(status="pending") BE->>BE: Assign derived destination when configured BE->>RN: Create Request Network intent BE-->>FE: inHouseCheckout block B->>BC: approve + transferFromWithReferenceAndFee RN-->>BE: signed webhook / status evidence BE->>BE: Transaction Safety Provider checks BE->>DB: Payment.status="completed", escrowState="funded" BE->>DB: append FundsLedgerEntry(payment_detected / hold) ``` ## Sequence Diagram - Release / Refund ```mermaid sequenceDiagram autonumber actor A as Admin actor C as Custody signer participant BE as Backend participant DB as MongoDB participant BC as EVM Chain A->>BE: POST /api/payment/{id}/release or refund BE->>DB: Load Payment + ledger balance BE->>BE: Check dispute hold + ledger availability BE-->>A: unsigned instruction A->>C: Request signature / Safe execution C->>BC: Broadcast tx BC-->>C: txHash A->>BE: POST /confirm { txHash, optional trezor proof } BE->>BE: Verify signer proof when required BE->>DB: append release/refund ledger entry BE->>DB: escrowState="released" or "refunded" ``` ## Sequence Diagram - Dispute Resolution (Escrow Path) ```mermaid sequenceDiagram autonumber actor A as Admin / Mediator participant DR as Dashboard Dispute Router\n(POST /api/disputes/:id/resolve) participant RH as releaseHold Router\n(POST /api/disputes/:purchaseRequestId/resolve) participant DB as MongoDB participant ES as Escrow / Payment Note over DR,RH: Both routers mounted at /api/disputes — dashboard router registered first A->>DR: POST /api/disputes/{disputeId}/resolve DR->>DB: Update Dispute document (status, notes) DR-->>A: 200 OK (Dispute updated only) Note over ES: Escrow funds still on hold at this point A->>RH: POST /api/disputes/{purchaseRequestId}/resolve RH->>DB: Remove hold from Payment + PurchaseRequest RH->>ES: Escrow now eligible for release or refund RH-->>A: 200 OK (Hold removed) ``` ## API Calls | Method | Endpoint | Purpose | |---|---|---| | `POST` | `/api/payment/request-network/intents` | Create Request Network pay-in intent | | `GET` | `/api/payment/request-network/:paymentId/checkout` | Rehydrate in-house checkout block | | `POST` | `/api/payment/request-network/webhook` | Receive signed RN webhook | | `POST` | `/api/payment/:id/release` | Build release instruction | | `POST` | `/api/payment/:id/release/confirm` | Confirm release tx hash / signer proof | | `POST` | `/api/payment/:id/refund` | Build refund instruction | | `POST` | `/api/payment/:id/refund/confirm` | Confirm refund tx hash / signer proof | | `GET` | `/api/payment/:id` | Read payment details | | `GET` | `/api/payment/derived-destinations` | Admin list of derived destinations | | `POST` | `/api/disputes/:id/resolve` | Update Dispute document only — does NOT touch escrow | | `POST` | `/api/disputes/:purchaseRequestId/resolve` | Remove dispute hold from escrow (releaseHold router) — see shadowing note above | ## Side Effects And Risks - **No custom on-chain escrow contract yet.** This is deliberate; [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] recommends Safe/Trezor custody controls before a custom contract pilot. - **Ledger enforcement is configurable.** `PAYMENT_LEDGER_ENFORCEMENT` must be enabled before real custody decentralization work is considered complete. - **Trezor enforcement is configurable.** `TREZOR_SAFEKEEPING_REQUIRED=true` makes Trezor proof mandatory for release/refund confirmation, but target custody should be Safe multisig. - **Durable webhook ingress is still roadmap work.** Until the Worker/replay layer is live, backend availability remains important for Request Network webhook delivery. - **Dispute model is implemented but not fully canonical.** The current model works with legacy enum names; canonical status alignment remains required. - **Route shadowing on `/api/disputes`** — two routers registered at the same mount point. Dashboard router intercepts first; releaseHold handler may not be reachable by the expected URL in all configurations. See section 6 above. - **`confirm-delivery` has no authorization guard** — any authenticated user can advance a purchase request to `delivered`. See section 7 above. ## Linked Flows - [[PRD - Request Network In-House Checkout]] -- current primary pay-in path. - [[Dispute Flow]] -- can block or redirect escrow. - [[Delivery Confirmation Flow]] -- happy-path release trigger. - [[Payout Flow]] -- historical payout context and release mechanics. - [[Trezor Safekeeping Flow]] -- hardware proof for admin actions. - [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- custody decentralization and smart-contract decision plan. ## Source Files - Backend: `backend/src/models/Payment.ts` - Backend: `backend/src/models/FundsLedgerEntry.ts` - Backend: `backend/src/services/payment/requestNetwork/requestNetworkPayInService.ts` - Backend: `backend/src/services/payment/safety/transactionSafetyProvider.ts` - Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts` - Backend: `backend/src/services/payment/wallets/derivedDestinations.ts` - Backend: `backend/src/services/payment/wallets/sweepService.ts` - Backend: `backend/src/services/dispute/releaseHoldService.ts` - Backend: `backend/src/services/trezor/trezorService.ts`