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.

8 min readUpdated 6 August 2026transfers, queue, wallets, fees, settlement

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 → FUTURES and FUTURES → ECO wallet 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 ↔ ECO and 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:

Percentage taken on every wallet transfer. Range 0-10%.
Margin applied against the mid-market rate on cross-currency transfers, covering rate-feed lag. Range 0-10%.

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.

Bulk approve or reject. Delegates to the single-row settlement, one row at a time.
Settle one transfer.

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

  1. The row is locked and re-checked. The status is verified as PENDING again inside the database transaction, so two operators approving concurrently cannot both settle it. The loser gets Only pending transactions can be updated.

  2. The destination is creditedmetadata.targetAmount into metadata.targetWalletId, under the idempotency key admin_transfer_approve_<transactionId>.

  3. 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.