The P2P desk dashboard and its two SLA queues
/admin/p2p is a work queue — the dispute book aged against a 24-hour budget, the offer queue against 72 hours, escrow frozen per currency, and where every figure comes from.
/admin/p2p is the first screen in the P2P admin nav and the only one that
answers "who is waiting on me right now". It was rebuilt as a work queue: two
queues are aged against platform SLA budgets, every count links to the rows that
produced it, and no money figure is ever summed across currencies.
Reading it needs access.p2p. Everything it links to is gated separately — see
the permission table on Moderating offers and trades.
The page polls that endpoint every 60 seconds, and only while the tab is visible. One load is roughly 25 counts plus six grouped aggregates over the trade, offer, dispute and commission tables, so leaving the tab open overnight would otherwise run that scan 2,880 times for nobody.
The dispute queue is the masthead
Directly under the page title, before any tile, sits the open dispute queue drawn as three bands against a budget.
SLA_HOURS.dispute is 24 hours — deliberately tighter than a withdrawal,
which gets seven days. The reason is escrow: while a dispute runs, the seller's
crypto is out of their wallet and the buyer has not been paid, so both sides
are frozen out of their own money until somebody rules. A late withdrawal is
one person waiting. A late dispute is two.
| Band | Means | Drawn |
|---|---|---|
| Within target | under 12 hours old | green |
| Due soon | 12 hours or more, under 24 | amber |
| Past target | 24 hours or more | red |
"Due" is half the budget, not a fixed hour count. The same rule runs across every queue on the platform: a 24-hour dispute budget and a seven-day KYC budget both want "getting on" to mean the same fraction of the time available.
The three buckets are disjoint by construction. The endpoint counts due
and breached server-side and sends only those two; the page derives
fresh = open − due − breached rather than counting it a fourth time. That is
why the bands can never sum to something other than the Open Disputes tile
beside them.
A red band also raises a full-width alert above the tiles naming how many disputes are past target and how much escrow the open ones are sitting on.
The queue's clock is filedOn, not createdAt
Ageing uses p2pDispute.filedOn, for two reasons:
- It is the column
/admin/p2p/disputesorts its own queue by, so the dashboard and the queue rank the same dispute the same way. - A dispute raised by the trade-timeout cron carries a
filedOnset to the moment the trade was disputed, which is earlier than the row'screatedAt. Ageing those bycreatedAtwould restart their clock at the moment a background job happened to notice them.
The Open Disputes panel below the tiles shows the head of that same queue —
open statuses only (PENDING, IN_PROGRESS), oldest first, six rows, each with
its own age badge. It is the top of the list at /admin/p2p/dispute, not a
second list that can disagree with it.
The offer approval queue
The Offers awaiting review tile and the Offer book panel age
PENDING_APPROVAL offers against SLA_HOURS.approval — 72 hours.
The budget is looser on purpose. Nobody is out of pocket in the sense a dispute
means: it is a queue, not an incident. But read that alongside the fact that
PENDING_APPROVAL is a collateralized status — a SELL offer took its full
advertised total out of the maker's spendable balance when it was submitted and
holds it while it waits. See the approval queue.
Two differences from the dispute queue, worth knowing before you go looking for something that is not there:
- The offer queue reports
breachedonly. There is no "due soon" bucket and no three-band meter; the tile turns amber once anything is past 72 hours. - It ages on
createdAt, because an offer has nofiledOn.
The panel also shows the age of the oldest pending offer, computed at render from a server timestamp rather than from a server-side duration — a duration goes stale between polls, an instant does not.
The two SLA numbers live in two files, and both must move together
| File | Role |
|---|---|
backend/src/utils/sla.ts |
what the endpoint counts against |
frontend/config/sla.ts |
what the badges and age columns render against |
The frontend file is a mirror, not an import. Edit one without the other and the dashboard badge disagrees with the queue it links to, which is worse than having no badge at all.
The same two constants are read by the dispute queue's own age column and by the platform operations inbox, so all three surfaces call the same dispute late at the same moment.
Growth figures are rolling windows, not calendar periods
Every percentage on this page compares two windows of equal length, both anchored at now:
| Figure | Current window | Compared against |
|---|---|---|
| Offers | last 1 day | the 1 day before that |
| Active trades | last 7 days | the 7 days before that |
| Disputes | last 1 day | the 1 day before that |
| Revenue | last 30 days | days 60–30 ago |
No calendar boundary is involved, so no timezone assumption is either. Trade growth is measured as trades opened per window — counting how many trades opened in a past window are still active is not a comparable quantity, since they have since completed or expired.
When the previous window holds no data at all the endpoint returns null rather than a percentage. That is why a figure sometimes has no change badge: the ratio is genuinely undefined, and the older code answered "+100%" for it, which is how "Active Trades" used to sit at a permanent +100%.
Money is per currency and is never added up
There is no FX oracle anywhere in this platform, so 1 BTC + 1 USDT is not a
number and this page never pretends otherwise. Every money figure is returned as
a per-currency breakdown:
| Field | What it holds |
|---|---|
totalVolumeByCurrency |
all-time completed-trade volume |
weekVolumeByCurrency |
completed-trade volume, last 7 days |
avgTradeValueByCurrency |
volume ÷ completed trades, per currency |
platformRevenueByCurrency |
all-time commissions |
escrow.byCurrency |
escrow currently frozen |
escrow.disputedByCurrency |
of that, the part a dispute is sitting on |
A flat scalar is sent alongside each only when exactly one currency is in play; with several it is null, and the page renders the largest denomination plus "+2 more" rather than a sum with a currency symbol glued to the front. When any figure spans more than one currency the masthead says so once, at the top, instead of leaving six "+N more" suffixes to imply it.
The throughput chart follows the same rule: Volume and Revenue are only offered as chart metrics when every non-empty day of the seven agrees on one currency. Otherwise the server sends null for those points, and a series of nulls draws as a gap that reads exactly like a day nobody traded.
Which currency a trade counts under
Trade volume is denominated in the offer's pricing currency, resolved as:
COALESCE(NULLIF(o.priceCurrency, ''), JSON_UNQUOTE(JSON_EXTRACT(o.priceConfig, '$.currency')), 'USD')Newer offers carry priceCurrency as a column; older ones only have it inside
the priceConfig JSON. 'USD' is the same default offer creation applies when
no price currency is supplied — it is a fallback, not a conversion.
Escrow is different. p2pTrade.escrowAmount is denominated in the trade's
asset currency (it is set to the trade amount at initiation), so the escrow
figures group on t.currency instead. Frozen escrow is restricted to trades in
PENDING, PAYMENT_SENT or DISPUTED and escrowStatus = 'HELD' — a
settled trade has its escrow zeroed, but a row written before those columns
existed can still carry a stale HELD, and a frozen-funds figure that counts
money already paid out is worse than no figure.
Revenue
Platform revenue is read from p2p_commissions, left-joined to p2p_trades for
the currency. Commissions carry no currency of their own — they are charged in
the trade's asset currency — so COALESCE(t.currency, 'UNKNOWN') is the group
key, and a commission whose trade row has gone reports as UNKNOWN rather than
being silently dropped.
The seven-day timeline is bucketed in UTC on both sides
Sequelize connects with the session time zone at UTC, so the timeline groups on
DATE_FORMAT(createdAt, '%Y-%m-%d') server-side and builds its seven day
buckets from Date.UTC(...) client-side. The axis labels are formatted
server-side as "Aug 2" with timeZone: "UTC" and are treated as category labels
by the chart, so nothing re-parses them in the viewer's zone.
If your desk is in UTC+8, the day boundary on this chart is not your local midnight. That is deliberate and consistent; it is not a bug to file.
The trade pipeline card
activeTrades is one number doing two jobs, so the pipeline card breaks it out:
| Row | Trade status | Waiting on |
|---|---|---|
| Awaiting payment | PENDING |
the buyer to pay |
| Awaiting release | PAYMENT_SENT |
the seller to release |
| In dispute | DISPUTED |
you |
activeTrades is the first two added together. DISPUTED is in neither —
it is already the desk's problem — which is why it needs its own row or those
trades appear in no count on the page at all.
Below it sits escrow held across all live trades, and how much of that total is frozen by open disputes.
The panels that are samples, not totals
Three panels on this page are truncated lists, and each says so:
- Open disputes — six rows, oldest first.
- Latest trades — the five most recent, any status.
- Top currencies by active offers — the endpoint caps the distribution at ten and the panel shows five.
The endpoint groups completed trades by sellerId and ranks by trade count,
and the caption on the card says so. Ranking by a summed total would order
sellers by whichever currency happens to have the largest nominal units — a
seller who did ten trades in IDR would outrank one who did a hundred in USD.
Volume is still shown on each row, as context, using the same per-currency helper as everything else. It is not the rank key. The panel this replaced was captioned "By trading volume" and ranked by count, which is the version to stop quoting.
What is deliberately gone
The composite health score dial is no longer drawn. It was one number out of
100 built from four inputs, each with a hardcoded step — dispute rate,
high-priority dispute count, pending-offer rate, flagged-offer count. Nobody
acts on "84 out of 100"; they act on the four numbers that made it. All four are
still on the page, each linking to the rows it counts. The endpoint still
returns healthScore and systemHealth for any integration that reads them.
The Quick Actions panel is gone too — two of its three links were in the nav directly above it.
When the page cannot load
Two different states, and they are not the same:
- A failed poll with data on screen. An amber "Refresh failed" notice
appears and the figures stay, because they are still true as of their own
generatedAttimestamp. The live dot in the masthead flips to Stale so a green pulse never sits above a clock that stopped advancing. - A failure with nothing behind it. The body is replaced entirely — a console of zeros under a red banner would be a page full of claims it cannot support. The header stays, so Retry and direct links to Disputes and Trades still work. Both queues are usable without this aggregate.