Crypto news Community
30 September, 2024

Batch Ether and ERC-20 Transfer Method

Batch Ether and ERC-20 Transfer Method

Originally published by Eric Shek (Senior Software Engineer at Banxa) on his blog.

When compared to other L1s, lacking of capability to batch transfer has been one of the issue in EVM chains. When it comes to batching Ethers or ERC-20 token transfer, there are actually a few ways to accomplish it. In this article, we are going to introduce and compare some methods to achieve this and compare their pros and cons.

A. Disperse App

Disperse App is a smart contract which was introduced in 2018 to cater batch transfer of ERC-20 token or ether on EVM blockchains. Many crypto and De-Fi projects make use of this smart contract to airdrop tokens to their investors and users.

Disperse app smart contract is easy to use. It only has three interface disperseTokendisperseTokenSimple and disperseEther where you will have to pass in the ERC-20 token address, recipient and corresponding transfer amount in an array. One of the advantage of Disperse App is the transfer is in one single transaction, you can expect your investor and users to receive the token in the same block.

Disperse App has bounded the token transfer to be one type of ERC-20 token or ether in one single transaction. For ERC-20 batch transfer, senders are also required to approve transfer from the disperse app contract before calling the function.

The difference between disperseToken and disperseTokenSimple is the former function would transfer the all ERC-20 tokens required for the transaction from sender to the contract and then send it to the receiver one by one. While disperseTokenSimple would take ERC-20 tokens from sender to receiver one by one. The later is going to cost more gas because each transferFrom call is costing slight more than a transfer.

disperseToken

function disperseToken(IERC20 token, address[] recipients, uint256[] values) external {
    uint256 total = 0;
    for (uint256 i = 0; i < recipients.length; i++)
        total += values[i];
    require(token.transferFrom(msg.sender, address(this), total));
    for (i = 0; i < recipients.length; i++)
        require(token.transfer(recipients[i], values[i]));
}

disperseTokenSimple

function disperseTokenSimple(IERC20 token, address[] recipients, uint256[] values) external {
    for (uint256 i = 0; i < recipients.length; i++)
        require(token.transferFrom(msg.sender, recipients[i], values[i]));
}
Summary of gas cost saving when using Disperse App to batch transfer ERC-20 token (from Disperse App white paper: https://disperse.app/disperse.pdf)

According to the white paper, it can achieve around 1.84–2.85x improvement when it batch transfer to 284– 616 wallet address.

A side note on gas usage

There is one important thing to note before getting our hands dirty. The gas usage to transfer ETH and ERC-20 token to a new and existing account would be different according to the Disperse white paper. In particular when transferring ETH token, it is costing 21,000 gas for regular transfer regardless to a new or existing account. However, when we transfer ETH through a smart contract, it is going to take 25,000 gas to transfer to a new account.

Demo

Disperse app has a UI to input the recipient addresses and amount. I am going to use it to run batch transfer of Ethers and my test ERC-20 token to 10, 20 and 100 new and existing accounts. Obviously, it is possible to build a script to interact with Disperse app smart contract directly.

Disperse App UI
Disperse App UI

Result

I am not surprised that it’s actually spending more gas to transfer Ether to new account according to the side note above.

 ETH via Disperse App
Batch ETH via Disperse App results

It is worth to batch transfer ETH to an existing account on the blockchain. The savings are from 41% to 50%.

However, when it comes to ERC-20 transfer, it’s a whole different story. The gas usage to transfer ERC-20 is also depending on the underlying ERC-20 smart contract. Our testing ERC-20 contract is based on openzeppelin standard ERC-20.

pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract ERC20Mock is ERC20 {
    constructor(
        string memory name,
        string memory symbol,
        uint256 supply
    ) public ERC20(name, symbol) {
        _mint(msg.sender, supply);
    }
}

Here is the result:

Disperse App results
Batch ERC-20 via Disperse App results

Remarks: The gas usage above includes 46,323 gas to approve transfer which is a separate transaction. This is required once only, once you have given the allowance to Disperse contract on a specific ERC-20 token, you are no longer needed to approve transfer again.

It is a no brainer to batch ERC-20 transfer, looking at the gas savings. It is amazing that when we attempt to batch transfer to 100 accounts, it is saving more than 60% of gas.

Updated: There is one issue with batching ERC-20 token transfer with Disperse smart contract – the ERC-20 token has follow the ERC-20 standard which provide a transferFrom and transfer interface and return the transfer result as a boolean. Therefore, USDT does not work with Disperse smart contract.

Verdict

Using disperse smart contract to batch transfer ERC-20 tokens is simple and you can see the significant gas savings from the above. Another good thing is this smart contract is available across most EVM compatible blockchains. The drawback would be it requires approval prior to disperse and it is limited to one type of token transfer at a time.

B. Safe multi-send contract

The Safe mutli-send contract is part of the safe module that can be used by a safe account. It is a smart contract to enable multiple token transfers (Ether, ERC-20 or NFT) or multiple contract calls in a single transaction. Safe (previous known as Gnosis Safe) is a multi-sig smart contract wallet that enable companies or DAOs to safely enable multi party signature transaction. That is, owners of a safe account can configure the account to require additional signature before a transaction will be executed. It is widely used by crypto projects to avoid single point of failure or rug-pull by a single person.

The mutli-send contract takes an array of Safe meta transactions and execute them accordingly. It has to be used in-conjunction with a Safe smart contract account.

Demo

Below I am going to demo how to use this batch transfer Ethers and ERC-20 token with Safe account. My demo is based on Goerli testnet.

To start with, you will need to install the necessary Safe Dependency:

$ yarn add -D @safe-global/safe-core-sdk-types @safe-global/protocol-kit @safe-global/api-kit

With the dependencies installed, you can now deploy your Safe Account

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);

