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.
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 — or the seller, │ either party disputes
│ once the payment window passes ▼
▼ DISPUTED ──► COMPLETED (admin)
CANCELLED ├──────► CANCELLED (admin)
▲ └──────► PAYMENT_SENT (withdrawn)
│ 24h after the payment window closes
EXPIREDThe allowed transitions are exactly these — nothing else is accepted:
| From | To |
|---|---|
PENDING |
PAYMENT_SENT, CANCELLED, EXPIRED |
PAYMENT_SENT |
COMPLETED, DISPUTED |
DISPUTED |
COMPLETED, CANCELLED — admin only; PAYMENT_SENT when the trader who filed the dispute withdraws it, before an admin picks it up |
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:
-
The offer row is locked and must be
ACTIVE, and not owned by the taker — you cannot trade with yourself. -
The amount is checked against the offer's limits, converting
min/maxfrom the price currency into the traded currency. The offer must have enough remaining total. -
One active trade per offer per user. A taker with a
PENDINGorPAYMENT_SENTtrade on this offer is refused with a 409 naming the existing trade. Previously expired, cancelled or completed trades do not block. -
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.
-
Platform and per-currency minimums are applied, in that order.
-
The payment method is checked — it must be attached to this offer and still marked available.
-
Escrow is taken — on the offer's crypto leg, from whoever owes it. The leg is resolved from the offer, not from the trade's direction (see Which leg is escrowed), and it decides all three of owner, currency and amount:
- Crypto-denominated BUY: the taker is the seller. Their balance is verified and the trade amount is held in the offer's currency, keyed on the new trade id.
- Crypto-denominated SELL: the funds were already held at offer creation, and this step only verifies the hold is still there.
- Fiat-denominated, either direction: the crypto payer's price leg is held for the trade total — the maker on a BUY offer, the taker on a SELL. A wallet is auto-created only when the escrow owner is the caller; a maker with no wallet for that leg is refused rather than provisioned, so tapping Trade cannot mint wallets for a stranger.
An offer with fiat on both legs is refused here with a 409, the same class of refusal as a missing seller wallet: it is a fact about the offer. Only legacy offers can be in that shape — publishing one is refused.
-
The fee is computed and stored on the trade as
escrowFee, quoted on the escrowed leg, because settlement deducts it from the escrow without converting anything. -
The trade row is written with
escrowStatus: HELD,escrowAmount(the figure actually held, in the escrow currency) and the escrow identity —escrowCurrency,escrowWalletType,escrowOwnerId— set together; 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 — the counterparty credited, or split |
REFUNDED |
returned — to the escrow's owner, or back to the parent offer |
Four settlement outcomes exist. Their names date from when the escrow was always the asset leg and its owner was therefore always the seller; read them as holder-relative — a release pays whichever party is not holding the escrow, a refund returns it to whoever is:
| Outcome | What happens | Fee charged |
|---|---|---|
RELEASE_TO_BUYER, also spelled RELEASE_TO_COUNTERPARTY |
the whole escrow goes to the party who is not the escrow owner — the crypto buyer on an ordinary trade, the asset seller on a fiat-denominated one | yes |
REFUND_TO_SELLER, also spelled REFUND_TO_ESCROW_OWNER |
the escrow returns to its owner'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 share to the recipient | on the recipient's share |
The two spellings are the same outcome; the unambiguous ones exist so a caller does not have to name a role that is wrong half the time.
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 escrow's owner, so escrow
is never left held by nothing.
A fiat-denominated trade always takes that fallback. Its escrow is the price leg, held from a party who is not necessarily the maker, and the offer's pool is denominated in the asset leg it never held — handing a USDT escrow to a pool counted in euros would be a unit error, not a return. So the money goes back to the person it came from, and the offer's advertised capacity is restored separately, in its own currency.
Amounts are clamped twice: to the escrow recorded on the trade, and to the
escrow 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.
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.0001applies, 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:
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.offer.tradeSettings.paymentWindow— a legacy alias with the same meaning.- The admin setting
p2pDefaultPaymentWindow. - 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 still unpaid 24 hours after
their window closed are set to EXPIRED, their escrow returned to the parent
offer, and the offer's advertised capacity restored by what settlement actually
released. That grace is what makes a late payment possible at all, and it lives
in the sweep rather than in the deadline: the window still closes exactly when
the offer said it would, so the countdown a buyer is shown stays honest — only
the sweep waits. 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 — late or not |
PAYMENT_SENT or any terminal status, including once the sweep has expired the trade |
| Release funds | seller only | PAYMENT_SENT |
any terminal status |
| Cancel | buyer, window open or not; seller only once the payment window has passed | PENDING |
seller inside the window, or on an offer whose window never expires; 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 until the payment window has
passed. Before it they are refused and pointed at the trade chat, the buyer
being the only party who can end it until the deadline: the offer promised a
window, and taking the escrow back inside it would make that promise
worthless. The refusal used to send them to pause the offer instead, which was
a dead end — an offer cannot be edited while a trade is running against it, so
the advice produced a second refusal and no way out. Once it has passed the seller may cancel on demand —
the deadline's job is to release them from waiting, not to end the trade by
itself. An offer whose
autoCancelis 0 has no window to pass, so on that offer a pending trade can only be cancelled by the buyer. - Neither trader 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. An admin is not held to that: the admin cancellation door (permissionedit.p2p.trade) acceptsPENDING,PAYMENT_SENTandDISPUTEDalike and settles the escrow through the same authority. No dispute has to be open first.
A cancellation reason of at least 10 characters is required, and is stored on the trade.
Release
Whoever's crypto is held presses release — the seller on an ordinary trade, and
on a fiat-denominated one the asset buyer, since they are the party who paid
the coin and received the cash. In one transaction: the escrow settles to the
other party 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.
POST /api/p2p/trade/{id}/release loads the trade by sellerId and
POST /api/p2p/trade/{id}/confirm loads it by buyerId. On a fiat-denominated
trade both of those are the wrong party — the escrow owner is the asset buyer and
the off-platform payer is the asset seller — so the trade room draws each action
for the person who must take it and the API refuses it. Until those two doors
authorise on the escrow owner and its counterparty, a fiat-denominated trade is
finished from the admin side: the case desk resolves it through the same escrow
authority and the money moves correctly.
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.
- The room outlives the trade for a while. On a
COMPLETED,CANCELLEDorEXPIREDtrade both parties can keep writing — text and images alike — until Chat After a Trade Ends (p2pPostTradeChatHours, 168 hours by default) has passed since the trade ended, or since Support last wrote on it, whichever is later. So Support can pick up any finished trade's conversation just by writing into it, however old the trade is. Outside that window a message is refused with a 400 that says when the trade ended and when its chat closed, and the room's composer is disabled with the same reason; set the setting to 0 and the chat closes the moment the trade ends, with Support's messages reopening nothing. ADISPUTEDtrade's room is never closed by this, so the parties can keep talking while an admin arbitrates. - A message sent after the trade ended travels like any other: it reaches the other trader's room and notifications, and the admin trade screen. When Support has written on that trade it also alerts your P2P admins — in-app and by push, quoting the line — so whoever asked hears the answer without having to reopen the room (notifications). Support here means an admin who is not one of the two traders.
- 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.