docs: sync from backend 19f7eb9, frontend 60ee6fb — Task #10 AML screening

This commit is contained in:
Siavash Sameni
2026-05-28 20:35:38 +04:00
parent fd2aa71ef4
commit ddc0434819
34 changed files with 709 additions and 453 deletions

View File

@@ -1,199 +1,226 @@
---
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"]
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"]
---
# 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.
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
- **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`.
- **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.
## Escrow state machine (`Payment.escrowState`)
## Current State Model
Enum from `Payment.ts:112-115`: `funded | releasable | released | refunded | releasing | failed | cancelled | partial`.
`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.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
Pending --> Cancelled: webhook EXPIRED/CANCELLED
escrowState="cancelled"
Failed --> Releasing: admin retries
[*] --> 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 --> [*]
```
`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
## 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.
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 `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`).
While escrow is funded, funds are represented in two places:
### 3. Releasing (happy path)
- **On chain:** in the derived destination or custody wallet until swept/released/refunded.
- **In app accounting:** in `FundsLedgerEntry` rows and `Payment.escrowState`.
- 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`.
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.
### 4. Refunding (dispute / cancellation)
### 3. Release
- 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).
Release is triggered by delivery confirmation, auto-release policy, or dispute resolution for the seller.
### 5. Failed payout
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.
- If the payout tx reverts (insufficient gas, contract pause, wrong address), `escrowState = 'failed'`. Admin can retry by initiating a fresh payout.
### 4. Refund
## Sequence diagram (release path)
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.
## Sequence Diagram - Funding
```mermaid
sequenceDiagram
autonumber
actor B as Buyer
actor A as Admin
participant FE as Frontend
participant BE as Backend
participant RN as Request Network
participant BC as EVM Chain
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"
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 (refund path)
## 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 BSC
actor B as Buyer
participant BC as EVM Chain
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
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"
```
## API calls
## 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 |
| `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 |
## Database writes
## Side Effects And Risks
- **`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.
- **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.
## Socket events emitted
## Linked Flows
- **`purchase-request-update`** `status-changed` on every cascading status flip.
- **`payment-status`** (planned/admin) — admin dashboard real-time feed.
- [[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.
## Side effects
## Source Files
- **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`
- 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`