const ethAdapter = new EthersAdapter({
   // @ts-ignore
      ethers,
   // @ts-ignore
      signerOrProvider: wallet
});

const safeFactory = await SafeFactory.create({ ethAdapter })

// This Safe account config will not require any confirmation, you can use your own configuration by referring to Safe developer docs.
const safeAccountConfig: SafeAccountConfig = {
   owners: [wallet.address],
   threshold: 1,
};
const safeSdk = await safeFactory.deploySafe({ safeAccountConfig })
await safeSdk.isSafeDeployed();
console.log(await safeSdk.getAddress());

Before creating a transaction, you will have to send some Ethers to the Safe account. It will be used for funding the transaction as well as sending to the recipient. The Ethers or tokens is indeed sending from the Safe account instead of your own EOA account.

After fuelling up the Safe account, you can start transferring Ethers or the ERC-20 token.

To batch transfer Ethers, you will need this:

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);
const ownerAddress = wallet.address;

const safeService = new SafeApiKit({ chainId: 5n })

const ethAdapter = new EthersAdapter({
    // @ts-ignore
    ethers,
    // @ts-ignore
    signerOrProvider: wallet
});

const safeAddress = process.env.SAFE_ADDRESS || "";
const safeSdk = await Safe.create({ 
    ethAdapter,
    safeAddress,
});

const recipientHDWallet = HDNodeWallet.createRandom();

let transactions: MetaTransactionData[] = [];
const multiSendSize = parseInt(process.env.MULTI_SEND_SIZE || "10");
for (let i = 0; i < multiSendSize; i++){
    transactions.push({
        to: recipientHDWallet.deriveChild(i).address,
        data: "0x",
        value: "1",
    })
}

const safeTransaction = await safeSdk.createTransaction({transactions, onlyCalls: true});
const safeTxHash = await safeSdk.getTransactionHash(safeTransaction)
const senderSignature = await safeSdk.signTransactionHash(safeTxHash)
await safeService.proposeTransaction({
    safeAddress,
    safeTransactionData: safeTransaction.data,
    safeTxHash,
    senderAddress: ownerAddress,
    senderSignature: senderSignature.data,
});

const executeTxResponse = await safeSdk.executeTransaction(safeTransaction)
const receipt = executeTxResponse.transactionResponse && (await executeTxResponse.transactionResponse.wait())
console.log(receipt);

I have created a random HD wallet which can generate the random recipient address for this demo. All Safe transactions are required to be executed through Safe transaction service. By default, it will go to the one hosted by Safe. However, you can also specify custom ones. In fact, Safe provide some guidelines on how to run your own service if you prefer to.

To batch transfer ERC-20 tokens, you will need this:

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);
const ownerAddress = wallet.address;

const safeService = new SafeApiKit({ chainId: 5n })

const ethAdapter = new EthersAdapter({
    // @ts-ignore
    ethers,
    // @ts-ignore
    signerOrProvider: wallet
});

const safeAddress = process.env.SAFE_ADDRESS || "";
const safeFactory = await SafeFactory.create({ ethAdapter })
const safeSdk = await Safe.create({ 
        ethAdapter,
        safeAddress,
});

const recipientHDWallet = HDNodeWallet.createRandom();

const erc20Address = process.env.TOKEN_ADDRESS || "";
const erc20Interface = new Interface(ERC20Mock);

let transactions: MetaTransactionData[] = [];
const multiSendSize = parseInt(process.env.MULTI_SEND_SIZE || "10");
for (let i = 0; i < multiSendSize; i++){
        const recipientAddress = recipientHDWallet.deriveChild(i).address;
        const callData = erc20Interface.encodeFunctionData("transfer", [recipientAddress, parseEther("1.0")])
        transactions.push({
            to: erc20Address,
            data: callData,
            value: "0",
        })
}

const safeTransaction = await safeSdk.createTransaction({transactions, onlyCalls: true});
const safeTxHash = await safeSdk.getTransactionHash(safeTransaction)
const senderSignature = await safeSdk.signTransactionHash(safeTxHash)
await safeService.proposeTransaction({
        safeAddress,
        safeTransactionData: safeTransaction.data,
        safeTxHash,
        senderAddress: ownerAddress,
        senderSignature: senderSignature.data,
});

const executeTxResponse = await safeSdk.executeTransaction(safeTransaction)
const receipt = executeTxResponse.transactionResponse && (await executeTxResponse.transactionResponse.wait())
console.log(receipt);

I have deployed my own test ERC-20 token to Goerli and transferred them to the Safe account beforehand. In order to create the ERC-20 transfer transaction, I have imported a ERC-20 token contract ABI and use the interface to encode the call data.

Results

ETH transfer via Safe wallet
Batch ETH transfer via Safe wallet multi-send contract
Batch ERC-20 transfer via Safe wallet multi-send contract

Verdict

Using Safe smart contract account to batch transfer ERC-20 tokens may look a little bit complicated but it can support different kind of tokens in each transaction in each batch. The drawback would be this method is tied to Safe eco-system even-though Safe offers the option to run your own transaction infrastructure.

C. Batch user operation in ERC-4337

In ERC-4337 account abstraction, it has introduced the capability to batch user operations. A user operation is a transaction in account abstraction terms. However, a user operation is not sent to the blockchain directly, it requires a bundler service to process these user operation before sending to the blockchain. Therefore, it is possible to batch these user operations and send onto the blockchain in one go.

There are various ERC-4337 bundler service provider including Alchemy, Stackup, Etherspot etc. And Stackup, Zerodevs, Biconomy provides a infrastructure to create the smart contract wallet. For more details, you can refer to https://www.erc4337.io/resources for ERC-4337 resources. These providers normally will charge base on the usage of their bundler.

One of the advantage of using ERC-4337 is that with the help of PayMaster (another feature of ERC-4337), you can pay gas in other supported token. For example, Stackup has a huge list of supported token to pay for the gas fee https://docs.stackup.sh/docs/supported-erc-20-tokens#ethereum-goerli, which means you don’t have to hold ETH (or other native token for the chain) in order to execute a transaction.

Demo

In this demo, I am going to create a smart contract wallet with ZeroDev and send Ethers and ERC-20 token transfer user operations on Goerli testnet.

To begin with, you will need to create a project with ZeroDev https://zerodev.app. All requests on testnet are free.

After that, you will have to install all required dependency:

$ yarn add -D @zerodev/sdk @alchemy/aa-core

You don’t have to deploy the smart contract wallet separately. It will be deployed together with the first user operation. Therefore, the first transaction is going to cost more gas.

In this example, in order to send Ether to recipient, I will have to send some Ether to the contract. The smart contract wallet address is deterministic thanks to the CREATE2 function. The address can be preview with the following code:

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);
    
const zeroDevProjectId = process.env.ZERODEV_PROJECT_ID || "";
const ecdsaProvider = await ECDSAProvider.init({
    // ZeroDev projectId
    projectId: zeroDevProjectId,
    // The signer
    // @ts-ignore
    owner: wallet,
});

const walletAddress = await ecdsaProvider.getAddress();
console.log(`AA Wallet address ${walletAddress}`); 

To send Ethers in batch, you will need this:

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);
    
const zeroDevProjectId = process.env.ZERODEV_PROJECT_ID || "";
const ecdsaProvider = await ECDSAProvider.init({
    // ZeroDev projectId
    projectId: zeroDevProjectId,
    // The signer
    // @ts-ignore
    owner: wallet,
});

const walletAddress = await ecdsaProvider.getAddress();
console.log(`AA Wallet address ${walletAddress}`); 

