P2P Trading 6.2.6
11 August 2026
This release has upgrade notes. Read them before updating — they describe behaviour changes that need your attention.
P2P Trading v6.2.6
Release Date: August 11, 2026 Tags: MARKETPLACE, PAYMENT METHODS, DISPUTES, ADMIN, ESCROW, TRADES, OFFERS, FEES, SECURITY, BUG-FIXES, PERFORMANCE, DATABASE, INDEXES, REPUTATION
Overview
This release repairs two ways a seller's collateral came apart from a live trade's escrow, corrects what the marketplace promises a buyer before they commit, and finishes a performance pass over the backend. Pausing, disabling or rejecting an offer with a trade in flight handed that trade's escrow back to the seller; the admin offer editor changed an offer's advertised total while moving no money, publishing capacity nobody had collateralized.
The marketplace quoted the buyer the amount before the escrow fee while the offer's own page quoted it after, so one trade reached two ways promised two different receipts. Trades started from the marketplace board were refused outright, and every refusal on the dispute desk reached the operator as a database message naming no cause.
Read Upgrade Notes before updating: the escrow repairs need you to inspect records you already hold, an admin offer edit now takes collateral from the maker's wallet and can be refused, an install on MariaDB has lost moderation history it cannot recover, and five indexes are built the first time the backend starts.
These routes import a cleanup step that ships with the core, so an older core stops them loading. Requires Core v6.6.3.
Update Instructions
pnpm updatorNothing to run, nothing to configure, no setting changes, and no ruling you have already made is altered.
The escrow repairs change what happens from now on. They do not go back and correct offers or trades that were already damaged, so Upgrade Notes names what to go and look at.
If you have disputes sitting on the desk that refused to close, open them again after updating — they will either rule normally or tell you plainly why they will not.
Upgrade Notes
Escrow belonging to a live trade was handed back when an offer was taken down
A sell offer's collateral is held once, against the offer. Opening a trade does not take a second hold — it carves a share out of the offer's, and that share is only drawn down from the offer when the trade settles. So while a trade is in flight, the offer still carries it.
Pausing, disabling or rejecting that offer released all of it. The buyer's escrow went back to the seller, the trade then settled against escrow that no longer existed, and a buyer who may already have marked the fiat leg paid was credited short or not at all. The existing safety cap could not catch this, which is why it looked safe: the offer's unspent collateral and the live trade's share are held from the same seller wallet, so the cap saw funds enough to cover the whole figure and released the lot. That cap defends against a wallet holding less than the ledger claims; it says nothing about which trade the held funds belong to.
- Fixed a takedown releasing only what no live trade is holding, and leaving the rest attributed to the offer rather than zeroing it — so those funds can still be reconciled, and still be returned if the trade is later cancelled.
- Go and look at any offer you paused, disabled or rejected while it had a trade in flight. Those are the trades where a buyer may have sent fiat and received less than the escrow they were owed. Nothing in the product flagged it at the time: the trade closed and every screen agreed with itself.
- The escrow shortfall alarm in your logs now measures against the unattributed figure, so escrow a live trade is legitimately holding no longer reads as a ledger discrepancy. If you have been ignoring that message because it fires constantly, it is worth reading again.
Editing an offer from the admin panel now moves the maker's money
The admin offer editor wrote the advertised total and the status straight to the row and reconciled no escrow at all. Four consequences, all of them the same defect seen from different sides:
Raising a sell offer's total published capacity that had never been collateralized, so the next buyer's trade carved its escrow out of whatever else that maker had locked — another offer's collateral — and that offer's own trade then settled against funds already paid away. Re-publishing a paused offer did the same damage with the status field alone, because pausing had already returned the collateral. Taking a live offer down through this door instead of the Pause button left the maker's funds locked with nothing left able to release them. And an offer changed from buy to sell skipped the collateral question entirely, committing as an active sell offer advertising collateral it had never held.
- Fixed the editor reconciling collateral inside the same transaction as the write: it holds the difference when a sell offer's advertised total goes up, returns what is no longer advertised when it goes down, and in both directions leaves live trades' shares untouched. A hold that commits without the row, or a row that commits without its hold, is the same defect in a smaller window.
- Changed an edit that raises a total beyond what the maker has spendable is now refused, stating the shortfall. Lower the total, or have the maker fund their wallet first.
- Check any sell offer you edited from the admin panel, and any you re-published from paused. Compare what it advertises against what the maker actually has locked. An offer advertising more than it holds is the condition that lets one trade be paid out of another's collateral.
On MariaDB, moderating an offer destroyed the record of the last time you moderated it
Every offer carries a history of what your staff have done to it — approved, activated, paused, disabled, rejected, flagged — each entry naming the admin, with a take-down's reason or a pause's released collateral beside it. It is stored as structured data, and MariaDB hands that material back as plain text where MySQL hands it back as data. Same product, same version, same settings; the answer depends only on which database the install runs.
All six of those actions read the history, add their entry and write the whole thing back. Handed text instead of data, the read scattered it one character per entry. The first action on a fresh offer therefore recorded itself with two stray marks beside it, and every action after that dissolved everything already there into hundreds of single characters and kept only itself. On the offer's admin page that reads as a wall of undated rows labelled event, with the approvals and take-down reasons that used to be there gone from among them.
- Fixed all six moderation actions reading the history as data whatever the database hands back, so an approval, activation, pause, disable, rejection or flag is recorded as itself and the entries before it survive.
- If your install runs MariaDB, treat every offer's moderation history as incomplete. Updating does not recover what was already scattered — those entries were overwritten at the moment they were written. The P2P activity log is a separate record, written independently of the offer, and was correct throughout: what an admin did to an offer, and when, can still be established from there. An installation on MySQL was never affected and its histories are intact.
An admin who trades on your own platform can now close those disputes
An admin who is the buyer or the seller on a trade must not be the person who decides where its escrow goes. That rule was already here and it is unchanged for ordinary admins. What was wrong is what it did next.
It refused, and there was nowhere else to go. A platform account that posts its own offers is a legitimate counterparty — the fee is already waived when the seller is a platform account — so on an install with one Super Admin, every dispute raised against an offer your own account posted became permanently unresolvable: escrow frozen, trade stuck in DISPUTED, and no screen in the product able to clear it. The only apparent way out was editing the database by hand.
The refusal was not even doing its job. It lived on the dispute screen alone, while the trade screen — reachable in one click from Open the trade on the same page — settled the identical escrow, closed the same dispute and asked nothing.
- Changed a Super Admin may rule on a dispute for a trade they are party to. The outcome is stamped as a self-ruling on the dispute record, on the trade record, in the P2P activity log and in the admin audit trail. It is permitted because a self-ruling somebody can see is supervisable, and a refusal that sends the operator to an unchecked door is not.
- Changed an ordinary admin is now refused on both screens rather than only on the dispute screen. If your desk relies on non-Super-Admin staff resolving trades from the trade screen, they will now be refused on trades they are party to — which is the rule your platform already believed it had.
- Go back to any dispute you could not close. They are under Admin → P2P → Disputes. Nothing was lost and no escrow moved while they were stuck.
- The refusal an ordinary admin sees now names the remedy — ask a Super Admin — rather than stating only that they are blocked.
The first backend start after updating builds five indexes
This release adds four indexes to your offers table and one to the P2P activity log. There is no separate step to run: the schema sync creates them the first time the backend starts after updating.
- On an installation with a large offer table or a long activity log, expect that one start to take noticeably longer than usual. It is one-off — every start after it is normal.
- Let it finish rather than restarting part-way. A start that is interrupted simply does the same work again on the next one.
Changed
The case file says when an admin ruled on their own trade
- Changed the dispute case screen to show a Self-ruling badge on any outcome decided by a Super Admin who was also a party to the trade, beside the settlement figures rather than buried in an activity log nobody opens while reading a case.
The landing page's trading-volume figure no longer wears a dollar sign
The headline volume figure on the P2P landing page adds up the total of every completed trade, and a trade carries no fiat currency of its own — the fiat leg's currency lives on the offer. So naira, euro and dollar volumes were summed into a single number and published with a $ in front of it, to everybody who visits.
- Changed the trading-volume figure to print the magnitude with no currency mark. The number is the same number; it has stopped claiming to be dollars.
The marketplace board reads your offers once instead of twice
The board draws two things from the same set of offers: the offers themselves, and the counts on its filter chips — how many offers exist per asset, per fiat currency, per country, per payment rail. Those counts were gathered by a second read of the offer table with the identical filter and the identical payment-method joins, so an ordinary page load fetched the same rows twice and discarded one copy.
- Changed an unfiltered board to count the chips from the offers it has already loaded. The numbers are the same numbers. A board narrowed to an asset, a fiat currency or a country still takes the second read, and so does a board that hit the 600-offer scan ceiling — above that the two reads see different offers, and reusing one for the other would make a published count depend on how the trader happened to sort the page.
- Changed the picks shelf to stop computing the thirty-day trade tally it never shows. That tally decides the order of the asset strip on the board itself, which still computes it; the shelf above it was paying for a count of every completed trade in the last month and throwing the result away.
Trader reputation is recalculated with far fewer queries
- Changed the hourly reputation job to gather its inputs 500 traders at a time. It previously issued up to six separate reads per trader — completed trades, total trades, disputes ruled against them, their review averages, their last recorded score and whether a milestone had already been logged — so an active platform put thousands of small queries through the connection pool every hour. The score, the rating maths and the milestone ladder are untouched, and a trader whose score has not moved is still not rewritten.
The trade room opens with fewer round trips
- Changed the trade screen to look up both traders' completed-trade counts and completion rates together rather than four queries one after another. Both traders' figures are the same figures, and the screen has them sooner.
Escrow fee and minimum-trade settings come from memory
- Changed the escrow fee rate and the per-currency minimum trade amounts to be read from the settings the platform already holds in memory, instead of querying the settings table on every offer view and every trade initiation. A change you save in the admin still takes effect the same way.
Indexes for how the marketplace actually reads
- Changed the offers table to carry four indexes matching the queries the marketplace makes of it: by status and side, by status with the asset and fiat pair the board filters on, and two pairing status with recency. Without them the marketplace read the whole table, and the two reads ordered by recency — the offer list and the highlight strip — sorted all of it to produce a page of results.
- Changed the P2P activity log to carry an index for the per-trader lookups the reputation job makes — the latest recorded score, and whether a milestone was already awarded — on a log that grows by a row per active trader per hour.
Fixed
Starting a trade from the marketplace was refused for the method the marketplace chose
A payment method now has two halves: the rail everyone shares ("Bank Transfer", "Wise") and the account one trader holds on it. The marketplace shows the rail, so a board of forty offers taking Bank Transfer prints one chip rather than forty — but a trade has to be opened against the account, because that is what the offer actually accepts and what the buyer is eventually paying into.
The board was handing the opener the rail where the account belonged. Every trade started from the marketplace board, or from the picks shelf above it, was refused with "Selected payment method not allowed for this offer", and there was nothing the trader could do about it — the only method on the menu was the one being rejected. It was not intermittent: it failed for every offer whose method sits on a rail, which after the split is most of them. Opening the same offer from its own page always worked, because that screen was never rewritten.
- Fixed trades opening from the marketplace board and the picks shelf. The chip still reads as the rail; what it opens the trade with is the account.
- Fixed a trader's preferred payment method being ignored on the marketplace. The default is chosen by matching the offer's methods against the trader's own, and while the board published rails those two lists could never match, so the board always fell back to the offer's first method.
A saved marketplace link from before the payment-method split showed an empty board
The marketplace filter was rewritten to name rails when payment methods were split, and a link saved before that names an individual account instead. The board was built to accept both, and did not: it answered the older form with an empty board, which reads as "nobody accepts this any more" rather than as a stale link.
This affects anything holding a marketplace URL from before the split — a browser bookmark, a link shared between traders, a campaign or a support macro.
- Fixed the marketplace answering both forms, as it was meant to. Picking a method from the filter and following a link saved before the split now return the same offers.
The marketplace promised a buyer more than the trade actually pays out
The escrow fee comes out of the buyer's proceeds, so what a buyer receives is the asset amount minus it. The opener on an offer's own page has subtracted it since the day it was written. The opener on the marketplace board had no notion of the fee at all, and the picks shelf above it rendered a plain amount-divided-by-price as the largest figure on the card, under a label promising what the reader gets. The same trade, reached two ways, quoted the buyer two different amounts — and the one on the busier screen was the one that was too high.
- Fixed the marketplace opener and the picks shelf quoting what the buyer is actually credited. A seller's figure is unchanged: they send the whole amount, and the fee comes off the other side.
- The board now publishes the platform's escrow-fee terms once per response, so a quote on the screen and the fee that is charged are worked out from the same three published values and cannot disagree.
The last sentence before an irreversible confirm quoted the seller's figure
Directly above the Confirm button, a buyer reads that confirming tells the seller to release a stated amount. That amount was the seller's gross, not the buyer's net of the platform fee — so the last, largest number a buyer saw before an action that cannot be taken back was higher than what would land in their wallet. The money summary elsewhere on the same panel already made the distinction.
- Fixed the confirmation sentence quoting the amount the buyer is actually credited, so the figures on that screen agree with each other and with what settles.
Confirm was refused on a deadline the platform would never enforce
Confirm re-checks the payment window itself, so a trade the expiry sweep has not reached yet is still refused. It worked that window out with its own shorter version of the rule the sweep uses, and two shipped configurations came apart on it. Both of them lock out the buyer — the party who has already sent fiat off-platform, and for whom Confirm is the only control that tells the seller so.
An offer set to never auto-cancel was read as an offer with no window set, so Confirm died as soon as the platform's default payment window had elapsed — thirty minutes on an install that has not changed it — in a trade the offer had promised would never expire. And with p2pAutoCancelUnpaidTrades switched off nothing ever moves a trade to expired at all — while Confirm went on refusing on that grounds. A seller may not cancel a pending trade, so the trade sat there with the fiat already gone until an operator intervened.
- Fixed Confirm resolving the payment window through the same rule the expiry sweep uses, and skipping the check entirely when auto-cancel is off. Refusing on a deadline nothing will ever act on strands a trade rather than closing it.
- The screens still show a countdown on an offer set never to auto-cancel, so that countdown can elapse while Confirm keeps working. Publishing "never" over the wire is a change the web client has to be taught to read, and is left for a later release.
An offer that came back after an appeal could never be taken down again
Disabling, rejecting or auto-expiring an offer returns the maker's collateral under a token built from the offer's id and the name of the action — the same token every time. Releasing funds under a token that has already been used is refused outright rather than treated as a repeat, so a second takedown of the same offer failed with a server error and rolled back.
- Fixed each takedown releasing under its own token, so disable and reject work on an offer that was taken down once, re-activated on appeal and needed taking down again. Until now that offer was stuck ACTIVE with no way through the admin screens to stop it.
- Fixed the automatic offer-expiry sweep, which hit the same wall: an offer that had expired once, been re-activated and then gone idle again threw on every tick, so it never expired and every run of the sweep recorded a failure.
A resolved dispute could be re-ruled in a single request
Once a ruling has moved the money, the outcome recorded on the case is the record of where those funds went, and a second ruling on a resolved case was already refused. The refusal stood down whenever the request carried any status other than resolved — which handed back the exact bypass it was written to close. One request that re-opened the case and ruled on it in the same breath overwrote the outcome, the resolver and the notes, then stamped the case resolved again on its way out, leaving an audit trail that showed a ruling the money had never followed.
- Fixed the guard refusing a second outcome on an already-resolved case whatever else the request carries. Re-opening a case is its own deliberate act: clear the ruling first, then rule again.
A public offer page carried your operators' notes and their names
An offer's page needs no login and returned the whole offer row. Two columns on that row are written only by admin doors: the log of every approve, activate, disable and flag action, each entry carrying the acting admin's id and name, and the free-text notes an operator writes about the offer — including disable reasons and the amount of collateral released. Anyone who knew an offer's id could read both, and neither column has ever been rendered anywhere in the product.
- Fixed the admin action log no longer leaving this endpoint for anybody, the offer's own maker included. It is a record about your staff, not about the offer.
- Fixed the operator's notes reaching only the maker whose offer they describe. A maker whose offer was disabled has a real interest in the one sentence that explains why their liquidity came back; nobody else is entitled to it.
A trade's ruling carried the deciding admin's account id to both traders
A trade that has been ruled on records the outcome, the adjudicator's notes and the escrow figures. The two traders are entitled to all of that; which member of your staff decided it is not theirs, so the trade screen strips the deciding admin's account id before answering either of them. On an install whose database returns that record as text rather than as data — the same difference described in Upgrade Notes — the strip did not recognise what it had been given and passed the record through whole.
- Fixed the ruling being read as data before the deciding admin is removed from it, so a buyer or seller opening their own trade gets the outcome and the figures and nothing identifying your staff. No trader-facing screen ever displayed the id; it sat in the data behind the trade room, readable by either party to that trade and nobody else.
Clearing the details on a saved payment method left them in place
A trader can clear the account details held on one of their payment methods. Sending an empty set — or details the sanitizer rejects in full — dropped the field from the write rather than blanking it, so the old account number, name or reference survived on the method untouched while the trader was told the change had saved.
- Fixed an explicit clear actually clearing the stored details.
Ruling on a dispute failed with a database message instead of a reason
Every refusal the dispute screen makes — the amount is out of range, the case is already closed, you are a party to this trade — was being thrown away and replaced by "Transaction cannot be rolled back because it has been finished with state: rollback" before it reached the operator.
The cause is one shape repeated through the screen's code. Each check cleaned up after itself and then raised its refusal; the error handler that received it cleaned up a second time, which is an error in its own right, and that error is what arrived. It happened before the handler ever looked at the refusal it was holding. So an operator reading that sentence had been refused for a perfectly ordinary reason, and there was no way to find out which — the message is identical for all of them, and nothing was written to any log.
- Fixed every refusal reaching the operator in its own words. The out-of-range amount, the already-closed case, the missing dispute and the self-dealing block each say what they are.
- Fixed the same shape in six more admin actions that refuse for their own reasons and reported this sentence instead: resolving a trade, cancelling a trade, and activating, pausing, rejecting or taking down an offer. Nine separate refusals on the dispute screen alone were reaching operators as this one message.
A ruling that succeeded could be reported as a failure
The same handler ran when something went wrong after the escrow had already settled — while reloading the case to redraw it, or while sending the notifications. It cleaned up a transaction that had already been committed, which fails for the same reason, and the operator was shown an internal server error.
The money had moved. The trade was closed, the dispute was recorded and both traders had been paid. The screen said the ruling had failed, and the natural response — rule again — met the guard that stops a settled escrow being settled twice, which reported the case as already resolved. From the desk that reads as two contradictory failures on a case that was in fact finished and correct.
- Fixed the cleanup step doing nothing when there is nothing left to clean up, so a ruling that settled is reported as having settled.
- Fixed the same exposure in every P2P route that keeps working after its record is saved — thirteen in all. Publishing an offer, filing a dispute, leaving a trade review, uploading dispute evidence and writing an internal note each finish by redrawing, broadcasting to the trade room or notifying the other trader, and a stumble in any of those turned a completed action into a server error. A trader was told their offer had failed to publish when it had published.
The automatic trade-expiry job could stop part-way through a run
- Fixed the job that cancels overdue trades and auto-disputes stale ones. It already handled each trade on its own and recorded a failure before moving to the next, but the cleanup step in that per-trade handler could itself raise a second error, and that one escaped the handler and ended the whole sweep — so one bad row left every later overdue trade on the platform unprocessed until the next run, which could fail the same way. The cleanup no longer raises when there is nothing left to clean up, so a failure on one trade is stepped over as it was meant to be.