From d953dc47a02fdff2208bb18c31f9ac82c4b47402 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:39:41 -0700 Subject: [PATCH 01/28] add accruedRecipient, add test --- src/Gasback.sol | 22 ++++++++++++++++++++++ test/Gasback.t.sol | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/Gasback.sol b/src/Gasback.sol index 978507e..117e287 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -34,6 +34,8 @@ contract Gasback { uint256 minVaultBalance; // The amount of ETH accrued by taking a cut from the gas burned. uint256 accrued; + // The address to send accrued ETH to. + address accruedRecipient; // A mapping of addresses authorized to withdraw the accrued ETH. mapping(address => bool) accuralWithdrawers; } @@ -58,6 +60,7 @@ contract Gasback { $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; $.minVaultBalance = 0.42 ether; + $.accruedRecipient = 0x4200000000000000000000000000000000000019; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -120,6 +123,25 @@ 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 */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index 03463f1..3ccac5a 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -75,4 +75,30 @@ contract GasbackTest is SoladyTest { assertTrue(success); assertEq(pranker.balance, 0); } + + function testGasbackWithAccruedToAccruedRecipient() 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); + } } From da33c4ebf1417b81b1ba3008031122d1db8eb247 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:40:09 -0700 Subject: [PATCH 02/28] follow naming --- test/Gasback.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index 3ccac5a..aad4b7a 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -76,7 +76,7 @@ contract GasbackTest is SoladyTest { assertEq(pranker.balance, 0); } - function testGasbackWithAccruedToAccruedRecipient() public { + function testConvertGasbackWithAccruedToAccruedRecipient() public { address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; vm.prank(system); gasback.setAccruedRecipient(address(42)); From 4d8b26fd76477ef22a6d8575744579847934133e Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:46:55 -0700 Subject: [PATCH 03/28] update comment --- src/Gasback.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index 117e287..224713f 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -34,7 +34,7 @@ contract Gasback { uint256 minVaultBalance; // The amount of ETH accrued by taking a cut from the gas burned. uint256 accrued; - // The address to send accrued ETH to. + // The recipient of the accrued ETH. address accruedRecipient; // A mapping of addresses authorized to withdraw the accrued ETH. mapping(address => bool) accuralWithdrawers; From dd69a02216f007301e23e04a4c1d8fa870be233f Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:52:58 -0700 Subject: [PATCH 04/28] format --- src/Gasback.sol | 4 +++- test/Gasback.t.sol | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index 224713f..b7f3270 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -131,7 +131,9 @@ contract Gasback { address accruedRecipient = _getGasbackStorage().accruedRecipient; /// @solidity memory-safe-assembly assembly { - if iszero(call(gas(), accruedRecipient, amount, 0x00, 0x00, 0x00, 0x00)) { revert(0x00, 0x00) } + if iszero(call(gas(), accruedRecipient, amount, 0x00, 0x00, 0x00, 0x00)) { + revert(0x00, 0x00) + } } return true; } diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index aad4b7a..1710cc2 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -87,7 +87,7 @@ contract GasbackTest is SoladyTest { 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); From d0f180a91a27ceeaea62572d26a22551cd50d0dc Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 3 Oct 2025 20:53:23 -0700 Subject: [PATCH 05/28] snapshot --- .gas-snapshot | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.gas-snapshot b/.gas-snapshot index b3a3bea..152f635 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,8 +1,9 @@ -GasbackTest:testConvertGasback() (gas: 50450) -GasbackTest:testConvertGasback(uint256,uint256) (runs: 256, μ: 370473, ~: 135584) -GasbackTest:testConvertGasbackBaseFeeVault() (gas: 24310) -GasbackTest:testConvertGasbackMaxBaseFee() (gas: 21878) -GasbackTest:testConvertGasbackMinVaultBalance() (gas: 24154) -GasbackTest:test__codesize() (gas: 8178) +GasbackTest:testConvertGasback() (gas: 73039) +GasbackTest:testConvertGasback(uint256,uint256) (runs: 257, μ: 423506, ~: 308109) +GasbackTest:testConvertGasbackBaseFeeVault() (gas: 27070) +GasbackTest:testConvertGasbackMaxBaseFee() (gas: 44525) +GasbackTest:testConvertGasbackMinVaultBalance() (gas: 26953) +GasbackTest:testConvertGasbackWithAccruedToAccruedRecipient() (gas: 69305) +GasbackTest:test__codesize() (gas: 9846) SoladyTest:test__codesize() (gas: 4099) TestPlus:test__codesize() (gas: 393) \ No newline at end of file From 18c54d894acb579e0b90d57fe63e8ef2c7fc48cc Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:03:34 -0800 Subject: [PATCH 06/28] Create DeployGasback.s.sol --- script/DeployGasback.s.sol | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 script/DeployGasback.s.sol 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(); + } +} From f2fda19b32beae175da6ee64f7328d1d282d9a37 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:09:57 -0800 Subject: [PATCH 07/28] use our own system address --- src/Gasback.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index b7f3270..7e97c73 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -10,7 +10,7 @@ contract Gasback { /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ /// @dev The address authorized to configure the contract. - address internal constant _SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant _SYSTEM_ADDRESS = 0x761ba9f637aE875222f5042A1dC2Ab2Bef77C9DB; /// @dev The denominator of the gasback ratio. uint256 public constant GASBACK_RATIO_DENOMINATOR = 1 ether; From 0784ce1b5710a0e0b16629dce0f4294b1b195ac4 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:34:03 -0800 Subject: [PATCH 08/28] add payment splitter --- src/ShapePaymentSplitter.sol | 196 +++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 src/ShapePaymentSplitter.sol diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol new file mode 100644 index 0000000..6a42d3e --- /dev/null +++ b/src/ShapePaymentSplitter.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/** + * @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 _pull payment_ model. Payments are not automatically forwarded to accounts but are + * kept in this contract. The actual transfer is triggered as a separate step by calling the {release} function. + * + * 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); + + 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 { + 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 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 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(); + } + } +} \ No newline at end of file From bb2db8ebb97be3a1aa869bd792839f62b82c5c47 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 23 Jan 2026 19:38:45 -0800 Subject: [PATCH 09/28] release on receive --- src/ShapePaymentSplitter.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 6a42d3e..376c75e 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -63,6 +63,9 @@ contract ShapePaymentSplitter { * functions]. */ receive() external payable { + for (uint256 i = 0; i < _payees.length; i++) { + release(payable(_payees[i])); + } emit PaymentReceived(msg.sender, msg.value); } From 295849dba063912a36b87876ca01fa7dfbf577fb Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:18:23 -0800 Subject: [PATCH 10/28] set up test --- test/ShapePaymentSplitter.t.sol | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 test/ShapePaymentSplitter.t.sol diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol new file mode 100644 index 0000000..024d51b --- /dev/null +++ b/test/ShapePaymentSplitter.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; + +contract ShapePaymentSplitterTest is SoladyTest { + ShapePaymentSplitter public splitter; + + 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_Splitter() public { + assertEq(splitter.payeeCount(), 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); + } +} From 59d3d631cdca36a47b5b659dadcc9d6eb606a84f Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:21:40 -0800 Subject: [PATCH 11/28] update comment --- src/ShapePaymentSplitter.sol | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 376c75e..69b06db 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -10,8 +10,7 @@ pragma solidity 0.8.20; * 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 _pull payment_ model. Payments are not automatically forwarded to accounts but are - * kept in this contract. The actual transfer is triggered as a separate step by calling the {release} function. + * ShapePaymentSplitter follows a _push payment_ model. Payments are not automatically forwarded to accounts. * * The sender of Ether to this contract does not need to be aware of the split mechanism, as it is handled transparently. */ @@ -45,7 +44,8 @@ contract ShapePaymentSplitter { * duplicates in `payees`. */ constructor(address[] memory payees_, uint256[] memory shares_) payable { - if (payees_.length != shares_.length) revert PayeesAndSharesLengthMismatch(); + if (payees_.length != shares_.length) + revert PayeesAndSharesLengthMismatch(); if (payees_.length == 0) revert NoPayees(); for (uint256 i = 0; i < payees_.length; i++) { @@ -146,12 +146,13 @@ contract ShapePaymentSplitter { * @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; + function _pendingPayment( + address account, + uint256 totalReceived, + uint256 alreadyReleased + ) private view returns (uint256) { + return + (totalReceived * _shares[account]) / _totalShares - alreadyReleased; } /** @@ -191,9 +192,9 @@ contract ShapePaymentSplitter { revert InsufficientBalance(); } - (bool success,) = recipient.call{value: amount}(""); + (bool success, ) = recipient.call{value: amount}(""); if (!success) { revert FailedToSendValue(); } } -} \ No newline at end of file +} From 3f21332dd5f0bc1d2d916ae4dac96ea6b970b45f Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:25:48 -0800 Subject: [PATCH 12/28] bump sol version --- foundry.toml | 2 +- src/ShapePaymentSplitter.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 69b06db..d9ebe0d 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.20; +pragma solidity 0.8.28; /** * @title ShapePaymentSplitter From 54cae0f162ff575a7aae05c14e62d99adc1902c9 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:32:07 -0800 Subject: [PATCH 13/28] fix tests --- src/Gasback.sol | 2 +- src/ShapePaymentSplitter.sol | 18 +++++++++--------- test/ShapePaymentSplitter.t.sol | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index 7e97c73..b7f3270 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -10,7 +10,7 @@ contract Gasback { /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ /// @dev The address authorized to configure the contract. - address internal constant _SYSTEM_ADDRESS = 0x761ba9f637aE875222f5042A1dC2Ab2Bef77C9DB; + address internal constant _SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; /// @dev The denominator of the gasback ratio. uint256 public constant GASBACK_RATIO_DENOMINATOR = 1 ether; diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index d9ebe0d..854cc6b 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -44,8 +44,9 @@ contract ShapePaymentSplitter { * duplicates in `payees`. */ constructor(address[] memory payees_, uint256[] memory shares_) payable { - if (payees_.length != shares_.length) + if (payees_.length != shares_.length) { revert PayeesAndSharesLengthMismatch(); + } if (payees_.length == 0) revert NoPayees(); for (uint256 i = 0; i < payees_.length; i++) { @@ -146,13 +147,12 @@ contract ShapePaymentSplitter { * @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; + function _pendingPayment(address account, uint256 totalReceived, uint256 alreadyReleased) + private + view + returns (uint256) + { + return (totalReceived * _shares[account]) / _totalShares - alreadyReleased; } /** @@ -192,7 +192,7 @@ contract ShapePaymentSplitter { revert InsufficientBalance(); } - (bool success, ) = recipient.call{value: amount}(""); + (bool success,) = recipient.call{value: amount}(""); if (!success) { revert FailedToSendValue(); } diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index 024d51b..a173dbd 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -38,8 +38,8 @@ contract ShapePaymentSplitterTest is SoladyTest { splitter = new ShapePaymentSplitter(payees, shares); } - function test_Splitter() public { - assertEq(splitter.payeeCount(), 3); + 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); From d05d42f581e0d4936964f114e69a92257a8920a6 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:48:40 -0800 Subject: [PATCH 14/28] test basics --- test/ShapePaymentSplitter.t.sol | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index a173dbd..9a019d8 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -48,4 +48,88 @@ contract ShapePaymentSplitterTest is SoladyTest { 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 testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { + // Bound inputs to reasonable ranges + numPayees = uint8(bound(numPayees, 1, 50)); + paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); + + // Create dynamic arrays for payees and shares + address[] memory fuzzPayees = new address[](numPayees); + uint256[] memory fuzzShares = new uint256[](numPayees); + uint256[] memory balancesBefore = new uint256[](numPayees); + + uint256 totalSharesSum = 0; + + // Generate payees and shares + for (uint256 i = 0; i < numPayees; i++) { + // Generate unique addresses using index + 100 to avoid collisions with existing test addresses + fuzzPayees[i] = vm.addr(i + 100); + // Assign shares between 1 and 100 based on index (deterministic for reproducibility) + fuzzShares[i] = (i % 100) + 1; + totalSharesSum += fuzzShares[i]; + } + + // Deploy new splitter with fuzzed payees and shares + ShapePaymentSplitter fuzzSplitter = new ShapePaymentSplitter(fuzzPayees, fuzzShares); + + // Record balances before + for (uint256 i = 0; i < numPayees; i++) { + balancesBefore[i] = fuzzPayees[i].balance; + } + + // Send ETH to the splitter + vm.deal(address(this), paymentAmount); + (bool success,) = address(fuzzSplitter).call{value: paymentAmount}(""); + assertTrue(success, "Payment to splitter failed"); + + // Verify balance changes for each payee + for (uint256 i = 0; i < numPayees; i++) { + uint256 balanceAfter = fuzzPayees[i].balance; + uint256 expectedPayment = (paymentAmount * fuzzShares[i]) / totalSharesSum; + assertEq( + balanceAfter - balancesBefore[i], + expectedPayment, + string.concat("Payee ", vm.toString(i), " received incorrect amount") + ); + } + + // Verify splitter contract has no remaining balance (or only dust from rounding) + assertLe(address(fuzzSplitter).balance, numPayees, "Splitter should have minimal remaining balance"); + } } From 6aa065dabbb6a2e81fbd0c42ea769eb74cd6a9b5 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:23:57 -0800 Subject: [PATCH 15/28] fuzzing --- test/ShapePaymentSplitter.t.sol | 115 +++++++++++++++++++++----------- test/utils/TestPlus.sol | 6 +- 2 files changed, 77 insertions(+), 44 deletions(-) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index 9a019d8..dec1747 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -7,6 +7,57 @@ import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; contract ShapePaymentSplitterTest is SoladyTest { 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); @@ -74,9 +125,15 @@ contract ShapePaymentSplitterTest is SoladyTest { 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"); + 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"); @@ -85,51 +142,31 @@ contract ShapePaymentSplitterTest is SoladyTest { } function testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { - // Bound inputs to reasonable ranges numPayees = uint8(bound(numPayees, 1, 50)); paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); - // Create dynamic arrays for payees and shares - address[] memory fuzzPayees = new address[](numPayees); - uint256[] memory fuzzShares = new uint256[](numPayees); - uint256[] memory balancesBefore = new uint256[](numPayees); + FuzzTestState memory state = _createFuzzTestState(numPayees, 100); - uint256 totalSharesSum = 0; + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); - // Generate payees and shares - for (uint256 i = 0; i < numPayees; i++) { - // Generate unique addresses using index + 100 to avoid collisions with existing test addresses - fuzzPayees[i] = vm.addr(i + 100); - // Assign shares between 1 and 100 based on index (deterministic for reproducibility) - fuzzShares[i] = (i % 100) + 1; - totalSharesSum += fuzzShares[i]; - } - - // Deploy new splitter with fuzzed payees and shares - ShapePaymentSplitter fuzzSplitter = new ShapePaymentSplitter(fuzzPayees, fuzzShares); + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees)); + } - // Record balances before - for (uint256 i = 0; i < numPayees; i++) { - balancesBefore[i] = fuzzPayees[i].balance; - } + function testFuzz_balances_after_multiple_payments( + uint8 numPayees, + uint256[9] memory paymentAmounts + ) public { + numPayees = uint8(bound(numPayees, 1, 50)); - // Send ETH to the splitter - vm.deal(address(this), paymentAmount); - (bool success,) = address(fuzzSplitter).call{value: paymentAmount}(""); - assertTrue(success, "Payment to splitter failed"); + FuzzTestState memory state = _createFuzzTestState(numPayees, 200); - // Verify balance changes for each payee - for (uint256 i = 0; i < numPayees; i++) { - uint256 balanceAfter = fuzzPayees[i].balance; - uint256 expectedPayment = (paymentAmount * fuzzShares[i]) / totalSharesSum; - assertEq( - balanceAfter - balancesBefore[i], - expectedPayment, - string.concat("Payee ", vm.toString(i), " received incorrect amount") - ); + for (uint256 p = 0; p < 9; p++) { + uint256 paymentAmount = bound(paymentAmounts[p], 0.1 ether, 10 ether); + _sendPaymentAndUpdateState(state, paymentAmount); + _verifyPayeeBalances(state, numPayees); } - // Verify splitter contract has no remaining balance (or only dust from rounding) - assertLe(address(fuzzSplitter).balance, numPayees, "Splitter should have minimal remaining balance"); + assertLe(address(state.fuzzSplitter).balance, uint256(numPayees) * 9); } } 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) } From 22d1e3069418066b444310ea26e8f99c727bca11 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:32:06 -0800 Subject: [PATCH 16/28] fail tests --- test/ShapePaymentSplitter.t.sol | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index dec1747..321b375 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -169,4 +169,83 @@ contract ShapePaymentSplitterTest is SoladyTest { 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); + } } From 0192a0475326cb1e31b0c18347587a4add96f9b8 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:34:18 -0800 Subject: [PATCH 17/28] more tests --- test/ShapePaymentSplitter.t.sol | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index 321b375..21d9258 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -248,4 +248,17 @@ contract ShapePaymentSplitterTest is SoladyTest { 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)); + } } From 94c4e26ab0bee9dad414445c44065a7c4bf85e7e Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:40:21 -0800 Subject: [PATCH 18/28] test --- src/ShapePaymentSplitter.sol | 4 +--- test/ShapePaymentSplitter.t.sol | 11 +++++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 854cc6b..7634640 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -44,9 +44,7 @@ contract ShapePaymentSplitter { * duplicates in `payees`. */ constructor(address[] memory payees_, uint256[] memory shares_) payable { - if (payees_.length != shares_.length) { - revert PayeesAndSharesLengthMismatch(); - } + if (payees_.length != shares_.length) revert PayeesAndSharesLengthMismatch(); if (payees_.length == 0) revert NoPayees(); for (uint256 i = 0; i < payees_.length; i++) { diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index 21d9258..e1e1060 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -261,4 +261,15 @@ contract ShapePaymentSplitterTest is SoladyTest { 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)); + } } From 82382122f13df52fb9c74859e59e1f628e3218e6 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:44:45 -0800 Subject: [PATCH 19/28] remaining coverage --- test/ShapePaymentSplitter.t.sol | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index e1e1060..a1af845 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -4,6 +4,12 @@ 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 ShapePaymentSplitterTest is SoladyTest { ShapePaymentSplitter public splitter; @@ -272,4 +278,25 @@ contract ShapePaymentSplitterTest is SoladyTest { 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 will try to release to the rejecting payee + vm.deal(address(this), 1 ether); + (bool success, bytes memory returnData) = address(rejectorSplitter).call{value: 1 ether}(""); + assertEq(success, false); + assertEq(bytes4(returnData), ShapePaymentSplitter.FailedToSendValue.selector); + } } + From ed972da0304beec9e548bfe203067212de57f732 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:53:23 -0800 Subject: [PATCH 20/28] update snapshot --- .gas-snapshot | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 From 7fcd47d0fd28eedfd46262462c7b6fd8cae10761 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:01:04 -0800 Subject: [PATCH 21/28] add deploy splitter --- script/DeployShapePaymentSplitter.s.sol | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 script/DeployShapePaymentSplitter.s.sol 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]); + } +} From a1c60806638db5bf5829241bd97d256a9caab4cb Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:01:58 -0800 Subject: [PATCH 22/28] push --- src/ShapePaymentSplitter.sol | 37 +++++++++-- test/ShapePaymentSplitter.t.sol | 105 ++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 7634640..378c09f 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -10,7 +10,7 @@ pragma solidity 0.8.28; * 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. Payments are not automatically forwarded to accounts. + * 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. */ @@ -18,6 +18,7 @@ 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(); @@ -62,9 +63,7 @@ contract ShapePaymentSplitter { * functions]. */ receive() external payable { - for (uint256 i = 0; i < _payees.length; i++) { - release(payable(_payees[i])); - } + _distribute(0, _payees.length); emit PaymentReceived(msg.sender, msg.value); } @@ -118,6 +117,14 @@ contract ShapePaymentSplitter { 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. @@ -153,6 +160,28 @@ contract ShapePaymentSplitter { return (totalReceived * _shares[account]) / _totalShares - alreadyReleased; } + 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. diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index a1af845..4b67658 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -10,7 +10,26 @@ contract RejectingPayee { } } +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 @@ -147,6 +166,80 @@ contract ShapePaymentSplitterTest is SoladyTest { 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 testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { numPayees = uint8(bound(numPayees, 1, 50)); paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); @@ -292,11 +385,13 @@ contract ShapePaymentSplitterTest is SoladyTest { ShapePaymentSplitter rejectorSplitter = new ShapePaymentSplitter(rejectorPayees, rejectorShares); - // Send ETH to the splitter - it will try to release to the rejecting payee + // Send ETH to the splitter - it should emit a failure but not revert vm.deal(address(this), 1 ether); - (bool success, bytes memory returnData) = address(rejectorSplitter).call{value: 1 ether}(""); - assertEq(success, false); - assertEq(bytes4(returnData), ShapePaymentSplitter.FailedToSendValue.selector); + (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))); } } - From 83547cdd3a702ea8dd911711cb92ac49506a3941 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:06:15 -0800 Subject: [PATCH 23/28] add comments --- src/ShapePaymentSplitter.sol | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ShapePaymentSplitter.sol b/src/ShapePaymentSplitter.sol index 378c09f..59f6fef 100644 --- a/src/ShapePaymentSplitter.sol +++ b/src/ShapePaymentSplitter.sol @@ -160,6 +160,10 @@ contract ShapePaymentSplitter { 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) { @@ -176,7 +180,8 @@ contract ShapePaymentSplitter { continue; } - try this.release(account) {} catch (bytes memory reason) { + try this.release(account) {} + catch (bytes memory reason) { emit PaymentFailed(account, payment, reason); } } From 270bb7008debecc21b68a8cb61074dcce9313a06 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:11:36 -0800 Subject: [PATCH 24/28] test distribute --- test/ShapePaymentSplitter.t.sol | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index 4b67658..c9d63ce 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -240,6 +240,43 @@ contract ShapePaymentSplitterTest is SoladyTest { 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 testFuzz_balances_after_payment(uint8 numPayees, uint256 paymentAmount) public { numPayees = uint8(bound(numPayees, 1, 50)); paymentAmount = bound(paymentAmount, 1 ether, 1000 ether); From 6e6a3ef8ba94cf5bf992f8d9e62533372ff9bb5e Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:39:31 -0800 Subject: [PATCH 25/28] add invariant test --- test/ShapePaymentSplitter.t.sol | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/ShapePaymentSplitter.t.sol b/test/ShapePaymentSplitter.t.sol index c9d63ce..7317f5b 100644 --- a/test/ShapePaymentSplitter.t.sol +++ b/test/ShapePaymentSplitter.t.sol @@ -277,6 +277,34 @@ contract ShapePaymentSplitterTest is SoladyTest { 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); From 798fbc88f72ae010f159521ee5da02b60281e0e2 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:45:54 -0800 Subject: [PATCH 26/28] remove accruedRecipient --- src/Gasback.sol | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index b7f3270..978507e 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 */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ From 1f34ddc2b430aa088ac827217c2b29b13eb09668 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:46:31 -0800 Subject: [PATCH 27/28] rm from test --- test/Gasback.t.sol | 26 -------------------------- 1 file changed, 26 deletions(-) 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); - } } From 140154e14fe35e91015a1da8a080eac0122cafb9 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:48:41 -0800 Subject: [PATCH 28/28] move check --- src/Gasback.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index 978507e..4f90843 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -193,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. @@ -223,6 +220,10 @@ contract Gasback { gasToBurn = 0; } + unchecked { + $.accrued += ethFromGas - ethToGive; + } + /// @solidity memory-safe-assembly assembly { if gasToBurn {