Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
d953dc4
add accruedRecipient, add test
j6i Oct 4, 2025
da33c4e
follow naming
j6i Oct 4, 2025
4d8b26f
update comment
j6i Oct 4, 2025
e3c9f43
Merge pull request #12 from shape-network/j6i/add-accruedRecipient
j6i Oct 4, 2025
dd69a02
format
j6i Oct 4, 2025
d0f180a
snapshot
j6i Oct 4, 2025
5eae3fd
Merge remote-tracking branch 'upstream/main'
j6i Jan 16, 2026
18c54d8
Create DeployGasback.s.sol
j6i Jan 17, 2026
ae5f745
Merge pull request #13 from shape-network/j6i/deploy-gasback-script
j6i Jan 17, 2026
f2fda19
use our own system address
j6i Jan 17, 2026
24364dd
Merge pull request #14 from shape-network/j6i/use-our-own-system-address
j6i Jan 17, 2026
0784ce1
add payment splitter
j6i Jan 24, 2026
ebfcdd3
Merge pull request #15 from shape-network/j6i/add-payment-splitter
j6i Jan 24, 2026
bb2db8e
release on receive
j6i Jan 24, 2026
dafc793
Merge pull request #16 from shape-network/j6i/release-on-recieve
j6i Jan 24, 2026
295849d
set up test
j6i Jan 27, 2026
59d3d63
update comment
j6i Jan 27, 2026
3f21332
bump sol version
j6i Jan 27, 2026
54cae0f
fix tests
j6i Jan 27, 2026
d05d42f
test basics
j6i Jan 27, 2026
6aa065d
fuzzing
j6i Jan 27, 2026
22d1e30
fail tests
j6i Jan 27, 2026
0192a04
more tests
j6i Jan 27, 2026
94c4e26
test
j6i Jan 27, 2026
8238212
remaining coverage
j6i Jan 27, 2026
ed972da
update snapshot
j6i Jan 27, 2026
c6dc3b3
Merge pull request #17 from shape-network/j6i/test-splitter
j6i Jan 27, 2026
7fcd47d
add deploy splitter
j6i Jan 27, 2026
a7347c2
Merge pull request #18 from shape-network/j6i/add-deploy-splitter
j6i Jan 27, 2026
a1c6080
push
j6i Jan 30, 2026
83547cd
add comments
j6i Jan 30, 2026
b9a5a80
Merge pull request #19 from shape-network/j6i/add-distribute-function
j6i Jan 30, 2026
270bb70
test distribute
j6i Jan 30, 2026
ecde32b
Merge pull request #20 from shape-network/j6i/test-distribute
j6i Jan 30, 2026
6e6a3ef
add invariant test
j6i Jan 30, 2026
e2657ed
Merge pull request #21 from shape-network/j6i/testing-invariants
j6i Jan 30, 2026
798fbc8
remove accruedRecipient
j6i Jan 31, 2026
1f34ddc
rm from test
j6i Jan 31, 2026
d192f5e
Merge pull request #22 from shape-network/j6i/remove-accruedRecipient
j6i Jan 31, 2026
140154e
move check
j6i Jan 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .gas-snapshot
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions script/DeployGasback.s.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
30 changes: 30 additions & 0 deletions script/DeployShapePaymentSplitter.s.sol
Original file line number Diff line number Diff line change
@@ -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]);
}
}
31 changes: 4 additions & 27 deletions src/Gasback.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -60,7 +58,6 @@ contract Gasback {
$.gasbackMaxBaseFee = type(uint256).max;
$.baseFeeVault = 0x4200000000000000000000000000000000000019;
$.minVaultBalance = 0.42 ether;
$.accruedRecipient = 0x4200000000000000000000000000000000000019;
}

/*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/
Expand Down Expand Up @@ -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 */
/*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/
Expand Down Expand Up @@ -217,9 +193,6 @@ contract Gasback {

uint256 ethFromGas = gasToBurn * block.basefee;
uint256 ethToGive = (ethFromGas * $.gasbackRatioNumerator) / GASBACK_RATIO_DENOMINATOR;
unchecked {
$.accrued += ethFromGas - ethToGive;
}

uint256 selfBalance = address(this).balance;
// If the contract has insufficient ETH, try to pull from the base fee vault.
Expand Down Expand Up @@ -247,6 +220,10 @@ contract Gasback {
gasToBurn = 0;
}

unchecked {
$.accrued += ethFromGas - ethToGive;
}

/// @solidity memory-safe-assembly
assembly {
if gasToBurn {
Expand Down
232 changes: 232 additions & 0 deletions src/ShapePaymentSplitter.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading