Deposit wallets and custody
Where customer coins actually sit — one permanent address per customer, the address model, how a token leaves an address that holds no gas at withdrawal, who pays the network fee, and how a deposit becomes a spendable balance.
An ECO wallet is one row per user per currency, holding a balance and a JSON map of addresses — one entry per chain that currency exists on. That map is the custody record. This page explains how the entries get there, why every one of them is the customer's own address, what happens between a transaction landing on-chain and a number changing in the platform, and how a token leaves an address that holds no gas when the customer withdraws.
See Custody migration — what the first boot does by itself, and the order to take the remaining decisions in.
Per-user addresses
The first time a user opens the deposit page for a currency, the platform:
- finds or creates their ECO wallet for that currency;
- looks up every active Ecosystem token with that currency symbol;
- for each token's chain, derives an address if one is missing;
- writes the address back into the wallet's address map and creates the
matching
wallet_datarow.
Every token gets an address this way — NATIVE, PERMIT and NO_PERMIT
alike, on every chain family. There is no shared deposit address anywhere on
the platform: nothing is reserved for a session, nothing expires, and two
customers are never shown the same address.
Derivation is per chain family. EVM chains derive from the master wallet's HD
material at the next unused index, which is why the master wallet's lastIndex
matters and why deleting a master wallet is unrecoverable; under the default
address model that derivation happens once per customer and
the key is reused on every other EVM chain. UTXO chains, Solana, Tron, TON and
Monero each generate through their own chain service.
The repair path is worth knowing about, because it runs on every fetch. An entry
whose address is missing is generated in place; so is a Tron entry that holds an
old 0x… EVM address from before Tron was handled properly. An entry that holds
a balance but no address — the shape a NO_PERMIT token's entry had when those
tokens deposited to a shared contract — is issued an address and keeps the
balance it was carrying. All of that is a silent, correct self-heal, not a
symptom.
Contract types
contractType on a token used to decide where a deposit went. It no longer
does: every EVM token deposits to the customer's own address. What it still
decides is which of the withdrawal movers
may be used to get the token out again.
| Type | What it means | Deposit goes to | Withdrawal |
|---|---|---|---|
NATIVE |
The chain's own coin — no contract | The customer's own address | Signed by the customer's own address; the actual gas comes out of the withdrawn balance |
PERMIT |
A token that implements EIP-2612 permit |
The customer's own address | Any mover — permit is one of the options |
NO_PERMIT |
A token that does not | The customer's own address | EIP-7702 or just-in-time gas |
NATIVE tokens carry the null-address sentinel
0x0000000000000000000000000000000000000000 as their contract, which is what
lets them pass address validation and match the seeded natives.
Tokens deployed through the admin panel are always created as PERMIT — the
bundled ERC20 contract supports it. NO_PERMIT arises when you import an
existing third-party token that does not. Neither flag needs any preparation
from you; the difference is only which movers the engine has to choose from.
Solana, Tron, Monero, TON, MO and every UTXO chain have no movers to choose between. They deposit to and withdraw from the customer's own address regardless of contract type, as they always have.
Address model
Set at Admin → Ecosystem → Settings → Addresses. Two values:
per_user— one key per customer, so the same address on every EVM chain. The first EVM address a customer is ever issued becomes canonical, whichever chain or currency asked for it; every later (wallet, chain) pair copies that key instead of deriving a new child. The chain's native coin shares it too, so a deposit of ETH and a deposit of any token on Ethereum land at one address.per_currency— the legacy layout: one HD address per ECO wallet per chain, derived at the master wallet's next index each time.
A new install starts on per_user. An install that has already issued
addresses starts on per_currency, because the model must not change under the
addresses it already handed out, and may switch whenever you choose.
Switching never changes an address that already exists. Only addresses issued after the switch follow the new model. A customer who already holds two different addresses on one chain from the legacy layout is given the one that chain already knows when a new wallet needs an address there, not a third.
Why per_user is the default:
- Wrong-network and wrong-token sends are recoverable. A token sent on the wrong EVM chain, or to the "wrong" token's address, still lands on an address the platform holds the key for. Under the legacy layout it lands on an address that exists on one chain only.
- Gas dust pools per customer instead of being scattered across one address per wallet per chain, so what the just-in-time mover leaves behind is reused by the next withdrawal rather than stranded.
- Fewer keys to hold, encrypt and back up.
How a deposit is detected and credited
There are three detection paths and one crediting path.
The per-session monitor. While a user has the deposit page open, a WebSocket monitor polls their address roughly every 30 seconds. This is the fast path and the one users experience.
The background scanner. Addresses seen on a deposit page are registered for
72 hours and swept by a rate-limited background loop, so a deposit that arrives
after the user closes the tab is still found. Each chain gets a token bucket, so
provider rate limits hold regardless of user count. Disable with
ECOSYSTEM_BACKGROUND_SCAN=false.
The Bitcoin scanner. BTC has its own 60-second cron scanner, plus optional realtime ZMQ detection when running against a self-hosted Bitcoin Core node.
All three do the same thing when they find something: they write a pending transaction into Redis. Crediting is a separate job.
The verification watchdog runs every 60 seconds, walks the pending set, checks confirmation depth, and credits the wallet when the transaction is deep enough. Required depth comes from the chain configuration — 3 for Bitcoin, 6 for Litecoin, Dogecoin and Dash, and 12 by default for anything that does not specify. This is the only job that credits deposits; an earlier unlisted 10-second worker was removed precisely because no admin surface could see, start or stop it, and it kept crediting after the extension had been disabled.
Between detection and crediting, a deposit lives in Redis. It has been debited from nobody and credited to nobody. Losing Redis in that window loses the pending record — the coins are still on-chain and still at the user's address, but the platform will only find them again on the next scan of that address.
Withdrawals
A withdrawal is a queue, not a request-response.
-
Validation. Address format, chain-specific address validity, decimal precision against the token's
precision, and the withdrawal 2FA policy if the admin has enabled it. -
Internal short-circuit. If the destination address belongs to another user on this platform, it is processed as an internal transfer instead — no on-chain transaction. Withdrawing to your own address is rejected outright, because the transfer would debit and credit the same wallet, charge a fee, and report success.
-
Debit under a row lock. The wallet row is locked
FOR UPDATE, the balance is checked, andamount + platform fee + network feeis deducted in one transaction alongside aPENDINGtransaction row. The network fee is the part that is reconciled afterwards. -
Queue. The transaction ID goes onto an in-memory FIFO, which broadcasts it and moves the row through
PENDING→PROCESSING→COMPLETED.
The platform fee is max(amount × percentage, min) from the token's fee
object, always denominated in the withdrawn currency, and it is kept in full.
The network fee is a separate line in the same currency, refunded down to what
the transactions actually cost.
Recovery
Because the queue is in-memory and the debit is already durable, a restart between the two would strand a wallet-debited row forever. Three jobs prevent that:
- a boot-time sweep that re-enqueues every
PENDINGrow with no age filter, before the queue starts taking new work; - a watchdog every 5 minutes for rows older than 3 minutes;
- a legacy 30-minute pass running the same recovery.
Recovery is careful about the one case that would cost real money. A
PROCESSING row that already has a transaction hash is never re-broadcast —
it is promoted to COMPLETED, because the funds have irreversibly left. A
PROCESSING row with no hash and no on-chain match is reverted to PENDING for
retry. A stale row it cannot classify is left alone for a human, which is
exactly the set the admin overview breaks out separately.
How a token is moved out at withdrawal
A customer's address holds the token and the platform holds its key, but the address holds no native coin, so it cannot pay for its own transfer. Every EVM token withdrawal is therefore two decisions: where the token is paid from, and how it leaves an address with no gas. Native-coin withdrawals make neither — the customer's own address signs and pays.
Where it is paid from
The engine tries these in order and checks every candidate on-chain before it is chosen, so a balance the platform's own records overstate cannot be picked and then fail after the customer has been debited:
- The customer's own address.
- Another customer's address on the same chain that holds enough of the token. The private ledger records the amount as lent to the platform by that address; the other customer's balance is untouched.
- The master wallet's own token balance — a legacy custodial balance that has been swept there, or a top-up an operator sent.
- A legacy custodial contract, only while the custodial mode is
drain. These are the shared contracts an older install deployed forNO_PERMITdeposits; the admin console covers draining them.
How it leaves
| Mover | What happens | Where it works |
|---|---|---|
eip7702 |
One transaction, sent by the master wallet, carrying an authorization signed by the customer's key. The authorization points the address at the platform's EcosystemDelegate contract, and the same transaction calls the delegate to transfer the token. The address never holds native coin. |
Chains that accept type-4 (EIP-7702) transactions |
permit |
The customer's key signs an EIP-2612 allowance off-chain; the master wallet submits it and pulls the token, paying the gas. | PERMIT tokens only |
jit_gas |
Two transactions: the master wallet sends the address exactly the native coin one transfer needs, then the address's own key signs the transfer at the same gas price. Whatever the estimate leaves behind is reused by the next top-up. | Every EVM chain, custom chains included |
auto, the default, tries them in that order — EIP-7702 first, then permit
for PERMIT tokens, then just-in-time gas — and is the right value for almost
every install. The forced values eip7702, permit and jit_gas pin one
mover; permit can only apply to tokens that implement it.
The delegate contract. EcosystemDelegate is deployed once per chain by
the master wallet, automatically, the first time a withdrawal on that chain
needs it — there is no deploy button and nothing to configure. It moves tokens
and native coin only when called by the master wallet and holds no storage. Its
address is recorded per chain and network and shown under Settings →
Diagnostics → Delegate contracts; before every use the recorded address is
verified on-chain, so a rotated master wallet redeploys rather than pointing
withdrawals at a contract the current master cannot call.
Chains that refuse. A chain whose node rejects a type-4 transaction is remembered for 24 hours and the fallback mover is used without paying the probe again. The list is shown under Settings → Diagnostics → Chains refusing EIP-7702, with the time of the last refusal. A revert, an insufficient-funds refusal or a nonce clash is not treated as a refusal — those are reported as the failure they are.
Network fee
Both live under Settings → Withdrawals. With the charge on — the default — a token withdrawal on an EVM chain costs the customer three things in the withdrawn token: the amount, the platform fee, and the network fee. The master wallet still pays the gas on-chain in native coin; the network fee is how it is made whole.
At initiation the engine estimates the gas the mover will use — both transactions, for just-in-time gas — at the chain's current adjusted gas price, adds the buffer (20% by default, so the customer is held for 1.2× the estimate), converts the native cost into the withdrawn token through the platform's USD rates, rounds up to the token's precision, and debits it together with the amount and the platform fee.
After confirmation the receipts are read, the actual cost is converted at
the same rate the quote used — so a price move between the two moments
cannot turn a refund into a second charge — and the surplus is credited back to
the customer. The transaction's fee is rewritten to the actual figure before the
row turns COMPLETED, and that is what is booked as withdrawal profit: the
platform fee plus the network fee the platform actually kept.
When there is no price. A self-listed token with no market, or a chain
whose native coin the platform does not price, cannot be converted. Then the
token's own optional fee.network — a flat amount in token units, set on
the token form — is charged instead, as a fixed fee that is not reconciled. If
that is unset too, the network fee is skipped and a warning is logged naming
the token; the withdrawal still goes out, because a token that cannot be
withdrawn is a worse outcome than one gas fee the operator chose not to
configure.
Two things this does not touch. NATIVE coin withdrawals keep the behaviour
they always had: the customer's own address pays, and the actual gas is deducted
from the withdrawn balance. And turning the charge off simply means the
master wallet absorbs every token withdrawal's gas.
Supporting records
wallet_data is the platform's own record of what it holds per (wallet,
chain, currency), maintained inside the same locked transaction that credits a
deposit or debits a withdrawal. It is not a live chain read and nothing claims it
is.
The private ledger (ecosystem_private_ledger) records offchainDifference
per wallet, index, currency and network — balance the platform still records at
an address but has already paid away from it, typically because that address
was used as the source of another customer's withdrawal. The withdrawal engine
subtracts it before it will source a payout, and so does the coverage figure on
the admin overview. Browse it at Admin → Ecosystem → Wallets → Ledgers.
Unspent outputs (ecosystem_utxo) track UTXO-chain inputs with a status of
UNSPENT, LOCKED or SPENT and a source of DEPOSIT, CHANGE,
CONSOLIDATION or SYNC. Browse them at Admin → Ecosystem → Wallets → UTXO.
A withdrawal that reports "not economical" is telling you the available inputs
would cost more in fees than the amount being sent.
Related
- Master wallets and the vault — the keys behind all of this, and what the master wallet pays for
- Tokens and markets — where
contractTypeand the token'sfeeare set - The admin console — the Settings screen and the legacy custodial contracts
- Operations — the cron jobs named above
- Troubleshooting — deposits not crediting, withdrawals stuck