Skip to content

feat(L2): add system address refunding to FeeDisburser#358

Open
0xth4nh wants to merge 10 commits into
mainfrom
mux/contracts-claude-mrb0yjit
Open

feat(L2): add system address refunding to FeeDisburser#358
0xth4nh wants to merge 10 commits into
mainfrom
mux/contracts-claude-mrb0yjit

Conversation

@0xth4nh

@0xth4nh 0xth4nh commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Closes CHAIN-4644

What changed?

`FeeDisburser` now tops up a configurable set of L2 system addresses before bridging the remaining fees to L1. Previously, the full collected balance was always bridged.

Specifically:

  • `disburseFees()` iterates over configured system addresses after vault withdrawals and before bridging. Each address is refilled up to its target balance via `SafeCall.send` (non-reverting — a failing recipient does not abort the rest).
  • `setSystemAddresses(address payable[], uint256[])` added — ProxyAdmin-gated setter for the system address/target-balance config. Validates: ≤20 entries, equal-length arrays, no `address(0)`, no zero target. Passing empty arrays clears the config and disables refunding.
  • `nonReentrantDisbursement` modifier added — custom reentrancy guard, not OZ's `ReentrancyGuardUpgradeable`. OZ cannot be used here because `FeeDisburser` is an upgradeable proxy with existing deployed storage; inheriting `ReentrancyGuardUpgradeable` would prepend its `_status` slot to the layout, shifting every existing variable and corrupting proxy storage on upgrade. Instead, `_disburseFeesEntered` is appended after the existing storage slots. It uses 1/2 sentinels (not 0/1/0) so the slot stays nonzero after first use, keeping subsequent SSTOREs warm; `0` (uninitialized proxy slot) is treated as not-entered.
  • `FeeDisburser` now inherits `ProxyAdminOwnedBase` (provides `_assertOnlyProxyAdmin` — reads the EIP-1967 admin slot directly, compatible with the raw L2-alias admin used in production without requiring a `ProxyAdmin` contract).
  • New events: `ProcessedFunds(systemAddress, success, balanceNeeded, balanceSent)`, `SystemAddressesUpdated(systemAddressCount)`.
  • New errors: `ArrayLengthMismatch`, `TooManySystemAddresses`, `ZeroTargetBalance`, `ReentrantCall`.
  • If refunds consume the entire balance, `FeesDisbursed(time, 0, 0)` is emitted and `lastDisbursementTime` is still updated (disbursement did occur, just nothing left to bridge).
  • Version bumped `1.0.0` → `1.1.0`.

Mirrors the existing `BalanceTracker` pattern on L1 (`src/L1/BalanceTracker.sol`).

Why?

L2 system addresses currently must be funded out-of-band, independently of the fee collection cycle. Routing a first-priority top-up through `FeeDisburser` (same contract that already holds all collected fees) eliminates that operational dependency and keeps the L1 and L2 funding patterns consistent.

How to test?

forge test --match-path test/L2/FeeDisburser.t.sol -vvv

Tests cover:

  1. Full refund — system address reaches target, remainder bridges to L1.
  2. Partial refund (insufficient funds) — target exceeds available balance; system address receives all available funds, nothing bridged.
  3. Skip when already at target — `ProcessedFunds(addr, false, 0, 0)` emitted, full balance bridges.
  4. Reverting recipient — recipient reverts on receive; `SafeCall.send` catches it, `success=false` emitted, full balance still bridges.
  5. Reentrancy blocked — system address calls back into `disburseFees()` on receive; inner call reverts with `ReentrantCall()`, caught by `SafeCall`, outer call completes normally.
  6. Multiple system addresses — correct ordering and cumulative deduction from fee balance.
  7. Clear config — passing empty arrays succeeds and disables refunding.
  8. `setSystemAddresses` access control — non-ProxyAdmin caller reverts.

Before bridging collected fees to L1, FeeDisburser now tops up
configured L2 system addresses to their target balances, mirroring
the BalanceTracker pattern on L1. Remaining balance after refunds
is bridged to L1_WALLET as before.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
@linear

linear Bot commented Jul 8, 2026

Copy link
Copy Markdown

CHAIN-4644

@cb-heimdall

cb-heimdall commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

0xth4nh and others added 5 commits July 9, 2026 15:35
Adds tests covering the new setSystemAddresses function, disburseFees
with system address refunds, and reentrancy guard. Also fixes
setSystemAddresses access control to use _assertOnlyProxyAdmin()
instead of _assertOnlyProxyAdminOwner() so the L2 alias (raw EIP-1967
admin slot) can call it directly via deposit tx without requiring a
ProxyAdmin contract.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Inline _processSystemAddressRefunds into disburseFees and
_validateSystemAddressConfig into setSystemAddresses — both were
private, single-caller wrappers. Add _makeSingleConfig test helper
to collapse repeated 4-line array setup. Drop no-op vm.deal(addr, 0)
calls. FeeDisburser now matches BalanceTracker's pattern on L1.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
- Fix ProcessedFunds NatSpec: clarify balanceSent is amount attempted, not received
- Remove EmptySystemAddresses error; allow empty arrays to clear config
- Change reentrancy guard from 0/1/0 to 1/2/1 to keep slot warm after first call
- Fix setSystemAddresses NatSpec: "ProxyAdmin owner" → "ProxyAdmin"
- Remove virtual from _refillBalanceIfNeeded (matches BalanceTracker pattern)
- Add RevertingReceiver helper and tests for clearing config, multiple system
  addresses ordering, and reverting recipients

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
These files are not part of this PR; the fmt changes are noise.

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

@jackchuma jackchuma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The patch leaves required generated snapshots stale, which will fail repository checks, and the new setter is not reachable through the standard proxy admin owner flow. These should be fixed before the change is considered correct.

Comment thread src/L2/FeeDisburser.sol
Comment thread src/L2/FeeDisburser.sol Outdated
Comment thread src/L2/FeeDisburser.sol Outdated
Comment thread test/L2/FeeDisburser.t.sol Outdated
Comment thread test/L2/FeeDisburser.t.sol Outdated
Comment thread test/L2/FeeDisburser.t.sol Outdated
Comment thread test/L2/FeeDisburser.t.sol Outdated
- Replace setSystemAddresses with initialize(reinitializer(2)) so config
  is set atomically via upgradeAndCall, resolving P2 access control issue
- Regenerate ABI, storage layout, and semver-lock snapshots (P1)
- Simplify ReentrantReceiver to be stateless (use msg.sender in receive)
- Replace RevertingReceiver helper with vm.mockCallRevert
- Remove redundant vm.deal(vault, 0) lines in refund tests (fresh state is 0)
- Delete test_disburseFees_success_allFundsConsumedByRefund (covered by
  the more general partial refund test)

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
@cb-heimdall cb-heimdall dismissed jackchuma’s stale review July 10, 2026 03:45

Approved review 4667004766 from jackchuma is now dismissed due to new commit. Re-request for approval.

0xth4nh and others added 3 commits July 10, 2026 14:54
Co-authored-by: Codex <codex-noreply@coinbase.com>
Co-authored-by: Codex <codex-noreply@coinbase.com>
Co-authored-by: Codex <codex-noreply@coinbase.com>
@0xth4nh 0xth4nh requested a review from jackchuma July 10, 2026 20:10
Comment thread src/L2/FeeDisburser.sol
Comment on lines +201 to +206
function initialize(
address payable[] memory systemAddresses_,
uint256[] memory targetBalances_
)
external
reinitializer(2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Restrict one-time fee recipient initialization. If this proxy is upgraded or deployed without executing this initializer in the same transaction, any account can call the public reinitializer first, permanently consume version 2, and set systemAddresses/targetBalances to addresses they control; subsequent disburseFees calls will top those addresses up before bridging fees. This should be limited to the ProxyAdmin/owner or otherwise made impossible to front-run

Comment thread src/L2/FeeDisburser.sol
}
}

uint256 bridgeBalance = address(this).balance;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: the zero-bridge case has its own early return, but it emits the same event shape as the normal path with bridgeBalance == 0. You can avoid the duplicated FeesDisbursed path by only guarding the bridge call:

  uint256 bridgeBalance = address(this).balance;
  if (bridgeBalance != 0) {
      IL2StandardBridge(payable(Predeploys.L2_STANDARD_BRIDGE)).bridgeETHTo{ value: bridgeBalance }(
          L1_WALLET, WITHDRAWAL_MIN_GAS, bytes("")
      );
  }

  emit FeesDisbursed(disbursementTime, 0, bridgeBalance);

Comment on lines +821 to +824
for (uint256 i; i < n; i++) {
addrs[i] = payable(address(uint160(0x6000 + i)));
balances[i] = 1 ether;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: this test expects TooManySystemAddresses, which happens before the initializer reads any array values. Filling every address and balance makes the test look like it depends on valid contents, but it only depends on length. These lines can be deleted

feeDisburser.initialize(addrs, balances);
}

function test_initialize_revert_arrayLengthMismatch() public {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Populating the arrays in this test is unnecessary since only the length mismatch is what is tested

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.

3 participants