Internal transfers
The transfer queue and its settlement screen — which wallet pairs are allowed, what approving and rejecting actually move, and the two fee settings that govern the whole flow.
An internal transfer moves funds without them leaving the platform — between two
of a customer's own wallets, or from one customer to another. Most complete
instantly. A small subset sits PENDING and waits for you.
Finance → Transaction Management → Internal Transfers
(/admin/finance/transfer) is that queue. It is one of the five core queues in
the header's Operations inbox, with an SLA budget of 72 hours — a row
older than three days is counted late on the dashboard health card.
The queue opens filtered to PENDING and sorted oldest first, which is the
order you should work it in.
The two kinds of transfer
transferType is set by the customer's own transfer screen and decides
everything downstream.
transferType |
Between | Currency and type |
|---|---|---|
wallet |
Two wallet types of the same customer — FIAT to ECO, ECO to FUTURES | May change currency, and must change type |
client |
Two different customers | Must keep the same currency and the same wallet type |
A wallet transfer between two wallets of the same type is refused outright:
Wallet transfers must be between different wallet types. There is nothing to
move — it would be a wallet transferring to itself.
A client transfer that tries to change currency or wallet type is refused for a
sharper reason. The ecosystem client path credits the recipient the raw amount
without applying an exchange rate, so a hand-rolled request asking to send 1 SHIB
and receive 1 BTC would have been honoured 1:1. Both are now blocked at the
route: Client transfers must use the same currency for sender and recipient and
Client transfers must use the same wallet type for sender and recipient.
Which wallet pairs are allowed
The matrix is enforced server-side on every transfer, and it changes shape depending on whether spot wallets are switched on.
With spot enabled — Finance → Trading Infrastructure → Trading Settings
(/admin/trading/settings), the Enable Spot Trading switch (spotWallets):
| From | May go to |
|---|---|
FIAT |
SPOT, ECO |
SPOT |
FIAT, ECO |
ECO |
FIAT, SPOT, FUTURES |
FUTURES |
ECO only |
With spotWallets off, the same matrix becomes FIAT → ECO, ECO → FIAT or
FUTURES, and FUTURES → ECO. Any transfer with SPOT on either side is
refused before the matrix is even consulted, with
Spot wallet transfers are currently disabled.
Customers holding spot balances are then unable to move them anywhere at all — not to fiat, not to the ecosystem. The switch is presented as a trading feature toggle and reads like one; it is also a custody decision. If you are turning spot off on a platform with funded spot wallets, drain them first.
FUTURES is doubly constrained: the matrix allows only ECO, and a second
explicit guard repeats it — FUTURES wallet can only transfer to ECO wallet.
There is no route from futures margin to fiat that does not go through the
ecosystem wallet.
The wallet-type picker the customer sees is built from what the install actually
has: FIAT appears only when the fiatWallets setting is on, SPOT only when
spotWallets is on and an exchange provider is active, FUTURES only when an
exchange provider is active, and ECO only when the ecosystem extension is
installed.
What is actually pending
Most transfers never reach this queue. A transfer is written PENDING only when
it needs a private-ledger update and is not an ecosystem client transfer:
ECO → FUTURESandFUTURES → ECOwallet transfers — these are the rows you will find waiting.- ECO client transfers complete immediately. They do the ledger work inline
and are written
COMPLETED, so a customer sending ecosystem funds to another customer never waits on an operator. - Everything else —
FIAT ↔ SPOT,FIAT ↔ ECO,SPOT ↔ ECOand all non-ECO client transfers — completes on the spot.
So a long transfer queue almost always means ecosystem-to-futures movement is backing up, not that transfers in general are broken.
Every transfer writes a pair of rows
One transfer produces two transaction rows:
OUTGOING_TRANSFER— the sender's debit, written when the transfer is created.INCOMING_TRANSFER— the recipient's credit, written when it settles.
The queue lists OUTGOING_TRANSFER only, and that is deliberate: it is the
row that exists while a transfer is PENDING, it carries the metadata the
settlement needs, and there is exactly one per transfer so completed transfers
are not double-counted. Filtering the queue on INCOMING_TRANSFER would hide
every row it exists to decide.
A PENDING transfer therefore has an outgoing row and no incoming row. That
is not a missing record; the credit has not happened yet.
The sender has already been debited. Creating the transfer took the money; approval delivers it, rejection gives it back. See Transaction types and statuses for the wider vocabulary.
Fees and the cross-currency spread
Two settings, both on Admin → System → Platform Settings → Wallet → Fees:
The fee is deducted from the amount, not added to it. A customer sending 100
at a 1% fee has 100 debited and the recipient receives 99. The fee is then
collected through the standard platform-fee path — it credits the Super Admin's
wallet and books an adminProfit row of type TRANSFER, which is what
the revenue screen reports.
The spread applies only when the currencies differ. The engine converts the post-fee amount at the exchange rate with the spread applied, and stores the result on the transfer. It is not re-quoted later — see the approval section below.
The fee lookup reads walletTransferFee first and falls back to
walletTransferFeePercentage — a key no admin screen writes, kept only for
installs that set it by hand.
This is worth knowing because the fallback used to be the only key the engine
read, so the configured fee was silently never charged. If your transfers are
charging a rate that does not match the settings screen, check whether a
walletTransferFeePercentage row exists in settings and remove it. A value
above 100 is clamped to 100; a zero, negative or unparseable value means no fee.
Working the queue
Each row shows the sender, the age, the amount and currency, the fee, and a To column carrying the destination amount and currency read out of the transfer's stored metadata.
The settlement reads metadata.targetWalletId (or metadata.toWallet) and
metadata.targetAmount. A row without them is refused with Pending transfer is
missing its destination wallet; cannot approve or …missing its converted
destination amount; cannot approve.
The column is showing you that in advance so you do not discover it after clicking. Such a row can only be rejected, which refunds the sender — which is the right outcome anyway.
Row actions and the bulk menu are both built from one config, so they can never
disagree about what is decidable. Only PENDING rows offer Approve and Reject.
status must be COMPLETED or REJECTED — anything else is a 400. On
REJECTED a reason of at least 3 characters is required, and it is not
decoration: it is stored on the transaction at metadata.message and is the text
the sender's email is composed from.
A bulk decision is reported, not rolled back. Each row is its own wallet
movement and the ones that already settled cannot be undone, so the response
reads 7 of 10 processed. 3 could not be: … and names the failures. Rows that
are no longer PENDING come back as Already COMPLETED. Do not re-run the
whole batch — only the named ids need attention. The request fails outright
only when nothing succeeded.
The settlement screen
Clicking a row opens /admin/finance/transfer/[id], which needs
access.transfer. Five tabs: Details, User, Wallet, Manage and
Audit Trail.
Manage is where the decision is made, and it is available only while the row
is PENDING — otherwise it shows Transaction locked. It lets you edit
amount, fee, referenceId and description before deciding, then offers
Complete transaction and Reject transaction. The reject dialog's Confirm
button stays disabled until you type a real reason.
Audit Trail carries two panels: the administrative actions taken against this transfer, and the balance ledger for the wallet it moved. That second panel is what proves the credit or the refund actually landed.
What Approve does
-
The row is locked and re-checked. The status is verified as
PENDINGagain inside the database transaction, so two operators approving concurrently cannot both settle it. The loser gets Only pending transactions can be updated. -
The destination is credited —
metadata.targetAmountintometadata.targetWalletId, under the idempotency keyadmin_transfer_approve_<transactionId>. -
Both parties are emailed — an outgoing-transfer notice to the sender and an incoming-transfer notice to the recipient. Mail is sent after the money moves and its failure is swallowed, because a mail queue being down must not roll back a settled transfer.
Approval credits the stored converted amount. It does not re-quote the exchange rate at approval time — deliberately, so a rate that moves between request and decision cannot change what the customer receives.
A transfer that has been sitting in the queue for a week settles at last week's rate. If that is unacceptable for a given row, reject it and ask the customer to re-submit.
What Reject does
The sender is refunded to the source wallet, under the key
transfer_reject_<transactionId>, and then emailed your reason.
The refund is capped at what the transfer actually debited, recorded as
metadata.totalDebit. That cap matters because amount is editable on the
Manage tab in the same request — without it, inflating the amount before
rejecting would credit the sender more than was ever taken from them. When the
cap bites, the backend logs Refund for transaction <id> capped at the original debit.
If the source wallet cannot be resolved at all, the status still changes and the
refund is skipped, with an error logged under the TRANSFER module. That is
the one case where a rejected transfer leaves the customer out of pocket — check
the balance ledger for the sender's wallet
after rejecting anything unusual.
CANCELLED takes the same refund path as REJECTED on the single-row endpoint,
but the queue's buttons only ever send COMPLETED or REJECTED.
Deleting transfers
delete.transfer removes rows from the queue. Deleting a transfer does not
reverse it — it hides the record of a movement that already happened. Use reject,
not delete, on anything that still needs a decision.