From c10c4ea0957b273df3b980db01d5e71a7f59e696 Mon Sep 17 00:00:00 2001 From: francisco-leal Date: Tue, 30 Sep 2025 09:29:37 +0100 Subject: [PATCH] Add erc20 transfer with different implementation --- .tool-versions | 1 + contracts/utils/MultiSendErc20.sol | 139 +++++++ hardhat.config.ts | 1 + test/contracts/utils/MultiSendErc20.ts | 512 +++++++++++++++++++++++++ test/shared/artifacts.ts | 2 + 5 files changed, 655 insertions(+) create mode 100644 contracts/utils/MultiSendErc20.sol create mode 100644 test/contracts/utils/MultiSendErc20.ts diff --git a/.tool-versions b/.tool-versions index c6743f9b..4d6a5199 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ nodejs 20.12.2 solidity 0.8.24 +yarn 1.22.19 diff --git a/contracts/utils/MultiSendErc20.sol b/contracts/utils/MultiSendErc20.sol new file mode 100644 index 00000000..9aa4a0ba --- /dev/null +++ b/contracts/utils/MultiSendErc20.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title MultiSendErc20 + * @notice A utility contract for sending ERC20 tokens to multiple recipients in a single transaction + */ +contract MultiSendErc20 { + using SafeERC20 for IERC20; + + /// @notice Maximum number of recipients allowed in a single batch + uint256 public constant ARRAY_LIMIT = 200; + + /// @dev Custom errors for gas-efficient reverts + error MismatchedArrays(); + error ArrayLengthExceedsLimit(); + + /// @notice Emitted when a batch of ERC20 transfers is completed + /// @param total The total amount of tokens sent + event Multisended(uint256 total); + + /** + * @notice Send ERC20 tokens to multiple recipients in a single transaction + * @param _token Address of the ERC20 token to send + * @param _recipients Array of recipient addresses + * @param _amounts Array of amounts to send to each recipient + * @dev The caller must approve this contract to pull the total amount before calling + * @dev Arrays must have matching lengths and not exceed ARRAY_LIMIT + */ + function multisendERC20( + address _token, + address[] calldata _recipients, + uint256[] calldata _amounts + ) external { + uint256 length = _recipients.length; + if (length != _amounts.length) revert MismatchedArrays(); + if (length > ARRAY_LIMIT) revert ArrayLengthExceedsLimit(); + + IERC20 token = IERC20(_token); + + uint256 total = 0; + for (uint256 i = 0; i < length; ) { + unchecked { + total += _amounts[i]; + ++i; + } + } + + // Pull total amount from caller to contract + token.safeTransferFrom(msg.sender, address(this), total); + + // Distribute tokens from contract to recipients + for (uint256 i = 0; i < length; ) { + token.safeTransfer(_recipients[i], _amounts[i]); + unchecked { + ++i; + } + } + + emit Multisended(total); + } + + /** + * @notice Send ERC20 tokens using transferFrom for each recipient + * @param _token Address of the ERC20 token to send + * @param _recipients Array of recipient addresses + * @param _amounts Array of amounts to send to each recipient + * @dev Caller must approve the contract to spend at least the sum of amounts + * @dev Uses safeTransferFrom per recipient instead of pulling once and transferring + */ + function multisendERC20From( + address _token, + address[] calldata _recipients, + uint256[] calldata _amounts + ) external { + uint256 length = _recipients.length; + if (length != _amounts.length) revert MismatchedArrays(); + if (length > ARRAY_LIMIT) revert ArrayLengthExceedsLimit(); + + IERC20 token = IERC20(_token); + + uint256 total = 0; + for (uint256 i = 0; i < length; ) { + token.safeTransferFrom(msg.sender, _recipients[i], _amounts[i]); + unchecked { + total += _amounts[i]; + ++i; + } + } + + emit Multisended(total); + } + + /** + * @notice Send ERC20 tokens without SafeERC20 helpers (raw transfer/transferFrom) + * @param _token Address of the ERC20 token to send + * @param _recipients Array of recipient addresses + * @param _amounts Array of amounts to send to each recipient + * @dev This function uses raw ERC20 calls and may not work with non-standard tokens + * @dev Caller must approve the contract to spend at least the sum of amounts + */ + function multisendERC20Unsafe( + address _token, + address[] calldata _recipients, + uint256[] calldata _amounts + ) external { + uint256 length = _recipients.length; + if (length != _amounts.length) revert MismatchedArrays(); + if (length > ARRAY_LIMIT) revert ArrayLengthExceedsLimit(); + + IERC20 token = IERC20(_token); + + uint256 total = 0; + for (uint256 i = 0; i < length; ) { + unchecked { + total += _amounts[i]; + ++i; + } + } + + // Pull total amount from caller to contract using raw transferFrom + require(token.transferFrom(msg.sender, address(this), total)); + + // Distribute tokens from contract to recipients using raw transfer + for (uint256 i = 0; i < length; ) { + require(token.transfer(_recipients[i], _amounts[i])); + unchecked { + ++i; + } + } + + emit Multisended(total); + } +} + + diff --git a/hardhat.config.ts b/hardhat.config.ts index d7f4f555..3a5204c4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -5,6 +5,7 @@ import "@nomiclabs/hardhat-ethers"; import "@nomicfoundation/hardhat-viem"; import "@nomicfoundation/hardhat-verify"; import "@nomiclabs/hardhat-waffle"; +import "@nomicfoundation/hardhat-chai-matchers"; import "hardhat-gas-reporter"; import dotenv from "dotenv"; diff --git a/test/contracts/utils/MultiSendErc20.ts b/test/contracts/utils/MultiSendErc20.ts new file mode 100644 index 00000000..dac5473f --- /dev/null +++ b/test/contracts/utils/MultiSendErc20.ts @@ -0,0 +1,512 @@ +import chai from "chai"; +import { ethers, waffle } from "hardhat"; +import { solidity } from "ethereum-waffle"; +import { BigNumber } from "ethers"; + +import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers"; + +import { MultiSendErc20, ERC20Mock as ERC20MockType } from "../../../typechain-types"; +import { Artifacts } from "../../shared"; + +import { findEvent } from "../../shared/utils"; + +chai.use(solidity); + +const { expect } = chai; +const { deployContract } = waffle; + +describe("MultiSendErc20", () => { + let deployer: SignerWithAddress; + let sender: SignerWithAddress; + let recipient1: SignerWithAddress; + let recipient2: SignerWithAddress; + let recipient3: SignerWithAddress; + + let multiSendErc20: MultiSendErc20; + let token: ERC20MockType; + + beforeEach(async () => { + [deployer, sender, recipient1, recipient2, recipient3] = await ethers.getSigners(); + }); + + async function deployMultiSendErc20() { + return deployContract(deployer, Artifacts.MultiSendErc20, []); + } + + async function deployToken() { + return deployContract(deployer, Artifacts.ERC20Mock, ["MockToken", "MTK"]); + } + + describe("Contract deployment", () => { + beforeEach(async () => { + multiSendErc20 = (await deployMultiSendErc20()) as MultiSendErc20; + }); + + it("should deploy with correct constants", async () => { + expect(await multiSendErc20.ARRAY_LIMIT()).to.eq(200); + }); + }); + + describe("multisendERC20 function", () => { + beforeEach(async () => { + multiSendErc20 = (await deployMultiSendErc20()) as MultiSendErc20; + token = (await deployToken()) as unknown as ERC20MockType; + }); + + async function fundSenderAndApprove(totalAmount: BigNumber) { + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + } + + describe("Successful transfers", () => { + it("should send tokens to single recipient", async () => { + const recipients = [recipient1.address]; + const amounts = [ethers.utils.parseUnits("100", 18)]; + const totalAmount = amounts[0]; + + const initialBalance = await token.balanceOf(recipient1.address); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts); + + const finalBalance = await token.balanceOf(recipient1.address); + expect(finalBalance.sub(initialBalance)).to.eq(totalAmount); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should send tokens to multiple recipients with different amounts", async () => { + const recipients = [recipient1.address, recipient2.address, recipient3.address]; + const amounts = [ + ethers.utils.parseUnits("10", 18), + ethers.utils.parseUnits("20", 18), + ethers.utils.parseUnits("5", 18), + ]; + const totalAmount = amounts[0].add(amounts[1]).add(amounts[2]); + + const initialBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts); + + const finalBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + expect(finalBalances[0].sub(initialBalances[0])).to.eq(amounts[0]); + expect(finalBalances[1].sub(initialBalances[1])).to.eq(amounts[1]); + expect(finalBalances[2].sub(initialBalances[2])).to.eq(amounts[2]); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should handle maximum array length", async () => { + const arrayLength = 200; // ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(arrayLength); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + }); + + describe("Validation failures", () => { + it("should revert with mismatched arrays", async () => { + const recipients = [recipient1.address, recipient2.address]; + const amounts = [ethers.utils.parseUnits("1", 18)]; + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("1", 18)); + + await expect(multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts)).to.be.reverted; // Custom error: MismatchedArrays + }); + + it("should revert when array length exceeds limit", async () => { + const arrayLength = 201; // Exceeds ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(ethers.utils.parseUnits("1", 18)); + } + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("201", 18)); + + await expect(multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts)).to.be.reverted; // Custom error: ArrayLengthExceedsLimit + }); + }); + }); + + describe("Gas optimization tests", () => { + beforeEach(async () => { + multiSendErc20 = (await deployMultiSendErc20()) as MultiSendErc20; + token = (await deployToken()) as unknown as ERC20MockType; + }); + + it("should have reasonable gas consumption for different array sizes", async () => { + const testSizes = [1, 5, 10, 50, 100]; + + for (const size of testSizes) { + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < size; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(size); + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + + const tx = await multiSendErc20.connect(sender).multisendERC20(token.address, recipients, amounts); + const receipt = await tx.wait(); + console.log(`Gas used for ${size} recipients: ${receipt.gasUsed.toString()}`); + expect(receipt.status).to.eq(1); + } + }); + }); + + describe("multisendERC20From function", () => { + beforeEach(async () => { + multiSendErc20 = (await deployMultiSendErc20()) as MultiSendErc20; + token = (await deployToken()) as unknown as ERC20MockType; + }); + + async function fundSenderAndApprove(totalAmount: BigNumber) { + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + } + + describe("Successful transfers", () => { + it("should send tokens to single recipient using transferFrom per recipient", async () => { + const recipients = [recipient1.address]; + const amounts = [ethers.utils.parseUnits("100", 18)]; + const totalAmount = amounts[0]; + + const initialBalance = await token.balanceOf(recipient1.address); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts); + + const finalBalance = await token.balanceOf(recipient1.address); + expect(finalBalance.sub(initialBalance)).to.eq(totalAmount); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should send tokens to multiple recipients with different amounts using transferFrom per recipient", async () => { + const recipients = [recipient1.address, recipient2.address, recipient3.address]; + const amounts = [ + ethers.utils.parseUnits("10", 18), + ethers.utils.parseUnits("20", 18), + ethers.utils.parseUnits("5", 18), + ]; + const totalAmount = amounts[0].add(amounts[1]).add(amounts[2]); + + const initialBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts); + + const finalBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + expect(finalBalances[0].sub(initialBalances[0])).to.eq(amounts[0]); + expect(finalBalances[1].sub(initialBalances[1])).to.eq(amounts[1]); + expect(finalBalances[2].sub(initialBalances[2])).to.eq(amounts[2]); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should handle maximum array length", async () => { + const arrayLength = 200; // ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(arrayLength); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + }); + + describe("Validation failures", () => { + it("should revert with mismatched arrays", async () => { + const recipients = [recipient1.address, recipient2.address]; + const amounts = [ethers.utils.parseUnits("1", 18)]; + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("1", 18)); + + await expect( + multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts) + ).to.be.reverted; // Custom error: MismatchedArrays + }); + + it("should revert when array length exceeds limit", async () => { + const arrayLength = 201; // Exceeds ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(ethers.utils.parseUnits("1", 18)); + } + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("201", 18)); + + await expect( + multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts) + ).to.be.reverted; // Custom error: ArrayLengthExceedsLimit + }); + }); + + describe("Gas optimization tests (transferFrom per recipient)", () => { + beforeEach(async () => { + // already deployed in outer beforeEach + }); + + it("should log gas for different array sizes", async () => { + const testSizes = [1, 5, 10, 50, 100]; + + for (const size of testSizes) { + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < size; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(size); + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20From(token.address, recipients, amounts); + const receipt = await tx.wait(); + console.log( + `Gas used (transferFrom-per-recipient) for ${size} recipients: ${receipt.gasUsed.toString()}` + ); + expect(receipt.status).to.eq(1); + } + }); + }); + }); + + describe("multisendERC20Unsafe function", () => { + beforeEach(async () => { + multiSendErc20 = (await deployMultiSendErc20()) as MultiSendErc20; + token = (await deployToken()) as unknown as ERC20MockType; + }); + + async function fundSenderAndApprove(totalAmount: BigNumber) { + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + } + + describe("Successful transfers", () => { + it("should send tokens to single recipient using raw transfers", async () => { + const recipients = [recipient1.address]; + const amounts = [ethers.utils.parseUnits("100", 18)]; + const totalAmount = amounts[0]; + + const initialBalance = await token.balanceOf(recipient1.address); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts); + + const finalBalance = await token.balanceOf(recipient1.address); + expect(finalBalance.sub(initialBalance)).to.eq(totalAmount); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should send tokens to multiple recipients with different amounts using raw transfers", async () => { + const recipients = [recipient1.address, recipient2.address, recipient3.address]; + const amounts = [ + ethers.utils.parseUnits("10", 18), + ethers.utils.parseUnits("20", 18), + ethers.utils.parseUnits("5", 18), + ]; + const totalAmount = amounts[0].add(amounts[1]).add(amounts[2]); + + const initialBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts); + + const finalBalances = [ + await token.balanceOf(recipient1.address), + await token.balanceOf(recipient2.address), + await token.balanceOf(recipient3.address), + ]; + + expect(finalBalances[0].sub(initialBalances[0])).to.eq(amounts[0]); + expect(finalBalances[1].sub(initialBalances[1])).to.eq(amounts[1]); + expect(finalBalances[2].sub(initialBalances[2])).to.eq(amounts[2]); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + + it("should handle maximum array length", async () => { + const arrayLength = 200; // ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(arrayLength); + + await fundSenderAndApprove(totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts); + + const multisentEvent = await findEvent(tx, "Multisended"); + expect(multisentEvent?.args?.total).to.eq(totalAmount); + }); + }); + + describe("Validation failures", () => { + it("should revert with mismatched arrays", async () => { + const recipients = [recipient1.address, recipient2.address]; + const amounts = [ethers.utils.parseUnits("1", 18)]; + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("1", 18)); + + await expect( + multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts) + ).to.be.reverted; // Custom error: MismatchedArrays + }); + + it("should revert when array length exceeds limit", async () => { + const arrayLength = 201; // Exceeds ARRAY_LIMIT + const recipients: string[] = []; + const amounts: BigNumber[] = []; + + for (let i = 0; i < arrayLength; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(ethers.utils.parseUnits("1", 18)); + } + + await token.connect(deployer).transfer(sender.address, ethers.utils.parseUnits("201", 18)); + + await expect( + multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts) + ).to.be.reverted; // Custom error: ArrayLengthExceedsLimit + }); + }); + + describe("Gas optimization tests (unsafe)", () => { + beforeEach(async () => { + // already deployed in outer beforeEach + }); + + it("should log gas for different array sizes", async () => { + const testSizes = [1, 5, 10, 50, 100]; + + for (const size of testSizes) { + const recipients: string[] = []; + const amounts: BigNumber[] = []; + const amountPerRecipient = ethers.utils.parseUnits("1", 18); + + for (let i = 0; i < size; i++) { + recipients.push(ethers.Wallet.createRandom().address); + amounts.push(amountPerRecipient); + } + + const totalAmount = amountPerRecipient.mul(size); + await token.connect(deployer).transfer(sender.address, totalAmount); + await token.connect(sender).approve(multiSendErc20.address, totalAmount); + + const tx = await multiSendErc20 + .connect(sender) + .multisendERC20Unsafe(token.address, recipients, amounts); + const receipt = await tx.wait(); + console.log( + `Gas used (unsafe/raw) for ${size} recipients: ${receipt.gasUsed.toString()}` + ); + expect(receipt.status).to.eq(1); + } + }); + }); + }); +}); diff --git a/test/shared/artifacts.ts b/test/shared/artifacts.ts index 66ee9bf9..5dc98014 100644 --- a/test/shared/artifacts.ts +++ b/test/shared/artifacts.ts @@ -14,6 +14,7 @@ import TalentVault from "../../artifacts/contracts/talent/TalentVault.sol/Talent import TalentVaultV2 from "../../artifacts/contracts/talent/TalentVaultV2.sol/TalentVaultV2.json"; import BaseAPY from "../../artifacts/contracts/talent/vault-options/BaseAPY.sol/BaseAPY.json"; import MultiSendETH from "../../artifacts/contracts/utils/MultiSendETH.sol/MultiSendETH.json"; +import MultiSendErc20 from "../../artifacts/contracts/utils/MultiSendErc20.sol/MultiSendErc20.json"; export { PassportRegistry, @@ -32,4 +33,5 @@ export { TalentVaultV2, BaseAPY, MultiSendETH, + MultiSendErc20, };