Deposit wallets and custody

Where customer coins actually sit — per-user HD addresses, the three token contract types, shared custodial contracts for non-permit tokens, and how a deposit becomes a spendable balance.

8 min readUpdated 3 August 2026custody, deposits, custodial-wallet, withdrawals, 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, which ones are the customer's own address and which are shared, and what happens between a transaction landing on-chain and a number changing in the platform.

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.

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. 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, or whose Tron entry holds an old 0x… EVM address from before Tron was handled properly, is regenerated in place. That is a silent, correct self-heal — not a symptom.

Returns the user's ECO wallet for a currency, creating and backfilling addresses as needed

The three token contract types

Everything about how a deposit and a withdrawal work follows from one field on the token: contractType.

Type What it means Deposit goes to Withdrawal signed by
NATIVE The chain's own coin — no contract The user's own address The user's own address; network fee comes out of the withdrawn balance
PERMIT A token that can delegate its fee The user's own address The user's address, with the master wallet as gas payer
NO_PERMIT A token that cannot A shared custodial contract The custodial wallet, from its own native balance

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.

Custodial wallets

Custodial wallets exist for exactly one case: an EVM NO_PERMIT token. The user's own address holds no gas, and the token cannot pay its own fee, so it cannot be moved from there. Instead the platform deploys a contract it owns, users deposit into it, and the platform moves funds out using the contract's own native balance.

They apply to EVM chains only. Solana, Tron, Monero, TON, MO and every UTXO chain deposit to the user's own address regardless of contract type — requesting a custodial wallet on those chains is not a thing the platform does.

  1. Create the chain's master wallet first. The custodial contract is deployed by it and constructed with its address as owner.

  2. Fund the master wallet with native coin. Deployment costs real gas; an underfunded master wallet returns "Insufficient funds in master wallet to deploy custodial contract".

  3. Deploy from Admin → Ecosystem → Wallets → Custodial Wallets.

    Deploys a custodial wallet contract and records it
  4. Deploy several. Deposit sessions take an exclusive lock on a custodial address, so the number of contracts is your concurrency limit for non-permit deposits.

  5. Keep each one funded with native coin. The custodial wallet pays the network fee when a customer withdraws the tokens it holds.

The address lock

When a user opens a deposit page for a NO_PERMIT token, the platform picks a custodial wallet that is not currently locked, locks it, and shows its address. The lock is in-memory, per process, and expires after one hour; expired locks are swept whenever another request needs a wallet.

If every custodial wallet is locked, the request fails with 404 and the message "All custodial wallets are currently in use. Please try again later." That is a capacity signal — deploy more contracts.

Releases the lock on a deposit address early

Two consequences of the lock being in-memory: it does not survive a restart, and on a multi-process deployment each process keeps its own view. Neither is dangerous — a duplicate assignment means two users are shown the same address, and deposits are still attributed correctly by transaction — but it does mean the lock is an ordering convenience, not a guarantee.

Lists custodial wallets with balances
Sweeps native coin out of a custodial wallet
Sweeps a token out of a custodial wallet

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.

Set to the literal string "true" to enable the optional background deposit sweep loop, separate from the per-session monitors.
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 is deducted in one transaction alongside a PENDING transaction row.

  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, and it is always denominated in the withdrawn currency. Network gas is not added to the user's debit — the master, custodial or user address pays it in the chain's native asset, and the platform recovers it through the token fee. A quoted "estimated network fee" is informational only.

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.

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 an earlier payout was sourced there. 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.