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.

12 min readUpdated 3 September 2026custody, deposits, withdrawals, address-model, eip-7702, network-fee, utxo

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:

  1. finds or creates their ECO wallet for that currency;
  2. looks up every active Ecosystem token with that currency symbol;
  3. for each token's chain, derives an address if one is missing;
  4. writes the address back into the wallet's address map and creates the matching wallet_data row.

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.

GET/api/ecosystem/wallet/{currency}
Returns the user's ECO wallet for a currency, creating and backfilling addresses as needed

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

ecosystemAddressModeltype: selectdefault: per_user on a new install
Which deposit address a customer is given on an EVM chain

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.

ENABLE_DEPOSIT_MONITORINGtype: booleandefault: false
Set to the literal string "true" to enable the optional background deposit sweep loop, separate from the per-session monitors.
ECOSYSTEM_BACKGROUND_SCANtype: booleandefault: true
Set to "false" to disable the rate-limited background deposit scanner entirely.

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.

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

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

  3. Debit under a row lock. The wallet row is locked FOR UPDATE, the balance is checked, and amount + platform fee + network fee is deducted in one transaction alongside a PENDING transaction row. The network fee is the part that is reconciled afterwards.

  4. Queue. The transaction ID goes onto an in-memory FIFO, which broadcasts it and moves the row through PENDINGPROCESSINGCOMPLETED.

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 PENDING row 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:

  1. The customer's own address.
  2. 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.
  3. The master wallet's own token balance — a legacy custodial balance that has been swept there, or a top-up an operator sent.
  4. A legacy custodial contract, only while the custodial mode is drain. These are the shared contracts an older install deployed for NO_PERMIT deposits; the admin console covers draining them.

How it leaves

ecosystemEvmMovertype: selectdefault: auto
Which mover a token withdrawal from a customer's address uses
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

ecosystemChargeNetworkFeetype: booleandefault: true
Charge the gas of the withdrawal transactions to the customer, in the withdrawn token
ecosystemNetworkFeeBufferPercenttype: numberdefault: 20
Head-room added to the gas estimate at initiation, in percent

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.