diff --git a/.gas-snapshot b/.gas-snapshot index 152f635..d4bcf8d 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,9 +1,24 @@ GasbackTest:testConvertGasback() (gas: 73039) -GasbackTest:testConvertGasback(uint256,uint256) (runs: 257, μ: 423506, ~: 308109) +GasbackTest:testConvertGasback(uint256,uint256) (runs: 256, μ: 426833, ~: 294047) GasbackTest:testConvertGasbackBaseFeeVault() (gas: 27070) GasbackTest:testConvertGasbackMaxBaseFee() (gas: 44525) GasbackTest:testConvertGasbackMinVaultBalance() (gas: 26953) GasbackTest:testConvertGasbackWithAccruedToAccruedRecipient() (gas: 69305) GasbackTest:test__codesize() (gas: 9846) +ShapePaymentSplitterTest:testFuzz_balances_after_multiple_payments(uint8,uint256[9]) (runs: 256, μ: 3423898, ~: 1986039) +ShapePaymentSplitterTest:testFuzz_balances_after_payment(uint8,uint256) (runs: 256, μ: 1991966, ~: 1164010) +ShapePaymentSplitterTest:test__codesize() (gas: 18192) +ShapePaymentSplitterTest:test_balances_after_payment() (gas: 254247) +ShapePaymentSplitterTest:test_read_public_variables() (gas: 49230) +ShapePaymentSplitterTest:test_revert_deploy_duplicate_payee() (gas: 180871) +ShapePaymentSplitterTest:test_revert_deploy_empty_payees() (gas: 38066) +ShapePaymentSplitterTest:test_revert_deploy_length_mismatch_more_payees() (gas: 45641) +ShapePaymentSplitterTest:test_revert_deploy_length_mismatch_more_shares() (gas: 42882) +ShapePaymentSplitterTest:test_revert_deploy_zero_address_payee() (gas: 131126) +ShapePaymentSplitterTest:test_revert_deploy_zero_shares() (gas: 133285) +ShapePaymentSplitterTest:test_revert_release_account_has_no_shares() (gas: 11396) +ShapePaymentSplitterTest:test_revert_release_account_not_due_payment() (gas: 20155) +ShapePaymentSplitterTest:test_revert_release_failed_to_send_value() (gas: 537386) +ShapePaymentSplitterTest:test_revert_release_insufficient_balance() (gas: 36339) SoladyTest:test__codesize() (gas: 4099) TestPlus:test__codesize() (gas: 393) \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 06c721a..2cc06bf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,7 +4,7 @@ # The Default Profile [profile.default] -solc_version = "0.8.30" +solc_version = "0.8.28" evm_version = "prague" auto_detect_solc = false optimizer = true diff --git a/script/DeployGasback.s.sol b/script/DeployGasback.s.sol new file mode 100644 index 0000000..24fd868 --- /dev/null +++ b/script/DeployGasback.s.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.7; + +import {Script} from "forge-std/Script.sol"; +import {Gasback} from "../src/Gasback.sol"; + +contract DeployGasbackScript is Script { + function run() external returns (Gasback deployed) { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + + vm.startBroadcast(privateKey); + deployed = new Gasback(); + vm.stopBroadcast(); + } +} diff --git a/script/DeployShapePaymentSplitter.s.sol b/script/DeployShapePaymentSplitter.s.sol new file mode 100644 index 0000000..c08b8d2 --- /dev/null +++ b/script/DeployShapePaymentSplitter.s.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; + +contract DeployShapePaymentSplitterScript is Script { + function run() external returns (ShapePaymentSplitter deployed) { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + + address[] memory payees = new address[](2); + uint256[] memory shares = new uint256[](2); + + /// @notice Replace with actual payee addresses + payees[0] = 0x1234567890123456789012345678901234567890; + payees[1] = 0x1234567890123456789012345678901234567891; + + /// @notice Replace with actual share amounts + shares[0] = 50; + shares[1] = 50; + + vm.startBroadcast(privateKey); + deployed = new ShapePaymentSplitter(payees, shares); + vm.stopBroadcast(); + + console.log("ShapePaymentSplitter deployed at:", address(deployed)); + console.log("Payee 1:", payees[0], "Shares:", shares[0]); + console.log("Payee 2:", payees[1], "Shares:", shares[1]); + } +} diff --git a/src/Gasback.sol b/src/Gasback.sol index b7f3270..4f90843 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -34,8 +34,6 @@ contract Gasback { uint256 minVaultBalance; // The amount of ETH accrued by taking a cut from the gas burned. uint256 accrued; - // The recipient of the accrued ETH. - address accruedRecipient; // A mapping of addresses authorized to withdraw the accrued ETH. mapping(address => bool) accuralWithdrawers; } @@ -60,7 +58,6 @@ contract Gasback { $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; $.minVaultBalance = 0.42 ether; - $.accruedRecipient = 0x4200000000000000000000000000000000000019; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -123,27 +120,6 @@ contract Gasback { return true; } - /// @dev Withdraws from the accrued amount to the accrued recipient. - function withdrawAccruedToAccruedRecipient(uint256 amount) public virtual returns (bool) { - // Checked math prevents underflow. - _getGasbackStorage().accrued -= amount; - - address accruedRecipient = _getGasbackStorage().accruedRecipient; - /// @solidity memory-safe-assembly - assembly { - if iszero(call(gas(), accruedRecipient, amount, 0x00, 0x00, 0x00, 0x00)) { - revert(0x00, 0x00) - } - } - return true; - } - - /// @dev Sets the accrued recipient. - function setAccruedRecipient(address value) public onlySystemOrThis returns (bool) { - _getGasbackStorage().accruedRecipient = value; - return true; - } - /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ /* ADMIN FUNCTIONS */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -217,9 +193,6 @@ contract Gasback { uint256 ethFromGas = gasToBurn * block.basefee; uint256 ethToGive = (ethFromGas * $.gasbackRatioNumerator) / GASBACK_RATIO_DENOMINATOR; - unchecked { - $.accrued += ethFromGas - ethToGive; - } uint256 selfBalance = address(this).balance; // If the contract has insufficient ETH, try to pull from the base fee vault. @@ -247,6 +220,10 @@ contract Gasback { gasToBurn = 0; } + unchecked { + $.accrued += ethFromGas - ethToGive; + } + /// @solidity memory-safe-assembly assembly { if gasToBurn { diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol new file mode 100644 index 0000000..59f6fef --- /dev/null +++ b/src/ShapePaymentSplitter.sol @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +/** + * @title ShapePaymentSplitter + * @dev This contract, forked from OpenZeppelin's PaymentSplitter, allows for splitting Ether payments among a group of accounts. + * It has been modified by Shape to remove ERC20 interactions, focusing solely on Ether distribution. + * + * The split can be in equal parts or in any other arbitrary proportion, specified by assigning shares to each account. + * Each account can claim an amount proportional to their percentage of total shares. The share distribution is set at + * contract deployment and cannot be updated thereafter. + * + * ShapePaymentSplitter follows a _push payment_ model. Incoming Ether triggers an attempt to release funds to all payees. + * + * The sender of Ether to this contract does not need to be aware of the split mechanism, as it is handled transparently. + */ +contract ShapePaymentSplitter { + event PayeeAdded(address account, uint256 shares); + event PaymentReleased(address to, uint256 amount); + event PaymentReceived(address from, uint256 amount); + event PaymentFailed(address to, uint256 amount, bytes reason); + + error FailedToSendValue(); + error PayeesAndSharesLengthMismatch(); + error NoPayees(); + error AccountAlreadyHasShares(); + error AccountIsTheZeroAddress(); + error SharesAreZero(); + error AccountHasNoShares(); + error AccountIsNotDuePayment(); + error InsufficientBalance(); + + uint256 private _totalShares; + uint256 private _totalReleased; + + mapping(address => uint256) private _shares; + mapping(address => uint256) private _released; + address[] private _payees; + + /** + * @dev Creates an instance of `ShapePaymentSplitter` 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`. + */ + constructor(address[] memory payees_, uint256[] memory shares_) payable { + if (payees_.length != shares_.length) revert PayeesAndSharesLengthMismatch(); + if (payees_.length == 0) revert NoPayees(); + + for (uint256 i = 0; i < payees_.length; i++) { + _addPayee(payees_[i], shares_[i]); + } + } + + /** + * @dev The Ether received will be logged with {PaymentReceived} events. Note that these events are not fully + * reliable: it's possible for a contract to receive Ether without triggering this function. This only affects the + * reliability of the events, and not the actual splitting of Ether. + * + * To learn more about this see the Solidity documentation for + * https://solidity.readthedocs.io/en/latest/contracts.html#fallback-function[fallback + * functions]. + */ + receive() external payable { + _distribute(0, _payees.length); + emit PaymentReceived(msg.sender, msg.value); + } + + /** + * @dev Getter for the total shares held by payees. + */ + function totalShares() public view returns (uint256) { + return _totalShares; + } + + /** + * @dev Getter for the total amount of Ether already released. + */ + function totalReleased() public view returns (uint256) { + return _totalReleased; + } + + /** + * @dev Getter for the amount of shares held by an account. + */ + function shares(address account) public view returns (uint256) { + return _shares[account]; + } + + /** + * @dev Getter for the amount of Ether already released to a payee. + */ + function released(address account) public view returns (uint256) { + return _released[account]; + } + + /** + * @dev Getter for the address of the payee number `index`. + */ + function payee(uint256 index) public view returns (address) { + return _payees[index]; + } + + /** + * @dev Getter for the addresses of the payees. + */ + function payees() public view returns (address[] memory) { + return _payees; + } + + /** + * @dev Getter for the amount of payee's releasable Ether. + */ + function releasable(address account) public view returns (uint256) { + uint256 totalReceived = address(this).balance + totalReleased(); + return _pendingPayment(account, totalReceived, released(account)); + } + + /** + * @dev Attempts to release payments for a slice of payees, skipping zero-due payees and emitting failures instead of + * reverting on send failures. + */ + function distribute(uint256 start, uint256 end) public { + _distribute(start, end); + } + + /** + * @dev Triggers a transfer to `account` of the amount of Ether they are owed, according to their percentage of the + * total shares and their previous withdrawals. + */ + function release(address payable account) public { + if (_shares[account] == 0) revert AccountHasNoShares(); + + uint256 payment = releasable(account); + + if (payment == 0) revert AccountIsNotDuePayment(); + + // _totalReleased is the sum of all values in _released. + // If "_totalReleased += payment" does not overflow, then "_released[account] += payment" cannot overflow. + _totalReleased += payment; + unchecked { + _released[account] += payment; + } + + _sendValue(account, payment); + + emit PaymentReleased(account, payment); + } + + /** + * @dev internal logic for computing the pending payment of an `account` given the token historical balances and + * already released amounts. + */ + function _pendingPayment(address account, uint256 totalReceived, uint256 alreadyReleased) + private + view + returns (uint256) + { + return (totalReceived * _shares[account]) / _totalShares - alreadyReleased; + } + + /** + * @dev Attempt to pay a slice of payees without reverting the whole call. + * Skips zero-due accounts and emits failures for accounts that revert on receive. + */ + function _distribute(uint256 start, uint256 end) private { + uint256 payeesLength = _payees.length; + if (end > payeesLength) { + end = payeesLength; + } + if (start >= end) { + return; + } + + for (uint256 i = start; i < end; i++) { + address payable account = payable(_payees[i]); + uint256 payment = releasable(account); + if (payment == 0) { + continue; + } + + try this.release(account) {} + catch (bytes memory reason) { + emit PaymentFailed(account, payment, reason); + } + } + } + + /** + * @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 account, uint256 shares_) private { + if (account == address(0)) revert AccountIsTheZeroAddress(); + if (shares_ == 0) revert SharesAreZero(); + if (_shares[account] != 0) revert AccountAlreadyHasShares(); + + _payees.push(account); + _shares[account] = shares_; + _totalShares = _totalShares + shares_; + emit PayeeAdded(account, shares_); + } + + /** + * @dev Replacement for Solidity's `transfer`: sends `amount` wei to + * `recipient`, forwarding all available gas and reverting on errors. + * + * https://eips.ethereum.org/EIPS/eip-1884[EIP1884] increases the gas cost + * of certain opcodes, possibly making contracts go over the 2300 gas limit + * imposed by `transfer`, making them unable to receive funds via + * `transfer`. {sendValue} removes this limitation. + * + * https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/[Learn more]. + * + * IMPORTANT: because control is transferred to `recipient`, care must be + * taken to not create reentrancy vulnerabilities. Consider using + * {ReentrancyGuard} or the + * https://solidity.readthedocs.io/en/v0.8.20/security-considerations.html#use-the-checks-effects-interactions-pattern[checks-effects-interactions pattern]. + */ + function _sendValue(address payable recipient, uint256 amount) private { + if (address(this).balance < amount) { + revert InsufficientBalance(); + } + + (bool success,) = recipient.call{value: amount}(""); + if (!success) { + revert FailedToSendValue(); + } + } +} diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index 1710cc2..03463f1 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -75,30 +75,4 @@ contract GasbackTest is SoladyTest { assertTrue(success); assertEq(pranker.balance, 0); } - - function testConvertGasbackWithAccruedToAccruedRecipient() public { - address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - vm.prank(system); - gasback.setAccruedRecipient(address(42)); - - uint256 baseFee = 1 ether; - uint256 gasToBurn = 333; - - address pranker = address(111); - vm.fee(baseFee); - vm.deal(pranker, 1000 ether); - - vm.prank(pranker); - (bool success,) = address(gasback).call(abi.encode(gasToBurn)); - assertTrue(success); - - uint256 accrued = gasback.accrued(); - - assertNotEq(accrued, 0); - - vm.prank(pranker); - gasback.withdrawAccruedToAccruedRecipient(accrued); - - assertEq(address(42).balance, accrued); - } } diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol new file mode 100644 index 0000000..7317f5b --- /dev/null +++ b/test/ShapePaymentSplitter.t.sol @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; + +contract RejectingPayee { + receive() external payable { + revert("I reject ETH"); + } +} + +contract ReentrantPayee { + ShapePaymentSplitter public splitter; + bool public didReenter; + + function setSplitter(ShapePaymentSplitter splitter_) external { + splitter = splitter_; + } + + receive() external payable { + if (!didReenter) { + didReenter = true; + (bool success,) = address(splitter).call{value: 1}(""); + require(success, "reenter failed"); + } + } +} + +contract ShapePaymentSplitterTest is SoladyTest { + event PaymentFailed(address to, uint256 amount, bytes reason); + + ShapePaymentSplitter public splitter; + + /// @dev fuzz helpers + + // Struct to reduce stack depth in fuzz tests + struct FuzzTestState { + address[] fuzzPayees; + uint256[] fuzzShares; + uint256[] initialBalances; + uint256 totalSharesSum; + uint256 cumulativeTotalPaid; + ShapePaymentSplitter fuzzSplitter; + } + + function _createFuzzTestState(uint8 numPayees, uint256 addrOffset) + internal + returns (FuzzTestState memory state) + { + state.fuzzPayees = new address[](numPayees); + state.fuzzShares = new uint256[](numPayees); + state.initialBalances = new uint256[](numPayees); + + for (uint256 i = 0; i < numPayees; i++) { + state.fuzzPayees[i] = vm.addr(i + addrOffset); + state.fuzzShares[i] = (i % 100) + 1; + state.totalSharesSum += state.fuzzShares[i]; + } + + state.fuzzSplitter = new ShapePaymentSplitter(state.fuzzPayees, state.fuzzShares); + + for (uint256 i = 0; i < numPayees; i++) { + state.initialBalances[i] = state.fuzzPayees[i].balance; + } + } + + function _sendPaymentAndUpdateState(FuzzTestState memory state, uint256 paymentAmount) + internal + { + state.cumulativeTotalPaid += paymentAmount; + vm.deal(address(this), paymentAmount); + (bool success,) = address(state.fuzzSplitter).call{value: paymentAmount}(""); + assertTrue(success); + } + + function _verifyPayeeBalances(FuzzTestState memory state, uint8 numPayees) internal view { + for (uint256 i = 0; i < numPayees; i++) { + uint256 actualReceived = state.fuzzPayees[i].balance - state.initialBalances[i]; + uint256 expectedReceived = + (state.cumulativeTotalPaid * state.fuzzShares[i]) / state.totalSharesSum; + assertEq(actualReceived, expectedReceived); + } + } + + address[] public payees = new address[](3); + uint256[] public shares = new uint256[](3); + + uint256 private _deployerKey = 1; + + uint256 private _payee1Key = 2; + uint256 private _payee2Key = 3; + uint256 private _payee3Key = 4; + + address private deployer = vm.addr(_deployerKey); + + address private payee1 = vm.addr(_payee1Key); + address private payee2 = vm.addr(_payee2Key); + address private payee3 = vm.addr(_payee3Key); + + uint256 public shares1 = 48; + uint256 public shares2 = 42; + uint256 public shares3 = 10; + + function setUp() public { + payees[0] = payee1; + payees[1] = payee2; + payees[2] = payee3; + + shares[0] = shares1; + shares[1] = shares2; + shares[2] = shares3; + + splitter = new ShapePaymentSplitter(payees, shares); + } + + function test_read_public_variables() public { + assertEq(splitter.payees().length, 3); + assertEq(splitter.totalShares(), 100); + assertEq(splitter.shares(payee1), shares1); + assertEq(splitter.shares(payee2), shares2); + assertEq(splitter.shares(payee3), shares3); + assertEq(splitter.payee(0), payee1); + assertEq(splitter.payee(1), payee2); + assertEq(splitter.payee(2), payee3); + } + + function test_balances_after_payment() public { + uint256 paymentAmount = 10 ether; + + // Record balances before + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + // Send ETH to the splitter (triggers receive() which releases to all payees) + vm.deal(address(this), paymentAmount); + (bool success,) = address(splitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + // Record balances after + uint256 balanceAfter1 = payee1.balance; + uint256 balanceAfter2 = payee2.balance; + uint256 balanceAfter3 = payee3.balance; + + // Calculate expected amounts based on shares + uint256 totalShares = splitter.totalShares(); + uint256 expectedPayment1 = (paymentAmount * shares1) / totalShares; + uint256 expectedPayment2 = (paymentAmount * shares2) / totalShares; + uint256 expectedPayment3 = (paymentAmount * shares3) / totalShares; + + // Verify balance changes match expected payments + assertEq( + balanceAfter1 - balanceBefore1, expectedPayment1, "Payee1 received incorrect amount" + ); + assertEq( + balanceAfter2 - balanceBefore2, expectedPayment2, "Payee2 received incorrect amount" + ); + assertEq( + balanceAfter3 - balanceBefore3, expectedPayment3, "Payee3 received incorrect amount" + ); + + // Verify the exact amounts (48%, 42%, 10% of 10 ether) + assertEq(balanceAfter1 - balanceBefore1, 4.8 ether, "Payee1 should receive 4.8 ether"); + assertEq(balanceAfter2 - balanceBefore2, 4.2 ether, "Payee2 should receive 4.2 ether"); + assertEq(balanceAfter3 - balanceBefore3, 1 ether, "Payee3 should receive 1 ether"); + } + + function test_receive_allows_small_payment() public { + uint256 paymentAmount = 1 wei; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(this), paymentAmount); + (bool success,) = address(splitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertEq(payee1.balance, balanceBefore1); + assertEq(payee2.balance, balanceBefore2); + assertEq(payee3.balance, balanceBefore3); + assertEq(address(splitter).balance, paymentAmount); + } + + function test_receive_skips_failed_payee_emits_failure() public { + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(rejecter); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 50; + localShares[1] = 50; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(this), paymentAmount); + vm.expectEmit(true, true, true, true); + emit PaymentFailed( + address(rejecter), + 0.5 ether, + abi.encodeWithSelector(ShapePaymentSplitter.FailedToSendValue.selector) + ); + + (bool success,) = address(localSplitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(address(localSplitter).balance, 0.5 ether); + } + + function test_receive_allows_reentrant_payee() public { + ReentrantPayee reentrant = new ReentrantPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(reentrant); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 1; + localShares[1] = 1; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + reentrant.setSplitter(localSplitter); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(this), paymentAmount); + (bool success,) = address(localSplitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + assertTrue(reentrant.didReenter()); + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(address(localSplitter).balance, 1 wei); + } + + function test_distribute_noop_start_gte_end() public { + uint256 paymentAmount = 1 ether; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(splitter), paymentAmount); + splitter.distribute(2, 2); + + assertEq(payee1.balance, balanceBefore1); + assertEq(payee2.balance, balanceBefore2); + assertEq(payee3.balance, balanceBefore3); + assertEq(address(splitter).balance, paymentAmount); + } + + function test_distribute_clamps_end_to_payees_length() public { + uint256 paymentAmount = 1 ether; + + uint256 balanceBefore1 = payee1.balance; + uint256 balanceBefore2 = payee2.balance; + uint256 balanceBefore3 = payee3.balance; + + vm.deal(address(splitter), paymentAmount); + splitter.distribute(0, 10); + + uint256 totalShares = splitter.totalShares(); + uint256 expectedPayment1 = (paymentAmount * shares1) / totalShares; + uint256 expectedPayment2 = (paymentAmount * shares2) / totalShares; + uint256 expectedPayment3 = (paymentAmount * shares3) / totalShares; + + assertEq(payee1.balance - balanceBefore1, expectedPayment1); + assertEq(payee2.balance - balanceBefore2, expectedPayment2); + assertEq(payee3.balance - balanceBefore3, expectedPayment3); + assertEq(address(splitter).balance, 0); + } + + function test_distribute_invariants_with_failed_payee() public { + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory localPayees = new address[](2); + localPayees[0] = address(rejecter); + localPayees[1] = payee1; + + uint256[] memory localShares = new uint256[](2); + localShares[0] = 50; + localShares[1] = 50; + + ShapePaymentSplitter localSplitter = new ShapePaymentSplitter(localPayees, localShares); + + uint256 paymentAmount = 1 ether; + uint256 payee1Before = payee1.balance; + + vm.deal(address(localSplitter), paymentAmount); + localSplitter.distribute(0, 2); + + assertEq(payee1.balance - payee1Before, 0.5 ether); + assertEq(localSplitter.released(payee1), 0.5 ether); + assertEq(localSplitter.released(address(rejecter)), 0); + assertEq(localSplitter.totalReleased(), 0.5 ether); + assertEq(address(localSplitter).balance, 0.5 ether); + assertEq(localSplitter.releasable(address(rejecter)), 0.5 ether); + assertEq(localSplitter.releasable(payee1), 0); + } + + function testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { + numPayees = uint8(bound(numPayees, 1, 50)); + paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); + + FuzzTestState memory state = _createFuzzTestState(numPayees, 100); + + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); + + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees)); + } + + function testFuzz_balances_after_multiple_payments( + uint8 numPayees, + uint256[9] memory paymentAmounts + ) public { + numPayees = uint8(bound(numPayees, 1, 50)); + + FuzzTestState memory state = _createFuzzTestState(numPayees, 200); + + for (uint256 p = 0; p < 9; p++) { + uint256 paymentAmount = bound(paymentAmounts[p], 0.1 ether, 10 ether); + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); + } + + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees) * 9); + } + + /// @dev deployment revert tests + + function test_revert_deploy_empty_payees() public { + address[] memory emptyPayees = new address[](0); + uint256[] memory emptyShares = new uint256[](0); + + vm.expectRevert(ShapePaymentSplitter.NoPayees.selector); + new ShapePaymentSplitter(emptyPayees, emptyShares); + } + + function test_revert_deploy_length_mismatch_more_payees() public { + address[] memory morePayees = new address[](3); + morePayees[0] = payee1; + morePayees[1] = payee2; + morePayees[2] = payee3; + + uint256[] memory fewerShares = new uint256[](2); + fewerShares[0] = 50; + fewerShares[1] = 50; + + vm.expectRevert(ShapePaymentSplitter.PayeesAndSharesLengthMismatch.selector); + new ShapePaymentSplitter(morePayees, fewerShares); + } + + function test_revert_deploy_length_mismatch_more_shares() public { + address[] memory fewerPayees = new address[](2); + fewerPayees[0] = payee1; + fewerPayees[1] = payee2; + + uint256[] memory moreShares = new uint256[](3); + moreShares[0] = 40; + moreShares[1] = 40; + moreShares[2] = 20; + + vm.expectRevert(ShapePaymentSplitter.PayeesAndSharesLengthMismatch.selector); + new ShapePaymentSplitter(fewerPayees, moreShares); + } + + function test_revert_deploy_zero_address_payee() public { + address[] memory badPayees = new address[](2); + badPayees[0] = payee1; + badPayees[1] = address(0); + + uint256[] memory validShares = new uint256[](2); + validShares[0] = 50; + validShares[1] = 50; + + vm.expectRevert(ShapePaymentSplitter.AccountIsTheZeroAddress.selector); + new ShapePaymentSplitter(badPayees, validShares); + } + + function test_revert_deploy_zero_shares() public { + address[] memory validPayees = new address[](2); + validPayees[0] = payee1; + validPayees[1] = payee2; + + uint256[] memory badShares = new uint256[](2); + badShares[0] = 100; + badShares[1] = 0; + + vm.expectRevert(ShapePaymentSplitter.SharesAreZero.selector); + new ShapePaymentSplitter(validPayees, badShares); + } + + function test_revert_deploy_duplicate_payee() public { + address[] memory duplicatePayees = new address[](3); + duplicatePayees[0] = payee1; + duplicatePayees[1] = payee2; + duplicatePayees[2] = payee1; // duplicate + + uint256[] memory validShares = new uint256[](3); + validShares[0] = 40; + validShares[1] = 40; + validShares[2] = 20; + + vm.expectRevert(ShapePaymentSplitter.AccountAlreadyHasShares.selector); + new ShapePaymentSplitter(duplicatePayees, validShares); + } + + function test_revert_release_account_has_no_shares() public { + address nonPayee = vm.addr(999); + + vm.expectRevert(ShapePaymentSplitter.AccountHasNoShares.selector); + splitter.release(payable(nonPayee)); + } + + function test_revert_release_account_not_due_payment() public { + // No ETH sent to splitter, so payee1 has 0 releasable + vm.expectRevert(ShapePaymentSplitter.AccountIsNotDuePayment.selector); + splitter.release(payable(payee1)); + } + + function test_revert_release_insufficient_balance() public { + // Manipulate storage to create an impossible state where totalReleased > 0 but balance = 0 + // _totalReleased is at storage slot 1 + vm.store(address(splitter), bytes32(uint256(1)), bytes32(uint256(100 ether))); + + // Now releasable(payee1) = (0 + 100 ether) * 48 / 100 - 0 = 48 ether + // But balance is 0, so _sendValue will revert + vm.expectRevert(ShapePaymentSplitter.InsufficientBalance.selector); + splitter.release(payable(payee1)); + } + + function test_revert_release_failed_to_send_value() public { + // Create a contract that rejects ETH + RejectingPayee rejecter = new RejectingPayee(); + + address[] memory rejectorPayees = new address[](1); + rejectorPayees[0] = address(rejecter); + + uint256[] memory rejectorShares = new uint256[](1); + rejectorShares[0] = 100; + + ShapePaymentSplitter rejectorSplitter = + new ShapePaymentSplitter(rejectorPayees, rejectorShares); + + // Send ETH to the splitter - it should emit a failure but not revert + vm.deal(address(this), 1 ether); + (bool success,) = address(rejectorSplitter).call{value: 1 ether}(""); + assertTrue(success); + + // Direct release should still revert since the payee rejects ETH + vm.expectRevert(ShapePaymentSplitter.FailedToSendValue.selector); + rejectorSplitter.release(payable(address(rejecter))); + } +} diff --git a/test/utils/TestPlus.sol b/test/utils/TestPlus.sol index ea13e1d..ae8d095 100644 --- a/test/utils/TestPlus.sol +++ b/test/utils/TestPlus.sol @@ -553,11 +553,7 @@ contract TestPlus is Brutalizer { /// @dev Truncate the bytes to `n` bytes. /// Returns the result for function chaining. - function _truncateBytes(bytes memory b, uint256 n) - internal - pure - returns (bytes memory result) - { + function _truncateBytes(bytes memory b, uint256 n) internal pure returns (bytes memory result) { /// @solidity memory-safe-assembly assembly { if gt(mload(b), n) { mstore(b, n) }