Skip to content

feat(EXSC-411): S3 — fee receivers & sweep distribution#1983

Draft
gvladika wants to merge 10 commits into
feature/exsc-410-s2-fee-engine-4-types-accrual-caps-per-fee-splitfrom
feature/exsc-411-s3-fee-receivers-sweep-distribution
Draft

feat(EXSC-411): S3 — fee receivers & sweep distribution#1983
gvladika wants to merge 10 commits into
feature/exsc-410-s2-fee-engine-4-types-accrual-caps-per-fee-splitfrom
feature/exsc-411-s3-fee-receivers-sweep-distribution

Conversation

@gvladika

@gvladika gvladika commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Which Linear task belongs to this PR?

EXSC-411 — S3: Fee receivers & sweep distribution

Stacked on S2. The base of this PR is the S2 branch (#1993, the fee accrual engine — now including S2b's performance fee and per-fee-type split, and the S5 pause module via the merged lineage): dev-vault-wrapper ← S1 ← S2 ← S3. The diff is only the S3 distribution layer; retarget to dev-vault-wrapper once S2 lands.

Why did I implement it this way?

S2 splits every fee between LI.FI and the integrator the moment it accrues, into four per-recipient counters: lifiFeeAssets/integratorFeeAssets (idle asset, held in the wrapper, excluded from totalAssets()) and lifiFeeShares/integratorFeeShares (dilution shares minted to address(this)). S3 is the payout layer: it folds receiver configuration and a permissionless sweep() directly into the wrapper (S2 keeps all fee logic there, so no separate mixin), paying out exactly those tracked entitlements — nothing is re-split at distribution, so a later default-split change can never retroactively re-split already-earned fees.

  • Payout in native denomination. sweep() first calls _accrueFees() (so pending management/performance fees crystallize even while deposits are paused — tested), then pays out each reservoir in its own token: the idle asset as the asset, the dilution shares as wrapper shares. No adapter touch, so the sweep can never be blocked by underlying illiquidity. LI.FI's booked parts go to the factory's live lifiFeeRecipient() (an integrator can never redirect it); the integrator's booked parts are fanned across its 1..5 wallets by their bps, with the last wallet absorbing the integer-division remainder so the pool zeroes exactly.
  • Single sweep(), never blockable. Each integrator transfer goes through a self-only trustedTransfer wrapped in try/catch; a reverting wallet (e.g. a USDC-blacklisted address) has its share redirected to LI.FI in the same sweep instead of reverting. There is no separate sweepLifiFees escape hatch — redirect makes one unnecessary. LI.FI's own transfer is not caught (a blacklisted LI.FI recipient is LI.FI's own, factory-governed, concern).
  • Receivers are mandatory. Set and validated at initialize via a typed IntegratorReceivers deploy param (1..5 non-zero wallets, bps summing to exactly 100%); the setIntegratorReceivers mutator (gated on vaultWrapperAdmin) re-validates, so an empty/unconfigured receiver set is structurally impossible — no griefing window where a permissionless sweep could divert unconfigured integrator fees.
  • Storage & CEI. Receiver arrays are appended after S2's storage before a reduced __gap (uint256[46]); the subsystem is pre-release and the layout freezes before first deployment. sweep() zeroes all four counters before any transfer (CEI) and is nonReentrant; trustedTransfer is self-only and deliberately not nonReentrant so the self-call doesn't trip the guard. If a counter ever saturated (S2's uint128 clamp), the sweep pays the tracked amount — the excess stays in the wrapper rather than reverting anything.

Tests drive real accrual (deposit/withdraw + time warp through S2's engine), not seeded pools: payout equals the at-accrual tracked parts across both reservoirs, 1..5-wallet fan-out with last-receiver remainder, the blacklist→redirect-to-LI.FI path, permissionless sweep, live-recipient reads, sweep-crystallizes-pending-management, sweep accrues and pays out while deposits are paused (the ticket behaviour previously deferred to S5, now in the base), zero-split books everything to LI.FI, and the init/mutator validation reverts.

/pr-ready (local CodeRabbit) findings

  • Re-run against the S2 base after merging S2 (incl. S2b's per-fee-type split/performance fee and the S2 review fixes) into this branch and porting the sweep to the per-recipient counters (see the merge commit for the port details): no findings. One pre-existing lint gate crossing: the merged wrapper reaches 16 state declarations, over solhint's max-states-count — scoped disable with justification (single beacon-proxy storage host; mixins deliberately rejected).

Checklist before requesting a review

  • I have performed a self-review of my code
  • This pull request is as small as possible and only tackles one problem
  • I have run /pr-ready (local CodeRabbit) on this branch and resolved (or explicitly documented) all findings — see .agents/commands/pr-ready.md
  • I have added tests that cover the functionality / test the bug
  • For new facets: I have checked all points from this list: https://www.notion.so/lifi/New-Facet-Contract-Checklist-157f0ff14ac78095a2b8f999d655622e
  • I have updated any required documentation

Checklist for reviewer (DO NOT DEPLOY and contracts BEFORE CHECKING THIS!!!)

  • I have checked that any arbitrary calls to external contracts are validated and or restricted
  • I have checked that any privileged calls (i.e. storage modifications) are validated and or restricted
  • I have ensured that any new contracts have had AT A MINIMUM 1 preliminary audit conducted on by <company/auditor>

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 77fdea00-a5a5-4ece-89f6-333c9becc833

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/exsc-411-s3-fee-receivers-sweep-distribution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/VaultWrapper/VaultWrapperFeeDistributor.sol Fixed
@gvladika gvladika force-pushed the feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open branch from b0e15d6 to f83fbb7 Compare June 26, 2026 14:31
gvladika and others added 5 commits June 29, 2026 15:58
Rebuild S3 on top of S2's accrual engine (dev-vault-wrapper <- S1 <- S2 <- S3).
S2 owns accrual into undivided totals (accruedFeeAssets, accruedFeeShares); S3
adds the receiver config + a permissionless sweep that applies the
integratorShareBps split at payout time over both reservoirs.

- Receiver config (1..5 wallets, bps summing to 100%) set at initialize via a
  typed IntegratorReceivers param and mutable by the vaultWrapperAdmin; never
  emptiable, so an unconfigured-receivers state is impossible.
- sweep() accrues pending management fees first, then distributes the idle-asset
  reservoir and the dilution-share reservoir in their native denominations (no
  adapter touch). Each split by integratorShareBps; integrator portion fanned
  across wallets (last absorbs the remainder); LI.FI paid to the factory's live
  lifiFeeRecipient.
- A failing integrator transfer (e.g. a blacklisted wallet) is caught via a
  self-only trustedTransfer and redirected to LI.FI, so a single hostile wallet
  can never block the sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CodeRabbit: a node_modules symlink with an absolute local path was accidentally
staged via git add -A (it escaped the node_modules/ dir-pattern gitignore as a
file). Untrack it; the on-disk symlink stays for local hooks.
…s.sol:48)

CodeRabbit: 'bps summing to exactly 100%' reads ambiguously as [60,40]; clarify
the denominator is 10_000.
…LiFiVaultWrapper.sol:193)

CodeRabbit: the check rejects a 100% integrator share (>= 10000) so LI.FI always
keeps a non-zero cut, but the doc said 'exceeds 100%'. Reword to match.
…rapperDistribution.t.sol:36,149)

