The trade lifecycle and escrow

Every P2P trade status, who may move it, exactly when escrow is taken and released, how the payment window works, what the cron expires, and the chat and review that run alongside.

8 min readUpdated 3 August 2026trades, escrow, timeouts, chat

A trade is the only object in P2P that moves money. Everything about how it is built exists to guarantee one property: a trade's escrow is paid out at most once, no matter which of the six doors settles it.

The state machine

                 buyer confirms          seller releases
   PENDING ──────────────────────► PAYMENT_SENT ──────────────► COMPLETED
      │                                  │
      │ buyer cancels                    │ either party disputes
      ▼                                  ▼
  CANCELLED                          DISPUTED ──► COMPLETED  (admin)
      ▲                                  └──────► CANCELLED  (admin)
      │ payment window elapses
   EXPIRED

The allowed transitions are exactly these — nothing else is accepted:

From To
PENDING PAYMENT_SENT, CANCELLED, EXPIRED
PAYMENT_SENT COMPLETED, DISPUTED
DISPUTED COMPLETED, CANCELLEDadmin only
COMPLETED (terminal)
CANCELLED (terminal)
EXPIRED (terminal)

A completed trade cannot be disputed and cannot be reopened. COMPLETED → DISPUTED used to exist so a buyer could complain after the fact; what it actually did was put a settled trade back on the dispute money path, and the second payout was drawn from whatever else the seller happened to be holding — in practice another offer's escrow.

Post-completion complaints are handled outside the escrow path: support, and if necessary a manual ledger adjustment. There is no button that undoes a release.

Opening a trade

The taker chooses an amount and one of the offer's payment methods. Before anything is locked, an unauthenticated pre-flight answers "can this person actually fund it" so a doomed request never takes the offer's write lock.

Then, under a Redis lock on the offer (p2p:initiate:<offerId>:lock, 30 s) and a SERIALIZABLE transaction:

  1. The offer row is locked and must be ACTIVE, and not owned by the taker — you cannot trade with yourself.

  2. The amount is checked against the offer's limits, converting min/max from the price currency into the traded currency. The offer must have enough remaining total.

  3. One active trade per offer per user. A taker with a PENDING or PAYMENT_SENT trade on this offer is refused with a 409 naming the existing trade. Previously expired, cancelled or completed trades do not block.

  4. The maker's requirements are applied — completed trades, success rate, account age, verified email, prior counterparty, KYC. Each failure is a 403 that says which bar was missed and by how much.

  5. Platform and per-currency minimums are applied, in that order.

  6. The payment method is checked — it must be attached to this offer and still marked available.

  7. Escrow is taken. For a BUY offer, the taker is the seller: their balance is verified and the trade amount is held, keyed on the new trade id. For a SELL offer the funds were already held at offer creation, and this step only verifies the hold is still there.

  8. The fee is computed and stored on the trade as escrowFee.

  9. The trade row is written with escrowStatus: HELD and escrowAmount set, the payment method's details are snapshotted onto the trade, and the offer's remaining total is decremented.

The payment method snapshot matters: trade.paymentDetails is a copy of the name, icon, instructions, processing time and the method's metadata as they were at that instant. If the maker later edits their bank details, the running trade still shows what the buyer agreed to.

Escrow: one authority, one guard

All escrow movement goes through a single settlement function. Six callers use it — the seller's release, user cancellation, the expiry cron, admin trade resolution, admin trade cancellation and admin dispute resolution — and they all share one wallet idempotency namespace, p2p_settle_<tradeId>.

p2pTrade.escrowStatus is the guard, taken under a row lock and flipped to a terminal value in the same transaction as the money:

escrowStatus Meaning
NONE nothing was ever held
HELD funds are held and this trade is settleable
RELEASED paid out — buyer credited, or split
REFUNDED returned — to the seller, or back to the parent offer

Four settlement outcomes exist:

Outcome What happens Fee charged
RELEASE_TO_BUYER the whole escrow goes to the buyer yes
REFUND_TO_SELLER the escrow returns to the seller's spendable balance no
RETURN_TO_OFFER the share is handed back to the parent offer, moving no money at all no
SPLIT divided by an explicit buyer share on the buyer's share

For a SELL offer the collateral was committed to the offer, not to the trade. Returning it to the seller's spendable balance un-commits liquidity the seller deliberately committed, and the offer's advertised capacity could then never be restored — it would be advertising funds it no longer holds.

That made abandoned trades free sabotage: a buyer could open and walk away from trades until any offer was dead. Keeping the funds in inOrder under the offer's own attribution makes the capacity genuinely restorable. If the offer can no longer absorb the share — a BUY offer, a deleted offer, one that is no longer collateralized — it falls back to refunding the seller, so escrow is never left held by nothing.

Amounts are clamped twice: to the escrow recorded on the trade, and to the seller wallet's real inOrder. A drifted ledger can therefore settle what genuinely exists rather than throwing and stranding the trade.

The fee

One fee exists. There is no maker fee, no taker fee, no dispute fee.

The platform's cut of every P2P trade, taken out of the crypto delivered to the buyer.

It is computed at trade initiation from the trade amount and stored on the row, then charged at settlement. Three things constrain it:

  • It comes out of the buyer's proceeds and can never exceed them.
  • It is never charged on a refund — a seller getting their own funds back is not taxed for it.
  • A floor of 0.0001 applies, but only while it stays under 5 % of the trade amount. Without that cap, the absolute floor was ten dollars' worth of BTC — enough to equal a legal minimum-size BTC trade, produce a zero buyer credit, be rejected by the wallet service, and leave the escrow permanently stuck.

Sellers who hold Super Admin are exempt from the fee entirely. Collected fees are booked as platform revenue and recorded in p2p_commissions.

The payment window

One definition, used by the expiry cron, the trade view, the WebSocket and the payment-confirmation endpoint. In precedence order:

  1. offer.tradeSettings.autoCancel, when it is a finite number ≥ 0. A value of 0 means never auto-cancel and stops here — it does not mean "expire immediately" and does not fall through.
  2. offer.tradeSettings.paymentWindow — a legacy alias with the same meaning.
  3. The admin setting p2pDefaultPaymentWindow.
  4. 30 minutes.

The window is measured from trade.createdAt. The admin switch p2pAutoCancelUnpaidTrades disables trade expiry altogether; it does not affect the stale-payment safety net below.

They used to be re-derived independently in four places with different fallbacks — 30 minutes in the cron, 240 in the API. A trade could be shown a four-hour countdown while the cron killed it after thirty minutes. If you are reading a countdown that disagrees with reality, that class of bug is what to suspect first.

What the cron does, every minute

Expires unpaid trades. PENDING trades past their window are set to EXPIRED, their escrow returned to the parent offer, and the offer's advertised capacity restored by what settlement actually released. Expiry is recorded on the cancellation columns (cancelledAt, cancellationReason: "Payment window elapsed") — there is no expiredAt column.

Auto-disputes stale payments. A trade sitting in PAYMENT_SENT for more than 24 hours is moved to DISPUTED with a high-priority dispute filed on the buyer's behalf. The escrow deliberately stays held; an admin settles it.

The deadline is anchored on paymentConfirmedAt — the instant the buyer declared payment — not on updatedAt. Every chat message rewrites the trade row, which bumped updatedAt and reset the safety net indefinitely: a seller who kept the conversation alive could hold a buyer's escrow forever.

Expires dead offers. See Offers.

Each scan is capped at 500 rows per tick and drains across ticks. The trade scan is deliberately newest-first: offers with autoCancel: 0 never expire and so never leave the candidate set, and an oldest-first batch would be permanently occupied by exactly those rows.

An admin can trigger the whole handler by hand from the trades screen if a window has clearly lapsed and nothing has happened.

Who may do what

Action Who When Refused if
Confirm payment buyer only PENDING, inside the window expired, or already past PENDING
Release funds seller only PAYMENT_SENT any terminal status
Cancel buyer only while PENDING PENDING after payment is confirmed, or while disputed
Dispute either party PAYMENT_SENT outside that status
Cancel a disputed trade nobody but an admin always, for users

Two of these deserve spelling out to support staff:

  • A seller cannot cancel a pending trade. They are told to pause their offer instead. Letting a seller cancel would let them pull out of a trade a buyer is mid-way through paying for.
  • Nobody can cancel after PAYMENT_SENT. The buyer cancelling would lose their money; the seller cancelling would be a straight theft — keep the fiat, get the crypto back. Both parties are directed to open a dispute.

A cancellation reason of at least 10 characters is required, and is stored on the trade.

Release

The seller presses release. In one transaction: the escrow settles to the buyer net of the fee, the trade goes COMPLETED with completedAt set, the activity log is written, and the offer's attributed escrow is drawn down.

After the commit: audit entries, notifications, a WebSocket status broadcast, and — if the MLM addon is installed — affiliate rewards for both parties' referrers, calculated from what settlement actually moved rather than a second local fee calculation.

Releasing is idempotent by two independent mechanisms. A Redis key caches the result for an hour, and the escrow authority's escrowStatus guard settles a trade at most once regardless.

Pressing the button again returns a success-shaped response that states plainly it is a replay, with buyerCredited: 0 and alreadySettled: true. It used to replay the cached body verbatim — "Funds released successfully" with a fresh-looking credit — which was indistinguishable from a second payout that never happened.

Chat and attachments

Every trade has a private conversation between the two parties, stored as MESSAGE entries on the trade timeline and delivered over a WebSocket subscription rather than by polling.

  • Messages are capped at 1000 characters and 100 per hour.
  • Images can be attached, up to 5 MB each.
  • An attachment can be read only by the buyer and the seller of that trade.
  • Admins can post into the conversation from the dispute or trade screen; those messages are marked as admin messages and both parties are notified.

Message text is sanitised on the way in: line breaks and tabs survive, control characters and the characters that could open a tag do not. It is rendered as plain text.

Reviews

After a trade completes, each party may review the other. The UI submits a single 1–5 star rating; the storage is three independent 0–100 dimensions — communication, speed and trust — and a star rating is spread across all three. A caller can send the dimensions individually instead.

Feedback text is capped at 2000 characters. Review averages feed the hourly reputation job and the trader cards on the market board.