Files
nick-doc/04 - Flows/Escrow Flow.md
Siavash Sameni a1f056e6a5 docs: align flow docs with code reality + create 35 implementation issue files
Flow docs updated (11 files):
- Delivery Confirmation: reversed actor roles (buyer generates, seller verifies),
  fixed endpoint paths (/delivery-code/generate, /delivery-code/verify)
- Passkey (WebAuthn): removed stub/simulated-key claims; real @simplewebauthn/server
  attestation is implemented; refresh tokens are persisted
- Dispute: corrected resolve schema (action enum), removed non-existent statuses,
  documented security gaps (no role guards on status/resolve/assign), route shadowing,
  all socket events are TODO stubs
- Seller Offer: corrected all endpoint paths, removed 'active' status, documented
  withdraw dead code, missing seller history page, select-offer notification gap
- Notification: corrected mark-all-read method+path, fixed GET /:id broken lookup,
  added unread-count-update socket event
- Authentication: corrected rate limiter (counts all attempts), axios 403 not handled,
  deleteAccount wrong endpoint bug, changePassword no UI
- Password Reset: corrected 6-digit code (not 8), documented no-complexity gap on
  reset-with-code vs token reset
- Payment Flow DePay: /create→/save, removed phantom sub-routes, SIM_ bypass risk,
  PaymentProvider type gap, getProviderIntentEndpoint routing bug
- Payment Flow SHKeeper: removed phantom polling endpoint, fixed release/refund paths
- Purchase Request: added pending_payment/active statuses, fixed sellers/attachments
  endpoints, corrected socket events, PUT→PATCH bug
- Escrow: documented dispute resolve does not touch escrow, route shadowing, confirm-delivery auth gap

Issues created (35 files in Issues/):
- 9 security issues (critical) including: dispute privilege escalation ×4,
  unauthenticated payment/scanner endpoints ×2, SIM_ production bypass,
  confirm-delivery ownership gap
- 26 additional major/critical bugs covering broken endpoints, missing features,
  data integrity gaps, and frontend-backend mismatches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 14:47:49 +04:00

14 KiB

title, tags, related_models, related_apis
title tags related_models related_apis
Escrow Flow
flow
escrow
payment
state-machine
custody
Payment
PurchaseRequest
Funds Ledger and Escrow State Machine Specification
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.

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

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

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)

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

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