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

@@ -50,7 +50,7 @@ stateDiagram-v2
- Re-loads with `populate('participants.userId', 'firstName lastName profile.avatar email')` for the response.
3. **Group chat (dispute)** — same pattern, but `type: 'group'`, all three participants (buyer, seller, admin) added (admin is added later by `DisputeService.assignAdmin`).
4. **Support chat**`ChatService.createSupportChat(userId)` (`:41-88`) auto-discovers `User.findOne({ email: 'support@amn.gg' })` and creates a `type: 'support'` chat with a welcome message. Idempotent.
5. **Post-payment auto-chat** — when SHKeeper confirms payment, `shkeeperWebhook.ts:606-618` calls `chatService.createChat` to ensure a direct chat exists between buyer and winning seller.
5. **Post-payment auto-chat** — when payment is confirmed, the payment-state cascade ensures a direct chat exists between buyer and winning seller.
### Joining the room (real-time)

View File

@@ -7,7 +7,7 @@ related_apis: ["POST /api/marketplace/purchase-requests/:id/delivery-code", "POS
# Delivery Confirmation Flow
After the escrow is funded ([[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
After the escrow is funded ([[PRD - Request Network In-House Checkout]] / [[Escrow Flow]]) and the seller has prepared the item, the seller **marks shipped**, the buyer **enters a delivery code** to confirm receipt, and the escrow becomes eligible for release ([[Payout Flow]]).
## Actors
@@ -113,7 +113,7 @@ sequenceDiagram
## Linked flows
- [[Payment Flow - SHKeeper]] / [[Payment Flow - DePay & Web3]] — funding precondition.
- [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — funding precondition.
- [[Escrow Flow]] — state transitions triggered by confirmation.
- [[Payout Flow]] — fires after confirmation (manual today).
- [[Dispute Flow]] — escape hatch.

View File

@@ -15,9 +15,9 @@ When something goes wrong (item not delivered, wrong item, fraud), either party
- **Seller** — party against whom the dispute is raised (or in rarer cases, initiator).
- **Admin / Mediator** — assigned to investigate.
- **Frontend** — buyer/seller "Report issue" buttons in the request detail view; admin dispute dashboard.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts` *(planned)*), `DisputeController` (`backend/src/controllers/disputeController.ts` *(planned)*), routes at `backend/src/routes/disputeRoutes.ts` *(planned)*.
> [!warning] Not implemented
> None of these files exist as of 2026-05-24. The dispute module is planned but not yet built.
- **Backend** — `DisputeService` (`backend/src/services/dispute/DisputeService.ts`), dashboard/controller routes at `backend/src/routes/disputeRoutes.ts`, and release-hold helpers in `backend/src/services/dispute/releaseHoldService.ts`.
> [!note] Alignment gap
> The module exists now, but it still uses the legacy status/action enum. [[Funds Ledger and Escrow State Machine Specification]] defines the canonical future dispute states and financial side effects.
- **MongoDB** — `disputes`, `chats`, `purchaserequests`, `payments`.
- **Socket.IO** — `new-notification`, `new-message`, `dispute-updated` (planned).
@@ -57,8 +57,8 @@ Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(in
- Persists `dispute.chatId = chat._id`.
5. Notifications (currently a `TODO` in the service — `:107-116`) should fire `new-notification` to the seller. Today the chat creation alone provides real-time presence via the `new-message` socket emit inside `Chat.create`'s lifecycle.
> [!warning] Dispute does not auto-pause escrow
> Today, opening a dispute does **not** flip `Payment.escrowState` away from `funded`. An admin could theoretically still release the escrow before resolving the dispute. Until a `disputed` flag is added to Payment, admins must check the dispute table before any release/refund action.
> [!note] Release hold behavior
> Opening a dispute now has backend release-hold support: `releaseHoldService.raiseDispute()` sets hold fields on the purchase request and related payments, and release/refund gates can consult those fields. The remaining work is to make this the single mandatory policy path for every release/refund/sweep operation and align it with the canonical `DISPUTED` escrow state.
### Phase 2 — Admin assignment
@@ -84,7 +84,7 @@ Resolution actions (from `Dispute.resolution.action` enum, see `Dispute.ts` *(in
- `dispute.closedAt = now`
- Appends `timeline` entry `dispute_resolved`.
- Saves.
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **payout** ([[Payout Flow]] with `kind: 'release'`) or the **refund** (`kind: 'refund'`, see [[Escrow Flow]]). The dispute service does not automatically dispatch the on-chain action.
13. **Financial side-effect (manual today):** depending on the action, the admin then triggers either the **release** ([[Payout Flow]] / [[Escrow Flow]]) or the **refund**. The dispute service records the resolution; full automatic dispatch through the release/refund policy engine is still a hardening item.
14. Both parties are notified (TODOs in code — planned: `notifyDisputeResolved`).
## Sequence diagram
@@ -179,7 +179,7 @@ All require `authenticateToken` (router-level middleware).
- **Initiator is neither buyer nor seller** → not enforced at service level — should be validated in `DisputeController` (recommended hardening).
- **Same user opens multiple disputes for the same request** → no uniqueness constraint today. Consider adding `unique on (purchaseRequestId, status:'pending'|'in_progress')` to prevent duplicates.
- **Evidence URL is hot-linked** → frontend uploads through `POST /api/files/upload` and the URL is served from `/uploads`. Ensure auth on the upload endpoint to prevent random users from polluting evidence.
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state. Add automation that auto-fires the payout/refund when the admin selects `release` or `refund`.
- **Dispute resolved without financial follow-up** → the dispute is "resolved" in record only; the escrow stays in its previous state until the admin/custody operator completes release/refund. Add automation that dispatches the policy-checked release/refund instruction when the admin selects a financial resolution.
- **Admin resigns mid-dispute** → no transfer-of-mediator endpoint today. Add `POST /api/disputes/:id/reassign`.
> [!tip] Sort disputes by priority + age
@@ -195,12 +195,10 @@ All require `authenticateToken` (router-level middleware).
## Source files
> [!warning] Not implemented
> None of the backend files below exist as of 2026-05-24. The dispute module is planned but not yet built.
- Backend: `backend/src/services/dispute/DisputeService.ts` *(planned)*
- Backend: `backend/src/controllers/disputeController.ts` *(planned)*
- Backend: `backend/src/routes/disputeRoutes.ts` *(planned)*
- Backend: `backend/src/models/Dispute.ts` *(planned)*
- Backend: `backend/src/services/dispute/DisputeService.ts`
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Backend: `backend/src/routes/disputeRoutes.ts`
- Backend: `backend/src/services/dispute/disputeRoutes.ts`
- Backend: `backend/src/models/Dispute.ts`
- Frontend: `frontend/src/sections/request/components/report-problem-to-admin.tsx`
- Frontend: admin dispute dashboard under `frontend/src/sections/admin/` (subject to organisation)

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`

View File

@@ -29,7 +29,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
1. **Open negotiation chat** — when a buyer first clicks "Chat with seller" on an offer card, the frontend calls `POST /api/chat` to find-or-create a `direct` chat tied to the purchase request (`ChatService.createChat`, `chat.ts:90-192`). The chat's `relatedTo = { type: 'PurchaseRequest', id }` makes it discoverable from the request view.
> [!tip] Pre-payment chats vs. post-payment chats
> A negotiation chat may exist **before** the SHKeeper webhook auto-creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates the same chat object is reused.
> A negotiation chat may exist **before** payment confirmation creates the post-payment chat. The `ChatService.createChat` `direct` find-or-create logic (`ChatService.ts:95-108`) prevents duplicates -- the same chat object is reused.
2. **Status flip to `in_negotiation`** — the first message in the negotiation chat triggers a backend hook (or a manual frontend PATCH) that calls `PurchaseRequestService.updatePurchaseRequest` with `{ status: 'in_negotiation' }`. The status-progression guard allows this (`received_offers → in_negotiation`).
@@ -41,7 +41,7 @@ After an offer is submitted ([[Seller Offer Flow]]), the buyer and seller can ne
- `SellerOffer.findByIdAndUpdate(id, { ...updateData, updatedAt: now }, { new: true })`.
- Emits `purchase-request-update` with `eventType: 'offer-updated'` to `request-{requestId}` (`SellerOfferService.ts:284-288`) — both parties' open tabs refresh.
5. **Buyer accepts** clicks "Accept this offer", which kicks off [[Payment Flow - SHKeeper]] with the (now-updated) `sellerOfferId`. The webhook flips offer `accepted` and request `payment`.
5. **Buyer accepts** -- clicks "Accept this offer", which kicks off [[PRD - Request Network In-House Checkout]] with the selected `sellerOfferId`. Payment confirmation flips offer -> `accepted` and request -> `payment`.
6. **Buyer rejects** — calls `PATCH /api/marketplace/offers/{id}` with `{ status: 'rejected' }`. `SellerOfferService.updateOfferStatus` (`:306-353`) sends `notifyOfferRejected` to the seller and stamps `rejectedAt` + `rejectionReason`.
@@ -80,7 +80,7 @@ sequenceDiagram
BE->>IO: emit request-{id} 'purchase-request-update' (offer-updated)
IO-->>FE_B: refresh offer card
alt Buyer accepts
B->>FE_B: Click "Pay" [[Payment Flow - SHKeeper]]
B->>FE_B: Click "Pay" -> [[PRD - Request Network In-House Checkout]]
Note over BE: Webhook PAID flips offer→accepted, request→payment
else Buyer rejects
B->>FE_B: Click "Reject"
@@ -135,7 +135,7 @@ sequenceDiagram
## Linked flows
- [[Seller Offer Flow]] — the prior step.
- [[Payment Flow - SHKeeper]] — closes the negotiation with an on-chain payment.
- [[PRD - Request Network In-House Checkout]] — closes the negotiation with an on-chain payment.
- [[Chat Flow]] — message-level mechanics, attachments, read receipts.
- [[Notification Flow]] — accept/reject notifications.

View File

@@ -7,7 +7,10 @@ related_apis: ["POST /api/payment/decentralized/create", "POST /api/payment/dece
# Payment Flow — DePay & Web3 (Wallet-Direct)
Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]], the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
> [!warning] Historical/legacy path
> This page describes the older wallet-direct payment path. The current primary checkout is [[PRD - Request Network In-House Checkout]] with Request Network metadata, derived destinations, and Transaction Safety Provider checks. Keep this page for migration and verification context only.
Legacy alternative pay-in path: the buyer connects their own wallet (MetaMask / WalletConnect / Coinbase Wallet) and signs an **on-chain transfer to the escrow wallet** directly. The backend then verifies the transaction against the BSC RPC.
## Actors
@@ -16,8 +19,8 @@ Alternative pay-in path: instead of routing through [[Payment Flow - SHKeeper]],
- **Wagmi / WalletConnect / MetaMask** — wallet stack.
- **Backend** — `decentralizedPaymentService.ts` (intent), `BSCTransactionVerifier` (on-chain verification), `decentralizedPaymentRoutes.ts`.
- **Blockchain (BSC)** — verified via `https://bsc-dataseed.binance.org/` JSON-RPC.
- **MongoDB** — `payments` collection (same model as SHKeeper, different `provider` value).
- **Socket.IO** — `payment-created`, plus the cascade events from [[Payment Flow - SHKeeper]] when verification succeeds.
- **MongoDB** — `payments` collection, with `provider` distinguishing the legacy wallet-direct source from Request Network.
- **Socket.IO** — `payment-created`, plus the funded-escrow cascade events when verification succeeds.
## Preconditions
@@ -132,7 +135,7 @@ sequenceDiagram
## Side effects
- **No SHKeeper involvement** — the escrow wallet is custodial; the platform admin holds the keys. Payouts from this wallet to sellers happen via [[Payout Flow]] (SHKeeper payouts API) or manual admin signing using `admin-wallet-payout.tsx` UI.
- **No provider custody** — the escrow wallet is custodial; the platform admin/custody signer controls the keys. Releases from this wallet to sellers should follow [[Payout Flow]] and the Safe/hardware-backed roadmap in [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
- **Verified-wallet field** — the buyer's connected wallet is also saved against `User.profile.walletAddress` (in `WalletConnectionCard`), which is later used to refund this same wallet if the order is disputed.
## Error / edge cases
@@ -152,7 +155,8 @@ sequenceDiagram
## Linked flows
- [[Payment Flow - SHKeeper]] — sibling pay-in path; same downstream cascade.
- [[PRD - Request Network In-House Checkout]] — current primary checkout.
- [[Payment Flow - SHKeeper]] — historical sibling pay-in path retained for migration context.
- [[Escrow Flow]] — funded state semantics.
- [[Payout Flow]] — releasing the funded escrow to the seller.
- [[Dispute Flow]] — refunds back to the buyer's verified wallet.

View File

@@ -7,6 +7,9 @@ related_apis: ["POST /api/payment/shkeeper/create", "POST /api/payment/shkeeper/
# Payment Flow — SHKeeper (Crypto Pay-In)
> [!warning] Historical migration document
> This page describes the older SHKeeper pay-in rail. It is retained for migration/reconciliation context only. The current primary pay-in path is [[PRD - Request Network In-House Checkout]], and the current escrow/custody model is [[Escrow Flow]] plus [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
End-to-end **crypto pay-in** via the self-hosted [SHKeeper](https://github.com/vsys-host/shkeeper.io) gateway at `pay.amn.gg`. The buyer pays in stablecoins/crypto; SHKeeper monitors the blockchain, sends webhooks back, and the backend marks the escrow funded.
## Supported assets

View File

@@ -1,133 +1,130 @@
---
title: Payout Flow
tags: [flow, payment, payout, shkeeper, seller]
related_models: ["[[Payment]]"]
related_apis: ["POST /api/payment/shkeeper/payout", "GET /api/payment/shkeeper/payout/:taskId"]
tags: [flow, payment, payout, release, refund, custody]
related_models: ["[[Payment]]", "[[Funds Ledger and Escrow State Machine Specification]]"]
related_apis: ["POST /api/payment/:id/release", "POST /api/payment/:id/release/confirm", "POST /api/payment/:id/refund", "POST /api/payment/:id/refund/confirm"]
---
# Payout Flow
How the **seller receives the escrowed crypto** once the order is complete. Two variants are implemented:
This page describes how escrowed funds leave Amanat custody after an order is complete or a dispute is resolved.
1. **SHKeeper Payouts API** (`shkeeperPayoutService.ts`) — the gateway signs and broadcasts on behalf of the platform.
2. **Manual admin wallet payout** (`admin-wallet-payout.tsx`) — an admin connects their own wallet and signs the transfer; the tx hash is reported back to the backend.
The current flow is no longer SHKeeper payout-task centric. Release and refund are instruction-based:
Both result in `Payment.escrowState = 'released'` and an outgoing `Payment` record with `direction: 'out'`.
1. Backend validates policy, dispute hold, and ledger availability.
2. Backend builds a release/refund instruction.
3. A custody signer executes the on-chain transaction.
4. Backend confirms the tx hash and appends the ledger entry.
Today the custody signer can be an admin/Trezor path when enabled. The roadmap target is Safe multisig execution before any custom escrow contract pilot. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]].
## Actors
- **Admin** (or scheduled system trigger) — initiates the payout.
- **Seller** — recipient, has saved their wallet address under `User.profile.walletAddress`.
- **Backend** — `shkeeperPayoutService.createPayoutTask` and the manual confirmation routes.
- **SHKeeper Payouts API** — `POST https://pay.amn.gg/api/v1/payout` (per SHKeeper docs).
- **Blockchain (BSC)** — final on-chain settlement.
- **MongoDB** — separate `Payment` document with `direction: 'out'`.
- **Admin / mediator** -- initiates release/refund after delivery confirmation or dispute resolution.
- **Custody signer** -- Trezor proof today when enabled; target state is Safe multisig owners.
- **Seller** -- recipient for release.
- **Buyer** -- recipient for refund.
- **Backend** -- `releaseRefundService.ts`, payment adapter, ledger service, Trezor service.
- **Blockchain** -- final on-chain settlement.
- **MongoDB** -- `Payment` and `FundsLedgerEntry`.
## Preconditions
- The original pay-in `Payment` has `escrowState = 'funded'` (or `releasable`).
- The seller has set `profile.walletAddress` (validated `^0x...` format).
- The corresponding `PurchaseRequest` is in a status that allows payout (`delivered`, `confirming`, `seller_paid`, or `completed`).
- The pay-in `Payment` is funded or releasable.
- The release/refund amount is positive and does not exceed available ledger balance.
- No active dispute hold blocks the operation, unless the operation is the explicit dispute resolution path.
- Recipient wallet is known and verified.
- If `TREZOR_SAFEKEEPING_REQUIRED=true`, the confirm step includes the expected Trezor operation signature.
- Production target: Safe multisig execution is required for custody movement.
## Step-by-step narrative
## Release Narrative
### SHKeeper-mediated payout
1. Buyer confirms delivery, an auto-release policy matures, or a dispute resolves for the seller.
2. Admin calls `POST /api/payment/:id/release` with optional partial amount.
3. Backend loads the `Payment`, validates ledger availability when `PAYMENT_LEDGER_ENFORCEMENT=true`, and returns an instruction payload.
4. Custody signer broadcasts the seller payment transaction.
5. Admin calls `POST /api/payment/:id/release/confirm` with `txHash` and optional Trezor proof.
6. Backend verifies signer proof when required, confirms adapter state, appends a `release` ledger entry, and marks escrow released.
1. Admin (or the auto-release scheduler — not yet implemented) hits `POST /api/payment/shkeeper/payout` with `{ purchaseRequestId, sellerOfferId, buyerId, sellerId, amount, recipientAddress, token?, network? }`.
2. Backend `shkeeperPayoutService.createPayoutTask` (`shkeeperPayoutService.ts:40-150`):
- Validates ObjectIds and the `recipientAddress` (`startsWith('0x')`).
- **Idempotency**: `Payment.findOne({ purchaseRequestId, sellerOfferId, sellerId, provider:'shkeeper', direction:'out', status: { $in:['pending','processing','completed'] } })` — if found, reuses it.
- Creates a new `Payment` document with `direction: 'out'`, `escrowState: 'releasing'`, `blockchain.receiver = recipientAddress`.
- Calls SHKeeper Payouts API (`POST /api/v1/payout`) with the body documented at <https://shkeeper.io/api/#tag/Payouts>. SHKeeper returns a `task_id`.
- Stores `Payment.providerPaymentId = task_id`, `metadata.shkeeperTaskId = task_id`, `metadata.payoutType = 'seller-payment'`.
3. Polling or webhook: when SHKeeper completes the payout, it pushes a webhook (or the backend polls `GET /api/v1/payout/{task_id}`) and the system flips `Payment.status = 'completed'`, `escrowState = 'released'`, populates `blockchain.transactionHash`.
4. The original pay-in `Payment` is updated in tandem: `escrowState = 'released'`, `PurchaseRequest.status = 'seller_paid'``completed`.
5. Notifications: `notifyPayoutSent` to the seller, internal admin log.
## Refund Narrative
### Manual admin payout
1. Dispute resolves for the buyer, order is cancelled before fulfillment, or support executes an approved recovery.
2. Admin calls `POST /api/payment/:id/refund`.
3. Backend validates available funds and policy.
4. Custody signer broadcasts the refund transaction.
5. Admin calls `POST /api/payment/:id/refund/confirm` with `txHash` and optional Trezor proof.
6. Backend appends a `refund` ledger entry and marks escrow refunded.
1. Admin opens the request detail in the admin view; the admin-step component `admin-wallet-payout.tsx` shows the recipient and amount.
2. Admin connects their wallet (`useWeb3` / `web3Service.connect()`).
3. Admin clicks "Send payout"; wagmi triggers `transfer(recipient, amount)` on the USDT contract.
4. After confirmation, the admin clicks "Confirm in system", which POSTs `POST /api/payment/admin/confirm-tx/:paymentId` with `{ txHash, kind: 'release' }`.
5. Backend `confirmAdminTx(paymentId, txHash, 'release')` (`shkeeperService.ts:628-647`) sets `status: 'completed'`, `escrowState: 'released'`, `blockchain.transactionHash = txHash`.
### Sequence diagram (SHKeeper payout)
## Sequence Diagram
```mermaid
sequenceDiagram
autonumber
actor A as Admin/System
actor A as Admin
actor C as Custody signer
participant BE as Backend
participant DB as MongoDB
participant SK as SHKeeper Payout API
participant BC as BSC
actor S as Seller
participant BC as EVM Chain
actor R as Recipient
A->>BE: POST /api/payment/shkeeper/payout
BE->>DB: Payment.create({direction:"out", escrowState:"releasing"})
BE->>SK: POST /api/v1/payout {to, amount, crypto}
SK-->>BE: { task_id, status:"pending" }
BE->>DB: Payment.providerPaymentId=task_id
SK->>BC: signed payout tx (managed wallet)
BC-->>SK: confirmed
SK->>BE: webhook payout-completed (or BE polls)
BE->>DB: Payment.status="completed"\nescrowState="released"\ntxHash
BE->>DB: pay-in Payment.escrowState="released"\nPurchaseRequest.status="seller_paid"
BE->>S: notifyPayoutSent
A->>BE: POST /api/payment/{id}/release or refund
BE->>DB: Load Payment + FundsLedger balance
BE->>BE: Check dispute hold + ledger availability
BE-->>A: unsigned release/refund instruction
A->>C: Request Trezor/Safe execution
C->>BC: Broadcast transfer
BC-->>C: txHash
A->>BE: POST /confirm { txHash, signer proof }
BE->>BE: Verify proof if required
BE->>DB: append release/refund ledger entry
BE->>DB: update Payment escrowState
BE-->>R: notification
```
## API calls
## API Calls
| Method | Endpoint | Source |
| Method | Endpoint | Purpose |
|---|---|---|
| `POST` | `/api/payment/shkeeper/payout` | `shkeeperPayoutRoutes.ts``createPayoutTask` |
| `GET` | `/api/payment/shkeeper/payout/:taskId` | Polls SHKeeper task status |
| `POST` | `/api/payment/admin/confirm-tx/:paymentId` | Manual admin confirmation |
| `GET` | `/api/payment/admin/payouts` | List payouts (admin dashboard) |
| `POST` | `/api/payment/:id/release` | Build release instruction |
| `POST` | `/api/payment/:id/release/confirm` | Confirm release transaction |
| `POST` | `/api/payment/:id/refund` | Build refund instruction |
| `POST` | `/api/payment/:id/refund/confirm` | Confirm refund transaction |
| `GET` | `/api/admin/payments/awaiting-confirmation` | Admin view of payments blocked on confirmation depth |
| `GET` | `/api/payment/derived-destinations` | Admin view of derived destination sweep state |
## Database writes
## Database Writes
- **`payments`** — new outgoing document; updates to `status`, `escrowState`, `blockchain.transactionHash` as the task progresses.
- **`payments`** (pay-in counterpart) — `escrowState = 'released'`.
- **`purchaserequests`** `status` advances to `seller_paid``completed`.
- **`notifications`** — seller payout receipt.
- **`payments`** -- status, `escrowState`, `blockchain.transactionHash`, signer metadata.
- **`funds_ledger_entries`** -- append-only `release` or `refund` entry with idempotency key.
- **`purchaserequests`** -- terminal business state after release/refund completes.
- **`notifications`** -- release/refund receipt to the relevant party.
## Socket events emitted
## Error / Edge Cases
- **`payment-status`** (admin) on each transition.
- **`purchase-request-update`** `status-changed`.
- **Insufficient ledger balance** -- reject instruction build/confirm.
- **Active dispute hold** -- reject release/refund unless the operation is the explicit dispute outcome.
- **Missing signer proof** -- reject when `TREZOR_SAFEKEEPING_REQUIRED=true`.
- **Custody tx sent but not confirmed in app** -- reconcile by tx hash and append the missing ledger entry once verified.
- **Partial split** -- build separate release and refund instructions whose sum does not exceed available balance.
- **Payout reverted** -- leave escrow in failed/retryable state and do not append the terminal ledger entry.
## Side effects
## Legacy SHKeeper Note
- **`fix-transaction-hashes.js`** at repo root (`backend/fix-transaction-hashes.js`) — script used to backfill missing `blockchain.transactionHash` on payouts where the SHKeeper webhook arrived without the txid (e.g. signature length mismatch in dev). Run locally with the same Mongo URI to repair stale documents. Use it as the reference for the data-fix pattern — pull recent payouts, query SHKeeper for invoice/task details, write back the hash.
- **Hash repair** — periodic reconciliation against SHKeeper invoice GET endpoints ensures bookkeeping accuracy.
Older versions used SHKeeper payout tasks and scripts such as `fix-transaction-hashes.js`. Those references remain useful for historical reconciliation, but new release/refund work should use the instruction, ledger, and custody-signer flow described here.
## Error / edge cases
## Linked Flows
- **Invalid recipient address** → throws synchronously, no DB record created.
- **SHKeeper insufficient hot-wallet balance** → SHKeeper returns an error; payout task stays `pending`, backend logs.
- **Duplicate payout request** → idempotency: existing payment returned with no extra SHKeeper call.
- **Payout reverted on chain** → SHKeeper marks the task `failed`; backend sets `Payment.status = 'failed'`, `escrowState = 'failed'`. Admin retries.
- **Missing `transactionHash` after success** → use `fix-transaction-hashes.js` to backfill.
- **Manual payout signed but never confirmed in system** → on-chain transfer happened, but `Payment.escrowState` stays `releasing`. Admin can run a reconciliation script that scans the escrow wallet's outgoing txs and matches by amount/timestamp.
- **Seller changes wallet address mid-flight** → the saved `recipientAddress` is the snapshot taken at payout creation; subsequent profile changes do not affect in-flight payouts.
- [[Escrow Flow]] -- sets up the conditions under which release/refund is allowed.
- [[Delivery Confirmation Flow]] -- happy-path release trigger.
- [[Dispute Flow]] -- can divert release to refund or split.
- [[Trezor Safekeeping Flow]] -- hardware-backed operation approval.
- [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] -- Safe-first custody roadmap.
> [!warning] Auto-release is not yet implemented
> Today, payouts are admin-initiated. The flow is ready for an automatic trigger when [[Delivery Confirmation Flow]] completes — implement a cron job or queue worker that scans for `PurchaseRequest.status='delivered'` and auto-creates payouts after a configurable grace period.
## Source Files
## Linked flows
- [[Escrow Flow]] — sets up the conditions under which payout is allowed.
- [[Delivery Confirmation Flow]] — green-lights the payout.
- [[Dispute Flow]] — can divert funds to a refund instead.
- [[Notification Flow]] — payout receipt to seller.
## Source files
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutService.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperPayoutRoutes.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperService.ts:614-647` (build & confirm admin tx payload)
- Backend: `backend/fix-transaction-hashes.js` (reconciliation script)
- Frontend: `frontend/src/sections/request/components/admin-steps/admin-wallet-payout.tsx`
- Frontend: `frontend/src/web3/web3Service.ts`
- Backend: `backend/src/services/payment/orchestration/releaseRefundService.ts`
- Backend: `backend/src/services/payment/ledger/fundsLedgerService.ts`
- Backend: `backend/src/services/payment/adapters/requestNetworkAdapter.ts`
- Backend: `backend/src/services/trezor/trezorService.ts`
- Backend: `backend/src/services/dispute/releaseHoldService.ts`
- Frontend: admin payment/release/refund surfaces under `frontend/src/sections/`

View File

@@ -34,7 +34,7 @@ stateDiagram-v2
pending --> received_offers: first SellerOffer saved\nSellerOfferService.createOffer
received_offers --> in_negotiation: buyer/seller chat\n(counter-offer, see [[Negotiation Flow]])
in_negotiation --> received_offers: counter rejected
received_offers --> payment: SHKeeper webhook PAID\n(selected offer)
received_offers --> payment: Request Network payment confirmed\n(selected offer)
in_negotiation --> payment: same
payment --> processing: seller acknowledges
processing --> delivery: seller marks shipped
@@ -151,7 +151,7 @@ sequenceDiagram
## Database writes
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[Payment Flow - SHKeeper]], and [[Delivery Confirmation Flow]].
- **`purchaserequests` collection**: full insert. Subsequent status transitions and `selectedOfferId` updates happen in [[Seller Offer Flow]], [[PRD - Request Network In-House Checkout]], and [[Delivery Confirmation Flow]].
- **`notifications` collection**: one per notified seller plus one for the buyer.
- **`users.referralStats`** is not touched at request creation.
@@ -186,7 +186,7 @@ sequenceDiagram
- [[Seller Offer Flow]] — sellers respond to the published request.
- [[Negotiation Flow]] — counter-offer mechanics in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — buyer pays for the accepted offer.
- [[PRD - Request Network In-House Checkout]] — buyer pays for the accepted offer.
- [[Delivery Confirmation Flow]] — seller ships, buyer confirms.
- [[Dispute Flow]] — escape hatch for failed deliveries.
- [[Notification Flow]] — backbone of the seller fan-out.

View File

@@ -149,7 +149,7 @@ sequenceDiagram
- [[Registration Flow]] — attribution point.
- [[Google OAuth Flow]] — also supports `referralCode`.
- [[Notification Flow]] — `referral-signup`, `level-up`, and points events surface here.
- [[Payment Flow - SHKeeper]] — completion of a purchase is the canonical trigger for awarding referral commission.
- [[PRD - Request Network In-House Checkout]] / [[Escrow Flow]] — completion of a purchase is the canonical trigger for awarding referral commission.
## Source files

View File

@@ -7,7 +7,7 @@ related_apis: ["POST /api/marketplace/offers", "GET /api/marketplace/offers/requ
# Seller Offer Flow
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[Payment Flow - SHKeeper]]) or reject.
A **seller** browses open purchase requests and submits an offer with a price, delivery time, and notes. The buyer is notified in real time and can accept (which moves the request to [[PRD - Request Network In-House Checkout]]) or reject.
## Actors
@@ -34,7 +34,7 @@ stateDiagram-v2
pending --> active: (optional — manual seller activation)
pending --> withdrawn: seller withdraws (only while pending)
pending --> rejected: another offer accepted\nor buyer rejects this one
pending --> accepted: acceptOffer()\nor SHKeeper PAID webhook
pending --> accepted: acceptOffer()\nor payment confirmed
accepted --> [*]
rejected --> [*]
withdrawn --> [*]
@@ -79,14 +79,14 @@ The active enum values are `pending | accepted | rejected | withdrawn` (`SellerO
### Accept → Payment
14. The buyer's "Pay this offer" button kicks off [[Payment Flow - SHKeeper]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; the SHKeeper webhook does that atomically when the on-chain payment is confirmed.
15. On `PAID`/`OVERPAID` webhook (see `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714`):
14. The buyer's "Pay this offer" button kicks off [[PRD - Request Network In-House Checkout]] with `purchaseRequestId` and `sellerOfferId`. The offer is **not** immediately marked `accepted`; payment confirmation does that atomically when the on-chain payment is confirmed.
15. On Request Network payment confirmation:
- The selected offer's `status``accepted`.
- All other offers on the same request → `rejected` via `SellerOffer.updateMany`.
- The purchase request: `status = "payment"`, `selectedOfferId = sellerOfferId`.
- A direct chat is created (see [[Chat Flow]]).
- Notifications: `notifyOfferAccepted` to the winning seller, generic rejection notifications to the others (`SellerOfferService.acceptOffer` does the same in the manual path).
- Socket events: `seller-offer-update` `payment-completed` to the winner, `seller-offer-update` `offer-rejected` to losers (`shkeeperWebhook.ts:679-705`).
- Socket events notify the winner and reject/close competing offers.
### Withdrawal
@@ -127,7 +127,7 @@ sequenceDiagram
BE-->>FE_B: offers
alt
B->>FE_B: Click pay to finish selected offer
B->>FE_B: SHKeeper webhook handles payment result
B->>FE_B: Request Network payment confirms
else
B->>FE_B: Open chat to negotiate
end
@@ -171,7 +171,7 @@ sequenceDiagram
- **Price = 0 or negative** → Mongoose validator on `SellerOffer.price.amount` rejects (`SellerOfferService.ts:55-60` logs the validation state).
- **Seller withdraws an `accepted` offer** → blocked by the `{ status: 'pending' }` filter; returns `null`.
- **`validUntil` in the past at creation** → schema-level validator should reject; otherwise the next `markExpiredOffersAsWithdrawn` cron run flips it to `withdrawn`.
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, the SHKeeper webhook coordinator (`PaymentCoordinator`) is idempotent and the first PAID wins.
- **Race condition: two payments to two different offers** → unlikely (frontend disables payment buttons once one is chosen); even if both arrive, `PaymentCoordinator` and provider idempotency decide which confirmed payment wins.
- **Offer for a deleted request** → orphan; the webhook handler logs `"Purchase request not found"` and continues. Periodic cleanup should remove orphans.
> [!tip] Real-time UX
@@ -181,7 +181,7 @@ sequenceDiagram
- [[Purchase Request Flow]] — produces the requests sellers offer on.
- [[Negotiation Flow]] — counter-offer in `in_negotiation`.
- [[Payment Flow - SHKeeper]] — locks in the accepted offer.
- [[PRD - Request Network In-House Checkout]] — locks in the accepted offer.
- [[Chat Flow]] — direct chat opened after payment.
- [[Notification Flow]] — channels for offer events.
- [[Rating Flow]] — seller's average rating displayed in the offer card.
@@ -191,7 +191,7 @@ sequenceDiagram
- Backend: `backend/src/services/marketplace/SellerOfferService.ts`
- Backend: `backend/src/services/marketplace/marketplaceController.ts`
- Backend: `backend/src/models/SellerOffer.ts`
- Backend: `backend/src/services/payment/shkeeper/shkeeperWebhook.ts:573-714` (acceptance via webhook)
- Backend: `backend/src/services/payment/paymentCoordinator.ts` (payment-state cascade)
- Frontend: `frontend/src/sections/request/components/seller-steps/step-1-send-proposal.tsx`
- Frontend: `frontend/src/sections/request/components/buyer-steps/step-3-select-and-pay.tsx`
- Frontend: `frontend/src/app/dashboard/seller/marketplace/`

View File

@@ -8,7 +8,7 @@ Default mode: optional. Existing release/refund flows do not require Trezor proo
- Generate a fresh receive address per user/payment from a registered Trezor xpub.
- Require a Trezor-produced signature before release/refund confirmation when safekeeping enforcement is enabled.
- Keep SHKeeper and Request Network optional provider paths intact.
- Keep the Request Network payment adapter and legacy provider abstractions intact while adding custody controls.
- Preserve the existing `Payment` model and orchestration surface.
## Registration
@@ -95,7 +95,7 @@ When `TREZOR_SAFEKEEPING_REQUIRED=true`, `confirmReleaseRefundInstruction` verif
TREZOR_SAFEKEEPING_REQUIRED=false
```
Default is permissive so existing SHKeeper and Request Network flows continue to work. Set it to `true` only after registering the operating admin's Trezor account and testing the signing path. Any value other than the literal string `true` is treated as disabled.
Default is permissive so existing Request Network release/refund flows continue to work. Set it to `true` only after registering the operating admin's Trezor account and testing the signing path. Any value other than the literal string `true` is treated as disabled.
## Safety Rules
@@ -108,7 +108,7 @@ Default is permissive so existing SHKeeper and Request Network flows continue to
## Upgrade Path To Multisig
The current design stores a single `trezor-eoa` signer. Later, replace the signer policy with:
The current design stores a single `trezor-eoa` signer. The recommended production path is to replace the signer policy with:
- `addressType: safe-multisig`
- a Safe address per tenant/admin group
@@ -116,4 +116,4 @@ The current design stores a single `trezor-eoa` signer. Later, replace the signe
- Trezor owners as Safe signers
- release/refund flow creates a Safe transaction and records collected signatures before execution
The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry.
The payment orchestration API should stay the same: build instruction, collect hardware-backed approval, confirm release/refund, append ledger entry. See [[PRD - Decentralized Custody and Smart-Contract Escrow Roadmap]] for the staged Safe-first path before any custom escrow contract.