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 504438e..fd2d3be 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -30,14 +30,12 @@ contract Gasback { // recipient of the base fee vault, it can be configured to auto-pull // funds from the base fee vault when it runs out of ETH. address baseFeeVault; - // The minimum balance of the base fee vault. - 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; + // The numerator for the share of the base fee vault. + uint256 baseFeeVaultShareNumerator; } /// @dev Returns a pointer to the storage struct. @@ -59,8 +57,7 @@ contract Gasback { $.gasbackRatioNumerator = 0.8 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; - $.minVaultBalance = 0.42 ether; - $.accruedRecipient = 0x4200000000000000000000000000000000000019; + $.baseFeeVaultShareNumerator = 600000000000000000; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -82,11 +79,6 @@ contract Gasback { return _getGasbackStorage().baseFeeVault; } - /// @dev The minimum balance of the base fee vault that allows a pull withdrawal. - function minVaultBalance() public view virtual returns (uint256) { - return _getGasbackStorage().minVaultBalance; - } - /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ /* ACCURAL FUNCTIONS */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -123,27 +115,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 */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -176,9 +147,10 @@ contract Gasback { return true; } - /// @dev Sets the minimum balance of the base fee vault. - function setMinVaultBalance(uint256 value) public onlySystemOrThis returns (bool) { - _getGasbackStorage().minVaultBalance = value; + /// @dev Sets the numerator for the share of the base fee vault. + function setBaseFeeVaultShareNumerator(uint256 value) public onlySystemOrThis returns (bool) { + require(value <= GASBACK_RATIO_DENOMINATOR); + _getGasbackStorage().baseFeeVaultShareNumerator = value; return true; } @@ -220,19 +192,17 @@ contract Gasback { uint256 selfBalance = address(this).balance; // If the contract has insufficient ETH, try to pull from the base fee vault. - if (ethToGive > selfBalance) { + if (ethToGive > selfBalance && block.basefee <= $.gasbackMaxBaseFee) { address vault = $.baseFeeVault; - uint256 minBalance = $.minVaultBalance; + uint256 shortfall = ethToGive - selfBalance; + uint256 vaultBalance = vault.balance; + uint256 expectedShare = + (vaultBalance * $.baseFeeVaultShareNumerator) / GASBACK_RATIO_DENOMINATOR; /// @solidity memory-safe-assembly assembly { - if extcodesize(vault) { - // If the vault has sufficient ETH, pull from it. - if gt(balance(vault), add(sub(ethToGive, selfBalance), minBalance)) { - mstore(0x00, 0x3ccfd60b) // `withdraw()`. - pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) - // Return ETH to vault to ensure that it has `minBalance`. - pop(call(gas(), vault, minBalance, 0x00, 0x00, 0x00, 0x00)) - } + if and(extcodesize(vault), iszero(lt(expectedShare, shortfall))) { + mstore(0x00, 0x3ccfd60b) // `withdraw()`. + pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) } } } 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..ea86f38 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -60,45 +60,4 @@ contract GasbackTest is SoladyTest { assertEq(pranker.balance, 0); } - function testConvertGasbackMinVaultBalance() public { - address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; - uint256 minVaultBalance = 50 ether; - vm.prank(system); - gasback.setMinVaultBalance(minVaultBalance); - - uint256 gasToBurn = 333; - - address pranker = address(111); - assertEq(pranker.balance, 0); - vm.prank(pranker); - (bool success,) = address(gasback).call(abi.encode(gasToBurn)); - 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))); + } +}