I am wondering if anyone is aware of the structure that is used by centralized exchanges like Binance, Coinbase, KuCoin, etc. to handle their ETH/ERC20 Tokens deposits.

  • I started to handle this by providing the users an EOA address and having an EOA master account and transfer all the deposits to it.

  • But when I encountered to the lack of ETH for ERC20 transfer(from user's account to the master account) fee problem, I decided to implement a modified version of this contract address approach by providing each user a contract address rather than an EOA. I handled the auto transfer of ETH with zero fee to the master and the support for all the ERC20s issues that this article did not proposed.

  • But the problem of high fee for deploying smart contracts (after a lot of modifications, I had to pay 100 bucks for each deployment, which means creating a contract address for each user cost me $100) and still having to pay fee for ERC20 token transfer to the master account, which is not affordable economically, made me to explore more.

  • I am currently working on @mikko-ohtamaa's suggestion to use some alternative EVM compatible chains to lower the costs.

  • Now, the question is, when I see big centralized exchanges' structures at first glance, I see that they provide their users EOAs and not contract addresses (so they don't have to pay for the address creation) and I see that each deposit to the user's address will end up with a transfer to their master account, but am unable to track down the rest of the flow. I appreciate it if anyone share their knowledge on how they handle the aforementioned deposits issue or any suggestions to handle this side of transaction flows with the minimum possible charges.

Thanks in Advance, R.

  • Geth version: 1.10.12-stable
  • Geth's web3 api version: 0.20.1
  • OS: Windows
  • web3.py version: 5.25.0
  • python version: 3.10.1
  • I started my geth using the following command: geth console --goerli --syncmode "light" --http --http.port 20000 --http.api personal,eth,net,web3 --allow-insecure-unlock

3 Answers 3


Your observations are all correct, each exchange choses its preferred way of handling, but most I've seen use an EOA.

Externally Owned Account

To lower costs, the exchange can postpone the withdrawal of assets to a time in the day when gas price is low, they usually got enough assets in their main wallet to have this flexibility.

Sending ETH is the cheapest and uses only 21k gas, unlike ERC20 transfers, so when gas price is low (10-20 Gwei) -

  1. Exchange sends ETH to the address (shouldn't cost more than $1-$3);
  2. Extracts the tokens ($4-$8);
  3. Keep the remaining ETH for a day or two;
  4. If no other deposits are made, withdraw the remaining ETH, leaving the EOA empty.

That's how FTX handles its deposits / withdrawals.

Contract Approach

Benefit of using a contract, are that you can use tap to Deposit events, so your backend will know a new deposit has arrived, instead of constantly checking all wallet's balance.

Another benefit is that for ETH deposits, you can lay the transaction cost on the sender by using the receive() payable to immediately redirect funds to contract owner.

To lower costs, you -

  1. Create a bulk of contract wallets when gas price is low;
  2. When user requests a deposit address, allocate an unused contract;
  3. User sends ETH to the contract;
  4. Contract forwards funds to owner, emits Deposit event;
  5. User pays gas fee.

For ERC20 deposits, a contract can save you the ETH for gas requirement - i.e. you won't have to send ETH to the contract in order to extract the tokens, then sending the ETH back, saving you 2 extra transactions.

ERC20 deposits using contract -

  1. Create a bulk of contract wallets when gas price is low;
  2. When user requests a deposit address, allocate an unused contract;
  3. User sends a token to the contract;
  4. When gas price is low, exchange initiates a call to contract's withdraw function to clean all tokens (possibly ETH too).

So it seems that the contract approach is mostly cheaper for the exchange.

One caveat though, the depositor tx sometimes fails due to being out of gas, that's because estimateGas sometimes calculates 21,000 gas when it's actually requires more for the additional transfer.

  • instead of creating a bunch of deposit contracts and keeping them for spare, why the exchanges don't take advantage of create2 deterministic deployment after the user makes a deposit?
    – hack3r-0m
    May 20, 2022 at 14:07
  • Deployment is very expensive, you want the lowest gas possible - if you pre-create them, you can choose to do so when gas price is at lowest. Otherwise, you'd have to wait (after user sent his deposit) to when gas price is low again, which may take a day or more, or pay high fee if you need his deposit and can't wait until it goes down.
    – Kof
    May 20, 2022 at 17:37
  • Do you know any project providing CEX style deposits as a service? This seems to be a common problem for anything centralized- games, casinos, exchanges. Oct 15, 2023 at 7:20

I guess using CREATE2 opcode and using selfdestruct() after every deposit is more cost efficient than creating a bulk of contracts when gas price is low.


One way to handle ERC20(or any EVM network token) deposits is to approve the master wallet to spend the token amounting to the maximum value of uint256.

This way, there will be no need to transfer ether(or native coin of the evm blockchain) to move future deposits to the master wallet. The master wallet just calls the transferFrom function of the token to transfer from the deposit wallet to the master wallet.

The approve function is also cheaper to the transfer function. And you only have to call it once since no one can deposit that amount(uint256 max) of any token in their lifetime, so there will always be approval for the master wallet to call transferFrom on future deposits.

For security reasons though. What happens when the master wallet is compromised? Since anyone with access to the master wallet's private key can transfer all the tokens in all deposit wallets that has ever approved the master wallet, it will make more sense to approve a contract address instead. Or better still, an upgradable one.

A function is defined inside the contract which will have a multi-signature access. So that more than one party has to sign a transaction to execute the function successfully.

The function receives the contract address of the token, and a list of deposit wallet addresses to move the token from, along with the deposits amounts.

Function code example below:

contract DepositsController  {

    TODO:   define onlySignatories modifier,
            define atLeast80PercentSignatories
            define variables
    constructor(address[] _signatories, address _masterWallet) {
        signatories = _signatories;
        masterWallet = _masterWallet;

    function moveDeposits(address erc20ContractAddress, address[] depositAddresses, uint256[] depositAmounts) external onlySignatories returns (bool) {
        // initialize the token interface
        string memory token = IERC20(erc20ContractAddress);
        // iterate through the deposit addresses
        for(uint16 i = 0; i < depositAddresses.length; i++) {
            // transfer the current amount from the current deposit address, to this contract, 
            // since this contract is the wallet with the spending approval from the deposit wallet
            token.transferFrom(depositAddresses[i], address(this), depositAmounts[i]);

        if(masterWallet != address(this)) {
            token.transfer(masterWallet, token.balanceOf(address(this)));
        return true;

    function changeMasterWallet(address _masterWallet) external onlySignatories returns (bool) {
        masterWallet = _masterWallet;
        return true;

    function removeSignatory(address signatory) external atLeast80PercentSignatories returns (bool) {
        //TODO: have a map of address to boolean to check if an address is a signatory to avoid 
        // unnecessary iterations
        for(uint8 i = 0; i < signatories.length; i++) {
            if(signatories[i] == signatory) {
                signatories[i] = signatories[ signatories.length - 1];
        return true;

    function addSignatory(address signatory) external atLeast80PercentSignatories returns (bool) {
        //TODO: have a map of address to boolean to check if an address is a signatory to avoid 
        // duplicate signatory
        return true;

For the native coin on the other hand, a direct transfer to the master wallet can be executed since the gas fee for the transfer can be paid for from the deposit itself.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.