P2P settings
Every field on the P2P settings screen — what it does, which code reads it, which ones are enforced server-side, which only persuade the browser, and which four are stored but read by nothing.
/admin/p2p/settings has four tabs: Trading, Fees & Limits,
Platform and UI & Display. Values are written through the core settings
endpoint into the settings table, and the backend reads them through the
cached settings manager.
false is the string "false", which is truthy. Code that reads a P2P
switch has to compare explicitly, and the ones that matter do. If you add a
consumer of your own, do the same.
The page keeps to one rule: every field on it has a reader. Fourteen controls were removed on 2026-08-03 after grepping the repository for each key — a maker fee that charged nobody, a "Max Active Trades" that capped nothing, an "Enable Dispute System" switch that could not stop a single dispute. The rows survive in the table, so nothing anyone typed was destroyed; the values were simply never read. Four remaining fields are documented below as having no consumer yet, and are marked as such rather than quietly implied to work.
Trading
- UI & Display is the fourth tab
Minutes, 1–1440. It is step 3 of the payment-window resolution — an offer's own
tradeSettings.autoCancel wins, then its legacy paymentWindow alias, then
this, then a hard-coded 30 minutes.
Read by the timeout cron, the trade detail endpoint, the trade WebSocket and the trade dashboard. All four must agree; a mismatch means a customer sees a countdown that does not match the deadline actually being enforced. Payment confirmation no longer reads it at all — see below.
This used to cancel a trade the moment its window ran out, and that took the buyer's confirm button with it. A transfer held for review, a bank app that would not load, a phone that lost signal — each of them ended the trade and put a cancellation on the buyer's record.
What the deadline does now:
- the buyer keeps their I've sent… button and can pay late; the lateness lands in their payment-time figure and nowhere else;
- the seller gains a Cancel trade button they did not have before, so they can stop waiting on their own terms rather than depending on a sweep;
- this cron becomes a backstop, closing a trade nobody touched a day after its window so a forgotten trade cannot hold escrow forever.
An offer whose own window is "never" is unaffected in either direction: no deadline passes, so no exit opens and only the buyer may cancel.
With it off, an unpaid trade stays open until somebody cancels it by hand, and the seller's escrow stays locked the whole time. That is now less severe than it was — the seller can cancel once the window has passed — but a seller who never comes back still leaves escrow locked. It does not disable the 24-hour stale-payment auto-dispute, which is a different safety net.
The trade room counts up from the moment a dispute is opened and shows both traders how long it has been and how much of this window is left. It answers "has anyone even looked at this?" on the page, which is otherwise a support ticket — a second queue, staffed by the same people, asking about the first one.
Set it to what you can actually meet. A target you miss is worse than no target, because the screen tells the trader you missed it. Set it to 0 and the room shows the elapsed time and promises nothing, which is the honest setting for a desk that cannot commit to a turnaround.
Nothing resolves a dispute when this elapses, and nothing should. An escrow decided by a timer rather than by a person would pay somebody out on silence alone, which is the one outcome a dispute system exists to prevent. Passing the target makes the room say the case is overdue. That is all it does.
Leave it on where your traders pay by bank transfer. The reference is what lets a seller match an incoming credit to a trade, and its absence is the most common cause of a P2P dispute.
Turn it off where your traders do not get one. Cash, some wallet transfers and
several mobile-money flows give the payer nothing to quote, and on those the
field is an unanswerable question standing between a buyer who really has paid
and the confirmation that protects them — a trade left unconfirmed through its
window and the day of grace after it expires anyway, and that is the worst
outcome in the product, because the money is gone, the escrow has gone back to
the offer, and an EXPIRED trade cannot be disputed.
The switch moves three things together: the trade room's reference row states
whether it is a rule or a courtesy, the confirm form marks the field required or
optional, and POST /api/p2p/trade/{id}/confirm refuses or accepts an empty
one. That last part is new — the requirement used to live only in the browser,
so anything that was not the platform's own form could confirm a payment with no
reference at all.
The platform's other concurrency rule — one live trade per offer — stops a trader opening the same offer twice and stops nothing else. This is the platform-wide count, and it is what limits somebody tying up a dozen sellers at once with no intention of paying any of them.
Two things it deliberately does not do:
- It counts only the trades a trader opened themselves, against other people's offers. A maker is a participant in every trade taken against their offer, and counting those would cap your best market makers for being popular — fill five of their offers and they could no longer trade anywhere.
- A disputed trade does not count. A trader cannot close a dispute; that needs one of your staff. Counting it would mean raising a dispute costs the trader their ability to trade until somebody gets to the case, which turns a limit into a punishment for using the dispute system.
PENDING and awaiting-payment trades are what count. Refusals arrive as a 409
naming the number they are holding.
Both are enforced in three places: offer creation, trade initiation, and the admin offer edit. An offer whose limits fall outside them is rejected at creation; a trade whose value falls outside them is refused when opened.
Both figures are dollars, and the platform converts them per offer. A 100000 maximum means one hundred thousand US dollars, whatever currency an offer is quoted in — on a naira marketplace it permits roughly ₦136,500,000, at the same fiat rates the rest of the platform uses. An offer priced in a cryptocurrency is converted the same way, at the exchange's listed price for a spot currency or the ecosystem market price for a token — a 100000 ceiling on an offer priced in BTC is quoted as 1.6666 BTC while BTC is at $60,000. That is not a rounding slip: the platform rounds a ceiling down and a floor up, so the figure it quotes is always one it accepts. Round the ceiling the other way and a maker who types the number the refusal told them to type is refused a second time — 1.6667 BTC is over the bound, and is refused. The offer form and the offer page both state the bound in the offer's own currency rather than in dollars, so the number a maker reads is the number they can type.
If you have set either bound and a currency has no usable rate, offers priced in it are refused rather than quietly exempted, and the refusal names the currency and says what is missing. The rate is read from the table the code lives in, fiat first, and an enabled fiat row wins a shared code outright: a national currency you are running that has no rate on file is refused with the advice to set its rate in Finance → Currencies, and is never priced off a coin that happens to use the same letters — EGP the pound is never priced as Eigenpie. For a cryptocurrency the missing thing is a live market price: a spot currency the exchange lists but the price job has not priced yet, or a token with no ecosystem market, is refused the same way, and the fix is to price the offer in a currency with a live rate — or to clear the bounds. A currency the platform does not know at all is refused as unknown, except that a code whose national currency row exists but is switched off is refused by that name instead, with the switch as the fix. That last message is only ever the last word after every listing has failed to price the code; it is never what a listed coin gets. A risk limit that silently stops applying is worse than one that stops you — that is exactly how the old dollar minimum came to permit trades worth fractions of a cent.
The spot price used here is the exchange listing's own price column, refreshed by the price job about every two minutes and treated as US dollars for the purpose of the bound. It is quoted in USDT, and the drift is negligible for a floor and a ceiling; it is never what a trade settles at. A coin the exchange lists but the job has not priced yet is refused with the "no market price … right now" message until the next tick. USDT never waits — it counts as 1 even before its listing is priced. A token's figure is the same shape: its ecosystem market's last trade, also quoted in USDT and also taken as dollars for the bound, and a market that has never traded is "no market price" rather than a price of zero.
An install that has set neither bound never needs a rate and is never blocked by this.
Ten codes are both a national currency and a live exchange ticker — BOB, BTN, CAD, EGP, ERN, GEL, MNT, RON, SCR and SOS; RON is the Romanian leu and it is also Ronin. Which one a code means is decided by your switch in Finance → Currencies, and it has to be: the currency table is seeded with a row for every ISO code and 139 of the 161 arrive switched off, so a row nobody ever touched cannot be allowed to claim a code. That is the only reason those ten coins can be priced at all.
The cost sits on the same lever. Switch a currency off while your exchange lists the code and it stops being that currency here — offers your makers published in leu are bounded against Ronin's market price from then on, with nothing said on any screen. The offer stores the code and not which table the maker meant, so nothing can separate them after the fact.
Check priceCurrency on the live offers before switching off one of those ten.
Every other code is safe: one with no listing behind it is refused by name
("… is currently switched off under Finance → Currencies") rather than
re-priced.
Set the maximum at the point where you would rather a counterparty split the order than carry it in one escrow — a single dispute over that amount is a single decision you have to get right.
Fees & Limits
- Fees & Limits is the second tab
There is no maker fee, no taker fee and no dispute fee. One function computes the charge, at trade initiation, and stores it on the trade.
Three behaviours to know:
- The fee comes out of the buyer's proceeds and is capped at them.
- It is never charged on a refund.
- A floor of
0.0001of the traded currency applies, but only while it stays under 5 % of the trade amount. Without that cap the absolute floor was ten dollars' worth of BTC — enough to equal a minimum-size BTC trade, produce a zero buyer credit, be rejected by the wallet service, and leave the escrow permanently stuck. - A seller who holds Super Admin is exempt entirely.
The fee calculator looks for a settings row keyed p2p first and reads
EscrowFeeRate out of it. Only if that is absent does it fall back to the
p2pEscrowFeeRate key this screen writes.
On a fresh install there is no such blob and this field is authoritative. On an install carried forward from an older release there may be, and then moving the slider changes nothing. If the fee on a completed trade does not match what this screen says, check for that row first.
A currency listed here overrides the dollar minimum above. This is the only way to stop dust trades in an asset whose unit price is nothing like a dollar — a ten-dollar minimum is meaningless on DOGE and larger than a viable trade on BTC.
Enforced at offer creation (against the converted minimum) and at trade
initiation (against the requested amount). The same legacy p2p blob caveat
applies: a MinimumTradeAmounts key inside it wins over this field.
Platform
- Platform is the third tab
This is the one switch on the Platform tab that is enforced server-side. Off means every offer and every edit waits in the admin offer list until somebody approves it. It is the strongest lever you have against a bad offer reaching the board, and the one that costs a person's time.
Remember that a pending SELL offer is already holding the maker's collateral. See Moderating offers.
The switches that only hide UI
No P2P API route consults p2pEnabled, p2pMaintenanceMode or
p2pAllowGuestBrowsing. The market board endpoint is deliberately
unauthenticated and answers anyone; the offer and trade endpoints never look
them up.
They stop a customer. They do not stop a script. Server-side enforcement is a backend change and is deliberately not faked here — a switch that claims to lock the door when it only takes the sign down is worse than no switch.
Guest browsing shapes the funnel rather than hiding your book — the board endpoint answers unauthenticated requests either way.
Also browser-side, and genuinely useful before maintenance when you want the book to drain rather than grow.
Two-factor on escrow release
Two controls, both off by default so an existing install keeps behaving exactly as it did until you turn them on. They are a pair, and turning on only the second one is the mistake to avoid.
Turn on enrollment first, then the challenge
p2pTwoFactorChallenge demands a code at release. p2pTwoFactorRequired is
what guarantees the person who will have to answer that challenge actually
has a second factor.
Turn on the challenge alone and a seller who never enrolled reaches Release — already holding a buyer's paid escrow — and cannot proceed. Turn on enrollment first, give your sellers time to enrol, then add the challenge.
Why it is the seller, and why it is checked at trade start
Releasing escrow is a seller-only action, so the release challenge can only ever be answered by the seller. But which party that is depends on the offer:
| Offer type | The maker is | The taker is |
|---|---|---|
| BUY offer | the buyer | the seller |
| SELL offer | the seller | the buyer |
So gating whoever starts the trade would not work: on a SELL offer the person starting it is the buyer, and the seller is a maker who is not in the request at all. The enrollment rule is therefore applied to the resolved seller, whichever side that turns out to be — and SELL-offer creation is gated too, so a maker learns about the requirement on their own offer form rather than through a stranger's failed trade.
Release itself does not enforce enrollment, deliberately. A release refused purely for missing enrollment would demand no code, so it stops nobody holding a stolen session while stranding a seller who is holding a buyer's money. The enrollment check does its work before anyone's funds are committed.
UI & Display
- UI & Display is the fourth tab
Read by the market page, which resolves anything other than the literal
classic back to guided.
Four fields with no consumer yet
The remaining four are stored and will be honoured when the consumer lands, but nothing reads them today. They are listed here so nobody spends an afternoon wondering why moving them changes nothing.
There is no Security tab
It used to hold three switches — Enable Dispute System, Enable Rating System, Enable Chat System — and no code read any of them. Disputes opened, ratings posted and chat messages sent regardless of the position of all three.
A tab named Security whose every control is decorative is worse than no tab, because it answers "can I turn chat off?" with a switch instead of with the truth, which is that turning chat off requires a backend gate that does not exist.
What is not configurable
Worth knowing before somebody goes looking:
| Behaviour | Value | Where it lives |
|---|---|---|
| Stale-payment auto-dispute delay | 24 hours | code |
| Offer auto-expiry | 30 days inactive and zero remaining total | code |
| Post-completion dispute | not possible — COMPLETED is terminal |
the trade state machine |
| Payment window fallback | 30 minutes | code |
| Minimum payment window on edit | 5 minutes | offer validation |
| Margin price range | −50 % to +50 % | offer validation |
| Max payment methods per user | 20 | code |
| Chat attachment size | 5 MB | code |
| Dispute description length | 20–1000 characters | code |
| All rate limits | see Install | code |
After you change something
Settings are cached and invalidated over Redis, so a change propagates to the other processes without a restart. Two things do not update retroactively:
- The fee on a running trade. It is computed and stored at initiation. Changing the rate affects trades opened afterwards.
- The payment window on a running trade. It is measured from
createdAtagainst the offer's setting, so an offer that names its own window is unaffected by a change here at all.