From 3032a7c16b50203033a6cd20c93232a9aa7fea60 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Mon, 15 Dec 2025 17:16:31 -0800 Subject: [PATCH 01/33] feat: add split method to Splitter --- src/Splitter.sol | 49 ++++++++ test/Splitter.t.sol | 285 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) diff --git a/src/Splitter.sol b/src/Splitter.sol index a3beeb1..67c78e5 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -8,6 +8,7 @@ import { UUPSUpgradeable } from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; +import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; /// @title Splitter /// @author ScopeLift @@ -16,6 +17,8 @@ import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; /// to configured distributors based on their weights. /// @custom:security-contact security@matterlabs.dev contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { + using SafeERC20 for IERC20Burnable; + /// @notice Role identifier for emergency admin who can update settings without governance delay. bytes32 public constant EMERGENCY_ADMIN_ROLE = keccak256("EMERGENCY_ADMIN_ROLE"); @@ -36,6 +39,12 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @param newBurnBps The new burn percentage in basis points. event BurnPercentageSet(uint256 oldBurnBps, uint256 newBurnBps); + /// @notice Emitted when tokens are split between burning and distributors. + /// @param amount The total amount of tokens that were split. + /// @param burned The amount of tokens that were burned (includes dust from rounding). + /// @param distributed The amount of tokens that were distributed to distributors. + event Split(uint256 amount, uint256 burned, uint256 distributed); + /// @notice Thrown when an invalid address is provided where a valid address is required. error Splitter_InvalidAddress(); @@ -48,6 +57,9 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @notice Thrown when an invalid burn percentage is provided. error Splitter_InvalidBurnPercentage(); + /// @notice Thrown when the received token amount does not match the expected amount. + error Splitter_AmountMismatch(); + /// @custom:storage-location erc7201:storage.Splitter struct SplitterStorage { IERC20Burnable _splitToken; @@ -132,6 +144,43 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { return $._distributors; } + /// @notice Splits tokens between burning and distributors based on configured percentages. + /// @dev Caller must approve this contract to spend the split token before calling. + /// Any dust from rounding is burned along with the burn portion. + /// @param _amount The amount of tokens to split. + function split(uint256 _amount) external { + SplitterStorage storage $ = _getSplitterStorage(); + + // Transfer tokens from caller (requires prior approval) + uint256 _balanceBefore = $._splitToken.balanceOf(address(this)); + $._splitToken.safeTransferFrom(msg.sender, address(this), _amount); + uint256 _balanceAfter = $._splitToken.balanceOf(address(this)); + + // Verify received amount matches expected + if (_balanceAfter - _balanceBefore != _amount) revert Splitter_AmountMismatch(); + + // Calculate burn amount + uint256 _burnAmount = (_amount * $._burnBps) / 10_000; + uint256 _distributeAmount = _amount - _burnAmount; + + // Distribute to each distributor based on weight + uint256 _totalDistributed; + uint256 _totalWeight = $._totalWeight; + for (uint256 _i; _i < $._distributors.length; ++_i) { + uint256 _share = (_distributeAmount * $._distributors[_i].weight) / _totalWeight; + if (_share > 0) { + $._splitToken.safeTransfer($._distributors[_i].recipient, _share); + _totalDistributed += _share; + } + } + + // Burn the burn amount + any dust from rounding + uint256 _totalBurned = _amount - _totalDistributed; + if (_totalBurned > 0) $._splitToken.burn(_totalBurned); + + emit Split(_amount, _totalBurned, _totalDistributed); + } + /// @notice Sets the burn percentage. /// @param _newBurnBps The new burn percentage in basis points (0-10000). function setBurnPercentage(uint256 _newBurnBps) external { diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 6569d88..d013740 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -700,3 +700,288 @@ contract SetBurnPercentage is SplitterTest { assertEq(splitter.burnPercentage(), DEFAULT_BURN_BPS); } } + +contract Split is SplitterTest { + address internal caller; + + function setUp() public override { + super.setUp(); + caller = makeAddr("caller"); + } + + function _setupDistributorAndBurn(address _recipient, uint256 _weight, uint256 _burnBps) + internal + { + Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient, _weight); + vm.prank(admin); + splitter.setDistributors(_distributors); + vm.prank(admin); + splitter.setBurnPercentage(_burnBps); + } + + function _mintAndApprove(address _to, uint256 _amount) internal { + splitToken.mint(_to, _amount); + vm.prank(_to); + splitToken.approve(address(splitter), _amount); + } + + function testFuzz_Split_BurnsCorrectAmount( + address _recipient, + uint256 _weight, + uint256 _burnBps, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _burnBps = bound(_burnBps, 0, 10_000); + _amount = bound(_amount, 1, type(uint128).max); + + _setupDistributorAndBurn(_recipient, _weight, _burnBps); + _mintAndApprove(caller, _amount); + + uint256 _expectedBurn = (_amount * _burnBps) / 10_000; + uint256 _expectedDistribute = _amount - _expectedBurn; + + vm.prank(caller); + splitter.split(_amount); + + assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_DistributesToSingleDistributor( + address _recipient, + uint256 _weight, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _amount = bound(_amount, 1, type(uint128).max); + + // Set 0% burn so all goes to distributor + _setupDistributorAndBurn(_recipient, _weight, 0); + _mintAndApprove(caller, _amount); + + vm.prank(caller); + splitter.split(_amount); + + assertEq(splitToken.balanceOf(_recipient), _amount); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_DistributesToMultipleDistributors( + address _recipient1, + address _recipient2, + uint256 _weight1, + uint256 _weight2, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient1); + _assumeNonZeroAddress(_recipient2); + vm.assume(_recipient1 != _recipient2); + _weight1 = _boundWeight(_weight1); + _weight2 = _boundWeight(_weight2); + // Ensure weights don't overflow when added + vm.assume(_weight1 <= type(uint96).max / 2); + vm.assume(_weight2 <= type(uint96).max / 2); + _amount = bound(_amount, 1, type(uint128).max); + + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, _weight1, _recipient2, _weight2); + vm.prank(admin); + splitter.setDistributors(_distributors); + vm.prank(admin); + splitter.setBurnPercentage(0); + + _mintAndApprove(caller, _amount); + + uint256 _totalWeight = _weight1 + _weight2; + uint256 _expectedShare1 = (_amount * _weight1) / _totalWeight; + uint256 _expectedShare2 = (_amount * _weight2) / _totalWeight; + + vm.prank(caller); + splitter.split(_amount); + + assertEq(splitToken.balanceOf(_recipient1), _expectedShare1); + assertEq(splitToken.balanceOf(_recipient2), _expectedShare2); + // Any dust should have been burned, not left in contract + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_100PercentBurn(uint256 _amount) public { + _amount = bound(_amount, 1, type(uint128).max); + + // Default is 100% burn with no distributors + _mintAndApprove(caller, _amount); + + uint256 _totalSupplyBefore = splitToken.totalSupply(); + + vm.prank(caller); + splitter.split(_amount); + + assertEq(splitToken.totalSupply(), _totalSupplyBefore - _amount); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_0PercentBurn(address _recipient, uint256 _weight, uint256 _amount) + public + { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _amount = bound(_amount, 1, type(uint128).max); + + _setupDistributorAndBurn(_recipient, _weight, 0); + _mintAndApprove(caller, _amount); + + uint256 _totalSupplyBefore = splitToken.totalSupply(); + + vm.prank(caller); + splitter.split(_amount); + + // No burn, all to distributor + assertEq(splitToken.totalSupply(), _totalSupplyBefore); + assertEq(splitToken.balanceOf(_recipient), _amount); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_BurnsDustFromRounding( + address _recipient1, + address _recipient2, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient1); + _assumeNonZeroAddress(_recipient2); + vm.assume(_recipient1 != _recipient2); + // Use an amount that will produce dust with these weights + _amount = bound(_amount, 100, type(uint128).max); + + // Set up 2 distributors with weights that will cause rounding + // weights 1 and 2 -> total 3, amounts not divisible by 3 will have dust + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, 1, _recipient2, 2); + vm.prank(admin); + splitter.setDistributors(_distributors); + vm.prank(admin); + splitter.setBurnPercentage(0); + + _mintAndApprove(caller, _amount); + + uint256 _totalSupplyBefore = splitToken.totalSupply(); + uint256 _share1 = (_amount * 1) / 3; + uint256 _share2 = (_amount * 2) / 3; + uint256 _dust = _amount - _share1 - _share2; + + vm.prank(caller); + splitter.split(_amount); + + assertEq(splitToken.balanceOf(_recipient1), _share1); + assertEq(splitToken.balanceOf(_recipient2), _share2); + // Dust was burned + assertEq(splitToken.totalSupply(), _totalSupplyBefore - _dust); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_EmitsEvent( + address _recipient, + uint256 _weight, + uint256 _burnBps, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _burnBps = bound(_burnBps, 0, 10_000); + _amount = bound(_amount, 1, type(uint128).max); + + _setupDistributorAndBurn(_recipient, _weight, _burnBps); + _mintAndApprove(caller, _amount); + + uint256 _expectedBurn = (_amount * _burnBps) / 10_000; + uint256 _expectedDistribute = _amount - _expectedBurn; + + vm.expectEmit(false, false, false, true); + emit Splitter.Split(_amount, _expectedBurn, _expectedDistribute); + + vm.prank(caller); + splitter.split(_amount); + } + + function testFuzz_RevertWhen_Split_InsufficientApproval( + address _recipient, + uint256 _weight, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _amount = bound(_amount, 1, type(uint128).max); + + _setupDistributorAndBurn(_recipient, _weight, 0); + splitToken.mint(caller, _amount); + // Don't approve + + vm.prank(caller); + vm.expectRevert(); + splitter.split(_amount); + } + + function testFuzz_Split_ZeroAmount(address _recipient, uint256 _weight) public { + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + + _setupDistributorAndBurn(_recipient, _weight, 5000); + // Approve 0 + vm.prank(caller); + splitToken.approve(address(splitter), 0); + + vm.prank(caller); + splitter.split(0); + + assertEq(splitToken.balanceOf(_recipient), 0); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_NoDistributors(uint256 _amount) public { + _amount = bound(_amount, 1, type(uint128).max); + + // Default setup has no distributors, 100% burn + _mintAndApprove(caller, _amount); + + uint256 _totalSupplyBefore = splitToken.totalSupply(); + + vm.expectEmit(false, false, false, true); + emit Splitter.Split(_amount, _amount, 0); + + vm.prank(caller); + splitter.split(_amount); + + // All burned + assertEq(splitToken.totalSupply(), _totalSupplyBefore - _amount); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function testFuzz_Split_AnyoneCanCall( + address _caller, + address _recipient, + uint256 _weight, + uint256 _amount + ) public { + _assumeNonZeroAddress(_caller); + _assumeNonZeroAddress(_recipient); + _weight = _boundWeight(_weight); + _amount = bound(_amount, 1, type(uint128).max); + + _setupDistributorAndBurn(_recipient, _weight, 5000); + + splitToken.mint(_caller, _amount); + vm.prank(_caller); + splitToken.approve(address(splitter), _amount); + + // Any address can call split + vm.prank(_caller); + splitter.split(_amount); + + // 50% burn, so distribute = amount - (amount * 5000 / 10000) + uint256 _expectedBurn = (_amount * 5000) / 10_000; + uint256 _expectedDistribute = _amount - _expectedBurn; + assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); + } +} From 312ea3f14315cd350a5b16f9ec4a4d35a1be8042 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:00:12 -0800 Subject: [PATCH 02/33] refactor: simplify split to push-based pattern --- src/Splitter.sol | 19 +++------ test/Splitter.t.sol | 101 ++++++++++++++++---------------------------- 2 files changed, 42 insertions(+), 78 deletions(-) diff --git a/src/Splitter.sol b/src/Splitter.sol index 67c78e5..103961d 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -57,9 +57,6 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @notice Thrown when an invalid burn percentage is provided. error Splitter_InvalidBurnPercentage(); - /// @notice Thrown when the received token amount does not match the expected amount. - error Splitter_AmountMismatch(); - /// @custom:storage-location erc7201:storage.Splitter struct SplitterStorage { IERC20Burnable _splitToken; @@ -144,20 +141,14 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { return $._distributors; } - /// @notice Splits tokens between burning and distributors based on configured percentages. - /// @dev Caller must approve this contract to spend the split token before calling. + /// @notice Splits the contract's token balance between burning and distributors. + /// @dev Tokens must be transferred to this contract before calling. /// Any dust from rounding is burned along with the burn portion. - /// @param _amount The amount of tokens to split. - function split(uint256 _amount) external { + function split() external { SplitterStorage storage $ = _getSplitterStorage(); + uint256 _amount = $._splitToken.balanceOf(address(this)); - // Transfer tokens from caller (requires prior approval) - uint256 _balanceBefore = $._splitToken.balanceOf(address(this)); - $._splitToken.safeTransferFrom(msg.sender, address(this), _amount); - uint256 _balanceAfter = $._splitToken.balanceOf(address(this)); - - // Verify received amount matches expected - if (_balanceAfter - _balanceBefore != _amount) revert Splitter_AmountMismatch(); + if (_amount == 0) return; // Calculate burn amount uint256 _burnAmount = (_amount * $._burnBps) / 10_000; diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index d013740..ba80a32 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -67,6 +67,10 @@ contract SplitterTest is Test { vm.assume(_addr != admin && _addr != emergencyAdmin); } + function _assumeNotSplitter(address _addr) internal view { + vm.assume(_addr != address(splitter)); + } + function _boundWeight(uint256 _weight) internal pure returns (uint256) { return bound(_weight, 1, type(uint96).max); } @@ -702,13 +706,6 @@ contract SetBurnPercentage is SplitterTest { } contract Split is SplitterTest { - address internal caller; - - function setUp() public override { - super.setUp(); - caller = makeAddr("caller"); - } - function _setupDistributorAndBurn(address _recipient, uint256 _weight, uint256 _burnBps) internal { @@ -719,10 +716,8 @@ contract Split is SplitterTest { splitter.setBurnPercentage(_burnBps); } - function _mintAndApprove(address _to, uint256 _amount) internal { - splitToken.mint(_to, _amount); - vm.prank(_to); - splitToken.approve(address(splitter), _amount); + function _mintToSplitter(uint256 _amount) internal { + splitToken.mint(address(splitter), _amount); } function testFuzz_Split_BurnsCorrectAmount( @@ -732,18 +727,18 @@ contract Split is SplitterTest { uint256 _amount ) public { _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _burnBps = bound(_burnBps, 0, 10_000); _amount = bound(_amount, 1, type(uint128).max); _setupDistributorAndBurn(_recipient, _weight, _burnBps); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _expectedBurn = (_amount * _burnBps) / 10_000; uint256 _expectedDistribute = _amount - _expectedBurn; - vm.prank(caller); - splitter.split(_amount); + splitter.split(); assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); assertEq(splitToken.balanceOf(address(splitter)), 0); @@ -755,15 +750,15 @@ contract Split is SplitterTest { uint256 _amount ) public { _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _amount = bound(_amount, 1, type(uint128).max); // Set 0% burn so all goes to distributor _setupDistributorAndBurn(_recipient, _weight, 0); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); - vm.prank(caller); - splitter.split(_amount); + splitter.split(); assertEq(splitToken.balanceOf(_recipient), _amount); assertEq(splitToken.balanceOf(address(splitter)), 0); @@ -778,6 +773,8 @@ contract Split is SplitterTest { ) public { _assumeNonZeroAddress(_recipient1); _assumeNonZeroAddress(_recipient2); + _assumeNotSplitter(_recipient1); + _assumeNotSplitter(_recipient2); vm.assume(_recipient1 != _recipient2); _weight1 = _boundWeight(_weight1); _weight2 = _boundWeight(_weight2); @@ -793,14 +790,13 @@ contract Split is SplitterTest { vm.prank(admin); splitter.setBurnPercentage(0); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _totalWeight = _weight1 + _weight2; uint256 _expectedShare1 = (_amount * _weight1) / _totalWeight; uint256 _expectedShare2 = (_amount * _weight2) / _totalWeight; - vm.prank(caller); - splitter.split(_amount); + splitter.split(); assertEq(splitToken.balanceOf(_recipient1), _expectedShare1); assertEq(splitToken.balanceOf(_recipient2), _expectedShare2); @@ -812,12 +808,11 @@ contract Split is SplitterTest { _amount = bound(_amount, 1, type(uint128).max); // Default is 100% burn with no distributors - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _totalSupplyBefore = splitToken.totalSupply(); - vm.prank(caller); - splitter.split(_amount); + splitter.split(); assertEq(splitToken.totalSupply(), _totalSupplyBefore - _amount); assertEq(splitToken.balanceOf(address(splitter)), 0); @@ -827,16 +822,16 @@ contract Split is SplitterTest { public { _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _amount = bound(_amount, 1, type(uint128).max); _setupDistributorAndBurn(_recipient, _weight, 0); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _totalSupplyBefore = splitToken.totalSupply(); - vm.prank(caller); - splitter.split(_amount); + splitter.split(); // No burn, all to distributor assertEq(splitToken.totalSupply(), _totalSupplyBefore); @@ -851,6 +846,8 @@ contract Split is SplitterTest { ) public { _assumeNonZeroAddress(_recipient1); _assumeNonZeroAddress(_recipient2); + _assumeNotSplitter(_recipient1); + _assumeNotSplitter(_recipient2); vm.assume(_recipient1 != _recipient2); // Use an amount that will produce dust with these weights _amount = bound(_amount, 100, type(uint128).max); @@ -864,15 +861,14 @@ contract Split is SplitterTest { vm.prank(admin); splitter.setBurnPercentage(0); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _totalSupplyBefore = splitToken.totalSupply(); uint256 _share1 = (_amount * 1) / 3; uint256 _share2 = (_amount * 2) / 3; uint256 _dust = _amount - _share1 - _share2; - vm.prank(caller); - splitter.split(_amount); + splitter.split(); assertEq(splitToken.balanceOf(_recipient1), _share1); assertEq(splitToken.balanceOf(_recipient2), _share2); @@ -888,12 +884,13 @@ contract Split is SplitterTest { uint256 _amount ) public { _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _burnBps = bound(_burnBps, 0, 10_000); _amount = bound(_amount, 1, type(uint128).max); _setupDistributorAndBurn(_recipient, _weight, _burnBps); - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _expectedBurn = (_amount * _burnBps) / 10_000; uint256 _expectedDistribute = _amount - _expectedBurn; @@ -901,39 +898,18 @@ contract Split is SplitterTest { vm.expectEmit(false, false, false, true); emit Splitter.Split(_amount, _expectedBurn, _expectedDistribute); - vm.prank(caller); - splitter.split(_amount); - } - - function testFuzz_RevertWhen_Split_InsufficientApproval( - address _recipient, - uint256 _weight, - uint256 _amount - ) public { - _assumeNonZeroAddress(_recipient); - _weight = _boundWeight(_weight); - _amount = bound(_amount, 1, type(uint128).max); - - _setupDistributorAndBurn(_recipient, _weight, 0); - splitToken.mint(caller, _amount); - // Don't approve - - vm.prank(caller); - vm.expectRevert(); - splitter.split(_amount); + splitter.split(); } - function testFuzz_Split_ZeroAmount(address _recipient, uint256 _weight) public { + function testFuzz_Split_ZeroBalance(address _recipient, uint256 _weight) public { _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _setupDistributorAndBurn(_recipient, _weight, 5000); - // Approve 0 - vm.prank(caller); - splitToken.approve(address(splitter), 0); + // Don't mint any tokens - balance is 0 - vm.prank(caller); - splitter.split(0); + splitter.split(); assertEq(splitToken.balanceOf(_recipient), 0); assertEq(splitToken.balanceOf(address(splitter)), 0); @@ -943,15 +919,14 @@ contract Split is SplitterTest { _amount = bound(_amount, 1, type(uint128).max); // Default setup has no distributors, 100% burn - _mintAndApprove(caller, _amount); + _mintToSplitter(_amount); uint256 _totalSupplyBefore = splitToken.totalSupply(); vm.expectEmit(false, false, false, true); emit Splitter.Split(_amount, _amount, 0); - vm.prank(caller); - splitter.split(_amount); + splitter.split(); // All burned assertEq(splitToken.totalSupply(), _totalSupplyBefore - _amount); @@ -966,18 +941,16 @@ contract Split is SplitterTest { ) public { _assumeNonZeroAddress(_caller); _assumeNonZeroAddress(_recipient); + _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); _amount = bound(_amount, 1, type(uint128).max); _setupDistributorAndBurn(_recipient, _weight, 5000); - - splitToken.mint(_caller, _amount); - vm.prank(_caller); - splitToken.approve(address(splitter), _amount); + _mintToSplitter(_amount); // Any address can call split vm.prank(_caller); - splitter.split(_amount); + splitter.split(); // 50% burn, so distribute = amount - (amount * 5000 / 10000) uint256 _expectedBurn = (_amount * 5000) / 10_000; From 0b8ef9f070236ef074791311088616acbd7fdea0 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:22:02 -0800 Subject: [PATCH 03/33] style: use modern vm.expectEmit and add BPS_DENOMINATOR constant --- test/Splitter.t.sol | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index ba80a32..1a09f74 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -13,6 +13,7 @@ contract SplitterTest is Test { address internal admin; address internal emergencyAdmin; uint256 internal constant DEFAULT_BURN_BPS = 10_000; + uint256 internal constant BPS_DENOMINATOR = 10_000; function _deploySplitter(address _admin, address _emergencyAdmin) internal returns (Splitter) { Splitter.DistributorConfig[] memory _emptyDistributors = new Splitter.DistributorConfig[](0); @@ -735,7 +736,7 @@ contract Split is SplitterTest { _setupDistributorAndBurn(_recipient, _weight, _burnBps); _mintToSplitter(_amount); - uint256 _expectedBurn = (_amount * _burnBps) / 10_000; + uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR; uint256 _expectedDistribute = _amount - _expectedBurn; splitter.split(); @@ -892,10 +893,10 @@ contract Split is SplitterTest { _setupDistributorAndBurn(_recipient, _weight, _burnBps); _mintToSplitter(_amount); - uint256 _expectedBurn = (_amount * _burnBps) / 10_000; + uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR; uint256 _expectedDistribute = _amount - _expectedBurn; - vm.expectEmit(false, false, false, true); + vm.expectEmit(); emit Splitter.Split(_amount, _expectedBurn, _expectedDistribute); splitter.split(); @@ -923,7 +924,7 @@ contract Split is SplitterTest { uint256 _totalSupplyBefore = splitToken.totalSupply(); - vm.expectEmit(false, false, false, true); + vm.expectEmit(); emit Splitter.Split(_amount, _amount, 0); splitter.split(); @@ -953,7 +954,7 @@ contract Split is SplitterTest { splitter.split(); // 50% burn, so distribute = amount - (amount * 5000 / 10000) - uint256 _expectedBurn = (_amount * 5000) / 10_000; + uint256 _expectedBurn = (_amount * 5000) / BPS_DENOMINATOR; uint256 _expectedDistribute = _amount - _expectedBurn; assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); } From 26272b4fa104493a5734eb58d76e71fc6cc3d633 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:18:51 -0800 Subject: [PATCH 04/33] refactor: extract _addDistributors and _setBurnBps helpers to base contract --- test/Splitter.t.sol | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 1a09f74..77bedfa 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -106,6 +106,17 @@ contract SplitterTest is Test { vm.prank(admin); splitter.setDistributors(_distributors); } + + function _addDistributors(address _recipient, uint256 _weight) internal { + Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient, _weight); + vm.prank(admin); + splitter.setDistributors(_distributors); + } + + function _setBurnBps(uint256 _burnBps) internal { + vm.prank(admin); + splitter.setBurnPercentage(_burnBps); + } } contract Initialize is SplitterTest { @@ -707,16 +718,6 @@ contract SetBurnPercentage is SplitterTest { } contract Split is SplitterTest { - function _setupDistributorAndBurn(address _recipient, uint256 _weight, uint256 _burnBps) - internal - { - Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient, _weight); - vm.prank(admin); - splitter.setDistributors(_distributors); - vm.prank(admin); - splitter.setBurnPercentage(_burnBps); - } - function _mintToSplitter(uint256 _amount) internal { splitToken.mint(address(splitter), _amount); } @@ -733,7 +734,8 @@ contract Split is SplitterTest { _burnBps = bound(_burnBps, 0, 10_000); _amount = bound(_amount, 1, type(uint128).max); - _setupDistributorAndBurn(_recipient, _weight, _burnBps); + _addDistributors(_recipient, _weight); + _setBurnBps(_burnBps); _mintToSplitter(_amount); uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR; @@ -756,7 +758,8 @@ contract Split is SplitterTest { _amount = bound(_amount, 1, type(uint128).max); // Set 0% burn so all goes to distributor - _setupDistributorAndBurn(_recipient, _weight, 0); + _addDistributors(_recipient, _weight); + _setBurnBps(0); _mintToSplitter(_amount); splitter.split(); @@ -827,7 +830,8 @@ contract Split is SplitterTest { _weight = _boundWeight(_weight); _amount = bound(_amount, 1, type(uint128).max); - _setupDistributorAndBurn(_recipient, _weight, 0); + _addDistributors(_recipient, _weight); + _setBurnBps(0); _mintToSplitter(_amount); uint256 _totalSupplyBefore = splitToken.totalSupply(); @@ -890,7 +894,8 @@ contract Split is SplitterTest { _burnBps = bound(_burnBps, 0, 10_000); _amount = bound(_amount, 1, type(uint128).max); - _setupDistributorAndBurn(_recipient, _weight, _burnBps); + _addDistributors(_recipient, _weight); + _setBurnBps(_burnBps); _mintToSplitter(_amount); uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR; @@ -907,7 +912,8 @@ contract Split is SplitterTest { _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); - _setupDistributorAndBurn(_recipient, _weight, 5000); + _addDistributors(_recipient, _weight); + _setBurnBps(5000); // Don't mint any tokens - balance is 0 splitter.split(); @@ -946,7 +952,8 @@ contract Split is SplitterTest { _weight = _boundWeight(_weight); _amount = bound(_amount, 1, type(uint128).max); - _setupDistributorAndBurn(_recipient, _weight, 5000); + _addDistributors(_recipient, _weight); + _setBurnBps(5000); _mintToSplitter(_amount); // Any address can call split From 8183615de99d82cc3e3fae2c972fd0e0464539d2 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:19:20 -0800 Subject: [PATCH 05/33] refactor: move _mintToSplitter to base test contract --- test/Splitter.t.sol | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 77bedfa..4e51e65 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -117,6 +117,10 @@ contract SplitterTest is Test { vm.prank(admin); splitter.setBurnPercentage(_burnBps); } + + function _mintToSplitter(uint256 _amount) internal { + splitToken.mint(address(splitter), _amount); + } } contract Initialize is SplitterTest { @@ -718,10 +722,6 @@ contract SetBurnPercentage is SplitterTest { } contract Split is SplitterTest { - function _mintToSplitter(uint256 _amount) internal { - splitToken.mint(address(splitter), _amount); - } - function testFuzz_Split_BurnsCorrectAmount( address _recipient, uint256 _weight, From bfd1d0d556b24aacbf3e36a224f727f70b3287fa Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:20:14 -0800 Subject: [PATCH 06/33] test: add concrete test cases with hardcoded expected values --- test/Splitter.t.sol | 96 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 4e51e65..baf87cd 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -965,4 +965,100 @@ contract Split is SplitterTest { uint256 _expectedDistribute = _amount - _expectedBurn; assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); } + + // Concrete test cases with hardcoded expected values + function test_Split_FiftyPercentBurn() public { + address _recipient = makeAddr("recipient"); + _addDistributors(_recipient, 100); + _setBurnBps(5000); // 50% + _mintToSplitter(1000); + + uint256 _supplyBefore = splitToken.totalSupply(); + + splitter.split(); + + // 1000 tokens, 50% burn = 500 burned, 500 distributed + assertEq(splitToken.balanceOf(_recipient), 500); + assertEq(splitToken.totalSupply(), _supplyBefore - 500); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function test_Split_TwentyFivePercentBurn() public { + address _recipient = makeAddr("recipient"); + _addDistributors(_recipient, 100); + _setBurnBps(2500); // 25% + _mintToSplitter(1000); + + uint256 _supplyBefore = splitToken.totalSupply(); + + splitter.split(); + + // 1000 tokens, 25% burn = 250 burned, 750 distributed + assertEq(splitToken.balanceOf(_recipient), 750); + assertEq(splitToken.totalSupply(), _supplyBefore - 250); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function test_Split_MultipleDistributorsEqualWeights() public { + address _recipient1 = makeAddr("recipient1"); + address _recipient2 = makeAddr("recipient2"); + + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, 100, _recipient2, 100); + vm.prank(admin); + splitter.setDistributors(_distributors); + _setBurnBps(0); // 0% burn + _mintToSplitter(1000); + + splitter.split(); + + // 1000 tokens, 0% burn, equal weights = 500 each + assertEq(splitToken.balanceOf(_recipient1), 500); + assertEq(splitToken.balanceOf(_recipient2), 500); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function test_Split_MultipleDistributorsUnequalWeights() public { + address _recipient1 = makeAddr("recipient1"); + address _recipient2 = makeAddr("recipient2"); + + // Weights 1:2, total weight 3 + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, 1, _recipient2, 2); + vm.prank(admin); + splitter.setDistributors(_distributors); + _setBurnBps(0); // 0% burn + _mintToSplitter(900); // Divisible by 3 + + splitter.split(); + + // 900 tokens, weights 1:2 = 300 and 600 + assertEq(splitToken.balanceOf(_recipient1), 300); + assertEq(splitToken.balanceOf(_recipient2), 600); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + + function test_Split_BurnAndDistributeWithDust() public { + address _recipient1 = makeAddr("recipient1"); + address _recipient2 = makeAddr("recipient2"); + + // Weights 1:2, total weight 3 + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, 1, _recipient2, 2); + vm.prank(admin); + splitter.setDistributors(_distributors); + _setBurnBps(1000); // 10% burn + _mintToSplitter(1000); + + uint256 _supplyBefore = splitToken.totalSupply(); + + splitter.split(); + + // 1000 tokens, 10% burn = 100 burned, 900 to distribute + // 900 with weights 1:2 = 300 and 600 + assertEq(splitToken.balanceOf(_recipient1), 300); + assertEq(splitToken.balanceOf(_recipient2), 600); + assertEq(splitToken.totalSupply(), _supplyBefore - 100); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } } From aba48be530d4926c2eeffcb5cb58df6aa1f8c5fb Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:21:18 -0800 Subject: [PATCH 07/33] style: rename split tests for better scopelint spec readability --- test/Splitter.t.sol | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index baf87cd..aadee34 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -808,7 +808,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_100PercentBurn(uint256 _amount) public { + function testFuzz_Split_BurnsAllTokensWhenBurnIsOneHundredPercent(uint256 _amount) public { _amount = bound(_amount, 1, type(uint128).max); // Default is 100% burn with no distributors @@ -822,9 +822,11 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_0PercentBurn(address _recipient, uint256 _weight, uint256 _amount) - public - { + function testFuzz_Split_DistributesAllTokensWhenBurnIsZeroPercent( + address _recipient, + uint256 _weight, + uint256 _amount + ) public { _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); @@ -967,7 +969,7 @@ contract Split is SplitterTest { } // Concrete test cases with hardcoded expected values - function test_Split_FiftyPercentBurn() public { + function test_Split_BurnsAndDistributesFiftyPercentEach() public { address _recipient = makeAddr("recipient"); _addDistributors(_recipient, 100); _setBurnBps(5000); // 50% @@ -983,7 +985,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function test_Split_TwentyFivePercentBurn() public { + function test_Split_BurnsTwentyFivePercentAndDistributesRest() public { address _recipient = makeAddr("recipient"); _addDistributors(_recipient, 100); _setBurnBps(2500); // 25% @@ -999,7 +1001,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function test_Split_MultipleDistributorsEqualWeights() public { + function test_Split_DistributesEquallyWithEqualWeights() public { address _recipient1 = makeAddr("recipient1"); address _recipient2 = makeAddr("recipient2"); @@ -1018,7 +1020,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function test_Split_MultipleDistributorsUnequalWeights() public { + function test_Split_DistributesProportionallyWithUnequalWeights() public { address _recipient1 = makeAddr("recipient1"); address _recipient2 = makeAddr("recipient2"); @@ -1038,7 +1040,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function test_Split_BurnAndDistributeWithDust() public { + function test_Split_BurnsAndDistributesWithMultipleRecipients() public { address _recipient1 = makeAddr("recipient1"); address _recipient2 = makeAddr("recipient2"); From c4f6a275ccd247938cbc6eb88592b118a4e5cfa0 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:32:10 -0800 Subject: [PATCH 08/33] test: drop redundant fuzz burn assertion Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2670484870 --- test/Splitter.t.sol | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index aadee34..1477121 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -722,31 +722,6 @@ contract SetBurnPercentage is SplitterTest { } contract Split is SplitterTest { - function testFuzz_Split_BurnsCorrectAmount( - address _recipient, - uint256 _weight, - uint256 _burnBps, - uint256 _amount - ) public { - _assumeNonZeroAddress(_recipient); - _assumeNotSplitter(_recipient); - _weight = _boundWeight(_weight); - _burnBps = bound(_burnBps, 0, 10_000); - _amount = bound(_amount, 1, type(uint128).max); - - _addDistributors(_recipient, _weight); - _setBurnBps(_burnBps); - _mintToSplitter(_amount); - - uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR; - uint256 _expectedDistribute = _amount - _expectedBurn; - - splitter.split(); - - assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } - function testFuzz_Split_DistributesToSingleDistributor( address _recipient, uint256 _weight, From 9161df69ccf1a1c5d689e50805f20ad4ab19f64e Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:40:20 -0800 Subject: [PATCH 09/33] test: assert split invariants via deltas Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2670483637 --- test/Splitter.t.sol | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 1477121..ee711c3 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -737,9 +737,19 @@ contract Split is SplitterTest { _setBurnBps(0); _mintToSplitter(_amount); + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _recipientBalanceBefore = splitToken.balanceOf(_recipient); + uint256 _totalSupplyBefore = splitToken.totalSupply(); + splitter.split(); - assertEq(splitToken.balanceOf(_recipient), _amount); + uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); + assertGe(_recipientBalanceAfter, _recipientBalanceBefore); + uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); + + assertEq(_burned, 0); + assertEq(_distributed + _burned, _splitterBalanceBefore); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -771,15 +781,23 @@ contract Split is SplitterTest { _mintToSplitter(_amount); - uint256 _totalWeight = _weight1 + _weight2; - uint256 _expectedShare1 = (_amount * _weight1) / _totalWeight; - uint256 _expectedShare2 = (_amount * _weight2) / _totalWeight; + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _recipient1BalanceBefore = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceBefore = splitToken.balanceOf(_recipient2); + uint256 _totalSupplyBefore = splitToken.totalSupply(); splitter.split(); - assertEq(splitToken.balanceOf(_recipient1), _expectedShare1); - assertEq(splitToken.balanceOf(_recipient2), _expectedShare2); - // Any dust should have been burned, not left in contract + uint256 _recipient1BalanceAfter = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); + assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); + assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); + uint256 _distributed = + (_recipient1BalanceAfter - _recipient1BalanceBefore) + + (_recipient2BalanceAfter - _recipient2BalanceBefore); + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); + + assertEq(_distributed + _burned, _splitterBalanceBefore); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -811,13 +829,19 @@ contract Split is SplitterTest { _setBurnBps(0); _mintToSplitter(_amount); + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _recipientBalanceBefore = splitToken.balanceOf(_recipient); uint256 _totalSupplyBefore = splitToken.totalSupply(); splitter.split(); - // No burn, all to distributor - assertEq(splitToken.totalSupply(), _totalSupplyBefore); - assertEq(splitToken.balanceOf(_recipient), _amount); + uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); + assertGe(_recipientBalanceAfter, _recipientBalanceBefore); + uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); + + assertEq(_burned, 0); + assertEq(_distributed + _burned, _splitterBalanceBefore); assertEq(splitToken.balanceOf(address(splitter)), 0); } From 03128eb0889620af56cf80ddd6159bebfdec25a2 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:42:51 -0800 Subject: [PATCH 10/33] test: assert burn via total supply delta Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2670474755 --- test/Splitter.t.sol | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index ee711c3..8270030 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -951,20 +951,28 @@ contract Split is SplitterTest { _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); - _amount = bound(_amount, 1, type(uint128).max); + _amount = bound(_amount, 2, type(uint128).max); _addDistributors(_recipient, _weight); _setBurnBps(5000); _mintToSplitter(_amount); + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _recipientBalanceBefore = splitToken.balanceOf(_recipient); + uint256 _totalSupplyBefore = splitToken.totalSupply(); + // Any address can call split vm.prank(_caller); splitter.split(); - // 50% burn, so distribute = amount - (amount * 5000 / 10000) - uint256 _expectedBurn = (_amount * 5000) / BPS_DENOMINATOR; - uint256 _expectedDistribute = _amount - _expectedBurn; - assertEq(splitToken.balanceOf(_recipient), _expectedDistribute); + uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); + assertGe(_recipientBalanceAfter, _recipientBalanceBefore); + uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); + + assertGt(_burned, 0); + assertEq(_distributed + _burned, _splitterBalanceBefore); + assertEq(splitToken.balanceOf(address(splitter)), 0); } // Concrete test cases with hardcoded expected values From 90eed4c04d4bc9e8e05442d053f6f1fc5f1fe5a8 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:48:07 -0800 Subject: [PATCH 11/33] test: document burn rounding bound --- test/Splitter.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 8270030..164faed 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -951,6 +951,7 @@ contract Split is SplitterTest { _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); + // Ensure 50% burn rounds to > 0 so we can assert that a burn happened. _amount = bound(_amount, 2, type(uint128).max); _addDistributors(_recipient, _weight); From 6ce3bffcf066fa414f924dc725528c2c59f5f142 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:52:08 -0800 Subject: [PATCH 12/33] test: cover dust burn with nonzero burn bps Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2670473274 --- test/Splitter.t.sol | 51 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 164faed..76af2ab 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -883,6 +883,57 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } + function testFuzz_Split_BurnsDustFromRounding_WhenBurnBpsSet( + address _recipient1, + address _recipient2, + uint256 _burnBps, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient1); + _assumeNonZeroAddress(_recipient2); + _assumeNotSplitter(_recipient1); + _assumeNotSplitter(_recipient2); + vm.assume(_recipient1 != _recipient2); + + _burnBps = bound(_burnBps, 1, BPS_DENOMINATOR - 1); + _amount = bound(_amount, 100, type(uint128).max); + + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, 1, _recipient2, 2); + vm.prank(admin); + splitter.setDistributors(_distributors); + vm.prank(admin); + splitter.setBurnPercentage(_burnBps); + + _mintToSplitter(_amount); + + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _expectedBurn = (_splitterBalanceBefore * _burnBps) / BPS_DENOMINATOR; + uint256 _expectedDistribute = _splitterBalanceBefore - _expectedBurn; + // Ensure the distributable amount produces dust with weights 1 and 2 (total 3). + vm.assume(_expectedDistribute % 3 != 0); + + uint256 _recipient1BalanceBefore = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceBefore = splitToken.balanceOf(_recipient2); + uint256 _totalSupplyBefore = splitToken.totalSupply(); + + splitter.split(); + + uint256 _recipient1BalanceAfter = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); + assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); + assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); + + uint256 _distributed = + (_recipient1BalanceAfter - _recipient1BalanceBefore) + + (_recipient2BalanceAfter - _recipient2BalanceBefore); + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); + + assertEq(_distributed + _burned, _splitterBalanceBefore); + assertGt(_burned, _expectedBurn); + assertEq(splitToken.balanceOf(address(splitter)), 0); + } + function testFuzz_Split_EmitsEvent( address _recipient, uint256 _weight, From cd2e5d9b30092ab72982fdd60329832b5ca37477 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:57:30 -0800 Subject: [PATCH 13/33] test: remove weight overflow assumes Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2670456437 --- test/Splitter.t.sol | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 76af2ab..f6fac5b 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -767,9 +767,6 @@ contract Split is SplitterTest { vm.assume(_recipient1 != _recipient2); _weight1 = _boundWeight(_weight1); _weight2 = _boundWeight(_weight2); - // Ensure weights don't overflow when added - vm.assume(_weight1 <= type(uint96).max / 2); - vm.assume(_weight2 <= type(uint96).max / 2); _amount = bound(_amount, 1, type(uint128).max); Splitter.DistributorConfig[] memory _distributors = From 722e689550e7665e4df6cdc77ebe965dcaebdc2f Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:00:02 -0800 Subject: [PATCH 14/33] docs: clarify dust bound and burn accounting Thread: https://github.com/ScopeLift/zksync-fee-flow/pull/31#discussion_r2635736703 --- src/Splitter.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Splitter.sol b/src/Splitter.sol index 103961d..2541fe9 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -143,7 +143,8 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @notice Splits the contract's token balance between burning and distributors. /// @dev Tokens must be transferred to this contract before calling. - /// Any dust from rounding is burned along with the burn portion. + /// Any dust from rounding during distribution is burned along with the burn portion. + /// This dust is strictly less than the number of distributors (in the token's smallest unit). function split() external { SplitterStorage storage $ = _getSplitterStorage(); uint256 _amount = $._splitToken.balanceOf(address(this)); @@ -165,8 +166,8 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { } } - // Burn the burn amount + any dust from rounding - uint256 _totalBurned = _amount - _totalDistributed; + uint256 _dust = _distributeAmount - _totalDistributed; + uint256 _totalBurned = _burnAmount + _dust; if (_totalBurned > 0) $._splitToken.burn(_totalBurned); emit Split(_amount, _totalBurned, _totalDistributed); From fb254427b07bac5495cba268876c31e0aca69ca9 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:16:02 -0800 Subject: [PATCH 15/33] style: scopelint fmt --- test/Splitter.t.sol | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index f6fac5b..aa9cd3d 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -789,9 +789,8 @@ contract Split is SplitterTest { uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); - uint256 _distributed = - (_recipient1BalanceAfter - _recipient1BalanceBefore) + - (_recipient2BalanceAfter - _recipient2BalanceBefore); + uint256 _distributed = (_recipient1BalanceAfter - _recipient1BalanceBefore) + + (_recipient2BalanceAfter - _recipient2BalanceBefore); uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_distributed + _burned, _splitterBalanceBefore); @@ -921,9 +920,8 @@ contract Split is SplitterTest { assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); - uint256 _distributed = - (_recipient1BalanceAfter - _recipient1BalanceBefore) + - (_recipient2BalanceAfter - _recipient2BalanceBefore); + uint256 _distributed = (_recipient1BalanceAfter - _recipient1BalanceBefore) + + (_recipient2BalanceAfter - _recipient2BalanceBefore); uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_distributed + _burned, _splitterBalanceBefore); From fd6d8926481faee5653f6fe0315d2c6fc98ec6cd Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Thu, 8 Jan 2026 11:26:15 -0800 Subject: [PATCH 16/33] ci: pass token to foundry toolchain --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45a10eb..ebc6433 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 + with: + token: ${{ github.token }} - name: Build contracts run: | @@ -35,6 +37,8 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 + with: + token: ${{ github.token }} - name: Run tests run: forge test @@ -50,6 +54,8 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 + with: + token: ${{ github.token }} - name: Run coverage run: forge coverage --report summary --report lcov @@ -94,6 +100,8 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 + with: + token: ${{ github.token }} - name: Install Rust uses: actions-rs/toolchain@v1 From 9ecf8437fabe59b21274879cb22cc70664c88311 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:43:04 -0800 Subject: [PATCH 17/33] chore: drop foundry token inputs --- .github/workflows/ci.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebc6433..0b03e3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,6 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 - with: - token: ${{ github.token }} - name: Build contracts run: | @@ -37,8 +35,6 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 - with: - token: ${{ github.token }} - name: Run tests run: forge test @@ -54,8 +50,6 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 - with: - token: ${{ github.token }} - name: Run coverage run: forge coverage --report summary --report lcov From a750d347ad802cb72ca79e6caef7f87158d9d6ee Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:50:42 -0800 Subject: [PATCH 18/33] chore: use openzeppelin remappings --- foundry.toml | 4 ++++ src/FeeFlow.sol | 8 ++++---- src/Splitter.sol | 6 +++--- src/interfaces/IERC20Burnable.sol | 2 +- test/FeeFlow.t.sol | 8 ++++---- test/Splitter.t.sol | 2 +- test/mocks/ERC20BurnableMock.sol | 2 +- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/foundry.toml b/foundry.toml index 86b1a95..12bca1d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,6 +2,10 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 + remappings = [ + "openzeppelin-contracts/=lib/openzeppelin-contracts/", + "openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", + ] solc_version = "0.8.30" verbosity = 3 diff --git a/src/FeeFlow.sol b/src/FeeFlow.sol index 013ecab..e3ed542 100644 --- a/src/FeeFlow.sol +++ b/src/FeeFlow.sol @@ -3,12 +3,12 @@ pragma solidity 0.8.30; import { AccessControlUpgradeable -} from "lib/openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +} from "openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; import { UUPSUpgradeable -} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; -import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; /// @title FeeFlow /// @author ScopeLift diff --git a/src/Splitter.sol b/src/Splitter.sol index 2541fe9..e49428a 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -3,12 +3,12 @@ pragma solidity 0.8.30; import { AccessControlUpgradeable -} from "lib/openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; +} from "openzeppelin-contracts-upgradeable/contracts/access/AccessControlUpgradeable.sol"; import { UUPSUpgradeable -} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; +} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/UUPSUpgradeable.sol"; import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; -import {SafeERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; /// @title Splitter /// @author ScopeLift diff --git a/src/interfaces/IERC20Burnable.sol b/src/interfaces/IERC20Burnable.sol index c80404c..d8f27c5 100644 --- a/src/interfaces/IERC20Burnable.sol +++ b/src/interfaces/IERC20Burnable.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.30; -import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; /// @notice Interface for ERC20 tokens with burn functionality. interface IERC20Burnable is IERC20 { diff --git a/test/FeeFlow.t.sol b/test/FeeFlow.t.sol index f105e4e..7a70178 100644 --- a/test/FeeFlow.t.sol +++ b/test/FeeFlow.t.sol @@ -3,12 +3,12 @@ pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {FeeFlow} from "src/FeeFlow.sol"; -import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {IERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; -import {ERC20Mock} from "lib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {ERC20Mock} from "openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol"; import { Initializable -} from "lib/openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +} from "openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; contract FeeFlowTest is Test { FeeFlow internal feeFlow; diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index aa9cd3d..b4d6110 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; import {Splitter} from "src/Splitter.sol"; import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; -import {ERC1967Proxy} from "lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {ERC20BurnableMock} from "test/mocks/ERC20BurnableMock.sol"; contract SplitterTest is Test { diff --git a/test/mocks/ERC20BurnableMock.sol b/test/mocks/ERC20BurnableMock.sol index ae9a4a4..80ef8f2 100644 --- a/test/mocks/ERC20BurnableMock.sol +++ b/test/mocks/ERC20BurnableMock.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.30; -import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; +import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol"; contract ERC20BurnableMock is ERC20 { constructor() ERC20("Mock Token", "MOCK") {} From 2a3635e435918ff8a5e967cd9b5e807be9332e67 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:53:37 -0800 Subject: [PATCH 19/33] test: assert exact distributions --- test/Splitter.t.sol | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index b4d6110..22c4fba 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -744,12 +744,12 @@ contract Split is SplitterTest { splitter.split(); uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); - assertGe(_recipientBalanceAfter, _recipientBalanceBefore); uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); - assertEq(_burned, 0); assertEq(_distributed + _burned, _splitterBalanceBefore); + // Rounding dust is always < distributor count; with 2 recipients, burned is 0 or 1. + assertLt(_burned, 2); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -787,13 +787,12 @@ contract Split is SplitterTest { uint256 _recipient1BalanceAfter = splitToken.balanceOf(_recipient1); uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); - assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); - assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); uint256 _distributed = (_recipient1BalanceAfter - _recipient1BalanceBefore) + (_recipient2BalanceAfter - _recipient2BalanceBefore); uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_distributed + _burned, _splitterBalanceBefore); + assertLt(_burned, 2); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -832,12 +831,11 @@ contract Split is SplitterTest { splitter.split(); uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); - assertGe(_recipientBalanceAfter, _recipientBalanceBefore); uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_burned, 0); - assertEq(_distributed + _burned, _splitterBalanceBefore); + assertEq(_distributed, _splitterBalanceBefore); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -917,8 +915,6 @@ contract Split is SplitterTest { uint256 _recipient1BalanceAfter = splitToken.balanceOf(_recipient1); uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); - assertGe(_recipient1BalanceAfter, _recipient1BalanceBefore); - assertGe(_recipient2BalanceAfter, _recipient2BalanceBefore); uint256 _distributed = (_recipient1BalanceAfter - _recipient1BalanceBefore) + (_recipient2BalanceAfter - _recipient2BalanceBefore); @@ -1013,7 +1009,6 @@ contract Split is SplitterTest { splitter.split(); uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); - assertGe(_recipientBalanceAfter, _recipientBalanceBefore); uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); From 84015c3ee8eff68076408c105c719f618edc7934 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:55:18 -0800 Subject: [PATCH 20/33] test: use _setBurnBps helper --- test/Splitter.t.sol | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 22c4fba..30e9be2 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -773,8 +773,7 @@ contract Split is SplitterTest { _createDistributors(_recipient1, _weight1, _recipient2, _weight2); vm.prank(admin); splitter.setDistributors(_distributors); - vm.prank(admin); - splitter.setBurnPercentage(0); + _setBurnBps(0); _mintToSplitter(_amount); From dc4a1f38a008f4f72e4eee7b4ff9d29205e722de Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:11:41 -0800 Subject: [PATCH 21/33] test: tighten dust bound to <= 1 wei --- test/Splitter.t.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 30e9be2..de41b26 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -748,8 +748,8 @@ contract Split is SplitterTest { uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_distributed + _burned, _splitterBalanceBefore); - // Rounding dust is always < distributor count; with 2 recipients, burned is 0 or 1. - assertLt(_burned, 2); + // Rounding dust is always < distributor count; here burned is <= 1 wei. + assertLe(_burned, 1); assertEq(splitToken.balanceOf(address(splitter)), 0); } @@ -791,7 +791,8 @@ contract Split is SplitterTest { uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); assertEq(_distributed + _burned, _splitterBalanceBefore); - assertLt(_burned, 2); + // Rounding dust is always < distributor count; with 2 distributors, burned is <= 1 wei. + assertLe(_burned, 1); assertEq(splitToken.balanceOf(address(splitter)), 0); } From 963ceb4f15ab44bef6890c2e4fefea135aeeb7a0 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:12:30 -0800 Subject: [PATCH 22/33] test: remove redundant 100% burn split test --- test/Splitter.t.sol | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index de41b26..34f146d 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -796,20 +796,6 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_BurnsAllTokensWhenBurnIsOneHundredPercent(uint256 _amount) public { - _amount = bound(_amount, 1, type(uint128).max); - - // Default is 100% burn with no distributors - _mintToSplitter(_amount); - - uint256 _totalSupplyBefore = splitToken.totalSupply(); - - splitter.split(); - - assertEq(splitToken.totalSupply(), _totalSupplyBefore - _amount); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } - function testFuzz_Split_DistributesAllTokensWhenBurnIsZeroPercent( address _recipient, uint256 _weight, From 2b90467ee8d3f5f7ff6822d309a929968e6c8896 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:13:38 -0800 Subject: [PATCH 23/33] test: ensure dust rounding fuzz always produces dust --- test/Splitter.t.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 34f146d..73e17b2 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -837,6 +837,7 @@ contract Split is SplitterTest { vm.assume(_recipient1 != _recipient2); // Use an amount that will produce dust with these weights _amount = bound(_amount, 100, type(uint128).max); + vm.assume(_amount % 3 != 0); // Set up 2 distributors with weights that will cause rounding // weights 1 and 2 -> total 3, amounts not divisible by 3 will have dust From 6f0749c67e4431e65674006af234fdb2398dc90d Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:20:21 -0800 Subject: [PATCH 24/33] test: rename split event test --- test/Splitter.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 73e17b2..cffc02b 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -912,7 +912,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_EmitsEvent( + function testFuzz_Split_EmitsSplitEvent( address _recipient, uint256 _weight, uint256 _burnBps, From 2d6e4a71f614e73d2a7f427df8a6a1ff7d3dc25b Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:21:54 -0800 Subject: [PATCH 25/33] docs: document split() zero-balance no-op --- src/Splitter.sol | 1 + test/Splitter.t.sol | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Splitter.sol b/src/Splitter.sol index e49428a..8f25e58 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -143,6 +143,7 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @notice Splits the contract's token balance between burning and distributors. /// @dev Tokens must be transferred to this contract before calling. + /// If the contract's balance is zero, this function is a no-op and emits no `Split` event. /// Any dust from rounding during distribution is burned along with the burn portion. /// This dust is strictly less than the number of distributors (in the token's smallest unit). function split() external { diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index cffc02b..8479d40 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.30; import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; import {Splitter} from "src/Splitter.sol"; import {IERC20Burnable} from "src/interfaces/IERC20Burnable.sol"; import {ERC1967Proxy} from "openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -937,7 +938,7 @@ contract Split is SplitterTest { splitter.split(); } - function testFuzz_Split_ZeroBalance(address _recipient, uint256 _weight) public { + function testFuzz_Split_ZeroBalanceIsNoOp(address _recipient, uint256 _weight) public { _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); @@ -946,10 +947,15 @@ contract Split is SplitterTest { _setBurnBps(5000); // Don't mint any tokens - balance is 0 + uint256 _totalSupplyBefore = splitToken.totalSupply(); + vm.recordLogs(); splitter.split(); + Vm.Log[] memory _entries = vm.getRecordedLogs(); assertEq(splitToken.balanceOf(_recipient), 0); assertEq(splitToken.balanceOf(address(splitter)), 0); + assertEq(splitToken.totalSupply(), _totalSupplyBefore); + assertEq(_entries.length, 0); } function testFuzz_Split_NoDistributors(uint256 _amount) public { From 433b17fd92b41d9eee13a400fc348686810a1a67 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:30:52 -0800 Subject: [PATCH 26/33] test: clarify no-distributors split event test --- test/Splitter.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 8479d40..1c64f2d 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -958,7 +958,7 @@ contract Split is SplitterTest { assertEq(_entries.length, 0); } - function testFuzz_Split_NoDistributors(uint256 _amount) public { + function testFuzz_Split_NoDistributors_EmitsSplitEventAndBurnsAll(uint256 _amount) public { _amount = bound(_amount, 1, type(uint128).max); // Default setup has no distributors, 100% burn From c3a44d9a4cc195b4c55eccc9f471b1f0367ca0ee Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:34:02 -0800 Subject: [PATCH 27/33] test: fold permissionless split into existing fuzz --- test/Splitter.t.sol | 38 ++++---------------------------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 1c64f2d..485accd 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -724,10 +724,12 @@ contract SetBurnPercentage is SplitterTest { contract Split is SplitterTest { function testFuzz_Split_DistributesToSingleDistributor( + address _caller, address _recipient, uint256 _weight, uint256 _amount ) public { + _assumeNonZeroAddress(_caller); _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); @@ -742,6 +744,8 @@ contract Split is SplitterTest { uint256 _recipientBalanceBefore = splitToken.balanceOf(_recipient); uint256 _totalSupplyBefore = splitToken.totalSupply(); + // split() is permissionless. + vm.prank(_caller); splitter.split(); uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); @@ -976,40 +980,6 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_AnyoneCanCall( - address _caller, - address _recipient, - uint256 _weight, - uint256 _amount - ) public { - _assumeNonZeroAddress(_caller); - _assumeNonZeroAddress(_recipient); - _assumeNotSplitter(_recipient); - _weight = _boundWeight(_weight); - // Ensure 50% burn rounds to > 0 so we can assert that a burn happened. - _amount = bound(_amount, 2, type(uint128).max); - - _addDistributors(_recipient, _weight); - _setBurnBps(5000); - _mintToSplitter(_amount); - - uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); - uint256 _recipientBalanceBefore = splitToken.balanceOf(_recipient); - uint256 _totalSupplyBefore = splitToken.totalSupply(); - - // Any address can call split - vm.prank(_caller); - splitter.split(); - - uint256 _recipientBalanceAfter = splitToken.balanceOf(_recipient); - uint256 _distributed = _recipientBalanceAfter - _recipientBalanceBefore; - uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); - - assertGt(_burned, 0); - assertEq(_distributed + _burned, _splitterBalanceBefore); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } - // Concrete test cases with hardcoded expected values function test_Split_BurnsAndDistributesFiftyPercentEach() public { address _recipient = makeAddr("recipient"); From 90c057ece64f3f1bce166b645867df4fb35d5d72 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:44:07 -0800 Subject: [PATCH 28/33] test: replace hardcoded split cases with burn invariant fuzz --- test/Splitter.t.sol | 116 +++++++++++++------------------------------- 1 file changed, 34 insertions(+), 82 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 485accd..65fa673 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -980,99 +980,51 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - // Concrete test cases with hardcoded expected values - function test_Split_BurnsAndDistributesFiftyPercentEach() public { - address _recipient = makeAddr("recipient"); - _addDistributors(_recipient, 100); - _setBurnBps(5000); // 50% - _mintToSplitter(1000); - - uint256 _supplyBefore = splitToken.totalSupply(); - - splitter.split(); - - // 1000 tokens, 50% burn = 500 burned, 500 distributed - assertEq(splitToken.balanceOf(_recipient), 500); - assertEq(splitToken.totalSupply(), _supplyBefore - 500); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } - - function test_Split_BurnsTwentyFivePercentAndDistributesRest() public { - address _recipient = makeAddr("recipient"); - _addDistributors(_recipient, 100); - _setBurnBps(2500); // 25% - _mintToSplitter(1000); - - uint256 _supplyBefore = splitToken.totalSupply(); - - splitter.split(); - - // 1000 tokens, 25% burn = 250 burned, 750 distributed - assertEq(splitToken.balanceOf(_recipient), 750); - assertEq(splitToken.totalSupply(), _supplyBefore - 250); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } + function testFuzz_Split_BurnedIsExpectedBurnPlusAtMostOneWei( + address _recipient1, + address _recipient2, + uint256 _weight1, + uint256 _weight2, + uint256 _burnBps, + uint256 _amount + ) public { + _assumeNonZeroAddress(_recipient1); + _assumeNonZeroAddress(_recipient2); + _assumeNotSplitter(_recipient1); + _assumeNotSplitter(_recipient2); + vm.assume(_recipient1 != _recipient2); - function test_Split_DistributesEquallyWithEqualWeights() public { - address _recipient1 = makeAddr("recipient1"); - address _recipient2 = makeAddr("recipient2"); + _weight1 = _boundWeight(_weight1); + _weight2 = _boundWeight(_weight2); + _burnBps = bound(_burnBps, 0, BPS_DENOMINATOR); + _amount = bound(_amount, 1, type(uint128).max); Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, 100, _recipient2, 100); + _createDistributors(_recipient1, _weight1, _recipient2, _weight2); vm.prank(admin); splitter.setDistributors(_distributors); - _setBurnBps(0); // 0% burn - _mintToSplitter(1000); - - splitter.split(); - - // 1000 tokens, 0% burn, equal weights = 500 each - assertEq(splitToken.balanceOf(_recipient1), 500); - assertEq(splitToken.balanceOf(_recipient2), 500); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } + _setBurnBps(_burnBps); + _mintToSplitter(_amount); - function test_Split_DistributesProportionallyWithUnequalWeights() public { - address _recipient1 = makeAddr("recipient1"); - address _recipient2 = makeAddr("recipient2"); + uint256 _splitterBalanceBefore = splitToken.balanceOf(address(splitter)); + uint256 _recipient1BalanceBefore = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceBefore = splitToken.balanceOf(_recipient2); + uint256 _totalSupplyBefore = splitToken.totalSupply(); - // Weights 1:2, total weight 3 - Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, 1, _recipient2, 2); - vm.prank(admin); - splitter.setDistributors(_distributors); - _setBurnBps(0); // 0% burn - _mintToSplitter(900); // Divisible by 3 + uint256 _expectedBurn = (_splitterBalanceBefore * _burnBps) / BPS_DENOMINATOR; splitter.split(); - // 900 tokens, weights 1:2 = 300 and 600 - assertEq(splitToken.balanceOf(_recipient1), 300); - assertEq(splitToken.balanceOf(_recipient2), 600); - assertEq(splitToken.balanceOf(address(splitter)), 0); - } - - function test_Split_BurnsAndDistributesWithMultipleRecipients() public { - address _recipient1 = makeAddr("recipient1"); - address _recipient2 = makeAddr("recipient2"); - - // Weights 1:2, total weight 3 - Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, 1, _recipient2, 2); - vm.prank(admin); - splitter.setDistributors(_distributors); - _setBurnBps(1000); // 10% burn - _mintToSplitter(1000); - - uint256 _supplyBefore = splitToken.totalSupply(); - - splitter.split(); + uint256 _recipient1BalanceAfter = splitToken.balanceOf(_recipient1); + uint256 _recipient2BalanceAfter = splitToken.balanceOf(_recipient2); + uint256 _distributed = (_recipient1BalanceAfter - _recipient1BalanceBefore) + + (_recipient2BalanceAfter - _recipient2BalanceBefore); + uint256 _burned = _totalSupplyBefore - splitToken.totalSupply(); - // 1000 tokens, 10% burn = 100 burned, 900 to distribute - // 900 with weights 1:2 = 300 and 600 - assertEq(splitToken.balanceOf(_recipient1), 300); - assertEq(splitToken.balanceOf(_recipient2), 600); - assertEq(splitToken.totalSupply(), _supplyBefore - 100); + // With 2 distributors, rounding dust is <= 1 wei. + assertGe(_burned, _expectedBurn); + assertLe(_burned, _expectedBurn + 1); + assertEq(_distributed + _burned, _splitterBalanceBefore); assertEq(splitToken.balanceOf(address(splitter)), 0); } } From cf9628b4458bdd723ca018e96f393935e3d677dd Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:48:56 -0800 Subject: [PATCH 29/33] test: reuse two-distributor helpers --- test/Splitter.t.sol | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 65fa673..fc66719 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -101,17 +101,28 @@ contract SplitterTest is Test { return _distributors; } + function _setDistributors(Splitter.DistributorConfig[] memory _distributors) internal { + vm.prank(admin); + splitter.setDistributors(_distributors); + } + function _addDistributor() internal { address _recipient = makeAddr("recipient"); Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient, 100); - vm.prank(admin); - splitter.setDistributors(_distributors); + _setDistributors(_distributors); } function _addDistributors(address _recipient, uint256 _weight) internal { Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient, _weight); - vm.prank(admin); - splitter.setDistributors(_distributors); + _setDistributors(_distributors); + } + + function _addTwoDistributors(address _recipient1, uint256 _weight1, address _recipient2, uint256 _weight2) + internal + { + Splitter.DistributorConfig[] memory _distributors = + _createDistributors(_recipient1, _weight1, _recipient2, _weight2); + _setDistributors(_distributors); } function _setBurnBps(uint256 _burnBps) internal { @@ -846,12 +857,8 @@ contract Split is SplitterTest { // Set up 2 distributors with weights that will cause rounding // weights 1 and 2 -> total 3, amounts not divisible by 3 will have dust - Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, 1, _recipient2, 2); - vm.prank(admin); - splitter.setDistributors(_distributors); - vm.prank(admin); - splitter.setBurnPercentage(0); + _addTwoDistributors(_recipient1, 1, _recipient2, 2); + _setBurnBps(0); _mintToSplitter(_amount); @@ -884,12 +891,8 @@ contract Split is SplitterTest { _burnBps = bound(_burnBps, 1, BPS_DENOMINATOR - 1); _amount = bound(_amount, 100, type(uint128).max); - Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, 1, _recipient2, 2); - vm.prank(admin); - splitter.setDistributors(_distributors); - vm.prank(admin); - splitter.setBurnPercentage(_burnBps); + _addTwoDistributors(_recipient1, 1, _recipient2, 2); + _setBurnBps(_burnBps); _mintToSplitter(_amount); @@ -999,10 +1002,7 @@ contract Split is SplitterTest { _burnBps = bound(_burnBps, 0, BPS_DENOMINATOR); _amount = bound(_amount, 1, type(uint128).max); - Splitter.DistributorConfig[] memory _distributors = - _createDistributors(_recipient1, _weight1, _recipient2, _weight2); - vm.prank(admin); - splitter.setDistributors(_distributors); + _addTwoDistributors(_recipient1, _weight1, _recipient2, _weight2); _setBurnBps(_burnBps); _mintToSplitter(_amount); From 17556812f89846ec9972981ee6ced9f5c3e41624 Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:00:49 -0800 Subject: [PATCH 30/33] test: drop redundant Split_ prefix in Split suite --- test/Splitter.t.sol | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index fc66719..5668915 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -734,7 +734,7 @@ contract SetBurnPercentage is SplitterTest { } contract Split is SplitterTest { - function testFuzz_Split_DistributesToSingleDistributor( + function testFuzz_DistributesToSingleDistributor( address _caller, address _recipient, uint256 _weight, @@ -769,7 +769,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_DistributesToMultipleDistributors( + function testFuzz_DistributesToMultipleDistributors( address _recipient1, address _recipient2, uint256 _weight1, @@ -812,7 +812,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_DistributesAllTokensWhenBurnIsZeroPercent( + function testFuzz_DistributesAllTokensWhenBurnIsZeroPercent( address _recipient, uint256 _weight, uint256 _amount @@ -841,7 +841,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_BurnsDustFromRounding( + function testFuzz_BurnsDustFromRounding( address _recipient1, address _recipient2, uint256 _amount @@ -876,7 +876,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_BurnsDustFromRounding_WhenBurnBpsSet( + function testFuzz_BurnsDustFromRounding_WhenBurnBpsSet( address _recipient1, address _recipient2, uint256 _burnBps, @@ -920,7 +920,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_EmitsSplitEvent( + function testFuzz_EmitsSplitEvent( address _recipient, uint256 _weight, uint256 _burnBps, @@ -945,7 +945,7 @@ contract Split is SplitterTest { splitter.split(); } - function testFuzz_Split_ZeroBalanceIsNoOp(address _recipient, uint256 _weight) public { + function testFuzz_ZeroBalanceIsNoOp(address _recipient, uint256 _weight) public { _assumeNonZeroAddress(_recipient); _assumeNotSplitter(_recipient); _weight = _boundWeight(_weight); @@ -965,7 +965,7 @@ contract Split is SplitterTest { assertEq(_entries.length, 0); } - function testFuzz_Split_NoDistributors_EmitsSplitEventAndBurnsAll(uint256 _amount) public { + function testFuzz_NoDistributors_EmitsSplitEventAndBurnsAll(uint256 _amount) public { _amount = bound(_amount, 1, type(uint128).max); // Default setup has no distributors, 100% burn @@ -983,7 +983,7 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_Split_BurnedIsExpectedBurnPlusAtMostOneWei( + function testFuzz_BurnedIsExpectedBurnPlusAtMostOneWei( address _recipient1, address _recipient2, uint256 _weight1, From 93b4daf608149a4228d899ca6e37867f26a3308d Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:02:37 -0800 Subject: [PATCH 31/33] docs: clarify dust bound and burn includes dust --- src/Splitter.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Splitter.sol b/src/Splitter.sol index 8f25e58..fa09b9b 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -145,7 +145,8 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @dev Tokens must be transferred to this contract before calling. /// If the contract's balance is zero, this function is a no-op and emits no `Split` event. /// Any dust from rounding during distribution is burned along with the burn portion. - /// This dust is strictly less than the number of distributors (in the token's smallest unit). + /// This dust is strictly less than the number of distributors (i.e. <= distributors.length - 1, in the token's + /// smallest unit). function split() external { SplitterStorage storage $ = _getSplitterStorage(); uint256 _amount = $._splitToken.balanceOf(address(this)); @@ -168,6 +169,7 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { } uint256 _dust = _distributeAmount - _totalDistributed; + // Burn includes dust from rounding during distribution. uint256 _totalBurned = _burnAmount + _dust; if (_totalBurned > 0) $._splitToken.burn(_totalBurned); From 4bd630cf3bfce85f678be85325a2abd8d30793da Mon Sep 17 00:00:00 2001 From: marcomariscal <42938673+marcomariscal@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:18:49 -0800 Subject: [PATCH 32/33] chore: scopelint fmt --- src/Splitter.sol | 4 ++-- test/Splitter.t.sol | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Splitter.sol b/src/Splitter.sol index fa09b9b..29988d9 100644 --- a/src/Splitter.sol +++ b/src/Splitter.sol @@ -145,8 +145,8 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable { /// @dev Tokens must be transferred to this contract before calling. /// If the contract's balance is zero, this function is a no-op and emits no `Split` event. /// Any dust from rounding during distribution is burned along with the burn portion. - /// This dust is strictly less than the number of distributors (i.e. <= distributors.length - 1, in the token's - /// smallest unit). + /// This dust is strictly less than the number of distributors (i.e. <= distributors.length - 1, + /// in the token's smallest unit). function split() external { SplitterStorage storage $ = _getSplitterStorage(); uint256 _amount = $._splitToken.balanceOf(address(this)); diff --git a/test/Splitter.t.sol b/test/Splitter.t.sol index 5668915..d21f75f 100644 --- a/test/Splitter.t.sol +++ b/test/Splitter.t.sol @@ -117,9 +117,12 @@ contract SplitterTest is Test { _setDistributors(_distributors); } - function _addTwoDistributors(address _recipient1, uint256 _weight1, address _recipient2, uint256 _weight2) - internal - { + function _addTwoDistributors( + address _recipient1, + uint256 _weight1, + address _recipient2, + uint256 _weight2 + ) internal { Splitter.DistributorConfig[] memory _distributors = _createDistributors(_recipient1, _weight1, _recipient2, _weight2); _setDistributors(_distributors); @@ -841,11 +844,9 @@ contract Split is SplitterTest { assertEq(splitToken.balanceOf(address(splitter)), 0); } - function testFuzz_BurnsDustFromRounding( - address _recipient1, - address _recipient2, - uint256 _amount - ) public { + function testFuzz_BurnsDustFromRounding(address _recipient1, address _recipient2, uint256 _amount) + public + { _assumeNonZeroAddress(_recipient1); _assumeNonZeroAddress(_recipient2); _assumeNotSplitter(_recipient1); From c0a508743cc5605f36d058c98badd3a96667c64e Mon Sep 17 00:00:00 2001 From: keating Date: Mon, 16 Feb 2026 15:13:28 -0500 Subject: [PATCH 33/33] Minor changes --- .github/workflows/ci.yml | 2 -- foundry.toml | 4 ---- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b03e3f..45a10eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,8 +94,6 @@ jobs: - name: Install Foundry uses: dutterbutter/foundry-zksync-toolchain@v1 - with: - token: ${{ github.token }} - name: Install Rust uses: actions-rs/toolchain@v1 diff --git a/foundry.toml b/foundry.toml index 12bca1d..86b1a95 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,10 +2,6 @@ evm_version = "cancun" optimizer = true optimizer_runs = 10_000_000 - remappings = [ - "openzeppelin-contracts/=lib/openzeppelin-contracts/", - "openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/", - ] solc_version = "0.8.30" verbosity = 3