diff --git a/contracts/errors/PaymentSplitterErrors.sol b/contracts/errors/PaymentSplitterErrors.sol new file mode 100644 index 00000000..0239ac47 --- /dev/null +++ b/contracts/errors/PaymentSplitterErrors.sol @@ -0,0 +1,20 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +//SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +interface IPaymentSplitterErrors { + /// @dev caller tried to add payees with shares of unequal length + error PaymentSplitterLengthMismatchSharesPayees(); + + /// @dev caller tried to add payees with length of 0 + error PaymentSplitterNoPayeesAdded(); + + /// @dev caller tried to add payee with zeroth address + error PaymentSplitterPayeeZerothAddress(); + + /// @dev caller tried to add payee with 0 shares + error PaymentSplitterPayeeZeroShares(); + + /// @dev caller tried to add shares to account with existing shares + error PaymentSplitterSharesAlreadyExistForPayee(); +} diff --git a/contracts/payment-splitter/PaymentSplitter.sol b/contracts/payment-splitter/PaymentSplitter.sol new file mode 100644 index 00000000..c2f2e48d --- /dev/null +++ b/contracts/payment-splitter/PaymentSplitter.sol @@ -0,0 +1,269 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {AccessControlEnumerable} from "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; +import {IPaymentSplitterErrors} from "../errors/PaymentSplitterErrors.sol"; + +/** + * @title PaymentSplitter + * @dev This contract allows to split Ether payments among a group of accounts. The sender does not need to be aware + * that the Ether will be split in this way, since it is handled transparently by the contract. + * + * Implementation is based on openzeppelin/PaymentSplitter smart contract + */ +contract PaymentSplitter is AccessControlEnumerable, IPaymentSplitterErrors { + /// @notice Emitted when the payees list is updated + event PayeeAdded(address account, uint256 shares); + + /// @notice Emitted when IMX is paid to the contract + event PaymentReleased(address to, uint256 amount); + + /// @notice Emitted when ERC20 is paid to the contract + event ERC20PaymentReleased(IERC20 indexed token, address to, uint256 amount); + + /// @notice Emitted when the contract receives IMX + event PaymentReceived(address from, uint256 amount); + + /// @notice Role responsible for releasing funds + bytes32 public constant RELEASE_FUNDS_ROLE = bytes32("RELEASE_FUNDS_ROLE"); + + /// @notice Role responsible for registering tokens + bytes32 public constant TOKEN_REGISTRAR_ROLE = bytes32("TOKEN_REGISTRAR_ROLE"); + + /// @notice the totalshares held by payees + uint256 private _totalShares; + + /// @notice the number of shares held by each payee + mapping(address => uint256) private _shares; + + /// @notice the address of the payees + address payable[] private _payees; + + /// @notice the list of erc20s that are allowed to be released and interacted with + IERC20[] private allowedERC20List; + + /** + * @notice Creates an instance of `PaymentSplitter` where each account in `payees` is assigned the number of shares at + * the matching position in the `shares` array. + * + * All addresses in `payees` must be non-zero. Both arrays must have the same non-zero length, and there must be no + * duplicates in `payees`. + * @param admin default admin responsible for adding/removing payees and managing roles + * @param registrar default registrar responsible for registering tokens + * @param fundsAdmin default admin responsible for releasing funds + */ + constructor(address admin, address registrar, address fundsAdmin) { + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(RELEASE_FUNDS_ROLE, fundsAdmin); + _grantRole(TOKEN_REGISTRAR_ROLE, registrar); + } + + /** + * @notice Payable fallback method to receive IMX. The IMX received will be logged with {PaymentReceived} events. + * this contract has no other payable method, all IMX receives will be tracked by the events emitted by this event + * ERC20 receives will not be tracked by this contract but tranfers events will be emitted by the erc20 contracts themselves. + */ + receive() external payable virtual { + emit PaymentReceived(_msgSender(), msg.value); + } + + /** + * @notice Grants the release funds role to an address. See {AccessControlEnumerable-grantRole}. + * @param user The address of the funds role admin + */ + function grantReleaseFundsRole(address user) external onlyRole(DEFAULT_ADMIN_ROLE) { + grantRole(RELEASE_FUNDS_ROLE, user); + } + + /** + * @notice revokes the release funds role to an address. See {AccessControlEnumerable-revokeRole}. + * @param user The address of the funds role admin + */ + function revokeReleaseFundsRole(address user) external onlyRole(DEFAULT_ADMIN_ROLE) { + revokeRole(RELEASE_FUNDS_ROLE, user); + } + + /** + * @notice removes a token from the allowlist, does nothing is token is not in the list + * only executable by the token registrar + * @param token The address of the ERC20 token to be removed + */ + function removeFromAllowlist(IERC20 token) external onlyRole(TOKEN_REGISTRAR_ROLE) { + for (uint256 index; index < allowedERC20List.length; index++) { + if (allowedERC20List[index] == token) { + allowedERC20List[index] = allowedERC20List[allowedERC20List.length - 1]; + allowedERC20List.pop(); + break; + } + } + } + + /** + * @notice returns the list of allowed ERC20 tokens + */ + function erc20Allowlist() external view returns (IERC20[] memory) { + return allowedERC20List; + } + + /** + * @notice Getter for the total shares held by payees. + */ + function totalShares() external view returns (uint256) { + return _totalShares; + } + + /** + * @notice Getter for the amount of shares held by an account. + * @param account The address of the payee. + */ + function shares(address account) external view returns (uint256) { + return _shares[account]; + } + + /** + * @notice Getter for the address of the payee number `index`. + * @param index The index of the payee. + */ + function payee(uint256 index) external view returns (address) { + return _payees[index]; + } + + /** + * @notice Getter for the amount of payee's releasable IMX. + * @param account The address of the payee. + */ + function releasable(address account) external view returns (uint256) { + return _pendingPayment(account, address(this).balance); + } + + /** + * @notice Getter for the amount of payee's releasable `token` tokens. `token` should be the address of an + * IERC20 contract. + * @param token The contract address of the ERC20 token. + * @param account The address of the payee. + */ + function releasable(IERC20 token, address account) external view returns (uint256) { + return _pendingPayment(account, token.balanceOf(address(this))); + } + + /** + * @notice Triggers a transfer to all payees of the amount of Ether they are owed, according to their percentage of the + * total shares and their previous withdrawals. + * Triggers a transfer to all payees of the amount of `token` tokens they are owed, according to their + * percentage of the total shares and their previous withdrawals. `token` must be the address of an IERC20 + * contract. + */ + function releaseAll() public virtual onlyRole(RELEASE_FUNDS_ROLE) { + uint256 startBalance = address(this).balance; + if (startBalance > 0) { + for (uint256 payeeIndex = 0; payeeIndex < _payees.length; payeeIndex++) { + address payable account = _payees[payeeIndex]; + uint256 nativePaymentAmount = _pendingPayment(account, startBalance); + Address.sendValue(account, nativePaymentAmount); + emit PaymentReleased(account, nativePaymentAmount); + } + } + + for (uint256 tokenIndex = 0; tokenIndex < allowedERC20List.length; tokenIndex++) { + IERC20 erc20 = allowedERC20List[tokenIndex]; + uint256 startBalanceERC20 = erc20.balanceOf(address(this)); + if (startBalanceERC20 > 0) { + for (uint256 payeeIndex = 0; payeeIndex < _payees.length; payeeIndex++) { + address account = _payees[payeeIndex]; + uint256 erc20PaymentAmount = _pendingPayment(account, startBalanceERC20); + SafeERC20.safeTransfer(erc20, account, erc20PaymentAmount); + emit ERC20PaymentReleased(erc20, account, erc20PaymentAmount); + } + } + } + } + + /** + * @notice replaces the existing entry of payees and shares with the new payees and shares. + * @param payees the address of new payees + * @param shares_ the shares of new payees + */ + function overridePayees( + address payable[] memory payees, + uint256[] memory shares_ + ) public onlyRole(DEFAULT_ADMIN_ROLE) { + if (payees.length != shares_.length) { + revert PaymentSplitterLengthMismatchSharesPayees(); + } + + if (payees.length == 0) { + revert PaymentSplitterNoPayeesAdded(); + } + + for (uint256 i = 0; i < _payees.length; i++) { + delete _shares[_payees[i]]; + } + + delete _payees; + _totalShares = 0; + + for (uint256 i = 0; i < payees.length; i++) { + _addPayee(payees[i], shares_[i]); + } + } + + /** + * @notice adds new tokens to the allowlist, does nothing is token is already in the list + * only executable by the token registrar + * @param tokens The addresses of the ERC20 token to be added + */ + function addToAllowlist(IERC20[] memory tokens) public onlyRole(TOKEN_REGISTRAR_ROLE) { + for (uint256 index; index < tokens.length; index++) { + addToAllowlist(tokens[index]); + } + } + + /** + * @notice adds a new token to the allowlist, does nothing is token is already in the list + * @param token The address of the ERC20 token to be added + */ + function addToAllowlist(IERC20 token) internal onlyRole(TOKEN_REGISTRAR_ROLE) { + for (uint256 index; index < allowedERC20List.length; index++) { + if (allowedERC20List[index] == token) { + return; + } + } + allowedERC20List.push(token); + } + + /** + * @notice internal logic for computing the pending payment of an `account` given the token historical balances and + * already released amounts. + */ + function _pendingPayment(address account, uint256 currentBalance) private view returns (uint256) { + return (currentBalance * _shares[account]) / _totalShares; + } + + /** + * @dev Add a new payee to the contract. + * @param account The address of the payee to add. + * @param shares_ The number of shares owned by the payee. + */ + function _addPayee(address payable account, uint256 shares_) private { + if (account == address(0)) { + revert PaymentSplitterPayeeZerothAddress(); + } + + if (shares_ == 0) { + revert PaymentSplitterPayeeZeroShares(); + } + + if (_shares[account] > 0) { + revert PaymentSplitterSharesAlreadyExistForPayee(); + } + + _payees.push(account); + _shares[account] = shares_; + _totalShares = _totalShares + shares_; + emit PayeeAdded(account, shares_); + } +} diff --git a/test/payment-splitter/MockERC20.sol b/test/payment-splitter/MockERC20.sol new file mode 100644 index 00000000..043ba4a9 --- /dev/null +++ b/test/payment-splitter/MockERC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.9; + +// Basic ERC20. It is deployed by the tests in order to help testing the PaymentSplitter ERC20 payment feature + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockERC20 is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address account, uint256 amount) public { + _mint(account, amount); + } +} diff --git a/test/payment-splitter/PaymentSplitter.t.sol b/test/payment-splitter/PaymentSplitter.t.sol new file mode 100644 index 00000000..4125cae9 --- /dev/null +++ b/test/payment-splitter/PaymentSplitter.t.sol @@ -0,0 +1,303 @@ +// Copyright Immutable Pty Ltd 2018 - 2024 +// SPDX-License-Identifier: Apache 2.0 +pragma solidity 0.8.19; + +import "forge-std/Test.sol"; +import {PaymentSplitter} from "../../contracts/payment-splitter/PaymentSplitter.sol"; +import {MockERC20} from "./MockERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +contract PaymentSplitterTest is Test { + event PaymentReleased(address to, uint256 amount); + + event ERC20PaymentReleased(IERC20 indexed token, address to, uint256 amount); + + event PaymentReceived(address from, uint256 amount); + + PaymentSplitter public paymentSplitter; + MockERC20 public mockToken1; + MockERC20 public mockToken2; + + address payee1 = makeAddr("payee1"); + address payee2 = makeAddr("payee2"); + address payee3 = makeAddr("payee3"); + address payee4 = makeAddr("payee4"); + address defaultAdmin = makeAddr("defaultAdmin"); + address registrarAdmin = makeAddr("registrarAdmin"); + address fundsAdmin = makeAddr("fundsAdmin"); + + address payable [] payees = new address payable[](2); + IERC20[] erc20s = new IERC20[](2); + uint256[] shares = new uint256[](2); + + function setUp() public { + mockToken1 = new MockERC20("MockToken1", "MT1"); + mockToken2 = new MockERC20("MockToken2", "MT2"); + erc20s[0] = mockToken1; + erc20s[1] = mockToken2; + + shares[0] = 1; + shares[1] = 4; + + payees[0] = payable(payee1); + payees[1] = payable(payee2); + + vm.prank(defaultAdmin); + paymentSplitter = new PaymentSplitter(defaultAdmin, registrarAdmin, fundsAdmin); + + vm.prank(registrarAdmin); + paymentSplitter.addToAllowlist(erc20s); + + vm.prank(defaultAdmin); + paymentSplitter.overridePayees(payees, shares); + } + + function testDeployRoles() public { + assertTrue(paymentSplitter.hasRole(paymentSplitter.DEFAULT_ADMIN_ROLE(), defaultAdmin)); + assertTrue(paymentSplitter.hasRole(paymentSplitter.TOKEN_REGISTRAR_ROLE(), registrarAdmin)); + assertTrue(paymentSplitter.hasRole(paymentSplitter.RELEASE_FUNDS_ROLE(), fundsAdmin)); + } + + function testTokensAdded() public { + assertEq(address(paymentSplitter.erc20Allowlist()[1]), address(erc20s[1])); + assertEq(address(paymentSplitter.erc20Allowlist()[0]), address(erc20s[0])); + } + + function testPayeeAdded() public { + assertEq(paymentSplitter.payee(0), payees[0]); + assertEq(paymentSplitter.payee(1), payees[1]); + } + + function testSharesAdded() public { + assertEq(paymentSplitter.shares(payees[0]), shares[0]); + assertEq(paymentSplitter.shares(payees[1]), shares[1]); + } + + function testInvalidPermissions() public { + vm.prank(defaultAdmin); + vm.expectRevert("AccessControl: account 0x6fcb7bf6c32f0cd3bbc5fde0a55a80d3af6d0050 is missing role 0x544f4b454e5f5245474953545241525f524f4c45000000000000000000000000"); + paymentSplitter.addToAllowlist(erc20s); + + vm.startPrank(registrarAdmin); + vm.expectRevert("AccessControl: account 0xa4985bf934d639cba655d34733ebf617e7f82429 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000"); + paymentSplitter.overridePayees(payees, shares); + vm.expectRevert("AccessControl: account 0xa4985bf934d639cba655d34733ebf617e7f82429 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000"); + paymentSplitter.revokeReleaseFundsRole(fundsAdmin); + + vm.expectRevert("AccessControl: account 0xa4985bf934d639cba655d34733ebf617e7f82429 is missing role 0x52454c454153455f46554e44535f524f4c450000000000000000000000000000"); + paymentSplitter.releaseAll(); + vm.stopPrank(); + } + + function testGrantReleaseFundsRole() public { + vm.prank(defaultAdmin); + address newfundAdmin = makeAddr("newfundAdmin"); + paymentSplitter.grantReleaseFundsRole(newfundAdmin); + assertTrue(paymentSplitter.hasRole(paymentSplitter.RELEASE_FUNDS_ROLE(), newfundAdmin)); + } + + function testReleaseNativeTokenFundsSimple() public { + assertEq(payee1.balance, 0); + assertEq(payee2.balance, 0); + + vm.deal(address(paymentSplitter), 100); + + vm.startPrank(fundsAdmin); + paymentSplitter.releaseAll(); + + assertEq(payee1.balance, 20); + assertEq(payee2.balance, 80); + assertEq(address(paymentSplitter).balance, 0); + + vm.deal(address(paymentSplitter), 10); + paymentSplitter.releaseAll(); + + assertEq(payee1.balance, 22); + assertEq(payee2.balance, 88); + vm.stopPrank(); + } + + function testReleaseNativeFundsOverridePayees() public { + assertEq(payee1.balance, 0); + assertEq(payee2.balance, 0); + + vm.deal(address(paymentSplitter), 100); + + vm.prank(fundsAdmin); + vm.expectEmit(true, true, false, false, address(paymentSplitter)); + emit PaymentReleased(payee1, 20); + vm.expectEmit(true, true, false, false, address(paymentSplitter)); + emit PaymentReleased(payee2, 80); + paymentSplitter.releaseAll(); + + assertEq(payee1.balance, 20); + assertEq(payee2.balance, 80); + assertEq(address(paymentSplitter).balance, 0); + + + address payable [] memory newPayees = new address payable[](2); + uint256[] memory newShares = new uint256[](2); + newPayees[0] = payable(payee3); + newPayees[1] = payable(payee4); + newShares[0] = 1; + newShares[1] = 1; + + vm.prank(defaultAdmin); + paymentSplitter.overridePayees(newPayees, newShares); + + vm.deal(address(paymentSplitter), 10); + + vm.prank(fundsAdmin); + paymentSplitter.releaseAll(); + + assertEq(payee1.balance, 20); + assertEq(payee2.balance, 80); + assertEq(payee3.balance, 5); + assertEq(payee4.balance, 5); + } + + function testReleaseERC20sSimple() public { + assertEq(mockToken1.balanceOf(payee1), 0); + assertEq(mockToken1.balanceOf(payee2), 0); + assertEq(mockToken2.balanceOf(payee1), 0); + assertEq(mockToken2.balanceOf(payee2), 0); + + mockToken1.mint(address(paymentSplitter), 100); + mockToken2.mint(address(paymentSplitter), 100); + + + vm.prank(fundsAdmin); + vm.expectEmit(true, true, true, false, address(paymentSplitter)); + emit ERC20PaymentReleased(mockToken1, payee1, 20); + vm.expectEmit(true, true, true, false, address(paymentSplitter)); + emit ERC20PaymentReleased(mockToken1, payee2, 80); + paymentSplitter.releaseAll(); + + assertEq(mockToken1.balanceOf(payee1), 20); + assertEq(mockToken1.balanceOf(payee2), 80); + assertEq(mockToken2.balanceOf(payee1), 20); + assertEq(mockToken2.balanceOf(payee2), 80); + + assertEq(mockToken1.balanceOf(address(paymentSplitter)), 0); + assertEq(mockToken2.balanceOf(address(paymentSplitter)), 0); + + + mockToken1.mint(address(paymentSplitter), 10); + mockToken2.mint(address(paymentSplitter), 10); + + vm.prank(fundsAdmin); + paymentSplitter.releaseAll(); + + assertEq(mockToken1.balanceOf(payee1), 22); + assertEq(mockToken1.balanceOf(payee2), 88); + assertEq(mockToken2.balanceOf(payee1), 22); + assertEq(mockToken2.balanceOf(payee2), 88); + } + + function testReleaseERC20sOverridePayees() public { + assertEq(mockToken1.balanceOf(payee1), 0); + assertEq(mockToken1.balanceOf(payee2), 0); + assertEq(mockToken2.balanceOf(payee1), 0); + assertEq(mockToken2.balanceOf(payee2), 0); + + mockToken1.mint(address(paymentSplitter), 100); + mockToken2.mint(address(paymentSplitter), 100); + + + vm.prank(fundsAdmin); + paymentSplitter.releaseAll(); + + assertEq(mockToken1.balanceOf(payee1), 20); + assertEq(mockToken1.balanceOf(payee2), 80); + assertEq(mockToken2.balanceOf(payee1), 20); + assertEq(mockToken2.balanceOf(payee2), 80); + + assertEq(mockToken1.balanceOf(address(paymentSplitter)), 0); + assertEq(mockToken2.balanceOf(address(paymentSplitter)), 0); + + + mockToken1.mint(address(paymentSplitter), 10); + mockToken2.mint(address(paymentSplitter), 10); + + address payable [] memory newPayees = new address payable[](2); + uint256[] memory newShares = new uint256[](2); + newPayees[0] = payable(payee3); + newPayees[1] = payable(payee4); + newShares[0] = 1; + newShares[1] = 1; + + + vm.prank(defaultAdmin); + paymentSplitter.overridePayees(newPayees, newShares); + + vm.prank(fundsAdmin); + paymentSplitter.releaseAll(); + + assertEq(mockToken1.balanceOf(payee1), 20); + assertEq(mockToken1.balanceOf(payee2), 80); + assertEq(mockToken2.balanceOf(payee1), 20); + assertEq(mockToken2.balanceOf(payee2), 80); + + assertEq(mockToken1.balanceOf(payee3), 5); + assertEq(mockToken1.balanceOf(payee4), 5); + assertEq(mockToken2.balanceOf(payee3), 5); + assertEq(mockToken2.balanceOf(payee4), 5); + } + + function testCalculateReleasableAmount() public { + mockToken1.mint(address(paymentSplitter), 10 ether); + mockToken2.mint(address(paymentSplitter), 20 ether); + + vm.deal(address(paymentSplitter), 100); + + address payable [] memory newPayees = new address payable[](2); + uint256[] memory newShares = new uint256[](2); + newPayees[0] = payable(payee3); + newPayees[1] = payable(payee4); + newShares[0] = 1; + newShares[1] = 3; + vm.prank(defaultAdmin); + paymentSplitter.overridePayees(newPayees, newShares); + + assertEq(paymentSplitter.releasable(payee3), 25); + assertEq(paymentSplitter.releasable(payee4), 75); + + assertEq(paymentSplitter.releasable(mockToken1, payee3), 2.5 ether); + assertEq(paymentSplitter.releasable(mockToken1, payee4), 7.5 ether); + + assertEq(paymentSplitter.releasable(mockToken2, payee3), 5 ether); + assertEq(paymentSplitter.releasable(mockToken2, payee4), 15 ether); + } + + function testAddErc20() public { + MockERC20 token1 = new MockERC20("Token1", "T1"); + MockERC20 token2 = new MockERC20("Token2", "T2"); + IERC20[] memory newErc20s = new IERC20[](2); + newErc20s[0] = token1; + newErc20s[1] = token2; + + + vm.startPrank(registrarAdmin); + paymentSplitter.addToAllowlist(newErc20s); + assertEq(paymentSplitter.erc20Allowlist().length, 4); + assertEq(address(paymentSplitter.erc20Allowlist()[2]), address(token1)); + assertEq(address(paymentSplitter.erc20Allowlist()[3]), address(token2)); + + paymentSplitter.addToAllowlist(newErc20s); + assertEq(paymentSplitter.erc20Allowlist().length, 4); + + paymentSplitter.removeFromAllowlist(token1); + assertEq(paymentSplitter.erc20Allowlist().length, 3); + assertEq(address(paymentSplitter.erc20Allowlist()[2]), address(token2)); + vm.stopPrank(); + } + + function testReceiveNativeTokenEvent() public { + vm.deal(address(this), 100); + vm.expectEmit(true, true, false, false, address(paymentSplitter)); + emit PaymentReceived(address(this), 100); + Address.sendValue(payable(address(paymentSplitter)), 100); + } + +} \ No newline at end of file