MMashDiv

Creating and pricing offers

What a maker fills in on a P2P offer, which currency each amount field is in, how much escrow is taken and when, the eight offer statuses, and every rule that will reject a publish.

16 min readUpdated 1 September 2026offers, pricing, escrow, limits

An offer is a public financial commitment. Publishing one puts a price on a board that strangers can act on, and — for a SELL offer — locks the maker's crypto behind it immediately. Everything the offer form asks for exists because one of those two facts requires it.

Users create offers at /p2p/offer/new. Admins can edit any offer from /admin/p2p/offer.

BUY and SELL are the maker's intent, not the taker's

This trips up everyone once, including the code — it is worth stating plainly.

Offer type The maker is the… The taker is the… Whose funds are escrowed When
SELL seller buyer the maker's at offer creation, the whole advertised total
BUY buyer seller the taker's at trade initiation, the trade amount only

That table is the offer denominated in crypto, which is what almost every board is made of. An offer denominated in a national currency escrows the price leg instead, and that reverses both columns — see Which leg is escrowed.

So a visitor who wants to buy crypto is shown SELL offers. The market board takes a side parameter named for the viewer precisely so this stops being ambiguous.

The fields

Currency and wallet type

currency is what is being traded (BTC, USDT, EUR…). walletType decides which of the platform's balance stores it comes from:

walletType Currency list drawn from Requires Escrowable
FIAT active fiat currencies core no — the platform never holds fiat
SPOT active exchange currencies core, with an exchange connected yes
ECO active ecosystem tokens the Ecosystem addon yes

If Ecosystem is not installed, an ECO offer is refused at creation — there is no token list to validate against and no wallet to settle into.

walletType: FIAT says what the offer is denominated in. It does not mean the platform will hold fiat, and it does not mean the maker needs a fiat balance or even a fiat wallet: a fiat-denominated offer is collateralised on its price leg instead. Which is why the second wallet type matters.

priceWalletType (nullable) is the wallet type of the price leg, derived server-side from priceConfig.currency at publish and re-derived whenever the price currency is edited. It is never accepted from the client — a caller who could claim EUR is ECO would point the escrow at a wallet that has never held anything. NULL means "never derived", which is the state of every offer published before the price leg was recorded and of every draft.

For an offer whose escrow is taken at trade initiation the pair is checked explicitly at creation, because no wallet is touched at that point and nothing else would catch an unsettleable pair.

Which leg is escrowed

An offer has two legs, and exactly one of them can be escrowed — the crypto one. The platform never touches the fiat leg (why), so the leg the collateral comes from, the party who posts it, the currency it is counted in and the moment it is taken all follow from which leg is crypto:

Asset leg (walletType) Price leg (priceWalletType) Escrowed leg Posted by Amount Taken
crypto anything asset SELL: the maker · BUY: the taker the trade's asset amount, in currency SELL: at publish · BUY: at trade initiation
fiat crypto price BUY: the maker · SELL: the taker the trade total (amount × price), in priceCurrency at trade initiation
fiat fiat — — — refused at publish

Read the middle row twice: it is the one people get wrong. The party posting the collateral is whoever pays the crypto, and on a fiat-denominated offer that is the asset buyer, not the asset seller. A maker advertising "I will buy EUR for USDT" is buying the cash and paying the coin, so it is the maker's USDT that is held — and the taker, who sends euros from their own bank, needs no platform balance whatsoever.

Whichever direction it is written in, its escrow is the price leg and is taken at trade initiation. So the publish path performs no balance check, creates no wallet and takes no hold, and a maker with an empty account — or with no fiat wallet at all — can post one. The old refusal, "Insufficient balance. Available: 0 EUR, Required: 90.909 EUR … your FIAT wallet", is not reachable for a fiat-denominated offer.

"I will sell EUR for USD" has nothing the platform can hold, so publishing it is refused with a 400 naming which leg failed, and so is an edit that would leave an existing offer in that shape active. Pausing and deleting such an offer stay possible on purpose — refusing every write would leave a maker with a live ad they cannot take down. Reprice it in a crypto currency and it becomes tradeable.

The trade row records the decision at initiation — escrowCurrency, escrowWalletType and escrowOwnerId — so settlement releases and refunds against what was actually held rather than re-deriving it. All three are NULL on trades opened before those columns existed; a NULL triple resolves exactly as it always did (the seller, the offer's wallet type, the trade's currency), including where that wallet is a fiat one, so money already held under the old behaviour still releases, refunds and unwinds normally.

Amounts — and the currency trap

  1. Total up for trade — the traded currency

amountConfig.total is the size of the offer, in the crypto or fiat being traded — 500 USDT, 0.05 BTC. amountConfig.min and amountConfig.max are the per-trade limits, expressed in the price currency (USD, EUR, USDT…).

