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.
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:
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:
price onlyThe 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.
confirm=trueIt 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 theirname,precisionandfeerefreshed and theirstatusdeliberately 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.
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.
Related
- Connecting a spot exchange provider — what supplies the spot list in the first place, and what happens when it is degraded.
- Environment variables — the
APP_FIAT_RATES_*block that decides which rate sources are used and how they are merged. - Scheduled jobs — both currency jobs, and how to tell whether the scheduler is actually running.
- Wallet administration — which custody figures are dollars and which are meaningless native sums.