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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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/48] 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 { From 5f77c11b8a016613cc0abfe66c8ee745aa195dae Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:33:26 -0800 Subject: [PATCH 29/48] rm minVaultBalance --- src/Gasback.sol | 24 ++---------------------- test/Gasback.t.sol | 15 --------------- 2 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index 4f90843..fb6333d 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -30,8 +30,6 @@ 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; // A mapping of addresses authorized to withdraw the accrued ETH. @@ -57,7 +55,6 @@ contract Gasback { $.gasbackRatioNumerator = 0.8 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; - $.minVaultBalance = 0.42 ether; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -79,11 +76,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 */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -152,12 +144,6 @@ 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; - return true; - } - /// @dev A noop function. function noop() public payable returns (bool) { return true; @@ -198,17 +184,11 @@ contract Gasback { // If the contract has insufficient ETH, try to pull from the base fee vault. if (ethToGive > selfBalance) { address vault = $.baseFeeVault; - uint256 minBalance = $.minVaultBalance; /// @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)) - } + mstore(0x00, 0x3ccfd60b) // `withdraw()`. + pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) } } } diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index 03463f1..ea86f38 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -60,19 +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); - } } From 97588e23ec03509a0604c0d795794e6a8ee5a4f2 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Wed, 4 Feb 2026 23:32:26 -0800 Subject: [PATCH 30/48] add baseFeeVaultShareNumerator --- src/Gasback.sol | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Gasback.sol b/src/Gasback.sol index fb6333d..fd2d3be 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -34,6 +34,8 @@ contract Gasback { uint256 accrued; // 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. @@ -55,6 +57,7 @@ contract Gasback { $.gasbackRatioNumerator = 0.8 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; + $.baseFeeVaultShareNumerator = 600000000000000000; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ @@ -144,6 +147,13 @@ contract Gasback { return true; } + /// @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; + } + /// @dev A noop function. function noop() public payable returns (bool) { return true; @@ -182,11 +192,15 @@ 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 shortfall = ethToGive - selfBalance; + uint256 vaultBalance = vault.balance; + uint256 expectedShare = + (vaultBalance * $.baseFeeVaultShareNumerator) / GASBACK_RATIO_DENOMINATOR; /// @solidity memory-safe-assembly assembly { - if extcodesize(vault) { + if and(extcodesize(vault), iszero(lt(expectedShare, shortfall))) { mstore(0x00, 0x3ccfd60b) // `withdraw()`. pop(call(gas(), vault, 0, 0x1c, 0x04, 0x00, 0x00)) } From c0d25eef508884fc21594855dca64c55e2f3cd0e Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:54:58 -0800 Subject: [PATCH 31/48] add GasbackExtendedTest --- test/GasbackExtended.t.sol | 511 +++++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 test/GasbackExtended.t.sol diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol new file mode 100644 index 0000000..85d4d27 --- /dev/null +++ b/test/GasbackExtended.t.sol @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +import "./utils/SoladyTest.sol"; +import {Gasback} from "../src/Gasback.sol"; + +contract RejectingReceiver { + receive() external payable { + revert(); + } +} + +contract RejectingCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 ethToGive) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + ethToGive = abi.decode(data, (uint256)); + } + + receive() external payable { + revert(); + } +} + +contract GasbackExtendedTest is SoladyTest { + address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant DEFAULT_BASE_FEE_VAULT = 0x4200000000000000000000000000000000000019; + uint256 internal constant DENOMINATOR = 1 ether; + + Gasback public gasback; + + function setUp() public { + gasback = new Gasback(); + } + + function _callFallback(address caller, uint256 gasToBurn) + internal + returns (bool success, uint256 ethToGive) + { + vm.prank(caller); + bytes memory data; + (success, data) = address(gasback).call(abi.encode(gasToBurn)); + if (success) { + ethToGive = abi.decode(data, (uint256)); + } + } + + function _accrueViaPassThrough(uint256 baseFee, uint256 gasToBurn) + internal + returns (uint256 ethFromGas) + { + ethFromGas = baseFee * gasToBurn; + vm.fee(baseFee); + (bool success,) = _callFallback(address(0xA11CE), gasToBurn); + assertTrue(success); + assertEq(gasback.accrued(), ethFromGas); + } + + function _configureBaseFeeVault(address vault, uint256 shareNumerator) internal { + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(vault); + gasback.setBaseFeeVaultShareNumerator(shareNumerator); + vm.stopPrank(); + } + + function test_constructorDefaults() public { + assertEq(gasback.gasbackRatioNumerator(), 0.8 ether); + assertEq(gasback.gasbackMaxBaseFee(), type(uint256).max); + assertEq(gasback.baseFeeVault(), DEFAULT_BASE_FEE_VAULT); + assertEq(gasback.accrued(), 0); + assertEq(gasback.GASBACK_RATIO_DENOMINATOR(), DENOMINATOR); + assertFalse(gasback.isAuthorizedAccuralWithdrawer(address(this))); + } + + function test_receiveAcceptsEth() public { + vm.deal(address(this), 1 ether); + (bool success,) = address(gasback).call{value: 1 ether}(""); + assertTrue(success); + assertEq(address(gasback).balance, 1 ether); + } + + function test_noopAcceptsEthAndReturnsTrue() public { + vm.deal(address(this), 1 ether); + bool success = gasback.noop{value: 1 ether}(); + assertTrue(success); + assertEq(address(gasback).balance, 1 ether); + } + + function test_revert_onlySystemOrThis() public { + address user = address(0xBEEF); + vm.startPrank(user); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(1); + vm.expectRevert(); + gasback.setGasbackMaxBaseFee(1); + vm.expectRevert(); + gasback.setBaseFeeVault(address(1)); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(1); + vm.expectRevert(); + gasback.setAccuralWithdrawer(address(1), true); + vm.expectRevert(); + gasback.withdraw(address(1), 1); + vm.stopPrank(); + } + + function test_systemCanCallAdminFunctions() public { + vm.deal(address(gasback), 1 ether); + + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setGasbackRatioNumerator(0.9 ether)); + assertTrue(gasback.setGasbackMaxBaseFee(123)); + assertTrue(gasback.setBaseFeeVault(address(0x1234))); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.7 ether)); + assertTrue(gasback.setAccuralWithdrawer(address(0x99), true)); + assertTrue(gasback.withdraw(address(0xA11CE), 0.2 ether)); + vm.stopPrank(); + + assertEq(gasback.gasbackRatioNumerator(), 0.9 ether); + assertEq(gasback.gasbackMaxBaseFee(), 123); + assertEq(gasback.baseFeeVault(), address(0x1234)); + assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(0x99))); + assertEq(address(0xA11CE).balance, 0.2 ether); + } + + function test_selfCanCallAdminFunctions() public { + vm.deal(address(gasback), 1 ether); + + vm.prank(address(gasback)); + assertTrue(gasback.setGasbackRatioNumerator(1 ether)); + vm.prank(address(gasback)); + assertTrue(gasback.setGasbackMaxBaseFee(77)); + vm.prank(address(gasback)); + assertTrue(gasback.setBaseFeeVault(address(0x4321))); + vm.prank(address(gasback)); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.5 ether)); + vm.prank(address(gasback)); + assertTrue(gasback.setAccuralWithdrawer(address(this), true)); + vm.prank(address(gasback)); + assertTrue(gasback.withdraw(address(0xB0B), 0.25 ether)); + + assertEq(gasback.gasbackRatioNumerator(), 1 ether); + assertEq(gasback.gasbackMaxBaseFee(), 77); + assertEq(gasback.baseFeeVault(), address(0x4321)); + assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(this))); + assertEq(address(0xB0B).balance, 0.25 ether); + } + + function test_revert_setGasbackRatioNumeratorAboveDenominator() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(DENOMINATOR + 1); + } + + function test_revert_setBaseFeeVaultShareNumeratorAboveDenominator() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR + 1); + } + + function test_revert_fallbackInvalidCalldataLength() public { + vm.prank(address(1)); + (bool success0,) = address(gasback).call(new bytes(1)); + assertFalse(success0); + + vm.prank(address(1)); + (bool success1,) = address(gasback).call(new bytes(31)); + assertFalse(success1); + + vm.prank(address(1)); + (bool success2,) = address(gasback).call(abi.encode(uint256(1), uint256(2))); + assertFalse(success2); + } + + function test_fallbackPaysCallerAndAccruesCut() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackWithZeroRatioAccruesAll() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0); + + uint256 baseFee = 13; + uint256 gasToBurn = 101; + uint256 ethFromGas = baseFee * gasToBurn; + + vm.fee(baseFee); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + } + + function test_fallbackPassThroughWhenInsufficientBalance() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive - 1); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + assertEq(address(gasback).balance, ethToGive - 1); + } + + function test_fallbackPassThroughWhenBaseFeeAboveMax() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackMaxBaseFee(baseFee - 1); + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + assertEq(address(gasback).balance, ethToGive); + } + + function test_revert_fallbackOnEthFromGasOverflow() public { + vm.fee(2); + vm.prank(address(1)); + (bool success,) = address(gasback).call(abi.encode(type(uint256).max)); + assertFalse(success); + } + + function test_revert_fallbackWhenCannotBurnRequestedGas() public { + vm.fee(0); + vm.prank(address(1)); + (bool success,) = address(gasback).call(abi.encode(type(uint256).max)); + assertFalse(success); + } + + function test_fallbackAccruedIsAdditiveAcrossCalls() public { + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(address(gasback), 2 * ethToGive); + vm.fee(baseFee); + + (bool success0,) = _callFallback(address(0x1111), gasToBurn); + (bool success1,) = _callFallback(address(0x2222), gasToBurn); + + assertTrue(success0); + assertTrue(success1); + assertEq(address(0x1111).balance, ethToGive); + assertEq(address(0x2222).balance, ethToGive); + assertEq(gasback.accrued(), 2 * (ethFromGas - ethToGive)); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackPullsFromBaseFeeVaultWhenShareCoversShortfall() public { + address vault = address(0xA001); + vm.etch(vault, hex"33ff00"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(vault, ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(vault.balance, 0); + } + + function test_fallbackDoesNotPullFromVaultWhenExpectedShareBelowShortfall() public { + address vault = address(0xA002); + vm.etch(vault, hex"60016000550000"); + _configureBaseFeeVault(vault, 0.5 ether); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + + vm.deal(vault, 1000); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + assertEq(uint256(vm.load(vault, bytes32(0))), 0); + assertEq(vault.balance, 1000); + } + + function test_fallbackAttemptedVaultPullWithoutTransferFallsBackToPassThrough() public { + address vault = address(0xA003); + vm.etch(vault, hex"60016000550000"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + + vm.deal(vault, 1000); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + assertEq(uint256(vm.load(vault, bytes32(0))), 1); + assertEq(vault.balance, 1000); + } + + function test_fallbackHighBaseFeeSkipsVaultPull() public { + address vault = address(0xA004); + vm.etch(vault, hex"60016000550000"); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(vault); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR); + gasback.setGasbackMaxBaseFee(baseFee - 1); + vm.stopPrank(); + + vm.deal(vault, 1000); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(gasback.accrued(), ethFromGas); + assertEq(uint256(vm.load(vault, bytes32(0))), 0); + } + + function test_fallbackForceSendsWhenCallerRejectsEth() public { + RejectingCaller caller = new RejectingCaller(); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, ethToGive); + assertEq(address(caller).balance, ethToGive); + assertEq(gasback.accrued(), ethFromGas - ethToGive); + } + + function test_revert_withdrawWhenRecipientRejectsEth() public { + RejectingReceiver rejector = new RejectingReceiver(); + vm.deal(address(gasback), 1 ether); + + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.withdraw(address(rejector), 0.1 ether); + + assertEq(address(gasback).balance, 1 ether); + } + + function test_revert_withdrawWhenAmountExceedsBalance() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.withdraw(address(1), 1); + } + + function test_withdrawAccruedAuthorizedSuccess() public { + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + address recipient = address(0xCAFE); + uint256 before = recipient.balance; + bool success = gasback.withdrawAccrued(recipient, 400); + + assertTrue(success); + assertEq(recipient.balance - before, 400); + assertEq(gasback.accrued(), accruedAmount - 400); + } + + function test_revert_withdrawAccruedUnauthorized() public { + _accrueViaPassThrough(10, 100); + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + } + + function test_revert_withdrawAccruedUnderflow() public { + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), accruedAmount + 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function test_revert_withdrawAccruedWhenRecipientRejectsEth() public { + RejectingReceiver rejector = new RejectingReceiver(); + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(rejector), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function test_revert_withdrawAccruedWhenBalanceInsufficient() public { + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(0xCAFE), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + + function testFuzz_fallbackPayoutAndAccrualWithSufficientBalance( + uint256 baseFee, + uint256 gasToBurn, + uint256 ratioNumerator + ) public { + baseFee = _bound(baseFee, 0, 1e12); + gasToBurn = _bound(gasToBurn, 0, 20000); + ratioNumerator = _bound(ratioNumerator, 0, DENOMINATOR); + + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(ratioNumerator); + + uint256 ethFromGas = baseFee * gasToBurn; + uint256 expectedEthToGive = (ethFromGas * ratioNumerator) / DENOMINATOR; + + vm.deal(address(gasback), expectedEthToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, expectedEthToGive); + assertEq(address(0xB0B).balance, expectedEthToGive); + assertEq(gasback.accrued(), ethFromGas - expectedEthToGive); + } + + function testFuzz_fallbackPassThroughOnInsufficientBalance(uint256 baseFee, uint256 gasToBurn) + public + { + baseFee = _bound(baseFee, 1, 1e12); + gasToBurn = _bound(gasToBurn, 1, 20000); + + uint256 ethFromGas = baseFee * gasToBurn; + + vm.fee(baseFee); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + } +} From bf52f42a3ee8e96e5aa4e00204cb15f625f74a8c Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:20:52 -0800 Subject: [PATCH 32/48] add coverage --- test/GasbackExtended.t.sol | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index 85d4d27..92480fc 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -207,6 +207,22 @@ contract GasbackExtendedTest is SoladyTest { assertEq(gasback.accrued(), ethFromGas); } + function test_fallbackZeroGasToBurnNoops() public { + vm.deal(address(gasback), 1 ether); + vm.fee(123); + + uint256 beforeBalance = address(gasback).balance; + uint256 beforeAccrued = gasback.accrued(); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), 0); + + assertTrue(success); + assertEq(returnedEthToGive, 0); + assertEq(address(0xB0B).balance, 0); + assertEq(address(gasback).balance, beforeBalance); + assertEq(gasback.accrued(), beforeAccrued); + } + function test_fallbackPassThroughWhenInsufficientBalance() public { uint256 baseFee = 10; uint256 gasToBurn = 100; @@ -428,6 +444,20 @@ contract GasbackExtendedTest is SoladyTest { gasback.withdrawAccrued(address(this), 1); } + function test_setAccuralWithdrawerRevokeBlocksWithdrawAccrued() public { + _accrueViaPassThrough(10, 100); + + vm.startPrank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + gasback.setAccuralWithdrawer(address(this), false); + vm.stopPrank(); + + assertFalse(gasback.isAuthorizedAccuralWithdrawer(address(this))); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + } + function test_revert_withdrawAccruedUnderflow() public { uint256 accruedAmount = _accrueViaPassThrough(10, 100); From e824a281a30cfeab45dde5d9cfec76d72357b54f Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:47:00 -0800 Subject: [PATCH 33/48] tests --- test/GasbackExtended.t.sol | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index 92480fc..019e137 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -318,6 +318,27 @@ contract GasbackExtendedTest is SoladyTest { assertEq(vault.balance, 0); } + function test_fallbackPullsFromVaultWhenExpectedShareEqualsShortfall() public { + address vault = address(0xA005); + vm.etch(vault, hex"33ff00"); + _configureBaseFeeVault(vault, DENOMINATOR); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(vault, ethToGive); + vm.fee(baseFee); + + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); + + assertTrue(success); + assertEq(returnedEthToGive, ethToGive); + assertEq(address(0xB0B).balance, ethToGive); + assertEq(vault.balance, 0); + } + function test_fallbackDoesNotPullFromVaultWhenExpectedShareBelowShortfall() public { address vault = address(0xA002); vm.etch(vault, hex"60016000550000"); @@ -538,4 +559,11 @@ contract GasbackExtendedTest is SoladyTest { assertEq(address(0xB0B).balance, 0); assertEq(gasback.accrued(), ethFromGas); } + + function testRevertSetBaseFeeVaultShareNumeratorAboveDenominator() public { + address system = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + vm.prank(system); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(1 ether + 1); + } } From 26afb17efc9af1b4416b8b90b65308e9aed533fa Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:55:01 -0800 Subject: [PATCH 34/48] tests --- test/GasbackExtended.t.sol | 46 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index 019e137..c3a9942 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -22,6 +22,16 @@ contract RejectingCaller { } } +contract AcceptingCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 ethToGive) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + ethToGive = abi.decode(data, (uint256)); + } + + receive() external payable {} +} + contract GasbackExtendedTest is SoladyTest { address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; address internal constant DEFAULT_BASE_FEE_VAULT = 0x4200000000000000000000000000000000000019; @@ -426,6 +436,42 @@ contract GasbackExtendedTest is SoladyTest { assertEq(gasback.accrued(), ethFromGas - ethToGive); } + function test_fallbackPaysAcceptingContractCaller() public { + AcceptingCaller caller = new AcceptingCaller(); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + + vm.deal(address(gasback), ethToGive); + vm.fee(baseFee); + + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, ethToGive); + assertEq(address(caller).balance, ethToGive); + assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(address(gasback).balance, 0); + } + + function test_fallbackSkipsEthSendWhenCallerRejectsAndEthToGiveIsZero() public { + RejectingCaller caller = new RejectingCaller(); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0); + + uint256 baseFee = 10; + uint256 gasToBurn = 100; + uint256 ethFromGas = baseFee * gasToBurn; + + vm.fee(baseFee); + uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); + + assertEq(returnedEthToGive, 0); + assertEq(address(caller).balance, 0); + assertEq(gasback.accrued(), ethFromGas); + } + function test_revert_withdrawWhenRecipientRejectsEth() public { RejectingReceiver rejector = new RejectingReceiver(); vm.deal(address(gasback), 1 ether); From fa662130e69c53a094f770b771948fc41abe427e Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:01:01 -0800 Subject: [PATCH 35/48] tests --- test/GasbackExtended.t.sol | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index c3a9942..dd82a26 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -511,6 +511,32 @@ contract GasbackExtendedTest is SoladyTest { gasback.withdrawAccrued(address(this), 1); } + function test_withdrawAccruedRequireBranchTrue_authorized() public { + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + address recipient = address(0xD00D); + uint256 beforeBalance = recipient.balance; + bool success = gasback.withdrawAccrued(recipient, 1); + + assertTrue(success); + assertEq(recipient.balance, beforeBalance + 1); + assertEq(gasback.accrued(), accruedAmount - 1); + } + + function test_withdrawAccruedRequireBranchFalse_unauthorizedReverts() public { + uint256 accruedAmount = _accrueViaPassThrough(10, 100); + vm.deal(address(gasback), accruedAmount); + + vm.expectRevert(); + gasback.withdrawAccrued(address(0xD00D), 1); + + assertEq(gasback.accrued(), accruedAmount); + } + function test_setAccuralWithdrawerRevokeBlocksWithdrawAccrued() public { _accrueViaPassThrough(10, 100); From 90026918999ff187e740c3279b0cbe62f114c1a7 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:40:47 -0800 Subject: [PATCH 36/48] enforce solvency invariant --- script/Delegate7702.s.sol | 2 +- src/Gasback.sol | 15 +++++++++--- test/Gasback.t.sol | 5 +++- test/GasbackExtended.t.sol | 50 ++++++++++++++++++++++++++------------ 4 files changed, 51 insertions(+), 21 deletions(-) diff --git a/script/Delegate7702.s.sol b/script/Delegate7702.s.sol index f87e87a..bf02656 100644 --- a/script/Delegate7702.s.sol +++ b/script/Delegate7702.s.sol @@ -23,7 +23,7 @@ contract Delegate7702Script is Script { vm.startBroadcast(privateKey); Gasback(payable(deployer)).noop(); - Gasback(payable(deployer)).setGasbackRatioNumerator(900000000000000000); + Gasback(payable(deployer)).setGasbackRatioNumerator(600000000000000000); Gasback(payable(deployer)).setGasbackMaxBaseFee(type(uint256).max); Gasback(payable(deployer)).setBaseFeeVault(0x4200000000000000000000000000000000000019); vm.stopBroadcast(); diff --git a/src/Gasback.sol b/src/Gasback.sol index fd2d3be..7131e94 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -54,7 +54,7 @@ contract Gasback { constructor() payable { GasbackStorage storage $ = _getGasbackStorage(); - $.gasbackRatioNumerator = 0.8 ether; + $.gasbackRatioNumerator = 0.6 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; $.baseFeeVaultShareNumerator = 600000000000000000; @@ -79,6 +79,11 @@ contract Gasback { return _getGasbackStorage().baseFeeVault; } + /// @dev The numerator for the share of the base fee vault. + function baseFeeVaultShareNumerator() public view virtual returns (uint256) { + return _getGasbackStorage().baseFeeVaultShareNumerator; + } + /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ /* ACCURAL FUNCTIONS */ /*-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»-»*/ @@ -130,8 +135,10 @@ contract Gasback { /// @dev Sets the numerator for the gasback ratio. function setGasbackRatioNumerator(uint256 value) public onlySystemOrThis returns (bool) { + GasbackStorage storage $ = _getGasbackStorage(); require(value <= GASBACK_RATIO_DENOMINATOR); - _getGasbackStorage().gasbackRatioNumerator = value; + require(value <= $.baseFeeVaultShareNumerator); + $.gasbackRatioNumerator = value; return true; } @@ -149,8 +156,10 @@ contract Gasback { /// @dev Sets the numerator for the share of the base fee vault. function setBaseFeeVaultShareNumerator(uint256 value) public onlySystemOrThis returns (bool) { + GasbackStorage storage $ = _getGasbackStorage(); require(value <= GASBACK_RATIO_DENOMINATOR); - _getGasbackStorage().baseFeeVaultShareNumerator = value; + require(value >= $.gasbackRatioNumerator); + $.baseFeeVaultShareNumerator = value; return true; } diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index ea86f38..b20cb71 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -21,7 +21,10 @@ contract GasbackTest is SoladyTest { vm.prank(pranker); (bool success,) = address(gasback).call(abi.encode(gasToBurn)); assertTrue(success); - assertEq(pranker.balance, (gasToBurn * baseFee * 0.8 ether) / 1 ether); + assertEq( + pranker.balance, + (gasToBurn * baseFee * gasback.gasbackRatioNumerator()) / gasback.GASBACK_RATIO_DENOMINATOR() + ); } function testConvertGasback() public { diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index dd82a26..09f8d87 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -74,9 +74,10 @@ contract GasbackExtendedTest is SoladyTest { } function test_constructorDefaults() public { - assertEq(gasback.gasbackRatioNumerator(), 0.8 ether); + assertEq(gasback.gasbackRatioNumerator(), 0.6 ether); assertEq(gasback.gasbackMaxBaseFee(), type(uint256).max); assertEq(gasback.baseFeeVault(), DEFAULT_BASE_FEE_VAULT); + assertEq(gasback.baseFeeVaultShareNumerator(), 0.6 ether); assertEq(gasback.accrued(), 0); assertEq(gasback.GASBACK_RATIO_DENOMINATOR(), DENOMINATOR); assertFalse(gasback.isAuthorizedAccuralWithdrawer(address(this))); @@ -118,15 +119,16 @@ contract GasbackExtendedTest is SoladyTest { vm.deal(address(gasback), 1 ether); vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.9 ether)); assertTrue(gasback.setGasbackRatioNumerator(0.9 ether)); assertTrue(gasback.setGasbackMaxBaseFee(123)); assertTrue(gasback.setBaseFeeVault(address(0x1234))); - assertTrue(gasback.setBaseFeeVaultShareNumerator(0.7 ether)); assertTrue(gasback.setAccuralWithdrawer(address(0x99), true)); assertTrue(gasback.withdraw(address(0xA11CE), 0.2 ether)); vm.stopPrank(); assertEq(gasback.gasbackRatioNumerator(), 0.9 ether); + assertEq(gasback.baseFeeVaultShareNumerator(), 0.9 ether); assertEq(gasback.gasbackMaxBaseFee(), 123); assertEq(gasback.baseFeeVault(), address(0x1234)); assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(0x99))); @@ -136,6 +138,8 @@ contract GasbackExtendedTest is SoladyTest { function test_selfCanCallAdminFunctions() public { vm.deal(address(gasback), 1 ether); + vm.prank(address(gasback)); + assertTrue(gasback.setBaseFeeVaultShareNumerator(1 ether)); vm.prank(address(gasback)); assertTrue(gasback.setGasbackRatioNumerator(1 ether)); vm.prank(address(gasback)); @@ -143,13 +147,12 @@ contract GasbackExtendedTest is SoladyTest { vm.prank(address(gasback)); assertTrue(gasback.setBaseFeeVault(address(0x4321))); vm.prank(address(gasback)); - assertTrue(gasback.setBaseFeeVaultShareNumerator(0.5 ether)); - vm.prank(address(gasback)); assertTrue(gasback.setAccuralWithdrawer(address(this), true)); vm.prank(address(gasback)); assertTrue(gasback.withdraw(address(0xB0B), 0.25 ether)); assertEq(gasback.gasbackRatioNumerator(), 1 ether); + assertEq(gasback.baseFeeVaultShareNumerator(), 1 ether); assertEq(gasback.gasbackMaxBaseFee(), 77); assertEq(gasback.baseFeeVault(), address(0x4321)); assertTrue(gasback.isAuthorizedAccuralWithdrawer(address(this))); @@ -168,6 +171,19 @@ contract GasbackExtendedTest is SoladyTest { gasback.setBaseFeeVaultShareNumerator(DENOMINATOR + 1); } + function test_revert_setGasbackRatioNumeratorAboveBaseFeeVaultShare() public { + uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setGasbackRatioNumerator(shareNumerator + 1); + } + + function test_revert_setBaseFeeVaultShareNumeratorBelowGasbackRatio() public { + vm.prank(SYSTEM_ADDRESS); + vm.expectRevert(); + gasback.setBaseFeeVaultShareNumerator(0.5 ether); + } + function test_revert_fallbackInvalidCalldataLength() public { vm.prank(address(1)); (bool success0,) = address(gasback).call(new bytes(1)); @@ -186,7 +202,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); vm.fee(baseFee); @@ -237,7 +253,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive - 1); vm.fee(baseFee); @@ -255,7 +271,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.prank(SYSTEM_ADDRESS); gasback.setGasbackMaxBaseFee(baseFee - 1); @@ -290,7 +306,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), 2 * ethToGive); vm.fee(baseFee); @@ -314,7 +330,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(vault, ethToGive); vm.fee(baseFee); @@ -336,7 +352,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(vault, ethToGive); vm.fee(baseFee); @@ -352,13 +368,13 @@ contract GasbackExtendedTest is SoladyTest { function test_fallbackDoesNotPullFromVaultWhenExpectedShareBelowShortfall() public { address vault = address(0xA002); vm.etch(vault, hex"60016000550000"); - _configureBaseFeeVault(vault, 0.5 ether); + _configureBaseFeeVault(vault, DENOMINATOR); uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - vm.deal(vault, 1000); + vm.deal(vault, 500); vm.fee(baseFee); (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); @@ -368,7 +384,7 @@ contract GasbackExtendedTest is SoladyTest { assertEq(address(0xB0B).balance, 0); assertEq(gasback.accrued(), ethFromGas); assertEq(uint256(vm.load(vault, bytes32(0))), 0); - assertEq(vault.balance, 1000); + assertEq(vault.balance, 500); } function test_fallbackAttemptedVaultPullWithoutTransferFallsBackToPassThrough() public { @@ -424,7 +440,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); vm.fee(baseFee); @@ -442,7 +458,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; - uint256 ethToGive = (ethFromGas * 0.8 ether) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); vm.fee(baseFee); @@ -598,8 +614,10 @@ contract GasbackExtendedTest is SoladyTest { gasToBurn = _bound(gasToBurn, 0, 20000); ratioNumerator = _bound(ratioNumerator, 0, DENOMINATOR); - vm.prank(SYSTEM_ADDRESS); + vm.startPrank(SYSTEM_ADDRESS); + gasback.setBaseFeeVaultShareNumerator(DENOMINATOR); gasback.setGasbackRatioNumerator(ratioNumerator); + vm.stopPrank(); uint256 ethFromGas = baseFee * gasToBurn; uint256 expectedEthToGive = (ethFromGas * ratioNumerator) / DENOMINATOR; From fb9aa020456ea4edb6ede17af0a1173815d3f8b2 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:42:07 -0800 Subject: [PATCH 37/48] update readmen --- README.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7b1d7e5..1e1a64c 100644 --- a/README.md +++ b/README.md @@ -8,33 +8,37 @@ A barebones implementation of a gasback contract that implements [RIP-7767](http - The `baseFeeVault` is deployed at `0x4200000000000000000000000000000000000019`. - The `WITHDRAWAL_NETWORK` of the `baseFeeVault` is set to `1`. +- The `baseFeeVault` recipient is set to `ShapePaymentSplitter`. +- `Gasback` receives only its configured share from `ShapePaymentSplitter`. ### Via script -See `script/Delegate7702.s.sol` for an automated script that can help you deploy. +See `script/DeployGasback.s.sol` and `script/DeployShapePaymentSplitter.s.sol` for deployment scripts. -This script requires you to have the private key of the `baseFeeVault` recipient in your environment. +These scripts require you to have `PRIVATE_KEY` in your environment. For more information on how to run a foundry script, see `https://getfoundry.sh/guides/scripting-with-solidity`. ### Manual steps -1. Deploy the `gasback` contract which will be used as an implementation via EIP-7702. +1. Deploy the `Gasback` contract. -2. Use EIP-7702 to make the EOA `RECIPIENT` of the `baseFeeVault` delegated to the `gasback` implementation. - After delegating, use the EOA to call functions on itself to initialize the parameters: - +2. Deploy `ShapePaymentSplitter` with `Gasback` as one of the payees. + +3. Set the `baseFeeVault` recipient to the deployed `ShapePaymentSplitter`. + +4. Configure `Gasback` via authorized calls: + + - `setBaseFeeVault(address)` + `0x4200000000000000000000000000000000000019` + - `setBaseFeeVaultShareNumerator(uint256)` + `600000000000000000` (`0.6 ether`) and ensure it matches the splitter allocation for `Gasback`. - `setGasbackRatioNumerator(uint256)` - `900000000000000000` + Must be less than or equal to `setBaseFeeVaultShareNumerator`. - `setGasbackMaxBaseFee(uint256)` `115792089237316195423570985008687907853269984665640564039457584007913129639935` - - `setBaseFeeVault(address)` - `0x4200000000000000000000000000000000000019` -4. Put or leave some ETH into the EOA `RECIPIENT`, which will be the actual `gasback` contract. - The ETH will act as a buffer that will be temporarily dished out to contracts calling the EOA `RECIPIENT` in the span of a single block. +5. Put or leave some ETH in `Gasback`. + The ETH acts as a buffer that is temporarily dished out to contracts calling `Gasback` in the span of a single block. The base fees collected in a block will only be accrued into the `baseFeeVault` at the end of a block. - Try not to empty ETH from the `RECIPIENT` when you are actually taking out ETH from it. - -5. For better discoverabiity (for the devX), deploy the `gasbackBeacon` and use the system address to set the EOA `RECIPIENT`. - The exact CREATE2 instructions are in [`./deployments.md`](./deployments.md). + Try not to empty ETH from `Gasback` while actively serving gasback payouts. From aaeba18278e4e8fb23b7358645a24855e970b927 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:56:42 -0800 Subject: [PATCH 38/48] missing tests --- test/GasbackExtended.t.sol | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index 09f8d87..a56bc0d 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -172,16 +172,20 @@ contract GasbackExtendedTest is SoladyTest { } function test_revert_setGasbackRatioNumeratorAboveBaseFeeVaultShare() public { - uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); - vm.prank(SYSTEM_ADDRESS); + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.7 ether)); vm.expectRevert(); - gasback.setGasbackRatioNumerator(shareNumerator + 1); + gasback.setGasbackRatioNumerator(0.700000000000000001 ether); + vm.stopPrank(); } function test_revert_setBaseFeeVaultShareNumeratorBelowGasbackRatio() public { - vm.prank(SYSTEM_ADDRESS); + vm.startPrank(SYSTEM_ADDRESS); + assertTrue(gasback.setBaseFeeVaultShareNumerator(0.9 ether)); + assertTrue(gasback.setGasbackRatioNumerator(0.8 ether)); vm.expectRevert(); - gasback.setBaseFeeVaultShareNumerator(0.5 ether); + gasback.setBaseFeeVaultShareNumerator(0.79 ether); + vm.stopPrank(); } function test_revert_fallbackInvalidCalldataLength() public { From fe93a811f01faea11cc5a9477f19af1195b96204 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:08:23 -0800 Subject: [PATCH 39/48] add GasbackTestCaller --- src/GasbackTestCaller.sol | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/GasbackTestCaller.sol diff --git a/src/GasbackTestCaller.sol b/src/GasbackTestCaller.sol new file mode 100644 index 0000000..031d7be --- /dev/null +++ b/src/GasbackTestCaller.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract GasbackTestCaller { + error NotOwner(); + error ZeroAddress(); + error InvalidGasbackAddress(); + error GasbackCallFailed(); + error UnexpectedReturnData(); + error WithdrawFailed(); + + event GasbackCalled(address indexed caller, uint256 gasToBurn, uint256 ethReceived); + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event Withdrawal(address indexed to, uint256 amount); + + address public immutable GASBACK; + address public owner; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address gasback_) { + if (gasback_ == address(0)) revert ZeroAddress(); + if (gasback_.code.length == 0) revert InvalidGasbackAddress(); + GASBACK = gasback_; + owner = msg.sender; + emit OwnershipTransferred(address(0), msg.sender); + } + + function burnGas(uint256 gasToBurn) external returns (uint256 ethReceived) { + (bool success, bytes memory data) = GASBACK.call(abi.encode(gasToBurn)); + if (!success) revert GasbackCallFailed(); + if (data.length != 32) revert UnexpectedReturnData(); + ethReceived = abi.decode(data, (uint256)); + emit GasbackCalled(msg.sender, gasToBurn, ethReceived); + } + + function withdraw(address payable to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + (bool success,) = to.call{value: amount}(""); + if (!success) revert WithdrawFailed(); + emit Withdrawal(to, amount); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } + + receive() external payable {} +} From 5d14fdb03ebd9f9e00dda28ea92c97363c491403 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:11:00 -0800 Subject: [PATCH 40/48] deploy testcaller --- script/DeployGasbackTestCaller.s.sol | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 script/DeployGasbackTestCaller.s.sol diff --git a/script/DeployGasbackTestCaller.s.sol b/script/DeployGasbackTestCaller.s.sol new file mode 100644 index 0000000..e0d3c9b --- /dev/null +++ b/script/DeployGasbackTestCaller.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console} from "forge-std/Script.sol"; +import {GasbackTestCaller} from "../src/GasbackTestCaller.sol"; + +contract DeployGasbackTestCallerScript is Script { + error WrongChain(uint256 chainId); + + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + + function run() external returns (GasbackTestCaller deployed) { + if (block.chainid != SHAPE_SEPOLIA_CHAIN_ID) revert WrongChain(block.chainid); + + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + address gasback = vm.envAddress("GASBACK_ADDRESS"); + + vm.startBroadcast(privateKey); + deployed = new GasbackTestCaller(gasback); + vm.stopBroadcast(); + + console.log("GasbackTestCaller deployed at:", address(deployed)); + console.log("Gasback target:", gasback); + } +} From 74d1a98637ec5906102f8b1e5eceb4630a26df9c Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:13:17 -0800 Subject: [PATCH 41/48] move contract, fix script --- script/DeployGasbackTestCaller.s.sol | 2 +- src/{ => test}/GasbackTestCaller.sol | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{ => test}/GasbackTestCaller.sol (100%) diff --git a/script/DeployGasbackTestCaller.s.sol b/script/DeployGasbackTestCaller.s.sol index e0d3c9b..9e24906 100644 --- a/script/DeployGasbackTestCaller.s.sol +++ b/script/DeployGasbackTestCaller.s.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.28; import {Script, console} from "forge-std/Script.sol"; -import {GasbackTestCaller} from "../src/GasbackTestCaller.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; contract DeployGasbackTestCallerScript is Script { error WrongChain(uint256 chainId); diff --git a/src/GasbackTestCaller.sol b/src/test/GasbackTestCaller.sol similarity index 100% rename from src/GasbackTestCaller.sol rename to src/test/GasbackTestCaller.sol From 039c3d6b48bdd47cdbd0cfd73a788c420450422d Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:45:27 -0800 Subject: [PATCH 42/48] testnet tests --- script/TestGasbackTestCaller.s.sol | 121 +++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 script/TestGasbackTestCaller.s.sol diff --git a/script/TestGasbackTestCaller.s.sol b/script/TestGasbackTestCaller.s.sol new file mode 100644 index 0000000..d7b97b3 --- /dev/null +++ b/script/TestGasbackTestCaller.s.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; + +interface IGasbackRead { + function gasbackRatioNumerator() external view returns (uint256); + function gasbackMaxBaseFee() external view returns (uint256); + function accrued() external view returns (uint256); +} + +contract TestGasbackTestCallerScript is Script { + error WrongChain(uint256 chainId); + error InvalidCallerAddress(address caller); + error InvalidGasbackAddress(address gasback); + error CallerBalanceDecreased(uint256 beforeBalance, uint256 afterBalance); + error CallerBalanceDeltaMismatch(uint256 expectedDelta, uint256 observedDelta); + error AccruedDecreased(uint256 beforeAccrued, uint256 afterAccrued); + error UnexpectedZeroGasResult(uint256 payout, uint256 accruedDelta); + error AccruedInvariantBroke(uint256 accruedDelta, uint256 payout, uint256 gasToBurn); + error PayoutExceedsRatio(uint256 payout, uint256 maxPayoutByRatio); + error ExpectedPassThrough(uint256 realizedBaseFee, uint256 maxBaseFee, uint256 payout); + + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + uint256 internal constant DENOMINATOR = 1 ether; + uint256 internal constant DEFAULT_GAS_TO_BURN = 30_000; + address internal constant DEFAULT_SHAPE_SEPOLIA_CALLER = + 0x746E1dA1Dd0705640e93B1b8a4Db820fE29d19A5; + + function run() external { + if (block.chainid != SHAPE_SEPOLIA_CHAIN_ID) revert WrongChain(block.chainid); + + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + address callerAddress = + vm.envOr("GASBACK_TEST_CALLER_ADDRESS", DEFAULT_SHAPE_SEPOLIA_CALLER); + if (callerAddress.code.length == 0) revert InvalidCallerAddress(callerAddress); + + GasbackTestCaller caller = GasbackTestCaller(payable(callerAddress)); + address gasbackAddress = caller.GASBACK(); + if (gasbackAddress.code.length == 0) revert InvalidGasbackAddress(gasbackAddress); + + IGasbackRead gasback = IGasbackRead(gasbackAddress); + uint256 gasToBurn = vm.envOr("GAS_TO_BURN", DEFAULT_GAS_TO_BURN); + + console2.log("Shape Sepolia chain id:", block.chainid); + console2.log("GasbackTestCaller:", callerAddress); + console2.log("Gasback:", gasbackAddress); + console2.log("Configured gasToBurn for nonzero case:", gasToBurn); + + _runCase(privateKey, caller, gasback, 0); + _runCase(privateKey, caller, gasback, gasToBurn); + + console2.log("All checks passed."); + } + + function _runCase(uint256 privateKey, GasbackTestCaller caller, IGasbackRead gasback, uint256 gasToBurn) + internal + { + uint256 ratioNumerator = gasback.gasbackRatioNumerator(); + uint256 maxBaseFee = gasback.gasbackMaxBaseFee(); + + uint256 callerBalanceBefore = address(caller).balance; + uint256 accruedBefore = gasback.accrued(); + + vm.startBroadcast(privateKey); + uint256 payout = caller.burnGas(gasToBurn); + vm.stopBroadcast(); + + uint256 callerBalanceAfter = address(caller).balance; + uint256 accruedAfter = gasback.accrued(); + + if (callerBalanceAfter < callerBalanceBefore) { + revert CallerBalanceDecreased(callerBalanceBefore, callerBalanceAfter); + } + if (accruedAfter < accruedBefore) { + revert AccruedDecreased(accruedBefore, accruedAfter); + } + + uint256 callerBalanceDelta = callerBalanceAfter - callerBalanceBefore; + uint256 accruedDelta = accruedAfter - accruedBefore; + + if (callerBalanceDelta != payout) { + revert CallerBalanceDeltaMismatch(payout, callerBalanceDelta); + } + + if (gasToBurn == 0) { + if (payout != 0 || accruedDelta != 0) { + revert UnexpectedZeroGasResult(payout, accruedDelta); + } + + console2.log("Case gasToBurn:", gasToBurn); + console2.log("Payout:", payout); + console2.log("Accrued delta:", accruedDelta); + return; + } + + uint256 totalFromGas = accruedDelta + payout; + if (totalFromGas % gasToBurn != 0) { + revert AccruedInvariantBroke(accruedDelta, payout, gasToBurn); + } + + uint256 realizedBaseFee = totalFromGas / gasToBurn; + uint256 maxPayoutByRatio = (totalFromGas * ratioNumerator) / DENOMINATOR; + if (payout > maxPayoutByRatio) { + revert PayoutExceedsRatio(payout, maxPayoutByRatio); + } + + if (realizedBaseFee > maxBaseFee && payout != 0) { + revert ExpectedPassThrough(realizedBaseFee, maxBaseFee, payout); + } + + console2.log("Case gasToBurn:", gasToBurn); + console2.log("Payout:", payout); + console2.log("Accrued delta:", accruedDelta); + console2.log("Realized base fee:", realizedBaseFee); + console2.log("Max payout by ratio:", maxPayoutByRatio); + console2.log("Ratio numerator used:", ratioNumerator); + console2.log("Max base fee used:", maxBaseFee); + } +} From 38c48d9985e2a0c0badb7295bfa3f5ab56a5c458 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:38:16 -0800 Subject: [PATCH 43/48] update accrued comment, update gasbackRatioNumerator --- script/Delegate7702.s.sol | 2 +- src/Gasback.sol | 6 +++--- test/GasbackExtended.t.sol | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/script/Delegate7702.s.sol b/script/Delegate7702.s.sol index bf02656..763dde8 100644 --- a/script/Delegate7702.s.sol +++ b/script/Delegate7702.s.sol @@ -23,7 +23,7 @@ contract Delegate7702Script is Script { vm.startBroadcast(privateKey); Gasback(payable(deployer)).noop(); - Gasback(payable(deployer)).setGasbackRatioNumerator(600000000000000000); + Gasback(payable(deployer)).setGasbackRatioNumerator(500000000000000000); Gasback(payable(deployer)).setGasbackMaxBaseFee(type(uint256).max); Gasback(payable(deployer)).setBaseFeeVault(0x4200000000000000000000000000000000000019); vm.stopBroadcast(); diff --git a/src/Gasback.sol b/src/Gasback.sol index 7131e94..3b0607e 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -30,7 +30,7 @@ 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 amount of ETH accrued by taking a cut from the gas burned. + // The amount of ETH accrued by taking a cut from the gas burned (after the base fee vault share has been taken). uint256 accrued; // A mapping of addresses authorized to withdraw the accrued ETH. mapping(address => bool) accuralWithdrawers; @@ -54,10 +54,10 @@ contract Gasback { constructor() payable { GasbackStorage storage $ = _getGasbackStorage(); - $.gasbackRatioNumerator = 0.6 ether; + $.gasbackRatioNumerator = 0.5 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; - $.baseFeeVaultShareNumerator = 600000000000000000; + $.baseFeeVaultShareNumerator = 0.6 ether; } /*«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-«-*/ diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index a56bc0d..650626c 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -74,7 +74,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_constructorDefaults() public { - assertEq(gasback.gasbackRatioNumerator(), 0.6 ether); + assertEq(gasback.gasbackRatioNumerator(), 0.5 ether); assertEq(gasback.gasbackMaxBaseFee(), type(uint256).max); assertEq(gasback.baseFeeVault(), DEFAULT_BASE_FEE_VAULT); assertEq(gasback.baseFeeVaultShareNumerator(), 0.6 ether); From 6e84fe29d9874acd686032b6bf87e683b25a8478 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:47:46 -0800 Subject: [PATCH 44/48] add ethFromVaultShare, update accrued calc --- script/TestGasbackTestCaller.s.sol | 33 ++++----- src/Gasback.sol | 8 ++- test/GasbackExtended.t.sol | 107 +++++++++++++++++++---------- 3 files changed, 93 insertions(+), 55 deletions(-) diff --git a/script/TestGasbackTestCaller.s.sol b/script/TestGasbackTestCaller.s.sol index d7b97b3..67f8bbc 100644 --- a/script/TestGasbackTestCaller.s.sol +++ b/script/TestGasbackTestCaller.s.sol @@ -6,6 +6,7 @@ import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; interface IGasbackRead { function gasbackRatioNumerator() external view returns (uint256); + function baseFeeVaultShareNumerator() external view returns (uint256); function gasbackMaxBaseFee() external view returns (uint256); function accrued() external view returns (uint256); } @@ -18,12 +19,12 @@ contract TestGasbackTestCallerScript is Script { error CallerBalanceDeltaMismatch(uint256 expectedDelta, uint256 observedDelta); error AccruedDecreased(uint256 beforeAccrued, uint256 afterAccrued); error UnexpectedZeroGasResult(uint256 payout, uint256 accruedDelta); - error AccruedInvariantBroke(uint256 accruedDelta, uint256 payout, uint256 gasToBurn); - error PayoutExceedsRatio(uint256 payout, uint256 maxPayoutByRatio); + error PayoutExceedsTrackedShare(uint256 payout, uint256 trackedFromGas); + error UnexpectedPayoutWithZeroRatio(uint256 payout); + error EqualRatioShareMismatch(uint256 payout, uint256 trackedFromGas); error ExpectedPassThrough(uint256 realizedBaseFee, uint256 maxBaseFee, uint256 payout); uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; - uint256 internal constant DENOMINATOR = 1 ether; uint256 internal constant DEFAULT_GAS_TO_BURN = 30_000; address internal constant DEFAULT_SHAPE_SEPOLIA_CALLER = 0x746E1dA1Dd0705640e93B1b8a4Db820fE29d19A5; @@ -58,6 +59,7 @@ contract TestGasbackTestCallerScript is Script { internal { uint256 ratioNumerator = gasback.gasbackRatioNumerator(); + uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); uint256 maxBaseFee = gasback.gasbackMaxBaseFee(); uint256 callerBalanceBefore = address(caller).balance; @@ -95,27 +97,26 @@ contract TestGasbackTestCallerScript is Script { return; } - uint256 totalFromGas = accruedDelta + payout; - if (totalFromGas % gasToBurn != 0) { - revert AccruedInvariantBroke(accruedDelta, payout, gasToBurn); + uint256 trackedFromGas = accruedDelta + payout; + if (ratioNumerator == 0 && payout != 0) { + revert UnexpectedPayoutWithZeroRatio(payout); } - - uint256 realizedBaseFee = totalFromGas / gasToBurn; - uint256 maxPayoutByRatio = (totalFromGas * ratioNumerator) / DENOMINATOR; - if (payout > maxPayoutByRatio) { - revert PayoutExceedsRatio(payout, maxPayoutByRatio); + if (payout > trackedFromGas) { + revert PayoutExceedsTrackedShare(payout, trackedFromGas); } - - if (realizedBaseFee > maxBaseFee && payout != 0) { - revert ExpectedPassThrough(realizedBaseFee, maxBaseFee, payout); + if (ratioNumerator == shareNumerator && payout != trackedFromGas) { + revert EqualRatioShareMismatch(payout, trackedFromGas); + } + if (block.basefee > maxBaseFee && trackedFromGas != 0) { + revert ExpectedPassThrough(block.basefee, maxBaseFee, payout); } console2.log("Case gasToBurn:", gasToBurn); console2.log("Payout:", payout); console2.log("Accrued delta:", accruedDelta); - console2.log("Realized base fee:", realizedBaseFee); - console2.log("Max payout by ratio:", maxPayoutByRatio); + console2.log("Tracked from gas (accrued + payout):", trackedFromGas); console2.log("Ratio numerator used:", ratioNumerator); + console2.log("Vault share numerator used:", shareNumerator); console2.log("Max base fee used:", maxBaseFee); } } diff --git a/src/Gasback.sol b/src/Gasback.sol index 3b0607e..899198d 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -197,6 +197,8 @@ contract Gasback { GasbackStorage storage $ = _getGasbackStorage(); uint256 ethFromGas = gasToBurn * block.basefee; + uint256 ethFromVaultShare = + (ethFromGas * $.baseFeeVaultShareNumerator) / GASBACK_RATIO_DENOMINATOR; uint256 ethToGive = (ethFromGas * $.gasbackRatioNumerator) / GASBACK_RATIO_DENOMINATOR; uint256 selfBalance = address(this).balance; @@ -223,8 +225,10 @@ contract Gasback { gasToBurn = 0; } - unchecked { - $.accrued += ethFromGas - ethToGive; + if (gasToBurn != 0) { + unchecked { + $.accrued += ethFromVaultShare - ethToGive; + } } /// @solidity memory-safe-assembly diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index 650626c..cac1c4d 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -55,15 +55,24 @@ contract GasbackExtendedTest is SoladyTest { } } - function _accrueViaPassThrough(uint256 baseFee, uint256 gasToBurn) + function _accrueViaPayout(uint256 baseFee, uint256 gasToBurn) internal - returns (uint256 ethFromGas) + returns (uint256 accruedDelta) { - ethFromGas = baseFee * gasToBurn; + uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + uint256 beforeAccrued = gasback.accrued(); + + vm.deal(address(gasback), ethToGive); vm.fee(baseFee); - (bool success,) = _callFallback(address(0xA11CE), gasToBurn); + (bool success, uint256 returnedEthToGive) = _callFallback(address(0xA11CE), gasToBurn); assertTrue(success); - assertEq(gasback.accrued(), ethFromGas); + assertEq(returnedEthToGive, ethToGive); + + accruedDelta = gasback.accrued() - beforeAccrued; + assertEq(accruedDelta, ethFromVaultShare - ethToGive); } function _configureBaseFeeVault(address vault, uint256 shareNumerator) internal { @@ -206,6 +215,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); @@ -216,7 +227,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, ethToGive); assertEq(address(0xB0B).balance, ethToGive); - assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); assertEq(address(gasback).balance, 0); } @@ -227,6 +238,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 13; uint256 gasToBurn = 101; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; vm.fee(baseFee); (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); @@ -234,7 +247,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), ethFromVaultShare); } function test_fallbackZeroGasToBurnNoops() public { @@ -257,6 +270,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive - 1); @@ -267,7 +281,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); assertEq(address(gasback).balance, ethToGive - 1); } @@ -275,6 +289,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.prank(SYSTEM_ADDRESS); @@ -288,7 +303,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); assertEq(address(gasback).balance, ethToGive); } @@ -310,6 +325,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), 2 * ethToGive); @@ -322,7 +339,10 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success1); assertEq(address(0x1111).balance, ethToGive); assertEq(address(0x2222).balance, ethToGive); - assertEq(gasback.accrued(), 2 * (ethFromGas - ethToGive)); + assertEq(gasback.accrued(), 2 * (ethFromVaultShare - ethToGive)); + assertEq( + gasback.accrued() + address(0x1111).balance + address(0x2222).balance, 2 * ethFromVaultShare + ); assertEq(address(gasback).balance, 0); } @@ -334,6 +354,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(vault, ethToGive); @@ -344,7 +366,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, ethToGive); assertEq(address(0xB0B).balance, ethToGive); - assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); assertEq(vault.balance, 0); } @@ -356,6 +378,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(vault, ethToGive); @@ -366,6 +390,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, ethToGive); assertEq(address(0xB0B).balance, ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); assertEq(vault.balance, 0); } @@ -376,9 +401,9 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; - uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); - vm.deal(vault, 500); + vm.deal(vault, 499); vm.fee(baseFee); (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); @@ -386,9 +411,9 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); assertEq(uint256(vm.load(vault, bytes32(0))), 0); - assertEq(vault.balance, 500); + assertEq(vault.balance, 499); } function test_fallbackAttemptedVaultPullWithoutTransferFallsBackToPassThrough() public { @@ -398,7 +423,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; - uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); vm.deal(vault, 1000); vm.fee(baseFee); @@ -408,7 +433,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); assertEq(uint256(vm.load(vault, bytes32(0))), 1); assertEq(vault.balance, 1000); } @@ -419,7 +444,7 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; - uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); vm.startPrank(SYSTEM_ADDRESS); gasback.setBaseFeeVault(vault); @@ -434,7 +459,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); assertEq(uint256(vm.load(vault, bytes32(0))), 0); } @@ -444,6 +469,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); @@ -453,7 +480,7 @@ contract GasbackExtendedTest is SoladyTest { assertEq(returnedEthToGive, ethToGive); assertEq(address(caller).balance, ethToGive); - assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); } function test_fallbackPaysAcceptingContractCaller() public { @@ -462,6 +489,8 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 ethToGive = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; vm.deal(address(gasback), ethToGive); @@ -471,7 +500,7 @@ contract GasbackExtendedTest is SoladyTest { assertEq(returnedEthToGive, ethToGive); assertEq(address(caller).balance, ethToGive); - assertEq(gasback.accrued(), ethFromGas - ethToGive); + assertEq(gasback.accrued(), ethFromVaultShare - ethToGive); assertEq(address(gasback).balance, 0); } @@ -483,13 +512,15 @@ contract GasbackExtendedTest is SoladyTest { uint256 baseFee = 10; uint256 gasToBurn = 100; uint256 ethFromGas = baseFee * gasToBurn; + uint256 ethFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; vm.fee(baseFee); uint256 returnedEthToGive = caller.trigger(address(gasback), gasToBurn); assertEq(returnedEthToGive, 0); assertEq(address(caller).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), ethFromVaultShare); } function test_revert_withdrawWhenRecipientRejectsEth() public { @@ -510,29 +541,30 @@ contract GasbackExtendedTest is SoladyTest { } function test_withdrawAccruedAuthorizedSuccess() public { - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); + uint256 withdrawAmount = 40; vm.prank(SYSTEM_ADDRESS); gasback.setAccuralWithdrawer(address(this), true); address recipient = address(0xCAFE); uint256 before = recipient.balance; - bool success = gasback.withdrawAccrued(recipient, 400); + bool success = gasback.withdrawAccrued(recipient, withdrawAmount); assertTrue(success); - assertEq(recipient.balance - before, 400); - assertEq(gasback.accrued(), accruedAmount - 400); + assertEq(recipient.balance - before, withdrawAmount); + assertEq(gasback.accrued(), accruedAmount - withdrawAmount); } function test_revert_withdrawAccruedUnauthorized() public { - _accrueViaPassThrough(10, 100); + _accrueViaPayout(10, 100); vm.expectRevert(); gasback.withdrawAccrued(address(this), 1); } function test_withdrawAccruedRequireBranchTrue_authorized() public { - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); vm.prank(SYSTEM_ADDRESS); @@ -548,7 +580,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_withdrawAccruedRequireBranchFalse_unauthorizedReverts() public { - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); vm.expectRevert(); @@ -558,7 +590,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_setAccuralWithdrawerRevokeBlocksWithdrawAccrued() public { - _accrueViaPassThrough(10, 100); + _accrueViaPayout(10, 100); vm.startPrank(SYSTEM_ADDRESS); gasback.setAccuralWithdrawer(address(this), true); @@ -572,7 +604,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_revert_withdrawAccruedUnderflow() public { - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.prank(SYSTEM_ADDRESS); gasback.setAccuralWithdrawer(address(this), true); @@ -585,7 +617,7 @@ contract GasbackExtendedTest is SoladyTest { function test_revert_withdrawAccruedWhenRecipientRejectsEth() public { RejectingReceiver rejector = new RejectingReceiver(); - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); vm.prank(SYSTEM_ADDRESS); @@ -598,7 +630,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_revert_withdrawAccruedWhenBalanceInsufficient() public { - uint256 accruedAmount = _accrueViaPassThrough(10, 100); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.prank(SYSTEM_ADDRESS); gasback.setAccuralWithdrawer(address(this), true); @@ -624,6 +656,8 @@ contract GasbackExtendedTest is SoladyTest { vm.stopPrank(); uint256 ethFromGas = baseFee * gasToBurn; + uint256 expectedEthFromVaultShare = + (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; uint256 expectedEthToGive = (ethFromGas * ratioNumerator) / DENOMINATOR; vm.deal(address(gasback), expectedEthToGive); @@ -634,7 +668,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, expectedEthToGive); assertEq(address(0xB0B).balance, expectedEthToGive); - assertEq(gasback.accrued(), ethFromGas - expectedEthToGive); + assertEq(gasback.accrued(), expectedEthFromVaultShare - expectedEthToGive); } function testFuzz_fallbackPassThroughOnInsufficientBalance(uint256 baseFee, uint256 gasToBurn) @@ -642,8 +676,7 @@ contract GasbackExtendedTest is SoladyTest { { baseFee = _bound(baseFee, 1, 1e12); gasToBurn = _bound(gasToBurn, 1, 20000); - - uint256 ethFromGas = baseFee * gasToBurn; + uint256 beforeAccrued = gasback.accrued(); vm.fee(baseFee); (bool success, uint256 returnedEthToGive) = _callFallback(address(0xB0B), gasToBurn); @@ -651,7 +684,7 @@ contract GasbackExtendedTest is SoladyTest { assertTrue(success); assertEq(returnedEthToGive, 0); assertEq(address(0xB0B).balance, 0); - assertEq(gasback.accrued(), ethFromGas); + assertEq(gasback.accrued(), beforeAccrued); } function testRevertSetBaseFeeVaultShareNumeratorAboveDenominator() public { From 89b5ff56b7d51fca97623d3b92d964f63a3b3db9 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:25:31 -0800 Subject: [PATCH 45/48] update to 0.5, fix tests --- script/Delegate7702.s.sol | 2 +- src/Gasback.sol | 2 +- test/GasbackExtended.t.sol | 28 ++++++++++++++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/script/Delegate7702.s.sol b/script/Delegate7702.s.sol index 763dde8..bf02656 100644 --- a/script/Delegate7702.s.sol +++ b/script/Delegate7702.s.sol @@ -23,7 +23,7 @@ contract Delegate7702Script is Script { vm.startBroadcast(privateKey); Gasback(payable(deployer)).noop(); - Gasback(payable(deployer)).setGasbackRatioNumerator(500000000000000000); + Gasback(payable(deployer)).setGasbackRatioNumerator(600000000000000000); Gasback(payable(deployer)).setGasbackMaxBaseFee(type(uint256).max); Gasback(payable(deployer)).setBaseFeeVault(0x4200000000000000000000000000000000000019); vm.stopBroadcast(); diff --git a/src/Gasback.sol b/src/Gasback.sol index 899198d..00178de 100644 --- a/src/Gasback.sol +++ b/src/Gasback.sol @@ -54,7 +54,7 @@ contract Gasback { constructor() payable { GasbackStorage storage $ = _getGasbackStorage(); - $.gasbackRatioNumerator = 0.5 ether; + $.gasbackRatioNumerator = 0.6 ether; $.gasbackMaxBaseFee = type(uint256).max; $.baseFeeVault = 0x4200000000000000000000000000000000000019; $.baseFeeVaultShareNumerator = 0.6 ether; diff --git a/test/GasbackExtended.t.sol b/test/GasbackExtended.t.sol index cac1c4d..a6c6957 100644 --- a/test/GasbackExtended.t.sol +++ b/test/GasbackExtended.t.sol @@ -83,7 +83,7 @@ contract GasbackExtendedTest is SoladyTest { } function test_constructorDefaults() public { - assertEq(gasback.gasbackRatioNumerator(), 0.5 ether); + assertEq(gasback.gasbackRatioNumerator(), 0.6 ether); assertEq(gasback.gasbackMaxBaseFee(), type(uint256).max); assertEq(gasback.baseFeeVault(), DEFAULT_BASE_FEE_VAULT); assertEq(gasback.baseFeeVaultShareNumerator(), 0.6 ether); @@ -341,7 +341,8 @@ contract GasbackExtendedTest is SoladyTest { assertEq(address(0x2222).balance, ethToGive); assertEq(gasback.accrued(), 2 * (ethFromVaultShare - ethToGive)); assertEq( - gasback.accrued() + address(0x1111).balance + address(0x2222).balance, 2 * ethFromVaultShare + gasback.accrued() + address(0x1111).balance + address(0x2222).balance, + 2 * ethFromVaultShare ); assertEq(address(gasback).balance, 0); } @@ -541,6 +542,9 @@ contract GasbackExtendedTest is SoladyTest { } function test_withdrawAccruedAuthorizedSuccess() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0.5 ether); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); uint256 withdrawAmount = 40; @@ -564,6 +568,9 @@ contract GasbackExtendedTest is SoladyTest { } function test_withdrawAccruedRequireBranchTrue_authorized() public { + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(0.5 ether); + uint256 accruedAmount = _accrueViaPayout(10, 100); vm.deal(address(gasback), accruedAmount); @@ -615,6 +622,23 @@ contract GasbackExtendedTest is SoladyTest { assertEq(gasback.accrued(), accruedAmount); } + function test_revert_withdrawAccruedUnderflowWhenShareEqualsRatio() public { + uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackRatioNumerator(shareNumerator); + + uint256 accruedAmount = _accrueViaPayout(10, 100); + assertEq(accruedAmount, 0); + + vm.prank(SYSTEM_ADDRESS); + gasback.setAccuralWithdrawer(address(this), true); + + vm.expectRevert(); + gasback.withdrawAccrued(address(this), 1); + + assertEq(gasback.accrued(), 0); + } + function test_revert_withdrawAccruedWhenRecipientRejectsEth() public { RejectingReceiver rejector = new RejectingReceiver(); uint256 accruedAmount = _accrueViaPayout(10, 100); From 75c4ecf43f8a2f274a74c80a796fff5bcea43525 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Mon, 9 Feb 2026 22:42:54 -0800 Subject: [PATCH 46/48] add DeployGasbackStackScript --- script/DeployGasbackStack.s.sol | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 script/DeployGasbackStack.s.sol diff --git a/script/DeployGasbackStack.s.sol b/script/DeployGasbackStack.s.sol new file mode 100644 index 0000000..cecffb7 --- /dev/null +++ b/script/DeployGasbackStack.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; +import {Gasback} from "../src/Gasback.sol"; +import {ShapePaymentSplitter} from "../src/ShapePaymentSplitter.sol"; +import {GasbackTestCaller} from "../src/test/GasbackTestCaller.sol"; + +contract DeployGasbackStackScript is Script { + error MissingExtraShares(); + error MissingExtraPayees(); + error ExtraPayeesAndSharesLengthMismatch(uint256 payeesLength, uint256 sharesLength); + error GasbackShareIsZero(); + + function run() + external + returns (Gasback gasback, ShapePaymentSplitter splitter, GasbackTestCaller caller) + { + uint256 privateKey = uint256(vm.envBytes32("PRIVATE_KEY")); + uint256 gasbackShare = vm.envOr("GASBACK_SPLITTER_SHARE", uint256(1)); + + if (gasbackShare == 0) revert GasbackShareIsZero(); + + bool hasExtraPayees = vm.envExists("EXTRA_SPLITTER_PAYEES"); + bool hasExtraShares = vm.envExists("EXTRA_SPLITTER_SHARES"); + if (hasExtraPayees != hasExtraShares) { + if (hasExtraPayees) revert MissingExtraShares(); + revert MissingExtraPayees(); + } + + address[] memory extraPayees; + uint256[] memory extraShares; + if (hasExtraPayees) { + extraPayees = vm.envAddress("EXTRA_SPLITTER_PAYEES", ","); + extraShares = vm.envUint("EXTRA_SPLITTER_SHARES", ","); + if (extraPayees.length != extraShares.length) { + revert ExtraPayeesAndSharesLengthMismatch(extraPayees.length, extraShares.length); + } + } + + vm.startBroadcast(privateKey); + + gasback = new Gasback(); + + address[] memory payees = new address[](extraPayees.length + 1); + uint256[] memory shares = new uint256[](extraShares.length + 1); + payees[0] = address(gasback); + shares[0] = gasbackShare; + + for (uint256 i = 0; i < extraPayees.length; i++) { + payees[i + 1] = extraPayees[i]; + shares[i + 1] = extraShares[i]; + } + + splitter = new ShapePaymentSplitter(payees, shares); + caller = new GasbackTestCaller(address(gasback)); + + vm.stopBroadcast(); + + console2.log("Gasback:", address(gasback)); + console2.log("ShapePaymentSplitter:", address(splitter)); + console2.log("GasbackTestCaller:", address(caller)); + } +} From 2a838203651dff90ba9063b1f1e343f167eb189b Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:22:35 -0800 Subject: [PATCH 47/48] testnet testing --- script/TestGasbackTestCaller.s.sol | 11 +++++++---- test/Gasback.t.sol | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/script/TestGasbackTestCaller.s.sol b/script/TestGasbackTestCaller.s.sol index 67f8bbc..d273630 100644 --- a/script/TestGasbackTestCaller.s.sol +++ b/script/TestGasbackTestCaller.s.sol @@ -27,7 +27,7 @@ contract TestGasbackTestCallerScript is Script { uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; uint256 internal constant DEFAULT_GAS_TO_BURN = 30_000; address internal constant DEFAULT_SHAPE_SEPOLIA_CALLER = - 0x746E1dA1Dd0705640e93B1b8a4Db820fE29d19A5; + 0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d; function run() external { if (block.chainid != SHAPE_SEPOLIA_CHAIN_ID) revert WrongChain(block.chainid); @@ -55,9 +55,12 @@ contract TestGasbackTestCallerScript is Script { console2.log("All checks passed."); } - function _runCase(uint256 privateKey, GasbackTestCaller caller, IGasbackRead gasback, uint256 gasToBurn) - internal - { + function _runCase( + uint256 privateKey, + GasbackTestCaller caller, + IGasbackRead gasback, + uint256 gasToBurn + ) internal { uint256 ratioNumerator = gasback.gasbackRatioNumerator(); uint256 shareNumerator = gasback.baseFeeVaultShareNumerator(); uint256 maxBaseFee = gasback.gasbackMaxBaseFee(); diff --git a/test/Gasback.t.sol b/test/Gasback.t.sol index b20cb71..caa0a49 100644 --- a/test/Gasback.t.sol +++ b/test/Gasback.t.sol @@ -23,7 +23,8 @@ contract GasbackTest is SoladyTest { assertTrue(success); assertEq( pranker.balance, - (gasToBurn * baseFee * gasback.gasbackRatioNumerator()) / gasback.GASBACK_RATIO_DENOMINATOR() + (gasToBurn * baseFee * gasback.gasbackRatioNumerator()) + / gasback.GASBACK_RATIO_DENOMINATOR() ); } @@ -62,5 +63,4 @@ contract GasbackTest is SoladyTest { assertTrue(success); assertEq(pranker.balance, 0); } - } From e24f0a8ee6df73162a25c9a4b976164850fe4416 Mon Sep 17 00:00:00 2001 From: j6i <43463199+j6i@users.noreply.github.com> Date: Thu, 7 May 2026 22:29:12 -0700 Subject: [PATCH 48/48] sepolia test --- .env.example | 9 + .github/workflows/ci.yml | 47 +- .gitignore | 6 +- bun.lock | 53 ++ live/gasback-live.test.ts | 110 +++++ live/gasback-live.ts | 905 ++++++++++++++++++++++++++++++++++ package.json | 21 + src/test/GasbackLiveProbe.sol | 74 +++ test/GasbackLiveFork.t.sol | 278 +++++++++++ tsconfig.json | 22 + 10 files changed, 1521 insertions(+), 4 deletions(-) create mode 100644 .env.example create mode 100644 bun.lock create mode 100644 live/gasback-live.test.ts create mode 100644 live/gasback-live.ts create mode 100644 package.json create mode 100644 src/test/GasbackLiveProbe.sol create mode 100644 test/GasbackLiveFork.t.sol create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b7014ac --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +SHAPE_SEPOLIA_RPC_URL=https://shape-sepolia.g.alchemy.com/v2/YOUR_KEY +GASBACK_ADDRESS=0x21e34c5bea9253CDCd57671A1970BB31df4aBe83 +SPLITTER_ADDRESS=0x658E643b379b52Cd21605bFaF9C81e84713d8427 +GASBACK_TEST_CALLER_ADDRESS=0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d +GASBACK_LIVE_PROBE_ADDRESS= +PRIVATE_KEY= +MAX_WEI_SPEND=1000000000000000 +MAX_GAS_TO_BURN=120000 +REPORT_PATH=gasback-live-report.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b03213..8674100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,22 @@ on: branches: [main] paths: - '**.sol' + - '**.ts' - '**.yml' + - '.env.example' + - 'bun.lock' + - 'package.json' + - 'tsconfig.json' push: branches: [main] paths: - '**.sol' + - '**.ts' - '**.yml' + - '.env.example' + - 'bun.lock' + - 'package.json' + - 'tsconfig.json' jobs: tests: name: Forge Testing @@ -34,12 +44,44 @@ jobs: - name: Run Tests with ${{ matrix.profile }} run: > ( [ "${{ matrix.profile }}" = "regular" ] && - forge test + forge test --disable-labels ) || ( [ "${{ matrix.profile }}" = "intense" ] && - forge test --fuzz-runs 5000 + forge test --disable-labels --fuzz-runs 5000 ) + gasback-live-system: + name: Gasback live system checks + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Install Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + + - name: Install Foundry Dependencies + run: forge install + + - name: Install TypeScript Dependencies + run: bun install --frozen-lockfile + + - name: Run TypeScript Tests + run: bun run lint + + - name: Run Local Gasback Suite + run: bun run gasback:local + + - name: Run Fork Gasback Suite + run: bun run gasback:fork + codespell: runs-on: ${{ matrix.os }} strategy: @@ -57,4 +99,3 @@ jobs: check_filenames: true ignore_words_list: usera skip: ./.git,package-lock.json,ackee-blockchain-solady-report.pdf,EIP712Mock.sol - diff --git a/.gitignore b/.gitignore index 198f4cb..db81bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,8 @@ wake-coverage.cov create2 # Coverage report -report \ No newline at end of file +report + +# Gasback live test reports +gasback-live-report.json +gasback-live-report.*.json diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..0c44644 --- /dev/null +++ b/bun.lock @@ -0,0 +1,53 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "gasback-by-vectorized", + "dependencies": { + "viem": "^2.33.3", + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="], + + "@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], + + "@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="], + + "abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + + "isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="], + + "ox": ["ox@0.14.20", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "viem": ["viem@2.48.11", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.14.20", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-+WZ5E0dBS6GtKb+1wEk5DeYRRRW42+pFnXCo67Ydodf42sBwO+hu3wnQy66lc4MKmHz+llPVdbyehYr9oTE2iw=="], + + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + } +} diff --git a/live/gasback-live.test.ts b/live/gasback-live.test.ts new file mode 100644 index 0000000..6845856 --- /dev/null +++ b/live/gasback-live.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { + DENOMINATOR, + assertSpendWithinBudget, + buildCanaryGasValues, + computeOracle, + parseWei, + normalizePrivateKeyInput, + receiptFee, + stringifyReport, +} from "./gasback-live"; +import type { TransactionReceipt } from "viem"; + +describe("gasback oracle", () => { + test("computes payout and accrued delta with integer rounding", () => { + const result = computeOracle({ + gasToBurn: 333n, + baseFee: 100n, + ratioNumerator: 600_000_000_000_000_000n, + shareNumerator: 700_000_000_000_000_000n, + maxBaseFee: 1_000n, + gasbackBalanceBefore: 1_000_000n, + }); + + expect(result.ethFromGas).toBe(33_300n); + expect(result.expectedPayout).toBe(19_980n); + expect(result.expectedShare).toBe(23_310n); + expect(result.expectedAccruedDelta).toBe(3_330n); + expect(result.passThrough).toBe(false); + }); + + test("passes through when base fee exceeds max", () => { + const result = computeOracle({ + gasToBurn: 30_000n, + baseFee: 101n, + ratioNumerator: DENOMINATOR, + shareNumerator: DENOMINATOR, + maxBaseFee: 100n, + gasbackBalanceBefore: 3_030_000n, + }); + + expect(result.expectedPayout).toBe(0n); + expect(result.expectedAccruedDelta).toBe(0n); + expect(result.passThrough).toBe(true); + }); + + test("passes through when the local gasback buffer is insufficient", () => { + const result = computeOracle({ + gasToBurn: 30_000n, + baseFee: 100n, + ratioNumerator: DENOMINATOR, + shareNumerator: DENOMINATOR, + maxBaseFee: 100n, + gasbackBalanceBefore: 2_999_999n, + }); + + expect(result.expectedPayout).toBe(0n); + expect(result.expectedAccruedDelta).toBe(0n); + expect(result.passThrough).toBe(true); + }); +}); + +describe("canary guards", () => { + test("selects zero, small, and bounded medium canaries", () => { + expect(buildCanaryGasValues(120_000n)).toEqual([0n, 30_000n, 120_000n]); + expect(buildCanaryGasValues(30_000n)).toEqual([0n, 30_000n]); + expect(buildCanaryGasValues(1n)).toEqual([0n]); + }); + + test("parses decimal and hex wei values", () => { + expect(parseWei("123", "VALUE")).toBe(123n); + expect(parseWei("0x10", "VALUE")).toBe(16n); + expect(() => parseWei("", "VALUE")).toThrow("VALUE is empty"); + expect(() => parseWei("-1", "VALUE")).toThrow("VALUE must be non-negative"); + expect(() => parseWei("1.2", "VALUE")).toThrow("VALUE must be a decimal or hex integer"); + }); + + test("normalizes private keys without accepting malformed input", () => { + const raw = "1".repeat(64); + expect(normalizePrivateKeyInput(raw)).toBe(`0x${raw}`); + expect(normalizePrivateKeyInput(`0x${raw}`)).toBe(`0x${raw}`); + expect(() => normalizePrivateKeyInput("0x1")).toThrow("PRIVATE_KEY must be 32 bytes"); + }); + + test("rejects estimated spend over the remaining budget", () => { + expect(() => assertSpendWithinBudget(10n, 10n, "case")).not.toThrow(); + expect(() => assertSpendWithinBudget(11n, 10n, "case")).toThrow( + "case estimated cost 11 exceeds remaining budget 10", + ); + }); +}); + +describe("reporting", () => { + test("serializes bigint values as strings", () => { + expect(stringifyReport({ value: 1n })).toBe('{\n "value": "1"\n}'); + }); + + test("adds OP Stack fee fields to execution fees when present", () => { + const receipt = { + gasUsed: 10n, + effectiveGasPrice: 20n, + l1Fee: "0x64", + } as unknown as TransactionReceipt; + expect(receiptFee(receipt)).toEqual({ + executionFee: 200n, + extraReceiptFee: 100n, + totalFee: 300n, + }); + }); +}); diff --git a/live/gasback-live.ts b/live/gasback-live.ts new file mode 100644 index 0000000..240f0f3 --- /dev/null +++ b/live/gasback-live.ts @@ -0,0 +1,905 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { + createPublicClient, + createWalletClient, + decodeEventLog, + defineChain, + encodeDeployData, + getAddress, + http, + isAddress, + keccak256, + parseAbi, + type Abi, + type Address, + type Hex, + type PublicClient, + type TransactionReceipt, + type WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +export const SHAPE_SEPOLIA_CHAIN_ID = 11011; +export const DEFAULT_GASBACK_ADDRESS = "0x21e34c5bea9253CDCd57671A1970BB31df4aBe83"; +export const DEFAULT_SPLITTER_ADDRESS = "0x658E643b379b52Cd21605bFaF9C81e84713d8427"; +export const DEFAULT_GASBACK_TEST_CALLER_ADDRESS = + "0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d"; +export const DENOMINATOR = 1_000_000_000_000_000_000n; + +const gasbackAbi = parseAbi([ + "function GASBACK_RATIO_DENOMINATOR() view returns (uint256)", + "function gasbackRatioNumerator() view returns (uint256)", + "function baseFeeVaultShareNumerator() view returns (uint256)", + "function gasbackMaxBaseFee() view returns (uint256)", + "function baseFeeVault() view returns (address)", + "function accrued() view returns (uint256)", +]); + +const splitterAbi = parseAbi([ + "function totalShares() view returns (uint256)", + "function shares(address account) view returns (uint256)", + "function releasable(address account) view returns (uint256)", +]); + +const testCallerAbi = parseAbi(["function GASBACK() view returns (address)"]); + +const vaultAddressAbi = parseAbi([ + "function recipient() view returns (address)", + "function RECIPIENT() view returns (address)", +]); + +const vaultUintAbi = parseAbi([ + "function withdrawalNetwork() view returns (uint256)", + "function WITHDRAWAL_NETWORK() view returns (uint256)", +]); + +export type GasbackOracleInput = { + gasToBurn: bigint; + baseFee: bigint; + ratioNumerator: bigint; + shareNumerator: bigint; + maxBaseFee: bigint; + gasbackBalanceBefore: bigint; +}; + +export type GasbackOracleOutput = { + ethFromGas: bigint; + expectedShare: bigint; + expectedPayout: bigint; + expectedAccruedDelta: bigint; + passThrough: boolean; +}; + +export type LiveConfig = { + rpcUrl: string; + gasback: Address; + splitter: Address; + testCaller: Address; + probe?: Address; + maxWeiSpend?: bigint; + maxGasToBurn?: bigint; + reportPath: string; +}; + +type ForgeArtifact = { + abi: Abi; + bytecode: { object: Hex }; + deployedBytecode: { object: Hex }; +}; + +type GasbackState = { + ratioNumerator: bigint; + shareNumerator: bigint; + maxBaseFee: bigint; + baseFeeVault: Address; + accrued: bigint; + balance: bigint; +}; + +type AuditReport = { + chainId: number; + addresses: { + gasback: Address; + splitter: Address; + testCaller: Address; + }; + deployment: { + gasbackCodeHash: Hex; + localGasbackCodeHash: Hex; + gasbackCodeMatchesArtifact: boolean; + gasbackCodeBytes: number; + splitterCodeBytes: number; + testCallerCodeBytes: number; + }; + gasback: { + ratioNumerator: string; + baseFeeVaultShareNumerator: string; + gasbackMaxBaseFee: string; + baseFeeVault: Address; + baseFeeVaultCodeBytes: number; + }; + splitter: { + totalShares: string; + gasbackShares: string; + impliedGasbackShareNumerator: string; + matchesGasbackShareNumerator: boolean; + releasableToGasback: string; + }; + testCaller: { + gasback: Address; + matchesGasback: boolean; + }; + vault: { + recipientSupported: boolean; + recipient?: Address; + recipientMatchesSplitter?: boolean; + withdrawalNetworkSupported: boolean; + withdrawalNetwork?: string; + withdrawalNetworkIsL2?: boolean; + }; + failures: string[]; +}; + +type ProbeEvent = { + gasToBurn: bigint; + blockBaseFee: bigint; + payout: bigint; + accruedBefore: bigint; + accruedAfter: bigint; + gasbackBalanceBefore: bigint; + gasbackBalanceAfter: bigint; +}; + +type CanaryResult = { + gasToBurn: string; + transactionHash: Hex; + blockNumber: string; + payout: string; + accruedDelta: string; + expectedPayout: string; + expectedAccruedDelta: string; + gasbackBalanceBefore: string; + gasbackBalanceAfter: string; + probeBalanceDelta: string; + executionFee: string; + extraReceiptFee: string; + totalFee: string; + netProbeGainAfterFees: string; + profitable: boolean; + failures: string[]; +}; + +type GasbackLiveReport = { + generatedAt: string; + mode: "report" | "canary"; + audit: AuditReport; + probe?: Address; + canaries: CanaryResult[]; + failures: string[]; +}; + +export class GasbackLiveError extends Error { + constructor(message: string) { + super(message); + this.name = "GasbackLiveError"; + } +} + +export function computeOracle(input: GasbackOracleInput): GasbackOracleOutput { + const ethFromGas = input.gasToBurn * input.baseFee; + const expectedShare = (ethFromGas * input.shareNumerator) / DENOMINATOR; + let expectedPayout = (ethFromGas * input.ratioNumerator) / DENOMINATOR; + let expectedAccruedDelta = expectedShare - expectedPayout; + let passThrough = false; + + if (input.baseFee > input.maxBaseFee || expectedPayout > input.gasbackBalanceBefore) { + expectedPayout = 0n; + expectedAccruedDelta = 0n; + passThrough = true; + } + + return { ethFromGas, expectedShare, expectedPayout, expectedAccruedDelta, passThrough }; +} + +export function buildCanaryGasValues(maxGasToBurn: bigint): bigint[] { + const candidates = [0n, 30_000n, 120_000n] + .filter((value) => value <= maxGasToBurn) + .filter((value, index, values) => values.indexOf(value) === index); + if (candidates.length === 0 || candidates[0] !== 0n) { + candidates.unshift(0n); + } + return candidates; +} + +export function parseWei(value: string, name: string): bigint { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new GasbackLiveError(`${name} is empty`); + } + try { + const parsed = trimmed.startsWith("0x") ? BigInt(trimmed) : BigInt(trimmed); + if (parsed < 0n) { + throw new GasbackLiveError(`${name} must be non-negative`); + } + return parsed; + } catch (error) { + if (error instanceof GasbackLiveError) { + throw error; + } + throw new GasbackLiveError(`${name} must be a decimal or hex integer`); + } +} + +export function normalizePrivateKeyInput(value: string): Hex { + return normalizePrivateKey(value); +} + +function normalizePrivateKey(privateKey: string): Hex { + const normalized = privateKey.startsWith("0x") ? privateKey : `0x${privateKey}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) { + throw new GasbackLiveError("PRIVATE_KEY must be 32 bytes"); + } + return normalized as Hex; +} + +function readPrivateKey(): Hex { + const privateKey = process.env.PRIVATE_KEY; + if (!privateKey) { + throw new GasbackLiveError("PRIVATE_KEY is required"); + } + return normalizePrivateKey(privateKey); +} + +export function assertSpendWithinBudget(cost: bigint, remainingBudget: bigint, label: string) { + if (cost > remainingBudget) { + throw new GasbackLiveError( + `${label} estimated cost ${cost.toString()} exceeds remaining budget ${remainingBudget.toString()}`, + ); + } +} + +export function receiptFee(receipt: TransactionReceipt): { + executionFee: bigint; + extraReceiptFee: bigint; + totalFee: bigint; +} { + const executionFee = receipt.gasUsed * receipt.effectiveGasPrice; + const extraReceiptFee = ["l1Fee", "l1DataFee", "operatorFee"].reduce((sum, key) => { + const value = (receipt as unknown as Record)[key]; + return sum + unknownWei(value); + }, 0n); + return { executionFee, extraReceiptFee, totalFee: executionFee + extraReceiptFee }; +} + +export function stringifyReport(report: unknown): string { + return JSON.stringify( + report, + (_key, value) => (typeof value === "bigint" ? value.toString() : value), + 2, + ); +} + +async function main() { + const mode = process.argv[2]; + if (mode === "local") { + await runProcess("forge", ["test", "--offline", "--disable-labels"]); + return; + } + if (mode === "fork") { + const args = ["test", "--disable-labels", "--match-path", "test/GasbackLiveFork.t.sol"]; + if (!process.env.SHAPE_SEPOLIA_RPC_URL) { + args.splice(1, 0, "--offline"); + } + await runProcess("forge", args); + return; + } + if (mode === "report") { + const config = readConfig(false); + const report = await buildReport(config); + writeReport(config.reportPath, report); + assertNoFailures(report.failures); + return; + } + if (mode === "canary") { + const config = readConfig(true); + const report = await buildCanaryReport(config); + writeReport(config.reportPath, report); + assertNoFailures(report.failures); + return; + } + + throw new GasbackLiveError("Usage: bun run live/gasback-live.ts "); +} + +function readConfig(requireCanary: boolean): LiveConfig { + const rpcUrl = process.env.SHAPE_SEPOLIA_RPC_URL; + if (!rpcUrl) { + throw new GasbackLiveError("SHAPE_SEPOLIA_RPC_URL is required"); + } + + const config: LiveConfig = { + rpcUrl, + gasback: readAddress("GASBACK_ADDRESS", DEFAULT_GASBACK_ADDRESS), + splitter: readAddress("SPLITTER_ADDRESS", DEFAULT_SPLITTER_ADDRESS), + testCaller: readAddress("GASBACK_TEST_CALLER_ADDRESS", DEFAULT_GASBACK_TEST_CALLER_ADDRESS), + probe: process.env.GASBACK_LIVE_PROBE_ADDRESS + ? readAddress("GASBACK_LIVE_PROBE_ADDRESS", process.env.GASBACK_LIVE_PROBE_ADDRESS) + : undefined, + maxWeiSpend: process.env.MAX_WEI_SPEND + ? parseWei(process.env.MAX_WEI_SPEND, "MAX_WEI_SPEND") + : undefined, + maxGasToBurn: process.env.MAX_GAS_TO_BURN + ? parseWei(process.env.MAX_GAS_TO_BURN, "MAX_GAS_TO_BURN") + : undefined, + reportPath: process.env.REPORT_PATH ?? "gasback-live-report.json", + }; + + if (requireCanary) { + if (!process.env.PRIVATE_KEY) { + throw new GasbackLiveError("PRIVATE_KEY is required for canary mode"); + } + if (config.maxWeiSpend === undefined) { + throw new GasbackLiveError("MAX_WEI_SPEND is required for canary mode"); + } + if (config.maxGasToBurn === undefined) { + throw new GasbackLiveError("MAX_GAS_TO_BURN is required for canary mode"); + } + } + + return config; +} + +async function buildReport(config: LiveConfig): Promise { + const client = publicClient(config); + const audit = await runAudit(config, client); + return { + generatedAt: new Date().toISOString(), + mode: "report", + audit, + canaries: [], + failures: [...audit.failures], + }; +} + +async function buildCanaryReport(config: LiveConfig): Promise { + const client = publicClient(config); + const wallet = walletClient(config); + const audit = await runAudit(config, client); + const report: GasbackLiveReport = { + generatedAt: new Date().toISOString(), + mode: "canary", + audit, + canaries: [], + failures: [...audit.failures], + }; + + if (report.failures.length !== 0) { + return report; + } + + let remainingBudget = config.maxWeiSpend ?? 0n; + const account = privateKeyToAccount(readPrivateKey()); + const probe = await resolveProbe(config, client, wallet, account.address, remainingBudget); + report.probe = probe.address; + remainingBudget -= probe.estimatedCost; + + const gasValues = buildCanaryGasValues(config.maxGasToBurn ?? 0n); + for (const gasToBurn of gasValues) { + const result = await runCanaryCase({ + config, + client, + wallet, + probeAddress: probe.address, + account: account.address, + gasToBurn, + remainingBudget, + }); + remainingBudget -= result.estimatedCost; + report.canaries.push(result.result); + report.failures.push(...result.result.failures); + } + + return report; +} + +async function runAudit(config: LiveConfig, client: PublicClient): Promise { + const gasbackArtifact = loadArtifact("out/Gasback.sol/Gasback.json"); + const chainId = await client.getChainId(); + const gasbackCode = await getCode(client, config.gasback); + const splitterCode = await getCode(client, config.splitter); + const testCallerCode = await getCode(client, config.testCaller); + + const state = await readGasbackState(client, config.gasback); + const [denominator, totalShares, gasbackShares, releasableToGasback, testCallerGasback] = + await Promise.all([ + client.readContract({ + address: config.gasback, + abi: gasbackAbi, + functionName: "GASBACK_RATIO_DENOMINATOR", + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "totalShares", + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "shares", + args: [config.gasback], + }), + client.readContract({ + address: config.splitter, + abi: splitterAbi, + functionName: "releasable", + args: [config.gasback], + }), + client.readContract({ + address: config.testCaller, + abi: testCallerAbi, + functionName: "GASBACK", + }), + ]); + + const baseFeeVaultCode = await getCode(client, state.baseFeeVault); + const impliedShare = totalShares === 0n ? 0n : (gasbackShares * DENOMINATOR) / totalShares; + const liveCodeHash = keccak256(gasbackCode); + const localCodeHash = keccak256(gasbackArtifact.deployedBytecode.object); + const vault = await readVaultAudit(client, state.baseFeeVault, config.splitter); + + const report: AuditReport = { + chainId, + addresses: { + gasback: config.gasback, + splitter: config.splitter, + testCaller: config.testCaller, + }, + deployment: { + gasbackCodeHash: liveCodeHash, + localGasbackCodeHash: localCodeHash, + gasbackCodeMatchesArtifact: liveCodeHash === localCodeHash, + gasbackCodeBytes: byteLength(gasbackCode), + splitterCodeBytes: byteLength(splitterCode), + testCallerCodeBytes: byteLength(testCallerCode), + }, + gasback: { + ratioNumerator: state.ratioNumerator.toString(), + baseFeeVaultShareNumerator: state.shareNumerator.toString(), + gasbackMaxBaseFee: state.maxBaseFee.toString(), + baseFeeVault: state.baseFeeVault, + baseFeeVaultCodeBytes: byteLength(baseFeeVaultCode), + }, + splitter: { + totalShares: totalShares.toString(), + gasbackShares: gasbackShares.toString(), + impliedGasbackShareNumerator: impliedShare.toString(), + matchesGasbackShareNumerator: impliedShare === state.shareNumerator, + releasableToGasback: releasableToGasback.toString(), + }, + testCaller: { + gasback: getAddress(testCallerGasback), + matchesGasback: getAddress(testCallerGasback) === config.gasback, + }, + vault, + failures: [], + }; + + if (chainId !== SHAPE_SEPOLIA_CHAIN_ID) { + report.failures.push(`wrong chain id: expected ${SHAPE_SEPOLIA_CHAIN_ID}, got ${chainId}`); + } + if (gasbackCode === "0x") report.failures.push("gasback has no code"); + if (splitterCode === "0x") report.failures.push("splitter has no code"); + if (testCallerCode === "0x") report.failures.push("test caller has no code"); + if (!report.deployment.gasbackCodeMatchesArtifact) { + report.failures.push("gasback deployed bytecode does not match local artifact"); + } + if (denominator !== DENOMINATOR) { + report.failures.push(`unexpected denominator: ${denominator.toString()}`); + } + if (state.ratioNumerator > state.shareNumerator) { + report.failures.push("gasback ratio numerator exceeds base fee vault share numerator"); + } + if (state.shareNumerator > DENOMINATOR) { + report.failures.push("base fee vault share numerator exceeds denominator"); + } + if (baseFeeVaultCode === "0x") { + report.failures.push("base fee vault has no code"); + } + if (totalShares === 0n) { + report.failures.push("splitter total shares is zero"); + } + if (gasbackShares === 0n) { + report.failures.push("splitter gives gasback zero shares"); + } + if (impliedShare !== state.shareNumerator) { + report.failures.push( + `splitter implied gasback share ${impliedShare.toString()} does not match gasback share numerator ${state.shareNumerator.toString()}`, + ); + } + if (!report.testCaller.matchesGasback) { + report.failures.push("test caller points at a different gasback address"); + } + if (vault.recipientSupported && !vault.recipientMatchesSplitter) { + report.failures.push("base fee vault recipient does not match splitter"); + } + if (vault.withdrawalNetworkSupported && !vault.withdrawalNetworkIsL2) { + report.failures.push("base fee vault withdrawal network is not 1"); + } + + return report; +} + +async function runCanaryCase(input: { + config: LiveConfig; + client: PublicClient; + wallet: WalletClient; + probeAddress: Address; + account: Address; + gasToBurn: bigint; + remainingBudget: bigint; +}): Promise<{ estimatedCost: bigint; result: CanaryResult }> { + if (input.config.maxGasToBurn !== undefined && input.gasToBurn > input.config.maxGasToBurn) { + throw new GasbackLiveError( + `gasToBurn ${input.gasToBurn.toString()} exceeds MAX_GAS_TO_BURN ${input.config.maxGasToBurn.toString()}`, + ); + } + + const state = await readGasbackState(input.client, input.config.gasback); + const block = await input.client.getBlock(); + const baseFee = block.baseFeePerGas ?? 0n; + const preOracle = computeOracle({ + gasToBurn: input.gasToBurn, + baseFee, + ratioNumerator: state.ratioNumerator, + shareNumerator: state.shareNumerator, + maxBaseFee: state.maxBaseFee, + gasbackBalanceBefore: state.balance, + }); + + if (input.gasToBurn !== 0n && preOracle.passThrough) { + throw new GasbackLiveError( + `refusing canary gasToBurn ${input.gasToBurn.toString()} because the current buffer would pass through`, + ); + } + + const probeArtifact = loadArtifact("out/GasbackLiveProbe.sol/GasbackLiveProbe.json"); + await input.client.simulateContract({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + }); + const gas = await input.client.estimateContractGas({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + }); + const estimatedCost = gas * (await feeCap(input.client)); + assertSpendWithinBudget( + estimatedCost, + input.remainingBudget, + `probe(${input.gasToBurn.toString()})`, + ); + + const probeBalanceBefore = await input.client.getBalance({ address: input.probeAddress }); + const hash = await input.wallet.writeContract({ + address: input.probeAddress, + abi: probeArtifact.abi, + functionName: "probe", + args: [input.gasToBurn], + account: input.account, + chain: shapeSepolia(input.config.rpcUrl), + }); + const receipt = await input.client.waitForTransactionReceipt({ hash }); + const probeBalanceAfter = await input.client.getBalance({ address: input.probeAddress }); + const event = decodeProbeResult(probeArtifact.abi, input.probeAddress, receipt); + const fee = receiptFee(receipt); + const accruedDelta = event.accruedAfter - event.accruedBefore; + const probeBalanceDelta = probeBalanceAfter - probeBalanceBefore; + const oracle = computeOracle({ + gasToBurn: event.gasToBurn, + baseFee: event.blockBaseFee, + ratioNumerator: state.ratioNumerator, + shareNumerator: state.shareNumerator, + maxBaseFee: state.maxBaseFee, + gasbackBalanceBefore: event.gasbackBalanceBefore, + }); + const netProbeGainAfterFees = probeBalanceDelta - fee.totalFee; + const failures: string[] = []; + + if (event.payout !== oracle.expectedPayout) { + failures.push( + `payout mismatch: expected ${oracle.expectedPayout.toString()}, got ${event.payout.toString()}`, + ); + } + if (accruedDelta !== oracle.expectedAccruedDelta) { + failures.push( + `accrued delta mismatch: expected ${oracle.expectedAccruedDelta.toString()}, got ${accruedDelta.toString()}`, + ); + } + if (probeBalanceDelta !== event.payout) { + failures.push( + `probe balance delta ${probeBalanceDelta.toString()} does not equal payout ${event.payout.toString()}`, + ); + } + if (netProbeGainAfterFees > 0n) { + failures.push( + `profitable canary observed: net probe gain after fees ${netProbeGainAfterFees.toString()}`, + ); + } + + return { + estimatedCost, + result: { + gasToBurn: event.gasToBurn.toString(), + transactionHash: receipt.transactionHash, + blockNumber: receipt.blockNumber.toString(), + payout: event.payout.toString(), + accruedDelta: accruedDelta.toString(), + expectedPayout: oracle.expectedPayout.toString(), + expectedAccruedDelta: oracle.expectedAccruedDelta.toString(), + gasbackBalanceBefore: event.gasbackBalanceBefore.toString(), + gasbackBalanceAfter: event.gasbackBalanceAfter.toString(), + probeBalanceDelta: probeBalanceDelta.toString(), + executionFee: fee.executionFee.toString(), + extraReceiptFee: fee.extraReceiptFee.toString(), + totalFee: fee.totalFee.toString(), + netProbeGainAfterFees: netProbeGainAfterFees.toString(), + profitable: netProbeGainAfterFees > 0n, + failures, + }, + }; +} + +async function resolveProbe( + config: LiveConfig, + client: PublicClient, + wallet: WalletClient, + account: Address, + remainingBudget: bigint, +): Promise<{ address: Address; estimatedCost: bigint }> { + const artifact = loadArtifact("out/GasbackLiveProbe.sol/GasbackLiveProbe.json"); + if (config.probe) { + const code = await getCode(client, config.probe); + if (code === "0x") { + throw new GasbackLiveError("GASBACK_LIVE_PROBE_ADDRESS has no code"); + } + const target = await client.readContract({ + address: config.probe, + abi: artifact.abi, + functionName: "GASBACK", + }); + if (getAddress(target as Address) !== config.gasback) { + throw new GasbackLiveError("GASBACK_LIVE_PROBE_ADDRESS points at a different gasback"); + } + return { address: config.probe, estimatedCost: 0n }; + } + + const data = encodeDeployData({ + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [config.gasback], + }); + const gas = await client.estimateGas({ account, data }); + const estimatedCost = gas * (await feeCap(client)); + assertSpendWithinBudget(estimatedCost, remainingBudget, "GasbackLiveProbe deployment"); + + const hash = await wallet.deployContract({ + abi: artifact.abi, + bytecode: artifact.bytecode.object, + args: [config.gasback], + account, + chain: shapeSepolia(config.rpcUrl), + }); + const receipt = await client.waitForTransactionReceipt({ hash }); + if (!receipt.contractAddress) { + throw new GasbackLiveError("probe deployment receipt did not include a contract address"); + } + return { address: getAddress(receipt.contractAddress), estimatedCost }; +} + +async function readGasbackState(client: PublicClient, gasback: Address): Promise { + const [ + ratioNumerator, + shareNumerator, + maxBaseFee, + baseFeeVault, + accrued, + balance, + ] = await Promise.all([ + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "gasbackRatioNumerator" }), + client.readContract({ + address: gasback, + abi: gasbackAbi, + functionName: "baseFeeVaultShareNumerator", + }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "gasbackMaxBaseFee" }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "baseFeeVault" }), + client.readContract({ address: gasback, abi: gasbackAbi, functionName: "accrued" }), + client.getBalance({ address: gasback }), + ]); + return { + ratioNumerator, + shareNumerator, + maxBaseFee, + baseFeeVault: getAddress(baseFeeVault), + accrued, + balance, + }; +} + +async function readVaultAudit( + client: PublicClient, + vault: Address, + expectedRecipient: Address, +): Promise { + const recipient = await tryReadVaultAddress(client, vault, "recipient"); + const recipientFallback = recipient.supported + ? recipient + : await tryReadVaultAddress(client, vault, "RECIPIENT"); + const network = await tryReadVaultUint(client, vault, "withdrawalNetwork"); + const networkFallback = network.supported + ? network + : await tryReadVaultUint(client, vault, "WITHDRAWAL_NETWORK"); + + return { + recipientSupported: recipientFallback.supported, + recipient: recipientFallback.value, + recipientMatchesSplitter: recipientFallback.value + ? getAddress(recipientFallback.value) === expectedRecipient + : undefined, + withdrawalNetworkSupported: networkFallback.supported, + withdrawalNetwork: networkFallback.value?.toString(), + withdrawalNetworkIsL2: networkFallback.value === undefined ? undefined : networkFallback.value === 1n, + }; +} + +async function tryReadVaultAddress( + client: PublicClient, + address: Address, + functionName: "recipient" | "RECIPIENT", +): Promise<{ supported: boolean; value?: Address }> { + try { + const value = await client.readContract({ + address, + abi: vaultAddressAbi, + functionName, + }); + return { supported: true, value: getAddress(value) }; + } catch { + return { supported: false }; + } +} + +async function tryReadVaultUint( + client: PublicClient, + address: Address, + functionName: "withdrawalNetwork" | "WITHDRAWAL_NETWORK", +): Promise<{ supported: boolean; value?: bigint }> { + try { + const value = await client.readContract({ + address, + abi: vaultUintAbi, + functionName, + }); + return { supported: true, value }; + } catch { + return { supported: false }; + } +} + +function publicClient(config: LiveConfig): PublicClient { + return createPublicClient({ + chain: shapeSepolia(config.rpcUrl), + transport: http(config.rpcUrl), + }); +} + +function walletClient(config: LiveConfig): WalletClient { + return createWalletClient({ + account: privateKeyToAccount(readPrivateKey()), + chain: shapeSepolia(config.rpcUrl), + transport: http(config.rpcUrl), + }); +} + +function shapeSepolia(rpcUrl: string) { + return defineChain({ + id: SHAPE_SEPOLIA_CHAIN_ID, + name: "Shape Sepolia", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [rpcUrl] } }, + }); +} + +async function getCode(client: PublicClient, address: Address): Promise { + return (await client.getBytecode({ address })) ?? "0x"; +} + +async function feeCap(client: PublicClient): Promise { + const fees = await client.estimateFeesPerGas().catch(() => undefined); + if (fees?.maxFeePerGas !== undefined) { + return fees.maxFeePerGas; + } + return client.getGasPrice(); +} + +function decodeProbeResult(abi: Abi, probe: Address, receipt: TransactionReceipt): ProbeEvent { + for (const log of receipt.logs) { + if (getAddress(log.address) !== probe) continue; + try { + const decoded = decodeEventLog({ abi, data: log.data, topics: log.topics }); + if (decoded.eventName !== "ProbeResult") continue; + return decoded.args as unknown as ProbeEvent; + } catch { + continue; + } + } + throw new GasbackLiveError("ProbeResult event not found in transaction receipt"); +} + +function loadArtifact(path: string): ForgeArtifact { + if (!existsSync(path)) { + throw new GasbackLiveError(`missing artifact ${path}; run forge build first`); + } + return JSON.parse(readFileSync(path, "utf8")) as ForgeArtifact; +} + +function readAddress(name: string, fallback: string): Address { + const value = process.env[name] ?? fallback; + if (!isAddress(value)) { + throw new GasbackLiveError(`${name} is not a valid address`); + } + return getAddress(value); +} + +function byteLength(hex: Hex): number { + return hex === "0x" ? 0 : (hex.length - 2) / 2; +} + +function unknownWei(value: unknown): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + if (typeof value === "string" && value.length !== 0) return BigInt(value); + return 0n; +} + +function assertNoFailures(failures: string[]) { + if (failures.length !== 0) { + throw new GasbackLiveError(failures.join("\n")); + } +} + +function writeReport(path: string, report: GasbackLiveReport) { + const parent = dirname(path); + if (parent !== "." && !existsSync(parent)) { + mkdirSync(parent, { recursive: true }); + } + writeFileSync(path, `${stringifyReport(report)}\n`); +} + +function runProcess(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: "inherit", env: process.env }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new GasbackLiveError(`${command} ${args.join(" ")} exited with ${code}`)); + } + }); + }); +} + +if (import.meta.main) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd57840 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "gasback-by-vectorized", + "private": true, + "type": "module", + "scripts": { + "gasback:local": "bun run live/gasback-live.ts local", + "gasback:fork": "bun run live/gasback-live.ts fork", + "gasback:report": "bun run live/gasback-live.ts report", + "gasback:canary": "bun run live/gasback-live.ts canary", + "test:ts": "bun test live", + "check-types": "tsc --noEmit", + "lint": "bun test live && tsc --noEmit" + }, + "dependencies": { + "viem": "^2.33.3" + }, + "devDependencies": { + "@types/bun": "^1.3.1", + "typescript": "^5.9.3" + } +} diff --git a/src/test/GasbackLiveProbe.sol b/src/test/GasbackLiveProbe.sol new file mode 100644 index 0000000..add8584 --- /dev/null +++ b/src/test/GasbackLiveProbe.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +interface IGasbackLiveProbeTarget { + function accrued() external view returns (uint256); +} + +contract GasbackLiveProbe { + error NotOwner(); + error ZeroAddress(); + error InvalidGasbackAddress(); + error GasbackCallFailed(); + error UnexpectedReturnData(); + error WithdrawFailed(); + + event ProbeResult( + uint256 gasToBurn, + uint256 blockBaseFee, + uint256 payout, + uint256 accruedBefore, + uint256 accruedAfter, + uint256 gasbackBalanceBefore, + uint256 gasbackBalanceAfter + ); + + address public immutable GASBACK; + address public owner; + + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; + } + + constructor(address gasback) { + if (gasback == address(0)) revert ZeroAddress(); + if (gasback.code.length == 0) revert InvalidGasbackAddress(); + GASBACK = gasback; + owner = msg.sender; + } + + function probe(uint256 gasToBurn) external returns (uint256 payout) { + address gasback = GASBACK; + uint256 accruedBefore = IGasbackLiveProbeTarget(gasback).accrued(); + uint256 gasbackBalanceBefore = gasback.balance; + + (bool success, bytes memory data) = gasback.call(abi.encode(gasToBurn)); + if (!success) revert GasbackCallFailed(); + if (data.length != 32) revert UnexpectedReturnData(); + payout = abi.decode(data, (uint256)); + + emit ProbeResult( + gasToBurn, + block.basefee, + payout, + accruedBefore, + IGasbackLiveProbeTarget(gasback).accrued(), + gasbackBalanceBefore, + gasback.balance + ); + } + + function withdraw(address payable to, uint256 amount) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + (bool success,) = to.call{value: amount}(""); + if (!success) revert WithdrawFailed(); + } + + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + owner = newOwner; + } + + receive() external payable {} +} diff --git a/test/GasbackLiveFork.t.sol b/test/GasbackLiveFork.t.sol new file mode 100644 index 0000000..23bc01c --- /dev/null +++ b/test/GasbackLiveFork.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import "./utils/SoladyTest.sol"; +import {GasbackLiveProbe} from "../src/test/GasbackLiveProbe.sol"; + +interface IGasbackLiveFork { + function GASBACK_RATIO_DENOMINATOR() external view returns (uint256); + function gasbackRatioNumerator() external view returns (uint256); + function baseFeeVaultShareNumerator() external view returns (uint256); + function gasbackMaxBaseFee() external view returns (uint256); + function baseFeeVault() external view returns (address); + function accrued() external view returns (uint256); + function setBaseFeeVault(address value) external returns (bool); + function setGasbackMaxBaseFee(uint256 value) external returns (bool); +} + +interface IShapePaymentSplitterLiveFork { + function totalShares() external view returns (uint256); + function shares(address account) external view returns (uint256); + function releasable(address account) external view returns (uint256); +} + +interface IGasbackTestCallerLiveFork { + function GASBACK() external view returns (address); +} + +contract RejectingLiveCaller { + function trigger(address target, uint256 gasToBurn) external returns (uint256 payout) { + (bool success, bytes memory data) = target.call(abi.encode(gasToBurn)); + require(success); + payout = abi.decode(data, (uint256)); + } + + receive() external payable { + revert(); + } +} + +contract GasbackLiveForkTest is SoladyTest { + address internal constant SYSTEM_ADDRESS = 0xffffFFFfFFffffffffffffffFfFFFfffFFFfFFfE; + address internal constant GASBACK = 0x21E34c5bea9253CDCd57671A1970BB31df4aBe83; + address internal constant SPLITTER = 0x658e643B379b52cD21605bfAf9c81e84713D8427; + address internal constant TEST_CALLER = 0xA53D127f193858f5ef2Cf50dd1B3A94198ef811d; + uint256 internal constant SHAPE_SEPOLIA_CHAIN_ID = 11011; + uint256 internal constant DENOMINATOR = 1 ether; + + IGasbackLiveFork internal gasback; + IShapePaymentSplitterLiveFork internal splitter; + + function setUp() public { + if (!vm.envExists("SHAPE_SEPOLIA_RPC_URL")) { + vm.skip(true, "SHAPE_SEPOLIA_RPC_URL not set"); + return; + } + + vm.createSelectFork(vm.envString("SHAPE_SEPOLIA_RPC_URL")); + gasback = IGasbackLiveFork(GASBACK); + splitter = IShapePaymentSplitterLiveFork(SPLITTER); + } + + function test_liveDeploymentConfiguration() public view { + assertEq(block.chainid, SHAPE_SEPOLIA_CHAIN_ID); + assertGt(GASBACK.code.length, 0); + assertGt(SPLITTER.code.length, 0); + assertGt(TEST_CALLER.code.length, 0); + assertEq(keccak256(GASBACK.code), keccak256(vm.getDeployedCode("Gasback.sol:Gasback"))); + assertEq(IGasbackTestCallerLiveFork(TEST_CALLER).GASBACK(), GASBACK); + + uint256 ratio = gasback.gasbackRatioNumerator(); + uint256 share = gasback.baseFeeVaultShareNumerator(); + assertLe(ratio, share); + assertLe(share, DENOMINATOR); + assertEq(gasback.GASBACK_RATIO_DENOMINATOR(), DENOMINATOR); + assertGt(gasback.baseFeeVault().code.length, 0); + } + + function test_liveSplitterShareMatchesGasbackShareNumerator() public view { + uint256 totalShares = splitter.totalShares(); + uint256 gasbackShares = splitter.shares(GASBACK); + assertGt(totalShares, 0); + assertGt(gasbackShares, 0); + assertEq((gasbackShares * DENOMINATOR) / totalShares, gasback.baseFeeVaultShareNumerator()); + } + + function test_liveBaseFeeVaultConfigurationWhenAbiSupported() public view { + address vault = gasback.baseFeeVault(); + (bool hasRecipient, address recipient) = _tryReadAddress(vault, "recipient()"); + if (hasRecipient) { + assertEq(recipient, SPLITTER); + } + + (bool hasWithdrawalNetwork, uint256 withdrawalNetwork) = + _tryReadUint(vault, "withdrawalNetwork()"); + if (hasWithdrawalNetwork) { + assertEq(withdrawalNetwork, 1); + } + } + + function test_forkProbePayoutOracleWithFundedBuffer() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + + uint256 accruedBefore = gasback.accrued(); + vm.deal(GASBACK, expectedPayout + 1 ether); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 probeBalanceBefore = address(probe).balance; + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, expectedPayout); + assertEq(address(probe).balance - probeBalanceBefore, expectedPayout); + assertEq(gasback.accrued() - accruedBefore, expectedAccruedDelta); + } + + function test_forkPassThroughWhenBufferIsInsufficientAndVaultDisabled() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + vm.prank(SYSTEM_ADDRESS); + gasback.setBaseFeeVault(address(0)); + + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + if (expectedPayout == 0) { + expectedPayout = 1; + } + + vm.deal(GASBACK, expectedPayout - 1); + uint256 accruedBefore = gasback.accrued(); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, 0); + assertEq(address(probe).balance, 0); + assertEq(gasback.accrued(), accruedBefore); + assertEq(GASBACK.balance, expectedPayout - 1); + } + + function test_forkPassThroughWhenBaseFeeExceedsMax() public { + uint256 baseFee = 100; + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + vm.prank(SYSTEM_ADDRESS); + gasback.setGasbackMaxBaseFee(baseFee - 1); + + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + vm.deal(GASBACK, expectedPayout + 1 ether); + uint256 accruedBefore = gasback.accrued(); + + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + uint256 payout = probe.probe(gasToBurn); + + assertEq(payout, 0); + assertEq(address(probe).balance, 0); + assertEq(gasback.accrued(), accruedBefore); + } + + function test_forkRepeatedSameBlockCallsAreAdditive() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 12_000; + uint256 calls = 3; + vm.fee(baseFee); + + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + + vm.deal(GASBACK, calls * expectedPayout + 1 ether); + uint256 accruedBefore = gasback.accrued(); + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + + for (uint256 i = 0; i < calls; i++) { + assertEq(probe.probe(gasToBurn), expectedPayout); + } + + assertEq(address(probe).balance, calls * expectedPayout); + assertEq(gasback.accrued() - accruedBefore, calls * expectedAccruedDelta); + } + + function test_forkBoundedStressSweep() public { + uint256[5] memory gasValues = [uint256(0), 1, 30_000, 120_000, 250_000]; + uint256[3] memory baseFees = [uint256(0), 1, _boundedBaseFee()]; + GasbackLiveProbe probe = new GasbackLiveProbe(GASBACK); + + for (uint256 i = 0; i < baseFees.length; i++) { + vm.fee(baseFees[i]); + for (uint256 j = 0; j < gasValues.length; j++) { + (uint256 expectedPayout, uint256 expectedAccruedDelta) = + _expectedPayoutAndAccruedDelta(baseFees[i], gasValues[j]); + uint256 accruedBefore = gasback.accrued(); + uint256 probeBalanceBefore = address(probe).balance; + vm.deal(GASBACK, expectedPayout + 1 ether); + + uint256 payout = probe.probe(gasValues[j]); + + assertEq(payout, expectedPayout); + assertEq(address(probe).balance - probeBalanceBefore, expectedPayout); + assertEq(gasback.accrued() - accruedBefore, expectedAccruedDelta); + } + } + } + + function test_forkRejectingReceiverStillReceivesPayout() public { + uint256 baseFee = _boundedBaseFee(); + uint256 gasToBurn = 30_000; + vm.fee(baseFee); + (uint256 expectedPayout,) = _expectedPayoutAndAccruedDelta(baseFee, gasToBurn); + vm.deal(GASBACK, expectedPayout + 1 ether); + + RejectingLiveCaller caller = new RejectingLiveCaller(); + uint256 payout = caller.trigger(GASBACK, gasToBurn); + + assertEq(payout, expectedPayout); + assertEq(address(caller).balance, expectedPayout); + } + + function test_forkInvalidCalldataReverts() public { + (bool success,) = GASBACK.call(hex"01"); + assertFalse(success); + } + + function test_forkSplitterReleasableReadDoesNotRevert() public view { + splitter.releasable(GASBACK); + } + + function _boundedBaseFee() internal view returns (uint256 baseFee) { + baseFee = gasback.gasbackMaxBaseFee(); + if (baseFee == 0) { + return 0; + } + if (baseFee > 1 gwei) { + return 1 gwei; + } + } + + function _expectedPayoutAndAccruedDelta(uint256 baseFee, uint256 gasToBurn) + internal + view + returns (uint256 expectedPayout, uint256 expectedAccruedDelta) + { + uint256 ethFromGas = baseFee * gasToBurn; + uint256 expectedShare = (ethFromGas * gasback.baseFeeVaultShareNumerator()) / DENOMINATOR; + expectedPayout = (ethFromGas * gasback.gasbackRatioNumerator()) / DENOMINATOR; + expectedAccruedDelta = expectedShare - expectedPayout; + } + + function _tryReadAddress(address target, string memory signature) + internal + view + returns (bool ok, address value) + { + bytes memory data; + (ok, data) = target.staticcall(abi.encodeWithSignature(signature)); + if (ok && data.length == 32) { + value = abi.decode(data, (address)); + } else { + ok = false; + } + } + + function _tryReadUint(address target, string memory signature) + internal + view + returns (bool ok, uint256 value) + { + bytes memory data; + (ok, data) = target.staticcall(abi.encodeWithSignature(signature)); + if (ok && data.length == 32) { + value = abi.decode(data, (uint256)); + } else { + ok = false; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c4772fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": false, + "isolatedModules": true, + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023", + "types": ["bun"] + }, + "include": ["live/**/*.ts"] +}