The backend converts them with finalPrice before comparing. A maker who types min: 50 on a BTC offer is saying "no trade smaller than 50 dollars", not "50 bitcoin". Getting this backwards in a support answer will cost somebody a publish.

Publish-time rules on amounts:

  • total must be greater than 0.
  • min may not exceed max.
  • min and max, once converted to the traded currency, may not exceed total.
  • min must be at or above p2pMinimumTradeAmount; max may not exceed p2pMaximumTradeAmount. Both are platform settings, both are enforced server-side, and both are configured in US dollars — the server converts them into the offer's pricing currency before comparing, so a $100,000 ceiling permits roughly ₦136,500,000 rather than ₦100,000. The conversion uses the rate of the table the price currency lives in, fiat first: the fiat table for an enabled national currency, the exchange listing's own price column for a spot currency, the ecosystem market's last trade for a token. An offer priced in a currency whose table has no rate for it — an enabled national currency with no rate on file, a spot currency the price job has not priced yet, a token with no market — is refused while either bound is set, because the bound cannot be applied. The refusal names the currency and says which kind of rate is missing, and an enabled national currency is never priced off a coin that shares its code: an enabled fiat row wins the tie and ends the lookup whatever its rate, so an EGP you are running with no rate on file is refused with the advice to set its rate under Finance → Currencies rather than priced as Eigenpie. A row you have switched off wins nothing and decides nothing: the code is priced as the coin where a listing carries it, and refused by name — "… is currently switched off under Finance → Currencies" — only where none does. USDT never waits on the price job; it counts as one dollar even before its listing is priced.
  • The converted min must clear the per-currency dust floor from p2pMinimumTradeAmounts (defaults include BTC 0.00005, ETH 0.001, DOGE 10, XRP 5, SOL 0.01).

Price currency

priceConfig.currency (surfaced as priceCurrency on the row) is the money the price is quoted in — USD unless the maker says otherwise. It is also the currency min and max are read in.

It may be any currency the platform has enabled: a fiat currency, a spot exchange currency, or an ecosystem token — with one constraint, which only bites on a fiat-denominated offer: if the asset leg is fiat, the price currency must be crypto, because otherwise there is nothing on the offer to escrow. A crypto asset leg may be priced in anything. The composer fills its Priced in list from GET /api/p2p/offer/price-currencies, which lists enabled fiat first, alphabetically, then crypto with USDT and USDC at the front. Each group appears only where the platform can settle it: fiat when fiat wallets are on, spot when spot wallets are on and an exchange is connected, tokens when the Ecosystem addon is installed and enabled — the extension row switched on, not merely present.

A code that appears in more than one table is offered once, under the wallet type the server would assign it — fiat first, then spot, then ecosystem — and a group hidden by its wallet switch still claims its codes. So with fiat wallets off, a code that is both a national currency and a spot ticker is not offered at all rather than reappearing as the coin; with spot wallets off, USDT is not offered through the ecosystem listing. The list never shows a code under a wallet type the publish check would disagree with, because the rate endpoint would then be asked to price it from the wrong table.

A hidden group is not the same thing as a switched-off row, and the difference is the answer to "I disabled RON and it is still in the list". Every one of the three tables is read with status = true, so a national currency you have switched off claims nothing: with fiat wallets on and the RON row off, RON is offered in the crypto group as Ronin, publish accepts it as SPOT, and the platform bounds price it from Ronin's market. That is the same answer end to end, which is the point — but it also means switching a currency off re-prices the offers already quoted in that code. See P2P settings before disabling one of the ten codes that are both.

The list used to be the fiat deposit list. An install with no enabled fiat currency — typically one with no FX rate provider — therefore had an empty Priced in box and could not publish anything; pricing in a stablecoin is how such a platform trades at all.

Publishing validates the code against the same three tables. A price currency the platform does not know is refused with 400, "<code> is not a currency this platform can price an offer in. Pick one from the Priced in list." USD always passes, even when the USD row is disabled, because the platform prices USD at 1 without reading the table. Saving a draft never validates the price currency — not through the create route and not through the edit route — because a draft is listed nowhere and prices nothing; publishing does. An edit that is not a draft save re-validates the price currency only when it changes, so an operator disabling a currency does not brick edits of the offers already priced in it.

GET /api/p2p/offer/{id} returns priceCurrencyWalletType — FIAT, SPOT, ECO, or null when the offer has no price currency, when the platform no longer knows it, or when the lookup itself failed — beside assetIsFiat, because the rate endpoint needs the wallet type of both legs and a client should not have to guess it. The permalink never fails over that lookup; a null there costs the client a market rate, not the page.

