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 │ either party disputes
▼ ▼
CANCELLED DISPUTED ──► COMPLETED (admin)
▲ └──────► CANCELLED (admin)
│ payment window elapses
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 |
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. 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.
-
The fee is computed and stored on the trade as
escrowFee. -
The trade row is written with
escrowStatus: HELDandescrowAmountset, 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.
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 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.