The activity log, and proving who moved an escrow

The /admin/p2p/activity screen — its category and severity filters, the two tables behind it, which admin doors write an audit row, and how to prove which admin awarded a disputed escrow.

8 min readUpdated 6 August 2026admin, audit, activity, disputes, compliance

Every P2P admin action is recorded, but the record is spread across a screen, a table and three JSON columns, and only one of the four is browsable. This page is how you get from "somebody released that escrow" to a name, a timestamp and the reason they gave.

The screen

/admin/p2p/activity. The page is gated on access.p2p.activity; the endpoint behind it, GET /api/admin/p2p/dashboard/activity/all, is gated on view.p2p.activity. Both keys are seeded, but the seeders attach no permission to any role — a Super Admin bypasses the check entirely, and every other role needs both granted in Admin → Roles. Grant them as a pair: one without the other leaves an admin who can open the screen but not load it, or the reverse.

It reads one table: p2p_activity_logs, newest first, 25 rows a page.

The filters are queries, not a view over one page

The category buttons, the severity buttons and the search box are query parameters sent to the server, and the count under them is what the server matched across the whole table.

This is worth stating because it was not always true. The screen used to fetch the twenty most recent rows and then filter those in the browser, so a search for a dispute from last week returned "No activities found" — indistinguishable from "it never happened" — and the Critical tab meant "critical events among the most recent twenty". If you are reading an older install's behaviour into this screen, stop: an empty result here means the log genuinely holds nothing matching.

Categories map to a substring test on the type column:

Button Matches type containing Notes
Everything no filter
Trades TRADE
Disputes DISPUTE
Payment methods PAYMENT
Users USER
System none of the above expressed as the negation, so it is the catch-all

A row can match more than one pattern — ADMIN_TRADE_RESOLVED is a Trade row, and a dispute update on a trade is a Dispute row — so the categories are not a partition. Only System is exclusive by construction.

Severity is the same kind of derivation, and this is the part that surprises people:

Button Means Matches
Critical high type contains DISPUTE or FLAG
Notable medium type contains APPROVE or REJECT, and neither DISPUTE nor FLAG
Routine low none of DISPUTE, FLAG, APPROVE, REJECT

createP2PAuditLog stamps a riskLevel — LOW / MEDIUM / HIGH / CRITICAL — into the row's details JSON at write time, and that value is what decides whether a security alert fires.

The screen's severity filter does not read it. It tests the type string, which is a different classification with a different answer: an entry stamped CRITICAL by determineRiskLevel (a funds transfer, say) sits under Routine here, because its type contains none of the four words.

So "Critical" on this screen means "a dispute or a flag", nothing more. And on an admin row there is no stamped risk level to fall back on: createP2PAuditLog is called only from the user-side trade routes, while every admin door logs through logP2PAdminAction, which stamps none — see the SQL below.

The search box is a LIKE over three columns: type, details and relatedEntityId. details is the JSON blob the row's description is rendered from, so an amount, a currency or a rejection reason is searchable. A trade id is searchable because relatedEntityId is.

The two tables

Table What it is Written by Read by
p2p_activity_logs the event stream, and the admin trail the audit helper, logP2PAdminAction, the timeout cron, several routes directly the activity screen, the dashboard's recent panel
p2p_admin_activity a dedicated admin trail one route — the admin offer note endpoint, with type NOTE_ADDED nothing

p2p_activity_logs columns:

Column Holds
userId who acted — nullable, so a cron row has none
type the event name; admin actions are prefixed ADMIN_
action the event name again — the audit helper and the routes that write a row directly copy type into it, but logP2PAdminAction stores the bare action, so its rows read TRADE_RESOLVED against a type of ADMIN_TRADE_RESOLVED
details a JSON string. Every writer stamps timestamp; logP2PAdminAction adds isAdminAction: true and whatever metadata the door passed; only createP2PAuditLog also adds riskLevel and adminId
relatedEntity TRADE, OFFER, DISPUTE, USER or WALLET
relatedEntityId the id of that thing

The table exists, is modelled and has columns for it (type, relatedEntityId, relatedEntityName, adminId), but exactly one endpoint writes to it — POST /api/admin/p2p/offer/{id}/note — and no screen or route reads it back. Do not go looking there for a resolution or an approval; they are all in p2p_activity_logs.

Which admin doors write an audit row

logP2PAdminAction(userId, action, entityType, entityId, metadata, ctx, transaction) writes a p2p_activity_logs row with type set to ADMIN_<action>. Ten admin doors call it:

Door action recorded Written inside the change's transaction
POST /admin/p2p/offer/{id}/approve OFFER_APPROVED no
POST /admin/p2p/offer/{id}/reject OFFER_REJECTED yes
POST /admin/p2p/offer/{id}/pause OFFER_PAUSED no
POST /admin/p2p/offer/{id}/activate OFFER_ACTIVATED no
POST /admin/p2p/offer/{id}/disable OFFER_DISABLED yes
POST /admin/p2p/offer/{id}/flag OFFER_FLAGGED yes
PUT /admin/p2p/offer/{id} ADMIN_UPDATE, or the status-specific name no
POST /admin/p2p/trade/{id}/cancel TRADE_CANCELLED yes
POST /admin/p2p/trade/{id}/resolve TRADE_RESOLVED yes
PUT /admin/p2p/dispute/{id} DISPUTE_UPDATE yes

The transaction column is the one that matters for an audit. Every door that moves escrow writes its log inside the same transaction as the money — cancel, resolve, dispute update, reject, disable — so a rolled-back resolution leaves no entry claiming it happened, and an entry that exists is proof the change committed.

The four that write outside a transaction (approve, pause, activate, and the admin offer edit) do so before their commit. A failure between the log line and the commit would leave an entry describing something that did not happen. None of those four moves money out of an escrow, so the exposure is an offer status, not a balance — but treat those four rows as "an admin attempted this" rather than "this happened", and confirm against the offer's own status.

Failures in the logger itself are swallowed and reported under the P2P_ADMIN log module: an audit failure never rolls back the action it was describing.

Proving who moved an escrow

Three questions come up, and they have three different answers.

"Who ruled on this dispute?"

p2p_disputes.resolution is a JSON column and its resolvedBy field is a user id. The admin dispute payload resolves it against the user table and returns it as resolvedByName alongside the raw id, so the case screen at /admin/p2p/dispute/<id> shows the name. That field was returned raw and rendered nowhere for a long time — if you are looking at an older export, expect a bare UUID.

The same shape exists on the trade: p2p_trades.resolution carries outcome, notes, resolvedBy, resolvedAt and the settlement figures (escrowConsumed, buyerCredited, sellerRefunded, platformFee).

SELECT d.id,
       d.status,
       d.resolvedOn,
       JSON_UNQUOTE(JSON_EXTRACT(d.resolution, '$.outcome'))    AS outcome,
       JSON_UNQUOTE(JSON_EXTRACT(d.resolution, '$.notes'))      AS notes,
       CONCAT(u.firstName, ' ', u.lastName)                     AS ruledBy
  FROM p2p_disputes d
  LEFT JOIN user u
         ON u.id = JSON_UNQUOTE(JSON_EXTRACT(d.resolution, '$.resolvedBy'))
 WHERE d.tradeId = '<tradeId>';

"Why did they rule that way?"

The reasoning is free text, and it is copied to three places. The two the case screens read back:

  • p2p_trades.resolution.notes — the note the admin typed on the resolve form, sanitised. The same string is copied into the dispute's resolution and pushed onto the trade timeline as an ADMIN_RESOLVED entry.
  • The dispute's admin notes — entries of type: "note" inside the p2p_disputes.activityLog JSON array. The admin case payload extracts them as adminNotes, newest first, each with its content, timestamp and the admin's name.

The p2p_activity_logs trail carries a copy of the prose too, on one row of two. The resolve door writes two rows, both typed ADMIN_TRADE_RESOLVED and told apart by the action column:

  • the row the route writes directly (action = ADMIN_TRADE_RESOLVED) holds previousStatus, finalStatus, resolution, fundsReleased, adminId, adminName and notes — the admin's typed reasoning, the same sanitised string as above;
  • the row written through logP2PAdminAction (action = TRADE_RESOLVED) holds the four structured fields and neither notes nor a name.

Every other door is structured only, apart from the short free-text field it collects — reason on reject, disable, flag and trade-cancel, adminNotes on approve — which is copied into details. So insist on the notes: the resolve door is the only one that puts an admin's full reasoning in the log.

"What happened to this case, in order?"