A MARGIN price needs a market rate on both legs. The rate between the traded asset and the price currency exists only when the platform can price each side — a fiat with a rate on file, a spot currency the exchange has priced, a token with an ecosystem market. When either side has no price the composer reports the market rate as unavailable and the maker must price the offer FIXED. Nothing is guessed: an asset with no ecosystem market priced in USDT has no market rate, and the form says so rather than inventing one.

Price

Two models.

FIXED — value is the price, and must be greater than 0. finalPrice equals it.

MARGIN — value is a percentage offset from the market price, and may be negative. At creation it must be between -50 % and +50 %. If a marketPrice is supplied it must be greater than 0.

finalPrice must always be greater than 0, whichever model is used. This is checked before any escrow is taken:

An offer published with finalPrice: 0 used to lock the seller's funds and then refuse every taker, because trade initiation requires a positive price. The escrow was stranded with no way to trade out of it. The price validation now runs first, so a bad price costs a 400 and nothing else.

Trade settings

Field Meaning
autoCancel The payment window in minutes. 0 means never auto-cancel. Between 5 and 1440 when edited. Omitted, the platform default (p2pDefaultPaymentWindow) applies.
kycRequired Only KYC-verified takers may open a trade. Fails open when the platform has no KYC programme at all.
visibility PUBLIC or PRIVATE. A PRIVATE offer is excluded from every public listing and is reachable only by direct link.
termsOfTrade Required to publish. Up to 1000 characters. Shown to the taker before they commit and copied onto the trade.
additionalNotes Optional, up to 500 characters.

Location and restrictions

locationSettings.country is required to publish, and must be a valid ISO 3166-1 alpha-2 code. Region and city are optional free text.

The restricted-countries list is stored on the offer and shown to takers. Trade initiation does not check it, and this is deliberate.

Enforcing it needs a trustworthy country for the taker, and the platform does not have one. The only stored value is user.profile.location.country — self-declared, never validated, null for most accounts, editable by the taker at any moment; the platform's own geo policy ships with trustProfileCountry: false. An approved KYC application has no fixed country key and is found by scanning application JSON for keys matching /country|nationality/i. A CDN header is a VPN-defeatable guess about a connection.

The choice was between a gate that passes almost everyone while the maker believes it blocks a country, and one that wrongly refuses legitimate takers on a heuristic. Both are worse than saying so. If a maker asks you why their restriction "isn't working": it is displayed, not applied.

Requirements on the taker

All optional, all enforced at trade initiation, all returning 403 with a message naming the shortfall.

Requirement Checked against
minCompletedTrades count of the taker's COMPLETED trades, either side
minSuccessRate completed ÷ (completed + cancelled + expired), as a percentage
minAccountAge days since the taker's account was created (0–365)
verifiedOnly user.emailVerified — not KYC
trustedOnly at least one COMPLETED trade between the taker and this maker, in either direction

verifiedOnly reads the same column the market board publishes as the trader's "verified" badge. If it meant identity verification, the badge on the board and the filter claiming to select for it would be two different promises — and the weaker bar would become unexpressible, since kycRequired already covers identity.

Payment methods

At least one is required to publish. A maker may attach global methods created by an admin and their own private methods; attaching someone else's private method is refused. See Payment methods.

Escrow at publish

Exactly one shape of offer takes a hold at publish: a SELL offer whose asset leg is crypto. Everything else — a BUY offer, a fiat-denominated offer in either direction, a draft — holds nothing until a trade opens, if ever.

Not the minimum, not the first trade's worth — amountConfig.total, in full, moved out of the maker's spendable balance into inOrder in the same transaction that writes the offer row. If the balance is short, the publish is refused with the exact numbers.

The amount held is recorded on the offer as escrowAmount, so every later release knows what was actually taken rather than re-deriving it from a total that shrinks as trades consume the offer.

A BUY offer holds nothing at publish. Its collateral is the taker's, and it is taken per trade.

A fiat-denominated offer holds nothing at publish either, in either direction, because its escrow is the price leg and the price leg is taken at trade initiation — from the maker on a BUY, from the taker on a SELL. The publish path reads no wallet at all for one, which is why it needs no fiat balance and does not require the maker to own a fiat wallet. Opening a new hold in a fiat wallet is refused outright at the escrow authority, so this cannot regress quietly.

A draft holds nothing either, and that is a hard structural guarantee rather than a flag: the draft path in the code contains no call to the escrow or wallet services at all. Holding funds for an offer that is listed nowhere would be a silent freeze on money the maker can still see and can no longer spend.

Statuses

DRAFT ──────────► PENDING_APPROVAL ──► ACTIVE ◄──► PAUSED
  │                      │                │           │
  └──► CANCELLED         └──► REJECTED    ├──► COMPLETED
                                          ├──► CANCELLED
                                          └──► EXPIRED
