Fee Management

How management and performance fees are configured, accrued, and harvested

① Fee Configuration — setters, storage keys, and caps
CONFIGURATOR DEFAULT_ADMIN_ROLE timelocked via HaTimelockController cap: rate ≤ max MAX_MGMT = 0.1e18 (10%/yr) MAX_PERF = 0.5e18 (50%) FeeRateTooHigh on overflow ZeroAddress on receiver = 0 VAULTMANAGERADMIN setManagementFeeRate(rate) applyUintConfig → SetUint("MANAGEMENT_FEE_RATE") VAULTMANAGERADMIN setPerformanceFeeRate(rate) applyUintConfig → SetUint("PERFORMANCE_FEE_RATE") VAULTMANAGERADMIN setFeeReceiver(receiver) applyAddrConfig → SetAddress("FEE_RECEIVER") UINT VALUES (VAULTMANAGER STORAGE) MANAGEMENT_FEE_RATE_KEY WAD (1e18 = 100%) · annualised UINT VALUES (VAULTMANAGER STORAGE) PERFORMANCE_FEE_RATE_KEY WAD · charged on PPS gain above watermark ADDR VALUES (VAULTMANAGER STORAGE) FEE_RECEIVER_KEY recipient of every minted fee share
Storage key (library) Scale Written by
MANAGEMENT_FEE_RATE (UintValueLib)WAD · 1e18 = 100%/yrVaultManagerAdmin.setManagementFeeRate · bounded by MAX_MANAGEMENT_FEE_RATE = 0.1e18
PERFORMANCE_FEE_RATE (UintValueLib)WAD · 1e18 = 100% of gainVaultManagerAdmin.setPerformanceFeeRate · bounded by MAX_PERFORMANCE_FEE_RATE = 0.5e18
FEE_RECEIVER (AddrValueLib)addressVaultManagerAdmin.setFeeReceiver · rejects address(0)
LAST_MANAGEMENT_HARVEST (UintValueLib)unix secondsVaultManager.harvestManagementFee · bootstrapped on first call
HIGH_WATERMARK (UintValueLib)WAD · PPS unitsVaultManager.harvestPerformanceFee · bootstrapped on first call, ratchets up
LAST_PERFORMANCE_HARVEST (UintValueLib)unix secondsVaultManager.harvestPerformanceFee · updated on every successful run
Configuration rules: all three setters require DEFAULT_ADMIN_ROLE and pass through HaTimelockController — no rate or receiver change is instant. Both rates use the WAD scale (1e18 = 100%) and revert with FeeRateTooHigh above the constant cap. Calling harvestManagementFee() or harvestPerformanceFee() while FEE_RECEIVER is the zero address reverts with FeeReceiverNotSet.
② Management Fee — linear time-based dilution
PERIOD
period = block.timestamp − lastManagementHarvest

FEE AMOUNT — annualised on the effective NAV
feeAmount = effNavDenomination × period × feeRate / (SECONDS_PER_YEAR × WAD)

SHARES TO MINT — pure-dilution issue
sharesToMint = feeAmount × effectiveSupply / (effNavDenomination − feeAmount)

CONSTANTS
SECONDS_PER_YEAR = 365 × 86400 = 31,536,000 · WAD = 1e18
Why dilution, not asset withdrawal: the fund's USDC never leaves the system — new shares are minted directly to the fee receiver. Total NAV is unchanged post-mint, but supply grows by sharesToMint, so PPS drops slightly. The fee receiver's new shares are worth exactly feeAmount in the denomination at the post-mint PPS, by construction.
harvestManagementFee() — on-chain flow
OPERATOR_ROLE harvestManagementFee() GUARDS feeRate > 0 · receiver ≠ 0 FeeReceiverNotSet on miss BOOTSTRAP BRANCH lastHarvest == 0? set lastHarvest · return 0 TIME GUARD block.timestamp > lastHarvest? NoTimeElapsed otherwise COMPUTE _computeManagementFee(...) effNav, effSupply, period, feeRate → (feeAmount, sharesToMint) PERSIST setLastManagementHarvest(now) always — even if shares == 0 SHARETOKEN mintFromVaultManager (receiver, sharesToMint) onlyVaultManager RECOMPUTE PPS newPps = effNav / effSupply Math.Rounding.Floor setPricePerShare(newPps) EVENTS PricePerShareUpdated(old, new) ManagementFeeCollected(...) indexed receiver, shares, feeAmt first call revert FeeReceiverNotSet revert NoTimeElapsed If sharesToMint rounds to 0 → lastHarvest still updated, no mint, no event.
#StepEffect / guard
onlyRole(OPERATOR_ROLE)Caller must hold the operator role; otherwise revert.
Read feeRateIf feeRate == 0, return 0 immediately (no-op).
Read FEE_RECEIVERIf zero, revert with FeeReceiverNotSet.
Bootstrap lastHarvestFirst call ever: set lastHarvest = block.timestamp and return 0 — no fee for the genesis period.
Time guardRequire block.timestamp > lastHarvest. Same-block re-harvest reverts with NoTimeElapsed.
_computeManagementFeeCompute (feeAmount, sharesToMint) from effNavDenomination, effectiveSupply, elapsed period, and feeRate. Returns (0,0) if any input is zero or fees round to zero.
Persist lastHarvestWrite block.timestamp to LAST_MANAGEMENT_HARVEST_KEY; advances even when sharesToMint == 0.
ShareToken.mintFromVaultManagerMint sharesToMint hSHARE to the fee receiver. Skipped when sharesToMint == 0.
Recompute PPSnewPps = effNavDenomination × WAD / effectiveSupply (floor). NAV unchanged; supply grew, so PPS drops.
Emit eventsPricePerShareUpdated(oldPps, newPps) + ManagementFeeCollected(receiver, sharesMinted, feeAmount).

