ERC20 – Collecting ERC20 Balances from Multiple Addresses: A Guide

dapp-designdappserc-20ethereumjs

I'm building a dApp that involves creating unique accounts for individual users for ERC20 deposits [I keep the addresses and private keys in a centralized database], for popular ERC20 tokens like BNB, 0x, etc. However, to function, my dApp needs to collect all these balances into a central wallet. Since these are ERC20 deposits and not ETH deposits, the user wallets have no ETH deposited in them and therefore will have no way to pay for the gas to send their ERC20 balances to the central wallet.

Does anyone know an efficient solution to this problem? My naive solution is to just send ETH into a user's wallet every time I detect an ERC20 deposit into that wallet, so that said wallet can pay for the gas to transfer, but this is ugly and seems like a lot of overhead in gas.

Thanks!

Best Answer

There are two ways this is done today:

1. Normal Accounts

Generate wallet keys / addresses that your users transfer to, and you have to send gas to those addresses in order to move funds.

Downsides:

  • collecting funds requires you to send gas to these addresses which will ultimately result in dust accumulating in these
  • storing and managing the account private keys can become a challenge

2. Smart Contract Accounts

Deploy a smart contract factory that generates a "receiving address" that the main "account can control".

In essence you have a smart contract that creates new addresses that store received funds, and you have an "owner" address that can call a method to transfer funds.

Factory Smart Contract

There are multiple ways you can implement this:

The Factory contract, can implement ownership validation and control the receivers, or each receiver could do the validation itself.

Best implementation, security and performance wise i can think of is the following:

Factory contract:

  • has an owner ( the owner can be changed )
  • the owner can create new "receivers"
  • the factory indexes receivers in a mapping ( uint => address )
  • the owner can collect or return funds from receiver contracts
  • accepts a batch call in order to collect / refund from multiple receivers

Batching is important if you want to lower gas usage and implement hardware signing

Think about having a separate server that does the signing, you could even have an external hardware wallet plugged into this, or an HSM ( Hardware signing module ).

The awesome part here is that you can even implement this in such a way that you can collect tokens from any Ethereum based smart contract / token tracker ( BNB / DAI / etc. ) or any token standard using a custom caller interface ( you can call arbitrary methods at any address you want ).

No need to send gas to these receivers as the owner is the one "sending the transaction" and the "receiver contract is the owner of the tokens"

Cheap gas for deploying and interacting with receivers, since you can do it when the network is not congested, as well as the ability to integrate with GasToken ( gastoken.io ) to lower your price even more.

Hope this helps.

Sample Starting Code

pragma solidity ^0.4.25;

/*
    Just the interface so solidity can compile properly
    We could skip this if we use generic call creation or abi.encodeWithSelector
*/
contract ERC20 {
    function totalSupply() public constant returns (uint);
    function balanceOf(address tokenOwner) public constant returns (uint balance);
    function allowance(address tokenOwner, address spender) public constant returns (uint remaining);
    function transfer(address to, uint tokens) public returns (bool success);
    function approve(address spender, uint tokens) public returns (bool success);
    function transferFrom(address from, address to, uint tokens) public returns (bool success);
    event Transfer(address indexed from, address indexed to, uint tokens);
    event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

/*
    Generic Receiver Contract
*/
contract Receiver {
    
    address public owner;

    constructor() public {
        /* 
            Deployer's address ( Factory in our case )
            do not pass this as a constructor argument because 
            etherscan will have issues displaying our validated source code
        */
        owner = msg.sender;
    }
    
    /*
        @notice Send funds owned by this contract to another address
        @param tracker  - ERC20 token tracker ( DAI / MKR / etc. )
        @param amount   - Amount of tokens to send
        @param receiver - Address we're sending these tokens to
        @return true if transfer succeeded, false otherwise 
    */
    function sendFundsTo( address tracker, uint256 amount, address receiver) public returns ( bool ) {
        // callable only by the owner, not using modifiers to improve readability
        require(msg.sender == owner);
        
        // Transfer tokens from this address to the receiver
        return ERC20(tracker).transfer(receiver, amount);
    }
    
    // depending on your system,  you probably want to suicide this at some
    // point in the future, or reuse it for other clients
}

/*
    Factory Contract
*/

contract Factory {
    
    address public owner;
    mapping ( uint256 => address ) public receiversMap;
    uint256 receiverCount = 0;
    
    constructor() public {
        /* 
            Deployer's address ( Factory in our case )
            do not pass this as a constructor argument because 
            etherscan will have issues displaying our validated source code
        */
        owner = msg.sender;
    }
    
    /*
        @notice Create a number of receiver contracts
        @param number  - 0-255 
    */
    function createReceivers( uint8 number ) public {
        require(msg.sender == owner);

        for(uint8 i = 0; i < number; i++) {
            // Create and index our new receiver
            receiversMap[++receiverCount] = new Receiver();
        }
        // add event here if you need it
    }
    
    /*
        @notice Send funds in a receiver to another address
        @param ID       - Receiver indexed ID
        @param tracker  - ERC20 token tracker ( DAI / MKR / etc. )
        @param amount   - Amount of tokens to send
        @param receiver - Address we're sending tokens to
        @return true if transfer succeeded, false otherwise 
    */
    function sendFundsFromReceiverTo( uint256 ID, address tracker, uint256 amount, address receiver ) public returns (bool) {
        require(msg.sender == owner);
        return Receiver( receiversMap[ID] ).sendFundsTo( tracker, amount, receiver);
    }
    
    /*
        Batch Collection - Should support a few hundred transansfers
        
        @param tracker           - ERC20 token tracker ( DAI / MKR / etc. )
        @param receiver          - Address we're sending tokens to
        @param contractAddresses - we send an array of addresses instead of ids, so we don't need to read them ( lower gas cost )
        @param amounts           - array of amounts 

    */
    function batchCollect( address tracker, address receiver, address[] contractAddresses, uint256[] amounts ) public {
        require(msg.sender == owner);
        
        for(uint256 i = 0; i < contractAddresses.length; i++) {
            
            // add exception handling
            Receiver( contractAddresses[i] ).sendFundsTo( tracker, amounts[i], receiver);
        }
    }
    
}

TODO

  • add ownership change
  • add view that reads balance of a receiver for a specified tracker
  • add events where needed
  • add exception handling in batchCollect

Flow:

  • 1.Deploy Factory contract
  • 2.Call createReceivers on the Factory contract
  • 3.Send some test tokens to a receiver mapped in the receiversMap
  • 4.Call sendFundsFromReceiverTo on the Factory to move said tokens to a new address