Moving to per-user addresses

The operator's step-by-step for an existing install updating to per-user deposit addresses, EIP-7702 withdrawals and the customer-paid network fee — what the first boot does by itself, the decisions on the Settings screen, how to retire the custodial contracts, and how to verify and roll back.

8 min readUpdated 3 September 2026custody, migration, address-model, custodial-wallet, eip-7702, network-fee

This page is for an install that already has customers, addresses and — on EVM chains — custodial contracts, and is updating to the release that moves every EVM token to the customer's own address. A fresh install needs none of it; it starts on the new defaults. Deposit wallets explains the model. This page is the order to do things in.

The short version: nothing breaks on the first boot, nothing you already issued changes, and the decisions that remain are settings you can take at your own pace.

What the first boot does by itself

Update the release and restart. Four things happen with nothing to run.

  1. Two settings rows are materialised from what the database holds. ecosystemAddressModel becomes per_currency when any wallet_data row exists — an install that has handed out addresses keeps handing them out the same way — and per_user when none does. ecosystemCustodialMode becomes drain when any custodial contract row exists and off when none does. Both are written once; from then on the Settings screen owns them.

  2. The unique index on wallet.addressLookupKey is relaxed to a plain index during the schema sync. Under per_user several currency wallets of one customer share one address, so the key that used to be unique is now designed to repeat. An install that boots with DB_SYNC=none makes no schema changes at boot, so run the script once instead:

    node backend/scripts/relax-wallet-address-lookup.mjs --apply
  3. Every existing address stays exactly as it is. No customer is given a new address, no key is re-derived, and nothing moves on-chain.

  4. Legacy NO_PERMIT wallets repair themselves. A wallet whose chain entry was { balance: N } with no address — the shape a shared-contract deposit left behind — is issued a real address the next time the customer opens it, and keeps N.

Every EVM token — NO_PERMIT included — now deposits to the customer's own address. The contracts still hold whatever they held before the update; that balance is what step 3 is about, and it keeps serving withdrawals in the meantime.

Step 1 — Read the Settings screen once

Admin → Ecosystem → Settings (/admin/ecosystem/settings) has four tabs. On an upgraded install they open like this:

Tab Setting What the upgraded default means
Addresses ecosystemAddressModel = per_currency New addresses are still one per currency wallet per chain. Nothing changes for anyone until you decide otherwise in step 2.
Withdrawals ecosystemEvmMover = auto Token withdrawals leave the customer's address by EIP-7702 where the chain allows it, else permit, else just-in-time gas. No custodial contract is needed for any of them.
Withdrawals ecosystemChargeNetworkFee = on, buffer 20% The one default that changes what customers see straight away: a token withdrawal now debits an estimated network fee in the withdrawn token, and refunds the surplus after confirmation. Step 4 covers it.
Custodial wallets ecosystemCustodialMode = drain (when contracts exist) The contracts are the last-resort withdrawal source and their admin page stays visible. The summary tile shows how many exist and how many are still active.
Diagnostics read-only Empty until the first token withdrawal on each chain, then the delegate contract address per chain and any chain that refused EIP-7702.

Nothing on this screen needs saving to proceed. The rest of this page is the three decisions it holds, in the order they are worth taking.

Step 2 — Decide the address model

ecosystemAddressModeltype: selectdefault: per_currency on an upgraded install
Which deposit address a customer is given on an EVM chain

per_currency keeps issuing one address per currency wallet per chain, as the install always has. Choose it and nothing changes for anyone.

per_user makes every new (wallet, chain) pair reuse the customer's one EVM key, so the address is the same on every EVM chain. The first EVM address a customer already holds becomes that key; anything issued afterwards copies it.

Switching never changes an existing address. A customer who already has two addresses on BSC keeps both, and continues to be shown the one each currency wallet already carries. Only newly added currencies and chains join the shared address — so on an upgraded install the two layouts coexist for as long as old wallets exist, and that is fine.

Why switch:

  • Wrong-network and wrong-token sends still land on a platform-held address. A token sent on the wrong EVM chain, or to the "wrong" token's address, is recoverable because you hold the key on every chain.
  • Gas dust pools per customer rather than per address, so what a just-in-time top-up leaves behind is reused by the next withdrawal.
  • Fewer keys to encrypt, hold and back up.

The one operational consequence: the chain's native coin — ETH, BNB, MATIC — shares the address with the tokens. That is deliberate, and it means the platform's own gas top-ups to that address, sent by the master wallet before a just-in-time transfer, are never credited as deposits. They are the platform's gas, not the customer's money, and the deposit monitor knows the difference.

