Files
nick-doc/09 - Audits/Funds Ledger and Escrow State Machine Specification.md
Siavash Sameni 4cf5c49274 docs(audit): align documentation with post-remediation backend reality
- 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/)
2026-05-24 11:16:29 +04:00

54 KiB

title, tags, created, status, reviewers
title tags created status reviewers
Funds Ledger and Escrow State Machine Specification
specification
funds-ledger
escrow
state-machine
canonical
2026-05-24 canonical
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


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

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

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

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

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:

// 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:


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.