Internal transfers between ECO wallets

The transfer endpoint that moves money between customers without touching a chain, the withdrawal that silently becomes one, the fees that differ between them, and where to look when one goes to the wrong person.

7 min readUpdated 6 August 2026transfers, withdrawals, fees, reconciliation, wallets

Money can move between two customers on this install without a transaction ever reaching a blockchain. There are two ways it happens, one of them is invisible to the customer who triggered it, and neither produces anything a block explorer can show you.

An operator needs to know both, because "the transfer went to the wrong person" and "I sent a withdrawal and it never appeared on-chain" are the same conversation from two different directions.

The direct transfer endpoint

Transfers funds from the caller's ECO wallet to another user's

The body is { amount, currency }, both required. The caller's own ECO wallet for that currency is the source; a customer with no ECO wallet for the currency gets a 404.

The API description in the schema says "UUID of the recipient's wallet or user". The handler resolves it as a user id and nothing else — it looks the id up in the user table and returns 404 "Recipient user not found" if there is no match. Passing a wallet id therefore fails outright.

The dangerous case is the one that succeeds. Any uuid that happens to belong to a real user is accepted, and this endpoint has no confirmation step — it does not return the recipient's name or email before moving the money, the way the core transfer flow's validate call does. A wrong-but-valid id sends the money to a real stranger and reports success. That is the mechanism behind almost every misdirected internal transfer.

It is throttled at the moderate tier — 30 requests per 60 seconds per caller — because it is a money-movement endpoint and the throttle blunts replay and timing attacks against it.

What the handler does, in order:

  1. Finds the sender's ECO wallet for the requested currency. No wallet, no transfer.

  2. Resolves the recipient user from the path parameter.

  3. Creates the recipient's ECO wallet if they do not have one for that currency. A customer who has never touched that asset can still receive it; the wallet, and its deposit addresses, are provisioned on demand.

  4. Checks the sender's balance. The check is against wallet.balance, which in this platform is already the spendable figure — funds held in open orders live in inOrder and are not available to transfer.

  5. Moves the money atomically. Both wallet rows are locked in a deterministic order, an idempotency key is checked, and the balance test is re-run against the locked rows so the check and the debit cannot race.

Two rows land in transaction: an OUTGOING_TRANSFER on the sender's wallet and an INCOMING_TRANSFER on the recipient's, each carrying the before and after balances in its metadata. Those two rows are the ledger — there is no third record, and nothing is written to any chain.

This endpoint charges no fee. It passes no fee percentage to the wallet service, so the recipient receives exactly the amount sent. That is not true of the other path below, which is the single most surprising thing on this page.

Self-transfers are refused: same user, same wallet type and same currency is rejected before anything moves.

The withdrawal that quietly becomes a transfer

When a customer submits an on-chain withdrawal, the platform first asks whether the destination address belongs to somebody on this install.

Submits an ECO withdrawal, or short-circuits it into an internal transfer

The lookup is indexed: the destination is hashed with SHA-256 and compared against wallet.addressLookupKey across ECO wallets, then confirmed against the wallet's parsed address map. Because that key only covers each wallet's primary address, there is a second pass that searches the stored address JSON, so a match on a secondary chain's address is still found.

  • The address belongs to another user here. The withdrawal is processed as an internal transfer. No transaction is broadcast, nothing enters the withdrawal queue, and no network fee is paid by anyone.
  • The address belongs to the sender. Refused with a 400 telling them to use a different address. Without that guard the transfer would debit and credit the same wallet, charge a fee for the privilege, and report success while nothing left the platform.
  • The address is external. The ordinary withdrawal path runs.

The short-circuit runs through the core internal-transfer routine, which applies the platform's walletTransferFee percentage and records the fee in admin_profit. The addon's own /transfer endpoint applies no fee at all. So the same two customers moving the same amount are charged differently depending on whether one of them pasted the other's deposit address into the withdrawal form or the transfer was made through the transfer endpoint. That is a real asymmetry in the product, not a misreading — check which path a disputed amount took before explaining the difference to a customer.

