Fiat and spot currencies

The two currency screens, why the fiat price column is units-per-USD rather than the price you expect, how the spot import works, and the two crons that keep every figure on the site honest.

7 min readUpdated 6 August 2026currencies, fiat, exchange-rates, spot, cron

Two screens list the currencies your platform knows about:

Screen Path Table What it holds
Fiat currencies /admin/finance/currency/fiat currency The national currencies customers hold FIAT wallets in
Spot currencies /admin/finance/currency/spot exchange_currency The assets your exchange provider lists

Neither screen creates a currency by hand. Both pass canCreate={false}, canEdit={false} and canDelete={false} to the table, and the absence of those buttons is not a missing permission. The fiat set is seeded; the spot set is whatever your exchange provider returns. You enable, disable and price what already exists.

price is units-per-USD, and it is the inverse of what you expect

This is the single fact on this page that costs money to get wrong.

currency.price stores how many units of the currency one US dollar buys — not what one unit is worth in dollars. The column comment in the model says so, and so does every rate provider, because they are all called with base=USD.

Symbol Stored price Reads as
NGN 1365 1 USD = 1365 NGN
IRR 1375250 1 USD = 1,375,250 IRR
KWD 0.31 1 USD = 0.31 KWD
BHD 0.377 1 USD = 0.377 BHD

The tell is that the currencies stronger than the dollar are the ones stored below 1. If a row for a weak currency holds a small decimal, somebody has typed a USD-per-unit figure into a units-per-USD column.

Every fiat conversion on the platform inverts this figure — 1 / price — before using it. Nothing else on the platform is denominated this way, which is exactly why it gets typed backwards.

The conversion path is fromPriceUSD / toPriceUSD. Feeding it the reciprocal does not produce a slightly wrong rate — it produces the reciprocal of the true rate, in both directions. A USDT → NGN transfer credits 1/1365 NGN instead of 1365, and the mirror NGN → USDT direction credits 1365 times the value transferred, creating funds out of any weak-currency fiat balance.

Sanity-check a hand-entered rate against a public converter before you save it, and check the direction, not just the digits.

A price of zero, null or anything non-numeric is not treated as "free". The conversion helper returns null, and the caller raises "Price not configured for currency <code>. Please update the currency rate." rather than silently valuing the balance at par — the older behaviour valued a 1,000,000 IRR balance at one million dollars.

The fiat screen

Admin → Finance → Currency Management → Fiat Currencies (/admin/finance/currency/fiat) lists six columns: id, name, symbol, precision, price and status. The id is the ISO code — there is no separate code column, which is why every lookup in the codebase matches on where: { id: code }.

What you can do from the screen:

  • Toggle a currency on or off, per row.
    Switches one fiat currency on or off

That is the whole of the screen's write surface. There is no create form, no edit dialog and no delete action — and no bulk enable or disable. With create, edit, delete and bulk actions all off, the table draws no selection column at all, so there is nothing to select and nothing to act on in bulk: switching twenty currencies on is twenty toggles. A bulk endpoint does exist in the API, but no screen calls it:

Switches many fiat currencies at once. API only — nothing in the admin panel calls it

Correcting a price

price is written by the cron, and one endpoint can correct it. It accepts price and nothing else — title, symbol, precision and status are not in its schema, so they cannot be changed through it, and a negative value is rejected by a minimum: 0 constraint:

Sets one fiat currency's price. Body is price only

The route exists and is permission-gated, but the page passes canEdit={false} and supplies no form config, so there is no button that calls it. Today a manual price correction is an API call, and it survives only until the next cron run overwrites it. If a rate is persistently wrong, fix the provider configuration rather than the row — see Environment variables, APP_FIAT_RATES_PROVIDERS and APP_FIAT_RATES_UNITS.

The spot screen

Admin → Finance → Currency Management → Cryptocurrencies (/admin/finance/currency/spot) lists id, currency, name, precision, price, fee and status. The per-row status toggle mirrors the fiat one, on /api/admin/finance/currency/spot/[id]/status. There is no bulk selection action here either — the bulk endpoint /api/admin/finance/currency/spot/status is used, but only by the Activate missing currencies button described below. Both are behind edit.spot.currency.

