Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3032a7c
feat: add split method to Splitter
marcomariscal Dec 16, 2025
312ea3f
refactor: simplify split to push-based pattern
marcomariscal Dec 16, 2025
0b8ef9f
style: use modern vm.expectEmit and add BPS_DENOMINATOR constant
marcomariscal Dec 16, 2025
26272b4
refactor: extract _addDistributors and _setBurnBps helpers to base co…
marcomariscal Dec 18, 2025
8183615
refactor: move _mintToSplitter to base test contract
marcomariscal Dec 18, 2025
bfd1d0d
test: add concrete test cases with hardcoded expected values
marcomariscal Dec 18, 2025
aba48be
style: rename split tests for better scopelint spec readability
marcomariscal Dec 18, 2025
c4f6a27
test: drop redundant fuzz burn assertion
marcomariscal Jan 8, 2026
9161df6
test: assert split invariants via deltas
marcomariscal Jan 8, 2026
03128eb
test: assert burn via total supply delta
marcomariscal Jan 8, 2026
90eed4c
test: document burn rounding bound
marcomariscal Jan 8, 2026
6ce3bff
test: cover dust burn with nonzero burn bps
marcomariscal Jan 8, 2026
cd2e5d9
test: remove weight overflow assumes
marcomariscal Jan 8, 2026
722e689
docs: clarify dust bound and burn accounting
marcomariscal Jan 8, 2026
fb25442
style: scopelint fmt
marcomariscal Jan 8, 2026
fd6d892
ci: pass token to foundry toolchain
marcomariscal Jan 8, 2026
9ecf843
chore: drop foundry token inputs
marcomariscal Jan 16, 2026
a750d34
chore: use openzeppelin remappings
marcomariscal Jan 16, 2026
2a3635e
test: assert exact distributions
marcomariscal Jan 16, 2026
84015c3
test: use _setBurnBps helper
marcomariscal Feb 10, 2026
dc4a1f3
test: tighten dust bound to <= 1 wei
marcomariscal Feb 10, 2026
963ceb4
test: remove redundant 100% burn split test
marcomariscal Feb 10, 2026
2b90467
test: ensure dust rounding fuzz always produces dust
marcomariscal Feb 10, 2026
6f0749c
test: rename split event test
marcomariscal Feb 10, 2026
2d6e4a7
docs: document split() zero-balance no-op
marcomariscal Feb 10, 2026
433b17f
test: clarify no-distributors split event test
marcomariscal Feb 10, 2026
c3a44d9
test: fold permissionless split into existing fuzz
marcomariscal Feb 10, 2026
90c057e
test: replace hardcoded split cases with burn invariant fuzz
marcomariscal Feb 10, 2026
cf9628b
test: reuse two-distributor helpers
marcomariscal Feb 10, 2026
1755681
test: drop redundant Split_ prefix in Split suite
marcomariscal Feb 10, 2026
93b4daf
docs: clarify dust bound and burn includes dust
marcomariscal Feb 10, 2026
4bd630c
chore: scopelint fmt
marcomariscal Feb 10, 2026
c0a5087
Minor changes
alexkeating Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/FeeFlow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 46 additions & 2 deletions src/Splitter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +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 "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";

/// @title Splitter
/// @author ScopeLift
Expand All @@ -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");

Expand All @@ -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();

Expand Down Expand Up @@ -132,6 +141,41 @@ contract Splitter is AccessControlUpgradeable, UUPSUpgradeable {
return $._distributors;
}

/// @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 (i.e. <= distributors.length - 1,
/// in the token's smallest unit).
function split() external {
SplitterStorage storage $ = _getSplitterStorage();
uint256 _amount = $._splitToken.balanceOf(address(this));

if (_amount == 0) return;

// 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;
}
}

uint256 _dust = _distributeAmount - _totalDistributed;
// Burn includes dust from rounding during distribution.
uint256 _totalBurned = _burnAmount + _dust;
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.


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 {
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces/IERC20Burnable.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions test/FeeFlow.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading