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.
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 anADMIN_RESOLVEDentry.- The dispute's admin notes — entries of
type: "note"inside thep2p_disputes.activityLogJSON array. The admin case payload extracts them asadminNotes, 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) holdspreviousStatus,finalStatus,resolution,fundsReleased,adminId,adminNameandnotes— 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 neithernotesnor 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
activityLogJSON column and anadminNotestext 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.
Related
- Moderating offers and trades — the actions that produce these rows.
- Resolving a dispute — what a ruling does before it is logged.
- Working a trade case — the screen the audit rows point at.
- Which P2P events notify whom — the same events, delivered rather than recorded.