- Update data model enums to match backend models - Update API reference auth requirements - Add dispute module references and warning blocks - Add 2026-05-24 audit remediation callout to Overview - Generate task breakdowns and audit artifacts - Add doc alignment report (.taskmaster/reports/)
945 lines
54 KiB
Markdown
945 lines
54 KiB
Markdown
---
|
|
title: Funds Ledger and Escrow State Machine Specification
|
|
tags: [specification, funds-ledger, escrow, state-machine, canonical]
|
|
created: 2026-05-24
|
|
status: canonical
|
|
reviewers: [backend, security, product]
|
|
---
|
|
|
|
# Funds Ledger and Escrow State Machine Specification
|
|
|
|
> This document is the **canonical specification** for money movement, immutable ledger entries, derived balance views, and the full escrow/payment/dispute state machine for the Amanat platform. All implementations, API contracts, and data models must conform to this specification. Any prior document that conflicts with this specification is superseded.
|
|
|
|
## Cross-references
|
|
|
|
- [[Threat Model - Amanat Escrow Platform]] -- T05 (double payout), T06 (dispute bypass), T18 (insider manipulation), T19 (seller price manipulation), T23 (state machine inconsistency)
|
|
- [[Platform Logical Audit - 2026-05-24]] -- Findings 1, 5, 9, 20, 21
|
|
- [[Backend Stack Security and Refactor Assessment - 2026-05-24]] -- Phase 2: Payment and ledger extraction
|
|
- [[Escrow Flow]] -- current escrow state machine (superseded by this document)
|
|
- [[Payment Flow - SHKeeper]] -- SHKeeper pay-in path
|
|
- [[Payment Flow - DePay & Web3]] -- Web3 pay-in path
|
|
- [[Dispute Flow]] -- dispute lifecycle (status enums superseded by this document)
|
|
- [[Payout Flow]] -- seller payout mechanics
|
|
- [[Payment]] -- current Payment data model
|
|
- [[PurchaseRequest]] -- current PurchaseRequest data model
|
|
- [[Dispute]] -- current Dispute data model
|
|
- [[SellerOffer]] -- current SellerOffer data model
|
|
|
|
---
|
|
|
|
# Part 1: Funds Account and Ledger Model
|
|
|
|
## 1.1 FundsAccount
|
|
|
|
A `FundsAccount` is created per purchase request when a payment intent is first initiated. It is the single source of truth for all money movement associated with a transaction.
|
|
|
|
### Schema
|
|
|
|
| Field | Type | Required | Default | Description |
|
|
|-------|------|----------|---------|-------------|
|
|
| `accountId` | UUID (string) | yes | auto-generated | Primary key |
|
|
| `purchaseRequestId` | ObjectId | yes | -- | Reference to [[PurchaseRequest]] |
|
|
| `buyerId` | ObjectId | yes | -- | Buyer user reference |
|
|
| `sellerId` | ObjectId | yes | -- | Seller user reference |
|
|
| `sellerOfferId` | ObjectId | yes | -- | Accepted [[SellerOffer]] reference |
|
|
| `currency` | string | yes | `"USDT"` | Settlement currency (USDT, USDC) |
|
|
| `grossAmountPaid` | Decimal128 | yes | `0` | Total amount received from buyer (gross, before fees) |
|
|
| `providerFees` | Decimal128 | no | `0` | Fees taken by the payment provider (SHKeeper, gas, etc.) |
|
|
| `platformFees` | Decimal128 | no | `0` | Platform commission |
|
|
| `heldAmount` | Decimal128 | no | `0` | Amount held in escrow (not disputed) |
|
|
| `disputedAmount` | Decimal128 | no | `0` | Amount under dispute hold |
|
|
| `releasableAmount` | Decimal128 | no | `0` | Amount available for release or refund |
|
|
| `releasedAmount` | Decimal128 | no | `0` | Total amount released to seller |
|
|
| `refundedAmount` | Decimal128 | no | `0` | Total amount refunded to buyer |
|
|
| `status` | string (enum) | yes | `"active"` | `ACTIVE`, `SETTLED`, `CANCELLED` |
|
|
| `providerReference` | string | no | -- | External provider payment ID (SHKeeper external_id, etc.) |
|
|
| `escrowWalletAddress` | string | no | -- | On-chain address holding the funds |
|
|
| `settlementTxHash` | string | no | -- | Final settlement on-chain transaction hash |
|
|
| `idempotencyKey` | string | yes | auto-generated | Prevents duplicate account creation for the same intent |
|
|
| `createdAt` | Date | yes | `Date.now()` | Creation timestamp |
|
|
| `updatedAt` | Date | yes | `Date.now()` | Last modification timestamp |
|
|
| `settledAt` | Date | no | -- | When the account reached SETTLED status |
|
|
| `cancelledAt` | Date | no | -- | When the account reached CANCELLED status |
|
|
|
|
### Status enum
|
|
|
|
| Value | Description |
|
|
|-------|-------------|
|
|
| `ACTIVE` | Funds are in motion or held. Ledger entries can still be appended. |
|
|
| `SETTLED` | All funds have been released and/or refunded. No further entries allowed except adjustments. |
|
|
| `CANCELLED` | The transaction was cancelled before any funds were received. |
|
|
|
|
### Indexes
|
|
|
|
- `{ purchaseRequestId: 1 }` (unique) -- one account per purchase request
|
|
- `{ buyerId: 1, status: 1 }` -- buyer dashboard queries
|
|
- `{ sellerId: 1, status: 1 }` -- seller dashboard queries
|
|
- `{ providerReference: 1 }` (sparse) -- webhook lookup
|
|
- `{ idempotencyKey: 1 }` (unique) -- prevents duplicate creation
|
|
|
|
---
|
|
|
|
## 1.2 LedgerEntry (Immutable, Append-Only)
|
|
|
|
Every financial event creates an immutable ledger entry. Entries are never modified or deleted after creation.
|
|
|
|
### Schema
|
|
|
|
| Field | Type | Required | Default | Description |
|
|
|-------|------|----------|---------|-------------|
|
|
| `entryId` | UUID (string) | yes | auto-generated | Primary key |
|
|
| `accountId` | UUID (string) | yes | -- | Reference to FundsAccount |
|
|
| `entryType` | string (enum) | yes | -- | See entry type enum below |
|
|
| `amount` | Decimal128 | yes | -- | Amount for this entry (always positive) |
|
|
| `currency` | string | yes | -- | Currency of the amount |
|
|
| `runningBalance` | Object | yes | -- | Snapshot of all balances after this entry (see below) |
|
|
| `providerTxHash` | string | no | -- | On-chain transaction hash (if applicable) |
|
|
| `actor` | Object | yes | -- | Who/what triggered this entry |
|
|
| `actor.type` | string (enum) | yes | -- | `SYSTEM`, `ADMIN`, `BUYER`, `SELLER`, `PROVIDER_WEBHOOK`, `CRON_JOB` |
|
|
| `actor.userId` | ObjectId | no | -- | User ID if actor is a person |
|
|
| `actor.serviceName` | string | no | -- | Service name if actor is automated (e.g., `"PaymentCoordinator"`) |
|
|
| `idempotencyKey` | string | yes | -- | Prevents duplicate entries |
|
|
| `sourceEvent` | Object | no | -- | Reference to the originating event |
|
|
| `sourceEvent.webhookDeliveryId` | string | no | -- | Provider webhook delivery ID |
|
|
| `sourceEvent.disputeId` | ObjectId | no | -- | Dispute reference |
|
|
| `sourceEvent.payoutInstructionId` | string | no | -- | Payout instruction reference |
|
|
| `sourceEvent.adminActionId` | string | no | -- | Admin action audit reference |
|
|
| `description` | string | no | -- | Human-readable description |
|
|
| `createdAt` | Date | yes | `Date.now()` | Immutable creation timestamp |
|
|
|
|
### Entry type enum
|
|
|
|
| Value | Direction | Description |
|
|
|-------|-----------|-------------|
|
|
| `PAY_IN` | Credit (+) | Buyer payment received and confirmed |
|
|
| `PROVIDER_FEE` | Debit (-) | Fee deducted by the payment provider |
|
|
| `PLATFORM_FEE` | Debit (-) | Platform commission deducted |
|
|
| `HOLD` | Neutral (allocation) | Funds moved to held status in escrow |
|
|
| `DISPUTE_HOLD` | Neutral (allocation) | Funds moved from held to disputed due to dispute |
|
|
| `RELEASE` | Debit (-) | Funds released to seller |
|
|
| `REFUND` | Debit (-) | Funds refunded to buyer |
|
|
| `ADJUSTMENT` | +/- | Manual correction by admin (requires step-up auth) |
|
|
| `REVERSAL` | +/- | Reversal of a previous entry (e.g., failed payout, cancelled hold) |
|
|
|
|
### Running balance snapshot
|
|
|
|
Each entry includes a snapshot of the FundsAccount balances immediately after the entry is applied:
|
|
|
|
```
|
|
runningBalance: {
|
|
grossPaid: Decimal128,
|
|
providerFees: Decimal128,
|
|
platformFees: Decimal128,
|
|
held: Decimal128,
|
|
disputed: Decimal128,
|
|
releasable: Decimal128,
|
|
released: Decimal128,
|
|
refunded: Decimal128
|
|
}
|
|
```
|
|
|
|
### Immutability rules
|
|
|
|
1. Ledger entries MUST be append-only. No UPDATE or DELETE operations are permitted.
|
|
2. The `createdAt` field is set once and MUST never be modified.
|
|
3. The `entryId` MUST be a UUID v4 generated at creation time.
|
|
4. The collection SHOULD use MongoDB's `capped: false` with application-level enforcement (no update/delete hooks).
|
|
|
|
### Indexes
|
|
|
|
- `{ accountId: 1, createdAt: 1 }` -- chronological query per account
|
|
- `{ accountId: 1, entryType: 1 }` -- balance derivation queries
|
|
- `{ accountId: 1, idempotencyKey: 1 }` (unique) -- prevents duplicate entries
|
|
- `{ providerTxHash: 1 }` (sparse) -- lookup by on-chain transaction
|
|
- `{ "sourceEvent.disputeId": 1 }` -- dispute-related entries
|
|
- `{ "sourceEvent.webhookDeliveryId": 1 }` (sparse) -- webhook idempotency
|
|
|
|
---
|
|
|
|
## 1.3 FundsBalance (Derived View)
|
|
|
|
Balances are NOT stored as mutable fields. They are derived by summing ledger entries. The `runningBalance` snapshot on each entry provides a cache, but the canonical source is always the aggregation of ledger entries.
|
|
|
|
### Derivation formulas
|
|
|
|
```
|
|
grossPaid = SUM(amount) WHERE entryType = PAY_IN
|
|
providerFees = SUM(amount) WHERE entryType = PROVIDER_FEE
|
|
platformFees = SUM(amount) WHERE entryType = PLATFORM_FEE
|
|
released = SUM(amount) WHERE entryType = RELEASE
|
|
refunded = SUM(amount) WHERE entryType = REFUND
|
|
held = SUM(amount) WHERE entryType = HOLD
|
|
- SUM(amount) WHERE entryType = RELEASE AND heldFunds = true
|
|
- SUM(amount) WHERE entryType = REFUND AND heldFunds = true
|
|
+ SUM(amount) WHERE entryType = REVERSAL AND reversedType = HOLD
|
|
disputed = SUM(amount) WHERE entryType = DISPUTE_HOLD
|
|
- SUM(amount) WHERE entryType = RELEASE AND disputedFunds = true
|
|
- SUM(amount) WHERE entryType = REFUND AND disputedFunds = true
|
|
+ SUM(amount) WHERE entryType = REVERSAL AND reversedType = DISPUTE_HOLD
|
|
releasable = grossPaid - providerFees - platformFees - held - disputed - released - refunded
|
|
```
|
|
|
|
### Balance invariant
|
|
|
|
The following invariant MUST hold at all times:
|
|
|
|
```
|
|
grossPaid = providerFees + platformFees + released + refunded + releasable + held + disputed
|
|
```
|
|
|
|
This invariant MUST be checked:
|
|
1. After every ledger entry append.
|
|
2. During scheduled reconciliation.
|
|
3. Before any release or refund operation.
|
|
|
|
If the invariant is violated, the system MUST:
|
|
1. Halt all release and refund operations for the affected account.
|
|
2. Log an alert with severity `critical`.
|
|
3. Quarantine the account (set status to `ACTIVE` but flag for manual review).
|
|
4. Notify the operations team.
|
|
|
|
---
|
|
|
|
## 1.4 Reconciliation
|
|
|
|
### When reconciliation runs
|
|
|
|
| Trigger | Frequency | Description |
|
|
|---------|-----------|-------------|
|
|
| Scheduled | Every 6 hours | Automatic reconciliation job |
|
|
| Provider event | On webhook delivery | Cross-check webhook data against ledger |
|
|
| On-demand | Manual | Admin triggers reconciliation from dashboard |
|
|
| Post-migration | Once | After ledger backfill from legacy Payment records |
|
|
|
|
### What reconciliation compares
|
|
|
|
1. **Provider balance vs ledger-derived balance**: Compare the amount reported by the payment provider (SHKeeper invoice status, on-chain wallet balance) against the sum of ledger entries.
|
|
2. **On-chain transactions vs ledger entries**: For each `providerTxHash` in the ledger, verify the on-chain transaction exists with matching amount, sender, and recipient.
|
|
3. **Payment records vs ledger entries**: Cross-reference legacy `Payment` records with `LedgerEntry` records for consistency.
|
|
|
|
### Provider-specific event mapping
|
|
|
|
| Provider Event | Ledger Entry | Mapping |
|
|
|----------------|-------------|---------|
|
|
| SHKeeper webhook `PAID` | PAY_IN | `amount = balance_fiat`, `idempotencyKey = external_id + "PAID"` |
|
|
| SHKeeper webhook `OVERPAID` | PAY_IN + ADJUSTMENT | PAY_IN for expected amount; ADJUSTMENT for surplus |
|
|
| SHKeeper webhook `PARTIAL` | PAY_IN | `amount = balance_fiat` (partial amount received) |
|
|
| SHKeeper webhook `EXPIRED` | REVERSAL | Reverse any partial PAY_IN entries |
|
|
| SHKeeper fee deduction | PROVIDER_FEE | `amount = fee_percent * grossAmount` |
|
|
| Web3 verification success | PAY_IN | `amount = verified on-chain amount`, `idempotencyKey = txHash` |
|
|
| Payout task completed | RELEASE | `amount = payout amount`, `idempotencyKey = task_id` |
|
|
| Admin manual refund | REFUND | `amount = refund amount`, `idempotencyKey = admin-tx-hash` |
|
|
|
|
### Mismatch handling
|
|
|
|
| Severity | Condition | Response |
|
|
|----------|-----------|----------|
|
|
| `info` | Ledger-derived balance matches provider balance within 0.01 currency units | No action, log success |
|
|
| `warning` | Difference between 0.01 and 1.00 currency units | Log warning, flag for review, operations notified |
|
|
| `critical` | Difference greater than 1.00 currency units | Halt operations on the account, quarantine, create admin alert, require manual resolution |
|
|
| `critical` | Missing on-chain transaction for a recorded entry | Halt operations, quarantine, investigate potential fraud (addresses [[T05]], [[T18]]) |
|
|
|
|
---
|
|
|
|
## 1.5 Idempotency
|
|
|
|
### Idempotency key rules
|
|
|
|
1. Every ledger entry MUST have an `idempotencyKey`.
|
|
2. The uniqueness constraint is: `(accountId, idempotencyKey)`.
|
|
3. Attempting to insert a duplicate entry with the same `(accountId, idempotencyKey)` MUST be rejected with a conflict error.
|
|
4. The system MUST return the existing entry's details on duplicate rejection (not an error that hides the data).
|
|
|
|
### Idempotency key sources
|
|
|
|
| Entry Type | Idempotency Key Source | Format |
|
|
|------------|----------------------|--------|
|
|
| `PAY_IN` (SHKeeper) | Webhook `external_id` + status | `shk:{external_id}:PAID` |
|
|
| `PAY_IN` (Web3) | On-chain transaction hash | `w3:{transactionHash}` |
|
|
| `PROVIDER_FEE` | Payment reference + `"fee"` | `{paymentRef}:fee` |
|
|
| `PLATFORM_FEE` | Payment reference + `"commission"` | `{paymentRef}:commission` |
|
|
| `HOLD` | Account ID + `"hold"` | `{accountId}:hold` |
|
|
| `DISPUTE_HOLD` | Dispute ID | `dispute:{disputeId}` |
|
|
| `RELEASE` | Payout instruction ID or task ID | `release:{taskId}` or `release:{adminTxHash}` |
|
|
| `REFUND` | Refund instruction ID or admin tx hash | `refund:{instructionId}` or `refund:{adminTxHash}` |
|
|
| `ADJUSTMENT` | Admin action ID | `adj:{adminActionId}` |
|
|
| `REVERSAL` | Reference to reversed entry's idempotency key + `"reverse"` | `rev:{originalKey}` |
|
|
|
|
### FundsAccount idempotency
|
|
|
|
- Account creation is keyed by `purchaseRequestId` (one account per purchase request).
|
|
- The `idempotencyKey` field on FundsAccount is set to `account:{purchaseRequestId}`.
|
|
- Attempting to create a second account for the same purchase request MUST return the existing account.
|
|
|
|
---
|
|
|
|
# Part 2: Escrow State Machine
|
|
|
|
## 2.1 Canonical Status Enums
|
|
|
|
The following enums are the single source of truth. All previous definitions in data model files, API reference documents, and flow documents that conflict with these are superseded.
|
|
|
|
### 2.1.1 PurchaseRequest statuses
|
|
|
|
**Canonical enum:**
|
|
|
|
| Value | Description | Deprecated aliases |
|
|
|-------|-------------|-------------------|
|
|
| `pending` | Request created, awaiting seller offers | `pending_payment` (Data Model), `draft` (API Reference) |
|
|
| `received_offers` | At least one offer received | -- |
|
|
| `in_negotiation` | Buyer and seller actively negotiating | -- |
|
|
| `payment` | Payment captured, awaiting seller acknowledgment | `active` (Data Model -- deprecated) |
|
|
| `processing` | Seller acknowledged, preparing order | -- |
|
|
| `delivery` | Seller has shipped or is delivering | -- |
|
|
| `delivered` | Buyer has received the goods/service | -- |
|
|
| `confirming` | Delivery confirmed, awaiting escrow release | -- |
|
|
| `completed` | Escrow released to seller, transaction done | -- |
|
|
| `seller_paid` | Payout confirmed on chain (subset of completed) | -- |
|
|
| `cancelled` | Transaction cancelled | -- |
|
|
|
|
**Ghost states resolved:**
|
|
|
|
| Ghost State | Resolution | Migration |
|
|
|-------------|-----------|-----------|
|
|
| `draft` | Removed from canonical enum. Requests are created as `pending`. No `draft` state exists. | Map any `draft` records to `pending`. |
|
|
| `finalized` | Removed from canonical enum. `completed` is the terminal state after escrow release. Ratings and feedback are captured as sub-fields of `completed`, not as a separate status. | Map any `finalized` records to `completed`. |
|
|
| `archived` | Removed from canonical enum. Archival is a display concern, not a state-machine concern. Use a separate `isArchived` boolean flag or `archivedAt` timestamp. | Map any `archived` records to `completed` with `archivedAt` set. |
|
|
| `pending_payment` | Removed. Replaced by `pending`. | Map any `pending_payment` records to `pending`. |
|
|
| `active` | Removed. Replaced by `payment` (post-payment) or `pending` (pre-payment). | Map any `active` records to their correct state based on whether payment has been captured. |
|
|
|
|
**Previous document sources:**
|
|
|
|
| Document | Status values used | Alignment |
|
|
|----------|-------------------|-----------|
|
|
| [[PurchaseRequest]] Data Model | `pending_payment`, `pending`, `active`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `seller_paid`, `cancelled` | `pending_payment`, `active` deprecated |
|
|
| [[Marketplace API]] | `draft`, `pending`, `payment`, `processing`, `delivery`, `delivered`, `seller_paid`, `completed`, `cancelled` | `draft` deprecated |
|
|
| [[Purchase Request Flow]] | `pending`, `received_offers`, `in_negotiation`, `payment`, `processing`, `delivery`, `delivered`, `confirming`, `completed`, `finalized`, `archived`, `cancelled` | `finalized`, `archived` deprecated |
|
|
|
|
### 2.1.2 Payment statuses
|
|
|
|
**Canonical enum:**
|
|
|
|
| Value | Description | Deprecated aliases |
|
|
|-------|-------------|-------------------|
|
|
| `PENDING` | Payment intent created, awaiting funds | `pending` |
|
|
| `PROCESSING` | Funds detected, awaiting confirmation | `processing` |
|
|
| `COMPLETED` | Funds confirmed and escrow funded | `completed`, `confirmed` (skipped) |
|
|
| `FAILED` | Payment failed (expired, rejected, reverted) | `failed` |
|
|
| `CANCELLED` | Payment cancelled before funds arrived | `cancelled` |
|
|
| `RELEASED` | Escrow released to seller | -- |
|
|
| `REFUNDED` | Escrow refunded to buyer | -- |
|
|
|
|
**Ghost states resolved:**
|
|
|
|
| Ghost State | Resolution | Migration |
|
|
|-------------|-----------|-----------|
|
|
| `confirmed` | Removed. The SHKeeper webhook maps `PAID` directly to `COMPLETED`. `confirmed` was an intermediate state that no automated flow sets. | Map any `confirmed` records to `COMPLETED`. |
|
|
|
|
### 2.1.3 Payment escrowState values
|
|
|
|
**Canonical enum:**
|
|
|
|
| Value | Description | Deprecated aliases |
|
|
|-------|-------------|-------------------|
|
|
| `FUNDED` | Buyer payment confirmed, funds held in escrow | `funded` |
|
|
| `PARTIALLY_FUNDED` | Partial payment received, awaiting full amount | `partial` (Escrow Flow, Payment Flow - SHKeeper) |
|
|
| `RELEASABLE` | Delivery confirmed, funds ready for release | `releasable` |
|
|
| `DISPUTED` | Active dispute hold, all releases/refunds blocked | -- (NEW -- addresses [[T06]]) |
|
|
| `RELEASING` | Payout initiated, awaiting on-chain confirmation | `releasing` |
|
|
| `RELEASED` | Payout confirmed on chain, funds transferred to seller | `released` |
|
|
| `REFUNDING` | Refund initiated, awaiting on-chain confirmation | -- |
|
|
| `REFUNDED` | Refund confirmed on chain, funds returned to buyer | `refunded` |
|
|
| `FAILED` | Payout or refund failed on chain, retry required | `failed` |
|
|
| `CANCELLED` | Payment expired or cancelled before funding | -- |
|
|
|
|
**Ghost states resolved:**
|
|
|
|
| Ghost State | Resolution | Migration |
|
|
|-------------|-----------|-----------|
|
|
| `partial` | Replaced by `PARTIALLY_FUNDED`. Previously used in [[Escrow Flow]] and [[Payment Flow - SHKeeper]] but absent from the [[Payment]] data model. | Map any `partial` escrowState values to `PARTIALLY_FUNDED`. |
|
|
|
|
**Key addition: `DISPUTED` state**
|
|
|
|
The `DISPUTED` escrow state is the primary mitigation for [[T06]] (dispute bypass -- release during active dispute). This state:
|
|
|
|
1. Is set automatically when a dispute is opened against a funded payment.
|
|
2. Blocks all `RELEASE` and `REFUND` operations.
|
|
3. Can only be cleared by dispute resolution or admin override with step-up authentication.
|
|
4. Replaces the previous (non-functional) "hold flag" described in the [[Dispute API]].
|
|
|
|
**Key addition: `REFUNDING` state**
|
|
|
|
The `REFUNDING` state provides parity with `RELEASING` -- when a refund is initiated, the escrow state moves to `REFUNDING` until the on-chain transaction is confirmed, at which point it moves to `REFUNDED`.
|
|
|
|
**Previous document sources:**
|
|
|
|
| Document | escrowState values used | Alignment |
|
|
|----------|------------------------|-----------|
|
|
| [[Payment]] Data Model | `funded`, `releasable`, `released`, `refunded`, `releasing`, `failed` | Add `DISPUTED`, `PARTIALLY_FUNDED`, `REFUNDING`, `CANCELLED` |
|
|
| [[Escrow Flow]] | `funded`, `partial`, `releasable`, `releasing`, `released`, `refunded`, `failed` | `partial` -> `PARTIALLY_FUNDED`, add `DISPUTED`, `REFUNDING`, `CANCELLED` |
|
|
| [[Payment Flow - SHKeeper]] | `funded`, `partial`, `releasable`, `releasing`, `released`, `refunded`, `cancelled` | Same as Escrow Flow |
|
|
| [[Payment API]] | `unfunded`, `funded`, `released`, `refunded` | `unfunded` replaced by null/PENDING status; add missing states |
|
|
|
|
### 2.1.4 Dispute statuses and resolution actions
|
|
|
|
This is the resolution of the three mutually incompatible enum sets identified in [[Platform Logical Audit - 2026-05-24]] Finding 5.
|
|
|
|
**Canonical Dispute status enum:**
|
|
|
|
| Value | Description | Previously in |
|
|
|-------|-------------|---------------|
|
|
| `OPEN` | Dispute created, awaiting admin assignment | Data Model: `pending`; API: `open`; Flow: `pending` |
|
|
| `UNDER_REVIEW` | Admin assigned, investigation in progress | Data Model: `in_progress`, `waiting_response`; API: `under_review`; Flow: `in_progress` |
|
|
| `RESOLVED_BUYER` | Resolved in favor of buyer (refund/partial refund) | API: `resolved_buyer` |
|
|
| `RESOLVED_SELLER` | Resolved in favor of seller (release) | API: `resolved_seller` |
|
|
| `RESOLVED_SPLIT` | Split decision (partial refund + partial release) | -- (NEW) |
|
|
| `REJECTED` | Dispute rejected as invalid or duplicate | Data Model: `rejected` |
|
|
| `CLOSED` | Dispute closed (terminal) | Data Model: `closed`; Flow: `closed` |
|
|
|
|
**Canonical Dispute resolution action enum:**
|
|
|
|
| Value | Description | Escrow Effect | Previously in |
|
|
|-------|-------------|---------------|---------------|
|
|
| `REFUND` | Full refund to buyer | escrowState -> REFUNDING -> REFUNDED | Data Model: `refund`; Flow: `refund` |
|
|
| `PARTIAL_REFUND` | Partial refund to buyer, partial release to seller | escrowState -> REFUNDING/RELEASING -> split | Data Model: `compensation`; Flow: `partial`; API: `split` |
|
|
| `RELEASE` | Full release to seller | escrowState -> RELEASING -> RELEASED | Flow: `release`; API: `seller` |
|
|
| `REJECT` | No financial action, escrow returns to previous state | escrowState -> FUNDED (or RELEASABLE) | Flow: `reject`; API: N/A |
|
|
| `WARNING` | Seller warned but no financial action | No escrow change | Data Model: `warning_seller` |
|
|
| `BAN_SELLER` | Seller banned, funds refunded to buyer | escrowState -> REFUNDING -> REFUNDED | Data Model: `ban_seller` |
|
|
| `NO_ACTION` | No financial or disciplinary action | No escrow change | Data Model: `no_action` |
|
|
|
|
**Previous document sources and their incompatibilities:**
|
|
|
|
| Document | Status enum | Resolution action enum | Conflict |
|
|
|----------|-------------|----------------------|----------|
|
|
| [[Dispute]] Data Model | `pending`, `in_progress`, `waiting_response`, `resolved`, `rejected`, `closed` | `refund`, `replacement`, `compensation`, `warning_seller`, `ban_seller`, `no_action` | Uses `resolved` (ambiguous), `replacement` (not financial), `waiting_response` (substate of `in_progress`) |
|
|
| [[Dispute API]] | `open`, `under_review`, `resolved_buyer`, `resolved_seller`, `closed` | `buyer`, `seller`, `split` (as `decision`) | Uses outcome as status, `decision` instead of `action`, no `REJECTED` state |
|
|
| [[Dispute Flow]] | `pending`, `in_progress`, `resolved`, `closed` | `refund`, `partial`, `release`, `reject` | Uses `partial` (ambiguous), conflates status and action, missing `REJECTED` |
|
|
|
|
**Resolution rationale:**
|
|
|
|
1. `RESOLVED_BUYER`, `RESOLVED_SELLER`, `RESOLVED_SPLIT` encode the outcome directly in the status, eliminating ambiguity about who the resolution favored (matching the API design).
|
|
2. `UNDER_REVIEW` replaces both `in_progress` and `waiting_response`. The distinction between "admin is actively investigating" and "waiting for a party response" is tracked in the dispute timeline, not in the status.
|
|
3. `REJECTED` is distinct from `RESOLVED_*` because a rejection means the dispute was invalid, while a resolution means a judgment was rendered.
|
|
4. `replacement` is not a financial action -- it is an operational action between buyer and seller. It is removed from the canonical financial resolution enum. If needed, it can be tracked in the dispute timeline as a non-financial resolution.
|
|
5. `PARTIAL_REFUND` replaces `partial`, `compensation`, and the `split` decision, providing a clear financial description.
|
|
|
|
---
|
|
|
|
## 2.2 State Transition Diagrams
|
|
|
|
### 2.2.1 PurchaseRequest lifecycle
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> pending : buyer creates request
|
|
pending --> received_offers : first SellerOffer saved
|
|
pending --> cancelled : buyer cancels (no offers)
|
|
|
|
received_offers --> in_negotiation : buyer engages seller
|
|
received_offers --> payment : offer accepted + payment captured
|
|
received_offers --> cancelled : buyer cancels
|
|
|
|
in_negotiation --> received_offers : counter rejected, more offers pending
|
|
in_negotiation --> payment : offer accepted + payment captured
|
|
in_negotiation --> cancelled : buyer cancels
|
|
|
|
payment --> processing : seller acknowledges
|
|
payment --> cancelled : dispute resolved as refund (pre-shipment)
|
|
|
|
processing --> delivery : seller marks shipped
|
|
processing --> DISPUTED : dispute opened
|
|
|
|
delivery --> delivered : buyer confirms delivery (code or fast-track)
|
|
delivery --> DISPUTED : dispute opened
|
|
|
|
delivered --> confirming : delivery code verified or fast-track
|
|
delivered --> DISPUTED : dispute opened
|
|
|
|
confirming --> completed : escrow released
|
|
confirming --> DISPUTED : dispute opened
|
|
|
|
DISPUTED --> payment : dispute rejected (no action)
|
|
DISPUTED --> processing : dispute rejected (no action)
|
|
DISPUTED --> delivery : dispute rejected (no action)
|
|
DISPUTED --> delivered : dispute rejected (no action)
|
|
DISPUTED --> confirming : dispute rejected (no action)
|
|
DISPUTED --> confirming : dispute RESOLVED_SELLER (release)
|
|
DISPUTED --> cancelled : dispute RESOLVED_BUYER (refund)
|
|
|
|
completed --> seller_paid : payout confirmed on chain
|
|
seller_paid --> [*]
|
|
|
|
cancelled --> [*]
|
|
```
|
|
|
|
Note: `DISPUTED` on PurchaseRequest is triggered by dispute creation and reverts to the previous state if the dispute is rejected or resolved with no action. This is a transient overlay state -- the underlying state is preserved in the dispute record.
|
|
|
|
### 2.2.2 Payment + escrowState lifecycle
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> PENDING : payment intent created
|
|
PENDING --> PROCESSING : funds detected on chain
|
|
PENDING --> CANCELLED : intent expired / buyer cancelled
|
|
|
|
PROCESSING --> COMPLETED : funds confirmed
|
|
PROCESSING --> FAILED : verification failed
|
|
|
|
COMPLETED --> FUNDED : escrowState set to FUNDED
|
|
FUNDED --> PARTIALLY_FUNDED : partial payment received (escrowState only)
|
|
PARTIALLY_FUNDED --> FUNDED : top-up reaches threshold
|
|
|
|
FUNDED --> RELEASABLE : delivery confirmed
|
|
FUNDED --> DISPUTED : dispute opened
|
|
RELEASABLE --> DISPUTED : dispute opened
|
|
|
|
DISPUTED --> FUNDED : dispute rejected / no action
|
|
DISPUTED --> RELEASABLE : dispute RESOLVED_SELLER (release authorized)
|
|
DISPUTED --> REFUNDING : dispute RESOLVED_BUYER (refund authorized)
|
|
|
|
RELEASABLE --> RELEASING : payout initiated
|
|
RELEASING --> RELEASED : payout confirmed on chain
|
|
RELEASING --> FAILED : payout failed on chain
|
|
|
|
REFUNDING --> REFUNDED : refund confirmed on chain
|
|
REFUNDING --> FAILED : refund failed on chain
|
|
|
|
FAILED --> RELEASING : admin retries payout
|
|
FAILED --> REFUNDING : admin retries refund
|
|
|
|
RELEASED --> [*]
|
|
REFUNDED --> [*]
|
|
CANCELLED --> [*]
|
|
```
|
|
|
|
### 2.2.3 Dispute lifecycle
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> OPEN : buyer or seller opens dispute
|
|
OPEN --> UNDER_REVIEW : admin assigned
|
|
OPEN --> REJECTED : admin rejects (duplicate / spam / invalid)
|
|
OPEN --> CLOSED : auto-close (e.g. requester cancelled)
|
|
|
|
UNDER_REVIEW --> RESOLVED_BUYER : admin resolves in buyer favor
|
|
UNDER_REVIEW --> RESOLVED_SELLER : admin resolves in seller favor
|
|
UNDER_REVIEW --> RESOLVED_SPLIT : admin splits funds
|
|
UNDER_REVIEW --> REJECTED : admin rejects after review
|
|
|
|
RESOLVED_BUYER --> CLOSED : financial action completed
|
|
RESOLVED_SELLER --> CLOSED : financial action completed
|
|
RESOLVED_SPLIT --> CLOSED : financial actions completed
|
|
REJECTED --> CLOSED : admin closes
|
|
|
|
CLOSED --> [*]
|
|
```
|
|
|
|
### 2.2.4 Cross-entity interaction diagram
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
state "FundsAccount" as FA {
|
|
[*] --> ACTIVE : account created
|
|
ACTIVE --> SETTLED : released + refunded = grossPaid - fees
|
|
ACTIVE --> CANCELLED : no funds received
|
|
SETTLED --> [*]
|
|
CANCELLED --> [*]
|
|
}
|
|
|
|
state "LedgerEntry" as LE {
|
|
[*] --> PAY_IN : funds received
|
|
PAY_IN --> HOLD : funds allocated to held
|
|
HOLD --> DISPUTE_HOLD : dispute opened
|
|
DISPUTE_HOLD --> RELEASE : dispute resolved for seller
|
|
DISPUTE_HOLD --> REFUND : dispute resolved for buyer
|
|
HOLD --> RELEASE : delivery confirmed, payout
|
|
HOLD --> REFUND : pre-shipment cancellation
|
|
RELEASE --> [*]
|
|
REFUND --> [*]
|
|
}
|
|
|
|
state "Dispute" as DS {
|
|
[*] --> OPEN : created
|
|
OPEN --> UNDER_REVIEW : admin assigned
|
|
UNDER_REVIEW --> RESOLVED_BUYER : refund ordered
|
|
UNDER_REVIEW --> RESOLVED_SELLER : release ordered
|
|
UNDER_REVIEW --> RESOLVED_SPLIT : split ordered
|
|
RESOLVED_BUYER --> CLOSED : refund executed
|
|
RESOLVED_SELLER --> CLOSED : release executed
|
|
RESOLVED_SPLIT --> CLOSED : split executed
|
|
}
|
|
|
|
DS --> FA : OPEN triggers DISPUTE_HOLD on FundsAccount
|
|
DS --> FA : RESOLVED_BUYER triggers REFUND entry
|
|
DS --> FA : RESOLVED_SELLER triggers RELEASE entry
|
|
```
|
|
|
|
---
|
|
|
|
## 2.3 Valid Transitions Table
|
|
|
|
### 2.3.1 PurchaseRequest transitions
|
|
|
|
| From | To | Trigger | Preconditions | Side effects |
|
|
|------|----|---------|--------------|-------------|
|
|
| -- | `pending` | Buyer creates request | Buyer authenticated; category exists; title + description provided | Create PurchaseRequest; fan-out notification to sellers |
|
|
| `pending` | `received_offers` | First SellerOffer saved | Valid offer; seller is active | Add offer to `offers[]`; emit `purchase-request-update` |
|
|
| `pending` | `cancelled` | Buyer cancels | No accepted offer; no payment | Set `cancelledAt`; emit `request-cancelled` |
|
|
| `received_offers` | `in_negotiation` | Buyer engages seller | At least one pending offer | Emit `purchase-request-update` |
|
|
| `received_offers` | `payment` | Payment captured (SHKeeper PAID or Web3 verified) | escrowState = FUNDED; FundsAccount exists | Set `selectedOfferId`; create chat; emit cascade |
|
|
| `received_offers` | `cancelled` | Buyer cancels | No payment | Set `cancelledAt` |
|
|
| `in_negotiation` | `received_offers` | Counter rejected, more offers pending | No accepted offer | Emit `purchase-request-update` |
|
|
| `in_negotiation` | `payment` | Payment captured | escrowState = FUNDED; FundsAccount exists | Set `selectedOfferId`; create chat; emit cascade |
|
|
| `in_negotiation` | `cancelled` | Buyer cancels | No payment | Set `cancelledAt` |
|
|
| `payment` | `processing` | Seller acknowledges | Payment COMPLETED; escrowState FUNDED | Emit `purchase-request-update` |
|
|
| `payment` | `cancelled` | Dispute resolved as refund (pre-shipment) | Dispute status RESOLVED_BUYER | Set `cancelledAt`; escrowState -> REFUNDING |
|
|
| `processing` | `delivery` | Seller marks shipped | Seller is selected seller | Set `deliveryInfo.shippedAt`; emit update |
|
|
| `processing` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
|
|
| `delivery` | `delivered` | Buyer confirms (code or fast-track) | Delivery code valid OR fast-track authorized | Set `deliveryConfirmedAt` |
|
|
| `delivery` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
|
|
| `delivered` | `confirming` | Delivery code verified | Code matches; not already confirmed | Set `deliveryCodeUsedAt` |
|
|
| `delivered` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
|
|
| `confirming` | `completed` | Escrow released | escrowState = RELEASED; FundsAccount settled or partially settled | Set `completedAt`; emit notification |
|
|
| `confirming` | `DISPUTED` | Dispute opened | Buyer or seller opens dispute | Create Dispute; escrowState -> DISPUTED |
|
|
| `DISPUTED` | `confirming` | Dispute RESOLVED_SELLER or REJECTED | Dispute status terminal | escrowState -> RELEASABLE or previous state |
|
|
| `DISPUTED` | `cancelled` | Dispute RESOLVED_BUYER | Dispute status RESOLVED_BUYER | escrowState -> REFUNDING; set `cancelledAt` |
|
|
| `completed` | `seller_paid` | Payout confirmed on chain | escrowState = RELEASED; on-chain tx verified | Set `seller_paid`; FundsAccount settled |
|
|
|
|
### FORBIDDEN PurchaseRequest transitions
|
|
|
|
| From | To | Reason |
|
|
|------|----|--------|
|
|
| `payment` | `pending` | Cannot regress after payment captured |
|
|
| `processing` | `received_offers` | Cannot regress after seller acknowledges |
|
|
| `completed` | `cancelled` | Cannot cancel a completed transaction |
|
|
| `seller_paid` | any non-terminal | `seller_paid` is terminal |
|
|
| `cancelled` | any | `cancelled` is terminal |
|
|
| `payment` | `payment` | No self-transitions |
|
|
| any non-DISPUTED | `DISPUTED` without dispute | DISPUTED only via dispute creation |
|
|
| `DISPUTED` | `completed` | Must go through confirming first (release path) |
|
|
| `DISPUTED` | `cancelled` without dispute | Only dispute resolution can trigger cancellation from DISPUTED |
|
|
|
|
### 2.3.2 Payment + escrowState transitions
|
|
|
|
| From escrowState | To escrowState | Trigger | Preconditions | Side effects |
|
|
|-----------------|---------------|---------|--------------|-------------|
|
|
| null (PENDING) | `PARTIALLY_FUNDED` | SHKeeper PARTIAL webhook | `Payment.status` = PENDING | LedgerEntry PAY_IN (partial) |
|
|
| null (PENDING) | `FUNDED` | SHKeeper PAID/OVERPAID webhook or Web3 verification | `Payment.status` = PENDING or PARTIALLY_FUNDED | LedgerEntry PAY_IN; LedgerEntry HOLD; FundsAccount ACTIVE |
|
|
| `PARTIALLY_FUNDED` | `FUNDED` | Top-up reaches threshold | cumulative PAY_IN >= expected amount | LedgerEntry PAY_IN (top-up); LedgerEntry HOLD (full) |
|
|
| `FUNDED` | `RELEASABLE` | Buyer confirms delivery OR auto-release timer | No active dispute; FundsAccount releasable >= 0 | LedgerEntry (no entry -- held becomes releasable via derived balance) |
|
|
| `FUNDED` | `DISPUTED` | Dispute opened | Dispute created for this payment | LedgerEntry DISPUTE_HOLD; escrowState set to DISPUTED (addresses [[T06]]) |
|
|
| `RELEASABLE` | `DISPUTED` | Dispute opened | Dispute created | LedgerEntry DISPUTE_HOLD; block all payouts |
|
|
| `DISPUTED` | `FUNDED` | Dispute REJECTED or no action | Dispute status terminal | LedgerEntry REVERSAL (reverse DISPUTE_HOLD) |
|
|
| `DISPUTED` | `RELEASABLE` | Dispute RESOLVED_SELLER | Dispute status RESOLVED_SELLER; seller wallet present | LedgerEntry REVERSAL (reverse DISPUTE_HOLD); funds become releasable |
|
|
| `DISPUTED` | `REFUNDING` | Dispute RESOLVED_BUYER or RESOLVED_SPLIT | Dispute status terminal; releasable >= refund amount | LedgerEntry REVERSAL; LedgerEntry REFUND |
|
|
| `RELEASABLE` | `RELEASING` | Admin/system initiates payout | No active dispute; releasable >= release amount; seller wallet present | Create outgoing Payment; LedgerEntry RELEASE |
|
|
| `RELEASING` | `RELEASED` | Payout confirmed on chain | On-chain tx hash verified; `Payment.status` -> RELEASED | FundsAccount released += amount; emit notification |
|
|
| `RELEASING` | `FAILED` | Payout failed on chain | On-chain tx reverted | LedgerEntry REVERSAL; admin alerted |
|
|
| `REFUNDING` | `REFUNDED` | Refund confirmed on chain | On-chain tx hash verified; `Payment.status` -> REFUNDED | FundsAccount refunded += amount; emit notification |
|
|
| `REFUNDING` | `FAILED` | Refund failed on chain | On-chain tx reverted | LedgerEntry REVERSAL; admin alerted |
|
|
| `FAILED` | `RELEASING` | Admin retries payout | Admin step-up auth; seller wallet present | New LedgerEntry RELEASE |
|
|
| `FAILED` | `REFUNDING` | Admin retries refund | Admin step-up auth; buyer wallet present | New LedgerEntry REFUND |
|
|
|
|
### FORBIDDEN escrowState transitions
|
|
|
|
| From | To | Reason | Threat addressed |
|
|
|------|----|--------|-----------------|
|
|
| `FUNDED` | `RELEASING` | Must pass through RELEASABLE first | [[T05]] |
|
|
| `DISPUTED` | `RELEASING` | Cannot release during active dispute | [[T06]] |
|
|
| `DISPUTED` | `RELEASED` | Must resolve dispute first | [[T06]] |
|
|
| `RELEASED` | `FUNDED` | Cannot undo a confirmed release | [[T05]] |
|
|
| `REFUNDED` | `FUNDED` | Cannot undo a confirmed refund | [[T05]] |
|
|
| `RELEASED` | `REFUNDED` | Cannot refund after release | [[T05]] |
|
|
| `REFUNDED` | `RELEASED` | Cannot release after refund | [[T05]] |
|
|
| `FUNDED` | `FUNDED` | No self-transitions | [[T23]] |
|
|
| `RELEASED` | `RELEASED` | No self-transitions (prevents double release) | [[T05]] |
|
|
| any | `FUNDED` (after `RELEASED`/`REFUNDED`) | Terminal states are terminal | [[T05]], [[T23]] |
|
|
| `DISPUTED` | `RELEASABLE` (without dispute resolution) | Only dispute resolution clears disputed state | [[T06]] |
|
|
|
|
### 2.3.3 Dispute transitions
|
|
|
|
| From | To | Trigger | Preconditions | Side effects |
|
|
|------|----|---------|--------------|-------------|
|
|
| -- | `OPEN` | Buyer or seller creates dispute | PurchaseRequest exists; initiator is buyer or seller; no other OPEN/UNDER_REVIEW dispute for same request | Create Dispute; create group chat; escrowState -> DISPUTED (if FUNDED/RELEASABLE); set `responseDeadline = now + 48h`; set `deadline = now + 7d` |
|
|
| `OPEN` | `UNDER_REVIEW` | Admin assigned | Admin authenticated with admin role | Set `adminId`; add admin to chat participants; emit notification |
|
|
| `OPEN` | `REJECTED` | Admin rejects | Admin authenticated; dispute is duplicate, spam, or invalid | Set `rejectedAt`; escrowState -> previous state (reverse DISPUTE_HOLD) |
|
|
| `OPEN` | `CLOSED` | Auto-close (requester cancelled) | Requester cancels the dispute | Set `closedAt` |
|
|
| `UNDER_REVIEW` | `RESOLVED_BUYER` | Admin resolves in buyer favor | Admin authenticated; admin is assigned admin; resolution action provided | Set `resolution`; escrowState -> REFUNDING; LedgerEntry REFUND |
|
|
| `UNDER_REVIEW` | `RESOLVED_SELLER` | Admin resolves in seller favor | Admin authenticated; admin is assigned admin; resolution action provided | Set `resolution`; escrowState -> RELEASABLE |
|
|
| `UNDER_REVIEW` | `RESOLVED_SPLIT` | Admin splits funds | Admin authenticated; refundAmount + releaseAmount <= releasable | Set `resolution` with amounts; escrowState -> RELEASING + REFUNDING |
|
|
| `UNDER_REVIEW` | `REJECTED` | Admin rejects after review | Admin authenticated | Set `rejectedAt`; escrowState -> previous state |
|
|
| `RESOLVED_BUYER` | `CLOSED` | Refund executed | escrowState = REFUNDED; on-chain tx confirmed | Set `closedAt` |
|
|
| `RESOLVED_SELLER` | `CLOSED` | Release executed | escrowState = RELEASED; on-chain tx confirmed | Set `closedAt` |
|
|
| `RESOLVED_SPLIT` | `CLOSED` | Both release and refund executed | escrowState = RELEASED + REFUNDED | Set `closedAt` |
|
|
| `REJECTED` | `CLOSED` | Admin closes | Admin authenticated | Set `closedAt` |
|
|
|
|
### FORBIDDEN Dispute transitions
|
|
|
|
| From | To | Reason |
|
|
|------|----|--------|
|
|
| `CLOSED` | any | `CLOSED` is terminal |
|
|
| `RESOLVED_BUYER` | `UNDER_REVIEW` | Cannot reopen after resolution |
|
|
| `RESOLVED_SELLER` | `UNDER_REVIEW` | Cannot reopen after resolution |
|
|
| `RESOLVED_SPLIT` | `UNDER_REVIEW` | Cannot reopen after resolution |
|
|
| `REJECTED` | `UNDER_REVIEW` | Cannot reopen after rejection |
|
|
| `OPEN` | `RESOLVED_BUYER` | Must be assigned to admin first |
|
|
| `OPEN` | `RESOLVED_SELLER` | Must be assigned to admin first |
|
|
| `OPEN` | `RESOLVED_SPLIT` | Must be assigned to admin first |
|
|
| `UNDER_REVIEW` | `OPEN` | Cannot regress |
|
|
|
|
---
|
|
|
|
## 2.4 Dispute Hold Enforcement
|
|
|
|
Dispute holds are the primary defense against [[T06]] (release during active dispute) and support [[T18]] (insider manipulation detection).
|
|
|
|
### Hold creation
|
|
|
|
1. When a dispute is created (`Dispute.status = OPEN`), the system MUST:
|
|
a. Create a `LedgerEntry` of type `DISPUTE_HOLD` on the associated FundsAccount.
|
|
b. Set `Payment.escrowState = DISPUTED` on all funded payments associated with the PurchaseRequest.
|
|
c. Log the transition in the Dispute timeline.
|
|
d. This MUST happen atomically within a single MongoDB transaction (or with compensating actions if transactions are unavailable).
|
|
|
|
2. If the FundsAccount has no funds (e.g., dispute on an unfunded request), the dispute is still created but no `DISPUTE_HOLD` entry is made. The dispute is informational only.
|
|
|
|
3. If multiple payments exist for the same PurchaseRequest (should not happen with canonical model, but defensively): all funded payments MUST be placed in `DISPUTED` state.
|
|
|
|
### Hold checking
|
|
|
|
Every release and refund operation MUST check for active dispute holds BEFORE executing:
|
|
|
|
```
|
|
function checkDisputeHold(accountId):
|
|
activeDisputes = Dispute.find({
|
|
purchaseRequestId: account.purchaseRequestId,
|
|
status: { $in: [OPEN, UNDER_REVIEW] }
|
|
})
|
|
if (activeDisputes.length > 0):
|
|
throw Error("Cannot release/refund: active dispute hold exists")
|
|
```
|
|
|
|
This check MUST occur:
|
|
1. In the FundsService (not just in the controller) -- service-layer enforcement.
|
|
2. After acquiring any distributed lock (to prevent TOCTOU races).
|
|
3. On both the `Payment.escrowState` field AND the FundsAccount ledger (defense in depth).
|
|
|
|
### Hold release
|
|
|
|
A dispute hold is cleared only when:
|
|
|
|
1. **Dispute resolved**: `Dispute.status` transitions to `RESOLVED_BUYER`, `RESOLVED_SELLER`, `RESOLVED_SPLIT`, `REJECTED`, or `CLOSED`.
|
|
2. The resolution handler creates the appropriate `LedgerEntry` (RELEASE, REFUND, or REVERSAL).
|
|
3. The `Payment.escrowState` transitions from `DISPUTED` to the next state based on resolution.
|
|
|
|
### Admin override
|
|
|
|
An admin MAY override a dispute hold under the following conditions:
|
|
|
|
1. **Step-up authentication required**: The admin must re-authenticate (password + 2FA) before overriding.
|
|
2. **Audit trail**: An `ADJUSTMENT` ledger entry is created with:
|
|
- `actor.type = ADMIN`
|
|
- `actor.userId = adminId`
|
|
- `sourceEvent.adminActionId` set to a unique action ID
|
|
- `description` containing the reason for override
|
|
3. **Two-person approval**: For accounts holding more than a configurable threshold (default: 1000 USDT), two separate admin approvals are required.
|
|
4. **Alert**: An alert is sent to the operations team for every admin override.
|
|
5. **Rate limit**: An admin can override at most 3 disputes per hour. Exceeding this triggers a security alert.
|
|
|
|
---
|
|
|
|
## 2.5 Release and Refund Preconditions
|
|
|
|
### 2.5.1 Release to seller
|
|
|
|
**Preconditions (ALL must be true):**
|
|
|
|
| Condition | Check |
|
|
|-----------|-------|
|
|
| Escrow state | `escrowState` is `RELEASABLE` or (`DISPUTED` with dispute `RESOLVED_SELLER`) |
|
|
| No active disputes | No Dispute with status `OPEN` or `UNDER_REVIEW` for this PurchaseRequest |
|
|
| Sufficient releasable balance | `FundsAccount.releasable >= releaseAmount` (derived from ledger) |
|
|
| Balance invariant | `grossPaid = providerFees + platformFees + released + refunded + releasable + held + disputed` |
|
|
| Seller wallet present | `seller.profile.walletAddress` is set and valid (`^0x[0-9a-fA-F]{40}$`) |
|
|
| No concurrent release | Distributed lock acquired for this FundsAccount |
|
|
| Payment not already released | No existing `LedgerEntry` of type `RELEASE` with the same idempotency key |
|
|
| Offer price not modified | `SellerOffer.price.amount` matches the amount at payment creation time (addresses [[T19]]) |
|
|
|
|
**Execution steps:**
|
|
|
|
1. Acquire distributed lock (Redis) for `accountId`.
|
|
2. Re-check all preconditions inside the lock.
|
|
3. Create `LedgerEntry` of type `RELEASE` with idempotency key.
|
|
4. Set `Payment.escrowState = RELEASING`.
|
|
5. Initiate on-chain transfer (SHKeeper payout or manual admin signing).
|
|
6. On on-chain confirmation: set `Payment.escrowState = RELEASED`.
|
|
7. Update `FundsAccount.releasedAmount` (derived from ledger).
|
|
8. Release distributed lock.
|
|
9. If `released + refunded + fees = grossPaid`, set `FundsAccount.status = SETTLED`.
|
|
|
|
### 2.5.2 Refund to buyer
|
|
|
|
**Preconditions (ALL must be true):**
|
|
|
|
| Condition | Check |
|
|
|-----------|-------|
|
|
| Escrow state | `escrowState` is `FUNDED`, `RELEASABLE`, `DISPUTED`, or `PARTIALLY_FUNDED` |
|
|
| Dispute resolved (if disputed) | If `escrowState = DISPUTED`, Dispute status must be `RESOLVED_BUYER` or `RESOLVED_SPLIT` |
|
|
| OR pre-shipment cancellation | PurchaseRequest status is `payment` or earlier (no delivery started) |
|
|
| OR admin override | Admin with step-up auth has authorized the refund |
|
|
| Sufficient releasable balance | `FundsAccount.releasable >= refundAmount` (derived from ledger) |
|
|
| Balance invariant | Invariant holds before refund |
|
|
| Buyer wallet present | `Payment.blockchain.sender` (original buyer wallet) is available |
|
|
| No concurrent refund | Distributed lock acquired for this FundsAccount |
|
|
|
|
**Execution steps:**
|
|
|
|
1. Acquire distributed lock for `accountId`.
|
|
2. Re-check all preconditions inside the lock.
|
|
3. Create `LedgerEntry` of type `REFUND` with idempotency key.
|
|
4. Set `Payment.escrowState = REFUNDING`.
|
|
5. Build refund transaction payload (destination = buyer's original wallet).
|
|
6. Admin signs and broadcasts (or automated if SHKeeper payout).
|
|
7. On on-chain confirmation: set `Payment.escrowState = REFUNDED`.
|
|
8. Release distributed lock.
|
|
9. If `released + refunded + fees = grossPaid`, set `FundsAccount.status = SETTLED`.
|
|
|
|
### 2.5.3 Partial release and refund
|
|
|
|
Partial operations are allowed only in the following cases:
|
|
|
|
1. **Split dispute resolution** (`RESOLVED_SPLIT`): The admin specifies `refundAmount` and `releaseAmount` where `refundAmount + releaseAmount <= releasable`.
|
|
2. **Partial refund of overpayment**: If the buyer overpaid, the surplus can be refunded separately. `refundAmount = grossAmountPaid - expectedAmount`.
|
|
3. **Admin override with justification**: Admin may specify a custom amount with step-up auth and audit trail.
|
|
|
|
**Amount tracking for partial operations:**
|
|
|
|
- Each partial operation creates a separate `LedgerEntry` with its specific amount.
|
|
- The FundsAccount tracks cumulative `releasedAmount` and `refundedAmount` (derived from ledger sums).
|
|
- Multiple partial operations MAY occur for a single FundsAccount, but the invariant `released + refunded + fees <= grossPaid` must always hold.
|
|
- A FundsAccount reaches `SETTLED` when `released + refunded + providerFees + platformFees = grossPaid` and `held = 0` and `disputed = 0`.
|
|
|
|
### 2.5.4 Admin override additional checks
|
|
|
|
For any admin-initiated release or refund that deviates from the standard flow:
|
|
|
|
| Check | Requirement |
|
|
|-------|-------------|
|
|
| Step-up authentication | Admin must re-authenticate (password + 2FA) |
|
|
| Reason required | A non-empty `reason` string must be provided |
|
|
| Audit entry | `LedgerEntry` of type `ADJUSTMENT` with full context |
|
|
| Two-person approval (high value) | If amount > threshold (default 1000 USDT), second admin must approve |
|
|
| Alert | Operations team notified immediately |
|
|
| Rate limit | Max 5 admin overrides per hour per admin |
|
|
|
|
---
|
|
|
|
## 2.6 Ghost States and Cleanup
|
|
|
|
The following "ghost states" were identified in [[Platform Logical Audit - 2026-05-24]] as status values that exist in enums but are not set by any automated flow.
|
|
|
|
### 2.6.1 Assessment
|
|
|
|
| Ghost State | Entity | Assessment | Resolution |
|
|
|-------------|--------|------------|------------|
|
|
| `confirmed` (Payment.status) | Payment | No automated flow sets this. SHKeeper maps PAID directly to `completed`. The data model includes it as an intermediate between `processing` and `completed`, but the webhook skips it. | **Remove from canonical enum.** Any records currently in `confirmed` should be migrated to `COMPLETED`. |
|
|
| `partial` (Payment.escrowState) | Payment | Used by Escrow Flow and Payment Flow - SHKeeper but NOT present in the Payment data model enum. This is a documentation/implementation gap. | **Replace with `PARTIALLY_FUNDED` in canonical enum.** Add to the Payment model's `escrowState` enum. Migrate any records in `partial` to `PARTIALLY_FUNDED`. |
|
|
| `draft` (PurchaseRequest.status) | PurchaseRequest | Referenced only in the Marketplace API status transition description. No code creates requests in `draft` state; requests are always `pending`. | **Remove from canonical enum.** No migration needed (no records should exist in `draft`). |
|
|
| `finalized` (PurchaseRequest.status) | PurchaseRequest | Referenced in Purchase Request Flow as post-ratings state. No automated flow sets it. | **Remove from canonical enum.** Use `completed` as the terminal state. Track ratings via `PurchaseRequest.rating` and `Review` model. Map any `finalized` records to `completed`. |
|
|
| `archived` (PurchaseRequest.status) | PurchaseRequest | Referenced in Purchase Request Flow as 30-day idle state. No automated job sets it. | **Remove from canonical enum.** Use an `archivedAt` timestamp or `isArchived` boolean instead. Map any `archived` records to `completed` with `archivedAt` set. |
|
|
| `active` (PurchaseRequest.status) | PurchaseRequest | Listed in Data Model enum but semantically ambiguous (could mean pre-payment or post-payment). | **Remove from canonical enum.** Pre-payment is `pending`/`received_offers`/`in_negotiation`. Post-payment is `payment`. Map any `active` records to the appropriate state based on payment status. |
|
|
| `pending_payment` (PurchaseRequest.status) | PurchaseRequest | Listed in Data Model enum but no flow creates this state. Payment creates a Payment record, not a PurchaseRequest status change to `pending_payment`. | **Remove from canonical enum.** Map any `pending_payment` records to `pending`. |
|
|
| `replacement` (Dispute.resolution.action) | Dispute | Listed in Data Model enum. Not a financial action; cannot be represented in the ledger. | **Remove from canonical resolution action enum.** Track as a non-financial timeline event in the dispute. |
|
|
|
|
### 2.6.2 Migration strategy
|
|
|
|
**Phase 1: Data audit (before enforcement)**
|
|
|
|
Run a data audit script to identify all records currently in ghost states:
|
|
|
|
```javascript
|
|
// Identify ghost state records
|
|
db.payments.find({ status: "confirmed" })
|
|
db.payments.find({ escrowState: "partial" })
|
|
db.purchaserequests.find({ status: { $in: ["draft", "finalized", "archived", "active", "pending_payment"] } })
|
|
db.disputes.find({ "resolution.action": "replacement" })
|
|
```
|
|
|
|
**Phase 2: Migration (with ledger)**
|
|
|
|
For each ghost state, migrate records to the canonical state:
|
|
|
|
| Ghost State | Migration Target | Migration Action |
|
|
|-------------|-----------------|-----------------|
|
|
| `Payment.status = "confirmed"` | `"COMPLETED"` | Update status; verify escrowState is FUNDED |
|
|
| `Payment.escrowState = "partial"` | `"PARTIALLY_FUNDED"` | Update escrowState; verify partial amount in metadata |
|
|
| `PurchaseRequest.status = "draft"` | `"pending"` | Update status |
|
|
| `PurchaseRequest.status = "finalized"` | `"completed"` | Update status; verify escrow is RELEASED |
|
|
| `PurchaseRequest.status = "archived"` | `"completed"` | Update status; set `archivedAt = updatedAt` |
|
|
| `PurchaseRequest.status = "active"` | Determined by payment status | If payment exists: `"payment"`. If not: `"pending"` or `"received_offers"` based on offers |
|
|
| `PurchaseRequest.status = "pending_payment"` | `"pending"` | Update status |
|
|
| `Dispute.resolution.action = "replacement"` | `"NO_ACTION"` | Update action; add timeline note that replacement was agreed |
|
|
|
|
**Phase 3: Enum enforcement**
|
|
|
|
After migration:
|
|
|
|
1. Update all model schemas to use only the canonical enum values.
|
|
2. Add Mongoose enum validation that rejects any value not in the canonical enum.
|
|
3. Add a `pre('save')` hook that validates state transitions against the valid transitions tables.
|
|
4. Run the data audit script again to verify zero ghost state records.
|
|
|
|
**Phase 4: Documentation update**
|
|
|
|
After enforcement, update all referencing documents:
|
|
|
|
- [[Payment]] data model: update status and escrowState enums
|
|
- [[PurchaseRequest]] data model: update status enum
|
|
- [[Dispute]] data model: update status and resolution.action enums
|
|
- [[Payment API]]: update status model section
|
|
- [[Dispute API]]: update status and decision enums
|
|
- [[Marketplace API]]: update status transition descriptions
|
|
- [[Escrow Flow]]: update state machine diagram
|
|
- [[Dispute Flow]]: update state machine diagram
|
|
- [[Purchase Request Flow]]: update state machine diagram
|
|
|
|
---
|
|
|
|
## Appendix A: Threat Traceability
|
|
|
|
| Threat | Addressed by |
|
|
|--------|-------------|
|
|
| [[T05]] Double payout / double release | Immutable ledger with idempotency keys; distributed locks; releasable balance derived from ledger (not stored); FundsAccount status enforcement |
|
|
| [[T06]] Dispute bypass (release during active dispute) | `DISPUTED` escrow state; mandatory dispute hold check before every release/refund; service-layer enforcement |
|
|
| [[T18]] Insider fund manipulation | Immutable append-only ledger; admin override requires step-up auth; two-person approval for high-value operations; audit trail on every entry; reconciliation detects discrepancies |
|
|
| [[T19]] Seller price manipulation post-acceptance | Release precondition checks offer price matches payment-time snapshot; offer status `accepted` is immutable after payment |
|
|
| [[T23]] State machine inconsistency | Single canonical enum set; valid transition tables enforced at service layer; ghost states removed; pre-save validation hooks |
|
|
|
|
## Appendix B: Glossary
|
|
|
|
| Term | Definition |
|
|
|------|-----------|
|
|
| **FundsAccount** | The financial ledger account for a single purchase request, tracking all money movement |
|
|
| **LedgerEntry** | An immutable, append-only record of a single financial event |
|
|
| **escrowState** | The state of funds held by the platform, independent of payment processing status |
|
|
| **Dispute hold** | A mechanism that blocks all fund movement while a dispute is active |
|
|
| **Releasable balance** | Funds available for release to seller or refund to buyer (derived from ledger) |
|
|
| **Running balance** | A snapshot of all balance fields appended to each ledger entry for query efficiency |
|
|
| **Ghost state** | A status value that exists in an enum but is never set by any automated flow |
|
|
| **Step-up authentication** | Re-authentication required for high-risk admin operations (release, refund, override) |
|
|
| **Distributed lock** | A Redis-based mutex preventing concurrent operations on the same FundsAccount |
|
|
|
|
## Appendix C: Implementation Order
|
|
|
|
1. Add `FundsAccount` and `LedgerEntry` models to the database.
|
|
2. Build `FundsService` with idempotency enforcement and balance derivation.
|
|
3. Implement `EscrowStateMachine` service with valid transition validation.
|
|
4. Add `DISPUTED` state to Payment model and dispute hold logic to DisputeService.
|
|
5. Integrate FundsService into existing payment webhooks (SHKeeper and Web3).
|
|
6. Integrate FundsService into release and refund flows.
|
|
7. Add distributed locking for FundsAccount operations.
|
|
8. Build reconciliation job.
|
|
9. Migrate ghost state records.
|
|
10. Update all referencing documentation.
|