Customer wallets and balance adjustments
The wallet list, what a hand adjustment writes to the ledger, why a wallet cannot be created or deleted from the table, what freezing one refuses, and which custody figures are safe to read.
Finance → Transaction Management → Wallets (/admin/finance/wallet) lists
every wallet on the platform. It is also where you change a customer's balance by
hand, which is the highest-blast-radius control in the product.
There is no approval step, no queue and no undo. Add credits the customer;
Subtract debits them. Both commit the moment you press the button, both write
to the append-only balance ledger under your account, and both are visible in the
customer's own transaction history.
The only protection is that the reason you type is stored, and the ledger row is permanent. Write a reason a stranger could audit six months from now — including the external transaction hash or bank reference if you are correcting for one.
What a wallet row is
One row per customer, per currency, per type. The five types are set by the model's enum:
| Type | What it holds |
|---|---|
FIAT |
Fiat balances — the money side of gateway and manual deposits |
SPOT |
Exchange-backed crypto, custodied by your exchange provider |
ECO |
Ecosystem chain balances, custodied by the platform's own wallets |
FUTURES |
Margin for the futures engine |
COPY_TRADING |
Copy-trading balances |
The table's Type filter offers only the first four as choices, even though
COPY_TRADING is a valid value on the column — if you run copy trading, search
rather than filter when you are looking for one of those wallets.
Two money columns, and they are different things:
balance— free. Spendable, withdrawable, adjustable.inOrder— held. Locked against open orders, escrow or a pending payout.
Both are DECIMAL(36,18), and mysql2 hands every DECIMAL back as a string.
That is why the view dialog and the wallet detail page each compute a Total
(balance + inOrder) that no column shows — adding the two without converting
them first concatenates rather than adds. If you are reading these values out of
the API yourself, parse them before you do arithmetic.
Rows are created automatically on first use, so the wallet count is dominated by empty shells. Count funded wallets, not wallets.
Adjusting a balance
The action lives on the row's ⋯ menu — Adjust balance — and on the header
of the wallet detail page. Both need edit.wallet,
and a role without it sees no entry at all rather than a disabled one. The dialog
names the customer, the wallet type, the currency and the current balance in its
subtitle, so check that line before you type an amount: a customer with USDT on
SPOT and on ECO has two rows that look nearly identical in the table.
type is ADD or SUBTRACT.The body carries five things:
| Field | Effect |
|---|---|
type |
ADD or SUBTRACT |
amount |
Positive number. SUBTRACT is refused with Insufficient funds in wallet when it exceeds balance — inOrder is not available to it |
nonce |
Client-generated idempotency token. A fresh one is generated each time the dialog opens, so a network retry of one submit is de-duplicated while two deliberate identical adjustments are not merged |
description |
Optional. Written onto the ledger row as the reason, and used as the transaction description |
notifyUser |
Defaults to true. Off means the customer gets no email — they still see the transaction |
What it writes:
- A wallet-service credit or debit under the key
admin_wallet_adjust_<walletId>_<type>_<amount>_nonce_<nonce>, with the operation typeADMIN_ADJUSTMENT_CREDITorADMIN_ADJUSTMENT_DEBIT. - A row in the balance ledger carrying the before and after balance.
- An email to the wallet owner, unless you turned it off.
The adjustment goes through the same wallet service as everything else, so a wallet with its status off refuses the credit or debit. Unfreeze, adjust, re-freeze.
The anchor row
When a caller supplies no nonce — the admin dialog always supplies one, so
this is the API-client case — the endpoint has no stable idempotency key of its
own. Rather than fall back to a timestamp (which changes on every retry and
therefore permits a duplicate credit), it creates a short-lived transaction row
of type ADJUSTMENT_ANCHOR, status PENDING, purely so its id can seed the
key. Once the real credit or debit lands, the anchor is soft-deleted — it
stops matching the lookup and disappears from every history query.
If you find a PENDING ADJUSTMENT_ANCHOR row sitting in the transaction log,
it is the fingerprint of a request that crashed between minting the anchor and
completing the movement. It is not money and it was never a payment; it is typed
distinctly for exactly that reason. Check whether the adjustment actually
happened by looking at the wallet's ledger, then delete the orphan.
Freezing a wallet
The row menu's Disable wallet / Enable wallet entry flips
wallet.status.
A frozen wallet refuses every wallet-service operation: credit, debit, hold, release, execute-from-hold, both sides of a transfer, and the ecosystem credit and debit paths. In practice that means deposits into it fail, withdrawals out of it fail, orders that need to lock funds fail, transfers in either direction fail, and your own balance adjustments fail. The balance itself is untouched and remains visible to the customer.
That makes it the right control for a suspected-fraud freeze on one currency, and the wrong control for anything you intend to leave in place — customer money sitting in a disabled wallet is a support ticket and, past a threshold, a regulatory problem. There is a tile for it (below) precisely because it is easy to forget.
Why you cannot create or delete a wallet here
The table ships with create, edit and delete all disabled. Wallets are created on first use by the platform, so there is nothing to create. Deletion is a different story: the endpoint exists, and it is guarded.
Both kinds of delete destroy money, which is why assertWalletsAreEmpty refuses
any wallet whose balance or inOrder is above zero:
- A soft delete hides the row from every query, so the balance becomes
unreachable — and because the unique index on
(userId, currency, type)does not includedeletedAt, the platform cannot create a replacement either. The customer is permanently locked out of that currency: every later deposit, transfer or payout for it fails on the constraint. - A hard delete (
?force=true) cascades totransaction, erasing the balance and the entire ledger history that explains it together.
The refusal names each offending wallet and how much it holds. Move the balance out first — withdraw, transfer, or an admin adjustment — then delete. The whole batch is refused rather than partially applied, so you are never left guessing which ids went through.
The custody figures, and which ones to trust
The analytics strip above the table is a balance sheet, not a time series.
Almost every card reports as of now and ignores the date selector, because
bucketing a stock quantity on createdAt answers "the balance of wallets created
this month", which is nobody's question.
| Tile | What it is | Read it as |
|---|---|---|
| Total custodial liability | balance + inOrder, each converted to USD |
The single most important number on the platform: what you owe customers |
| Total balance | Sum of balance, converted to USD |
Free customer funds |
| In order | Sum of inOrder, converted to USD |
Customer funds locked by the engines |
| Locked vs available | inOrder as a percentage of balance |
A rising number means funds are being locked and not released |
| Funded wallets | Count where balance > 0 |
The real denominator for every per-user metric |
| Users holding funds | Distinct users where balance > 0 |
How many people you actually owe |
| Dormant funded wallets (90d) | Funded and untouched for 90 days | An escheatment and support problem that nothing else surfaces |
| Funds in disabled wallets | balance where status is off |
Customer money you have frozen |
| New funded wallets | Funded wallets created in the window | The one legitimately date-shaped metric here |
Total custodial liability, Total balance and In order are denominated per row and converted to USD before they are summed. Funds in disabled wallets is not — it is a raw sum across currencies, so 1 BTC and 1 USDT add to 2. Treat it as a signal that something is frozen, then use the filter to find which wallets, rather than as a money figure.
The same caveat applies to the ranked bars. Balance held by wallet type and
Top holders by balance measure balance only — they exclude the inOrder
leg, so read a bar as free balance, not as total liability. Custodial balance
by currency is grouped by currency, so each bar is internally consistent.
The Wallets over time chart plots wallets created against wallets created and still funded. The gap between the two lines is shell bloat: if the created line runs away, wallet creation is firing for accounts that never deposit.
The balance ledger
Every balance change the wallet service has ever made is written to an
append-only ledger — one row per operation carrying operation, amount,
previousBalance, newBalance, previousInOrder, newInOrder, the linked
transaction id and the idempotency key.
You reach it from the Audit Trail tab of the wallet detail page below, or of any deposit, withdrawal or transfer detail page — each shows the administrative actions taken against that record alongside the ledger for the wallet it moved. It is the record that answers "was this customer credited once or twice", and it is the only record that can.
The wallet detail page
View on the row's ⋯ menu opens /admin/finance/wallet/{id}, one wallet on
its own page. It needs the same access.wallet as the list.
| Tab | What it holds |
|---|---|
| Overview | The owner, with a link through to their customer record; the wallet and user ids; when it was opened and last changed; and, for ECO wallets, the on-chain addresses per network |
| Transactions | The money movements recorded against this wallet |
| Audit Trail | The administrative actions taken against this wallet, and beneath them the balance ledger — every credit, debit, hold and release with the balance either side of it |
Three tiles across the top carry Available, In Orders and their Total, so the split that the list table leaves in two columns is added up for you.
Adjust balance and Enable/Disable wallet sit in the header and behave
exactly as they do from the table — same endpoints, same edit.wallet, same
freshly-generated nonce per dialog open. The reason to work here instead is the
Audit Trail tab: you can make a correction and read back what it did to the
balance without leaving for the customer's record.
The controls are hidden, not disabled, for a role without edit.wallet. If an
operator reports the adjust action missing, check the role's grant before
anything else.