p2p_disputes.activityLog is the full trail — status changes, escrow movements and who made them, not only the notes. The admin dispute payload parses it and returns it as activity, sorted oldest-first, each entry carrying type, content, createdAt, adminName, adminId, from and to.

messages, evidence, activityLog and resolution on p2p_disputes are all DataTypes.JSON with no model getter. On a MariaDB install the driver hands back raw TEXT, so a naive Array.isArray() guard sees a string and returns an empty array — silently, at HTTP 200, on the screen whose whole purpose is reading that material before somebody rules on it.

The admin dispute serializer now parses defensively, so the screens are correct. If you query these columns yourself, parse rather than assume, and if a query of your own returns nothing for a dispute that visibly has evidence, this is why.

The money itself

The activity log records the decision. The ledger records the movement, and they are different records. The escrow legs of a settlement — the hold execution, the seller credit, the buyer credit and the ECO chain mirror — share one wallet idempotency namespace built from the trade id, p2p_settle_<tradeId>. The platform fee does not. collectPlatformFee books it under platform_fee_P2P_TRADE_p2p_fee_<tradeId>, which no p2p_settle_% pattern matches, so ask for both or you will read a settlement with its revenue leg missing:

SELECT createdAt, type, amount, currency, referenceId, description
  FROM transaction
 WHERE idempotencyKey LIKE 'p2p_settle_<tradeId>%'
    OR idempotencyKey = 'platform_fee_P2P_TRADE_p2p_fee_<tradeId>'
 ORDER BY createdAt;

The idempotency key is not the only handle. On SPOT and FIAT the fee row also carries referenceId = p2p_fee_<tradeId>_fee on a unique index — one row per trade, forever — which is the safer join when you are tracing revenue. An ECO fee row is credited through ecoCredit with no referenceId threaded through, so there the key is all you have. See Reconciling P2P escrow for the sub-key breakdown and Where P2P revenue lands for the fee row.

To read the activity trail for one entity:

SELECT l.createdAt,
       l.type,
       l.relatedEntity,
       CONCAT(u.firstName, ' ', u.lastName) AS actor,
       JSON_UNQUOTE(JSON_EXTRACT(l.details, '$.riskLevel')) AS riskLevel,
       l.details
  FROM p2p_activity_logs l
  LEFT JOIN user u ON u.id = l.userId
 WHERE l.relatedEntityId = '<tradeId>'
   AND l.deletedAt IS NULL
 ORDER BY l.createdAt;

riskLevel comes back NULL for every ADMIN_* row, because the admin doors log through logP2PAdminAction and it stamps none; the column is populated only on the rows createP2PAuditLog writes from the user-side trade routes. Read a NULL there as "no risk level was recorded", never as "low".

details is stored as a JSON string in a TEXT column, so JSON_EXTRACT works on MySQL's implicit parse but is not indexable. On a large log, filter on relatedEntityId and createdAt first.

Reputation entries no longer drown the dashboard

The hourly reputation job writes a REPUTATION_UPDATE / REPUTATION_SCORE_CALCULATED row, but only when a user's score actually moves. It compares against the most recent such row for that user and skips the insert when the number is unchanged.

It previously wrote one per P2P-active user per hour, forever. Because the dashboard's recent-activity panel takes the five newest rows with no type filter, every hour those five were reputation noise hiding the trades and disputes the panel exists to show. If your dashboard still looks like that, the job predates the change — the old rows stay, so clear them or wait them out.

The same job writes a REPUTATION_MILESTONE / MILESTONE_<n> row the first time a trader crosses 10, 50 or 100 completed trades. That row is the milestone flag — it is written before the notification, so a crash cannot cause a repeat. The check that reads it ignores soft deletes, so removing the row from the admin side changes nothing; only a hard delete would re-notify the trader.

There is no reputation column and no reputation table. The activity log row is the score's only home, and nothing reads the value back: guided matching recomputes reputation from p2p_trades and p2p_reviews on the fly.

What this log does not cover

  • Chat. Trade messages live on the trade, not here.
  • The offer's own history. Each offer carries an activityLog JSON column and an adminNotes text column of its own; the note endpoint appends a timestamped line to the latter.
  • Retention. Nothing prunes p2p_activity_logs. The table is paranoid, so a delete soft-deletes; the screen and the dashboard both exclude soft-deleted rows.
  • Export. There is an export helper in the codebase that shapes a date range for compliance, but no admin screen or route calls it. Getting a period out today means SQL.