Skip to content

Commit 275e08a

Browse files
Implements MultiSendETH smart contract, testing and deployment.
1 parent 119ee36 commit 275e08a

File tree

7 files changed

+1920
-1448
lines changed

7 files changed

+1920
-1448
lines changed

contracts/utils/MultiSendETH.sol

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.24;
3+
4+
/**
5+
* @title MultiSendETH
6+
* @notice A utility contract for sending ETH to multiple recipients in a single transaction
7+
*/
8+
contract MultiSendETH {
9+
/// @notice Maximum number of recipients allowed in a single batch
10+
uint8 public constant ARRAY_LIMIT = 200;
11+
12+
/// @notice Emitted when a batch of ETH transfers is completed
13+
/// @param total The total amount of ETH sent
14+
event Multisended(uint256 total);
15+
16+
/**
17+
* @notice Send ETH to multiple recipients in a single transaction
18+
* @param _recipients Array of recipient addresses
19+
* @param _amounts Array of amounts to send to each recipient (in wei)
20+
* @dev The sum of all amounts must equal msg.value
21+
* @dev Arrays must have matching lengths and not exceed ARRAY_LIMIT
22+
*/
23+
function multisendETH(address[] calldata _recipients, uint256[] calldata _amounts) external payable {
24+
require(_recipients.length == _amounts.length, "Mismatched arrays");
25+
require(_recipients.length <= ARRAY_LIMIT, "Array length exceeds limit");
26+
27+
uint256 total = 0;
28+
29+
// Execute transfers
30+
for (uint8 i = 0; i < _recipients.length; i++) {
31+
total += _amounts[i];
32+
(bool success, ) = payable(_recipients[i]).call{value: _amounts[i]}("");
33+
require(success, "Transfer failed");
34+
}
35+
36+
require(total == msg.value, "Incorrect ETH amount sent");
37+
38+
emit Multisended(total);
39+
}
40+
}

hardhat.config.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import "hardhat-storage-layout";
33
import "@typechain/hardhat";
44
import "@nomiclabs/hardhat-ethers";
55
import "@nomicfoundation/hardhat-viem";
6-
import "@nomiclabs/hardhat-etherscan";
6+
import "@nomicfoundation/hardhat-verify";
77
import "@nomiclabs/hardhat-waffle";
88
import "hardhat-gas-reporter";
9-
import "@nomiclabs/hardhat-etherscan";
109
import dotenv from "dotenv";
1110

1211
dotenv.config();
@@ -58,11 +57,8 @@ const config: HardhatUserConfig = {
5857
showMethodSig: true,
5958
},
6059
etherscan: {
61-
// Your API keys for Etherscan
62-
apiKey: {
63-
base: process.env.BASE_API_KEY || "",
64-
baseSepolia: process.env.BASE_API_KEY || "",
65-
},
60+
// Your API keys for Etherscan - using v2 format
61+
apiKey: process.env.BASE_API_KEY || "",
6662
// Custom chains that are not supported by default
6763
customChains: [
6864
{
@@ -83,6 +79,9 @@ const config: HardhatUserConfig = {
8379
},
8480
],
8581
},
82+
sourcify: {
83+
enabled: true,
84+
},
8685
};
8786