Worked example — 30 days at 2%/yr

Inputs: effNavDenomination = 1,000,000 × 1e18 (1M denomination units, e.g. USD-pegged), effectiveSupply = 1,000,000 × 1e18 hSHARE, feeRate = 0.02e18 (2%/yr), period = 30 × 86,400 = 2,592,000 s.

Stage effNavDenomination effectiveSupply (hSHARE) PPS
Before harvest 1,000,000.0001,000,000.0001.000000
harvestManagementFee()
feeAmount ≈ 1,643.836 · sharesToMint ≈ 1,646.541
1,000,000.0001,001,646.5410.998356
Receiver value check
sharesToMint × postPPS ≈ feeAmount
1,646.541 hSHARE≈ 1,643.836
Sanity check: NAV (the numerator) is unchanged — no USDC moved. Supply grew by exactly the amount needed for the receiver's new shares to be worth feeAmount at the post-mint PPS. Existing holders are diluted by exactly feeAmount of denominated value.
④ Performance Fee — high-water-mark math
GAIN PER SHARE
gainPerShare = currentPpshighWatermark

TOTAL PROFIT
totalProfit = gainPerShare × effectiveSupply / WAD

FEE AMOUNT
feeAmount = totalProfit × performanceFeeRate / WAD

SHARES TO MINT — same dilution form as management fee
sharesToMint = feeAmount × effectiveSupply / (effNavDenomination − feeAmount)
PPS vs HIGH-WATER-MARK — ratchet behaviour t0 t1 (harvest) t2 (drawdown) t3 (recovery, no fee) t4 (new high, harvest) t5 PPS 1.20 1.10 1.00 watermark PPS FEE FEE NO FEE SEED
High-water-mark rules: • First call (watermark == 0) bootstraps HIGH_WATERMARK to currentPps and returns 0 — no charge on the seed PPS.
• If currentPps ≤ watermark, emit PerformanceFeeCollected(receiver, 0, 0) and exit — no fee, no watermark change.
• On a successful run, watermark is raised to currentPps even if sharesToMint rounds to 0 — the protocol never re-charges the same gain.
• Drawdowns are absorbed by holders; the next fee only fires when PPS climbs above the previous peak.
harvestPerformanceFee() — on-chain flow
OPERATOR_ROLE harvestPerformanceFee() GUARDS feeRate > 0 · receiver ≠ 0 FeeReceiverNotSet on miss BOOTSTRAP BRANCH watermark == 0? set watermark = currentPps · return 0 NO-GAIN BRANCH currentPps ≤ watermark? emit zero event · return 0 COMPUTE _computePerformanceFee(...) currentPps, watermark, effSupply, effNav, feeRate → (feeAmount, sharesToMint) PERSIST setHighWatermark(currentPps) setLastPerformanceHarvest(now) always — even if shares == 0 SHARETOKEN mintFromVaultManager (receiver, sharesToMint) skipped if sharesToMint == 0 RECOMPUTE PPS newPps = effNav / effSupply Math.Rounding.Floor setPricePerShare(newPps) EVENTS PricePerShareUpdated(old, new) PerformanceFeeCollected(...) indexed receiver, shares, feeAmt first call no gain currentPps > watermark revert FeeReceiverNotSet If sharesToMint rounds to 0 → watermark + lastHarvest still updated, no mint, event emitted with shares = 0.
#StepEffect / guard
onlyRole(OPERATOR_ROLE)Caller must hold the operator role; otherwise revert.
Read feeRateIf feeRate == 0, return 0 immediately (no-op).
Read FEE_RECEIVERIf zero, revert with FeeReceiverNotSet.
Bootstrap watermarkIf watermark == 0, set watermark = currentPps, set lastPerformanceHarvest, return 0.
No-gain short-circuitIf currentPps ≤ watermark, emit PerformanceFeeCollected(receiver, 0, 0) and return 0. Watermark not raised.
_computePerformanceFeeCompute (feeAmount, sharesToMint) from currentPps, watermark, effectiveSupply, effNavDenomination, feeRate. Returns (0,0) when supply or NAV is zero or any factor rounds to zero.
Persist watermark + harvest timeRaise HIGH_WATERMARK to currentPps and write LAST_PERFORMANCE_HARVEST — even when sharesToMint == 0.
ShareToken.mintFromVaultManagerMint sharesToMint hSHARE to the fee receiver. Skipped when sharesToMint == 0; the zero-fee event is still emitted.
Recompute PPSnewPps = effNavDenomination × WAD / effectiveSupply (floor).
Emit eventsPricePerShareUpdated(oldPps, newPps) + PerformanceFeeCollected(receiver, sharesMinted, feeAmount).

