Fee Management
How management and performance fees are configured, accrued, and harvested
① Fee Configuration — setters, storage keys, and caps
| Storage key (library) | Scale | Written by |
|---|---|---|
MANAGEMENT_FEE_RATE (UintValueLib) | WAD · 1e18 = 100%/yr | VaultManagerAdmin.setManagementFeeRate · bounded by MAX_MANAGEMENT_FEE_RATE = 0.1e18 |
PERFORMANCE_FEE_RATE (UintValueLib) | WAD · 1e18 = 100% of gain | VaultManagerAdmin.setPerformanceFeeRate · bounded by MAX_PERFORMANCE_FEE_RATE = 0.5e18 |
FEE_RECEIVER (AddrValueLib) | address | VaultManagerAdmin.setFeeReceiver · rejects address(0) |
LAST_MANAGEMENT_HARVEST (UintValueLib) | unix seconds | VaultManager.harvestManagementFee · bootstrapped on first call |
HIGH_WATERMARK (UintValueLib) | WAD · PPS units | VaultManager.harvestPerformanceFee · bootstrapped on first call, ratchets up |
LAST_PERFORMANCE_HARVEST (UintValueLib) | unix seconds | VaultManager.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
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| # | Step | Effect / guard |
|---|---|---|
| ① | onlyRole(OPERATOR_ROLE) | Caller must hold the operator role; otherwise revert. |
| ② | Read feeRate | If feeRate == 0, return 0 immediately (no-op). |
| ③ | Read FEE_RECEIVER | If zero, revert with FeeReceiverNotSet. |
| ④ | Bootstrap lastHarvest | First call ever: set lastHarvest = block.timestamp and return 0 — no fee for the genesis period. |
| ⑤ | Time guard | Require block.timestamp > lastHarvest. Same-block re-harvest reverts with NoTimeElapsed. |
| ⑥ | _computeManagementFee | Compute (feeAmount, sharesToMint) from effNavDenomination, effectiveSupply, elapsed period, and feeRate. Returns (0,0) if any input is zero or fees round to zero. |
| ⑦ | Persist lastHarvest | Write block.timestamp to LAST_MANAGEMENT_HARVEST_KEY; advances even when sharesToMint == 0. |
| ⑧ | ShareToken.mintFromVaultManager | Mint sharesToMint hSHARE to the fee receiver. Skipped when sharesToMint == 0. |
| ⑨ | Recompute PPS | newPps = effNavDenomination × WAD / effectiveSupply (floor). NAV unchanged; supply grew, so PPS drops. |
| ⑩ | Emit events | PricePerShareUpdated(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.000 | 1,000,000.000 | 1.000000 |
| harvestManagementFee() feeAmount ≈ 1,643.836 · sharesToMint ≈ 1,646.541 |
1,000,000.000 | 1,001,646.541 | 0.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 = currentPps − highWatermark
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)
gainPerShare = currentPps − highWatermark
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)
High-water-mark rules:
• First call (
• If
• On a successful run,
• Drawdowns are absorbed by holders; the next fee only fires when PPS climbs above the previous peak.
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| # | Step | Effect / guard |
|---|---|---|
| ① | onlyRole(OPERATOR_ROLE) | Caller must hold the operator role; otherwise revert. |
| ② | Read feeRate | If feeRate == 0, return 0 immediately (no-op). |
| ③ | Read FEE_RECEIVER | If zero, revert with FeeReceiverNotSet. |
| ④ | Bootstrap watermark | If watermark == 0, set watermark = currentPps, set lastPerformanceHarvest, return 0. |
| ⑤ | No-gain short-circuit | If currentPps ≤ watermark, emit PerformanceFeeCollected(receiver, 0, 0) and return 0. Watermark not raised. |
| ⑥ | _computePerformanceFee | Compute (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 time | Raise HIGH_WATERMARK to currentPps and write LAST_PERFORMANCE_HARVEST — even when sharesToMint == 0. |
| ⑧ | ShareToken.mintFromVaultManager | Mint sharesToMint hSHARE to the fee receiver. Skipped when sharesToMint == 0; the zero-fee event is still emitted. |
| ⑨ | Recompute PPS | newPps = effNavDenomination × WAD / effectiveSupply (floor). |
| ⑩ | Emit events | PricePerShareUpdated(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.000000 | 1,000,000.000 | 1.100000 |
| harvestPerformanceFee() gainPerShare = 0.10 · totalProfit = 100,000 · feeAmount = 20,000 · sharesToMint ≈ 18,518.519 |
1.100000 | 1,018,518.519 | 1.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()
Why this order:
• Mgmt fee first: mints shares while the previous period's
• Sync feeds: off-chain prices must be pushed before
• updateNav: applies
• 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.
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
| Event | Declared in | Emitted 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
| Error | Trigger |
|---|---|
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
| View | Returns |
|---|---|
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. |