Two things are unique to this screen.

Import currencies

The Import Currencies button reads the live currency list from your configured exchange provider, normalises it and writes it into exchange_currency.

Imports the exchange's currency list. Preview-only unless confirm=true

It is preview-first. Pressing the button runs the endpoint without confirm, which writes nothing and returns a plan:

Field Meaning
provider Which exchange the list came from
toCreate Codes the provider lists that you do not have
toUpdate Existing rows whose name, precision and fee will be refreshed
toDelete Rows that will be deleted because the provider no longer lists them
deleteSample The first 25 of those codes, so the dialog can name them
enabledCount How many of your rows are currently switched on

toDelete is a real delete, inside the import transaction. Read that number before you confirm — a provider outage or a mis-set exchange key can return a short list, and confirming against it removes currencies you are still using. The preview exists precisely so the delete count is visible before you agree to it.

Two behaviours worth knowing:

  • Enablement is never trampled. New rows are created with status: false. Existing rows have their name, precision and fee refreshed and their status deliberately left alone, so re-importing to pick up a precision change does not switch off everything you had enabled.
  • Precision is normalised. ccxt reports precision either as a digit count (8) or as a tick size (1e-8) depending on the exchange's precision mode. Both are converted to a decimal-place count, so Binance and KuCoin — which report tick sizes — do not import BTC at a precision of 1.

A confirmed import runs processCurrenciesPrices once at the end, so prices are populated without waiting for the next tick.

The "Missing currencies" banner

Above the table, a destructive banner appears when an enabled market references a currency whose row is not enabled, with an Activate missing currencies button that switches all of them on at once.

Lists currencies used by enabled markets whose own row is switched off

It compares both sides of every active exchange_market row — the base and the quote — against the set of enabled exchange_currency rows. A market whose quote currency is switched off is a market whose prices cannot be resolved, which is what the banner is warning you about.

The two crons that keep the numbers alive

Prices are not entered; they are fetched. Two scheduled jobs do it, and both are listed on Admin → System → Scheduled Tasks (/admin/system/cron).

Job Every Writes
fetchFiatCurrencyPrices 30 minutes currency.price — the fiat table
processCurrenciesPrices 2 minutes exchange_currency.price — the spot table

Neither job announces its absence. The screens keep rendering the last value written, deposits and withdrawals keep converting through it, and nothing on the platform says the number is old. A weekend of a stopped scheduler is a weekend of transfers, P2P quotes and wallet valuations priced at Friday's rates.

Check the job's last run on the scheduler screen, not the presence of an error — an absent error is not evidence a job ran.

fetchFiatCurrencyPrices queries every configured provider concurrently, in priority order, and merges them. One provider failing does not stop the others; all of them failing throws, and the job records the failure. Which providers run, how disagreements are resolved, and how to override a specific code are all .env decisions — see Environment variables.

Two consequences of how it selects rows:

  • It prices enabled currencies only (where: { status: true }). A currency you switch on today keeps whatever stale price it had until the next 30-minute run.
  • Its currency list is cached in Redis for 5 minutes, so a newly enabled currency can miss one run before it starts being priced.

processCurrenciesPrices reads tickers for your enabled markets on the active exchange. It skips symbols the exchange does not list as spot, warns when none of your enabled markets are listed, and pins USDT to exactly 1. It writes only rows whose price actually moved.

What disabling a currency does — and does not do

Switching status off removes the currency from what customers are offered: the currency list served to the deposit, withdraw and wallet screens filters on status: true, and the fiat price conversion helper refuses a currency that is not enabled.

It does not touch balances. Existing wallets in that currency keep their funds, and those funds keep counting toward the custody totals on Admin → Finance → Transaction Management → Wallet Management (/admin/finance/wallet). Disabling a currency that customers hold strands their money rather than returning it, so treat it as a decision with a support queue attached.

The tile of that name in that same screen's analytics sums balance where the wallet's own status is false. It is about frozen wallets, not about currencies you have switched off — a funded wallet in a disabled currency does not appear there unless the wallet itself is also disabled. See Wallet administration.