8887
export default config;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
"@typechain/hardhat": "^6.1.6",
2222
"dayjs": "^1.10.7",
2323
"ethers": "^5.7.0",
24-
"hardhat": "^2.22.4",
2524
"hardhat-gas-reporter": "^1.0.9",
2625
"openzeppelin-solidity": "^4.2.0",
2726
"permissionless": "^0.1.4",
@@ -36,6 +35,7 @@
3635
"devDependencies": {
3736
"@nomicfoundation/hardhat-chai-matchers": "^1.0.6",
3837
"@nomicfoundation/hardhat-network-helpers": "^1.0.10",
38+
"@nomicfoundation/hardhat-verify": "^2.0.14",
3939
"@nomiclabs/hardhat-etherscan": "^3.1.7",
4040
"@types/chai": "^4.3.4",
4141
"@types/chai-as-promised": "^7.1.4",
@@ -49,6 +49,7 @@
4949
"dotenv": "^16.0.3",
5050
"eslint": "^8.19.0",
5151
"ethereum-waffle": "3.4.0",
52+
"hardhat": "^2.24.3",
5253
"hardhat-storage-layout": "^0.1.7",
5354
"prettier": "^2.4.1",
5455
"prettier-plugin-solidity": "^1.0.0-beta.18",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { ethers, network } from "hardhat";
2+
import hre from "hardhat";
3+
4+
async function main() {
5+
console.log(`Deploying MultiSendETH at ${network.name}`);
6+
7+
const [admin] = await ethers.getSigners();
8+
9+
console.log(`Admin will be ${admin.address}`);
10+
console.log("Account balance:", (await admin.getBalance()).toString());
11+
12+
// Deploy the MultiSendETH contract
13+
const MultiSendETH = await ethers.getContractFactory("MultiSendETH");
14+
const multiSendETH = await MultiSendETH.deploy();
15+
16+
await multiSendETH.deployed();
17+
18+
console.log(`MultiSendETH Address: ${multiSendETH.address}`);
19+
console.log(`Transaction hash: ${multiSendETH.deployTransaction.hash}`);
20+
21+
// Verify deployment
22+
const arrayLimit = await multiSendETH.ARRAY_LIMIT();
23+
console.log(`Array limit: ${arrayLimit.toString()}`);
24+
25+
// Verify contract on Sourcify (only for non-local networks)
26+
if (network.name !== "hardhat" && network.name !== "localhost") {
27+
console.log("\nWaiting for block confirmations...");
28+
await multiSendETH.deployTransaction.wait(5);
29+
30+
console.log("Verifying contract on Sourcify...");
31+
try {
32+
await hre.run("verify:sourcify", {
33+
address: multiSendETH.address,
34+
});
35+
console.log("Contract verified successfully on Sourcify!");
36+
} catch (error) {
37+
console.log("Sourcify verification failed:", error instanceof Error ? error.message : String(error));
38+
}
39+
}
40+
41+
console.log("Done");
42+
}
43+
44+
main()
45+
.then(() => process.exit(0))
46+
.catch((error) => {
47+
console.error(error);
48+
process.exit(1);
49+
});
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import chai from "chai";
2+
import { ethers, waffle } from "hardhat";
3+
import { solidity } from "ethereum-waffle";
4+
import { BigNumber } from "ethers";
5+
6+
import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
7+
8+
import { MultiSendETH } from "../../../typechain-types";
9+
import { Artifacts } from "../../shared";
10+
11+
import { findEvent } from "../../shared/utils";
12+
13+
chai.use(solidity);
14+
15+
const { expect } = chai;
16+
const { deployContract } = waffle;
17+
18+
describe("MultiSendETH", () => {
19+
let deployer: SignerWithAddress;
20+
let sender: SignerWithAddress;
21+
let recipient1: SignerWithAddress;
22+
let recipient2: SignerWithAddress;
23+
let recipient3: SignerWithAddress;
24+
25+
let multiSendETH: MultiSendETH;
26+
27+
beforeEach(async () => {
28+
[deployer, sender, recipient1, recipient2, recipient3] = await ethers.getSigners();
29+
});
30+
31+
async function deployMultiSendETH() {
32+
return deployContract(deployer, Artifacts.MultiSendETH, []);
33+
}
34+
35+
describe("Contract deployment", () => {
36+
beforeEach(async () => {
37+
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
38+
});
39+
40+
it("should deploy with correct constants", async () => {
41+
expect(await multiSendETH.ARRAY_LIMIT()).to.eq(200);
42+
});
43+
});
44+
45+
describe("multisendETH function", () => {
46+
beforeEach(async () => {
47+
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
48+
});
49+
50+
describe("Successful transfers", () => {
51+
it("should send ETH to single recipient", async () => {
52+
const recipients = [recipient1.address];
53+
const amounts = [ethers.utils.parseEther("1")];
54+
const totalAmount = ethers.utils.parseEther("1");
55+
56+
const initialBalance = await recipient1.getBalance();
57+
58+
const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });
59+
60+
const finalBalance = await recipient1.getBalance();
61+
expect(finalBalance.sub(initialBalance)).to.eq(ethers.utils.parseEther("1"));
62+
63+
// Check events
64+
const multisentEvent = await findEvent(tx, "Multisended");
65+
expect(multisentEvent?.args?.total).to.eq(totalAmount);
66+
});
67+
68+
it("should send ETH to multiple recipients with different amounts", async () => {
69+
const recipients = [recipient1.address, recipient2.address, recipient3.address];
70+
const amounts = [ethers.utils.parseEther("1"), ethers.utils.parseEther("2"), ethers.utils.parseEther("0.5")];
71+
const totalAmount = ethers.utils.parseEther("3.5");
72+
73+
const initialBalances = [
74+
await recipient1.getBalance(),
75+
await recipient2.getBalance(),
76+
await recipient3.getBalance(),
77+
];
78+
79+
const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });
80+
81+
const finalBalances = [
82+
await recipient1.getBalance(),
83+
await recipient2.getBalance(),
84+
await recipient3.getBalance(),
85+
];
86+
87+
expect(finalBalances[0].sub(initialBalances[0])).to.eq(ethers.utils.parseEther("1"));
88+
expect(finalBalances[1].sub(initialBalances[1])).to.eq(ethers.utils.parseEther("2"));
89+
expect(finalBalances[2].sub(initialBalances[2])).to.eq(ethers.utils.parseEther("0.5"));
90+
91+
// Check events
92+
const multisentEvent = await findEvent(tx, "Multisended");
93+
expect(multisentEvent?.args?.total).to.eq(totalAmount);
94+
});
95+
96+
it("should handle maximum array length", async () => {
97+
const arrayLength = 200; // ARRAY_LIMIT
98+
const recipients: string[] = [];
99+
const amounts: BigNumber[] = [];
100+
const amountPerRecipient = ethers.utils.parseEther("0.01");
101+
102+
// Create arrays with max length
103+
for (let i = 0; i < arrayLength; i++) {
104+
recipients.push(ethers.Wallet.createRandom().address);
105+
amounts.push(amountPerRecipient);
106+
}
107+
108+
const totalAmount = amountPerRecipient.mul(arrayLength);
109+
110+
const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });
111+
112+
const multisentEvent = await findEvent(tx, "Multisended");
113+
expect(multisentEvent?.args?.total).to.eq(totalAmount);
114+
});
115+
});
116+
117+
describe("Validation failures", () => {
118+
it("should revert with mismatched arrays", async () => {
119+
const recipients = [recipient1.address, recipient2.address];
120+
const amounts = [ethers.utils.parseEther("1")]; // Only one amount for two recipients
121+
122+
await expect(
123+
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: ethers.utils.parseEther("1") })
124+
).to.be.revertedWith("Mismatched arrays");
125+
});
126+
127+
it("should revert when array length exceeds limit", async () => {
128+
const arrayLength = 201; // Exceeds ARRAY_LIMIT
129+
const recipients: string[] = [];
130+
const amounts = [];
131+
132+
for (let i = 0; i < arrayLength; i++) {
133+
recipients.push(ethers.Wallet.createRandom().address);
134+
amounts.push(ethers.utils.parseEther("0.01"));
135+
}
136+
137+
await expect(
138+
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: ethers.utils.parseEther("2.01") })
139+
).to.be.revertedWith("Array length exceeds limit");
140+
});
141+
142+
it("should revert when total doesn't match msg.value (too little sent)", async () => {
143+
const recipients = [recipient1.address, recipient2.address];
144+
const amounts = [ethers.utils.parseEther("1"), ethers.utils.parseEther("1")];
145+
const insufficientValue = ethers.utils.parseEther("1.5"); // Less than total (2 ETH)
146+
147+
await expect(
148+
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: insufficientValue })
149+
).to.be.revertedWith("Transfer failed");
150+
});
151+
152+
it("should revert when total doesn't match msg.value (too much sent)", async () => {
153+
const recipients = [recipient1.address];
154+
const amounts = [ethers.utils.parseEther("1")];
155+
const excessiveValue = ethers.utils.parseEther("2"); // More than total (1 ETH)
156+
157+
await expect(
158+
multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: excessiveValue })
159+
).to.be.revertedWith("Incorrect ETH amount sent");
160+
});
161+
});
162+
});
163+
164+
describe("Gas optimization tests", () => {
165+
beforeEach(async () => {
166+
multiSendETH = (await deployMultiSendETH()) as MultiSendETH;
167+
});
168+
169+
it("should have reasonable gas consumption for different array sizes", async () => {
170+
const testSizes = [1, 5, 10, 50, 100];
171+
172+
for (const size of testSizes) {
173+
const recipients: string[] = [];
174+
const amounts = [];
175+
const amountPerRecipient = ethers.utils.parseEther("0.01");
176+
177+
for (let i = 0; i < size; i++) {
178+
recipients.push(ethers.Wallet.createRandom().address);
179+
amounts.push(amountPerRecipient);
180+
}
181+
182+
const totalAmount = amountPerRecipient.mul(size);
183+
184+
const tx = await multiSendETH.connect(sender).multisendETH(recipients, amounts, { value: totalAmount });
185+
186+
const receipt = await tx.wait();
187+
console.log(`Gas used for ${size} recipients: ${receipt.gasUsed.toString()}`);
188+
189+
// Ensure transaction succeeded
190+
expect(receipt.status).to.eq(1);
191+
}
192+
});
193+
});
194+
});

test/shared/artifacts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import TalentTGEUnlockTimestamp from "../../artifacts/contracts/talent/TalentTGE
1313
import TalentVault from "../../artifacts/contracts/talent/TalentVault.sol/TalentVault.json";
1414
import TalentVaultV2 from "../../artifacts/contracts/talent/TalentVaultV2.sol/TalentVaultV2.json";
1515
import BaseAPY from "../../artifacts/contracts/talent/vault-options/BaseAPY.sol/BaseAPY.json";
16+
import MultiSendETH from "../../artifacts/contracts/utils/MultiSendETH.sol/MultiSendETH.json";
1617

1718
export {
1819
PassportRegistry,
@@ -30,4 +31,5 @@ export {
3031
TalentVault,
3132
TalentVaultV2,
3233
BaseAPY,
34+
MultiSendETH,
3335
};

0 commit comments

Comments
 (0)