Skip to content

feat: add split method to Splitter#31

Merged
alexkeating merged 33 commits into
mainfrom
feat/split-method
Feb 16, 2026
Merged

feat: add split method to Splitter#31
alexkeating merged 33 commits into
mainfrom
feat/split-method

Conversation

@marcomariscal
Copy link
Copy Markdown
Contributor

Summary

  • Add permissionless split() method to Splitter contract
  • Burns configured percentage of tokens and distributes rest to weighted distributors
  • Uses SafeERC20 for safe token transfers

Changes

  • Added split(uint256 _amount) function using safeTransferFrom to pull tokens
  • Added Split(amount, burned, distributed) event
  • Added Splitter_AmountMismatch error for fee-on-transfer token protection
  • Rounding dust from distributor share calculations is burned (not left in contract)
  • Added 11 comprehensive fuzz tests covering all split scenarios

Test Plan

  • All 94 tests pass (forge test)
  • Linter passes (scopelint check)
  • Fuzz tests cover: burn calculations, single/multiple distributors, 0%/100% burn, dust burning, event emission, zero amount, insufficient approval

Resolves #16

@marcomariscal marcomariscal force-pushed the feat/burn-percentage branch 2 times, most recently from d0ca8b9 to a88e08c Compare December 16, 2025 19:47
@marcomariscal marcomariscal marked this pull request as ready for review December 16, 2025 20:17
@marcomariscal marcomariscal linked an issue Dec 16, 2025 that may be closed by this pull request
Comment thread src/Splitter.sol

// Burn the burn amount + any dust from rounding
uint256 _totalBurned = _amount - _totalDistributed;
if (_totalBurned > 0) $._splitToken.burn(_totalBurned);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why wouldn't we call with _burnAmount here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is documented in the @dev note: "Any dust from rounding is burned along with the burn portion." The _totalBurned captures both the intended burn amount and any dust from integer division in the distribution loop. Let me know if you'd like the inline comment or NatSpec to be clearer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What numbers are we talking about when it comes to dust? Also, I see n divisions for distributing and 1 for burnings so I would guess there is more dust from distribution meaning we would be burning an excess amount? We can also try to improve the precision by adding a scale factor like we do in staker. It all really depends what the dust numbers look like

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 93b4daf: added explicit docs that burn() is called with burnAmount + dust (since dust is intentionally burned), and clarified the dust bound as <= distributors.length - 1.

Comment thread test/Splitter.t.sol
address internal admin;
address internal emergencyAdmin;
uint256 internal constant DEFAULT_BURN_BPS = 10_000;
uint256 internal constant BPS_DENOMINATOR = 10_000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not reuse the constant above this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They represent different concepts: DEFAULT_BURN_BPS is the default burn percentage (happens to be 100%), while BPS_DENOMINATOR is the denominator for basis point math (always 10,000). They have the same value but different semantics - using DEFAULT_BURN_BPS in calculations would be confusing since we're not dividing by the burn percentage.

Comment thread test/Splitter.t.sol Outdated
splitter.setDistributors(_distributors);
vm.prank(admin);
splitter.setBurnPercentage(_burnBps);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would split this into two methods and move to the base test contract. _addDistributors and _burn

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 42f2d93 - extracted _addDistributors and _setBurnBps helpers to the base SplitterTest contract.

Comment thread test/Splitter.t.sol Outdated

function _mintToSplitter(uint256 _amount) internal {
splitToken.mint(address(splitter), _amount);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an existign mint? We should probably reuse that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in b4ec820 - moved _mintToSplitter to the base SplitterTest contract.

Comment thread test/Splitter.t.sol Outdated
_setupDistributorAndBurn(_recipient, _weight, _burnBps);
_mintToSplitter(_amount);

uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are testing the logic with the logic. If there is a bug in the logic we won't catch it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe have a couple tests with hardcoded weights

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 0c6c57f - added 5 concrete test cases with hardcoded expected values (e.g., 1000 tokens with 50% burn = 500 burned, 500 distributed).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably remove these, with the hardcode comment I made

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_100PercentBurn(uint256 _amount) public {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make the names more sentence like so they read better in scopelint spec

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 255ec3f - renamed tests to be more sentence-like (e.g., testFuzz_Split_BurnsAllTokensWhenBurnIsOneHundredPercent).

@marcomariscal marcomariscal changed the base branch from feat/burn-percentage to main January 6, 2026 18:04
Comment thread src/Splitter.sol

// Burn the burn amount + any dust from rounding
uint256 _totalBurned = _amount - _totalDistributed;
if (_totalBurned > 0) $._splitToken.burn(_totalBurned);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What numbers are we talking about when it comes to dust? Also, I see n divisions for distributing and 1 for burnings so I would guess there is more dust from distribution meaning we would be burning an excess amount? We can also try to improve the precision by adding a scale factor like we do in staker. It all really depends what the dust numbers look like

Comment thread test/Splitter.t.sol Outdated
_weight2 = _boundWeight(_weight2);
// Ensure weights don't overflow when added
vm.assume(_weight1 <= type(uint96).max / 2);
vm.assume(_weight2 <= type(uint96).max / 2);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is probably best to remove these assumes and rely on more flexible bounding for 786 and 787

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_BurnsDustFromRounding(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't test dust burning with a set burn percentage. We should test that case as well

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, Isn't this case tested in the earlier tests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. Addressed in 2b90467: added an assume(_amount % 3 != 0) so the dust-rounding fuzz test always exercises the non-zero dust path (instead of occasionally duplicating the “no dust” cases).

Comment thread test/Splitter.t.sol Outdated
splitter.split();

assertEq(splitToken.balanceOf(_recipient), _expectedDistribute);
assertEq(splitToken.balanceOf(address(splitter)), 0);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't assert that the burn happened. We should see total supply decrease by the burn

Comment thread test/Splitter.t.sol Outdated

splitter.split();

assertEq(splitToken.balanceOf(_recipient), _expectedDistribute);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recipient also could have a balance before the split

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also we don't need to assert this instead we could assert burned + distributed == amount where burned = totalSupplyBefore - totalSupplyAfter and distrbuted = recipientBalanceAfter - recipientBalanceBefore. This would avoid us having to recreate the logic of the tests

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be checking these invariants in other tests as well

Comment thread test/Splitter.t.sol Outdated
_setupDistributorAndBurn(_recipient, _weight, _burnBps);
_mintToSplitter(_amount);

uint256 _expectedBurn = (_amount * _burnBps) / BPS_DENOMINATOR;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can probably remove these, with the hardcode comment I made

Comment thread test/Splitter.t.sol Outdated
}

contract Split is SplitterTest {
function testFuzz_Split_DistributesToSingleDistributor(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need the split in these tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 1755681: removed the redundant Split_ prefix from test names inside contract Split (e.g. testFuzz_Split_... -> testFuzz_...).

Comment thread test/Splitter.t.sol Outdated
vm.prank(admin);
splitter.setDistributors(_distributors);
vm.prank(admin);
splitter.setBurnPercentage(0);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use the setBurn bips helper here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 84015c3: switched the direct setBurnPercentage(...) call to the existing _setBurnBps(...) helper for consistency.

Comment thread test/Splitter.t.sol Outdated
uint256 _burned = _totalSupplyBefore - splitToken.totalSupply();

assertEq(_distributed + _burned, _splitterBalanceBefore);
assertLt(_burned, 2);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maye be clear to do lte 1 for this and other tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in dc4a1f3: tightened dust assertions from < 2 to <= 1 wei (and made the intent explicit in comments).

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_DistributesAllTokensWhenBurnIsZeroPercent(
Copy link
Copy Markdown
Collaborator

@alexkeating alexkeating Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we test this in the first 2 tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 963ceb4: removed the redundant 100% burn test since testFuzz_Split_NoDistributors already asserts the 100% burn + event path.

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_EmitsEvent(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should speciify the event being tested in the name

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 6f0749c: renamed the test to testFuzz_Split_EmitsSplitEvent to make it explicit which event is being asserted.

Comment thread test/Splitter.t.sol Outdated
splitter.split();
}

function testFuzz_Split_ZeroBalance(address _recipient, uint256 _weight) public {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, should we allow splitting with a 0 balance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2d6e4a7: split() is explicitly documented and tested as a no-op when the splitter balance is 0 (no state changes and no Split event emitted).

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_NoDistributors(uint256 _amount) public {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this tested in the 100% burn test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 433b17f: kept the no-distributors case as an explicit event-emission assertion for the “no distributors / 100% burn” branch, and renamed the test to make that purpose clear (...NoDistributors_EmitsSplitEventAndBurnsAll).

Comment thread test/Splitter.t.sol Outdated
assertEq(splitToken.balanceOf(address(splitter)), 0);
}

function testFuzz_Split_AnyoneCanCall(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in one of the default cases, I don't think we need this explicit test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in c3a44d9: removed the dedicated testFuzz_Split_AnyoneCanCall test and folded the permissionless-call assertion into testFuzz_Split_DistributesToSingleDistributor by fuzzing _caller and calling split() under vm.prank(_caller).

Comment thread test/Splitter.t.sol Outdated
}

// Concrete test cases with hardcoded expected values
function test_Split_BurnsAndDistributesFiftyPercentEach() public {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need these tests. We can fuzz the burn rate and assert the invariant we want to enforce. which is burn equals the burn percentage of the previous balance give or take 1 wei

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 90c057e: removed the concrete hardcoded split cases and replaced them with a single fuzz invariant testFuzz_Split_BurnedIsExpectedBurnPlusAtMostOneWei (2 distributors so dust <= 1 wei, assert burned is expectedBurn or expectedBurn + 1).

Comment thread test/Splitter.t.sol Outdated
vm.prank(admin);
splitter.setDistributors(_distributors);
vm.prank(admin);
splitter.setBurnPercentage(_burnBps);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we reuse some helpers here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cf9628b: introduced _setDistributors(...) + _addTwoDistributors(...) helpers and refactored the 2-distributor dust/burn tests to use them (also switched those spots to _setBurnBps(...) for consistency).

@github-actions
Copy link
Copy Markdown

Coverage after merging feat/split-method into main will be

100.00%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
src
   FeeFlow.sol100%100%100%100%
   Splitter.sol100%100%100%100%

@alexkeating alexkeating merged commit 120e5da into main Feb 16, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Splitter Contract-03: Add split method

2 participants