diff --git a/contracts/Batcher.sol b/contracts/Batcher.sol index 65ba691..d42e67d 100644 --- a/contracts/Batcher.sol +++ b/contracts/Batcher.sol @@ -1,6 +1,10 @@ pragma solidity 0.8.20; import '@openzeppelin/contracts/access/Ownable2Step.sol'; +interface IERC20 { + function transferFrom(address sender, address recipient, uint256 amount) external returns (bool); +} + // SPDX-License-Identifier: Apache-2.0 /** @@ -78,6 +82,28 @@ contract Batcher is Ownable2Step { require(totalSent == msg.value, 'Total sent out must equal total received'); } + /** + * @dev Batch transferFrom for an ERC20 token. + * @param token The address of the ERC20 token contract. + * @param recipients The array of recipient addresses. + * @param amounts The array of amounts to transfer to each recipient. + * Requirements: + * - `recipients` and `amounts` must have the same length. + * - The caller must have approved the contract to spend the tokens being transferred. + */ + function batchTransferFrom( + address token, + address[] calldata recipients, + uint256[] calldata amounts + ) external lockCall { + require(recipients.length != 0, 'Must send to at least one person'); + require(recipients.length == amounts.length, "Unequal recipients and values"); + for (uint8 i = 0; i < recipients.length; i++) { + bool success = IERC20(token).transferFrom(msg.sender, recipients[i], amounts[i]); + require(success, "transferFrom failed"); + } + } + /** * Recovery function for the contract owner to recover any ERC20 tokens or ETH that may get lost in the control of this contract. * @param to The recipient to send to diff --git a/test/batcher.js b/test/batcher.js index 6bf04b8..3f9b978 100644 --- a/test/batcher.js +++ b/test/batcher.js @@ -664,6 +664,149 @@ describe('Batcher', () => { }); }); + describe('Batch ERC20 Token Transfers', () => { + let tokenContract; + let totalSupply; + let tokenContractOwner; + + beforeEach(async () => { + tokenContractOwner = accounts[9]; + tokenContract = await FixedSupplyToken.new({ + from: tokenContractOwner + }); + totalSupply = await tokenContract.totalSupply(); + }); + + const checkBalance = async (address, expectedAmt) => { + const balance = await tokenContract.balanceOf(address); + assert.strictEqual( + balance.toString(), + expectedAmt.toString(), + `Token balance of ${address} was ${balance.toString()} when ${expectedAmt.toString()} was expected` + ); + }; + + it('Successfully transfers tokens to multiple recipients', async () => { + const sender = accounts[1]; + const recipients = [accounts[2], accounts[3], accounts[4]]; + const amounts = [100, 200, 300]; + + // Transfer tokens to sender + await tokenContract.transfer(sender, 1000, { + from: tokenContractOwner + }); + await checkBalance(sender, 1000); + + // Approve batcher to spend tokens + await tokenContract.approve(batcherInstance.address, 1000, { + from: sender + }); + + // Execute batch transfer + await batcherInstance.batchTransferFrom( + tokenContract.address, + recipients, + amounts, + { from: sender } + ); + + // Verify balances + await checkBalance(sender, 400); // 1000 - (100 + 200 + 300) + await checkBalance(recipients[0], 100); + await checkBalance(recipients[1], 200); + await checkBalance(recipients[2], 300); + }); + + it('Fails when sender has insufficient balance', async () => { + const sender = accounts[1]; + const recipients = [accounts[2], accounts[3]]; + const amounts = [500, 600]; + + // Transfer fewer tokens than needed to sender + await tokenContract.transfer(sender, 500, { from: tokenContractOwner }); + await tokenContract.approve(batcherInstance.address, 1100, { + from: sender + }); + + await checkBalance(sender, 500); + await checkBalance(recipients[0], 0); + await checkBalance(recipients[1], 0); + + await assertVMException( + batcherInstance.batchTransferFrom( + tokenContract.address, + recipients, + amounts, + { from: sender } + ), + 'transferFrom failed' + ); + + // balance does not change + await checkBalance(sender, 500); + await checkBalance(recipients[0], 0); + await checkBalance(recipients[1], 0); + }); + + it('Fails when sender has not approved enough tokens', async () => { + const sender = accounts[1]; + const recipients = [accounts[2], accounts[3]]; + const amounts = [500, 600]; + + await tokenContract.transfer(sender, 1100, { + from: tokenContractOwner + }); + await tokenContract.approve(batcherInstance.address, 500, { + from: sender + }); + + await checkBalance(sender, 1100); + await checkBalance(recipients[0], 0); + await checkBalance(recipients[1], 0); + + await assertVMException( + batcherInstance.batchTransferFrom( + tokenContract.address, + recipients, + amounts, + { from: sender } + ), + 'transferFrom failed' + ); + + // balance does not change + await checkBalance(sender, 1100); + await checkBalance(recipients[0], 0); + await checkBalance(recipients[1], 0); + }); + + it('Fails with empty recipients array', async () => { + const sender = accounts[1]; + await assertVMException( + batcherInstance.batchTransferFrom(tokenContract.address, [], [], { + from: sender + }), + 'Must send to at least one person' + ); + }); + + it('Fails with mismatched recipients and amounts arrays', async () => { + const sender = accounts[1]; + const recipients = [accounts[2], accounts[3]]; + const amounts = [100]; + + await assertVMException( + batcherInstance.batchTransferFrom( + tokenContract.address, + recipients, + amounts, + { from: sender } + ), + 'Unequal recipients and values' + ); + }); + }); + describe('Using recover for ERC20 Tokens', () => { let tokenContract; let totalSupply;