let userOperations: UserOperationCallData[] = [];
const recipientHDWallet = HDNodeWallet.createRandom();
const batchSize = parseInt(process.env.BATCH_SIZE || "10");
for (let i = 0; i < batchSize; i++){
    userOperations.push({
        // @ts-ignore
        target: recipientHDWallet.deriveChild(i).address,
        data: "0x",
        value: parseEther("0.0000001")
    })
}

const { hash } = await ecdsaProvider.sendUserOperation(userOperations);
// @ts-ignore
await ecdsaProvider.waitForUserOperationTransaction(hash);

The first transaction is going to cause around 200,000 gas in order to deploy the smart contract wallet. You will find that the address has become a contract after the deployment.

first transaction

You cannot see the bundled user operation transaction like what you normally do because the transaction is neither executed by you or the smart contract wallet. Instead, it’s executed by the bundler on the EntryPoint contract. You can trace it from “Internal Transactions” tab in the block explorer.

Transaction is not showing up
Transaction is not showing up in “Transaction” tab
Every transaction
actually transaction where the user operations are executed
This is the actually transaction where the user operations are executed

If you check the wallet ETH balance, you will find that gas fee is not deducted from the smart contract wallet at all. Every transaction is “sponsored” by the bundler. That’s why ERC-4337 service providers are taking a cut from the transaction when the transaction is executed in Mainnet.

To batch transfer ERC-20 tokens, you will need this:

const privatekey = process.env.WALLET_PRIVATE_KEY || "";
const jsonRpcProvider = new JsonRpcProvider("YOUR_RPC_PROVIDER");
const wallet = new Wallet(privatekey, jsonRpcProvider);
    
const zeroDevProjectId = process.env.ZERODEV_PROJECT_ID || "";
const ecdsaProvider = await ECDSAProvider.init({
    // ZeroDev projectId
    projectId: zeroDevProjectId,
    // The signer
    // @ts-ignore
    owner: wallet,
});

const walletAddress = await ecdsaProvider.getAddress();
console.log(`AA Wallet address ${walletAddress}`); 

let userOperations: UserOperationCallData[] = [];
const recipientHDWallet = HDNodeWallet.createRandom();

const erc20Address = process.env.TOKEN_ADDRESS || "";
const erc20Interface = new Interface(ERC20Mock);

const batchSize = parseInt(process.env.BATCH_SIZE || "10");
for (let i = 0; i < batchSize; i++){
    const recipientAddress = recipientHDWallet.deriveChild(i).address;
    const callData = erc20Interface.encodeFunctionData("transfer", [recipientAddress, parseEther("1.0")])
    userOperations.push({
        // @ts-ignore
        target: erc20Address,
        // @ts-ignore
        data: callData,
        value: 0n,
    })
}

const { hash } = await ecdsaProvider.sendUserOperation(userOperations);
// @ts-ignore
await ecdsaProvider.waitForUserOperationTransaction(hash);

Results

It is hard to compare the gas used by ERC-4337. Because when we can see from the block explorer, we can only see the gas used to execute the user operations. However, there are some gas required to validate user operation as well. Also bear in mind that ERC-4337 service provider will charge additional service cost. For example, with ZeroDev, each request will cost $0.01 and 5% of the gas fee sponsored for the transaction.

Anyway, let’s look into the gas usage.

Batch ETH transfer via ERC-4337 Account
Batch ETH transfer via ERC-4337 Account Abstraction User Operations batching

It is interesting to see that even with existing account, the gas saving is on-par with regular ETH transfer when we try to batch ETH transfer with ERC-4337 user operations. It is because the operations to validate and execute a transaction in ERC-4337 is costing more gas.

batch ERC-20 transfer via ERC-4337 Account
Batch ERC-20 transfer via ERC-4337 Account Abstraction User Operations batching

We can still achieve savings when we batch ERC-20 transfer though.

Verdict

Using ERC-4337 method to bundle ERC-20 transfers has the least saving among the three methods compared. When compared to using Safe multi-send contract, this method is closer to the open standard. You can easily switch to other ERC-4337 service provider and most of the code is still going to work. With ERC-4337 paymaster, you will not required to hold ETH to complete the batch transfer too. However, the cost of this method is high and vary across ERC-4337 service provider. If the alternate mem pool for ERC-4337 user operation bundle is integrated with ETH client one day, it might further reduce the cost.

Final word

Other than the 3 methods discussed above, there are various DApps and contract out there to achieve batch token transfer. But remember to DYOR before using them.