From 013877691826a800b46e85ba57707a1d1a2df95c Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Fri, 23 Aug 2024 10:25:07 +1200 Subject: [PATCH] Add PaymentCombiner --- src/payments/IPaymentCombiner.sol | 65 ++++++++++ src/payments/PaymentCombiner.sol | 124 +++++++++++++++++++ src/payments/PaymentSplitter.sol | 19 +++ test/TestHelper.sol | 8 ++ test/payments/PaymentCombiner.t.sol | 180 ++++++++++++++++++++++++++++ 5 files changed, 396 insertions(+) create mode 100644 src/payments/IPaymentCombiner.sol create mode 100644 src/payments/PaymentCombiner.sol create mode 100644 src/payments/PaymentSplitter.sol create mode 100644 test/payments/PaymentCombiner.t.sol diff --git a/src/payments/IPaymentCombiner.sol b/src/payments/IPaymentCombiner.sol new file mode 100644 index 0000000..f1109d3 --- /dev/null +++ b/src/payments/IPaymentCombiner.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +interface IPaymentCombinerFunctions { + /** + * Get the address of the PaymentSplitter implementation. + * @return implementationAddr The address of the PaymentSplitter implementation. + */ + function implementationAddress() external view returns (address implementationAddr); + + /** + * Creates a PaymentSplitter proxy. + * @param payees The addresses of the payees + * @param shares The number of shares each payee has + * @return proxyAddr The address of the deployed proxy + */ + function deploy(address[] calldata payees, uint256[] calldata shares) external returns (address proxyAddr); + + /** + * Computes the address of a proxy instance. + * @param payees The addresses of the payees + * @param shares The number of shares each payee has + * @return proxyAddr The address of the proxy + */ + function determineAddress(address[] calldata payees, uint256[] calldata shares) external returns (address proxyAddr); + + /** + * Get the list of Payment Splitters this payee is associated with. + * @param payee The address of the payee + * @return splitterAddrs The list of payments splitters + */ + function listPayeeSplitters(address payee) external view returns (address[] memory splitterAddrs); + + /** + * Get the list of pending shares for a payee. + * @param payee The address of the payee + * @param tokenAddr The address of the ERC-20 token. If the token address is 0x0, then the native token is used. + * @return splitterAddrs The list of payments splitters with pending shares + * @return pendingShares The list of pending shares + * @dev The list includes zero balances. These should be removed before releasing shares. + */ + function listReleasable(address payee, address tokenAddr) + external + view + returns (address[] memory splitterAddrs, uint256[] memory pendingShares); + + /** + * Release the pending shares for a payee. + * @param payee The address of the payee + * @param tokenAddr The address of the ERC-20 token. If the token address is 0x0, then the native token is used. + * @param splitterAddrs The list of payments splitters to release shares from + * @dev Use the listReleasableSplitters function to get the list of splitters and pending shares + */ + function release(address payable payee, address tokenAddr, address[] calldata splitterAddrs) external; +} + +interface IPaymentCombinerSignals { + /** + * Event emitted when a new proxy contract is deployed. + * @param proxyAddr The address of the deployed proxy. + */ + event PaymentSplitterDeployed(address proxyAddr); +} + +interface IPaymentCombiner is IPaymentCombinerFunctions, IPaymentCombinerSignals {} diff --git a/src/payments/PaymentCombiner.sol b/src/payments/PaymentCombiner.sol new file mode 100644 index 0000000..df813bd --- /dev/null +++ b/src/payments/PaymentCombiner.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import {PaymentSplitter, IERC20Upgradeable} from "@0xsequence/contracts-library/payments/PaymentSplitter.sol"; +import { + IPaymentCombiner, IPaymentCombinerFunctions +} from "@0xsequence/contracts-library/payments/IPaymentCombiner.sol"; +import {IERC165} from "@0xsequence/erc-1155/contracts/interfaces/IERC165.sol"; +import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; + +/** + * Deployer of Payment Splitter proxies. + * @dev Unlike other factories in this library, payment splitters are unowned and not upgradeable. + */ +contract PaymentCombiner is IPaymentCombiner, IERC165 { + using Clones for address; + + address private immutable _IMPLEMENTATION; + + mapping(address => address[]) private _payeeSplitters; + + /** + * Creates a Payment Splitter Factory. + */ + constructor() { + _IMPLEMENTATION = address(new PaymentSplitter()); + } + + /// @inheritdoc IPaymentCombinerFunctions + function implementationAddress() external view returns (address) { + return _IMPLEMENTATION; + } + + /// @inheritdoc IPaymentCombinerFunctions + function deploy(address[] calldata payees, uint256[] calldata shares) external returns (address proxyAddr) { + bytes32 salt = _determineSalt(payees, shares); + proxyAddr = _IMPLEMENTATION.cloneDeterministic(salt); + PaymentSplitter(payable(proxyAddr)).initialize(payees, shares); + emit PaymentSplitterDeployed(proxyAddr); + + // Add the payees to the list of payee splitters + for (uint256 i = 0; i < payees.length; i++) { + _payeeSplitters[payees[i]].push(proxyAddr); + } + + return proxyAddr; + } + + /// @inheritdoc IPaymentCombinerFunctions + function determineAddress(address[] calldata payees, uint256[] calldata shares) + external + view + returns (address proxyAddr) + { + bytes32 salt = _determineSalt(payees, shares); + return _IMPLEMENTATION.predictDeterministicAddress(salt); + } + + /// @dev Computes the deployment salt for a Payment Splitter. + function _determineSalt(address[] calldata payees, uint256[] calldata shares) internal pure returns (bytes32) { + return keccak256(abi.encode(payees, shares)); + } + + /// @inheritdoc IPaymentCombinerFunctions + function listPayeeSplitters(address payee) external view returns (address[] memory splitterAddrs) { + return _payeeSplitters[payee]; + } + + /// @inheritdoc IPaymentCombinerFunctions + function listReleasable(address payee, address tokenAddr) + external + view + returns (address[] memory splitterAddrs, uint256[] memory pendingShares) + { + address[] memory payeeSplitters = _payeeSplitters[payee]; + uint256 len = payeeSplitters.length; + uint256[] memory payeePendingShares = new uint256[](len); + + if (tokenAddr == address(0)) { + for (uint256 i = 0; i < len;) { + payeePendingShares[i] = PaymentSplitter(payable(payeeSplitters[i])).releasable(payee); + unchecked { + i++; + } + } + } else { + for (uint256 i = 0; i < len;) { + payeePendingShares[i] = + PaymentSplitter(payable(payeeSplitters[i])).releasable(IERC20Upgradeable(tokenAddr), payee); + unchecked { + i++; + } + } + } + + return (payeeSplitters, payeePendingShares); + } + + /// @inheritdoc IPaymentCombinerFunctions + function release(address payable payee, address tokenAddr, address[] calldata splitterAddrs) external { + uint256 len = splitterAddrs.length; + if (tokenAddr == address(0)) { + for (uint256 i = 0; i < len;) { + PaymentSplitter(payable(splitterAddrs[i])).release(payee); + unchecked { + i++; + } + } + } else { + for (uint256 i = 0; i < len;) { + PaymentSplitter(payable(splitterAddrs[i])).release(IERC20Upgradeable(tokenAddr), payee); + unchecked { + i++; + } + } + } + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return type(IPaymentCombiner).interfaceId == interfaceId + || type(IPaymentCombinerFunctions).interfaceId == interfaceId || type(IERC165).interfaceId == interfaceId; + } +} diff --git a/src/payments/PaymentSplitter.sol b/src/payments/PaymentSplitter.sol new file mode 100644 index 0000000..43c197c --- /dev/null +++ b/src/payments/PaymentSplitter.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import { + PaymentSplitterUpgradeable, + IERC20Upgradeable +} from "@openzeppelin-upgradeable/contracts/finance/PaymentSplitterUpgradeable.sol"; + +contract PaymentSplitter is PaymentSplitterUpgradeable { + /** + * Initialize the PaymentSplitter contract. + * @param payees The addresses of the payees + * @param shares The number of shares each payee has + * @dev This function should be called only once immediately after the contract is deployed. + */ + function initialize(address[] memory payees, uint256[] memory shares) public initializer { + __PaymentSplitter_init(payees, shares); + } +} diff --git a/test/TestHelper.sol b/test/TestHelper.sol index b1b8783..0fa9e8f 100644 --- a/test/TestHelper.sol +++ b/test/TestHelper.sol @@ -46,6 +46,14 @@ abstract contract TestHelper is Test, Merkle { } } + function assumeNoDuplicates(address[] memory values) internal pure { + for (uint256 i = 0; i < values.length; i++) { + for (uint256 j = i + 1; j < values.length; j++) { + vm.assume(values[i] != values[j]); + } + } + } + function getMerkleParts(address[] memory allowlist, uint256 salt, uint256 leafIndex) internal pure returns (bytes32 root, bytes32[] memory proof) { bytes32[] memory leaves = new bytes32[](allowlist.length); for (uint256 i = 0; i < allowlist.length; i++) { diff --git a/test/payments/PaymentCombiner.t.sol b/test/payments/PaymentCombiner.t.sol new file mode 100644 index 0000000..5ea3fe8 --- /dev/null +++ b/test/payments/PaymentCombiner.t.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.19; + +import {TestHelper} from "../TestHelper.sol"; +import {ERC20Mock} from "../_mocks/ERC20Mock.sol"; + +import {PaymentCombiner, PaymentSplitter, IERC20Upgradeable} from "src/payments/PaymentCombiner.sol"; +import {IPaymentCombiner, IPaymentCombinerSignals, IPaymentCombinerFunctions} from "src/payments/IPaymentCombiner.sol"; +import {IERC165} from "@0xsequence/erc-1155/contracts/interfaces/IERC165.sol"; + +// Note we are not testing the OZ PaymentSplitter contract implementation, only the PaymentCombiner contract + +contract PaymentCombinerTest is TestHelper, IPaymentCombinerSignals { + PaymentCombiner public combiner; + ERC20Mock public erc20; + + function setUp() public { + combiner = new PaymentCombiner(); + erc20 = new ERC20Mock(address(this)); + } + + function testSupportsInterface() public view { + assertTrue(combiner.supportsInterface(type(IERC165).interfaceId)); + assertTrue(combiner.supportsInterface(type(IPaymentCombiner).interfaceId)); + assertTrue(combiner.supportsInterface(type(IPaymentCombinerFunctions).interfaceId)); + } + + function _validArrays(address[] memory payees, uint256[] memory shares) + internal + view + returns (address[] memory, uint256[] memory) + { + uint256 payeeLength = payees.length; + vm.assume(payeeLength > 0); + uint256 sharesLength = shares.length; + vm.assume(sharesLength > 0); + + uint256 maxLen = 5; + maxLen = payeeLength < maxLen ? payeeLength : maxLen; + maxLen = sharesLength < maxLen ? sharesLength : maxLen; + assembly { + mstore(payees, maxLen) + mstore(shares, maxLen) + } + + // Make sure addr is safe + for (uint256 i = 0; i < maxLen; i++) { + assumeSafeAddress(payees[i]); + } + assumeNoDuplicates(payees); + + // Bind shares to prevent overflow + for (uint256 i = 0; i < maxLen; i++) { + shares[i] = _bound(shares[i], 1, 100); + } + + return (payees, shares); + } + + function testDetermineAddress(address[] memory payees, uint256[] memory shares) public { + (payees, shares) = _validArrays(payees, shares); + + address expectedAddr = combiner.determineAddress(payees, shares); + address actualAddr = combiner.deploy(payees, shares); + assertEq(expectedAddr, actualAddr); + } + + function testListPayeeSplitters( + address[] memory payees1, + address[] memory payees2, + uint256[] memory shares1, + uint256[] memory shares2 + ) public returns (address targetPayee, address[] memory splitterAddrs) { + vm.assume(payees1.length > 0); + vm.assume(payees2.length > 0); + targetPayee = payees1[0]; + payees2[0] = targetPayee; + (payees1, shares1) = _validArrays(payees1, shares1); + (payees2, shares2) = _validArrays(payees2, shares2); + + address splitter1 = combiner.deploy(payees1, shares1); + address splitter2 = combiner.deploy(payees2, shares2); + splitterAddrs = combiner.listPayeeSplitters(targetPayee); + assertEq(splitterAddrs.length, 2); + assertEq(splitterAddrs[0], splitter1); + assertEq(splitterAddrs[1], splitter2); + } + + function testListReleasableNative( + uint256 amount, + address[] memory payees1, + address[] memory payees2, + uint256[] memory shares1, + uint256[] memory shares2 + ) public returns (address targetPayee, address[] memory splitterAddrs) { + (targetPayee, splitterAddrs) = testListPayeeSplitters(payees1, payees2, shares1, shares2); + + amount = _bound(amount, 0.1 ether, 1 ether); + vm.deal(address(this), amount * splitterAddrs.length); + + for (uint256 i = 0; i < splitterAddrs.length; i++) { + // Fund splitters + payable(splitterAddrs[i]).transfer(amount); + } + + (address[] memory splitterReleasable, uint256[] memory pendingShares) = + combiner.listReleasable(payable(targetPayee), address(0)); + + for (uint256 i = 0; i < splitterReleasable.length; i++) { + assertEq(splitterReleasable[i], splitterAddrs[i]); + PaymentSplitter splitter = PaymentSplitter(payable(splitterReleasable[i])); + assertEq(splitter.releasable(targetPayee), pendingShares[i]); + } + } + + function testListReleasableERC20( + uint256 amount, + address[] memory payees1, + address[] memory payees2, + uint256[] memory shares1, + uint256[] memory shares2 + ) public returns (address targetPayee, address[] memory splitterAddrs) { + (targetPayee, splitterAddrs) = testListPayeeSplitters(payees1, payees2, shares1, shares2); + + amount = _bound(amount, 0.1 ether, 1 ether); + erc20.mint(address(this), 0, amount * splitterAddrs.length); + + for (uint256 i = 0; i < splitterAddrs.length; i++) { + // Fund splitters + erc20.transfer(splitterAddrs[i], amount); + } + + (address[] memory splitterReleasable, uint256[] memory pendingShares) = + combiner.listReleasable(payable(targetPayee), address(erc20)); + + for (uint256 i = 0; i < splitterReleasable.length; i++) { + assertEq(splitterReleasable[i], splitterAddrs[i]); + PaymentSplitter splitter = PaymentSplitter(payable(splitterReleasable[i])); + assertEq(splitter.releasable(IERC20Upgradeable(address(erc20)), targetPayee), pendingShares[i]); + } + } + + function testListReleaseNative( + uint256 amount, + address[] memory payees1, + address[] memory payees2, + uint256[] memory shares1, + uint256[] memory shares2 + ) public { + (address targetPayee, address[] memory splitterAddrs) = + testListReleasableNative(amount, payees1, payees2, shares1, shares2); + + combiner.release(payable(targetPayee), address(0), splitterAddrs); + + for (uint256 i = 0; i < splitterAddrs.length; i++) { + PaymentSplitter splitter = PaymentSplitter(payable(splitterAddrs[i])); + assertGt(splitter.released(targetPayee), 0); + assertEq(splitter.released(IERC20Upgradeable(address(erc20)), targetPayee), 0); + } + } + + function testListReleaseERC20( + uint256 amount, + address[] memory payees1, + address[] memory payees2, + uint256[] memory shares1, + uint256[] memory shares2 + ) public { + (address targetPayee, address[] memory splitterAddrs) = + testListReleasableERC20(amount, payees1, payees2, shares1, shares2); + + combiner.release(payable(targetPayee), address(erc20), splitterAddrs); + + for (uint256 i = 0; i < splitterAddrs.length; i++) { + PaymentSplitter splitter = PaymentSplitter(payable(splitterAddrs[i])); + assertGt(splitter.released(IERC20Upgradeable(address(erc20)), targetPayee), 0); + assertEq(splitter.released(targetPayee), 0); + } + } +}