Status Escrow held (crypto-denominated SELL) Visible on the board
DRAFT no no
PENDING_APPROVAL yes no
ACTIVE yes yes, if PUBLIC
PAUSED no — released no
COMPLETED / CANCELLED / REJECTED / EXPIRED no no

A new offer lands in PENDING_APPROVAL unless p2pAutoApproveOffers is on, in which case it goes straight to ACTIVE.

PENDING_APPROVAL keeps its escrow deliberately, so approval never has to find the funds a second time — an approval that could fail on insufficient balance would be an approval queue that randomly rejects.

Editing a published offer

  1. Owner, type and currency
  2. Status decides which doors the case screen draws
  3. Open a row for the offer case and the admin edit

An edit republishes price, limits and available total. It is therefore gated by the same KYC feature as creation, and it re-submits the offer for approval unless auto-approve is on or the maker explicitly names a status.

Refusals you will be asked about:

  • 422, "Cannot edit offer while there are active trades." Any trade against the offer in PENDING, PAYMENT_SENT or DISPUTED blocks the edit outright. The maker must wait, or cancel.
  • 422, "Cannot edit offer in <status> status." Only DRAFT, PENDING_APPROVAL, ACTIVE and PAUSED are editable.
  • 422, "Cannot activate a draft offer." A draft is incomplete by definition and cannot be flipped to ACTIVE through the edit route; it has to go through publish.
  • 422, "Cannot turn a <status> offer back into a draft." Unpublishing is not a transition. Pausing is how an offer leaves the board.
  • 400, "Insufficient balance to collateralize this offer." Raising the total on a SELL offer takes the difference as a fresh hold.
  • 400, "<code> is not a currency this platform can price an offer in." The edit changed priceConfig.currency to a code that is in none of the fiat, spot or ecosystem tables with status on. Leaving the currency as it was is never refused for this reason, and neither is a draft save, whatever it changed.
  • 400, nothing on the offer can be escrowed. The edit would leave a both-legs-fiat offer ACTIVE or PENDING_APPROVAL. Pausing and deleting are still allowed. See Which leg is escrowed.

A price-currency change rewrites priceWalletType in the same statement as the code. A stale price wallet type is the one value here worse than none — an offer moved from USDT/ECO to EUR would send its escrow looking for euros in an ecosystem wallet — while an unchanged code is left alone, so an operator disabling a currency cannot brick edits to offers already priced in it.

Escrow is recomputed from the merged amounts and the resulting status on every SELL write. Raising the total takes more; lowering it releases the attributed amount and re-holds what is still required; pausing releases everything; resuming takes it back.

On a fiat-denominated offer the recomputed target is always zero, because the offer level escrows nothing. On an offer published before this behaviour existed that has an old fiat hold against it, the first edit therefore releases it back to the maker — a release, never a new hold, and the route refuses to edit an offer with a trade in flight, so nothing a taker is relying on is unwound. The same edit also fills in a missing priceWalletType, which is what makes an existing fiat-denominated ad tradeable again: open it, save it, and it is collateralised on the crypto it was always quoted in.

amountConfig.originalTotal is the ceiling a cancelled trade may restore the offer's advertised total back up to. It is seeded at the first trade and moved by the same delta whenever the total is edited — otherwise raising the total would leave the cap behind and permanently swallow the difference on the next cancellation.

Pausing, deleting and expiry

Pause takes the offer off the board and releases its escrow to the maker's spendable balance. Resuming re-takes it, and can fail if the money has been spent in the meantime.

Delete is refused while any trade against the offer is PENDING, PAYMENT_SENT or DISPUTED. Otherwise it soft-deletes the row and releases whatever escrow is still attributed to it.

Automatic expiry runs in the one-minute cron. An offer is expired when it is ACTIVE, has not been updated for 30 days, and has a remaining total of zero or less. Its escrow is released and the owner is notified. An offer with capacity left is never auto-expired, however old it is.

What a taker sees

The market board (/p2p/market) lists only ACTIVE, PUBLIC offers, filtered by the viewer's side, currency, fiat, amount, payment methods and country, and sorted by best / price / trades / speed / newest. Each row carries the counterparty's trust facts — completed trades, completion rate, typical release speed, verified badge, and whether the viewer has traded with them before.

Views are counted when a trade is initiated, not on page load, so the number reflects serious interest rather than the maker refreshing their own offer.

Makers also have a forecast endpoint available before publishing, which answers where a proposed price lands against the current board, roughly how many traders could pass the offer's requirements, and how long comparable offers waited for their first trade. Every figure is nullable — an absent forecast is correct where an invented one would not be.