Step 3 — Retire the custodial contracts

ecosystemCustodialModetype: selectdefault: drain when contracts exist, otherwise off
What the legacy shared custodial contracts still do

The contracts receive nothing now, but they may still hold tokens that back customer balances. Two modes:

  • drain — the contracts keep serving withdrawals as the last source, after the customer's own address, other customers' addresses and the master wallet. Admin → Ecosystem → Wallets → Custodial Wallets stays in the menu, and each row carries Sweep to master wallet, which moves one token's whole balance from that contract to the chain's master wallet. Once the master wallet holds the tokens it serves withdrawals directly, in either mode.
  • off — the menu entry is hidden and the custodial routes refuse. Any balance still sitting in a contract stops backing withdrawals.
POST/api/admin/ecosystem/wallet/custodial/{id}/sweeppermission: edit.ecosystem.custodial.wallet
Sweeps a contract's entire balance of one token to the master wallet. Body takes currency. Refused while the mode is off.

Creating a new contract is refused in both modes.

Convert immediately.

  1. Sweep every token on every contract. On the Custodial Wallets page, run the sweep once per token each contract holds. Each sweep is one on-chain transaction paid by the master wallet, so the master wallet needs native coin on that chain first.

  2. Confirm each contract reads zero on the Custodial Wallets page for every token it used to hold.

  3. Set the mode to off under Settings → Custodial wallets.

Fall back until drained. Leave the mode on drain, check the Custodial Wallets page weekly, and switch to off when everything reads zero. Customer withdrawals draw the contracts down by themselves, and any balance you would rather not wait for can be swept at any point.

Step 4 — Withdrawals and gas

ecosystemEvmMovertype: selectdefault: auto
Which mover a token withdrawal from a customer's address uses

auto tries EIP-7702 first, then permit for PERMIT tokens, then just-in-time gas. Leave it there unless step 6 gives you a reason not to.

The first token withdrawal on each chain does one extra thing: it deploys the EcosystemDelegate contract from the master wallet, once, costing one contract deployment's gas. There is no button for this. A chain whose node refuses type-4 transactions is remembered for 24 hours under Settings → Diagnostics and uses the fallback mover meanwhile; the probe is repeated after that.

So the master wallet's job has not changed: keep it funded with native coin on every chain that sees token withdrawals, exactly as before. A draining custodial contract needs no native coin of its own — the master wallet pays for every token moved out of it.

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

With the charge on, the customer is debited the estimated gas — with the buffer — in the withdrawn token, alongside the amount and the platform fee, and is refunded the surplus after confirmation; what the platform keeps is booked as withdrawal profit. That estimate needs a USD price for both the token and the chain's native coin. Set fee.network on any token that has no USD price, or whose chain's native coin has none — it is on the token's edit screen under Admin → Ecosystem → Trading → Tokens, a flat amount in token units. Without it, those withdrawals go out with the network charge skipped and a warning in the log, and the master wallet absorbs the gas.

Step 5 — Verify

  • Open the deposit page for a NO_PERMIT token as a customer. It shows a permanent address with no countdown, and the same address on a second visit.
  • Make one small token withdrawal per chain. Open the transaction record at Admin → Finance → Transactions and read the row's metadata: move.kind is eip7702, permit, jit_gas, master or custodial, move.source names where it was paid from, and networkFee carries tokenAmount (the quote), actualTokenAmount and refunded.
  • If move.source is alternative, the private ledger at Admin → Ecosystem → Wallets → Ledgers carries the matching entry for the address that lent the token.
  • After the first withdrawal on each chain, Settings → Diagnostics lists the delegate contract for that chain, and lists the chain under Chains refusing EIP-7702 only if its node rejected the transaction.
  • The Custodial Wallets entry appears in the admin menu while the mode is drain and disappears when it is off.

Step 6 — Rollback

Each decision reverses independently, and every reversal is a setting.

  • ecosystemAddressModel back to per_currency. Affects only addresses issued from then on; an address already shared across chains stays shared, and stays valid.
  • ecosystemEvmMover to jit_gas if a chain misbehaves under EIP-7702. Every chain then uses the two-transaction mover, which works everywhere the platform runs.
  • ecosystemCustodialMode back to drain if a legacy withdrawal is refused with "No funded source for … on …" after you set off — the credit behind it is still in a contract. Sweep, then try off again.

There is no way to go back to shared custodial deposits, and no need: every address a customer already holds keeps working, and every balance the contracts still hold keeps withdrawing under drain.