diff --git a/contracts/SplitterContract.sol b/contracts/SplitterContract.sol new file mode 100644 index 0000000..55d6503 --- /dev/null +++ b/contracts/SplitterContract.sol @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import '@openzeppelin/contracts/token/ERC20/IERC20.sol'; +import '@openzeppelin/contracts/security/ReentrancyGuard.sol'; + +contract ERC20Splitter is ReentrancyGuard { + mapping(address => mapping(address => uint256)) public balances; + mapping(address => address[]) private _userTokens; + mapping(address => mapping(address => bool)) private _hasToken; + + /** Events **/ + + event Deposit( + address indexed depositor, + address[] tokenAddresses, + uint256[] amounts, + uint256[][] shares, + address[][] recipients + ); + event Withdraw(address indexed user, address[] tokenAddresses, uint256[] amounts); + + uint256 public constant MAX_SHARES = 10000; + + /** External Functions **/ + + /// @notice Deposits ERC20 or native tokens and splits between recipients based on shares. + /// @param tokenAddresses Array of token addresses (use address(0) for native tokens). + /// @param amounts Array of amounts for each token. + /// @param shares Array of share percentages (out of 10000) for each recipient. + /// @param recipients Array of recipients for each token. + function deposit( + address[] calldata tokenAddresses, + uint256[] calldata amounts, + uint256[][] calldata shares, + address[][] calldata recipients + ) external payable nonReentrant { + require(tokenAddresses.length == amounts.length, 'ERC20Splitter: Invalid input lengths'); + require( + tokenAddresses.length == shares.length && tokenAddresses.length == recipients.length, + 'ERC20Splitter: Mismatched input sizes' + ); + + uint256 totalEthAmount = 0; + + for (uint256 i = 0; i < tokenAddresses.length; i++) { + if (tokenAddresses[i] == address(0)) { + totalEthAmount += amounts[i]; + } + _splitTokens(tokenAddresses[i], amounts[i], shares[i], recipients[i]); + } + + require(msg.value == totalEthAmount, 'ERC20Splitter: Incorrect native token amount sent'); + + emit Deposit(msg.sender, tokenAddresses, amounts, shares, recipients); + } + + /// @notice Withdraw all tokens that the caller is entitled to. + /// Tokens are automatically determined based on previous deposits. + function withdraw() external nonReentrant { + address[] storage userTokens = _userTokens[msg.sender]; + require(userTokens.length > 0, 'ERC20Splitter: No tokens to withdraw'); + + address[] memory withdrawnTokens = new address[](userTokens.length); + uint256[] memory withdrawnAmounts = new uint256[](userTokens.length); + + for (uint256 i = 0; i < userTokens.length; i++) { + address tokenAddress = userTokens[i]; + uint256 amount = balances[tokenAddress][msg.sender]; + + if (amount > 0) { + balances[tokenAddress][msg.sender] = 0; + + if (tokenAddress == address(0)) { + (bool success, ) = msg.sender.call{ value: amount }(''); + require(success, 'ERC20Splitter: Failed to send Ether'); + } else { + require(tokenAddress != address(0), 'ERC20Splitter: Invalid token address'); + + require( + IERC20(tokenAddress).transferFrom(address(this), msg.sender, amount), + 'ERC20Splitter: TransferFrom failed' + ); + } + + withdrawnTokens[i] = tokenAddress; + withdrawnAmounts[i] = amount; + } + + delete _hasToken[msg.sender][tokenAddress]; + } + + delete _userTokens[msg.sender]; + + emit Withdraw(msg.sender, withdrawnTokens, withdrawnAmounts); + } + + /** Internal Functions **/ + + /// @notice Internal function to split the tokens among recipients. + /// @param tokenAddress The address of the token being split (use address(0) for native tokens). + /// @param amount The amount of tokens to be split. + /// @param shares Array of share percentages (out of 10000) for each recipient. + /// @param recipients Array of recipients for the token. + function _splitTokens( + address tokenAddress, + uint256 amount, + uint256[] calldata shares, + address[] calldata recipients + ) internal { + require(shares.length == recipients.length, 'ERC20Splitter: Shares and recipients length mismatch'); + require(amount > 0, 'ERC20Splitter: Amount must be greater than zero'); + + uint256 totalSharePercentage = 0; + + for (uint256 i = 0; i < shares.length; i++) { + totalSharePercentage += shares[i]; + } + + require(totalSharePercentage == MAX_SHARES, 'ERC20Splitter: Shares must sum to 100%'); + + if (tokenAddress != address(0)) { + require( + IERC20(tokenAddress).transferFrom(msg.sender, address(this), amount), + 'ERC20Splitter: Transfer failed' + ); + } + + for (uint256 i = 0; i < recipients.length; i++) { + uint256 recipientAmount = (amount * shares[i]) / MAX_SHARES; + balances[tokenAddress][recipients[i]] += recipientAmount; + + _addTokenForUser(recipients[i], tokenAddress); + } + } + + /// @notice Adds a token to the list of tokens a user has received (for automatic withdrawals). + /// @param recipient The recipient of the token. + /// @param tokenAddress The address of the token. + function _addTokenForUser(address recipient, address tokenAddress) internal { + if (!_hasToken[recipient][tokenAddress]) { + _userTokens[recipient].push(tokenAddress); + _hasToken[recipient][tokenAddress] = true; + } + } +} diff --git a/test/SplitterContract.test.ts b/test/SplitterContract.test.ts new file mode 100644 index 0000000..3686e6f --- /dev/null +++ b/test/SplitterContract.test.ts @@ -0,0 +1,400 @@ +/* eslint-disable no-unexpected-multiline */ +import { ethers, network } from 'hardhat' +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { MockERC20, ERC20Splitter } from '../typechain-types' +import { AddressZero } from '../utils/constants' + +describe('ERC20Splitter', () => { + let splitter: ERC20Splitter + let mockERC20: MockERC20 + let owner: Awaited> + let recipient1: Awaited> + let recipient2: Awaited> + let recipient3: Awaited> + + const tokenAmount = ethers.parseEther('100') + const ethAmount = ethers.parseEther('1') + + before(async function () { + // prettier-ignore + [owner, recipient1, recipient2, recipient3] = await ethers.getSigners() + }) + + async function deploySplitterContracts() { + const MockERC20 = await ethers.getContractFactory('MockERC20') + const ERC20Splitter = await ethers.getContractFactory('ERC20Splitter') + + const mockERC20 = await MockERC20.deploy() + await mockERC20.waitForDeployment() + + const splitter = await ERC20Splitter.deploy() + await splitter.waitForDeployment() + + return { mockERC20, splitter } + } + + beforeEach(async () => { + const contracts = await loadFixture(deploySplitterContracts) + mockERC20 = contracts.mockERC20 + splitter = contracts.splitter + + // Mint tokens to the owner + await mockERC20.connect(owner).mint(owner, ethers.parseEther('1000')) + + const splitterAddress = await splitter.getAddress() + + await network.provider.send('hardhat_setBalance', [ + splitterAddress, + ethers.toQuantity(ethers.parseEther('2')), // Setting 2 Ether + ]) + + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [splitterAddress], + }) + const splitterSigner = await ethers.getSigner(splitterAddress) + + await mockERC20.connect(splitterSigner).approve(splitterAddress, ethers.MaxUint256) + + await network.provider.request({ + method: 'hardhat_stopImpersonatingAccount', + params: [splitterAddress], + }) + }) + + describe('Main Functions', async () => { + describe('Deposit', async () => { + beforeEach(async () => { + await mockERC20.connect(owner).approve(splitter.getAddress(), tokenAmount) + }) + + it('Should deposit ERC20 tokens and split them between recipients', async () => { + const shares = [[5000, 3000, 2000]] // 50%, 30%, 20% + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await expect( + splitter.connect(owner).deposit([mockERC20.getAddress()], [tokenAmount], shares, recipients), + ).to.emit(splitter, 'Deposit') + + expect(await splitter.balances(mockERC20.getAddress(), recipient1.address)).to.equal(ethers.parseEther('50')) + expect(await splitter.balances(mockERC20.getAddress(), recipient2.address)).to.equal(ethers.parseEther('30')) + expect(await splitter.balances(mockERC20.getAddress(), recipient3.address)).to.equal(ethers.parseEther('20')) + }) + + it('Should deposit native tokens (ETH) and split them between recipients', async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await expect( + splitter.connect(owner).deposit([AddressZero], [ethAmount], shares, recipients, { + value: ethAmount, + }), + ).to.emit(splitter, 'Deposit') + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(ethers.parseEther('0.5')) + expect(await splitter.balances(AddressZero, recipient2.address)).to.equal(ethers.parseEther('0.3')) + expect(await splitter.balances(AddressZero, recipient3.address)).to.equal(ethers.parseEther('0.2')) + }) + + it('Should revert if shares do not sum to 100%', async () => { + const invalidShares = [[4000, 4000, 1000]] // Sums to 90% + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await expect( + splitter.connect(owner).deposit([mockERC20.getAddress()], [tokenAmount], invalidShares, recipients), + ).to.be.revertedWith('ERC20Splitter: Shares must sum to 100%') + }) + + it('Should revert if the number of shares and recipients do not match', async () => { + const invalidShares = [[5000, 3000]] // Only 2 shares + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] // 3 recipients + + await expect( + splitter.connect(owner).deposit([mockERC20.getAddress()], [tokenAmount], invalidShares, recipients), + ).to.be.revertedWith('ERC20Splitter: Shares and recipients length mismatch') + }) + + it('Should handle multiple native token (ETH) deposits in a single transaction', async () => { + const ethShares = [ + [5000, 5000], + [6000, 4000], + ] + const ethRecipients1 = [recipient1.address, recipient2.address] // Recipients for first ETH deposit + const ethRecipients2 = [recipient2.address, recipient3.address] // Recipients for second ETH deposit + + const ethAmount1 = ethers.parseEther('1') // First ETH deposit (1 ETH) + const ethAmount2 = ethers.parseEther('2') // Second ETH deposit (2 ETH) + + await expect( + splitter + .connect(owner) + .deposit( + [AddressZero, AddressZero], + [ethAmount1, ethAmount2], + [ethShares[0], ethShares[1]], + [ethRecipients1, ethRecipients2], + { value: ethAmount1 + ethAmount2 }, + ), + ).to.emit(splitter, 'Deposit') + + // Check balances for recipient1 (50% of 1 ETH) + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(ethers.parseEther('0.5')) + + // Check balances for recipient2 (50% of 1 ETH + 60% of 2 ETH = 0.5 + 1.2 = 1.7 ETH) + expect(await splitter.balances(AddressZero, recipient2.address)).to.equal(ethers.parseEther('1.7')) + + // Check balances for recipient3 (40% of 2 ETH = 0.8 ETH) + expect(await splitter.balances(AddressZero, recipient3.address)).to.equal(ethers.parseEther('0.8')) + }) + + it('Should handle both native token (ETH) and ERC-20 deposits in a single transaction', async () => { + const ethShares = [[5000, 5000]] + const erc20Shares = [[6000, 4000]] + + const ethRecipients = [recipient1.address, recipient2.address] + const erc20Recipients = [recipient2.address, recipient3.address] + + const ethAmount = ethers.parseEther('1') // ETH deposit (1 ETH) + const erc20Amount = ethers.parseEther('100') // ERC-20 deposit (100 tokens) + + await mockERC20.connect(owner).approve(splitter.getAddress(), erc20Amount) + + await expect( + splitter + .connect(owner) + .deposit( + [AddressZero, mockERC20.getAddress()], + [ethAmount, erc20Amount], + [ethShares[0], erc20Shares[0]], + [ethRecipients, erc20Recipients], + { value: ethAmount }, + ), + ).to.emit(splitter, 'Deposit') + + // Check balances for recipient1 (50% of 1 ETH) + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(ethers.parseEther('0.5')) + + // Check balances for recipient2 (50% of 1 ETH + 60% of 100 ERC-20 tokens = 0.5 ETH + 60 tokens) + expect(await splitter.balances(AddressZero, recipient2.address)).to.equal(ethers.parseEther('0.5')) + expect(await splitter.balances(mockERC20.getAddress(), recipient2.address)).to.equal(ethers.parseEther('60')) + + // Check balances for recipient3 (40% of 100 ERC-20 tokens = 40 tokens) + expect(await splitter.balances(mockERC20.getAddress(), recipient3.address)).to.equal(ethers.parseEther('40')) + }) + }) + + describe('Withdraw', async () => { + beforeEach(async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await mockERC20.connect(owner).approve(splitter.getAddress(), tokenAmount) + await splitter.connect(owner).deposit([mockERC20.getAddress()], [tokenAmount], shares, recipients) + }) + + it('Should allow a recipient to withdraw their split ERC20 tokens without specifying token addresses', async () => { + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs(recipient1.address, [await mockERC20.getAddress()], [ethers.parseEther('50')]) + + expect(await splitter.balances(await mockERC20.getAddress(), recipient1.address)).to.equal(0) + }) + + it('Should allow a recipient to withdraw their split native tokens (ETH) and ERC20 tokens', async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await splitter.connect(owner).deposit([AddressZero], [ethAmount], shares, recipients, { + value: ethAmount, + }) + + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient1.address, + [await mockERC20.getAddress(), AddressZero], // Expect both ERC-20 and native token + [ethers.parseEther('50'), ethers.parseEther('0.5')], // 50 ERC20 tokens and 0.5 ETH + ) + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(0) + expect(await splitter.balances(mockERC20.getAddress(), recipient1.address)).to.equal(0) + }) + }) + + describe('Withdraw ERC-20 and Native Tokens', async () => { + beforeEach(async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await mockERC20.connect(owner).approve(splitter.getAddress(), tokenAmount) + await splitter.connect(owner).deposit([await mockERC20.getAddress()], [tokenAmount], shares, recipients) + }) + + it('Should allow a recipient to withdraw their split ERC20 tokens without specifying token addresses', async () => { + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs(recipient1.address, [await mockERC20.getAddress()], [ethers.parseEther('50')]) + + expect(await splitter.balances(mockERC20.getAddress(), recipient1.address)).to.equal(0) + }) + + it('Should allow a recipient to withdraw their split native tokens (ETH) and ERC20 tokens', async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await splitter.connect(owner).deposit([AddressZero], [ethAmount], shares, recipients, { + value: ethAmount, + }) + + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient1.address, + [await mockERC20.getAddress(), AddressZero], // Expect both ERC-20 and native token + [ethers.parseEther('50'), ethers.parseEther('0.5')], // 50 ERC20 tokens and 0.5 ETH + ) + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(0) + expect(await splitter.balances(mockERC20.getAddress(), recipient1.address)).to.equal(0) + }) + }) + + describe('Withdraw Only Native Tokens (ETH)', async () => { + beforeEach(async () => { + const shares = [[5000, 3000, 2000]] + const recipients = [[recipient1.address, recipient2.address, recipient3.address]] + + await splitter.connect(owner).deposit([AddressZero], [ethAmount], shares, recipients, { + value: ethAmount, + }) + }) + + it('Should allow a recipient to withdraw only their split native tokens (ETH)', async () => { + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient1.address, + [AddressZero], // Expect only native token (ETH) + [ethers.parseEther('0.5')], // Expect 0.5 ETH (50% of 1 ETH) + ) + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(0) + }) + }) + + describe('Deposit ETH for recipient1 and ERC-20 for other recipients', async () => { + beforeEach(async () => { + const ethShares = [[10000]] // 100% for recipient1 (ETH) + const erc20Shares = [[5000, 5000]] // 50% for recipient2, 50% for recipient3 (ERC-20) + const ethRecipients = [[recipient1.address]] // Only recipient1 gets ETH + const erc20Recipients = [ + [recipient2.address, recipient3.address], // recipient2 and recipient3 get ERC-20 tokens + ] + await splitter.connect(owner).deposit([AddressZero], [ethAmount], ethShares, ethRecipients, { + value: ethAmount, + }) + + // Then, deposit ERC-20 tokens for recipient2 and recipient3 + await mockERC20.connect(owner).approve(splitter.getAddress(), tokenAmount) + await splitter + .connect(owner) + .deposit([await mockERC20.getAddress()], [tokenAmount], erc20Shares, erc20Recipients) + }) + + it('Should allow recipient1 to withdraw only their ETH and other recipients to withdraw their ERC-20 tokens', async () => { + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient1.address, + [AddressZero], // Only native token (ETH) + [ethers.parseEther('1')], // Full 1 ETH + ) + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(0) + + await expect(splitter.connect(recipient2).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient2.address, + [await mockERC20.getAddress()], // Only ERC-20 token + [ethers.parseEther('50')], // 50% of ERC-20 tokens + ) + + expect(await splitter.balances(mockERC20.getAddress(), recipient2.address)).to.equal(0) + + await expect(splitter.connect(recipient3).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient3.address, + [await mockERC20.getAddress()], // Only ERC-20 token + [ethers.parseEther('50')], // 50% of ERC-20 tokens + ) + + expect(await splitter.balances(mockERC20.getAddress(), recipient3.address)).to.equal(0) + }) + }) + describe('Withdraw for both native tokens (ETH) and ERC-20 tokens multiples addresses 0', () => { + let ethShares, erc20Shares + let ethRecipients, erc20Recipients + let ethAmount, erc20Amount + + beforeEach(async () => { + // Define shares and recipients for both ETH and ERC-20 + ethShares = [[5000, 5000]] // 50%-50% for ETH + erc20Shares = [[6000, 4000]] // 60%-40% for ERC-20 + + ethRecipients = [recipient1.address, recipient2.address] + erc20Recipients = [recipient2.address, recipient3.address] + + ethAmount = ethers.parseEther('1') // 1 ETH + erc20Amount = ethers.parseEther('100') // 100 ERC-20 tokens + + await mockERC20.connect(owner).approve(splitter.getAddress(), erc20Amount) + + await splitter + .connect(owner) + .deposit( + [AddressZero, mockERC20.getAddress()], + [ethAmount, erc20Amount], + [ethShares[0], erc20Shares[0]], + [ethRecipients, erc20Recipients], + { value: ethAmount }, + ) + }) + + it('Should allow recipient1 to withdraw only ETH', async () => { + await expect(splitter.connect(recipient1).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient1.address, + [AddressZero], // Only native token (ETH) + [ethers.parseEther('0.5')], // 50% of 1 ETH + ) + + expect(await splitter.balances(AddressZero, recipient1.address)).to.equal(0) + }) + + it('Should allow recipient2 to withdraw both ETH and ERC-20 tokens', async () => { + await expect(splitter.connect(recipient2).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs( + recipient2.address, + [AddressZero, await mockERC20.getAddress()], // First ETH, then ERC-20 + [ethers.parseEther('0.5'), ethers.parseEther('60')], // 50% of 1 ETH and 60 ERC-20 tokens + ) + + expect(await splitter.balances(AddressZero, recipient2.address)).to.equal(0) + expect(await splitter.balances(mockERC20.getAddress(), recipient2.address)).to.equal(0) + }) + + it('Should allow recipient3 to withdraw only ERC-20 tokens', async () => { + await expect(splitter.connect(recipient3).withdraw()) + .to.emit(splitter, 'Withdraw') + .withArgs(recipient3.address, [await mockERC20.getAddress()], [ethers.parseEther('40')]) + + expect(await splitter.balances(mockERC20.getAddress(), recipient3.address)).to.equal(0) + }) + }) + }) +})