Worked example — 10% PPS gain at 20% performance fee

Inputs: watermark = 1.00 × 1e18, currentPps = 1.10 × 1e18 (+10% gain), effectiveSupply = 1,000,000 × 1e18 hSHARE, effNavDenomination = 1,100,000 × 1e18 (consistent with PPS), feeRate = 0.20e18 (20%).

Stage watermark effectiveSupply (hSHARE) PPS
Before harvest 1.0000001,000,000.0001.100000
harvestPerformanceFee()
gainPerShare = 0.10 · totalProfit = 100,000 · feeAmount = 20,000 · sharesToMint ≈ 18,518.519
1.1000001,018,518.5191.080000
Receiver value check
sharesToMint × postPPS ≈ feeAmount
18,518.519 hSHARE≈ 20,000.000
Sanity check: the protocol kept 80% of the +10% PPS gain (post-fee PPS = 1.08), and the fee receiver's 18,518.519 hSHARE are worth exactly the captured 20% (≈ 20,000) at the new PPS. Watermark advances to 1.10; the next harvest only fires when PPS climbs above that.
⑥ Recommended Harvest Sequence — pairing with updateNav()
OPERATOR_ROLE keeper / multisig STEP 1 harvestManagementFee() accrue mgmt fee on prior period — before NAV refresh supply ↑ · PPS ↓ STEP 2 · OFF-CHAIN FundNavFeed.syncNavValue push fresh strategy values (per asset / per category) required before updateNav STEP 3 VaultManager.updateNav() recompute PPS with fresh NAV + new supply deviationPps guard runs here STEP 4 harvestPerformanceFee() read freshest PPS vs watermark
Why this order: Mgmt fee first: mints shares while the previous period's effNavDenomination is still the basis — the fee captures time elapsed against the prior NAV, not the new one.
Sync feeds: off-chain prices must be pushed before updateNav() or the next NAV will be stale; otherwise PPS will move falsely on the next refresh.
updateNav: applies deviationPps and maxNavStaleness guards and locks in the post-fee PPS.
Perf fee last: reads the freshest PPS, so the fee is taken on the realised peak; watermark advances to that peak.
• Either fee is a no-op if its rate is 0, so the sequence is safe to run unconditionally.
⑦ Events, Errors, and Read-Only Previews

Events

EventDeclared inEmitted at
ManagementFeeCollected(address indexed receiver, uint256 sharesMinted, uint256 feeAmount) contracts/interfaces/IVaultManager.sol VaultManager.harvestManagementFee
PerformanceFeeCollected(address indexed receiver, uint256 sharesMinted, uint256 feeAmount) contracts/interfaces/IVaultManager.sol VaultManager.harvestPerformanceFee (incl. zero-fee no-gain branch)
PricePerShareUpdated(uint256 oldPps, uint256 newPps) contracts/interfaces/IVaultManager.sol Both harvest functions after PPS recomputation; also updateNav
SetUint(uint256 value, bytes configType) contracts/interfaces/IVaultManagerAdmin.sol setManagementFeeRate / setPerformanceFeeRate with configType ∈ {"MANAGEMENT_FEE_RATE", "PERFORMANCE_FEE_RATE"}
SetAddress(address addr, bytes configType) contracts/interfaces/IVaultManagerAdmin.sol setFeeReceiver with configType = "FEE_RECEIVER"

Custom errors

ErrorTrigger
FeeRateTooHigh()Setter input exceeds MAX_MANAGEMENT_FEE_RATE (0.1e18) or MAX_PERFORMANCE_FEE_RATE (0.5e18).
FeeReceiverNotSet()Either harvest function called while FEE_RECEIVER is the zero address.
NoTimeElapsed()harvestManagementFee() called twice in the same block (or with a clock that did not advance).
ZeroAddress()setFeeReceiver(address(0)).

Read-only previews

ViewReturns
previewHarvestManagementFee() (feeAmount, sharesToMint) using the same _computeManagementFee; returns (0,0) when the rate is 0, the receiver is unset, or no time has elapsed since the last harvest.
previewHarvestPerformanceFee() (feeAmount, sharesToMint) using the same _computePerformanceFee; returns (0,0) when the rate is 0, the receiver is unset, the watermark is unbootstrapped, or PPS has not exceeded the watermark.