This path also writes the private ledger. It walks the sender's per-chain address balances, takes what it needs from each in turn, and records a ledger entry per chain touched on both sides — because the customer balances changed while every coin stayed exactly where it already was. That is the designed behaviour, and it is why an install with heavy internal transfer traffic carries non-zero offchainDifference rows as a matter of course. See The private ledger.

If the sender's chain balances cannot cover the amount, the transfer is refused with "Insufficient chain balance to complete the transfer" even though their wallet balance looks sufficient. That is the same distinction as everywhere else in this addon: wallet.balance is what they are owed, the per-chain figures are where it is recorded to be sitting.

Cross-currency, and the spread that protects you

Neither path above changes currency. The ecosystem transfer endpoint takes a single currency and uses it for both sides, and the withdrawal short-circuit inherits the withdrawal's currency.

Person-to-person transfers on the core endpoint are refused outright when the currency or the wallet type differs — a hand-rolled request asking to send 1 SHIB and credit 1 BTC used to be honoured 1:1, and is now a 400.

Conversion happens in exactly one place: a wallet-to-wallet transfer, where a customer moves their own money between two of their own wallet types (ECO to SPOT, ECO to FUTURES, ECO to FIAT). There, the platform prices both sides in USD, derives a mid-market rate, and applies a spread against it.

Margin applied against the mid-market rate on cross-currency transfers, protecting against rate-feed lag. Applied as rate = mid x (100 - spread) / 100, so the customer receives slightly less than mid.
Percentage fee on wallet transfers. Charged on the withdrawal short-circuit path; not charged by the ecosystem transfer endpoint.

Both live in Admin → System → Settings → Wallet → Fees, as ranges from 0 to 10 percent in steps of 0.1.

They are stored in the platform's own settings table, not in anything the Ecosystem addon owns — disabling or reinstalling the addon does not touch them, and they apply to SPOT and FIAT transfers just as much as to ECO ones. Both keys are also on the platform's protected list, so an admin without the Super Admin role cannot change either, however their permissions are set.

The spread exists for one reason: the rate is derived from a price feed, and a feed lags. A transfer priced at exactly mid is free money for anyone watching a faster quote. The default of 0.5% is a small, deliberate margin in the platform's favour; setting it to 0 removes that protection.

The core transfer endpoint is additionally gated by the KYC feature transfer_wallets, so a customer whose level does not grant that feature cannot use it at all when KYC feature enforcement is on.

Reconciling one

There is no transaction hash, because there is no transaction. Searching a block explorer for it will find nothing, and the absence is not evidence that anything failed. Look in the database.

Both paths record the same two row types, so one query covers both:

SELECT t.id, t.userId, t.walletId, t.type, t.status,
       t.amount, t.fee, t.description, t.createdAt
FROM `transaction` t
WHERE t.type IN ('OUTGOING_TRANSFER', 'INCOMING_TRANSFER')
  AND t.walletId = '<walletId>'
ORDER BY t.createdAt DESC;
  • The OUTGOING_TRANSFER row is the sender's side; its metadata carries toWalletId and toUserId, which is how you identify who actually received the money when a customer says it went to the wrong person.
  • The INCOMING_TRANSFER row is the recipient's side, with fromWalletId and fromUserId.
  • Both carry the balance before and after in metadata, so the pair is self-verifying: you can confirm the arithmetic without trusting any current balance.
  • trxId is null on both. A transfer row with a hash in it is not an internal transfer.

If the short-circuit path charged a fee, there is a third row — the platform fee, credited to the Super Admin's wallet and recorded in admin_profit against the sender's outgoing transaction id.

There is no reversal action, no admin screen for it and no endpoint. Both wallet balances have already moved and both ledger rows are written. Recovering a misdirected transfer means persuading the recipient to send it back — through this same endpoint, in the other direction. Verify the recipient before the money moves, not after.