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,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/`