CodeRabbit: add a direct trustedTransfer external-caller revert (OnlySelf) test —
the self-call guard is the main protection against arbitrary payout routing — and
assert ReceiverBpsSumNot100 on the setIntegratorReceivers update path, not just at
initialize.
@gvladika gvladika force-pushed the feature/exsc-411-s3-fee-receivers-sweep-distribution branch from 9ef7a07 to b9afb41 Compare June 30, 2026 08:22
@gvladika gvladika changed the base branch from feature/exsc-413-s5-pause-instance-global-read-withdrawals-always-open to feature/exsc-410-s2-fee-engine-4-types-accrual-caps-per-fee-split June 30, 2026 08:23
…-fee-split' into feature/exsc-411-s3-rebased-on-s2

# Conflicts:
#	src/VaultWrapper/LiFiVaultWrapper.sol
#	src/VaultWrapper/interfaces/ILiFiVaultWrapper.sol
#	test/solidity/VaultWrapper/LiFiVaultWrapperFees.t.sol

/// @dev Integrator payout wallets (1..5), parallel to `_integratorReceiverBps`. Set at
/// `initialize` and mutable by the integrator; always non-empty after deploy.
address[] internal _integratorReceivers;
gvladika and others added 4 commits July 2, 2026 10:11
…-fee-split' into feature/exsc-411-s3-fee-receivers-sweep-distribution
…-fee-split' into feature/exsc-411-s3-rebased-on-s2

# Conflicts:
#	src/VaultWrapper/LiFiVaultWrapper.sol
…-fee-split' into feature/exsc-411-s3-fee-receivers-sweep-distribution

Brings in S2b (performance fee, per-fee-type split at accrual) and the
S2 review fixes (up-only HWM re-anchor, saturating counters, overage
booking, shared _splitFee, shared fee-test base).

Port of S3 onto the split-at-accrual model (the semantic part of this
merge): sweep no longer re-splits reservoirs by a single
integratorShareBps at distribution — it pays out the four per-recipient
counters (lifi/integrator x assets/shares) booked when each fee
accrued. _distributeReservoir now takes the two tracked parts; the
receiver fan-out and redirect-to-LI.FI behavior are unchanged.
initialize takes uint16[4] splits plus the IntegratorReceivers set; the
duplicate BPS_DENOMINATOR constant is dropped for
LibVaultWrapperMath.BASIS_POINT_SCALE. Distribution tests assert
against the tracked counters; the shared fee-test base gained
_defaultReceivers() and all S2-side call sites pass the receiver set.
The merged wrapper crosses solhint's max-states-count (16): scoped
disable with justification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…paused

The S2 base now includes the S5 pause module, so the ticket's
'sweep works while paused' behaviour is assertable — closes the
deferral documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants