Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions contracts/utils/MultiSendETH.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/**
* @title MultiSendETH
* @notice A utility contract for sending ETH to multiple recipients in a single transaction
*/
contract MultiSendETH {
/// @notice Maximum number of recipients allowed in a single batch
uint8 public constant ARRAY_LIMIT = 200;

/// @notice Emitted when a batch of ETH transfers is completed
/// @param total The total amount of ETH sent
event Multisended(uint256 total);

/**
* @notice Send ETH to multiple recipients in a single transaction
* @param _recipients Array of recipient addresses
* @param _amounts Array of amounts to send to each recipient (in wei)
* @dev The sum of all amounts must equal msg.value
* @dev Arrays must have matching lengths and not exceed ARRAY_LIMIT
*/
function multisendETH(address[] calldata _recipients, uint256[] calldata _amounts) external payable {
require(_recipients.length == _amounts.length, "Mismatched arrays");
require(_recipients.length <= ARRAY_LIMIT, "Array length exceeds limit");

uint256 total = 0;

// Execute transfers
for (uint8 i = 0; i < _recipients.length; i++) {
total += _amounts[i];
(bool success, ) = payable(_recipients[i]).call{value: _amounts[i]}("");
require(success, "Transfer failed");
}

require(total == msg.value, "Incorrect ETH amount sent");

emit Multisended(total);
}
}
29 changes: 21 additions & 8 deletions hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import "hardhat-storage-layout";
import "@typechain/hardhat";
import "@nomiclabs/hardhat-ethers";
import "@nomicfoundation/hardhat-viem";
import "@nomiclabs/hardhat-etherscan";
import "@nomicfoundation/hardhat-verify";
import "@nomiclabs/hardhat-waffle";
import "hardhat-gas-reporter";
import "@nomiclabs/hardhat-etherscan";
import dotenv from "dotenv";

dotenv.config();
Expand All @@ -30,7 +29,7 @@ const config: HardhatUserConfig = {
settings: {
optimizer: {
enabled: true,
runs: 4294967295,
runs: 200,
},
outputSelection: {
"*": {
Expand All @@ -52,17 +51,20 @@ const config: HardhatUserConfig = {
chainId: 8453,
gasMultiplier: 1.5,
},
celo: {
url: process.env.CELO_RPC_URL || "https://forno.celo.org",
accounts: deployer,
chainId: 42220,
gasMultiplier: 1.5,
},
},
gasReporter: {
currency: "ETH",
showMethodSig: true,
},
etherscan: {
// Your API keys for Etherscan
apiKey: {
base: process.env.BASE_API_KEY || "",
baseSepolia: process.env.BASE_API_KEY || "",
},
// Your API keys for Etherscan - using v2 format
apiKey: process.env.BASE_API_KEY || "",
// Custom chains that are not supported by default
customChains: [
{
Expand All @@ -81,8 +83,19 @@ const config: HardhatUserConfig = {
browserURL: "https://basescan.org",
},
},
{
network: "celo",
chainId: 42220,
urls: {
apiURL: "https://api.celoscan.io/api",
browserURL: "https://celoscan.io",
},
},
],
},
sourcify: {
enabled: true,
},
};

export default config;
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
"@typechain/hardhat": "^6.1.6",
"dayjs": "^1.10.7",
"ethers": "^5.7.0",
"hardhat": "^2.22.4",
"hardhat-gas-reporter": "^1.0.9",
"openzeppelin-solidity": "^4.2.0",
"permissionless": "^0.1.4",
Expand All @@ -36,6 +35,7 @@
"devDependencies": {
"@nomicfoundation/hardhat-chai-matchers": "^1.0.6",
"@nomicfoundation/hardhat-network-helpers": "^1.0.10",
"@nomicfoundation/hardhat-verify": "^2.0.14",
"@nomiclabs/hardhat-etherscan": "^3.1.7",
"@types/chai": "^4.3.4",
"@types/chai-as-promised": "^7.1.4",
Expand All @@ -49,6 +49,7 @@
"dotenv": "^16.0.3",
"eslint": "^8.19.0",
"ethereum-waffle": "3.4.0",
"hardhat": "^2.24.3",
"hardhat-storage-layout": "^0.1.7",
"prettier": "^2.4.1",
"prettier-plugin-solidity": "^1.0.0-beta.18",
Expand Down
59 changes: 59 additions & 0 deletions scripts/utils/deployMultiSendETH.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ethers, network } from "hardhat";
import hre from "hardhat";

async function main() {
console.log(`Deploying MultiSendETH at ${network.name}`);

const [admin] = await ethers.getSigners();

console.log(`Admin will be ${admin.address}`);
console.log("Account balance:", (await admin.getBalance()).toString());

// Deploy the MultiSendETH contract
const MultiSendETH = await ethers.getContractFactory("MultiSendETH");
const multiSendETH = await MultiSendETH.deploy();

await multiSendETH.deployed();

console.log(`MultiSendETH Address: ${multiSendETH.address}`);
console.log(`Transaction hash: ${multiSendETH.deployTransaction.hash}`);

// Verify deployment
const arrayLimit = await multiSendETH.ARRAY_LIMIT();
console.log(`Array limit: ${arrayLimit.toString()}`);

// Verify contract on Sourcify (only for non-local networks)
if (network.name !== "hardhat" && network.name !== "localhost") {
console.log("\nWaiting for block confirmations...");
await multiSendETH.deployTransaction.wait(5);

console.log("Verifying contract on Sourcify...");
try {
await hre.run("verify:sourcify", {
address: multiSendETH.address,
});
console.log("Contract verified successfully on Sourcify!");
} catch (error) {
console.log("Sourcify verification failed:", error instanceof Error ? error.message : String(error));
}

console.log("Verifying contract on Etherscan...");
try {
await hre.run("verify:verify", {
address: multiSendETH.address,
});
console.log("Contract verified successfully on Etherscan!");
} catch (error) {
console.log("Etherscan verification failed:", error instanceof Error ? error.message : String(error));
}
}

console.log("Done");
}

main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
194 changes: 194 additions & 0 deletions test/contracts/utils/MultiSendETH.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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 { MultiSendETH } from "../../../typechain-types";
import { Artifacts } from "../../shared";

import { findEvent } from "../../shared/utils";

chai.use(solidity);

const { expect } = chai;
const { deployContract } = waffle;

describe("MultiSendETH", () => {
let deployer: SignerWithAddress;
let sender: SignerWithAddress;
let recipient1: SignerWithAddress;
let recipient2: SignerWithAddress;
let recipient3: SignerWithAddress;

let multiSendETH: MultiSendETH;

beforeEach(async () => {
[deployer, sender, recipient1, recipient2, recipient3] = await ethers.getSigners();
});

async function deployMultiSendETH() {
return deployContract(deployer, Artifacts.MultiSendETH, []);
}

describe("Contract deployment", () => {
beforeEach(async () => {
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
});

it("should deploy with correct constants", async () => {
expect(await multiSendETH.ARRAY_LIMIT()).to.eq(200);
});
});

describe("multisendETH function", () => {
beforeEach(async () => {
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
});

describe("Successful transfers", () => {
it("should send ETH to single recipient", async () => {
const recipients = [recipient1.address];
const amounts = [ethers.utils.parseEther("1")];
const totalAmount = ethers.utils.parseEther("1");

const initialBalance = await recipient1.getBalance();

const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });

const finalBalance = await recipient1.getBalance();
expect(finalBalance.sub(initialBalance)).to.eq(ethers.utils.parseEther("1"));

// Check events
const multisentEvent = await findEvent(tx, "Multisended");
expect(multisentEvent?.args?.total).to.eq(totalAmount);
});

it("should send ETH to multiple recipients with different amounts", async () => {
const recipients = [recipient1.address, recipient2.address, recipient3.address];
const amounts = [ethers.utils.parseEther("1"), ethers.utils.parseEther("2"), ethers.utils.parseEther("0.5")];
const totalAmount = ethers.utils.parseEther("3.5");

const initialBalances = [
await recipient1.getBalance(),
await recipient2.getBalance(),
await recipient3.getBalance(),
];

const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });

const finalBalances = [
await recipient1.getBalance(),
await recipient2.getBalance(),
await recipient3.getBalance(),
];

expect(finalBalances[0].sub(initialBalances[0])).to.eq(ethers.utils.parseEther("1"));
expect(finalBalances[1].sub(initialBalances[1])).to.eq(ethers.utils.parseEther("2"));
expect(finalBalances[2].sub(initialBalances[2])).to.eq(ethers.utils.parseEther("0.5"));

// Check events
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.parseEther("0.01");

// Create arrays with max length
for (let i = 0; i < arrayLength; i++) {
recipients.push(ethers.Wallet.createRandom().address);
amounts.push(amountPerRecipient);
}

const totalAmount = amountPerRecipient.mul(arrayLength);

const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });

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.parseEther("1")]; // Only one amount for two recipients

await expect(
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: ethers.utils.parseEther("1") })
).to.be.revertedWith("Mismatched arrays");
});

it("should revert when array length exceeds limit", async () => {
const arrayLength = 201; // Exceeds ARRAY_LIMIT
const recipients: string[] = [];
const amounts = [];

for (let i = 0; i < arrayLength; i++) {
recipients.push(ethers.Wallet.createRandom().address);
amounts.push(ethers.utils.parseEther("0.01"));
}

await expect(
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: ethers.utils.parseEther("2.01") })
).to.be.revertedWith("Array length exceeds limit");
});

it("should revert when total doesn't match msg.value (too little sent)", async () => {
const recipients = [recipient1.address, recipient2.address];
const amounts = [ethers.utils.parseEther("1"), ethers.utils.parseEther("1")];
const insufficientValue = ethers.utils.parseEther("1.5"); // Less than total (2 ETH)

await expect(
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: insufficientValue })
).to.be.revertedWith("Transfer failed");
});

it("should revert when total doesn't match msg.value (too much sent)", async () => {
const recipients = [recipient1.address];
const amounts = [ethers.utils.parseEther("1")];
const excessiveValue = ethers.utils.parseEther("2"); // More than total (1 ETH)

await expect(
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: excessiveValue })
).to.be.revertedWith("Incorrect ETH amount sent");
});
});
});

describe("Gas optimization tests", () => {
beforeEach(async () => {
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
});

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 = [];
const amountPerRecipient = ethers.utils.parseEther("0.01");

for (let i = 0; i < size; i++) {
recipients.push(ethers.Wallet.createRandom().address);
amounts.push(amountPerRecipient);
}

const totalAmount = amountPerRecipient.mul(size);

const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });

const receipt = await tx.wait();
console.log(`Gas used for ${size} recipients: ${receipt.gasUsed.toString()}`);

// Ensure transaction succeeded
expect(receipt.status).to.eq(1);
}
});
});
});
Loading
Loading