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.
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 disperseToken
, disperseTokenSimple
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]));
}
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.
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.
I am not surprised that it’s actually spending more gas to transfer Ether to new account according to the side note above.
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:
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
andtransfer
interface and return the transfer result as a boolean. Therefore, USDT does not work with Disperse smart contract.
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.
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.
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.
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.
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.
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.
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.
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);
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.
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.
We can still achieve savings when we batch ERC-20 transfer though.
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.
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.