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.

8 min readUpdated 6 August 2026admin, dashboard, sla, disputes, escrow

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

Every figure on this page, in one request

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/dispute sorts 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 filedOn set to the moment the trade was disputed, which is earlier than the row's createdAt. Ageing those by createdAt would 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.approval72 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 breached only. 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 no filedOn.

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 generatedAt timestamp. 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.