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.

6 min readUpdated 11 August 2026wallets, balances, custody, adjustments, ledger

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.

Credit or debit one wallet. 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 balanceinOrder 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 type ADMIN_ADJUSTMENT_CREDIT or ADMIN_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.

Freeze or unfreeze one wallet.
Freeze or unfreeze a selection.

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.

Bulk delete wallets. Refused outright if any of them still holds funds.

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 include deletedAt, 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 to transaction, 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.

The balance ledger. Filter with a walletId or a userId. There is no write side.

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.