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.
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
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:
-
Finds the sender's ECO wallet for the requested currency. No wallet, no transfer.
-
Resolves the recipient user from the path parameter.
-
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.
-
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 ininOrderand are not available to transfer. -
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.
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.
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_TRANSFERrow is the sender's side; itsmetadatacarriestoWalletIdandtoUserId, which is how you identify who actually received the money when a customer says it went to the wrong person. - The
INCOMING_TRANSFERrow is the recipient's side, withfromWalletIdandfromUserId. - 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. trxIdis 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.
Related
- The private ledger — the rows the short-circuit path writes
- Where ecosystem data lives — the transaction and wallet tables in full
- Deposit wallets and custody — why every customer has an address in the first place
- When an Eco deposit or withdrawal goes wrong — the customer-facing version of the "it never appeared on-chain" question