@venusprotocol/liquidity-hub 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +422 -0
  3. package/contracts/Hub/Hub.sol +1614 -0
  4. package/contracts/Hub/HubStorage.sol +106 -0
  5. package/contracts/Hub/lib/HubAdminLib.sol +292 -0
  6. package/contracts/YieldGroup/YieldGroup.sol +128 -0
  7. package/contracts/YieldGroup/YieldGroupFRV.sol +107 -0
  8. package/contracts/YieldGroup/base/YieldGroupBase.sol +797 -0
  9. package/contracts/YieldGroup/base/YieldGroupBaseStorage.sol +73 -0
  10. package/contracts/adapters/AdapterCoreV1.sol +309 -0
  11. package/contracts/adapters/AdapterFRV.sol +276 -0
  12. package/contracts/adapters/AdapterFlux.sol +173 -0
  13. package/contracts/interfaces/IHub.sol +551 -0
  14. package/contracts/interfaces/IHubRegistry.sol +120 -0
  15. package/contracts/interfaces/IMigrator.sol +134 -0
  16. package/contracts/interfaces/IResourceAdapter.sol +154 -0
  17. package/contracts/interfaces/IYieldGroup.sol +119 -0
  18. package/contracts/interfaces/IYieldGroupBase.sol +507 -0
  19. package/contracts/interfaces/IYieldGroupFRV.sol +61 -0
  20. package/contracts/interfaces/external/IComptroller.sol +57 -0
  21. package/contracts/interfaces/external/IFRVVault.sol +113 -0
  22. package/contracts/interfaces/external/IFToken.sol +46 -0
  23. package/contracts/interfaces/external/IFluidLendingResolver.sol +63 -0
  24. package/contracts/interfaces/external/IVBep20.sol +39 -0
  25. package/contracts/interfaces/external/IVToken.sol +125 -0
  26. package/contracts/interfaces/external/IWBNB.sol +16 -0
  27. package/contracts/migrator/Migrator.sol +182 -0
  28. package/contracts/registry/HubRegistry.sol +179 -0
  29. package/deployments/bscmainnet/AdapterCoreV1.json +336 -0
  30. package/deployments/bscmainnet/AdapterFRV.json +225 -0
  31. package/deployments/bscmainnet/AdapterFlux.json +249 -0
  32. package/deployments/bscmainnet/CoreBeacon.json +110 -0
  33. package/deployments/bscmainnet/CoreSource_U.json +1083 -0
  34. package/deployments/bscmainnet/CoreSource_USDC.json +1083 -0
  35. package/deployments/bscmainnet/CoreSource_USDT.json +1083 -0
  36. package/deployments/bscmainnet/FRVBeacon.json +110 -0
  37. package/deployments/bscmainnet/FRVSource_U.json +978 -0
  38. package/deployments/bscmainnet/FRVSource_USDC.json +978 -0
  39. package/deployments/bscmainnet/FRVSource_USDT.json +978 -0
  40. package/deployments/bscmainnet/FluxBeacon.json +110 -0
  41. package/deployments/bscmainnet/FluxSource_U.json +1083 -0
  42. package/deployments/bscmainnet/FluxSource_USDC.json +1083 -0
  43. package/deployments/bscmainnet/FluxSource_USDT.json +1083 -0
  44. package/deployments/bscmainnet/HubBeacon.json +110 -0
  45. package/deployments/bscmainnet/HubImpl.json +2216 -0
  46. package/deployments/bscmainnet/HubRegistry.json +386 -0
  47. package/deployments/bscmainnet/HubRegistryImpl.json +386 -0
  48. package/deployments/bscmainnet/Hub_U.json +2216 -0
  49. package/deployments/bscmainnet/Hub_USDC.json +2216 -0
  50. package/deployments/bscmainnet/Hub_USDT.json +2216 -0
  51. package/deployments/bscmainnet/Migrator.json +375 -0
  52. package/deployments/bscmainnet/YieldGroupCoreImpl.json +1083 -0
  53. package/deployments/bscmainnet/YieldGroupFRVImpl.json +978 -0
  54. package/deployments/bscmainnet/YieldGroupFluxImpl.json +1083 -0
  55. package/deployments/bscmainnet_addresses.json +32 -0
  56. package/deployments/bsctestnet/AdapterCoreV1.json +336 -0
  57. package/deployments/bsctestnet/AdapterFRV.json +225 -0
  58. package/deployments/bsctestnet/AdapterFlux.json +249 -0
  59. package/deployments/bsctestnet/CoreBeacon.json +110 -0
  60. package/deployments/bsctestnet/CoreSource_USDT.json +1083 -0
  61. package/deployments/bsctestnet/FRVBeacon.json +110 -0
  62. package/deployments/bsctestnet/FRVSource_USDT.json +978 -0
  63. package/deployments/bsctestnet/FluxBeacon.json +110 -0
  64. package/deployments/bsctestnet/FluxSource_USDT.json +1083 -0
  65. package/deployments/bsctestnet/HubBeacon.json +110 -0
  66. package/deployments/bsctestnet/HubImpl.json +2216 -0
  67. package/deployments/bsctestnet/HubRegistry.json +386 -0
  68. package/deployments/bsctestnet/HubRegistryImpl.json +386 -0
  69. package/deployments/bsctestnet/HubRegistryProxyAdmin.json +157 -0
  70. package/deployments/bsctestnet/Hub_USDT.json +2216 -0
  71. package/deployments/bsctestnet/Migrator.json +375 -0
  72. package/deployments/bsctestnet/YieldGroupCoreImpl.json +1083 -0
  73. package/deployments/bsctestnet/YieldGroupFRVImpl.json +978 -0
  74. package/deployments/bsctestnet/YieldGroupFluxImpl.json +1083 -0
  75. package/deployments/bsctestnet_addresses.json +25 -0
  76. package/package.json +46 -0
@@ -0,0 +1,1614 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ import { IERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/IERC20Upgradeable.sol";
5
+ import { ERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol";
6
+ import { IERC4626Upgradeable } from "@openzeppelin/contracts-upgradeable/interfaces/IERC4626Upgradeable.sol";
7
+ import {
8
+ ReentrancyGuardUpgradeable
9
+ } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol";
10
+ import { SafeERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol";
11
+
12
+ import { AccessControlledV8 } from "@venusprotocol/governance-contracts/contracts/Governance/AccessControlledV8.sol";
13
+
14
+ import { IHub } from "../interfaces/IHub.sol";
15
+ import { IYieldGroupBase } from "../interfaces/IYieldGroupBase.sol";
16
+ import { HubStorage } from "./HubStorage.sol";
17
+ import { HubAdminLib } from "./lib/HubAdminLib.sol";
18
+
19
+ /**
20
+ * @title Hub
21
+ * @author Venus
22
+ * @notice Per-asset ERC-4626 vault that aggregates yield by routing deposits and withdrawals
23
+ * across multiple YieldGroups (typed routers to Venus Core vTokens, Fluid markets, FRVs, ...).
24
+ * @dev Architecture: Hub → YieldGroup → Vault. Hub never reaches past a YieldGroup.
25
+ * Atomic-or-revert: every deposit / withdraw / reallocate fully completes or reverts.
26
+ * Inheritance order is load-bearing for upgrade safety — do not reorder.
27
+ * Function ordering follows the Solidity style guide: external → public → internal →
28
+ * private, mutating before view within each group.
29
+ */
30
+ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeable, HubStorage, IHub {
31
+ using SafeERC20Upgradeable for IERC20Upgradeable;
32
+
33
+ // ============================== State (constants) =========================
34
+
35
+ /// @notice BPS denominator (`10_000` = 100%).
36
+ uint16 public constant BPS_DENOMINATOR = 10_000;
37
+
38
+ /// @notice Fixed-point scale for share-price math.
39
+ uint256 public constant EXP_SCALE = 1e18;
40
+
41
+ /// @notice Maximum allowed inflation-defense decimals offset.
42
+ /// @dev Capped at 12 (not 18): above ~12 the performance-fee branch loses resolution
43
+ /// because the HWM seed `EXP_SCALE / 10^offset` shrinks faster than realistic
44
+ /// per-share gains can climb past it, silently zeroing the performance fee.
45
+ uint8 public constant MAX_DECIMALS_OFFSET = 12;
46
+
47
+ /// @notice Maximum fee rate in BPS (50%).
48
+ uint16 public constant MAX_FEE_BPS = 5000;
49
+
50
+ /// @notice Maximum exit (withdraw/redeem) fee rate in BPS (5%). Far tighter than `MAX_FEE_BPS`:
51
+ /// an exit fee is a small anti-sandwich friction, never a primary revenue lever.
52
+ uint16 public constant MAX_REDEEM_FEE_BPS = 500;
53
+
54
+ /// @notice Ceiling on the management fee a single `_accrueFees` call may charge, as a
55
+ /// fraction of `totalAssets()` in BPS (100%). At 100% the per-accrual clamp only
56
+ /// binds once the elapsed-window fee would exceed 100% of TA, so a normal accrual
57
+ /// charges its full elapsed-window fee with no intermediate smoothing.
58
+ /// @dev The backstop against a >100% drain is the mint guard in `_accrueFees`
59
+ /// (`totalFee < totalAssets()`): an accrual whose combined management + performance fee
60
+ /// reaches 100% of TA is skipped and forfeited, not minted, while the cursor still
61
+ /// advances (the clamp caps `mgmtAssetFee` at TA and consumes only the window that
62
+ /// produces it, so any excess beyond 100% defers to the next call). Reaching that point
63
+ /// needs a near-max fee rate left un-accrued long enough for the owed fee to hit TA;
64
+ /// fees accrue on every deposit / withdraw, so normal operation never approaches it.
65
+ uint16 public constant MAX_MGMT_FEE_PER_ACCRUAL_BPS = 10_000;
66
+
67
+ /// @notice Seconds per year, used for management fee proration.
68
+ uint256 public constant SECONDS_PER_YEAR = 365 days;
69
+
70
+ // ============================== Modifiers ================================
71
+
72
+ /// @notice Reverts {HubPaused} when the Hub is paused.
73
+ modifier whenNotHubPaused() {
74
+ if (_hubPaused) revert HubPaused();
75
+ _;
76
+ }
77
+
78
+ // ============================== Constructor ==============================
79
+
80
+ /// @custom:oz-upgrades-unsafe-allow constructor
81
+ constructor() {
82
+ _disableInitializers();
83
+ }
84
+
85
+ // ============================== External — mutating =======================
86
+
87
+ /**
88
+ * @notice Initialise the Hub
89
+ * @dev The underlying `asset_` MUST be a standard ERC-20: fee-on-transfer, deflationary, and
90
+ * rebasing tokens are unsupported. The deposit path routes the full requested amount to
91
+ * YieldGroups without measuring the received balance, so an asset that delivers less than
92
+ * requested on transfer reverts the deposit (fails closed — no loss of funds).
93
+ * @param asset_ Underlying asset of the vault
94
+ * @param name_ ERC-20 name for the share token
95
+ * @param symbol_ ERC-20 symbol for the share token
96
+ * @param acm_ AccessControlManagerV8 instance gating governance functions
97
+ * @param decimalsOffset_ ERC-4626 inflation-defense offset. MUST be > 0 for EVERY asset
98
+ * regardless of decimals (e.g. 6): a non-zero offset is what makes the first-deposit
99
+ * inflation attack non-griefing, not merely non-profitable. Offset 0 would let a
100
+ * donation attack zero a first depositor's shares, so it is rejected on-chain.
101
+ * Bounded above by MAX_DECIMALS_OFFSET.
102
+ * @param initialMaxWithdrawalSize Initial per-transaction withdraw cap, in asset units
103
+ * @param feeRecipient_ Initial fee-share recipient (zero address disables fee minting)
104
+ * @custom:access Permissionless once (OZ `initializer` modifier prevents re-init)
105
+ * @custom:error Throws ZeroAddress if asset_ or acm_ is the zero address
106
+ * @custom:error Throws InvalidDecimalsOffset if decimalsOffset_ == 0 or > MAX_DECIMALS_OFFSET
107
+ * @custom:error Throws ZeroAmount if initialMaxWithdrawalSize == 0
108
+ */
109
+ function initialize(
110
+ IERC20Upgradeable asset_,
111
+ string calldata name_,
112
+ string calldata symbol_,
113
+ address acm_,
114
+ uint8 decimalsOffset_,
115
+ uint256 initialMaxWithdrawalSize,
116
+ address feeRecipient_
117
+ ) external initializer {
118
+ if (address(asset_) == address(0)) revert ZeroAddress();
119
+ if (acm_ == address(0)) revert ZeroAddress();
120
+ if (decimalsOffset_ == 0 || decimalsOffset_ > MAX_DECIMALS_OFFSET) revert InvalidDecimalsOffset();
121
+ if (initialMaxWithdrawalSize == 0) revert ZeroAmount();
122
+
123
+ __ERC20_init(name_, symbol_);
124
+ __ERC4626_init(asset_);
125
+ __AccessControlled_init(acm_);
126
+ __ReentrancyGuard_init();
127
+
128
+ _decimalsOffsetStored = decimalsOffset_;
129
+ _maxWithdrawalSize = initialMaxWithdrawalSize;
130
+ // `feeRecipient_ == address(0)` is permitted and disables fee-share minting in
131
+ // `_accrueFees` (the HWM and accrual timestamps still advance). Governance can
132
+ // later set a non-zero recipient via `setFeeRecipient`.
133
+ _feeRecipient = feeRecipient_;
134
+
135
+ uint64 nowTs = uint64(block.timestamp);
136
+ _lastFeeAccrualTimestamp = nowTs;
137
+ _lastFeeAccrualBlock = uint64(block.number);
138
+ // HWM is denominated as `totalAssets × EXP_SCALE / totalSupply`. With a non-zero
139
+ // `decimalsOffset_`, the very first deposit produces `ts = assets × 10^offset` so
140
+ // launch PPS = `EXP_SCALE / 10^offset`, not `EXP_SCALE`. Setting HWM to the
141
+ // launch PPS prevents the performance-fee branch from being permanently dead
142
+ // (PPS unable to climb past EXP_SCALE under realistic yields) when offset > 0.
143
+ _highWaterMarkPerShare = EXP_SCALE / (10 ** uint256(decimalsOffset_));
144
+ }
145
+
146
+ // -- YieldGroup registry (governance) --
147
+
148
+ /**
149
+ * @notice Add a YieldGroup to the registry with initial caps
150
+ * @dev Pause flag defaults to false. Does not auto-append to either outer queue —
151
+ * governance must call setOuterDepositQueue / setOuterWithdrawQueue separately
152
+ * to start routing flow to the new YieldGroup. Validates that the YieldGroup reports
153
+ * the same underlying asset as the Hub.
154
+ * @param yieldGroup YieldGroup contract address (must implement IYieldGroupBase)
155
+ * @param absoluteCap Initial absolute cap in asset units
156
+ * @param percentageCapBps Initial percentage cap in BPS (≤ 10_000)
157
+ * @custom:access ACM-gated role "addYieldGroup(address,uint256,uint16)" (VIP)
158
+ * @custom:event Emits YieldGroupAdded on successful registration
159
+ * @custom:error Throws ZeroAddress if yieldGroup is the zero address
160
+ * @custom:error Throws YieldGroupAlreadyRegistered if yieldGroup is already in the registry
161
+ * @custom:error Throws InvalidCap if percentageCapBps > 10_000 or absoluteCap == type(uint256).max
162
+ * @custom:error Throws YieldGroupAssetMismatch if the YieldGroup's asset() does not match the Hub's
163
+ */
164
+ function addYieldGroup(address yieldGroup, uint256 absoluteCap, uint16 percentageCapBps) external override {
165
+ _checkAccessAllowed("addYieldGroup(address,uint256,uint16)");
166
+ HubAdminLib.addYieldGroup(
167
+ _yieldGroups, _registeredYieldGroups, _yieldGroupIndex, yieldGroup, absoluteCap, percentageCapBps, asset()
168
+ );
169
+ }
170
+
171
+ /**
172
+ * @notice Remove a YieldGroup from the registry
173
+ * @dev Requires the YieldGroup to hold zero resource-backed value when
174
+ * `IYieldGroupBase.totalAssets()` is readable — drain via reallocate first. Idle asset
175
+ * sitting directly on the YieldGroup (e.g. a dust donation anyone can make to a drained
176
+ * group) is netted out of this check so it cannot block removal; any such residual is
177
+ * handled out-of-band. If that view reverts (a bricked / buggy YieldGroup) removal is
178
+ * permitted anyway as an emergency eviction, again with residual value handled out-of-band,
179
+ * so a misbehaving YieldGroup can be evicted without a Hub upgrade. Uses swap-and-pop on
180
+ * the registry array and cascade-strips the YieldGroup from both outer queues so callers
181
+ * never observe a queue entry that points to an unregistered YieldGroup.
182
+ * @param yieldGroup YieldGroup to remove
183
+ * @custom:access ACM-gated role "removeYieldGroup(address)" (VIP)
184
+ * @custom:event Emits YieldGroupRemoved on successful removal
185
+ * @custom:error Throws YieldGroupNotRegistered if yieldGroup is not in the registry
186
+ * @custom:error Throws YieldGroupHasBalance if yieldGroup holds readable, non-zero resource-backed value
187
+ */
188
+ function removeYieldGroup(address yieldGroup) external override {
189
+ _checkAccessAllowed("removeYieldGroup(address)");
190
+ HubAdminLib.removeYieldGroup(
191
+ _yieldGroups,
192
+ _registeredYieldGroups,
193
+ _yieldGroupIndex,
194
+ _outerDepositQueue,
195
+ _outerWithdrawQueue,
196
+ yieldGroup,
197
+ asset()
198
+ );
199
+ }
200
+
201
+ /**
202
+ * @notice Raise a YieldGroup's caps
203
+ * @dev A YieldGroup's intake is bounded by two independent ceilings, and the binding limit is
204
+ * whichever is lower:
205
+ * - `absoluteCap`: a fixed ceiling in asset units.
206
+ * - `percentageCapBps`: a ceiling relative to live `totalAssets()`, in BPS.
207
+ * Effective cap = `min(absoluteCap, percentageCapBps × totalAssets() / 10_000)`, except
208
+ * the sentinel `percentageCapBps == 10_000` (100%) disables the percentage dimension so
209
+ * only `absoluteCap` binds. Because the percentage component tracks `totalAssets()`, a
210
+ * YieldGroup's effective cap moves with Hub TVL even when neither stored value changes. See
211
+ * `yieldGroupEffectiveCap` for the resolved live value.
212
+ *
213
+ * Raise is monotonic: both dimensions must be ≥ their current values AND at least one
214
+ * must strictly increase; a full no-op (both equal to current) reverts NotIncreasing so
215
+ * the emitted YieldGroupCapRaised event always reflects a real state change.
216
+ * @param yieldGroup YieldGroup whose caps are being raised
217
+ * @param absoluteCap New absolute cap in asset units (≥ current)
218
+ * @param percentageCapBps New percentage cap in BPS (≥ current, ≤ 10_000; 10_000 = % disabled)
219
+ * @custom:access ACM-gated role "raiseYieldGroupCap(address,uint256,uint16)" (Guardian or VIP)
220
+ * @custom:event Emits YieldGroupCapRaised on successful update
221
+ * @custom:error Throws YieldGroupNotRegistered if yieldGroup is not in the registry
222
+ * @custom:error Throws InvalidCap if percentageCapBps > 10_000 or absoluteCap == type(uint256).max
223
+ * @custom:error Throws NotIncreasing if either new value is < its current value, or both are unchanged
224
+ */
225
+ function raiseYieldGroupCap(address yieldGroup, uint256 absoluteCap, uint16 percentageCapBps) external override {
226
+ _checkAccessAllowed("raiseYieldGroupCap(address,uint256,uint16)");
227
+ HubAdminLib.setYieldGroupCap(_yieldGroups, yieldGroup, absoluteCap, percentageCapBps, true);
228
+ }
229
+
230
+ /**
231
+ * @notice Lower a YieldGroup's caps
232
+ * @dev Caps are the two independent ceilings described on `raiseYieldGroupCap`: `absoluteCap`
233
+ * (fixed, asset units) and `percentageCapBps` (relative to live `totalAssets()`), with
234
+ * the binding limit being their min and `percentageCapBps == 10_000` disabling the
235
+ * percentage dimension. Lowering either tightens intake immediately; existing balance
236
+ * above the new cap is not force-unwound but the YieldGroup receives no new deposits until
237
+ * it is back under cap. See `yieldGroupEffectiveCap` for the resolved live value.
238
+ *
239
+ * Lower is monotonic: both dimensions must be ≤ their current values AND at least one
240
+ * must strictly decrease; a full no-op (both equal to current) reverts NotDecreasing so
241
+ * the emitted YieldGroupCapLowered event always reflects a real state change.
242
+ * @param yieldGroup YieldGroup whose caps are being lowered
243
+ * @param absoluteCap New absolute cap in asset units (≤ current)
244
+ * @param percentageCapBps New percentage cap in BPS (≤ current; 10_000 = % disabled)
245
+ * @custom:access ACM-gated role "lowerYieldGroupCap(address,uint256,uint16)" (Operator or VIP)
246
+ * @custom:event Emits YieldGroupCapLowered on successful update
247
+ * @custom:error Throws YieldGroupNotRegistered if yieldGroup is not in the registry
248
+ * @custom:error Throws InvalidCap if percentageCapBps > 10_000 or absoluteCap == type(uint256).max
249
+ * @custom:error Throws NotDecreasing if either new value is > its current value, or both are unchanged
250
+ */
251
+ function lowerYieldGroupCap(address yieldGroup, uint256 absoluteCap, uint16 percentageCapBps) external override {
252
+ _checkAccessAllowed("lowerYieldGroupCap(address,uint256,uint16)");
253
+ HubAdminLib.setYieldGroupCap(_yieldGroups, yieldGroup, absoluteCap, percentageCapBps, false);
254
+ }
255
+
256
+ // -- Outer queues (Operator) --
257
+
258
+ /**
259
+ * @notice Replace the deposit routing order
260
+ * @dev Validates the queue contains no duplicates and only registered YieldGroups. YieldGroups
261
+ * that are registered but absent from the queue still contribute to `totalAssets()`
262
+ * and remain withdrawable, but receive no new deposits. Pass an empty array to
263
+ * effectively pause new-deposit routing without pausing the Hub.
264
+ * Operator responsibility: keep every queue member in `_outerWithdrawQueue`. A member absent
265
+ * from the withdraw queue is funded by deposits but not reached by the normal redeem path
266
+ * until it is (re)added there. Recovery is one tx and lossless (re-set the withdraw queue,
267
+ * or drain via reallocate); not enforced on-chain.
268
+ * @param queue Full replacement queue in deposit-priority order
269
+ * @custom:access ACM-gated role "setOuterDepositQueue(address[])" (Operator)
270
+ * @custom:event Emits OuterDepositQueueSet on successful update
271
+ * @custom:error Throws InvalidQueue if queue contains duplicates or unregistered YieldGroups
272
+ */
273
+ function setOuterDepositQueue(address[] calldata queue) external override {
274
+ _checkAccessAllowed("setOuterDepositQueue(address[])");
275
+ HubAdminLib.validateDepositQueue(_yieldGroups, queue);
276
+ _outerDepositQueue = queue;
277
+ emit OuterDepositQueueSet(queue);
278
+ }
279
+
280
+ /**
281
+ * @notice Replace the withdraw routing order
282
+ * @dev The replacement must keep every balance-holding registered YieldGroup reachable: a new
283
+ * queue that omits a YieldGroup still custodying a balance is rejected, so the Operator
284
+ * cannot strand redeemable value by dropping a funded YieldGroup. Drain a YieldGroup via
285
+ * reallocate (it then reports a zero balance) before removing it from the queue.
286
+ * @param queue Full replacement queue in withdraw-priority order
287
+ * @custom:access ACM-gated role "setOuterWithdrawQueue(address[])" (Operator)
288
+ * @custom:event Emits OuterWithdrawQueueSet on successful update
289
+ * @custom:error Throws InvalidQueue if queue contains duplicates or unregistered YieldGroups
290
+ * @custom:error Throws WithdrawQueueOmitsFundedYieldGroup if the queue omits a funded registered YieldGroup
291
+ */
292
+ function setOuterWithdrawQueue(address[] calldata queue) external override {
293
+ _checkAccessAllowed("setOuterWithdrawQueue(address[])");
294
+ HubAdminLib.validateWithdrawQueue(_yieldGroups, _registeredYieldGroups, queue);
295
+ _outerWithdrawQueue = queue;
296
+ emit OuterWithdrawQueueSet(queue);
297
+ }
298
+
299
+ // -- Reallocate (Operator) / Emergency reallocate (governance) --
300
+
301
+ /**
302
+ * @notice Atomically rebalance assets across YieldGroups
303
+ * @dev Withdraw legs execute first, then deposit legs. `Σ withdraws == Σ deposits` is
304
+ * strictly enforced — funds cannot enter or leave the system via reallocate.
305
+ * Fees are accrued before any leg so the operation trades against the
306
+ * post-accrual state. Each push leg honours per-YieldGroup caps and the destination's
307
+ * pause flag. Reentrancy-guarded; reverts while the Hub is paused (use the
308
+ * governance-gated {emergencyReallocate} to wind down a stressed position during a pause).
309
+ * A push leg need not target a withdraw-queue member: funds placed in a YieldGroup outside
310
+ * `_outerWithdrawQueue` count in `totalAssets()` but are not reached by user withdrawals until
311
+ * the target is (re)added via {setOuterWithdrawQueue}.
312
+ * @param withdraws Ordered list of (yieldGroup, resource, amount) legs to pull from
313
+ * @param deposits Ordered list of (yieldGroup, resource, amount) legs to push into
314
+ * @custom:access ACM-gated role "reallocate((address,address,uint256)[],(address,address,uint256)[])" (Operator)
315
+ * @custom:event Emits WithdrawRouted per pull leg and DepositRouted per push leg
316
+ * @custom:event Emits FeesAccrued / HighWaterMarkUpdated as side effects of pre-leg accrual
317
+ * @custom:error Throws HubPaused if the Hub is paused
318
+ * @custom:error Throws YieldGroupNotRegistered if any leg targets an unregistered YieldGroup
319
+ * @custom:error Throws YieldGroupPaused if any deposit leg targets a paused YieldGroup
320
+ * @custom:error Throws YieldGroupUnderfilled if a YieldGroup returns fewer assets than requested on a leg
321
+ * @custom:error Throws HubCapacityExceeded if a deposit leg exceeds the destination's effective cap
322
+ * @custom:error Throws ReallocateImbalanced if Σ withdraws ≠ Σ deposits
323
+ */
324
+ function reallocate(IHub.ReallocateLeg[] calldata withdraws, IHub.ReallocateLeg[] calldata deposits)
325
+ external
326
+ override
327
+ nonReentrant
328
+ whenNotHubPaused
329
+ {
330
+ _checkAccessAllowed("reallocate((address,address,uint256)[],(address,address,uint256)[])");
331
+ _reallocate(withdraws, deposits);
332
+ }
333
+
334
+ /**
335
+ * @notice Emergency rebalance that remains callable while the Hub is paused, giving governance
336
+ * a wind-down lever for exactly the conditions that prompt a pause.
337
+ * @dev Identical net-zero semantics to {reallocate}, but split by authority: {reallocate} is the
338
+ * Operator's routine rebalancing lever and is frozen during a pause, whereas this is
339
+ * governance's emergency lever that bypasses the pause. The two carry separate ACM roles so
340
+ * governance can hold the pause-bypassing path without granting it to the Operator. Fee
341
+ * accrual is skipped while the Hub is paused so the frozen window is never charged (the
342
+ * accrual cursor stays put until {unpauseHub} resets it); called while unpaused it accrues
343
+ * like {reallocate}. This is a routing and containment lever, not a way to force-unlock a
344
+ * resource: every pull leg is still bound by the resource's own withdrawability, so a leg
345
+ * against a locked FRV vault reverts (its adapter `maxWithdraw` is 0 all through Fundraising,
346
+ * Lock, PendingSettlement and SettlementDeadlineExceeded, non-zero only at a terminal state).
347
+ * Emergency reallocation can therefore route around FRV exposure and move liquid resources,
348
+ * but cannot pull FRV principal out early, that capital is only reachable once the vault
349
+ * reaches Matured / Failed / Liquidated.
350
+ * @param withdraws Ordered list of (yieldGroup, resource, amount) legs to pull from
351
+ * @param deposits Ordered list of (yieldGroup, resource, amount) legs to push into
352
+ * @custom:access ACM-gated role
353
+ * "emergencyReallocate((address,address,uint256)[],(address,address,uint256)[])" (Guardian or VIP)
354
+ * @custom:event Emits WithdrawRouted per pull leg and DepositRouted per push leg
355
+ * @custom:event Emits FeesAccrued / HighWaterMarkUpdated when called while unpaused (skipped during a pause)
356
+ * @custom:error Throws YieldGroupNotRegistered if any leg targets an unregistered YieldGroup
357
+ * @custom:error Throws YieldGroupPaused if any deposit leg targets a paused YieldGroup
358
+ * @custom:error Throws YieldGroupUnderfilled if a YieldGroup returns fewer assets than requested on a leg
359
+ * @custom:error Throws HubCapacityExceeded if a deposit leg exceeds the destination's effective cap
360
+ * @custom:error Throws ReallocateImbalanced if Σ withdraws ≠ Σ deposits
361
+ */
362
+ function emergencyReallocate(IHub.ReallocateLeg[] calldata withdraws, IHub.ReallocateLeg[] calldata deposits)
363
+ external
364
+ override
365
+ nonReentrant
366
+ {
367
+ _checkAccessAllowed("emergencyReallocate((address,address,uint256)[],(address,address,uint256)[])");
368
+ _reallocate(withdraws, deposits);
369
+ }
370
+
371
+ // -- Pause --
372
+
373
+ /**
374
+ * @notice Pause the Hub
375
+ * @dev Blocks all ERC-4626 entry points (deposit/mint/withdraw/redeem), reallocate,
376
+ * accrueFees, and the fee setters (setManagementFeeBps, setPerformanceFeeBps,
377
+ * setFeeRecipient). `sweep` and `emergencyReallocate` stay callable so stray-token
378
+ * rescue and stressed-position wind-down remain available during a freeze. View
379
+ * functions remain readable. Records the pause start; `unpauseHub` later excludes this
380
+ * window from management-fee accrual. Pause makes no external call, so a bricked
381
+ * `totalAssets()` cannot block it. Idempotent — calling while already paused is a silent
382
+ * no-op.
383
+ * @custom:access ACM-gated role "pauseHub()" (Operator, Guardian, or VIP)
384
+ * @custom:event Emits HubPauseToggled(true) on transition from unpaused to paused
385
+ */
386
+ function pauseHub() external override {
387
+ _checkAccessAllowed("pauseHub()");
388
+ if (!_hubPaused) {
389
+ // Record the pause start; unpauseHub excludes this window from fee accrual. No external
390
+ // call here, so a bricked totalAssets() can't block the pause.
391
+ _pauseStart = uint64(block.timestamp);
392
+ _hubPaused = true;
393
+ emit HubPauseToggled(true);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * @notice Unpause the Hub
399
+ * @dev Advances the fee-accrual cursor by the pause duration so the frozen window does not
400
+ * contribute to management-fee proration. Only the time-based management fee is excluded
401
+ * from the pause window. The high-water mark is intentionally left unchanged, so real
402
+ * per-share gains the underlying protocols earn while the Hub is paused stay subject to the
403
+ * performance fee on the first post-resume accrual: the performance fee is a share of
404
+ * realized profit, not an availability charge, and the mark is only re-anchored on a true
405
+ * cohort reset (refill from empty), never on a pause. Idempotent — calling while
406
+ * already unpaused is a silent no-op.
407
+ * @custom:access ACM-gated role "unpauseHub()" (VIP)
408
+ * @custom:event Emits HubPauseToggled(false) on transition from paused to unpaused
409
+ */
410
+ function unpauseHub() external override {
411
+ _checkAccessAllowed("unpauseHub()");
412
+ if (_hubPaused) {
413
+ _hubPaused = false;
414
+ // Advance the proration cursor by the pause duration so the frozen window isn't charged.
415
+ _lastFeeAccrualTimestamp += uint64(block.timestamp) - _pauseStart;
416
+ emit HubPauseToggled(false);
417
+ }
418
+ }
419
+
420
+ /**
421
+ * @notice Pause routing to a specific YieldGroup
422
+ * @dev Existing YieldGroup balance still counts toward `totalAssets()` (NAV) but is no
423
+ * longer reachable by the user withdraw / redeem queue, which skips paused YieldGroups;
424
+ * it can only be unwound via {reallocate} / {emergencyReallocate}. New deposit /
425
+ * reallocate-push legs targeting this YieldGroup revert with YieldGroupPaused. Idempotent —
426
+ * paused-when-paused is a silent no-op.
427
+ * @param yieldGroup YieldGroup to pause
428
+ * @custom:access ACM-gated role "pauseYieldGroup(address)" (Operator or VIP)
429
+ * @custom:event Emits YieldGroupPauseToggled(yieldGroup, true) on transition from unpaused to paused
430
+ * @custom:error Throws YieldGroupNotRegistered if yieldGroup is not in the registry
431
+ */
432
+ function pauseYieldGroup(address yieldGroup) external override {
433
+ _checkAccessAllowed("pauseYieldGroup(address)");
434
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
435
+ if (!cfg.registered) revert YieldGroupNotRegistered(yieldGroup);
436
+ if (!cfg.paused) {
437
+ cfg.paused = true;
438
+ emit YieldGroupPauseToggled(yieldGroup, true);
439
+ }
440
+ }
441
+
442
+ /**
443
+ * @notice Unpause routing to a specific YieldGroup
444
+ * @param yieldGroup YieldGroup to unpause
445
+ * @custom:access ACM-gated role "unpauseYieldGroup(address)" (VIP)
446
+ * @custom:event Emits YieldGroupPauseToggled(yieldGroup, false) on transition from paused to unpaused
447
+ * @custom:error Throws YieldGroupNotRegistered if yieldGroup is not in the registry
448
+ */
449
+ function unpauseYieldGroup(address yieldGroup) external override {
450
+ _checkAccessAllowed("unpauseYieldGroup(address)");
451
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
452
+ if (!cfg.registered) revert YieldGroupNotRegistered(yieldGroup);
453
+ if (cfg.paused) {
454
+ cfg.paused = false;
455
+ emit YieldGroupPauseToggled(yieldGroup, false);
456
+ }
457
+ }
458
+
459
+ // -- Per-tx withdraw cap --
460
+
461
+ /**
462
+ * @notice Raise the per-transaction withdraw cap
463
+ * @param newSize New cap in asset units (strictly > current)
464
+ * @custom:access ACM-gated role "raiseMaxWithdrawalSize(uint256)" (VIP)
465
+ * @custom:event Emits MaxWithdrawalSizeRaised on successful update
466
+ * @custom:error Throws NotIncreasing if newSize ≤ current cap (no-op equal value is also rejected
467
+ * so the emitted event always reflects a real state change)
468
+ */
469
+ function raiseMaxWithdrawalSize(uint256 newSize) external override {
470
+ _checkAccessAllowed("raiseMaxWithdrawalSize(uint256)");
471
+ _setMaxWithdrawalSize(newSize, true);
472
+ }
473
+
474
+ /**
475
+ * @notice Lower the per-transaction withdraw cap
476
+ * @param newSize New cap in asset units (strictly < current; must be > 0)
477
+ * @custom:access ACM-gated role "lowerMaxWithdrawalSize(uint256)" (Operator or VIP)
478
+ * @custom:event Emits MaxWithdrawalSizeLowered on successful update
479
+ * @custom:error Throws NotDecreasing if newSize ≥ current cap (no-op equal value is also rejected
480
+ * so the emitted event always reflects a real state change)
481
+ * @custom:error Throws ZeroAmount if newSize == 0 (matches the initialize-time guard)
482
+ */
483
+ function lowerMaxWithdrawalSize(uint256 newSize) external override {
484
+ _checkAccessAllowed("lowerMaxWithdrawalSize(uint256)");
485
+ _setMaxWithdrawalSize(newSize, false);
486
+ }
487
+
488
+ // -- Fees --
489
+
490
+ /**
491
+ * @notice Set the management fee rate
492
+ * @dev Accrues pending fees at the OLD rate first so the change does not retroactively
493
+ * revalue elapsed time. Capped at MAX_FEE_BPS (50%).
494
+ * @param bps New management fee rate in BPS (≤ MAX_FEE_BPS)
495
+ * @custom:access ACM-gated role "setManagementFeeBps(uint16)" (VIP)
496
+ * @custom:event Emits ManagementFeeBpsSet on successful update
497
+ * @custom:event Emits FeesAccrued / HighWaterMarkUpdated as side effects of pre-set accrual
498
+ * @custom:error Throws InvalidFeeBps if bps > MAX_FEE_BPS
499
+ * @custom:error Throws HubPaused if the Hub is paused
500
+ */
501
+ function setManagementFeeBps(uint16 bps) external override whenNotHubPaused {
502
+ _checkAccessAllowed("setManagementFeeBps(uint16)");
503
+ if (bps > MAX_FEE_BPS) revert InvalidFeeBps(bps);
504
+ _accrueFees();
505
+ uint16 old = _managementFeeBps;
506
+ _managementFeeBps = bps;
507
+ emit ManagementFeeBpsSet(old, bps);
508
+ }
509
+
510
+ /**
511
+ * @notice Set the performance fee rate
512
+ * @param bps New performance fee rate in BPS (≤ MAX_FEE_BPS)
513
+ * @custom:access ACM-gated role "setPerformanceFeeBps(uint16)" (VIP)
514
+ * @custom:event Emits PerformanceFeeBpsSet on successful update
515
+ * @custom:event Emits FeesAccrued / HighWaterMarkUpdated as side effects of pre-set accrual
516
+ * @custom:error Throws InvalidFeeBps if bps > MAX_FEE_BPS
517
+ * @custom:error Throws HubPaused if the Hub is paused
518
+ */
519
+ function setPerformanceFeeBps(uint16 bps) external override whenNotHubPaused {
520
+ _checkAccessAllowed("setPerformanceFeeBps(uint16)");
521
+ if (bps > MAX_FEE_BPS) revert InvalidFeeBps(bps);
522
+ _accrueFees();
523
+ uint16 old = _performanceFeeBps;
524
+ _performanceFeeBps = bps;
525
+ emit PerformanceFeeBpsSet(old, bps);
526
+ }
527
+
528
+ /**
529
+ * @notice Set the exit (withdraw/redeem) fee rate, retained in the vault and socialised to
530
+ * remaining LPs as a price-per-share uplift. An anti-sandwich governance lever for
531
+ * discrete settlement YieldGroups - friction against racing out ahead of a downward
532
+ * settlement reprice (e.g. an FRV settling below its accrued mark on a shortfall); default 0.
533
+ * @param bps New exit fee rate in BPS (≤ MAX_REDEEM_FEE_BPS)
534
+ * @custom:access ACM-gated role "setRedeemFeeBps(uint16)" (VIP)
535
+ * @custom:event Emits RedeemFeeBpsSet on successful update
536
+ * @custom:error Throws InvalidFeeBps if bps > MAX_REDEEM_FEE_BPS
537
+ * @custom:error Throws HubPaused if the Hub is paused
538
+ */
539
+ function setRedeemFeeBps(uint16 bps) external override whenNotHubPaused {
540
+ _checkAccessAllowed("setRedeemFeeBps(uint16)");
541
+ if (bps > MAX_REDEEM_FEE_BPS) revert InvalidFeeBps(bps);
542
+ uint16 old = _redeemFeeBps;
543
+ _redeemFeeBps = bps;
544
+ emit RedeemFeeBpsSet(old, bps);
545
+ }
546
+
547
+ /**
548
+ * @notice Set the fee-share recipient
549
+ * @dev Accrues pending fees to the OLD recipient first. Zero address is permitted and
550
+ * disables future fee-share minting (accrueFees still advances HWM and timestamps,
551
+ * but the mint step is skipped).
552
+ * @param recipient New fee-share recipient (may be the zero address)
553
+ * @custom:access ACM-gated role "setFeeRecipient(address)" (VIP)
554
+ * @custom:event Emits FeeRecipientSet on successful update
555
+ * @custom:event Emits FeesAccrued / HighWaterMarkUpdated as side effects of pre-set accrual
556
+ * @custom:error Throws HubPaused if the Hub is paused
557
+ */
558
+ function setFeeRecipient(address recipient) external override whenNotHubPaused {
559
+ _checkAccessAllowed("setFeeRecipient(address)");
560
+ _accrueFees();
561
+ address old = _feeRecipient;
562
+ _feeRecipient = recipient;
563
+ emit FeeRecipientSet(old, recipient);
564
+ }
565
+
566
+ /**
567
+ * @notice Public poke that brings management + performance fee state up to date
568
+ * @custom:event Emits FeesAccrued and HighWaterMarkUpdated when state advances
569
+ * @custom:error Throws HubPaused if the Hub is paused
570
+ */
571
+ function accrueFees() external override nonReentrant whenNotHubPaused {
572
+ _accrueFees();
573
+ }
574
+
575
+ // -- Sweep --
576
+
577
+ /**
578
+ * @notice Forward the Hub's full balance of an arbitrary ERC-20 to `to`
579
+ * @param token ERC-20 to sweep (must not be `asset()`)
580
+ * @param to Recipient of the balance
581
+ * @custom:access ACM-gated role "sweep(address,address)" (VIP)
582
+ * @custom:event Emits Swept when a non-zero balance is transferred
583
+ * @custom:error Throws SweepProtectedAsset if token == asset()
584
+ * @custom:error Throws ZeroAddress if to is the zero address
585
+ */
586
+ function sweep(address token, address to) external override nonReentrant {
587
+ _checkAccessAllowed("sweep(address,address)");
588
+ if (token == asset()) revert SweepProtectedAsset();
589
+ if (to == address(0)) revert ZeroAddress();
590
+
591
+ uint256 bal = IERC20Upgradeable(token).balanceOf(address(this));
592
+ if (bal > 0) {
593
+ IERC20Upgradeable(token).safeTransfer(to, bal);
594
+ emit Swept(token, to, bal);
595
+ }
596
+ }
597
+
598
+ // ============================== External — view ==========================
599
+
600
+ /**
601
+ * @notice Hub-level pause state
602
+ * @return True if `pauseHub()` is in effect
603
+ */
604
+ function hubPaused() external view override returns (bool) {
605
+ return _hubPaused;
606
+ }
607
+
608
+ /**
609
+ * @notice Per-transaction withdraw cap in asset units
610
+ * @return Current cap value
611
+ */
612
+ function maxWithdrawalSize() external view override returns (uint256) {
613
+ return _maxWithdrawalSize;
614
+ }
615
+
616
+ /**
617
+ * @notice Recipient of newly-minted fee shares
618
+ * @dev Zero address means fee-share minting is disabled (HWM and timestamps still advance).
619
+ * @return Current fee recipient
620
+ */
621
+ function feeRecipient() external view override returns (address) {
622
+ return _feeRecipient;
623
+ }
624
+
625
+ /**
626
+ * @notice Current fee rates in BPS
627
+ * @return managementBps Management fee rate
628
+ * @return performanceBps Performance fee rate
629
+ */
630
+ function feeBps() external view override returns (uint16 managementBps, uint16 performanceBps) {
631
+ return (_managementFeeBps, _performanceFeeBps);
632
+ }
633
+
634
+ /// @inheritdoc IHub
635
+ function redeemFeeBps() external view override returns (uint16) {
636
+ return _redeemFeeBps;
637
+ }
638
+
639
+ /**
640
+ * @notice Performance high-water mark in share-price units (`totalAssets × 1e18 / totalSupply`)
641
+ * @dev Seeded at `initialize()` to `EXP_SCALE / 10^decimalsOffset` so the performance-fee branch is
642
+ * reachable from launch. Non-decreasing while the vault holds shares; moves on three paths:
643
+ * ratcheted to PPS on new highs in `_accrueFees`, incremented by the retained exit-fee uplift in
644
+ * `_withdraw`, and re-anchored to the entry PPS on a refill from empty in `_deposit`. The refill
645
+ * path is the only one that can lower it, so a fresh cohort is measured against its own entry; the
646
+ * anchor is bounded above by the virtual-share-inclusive supply and below by the seed, so dust
647
+ * cannot manipulate it. During a drawdown the mark can sit above the current PPS.
648
+ * @return Current HWM value
649
+ */
650
+ function highWaterMarkPerShare() external view override returns (uint256) {
651
+ return _highWaterMarkPerShare;
652
+ }
653
+
654
+ /**
655
+ * @notice Registered YieldGroups, in insertion order (swap-and-pop on remove)
656
+ * @return Storage copy of the registry array
657
+ */
658
+ function registeredYieldGroups() external view override returns (address[] memory) {
659
+ return _registeredYieldGroups;
660
+ }
661
+
662
+ /**
663
+ * @notice Current deposit routing order
664
+ * @return Storage copy of the deposit queue
665
+ */
666
+ function outerDepositQueue() external view override returns (address[] memory) {
667
+ return _outerDepositQueue;
668
+ }
669
+
670
+ /**
671
+ * @notice Current withdraw routing order
672
+ * @return Storage copy of the withdraw queue
673
+ */
674
+ function outerWithdrawQueue() external view override returns (address[] memory) {
675
+ return _outerWithdrawQueue;
676
+ }
677
+
678
+ /**
679
+ * @notice Full config struct for a YieldGroup
680
+ * @param yieldGroup Address to look up
681
+ * @return Stored config
682
+ */
683
+ function yieldGroupConfig(address yieldGroup) external view override returns (IHub.YieldGroupConfig memory) {
684
+ return _yieldGroups[yieldGroup];
685
+ }
686
+
687
+ /**
688
+ * @notice Live effective cap for a YieldGroup
689
+ * @dev Returns 0 for unregistered YieldGroups. For registered YieldGroups, returns
690
+ * `min(absoluteCap, percentageCapBps × totalAssets() / 10_000)`, with the special case
691
+ * `percentageCapBps == 10_000` treated as "percentage cap disabled" (effective cap =
692
+ * absoluteCap). Paused YieldGroups still report their cap — the pause flag is checked at
693
+ * the routing call site.
694
+ * @param yieldGroup Address to look up
695
+ * @return Effective cap in asset units
696
+ */
697
+ function yieldGroupEffectiveCap(address yieldGroup) external view override returns (uint256) {
698
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
699
+ if (!cfg.registered) return 0;
700
+ return _effectiveCap(cfg, totalAssets());
701
+ }
702
+
703
+ // ============================== Public — mutating (ERC-4626) =============
704
+ // Each entry wraps super.* with reentrancy guard + pause guard, then accrues fees BEFORE
705
+ // the share-price math inside super.* so the caller trades against the post-accrual price.
706
+
707
+ /**
708
+ * @inheritdoc IERC4626Upgradeable
709
+ * @custom:error Throws HubPaused if the Hub is paused
710
+ * @custom:error Throws HubCapacityExceeded if aggregate spare cap across unpaused YieldGroups is insufficient
711
+ */
712
+ function deposit(uint256 assets, address receiver)
713
+ public
714
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
715
+ nonReentrant
716
+ whenNotHubPaused
717
+ returns (uint256)
718
+ {
719
+ _accrueFees();
720
+ return super.deposit(assets, receiver);
721
+ }
722
+
723
+ /**
724
+ * @inheritdoc IERC4626Upgradeable
725
+ * @custom:error Throws HubPaused if the Hub is paused
726
+ * @custom:error Throws HubCapacityExceeded if aggregate spare cap is insufficient for the shares requested
727
+ */
728
+ function mint(uint256 shares, address receiver)
729
+ public
730
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
731
+ nonReentrant
732
+ whenNotHubPaused
733
+ returns (uint256)
734
+ {
735
+ _accrueFees();
736
+ return super.mint(shares, receiver);
737
+ }
738
+
739
+ /**
740
+ * @inheritdoc IHub
741
+ * @dev The consent hash is optional: passing `bytes32(0)` skips the emit and behaves like a plain
742
+ * {deposit}, so the frontend can use a single entry point whether or not a disclaimer applies.
743
+ * A non-zero hash emits {ConsentRecorded} first, then delegates to {deposit}, so the event is
744
+ * rolled back with the transaction if {deposit} reverts; all cap / pause / accrual gating
745
+ * lives in {deposit}. Event-only — no new storage, ERC-4626 path untouched.
746
+ */
747
+ function depositWithConsent(uint256 assets, address receiver, bytes32 consentHash)
748
+ external
749
+ override
750
+ returns (uint256 shares)
751
+ {
752
+ if (consentHash != bytes32(0)) emit ConsentRecorded(_msgSender(), receiver, consentHash);
753
+ shares = deposit(assets, receiver);
754
+ }
755
+
756
+ /**
757
+ * @inheritdoc IHub
758
+ * @dev The consent hash is optional: passing `bytes32(0)` skips the emit and behaves like a plain
759
+ * {mint}, so the frontend can use a single entry point whether or not a disclaimer applies. A
760
+ * non-zero hash emits {ConsentRecorded} first, then delegates to {mint}, so the event is
761
+ * rolled back with the transaction if {mint} reverts; all cap / pause / accrual gating lives
762
+ * in {mint}. Event-only — no new storage, ERC-4626 path untouched.
763
+ */
764
+ function mintWithConsent(uint256 shares, address receiver, bytes32 consentHash)
765
+ external
766
+ override
767
+ returns (uint256 assets)
768
+ {
769
+ if (consentHash != bytes32(0)) emit ConsentRecorded(_msgSender(), receiver, consentHash);
770
+ assets = mint(shares, receiver);
771
+ }
772
+
773
+ /**
774
+ * @inheritdoc IERC4626Upgradeable
775
+ * @custom:error Throws HubPaused if the Hub is paused
776
+ * @custom:error Throws HubWithdrawCapExceeded if assets > maxWithdrawalSize
777
+ * @custom:error Throws HubInsufficientLiquidity if aggregate liquid funds (withdraw queue plus Hub idle) are insufficient
778
+ * @custom:error Throws YieldGroupUnderfilled if any YieldGroup returns fewer assets than the leg requested
779
+ */
780
+ function withdraw(uint256 assets, address receiver, address owner)
781
+ public
782
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
783
+ nonReentrant
784
+ whenNotHubPaused
785
+ returns (uint256)
786
+ {
787
+ _accrueFees();
788
+ return super.withdraw(assets, receiver, owner);
789
+ }
790
+
791
+ /**
792
+ * @inheritdoc IERC4626Upgradeable
793
+ * @custom:error Throws HubPaused if the Hub is paused
794
+ * @custom:error Throws HubWithdrawCapExceeded if the converted assets exceed maxWithdrawalSize
795
+ * @custom:error Throws HubInsufficientLiquidity if aggregate liquid funds are insufficient
796
+ * @custom:error Throws YieldGroupUnderfilled if any YieldGroup returns fewer assets than the leg requested
797
+ */
798
+ function redeem(uint256 shares, address receiver, address owner)
799
+ public
800
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
801
+ nonReentrant
802
+ whenNotHubPaused
803
+ returns (uint256)
804
+ {
805
+ _accrueFees();
806
+ return super.redeem(shares, receiver, owner);
807
+ }
808
+
809
+ // ============================== Public — view (ERC-4626) =================
810
+
811
+ /**
812
+ * @inheritdoc IERC4626Upgradeable
813
+ * @dev NAV = `Σ YieldGroup.totalAssets()` plus the Hub's idle `asset()` balance.
814
+ *
815
+ * Idle is normally zero — deposits route out atomically, withdrawals pull exact amounts,
816
+ * and reallocate is balanced — so the only balance that lingers here is a direct `asset()`
817
+ * donation. It is folded into NAV (the underlying cannot be swept) so it accrues to LPs and
818
+ * is consumed first on withdraw.
819
+ *
820
+ * Fails closed. A YieldGroup whose `totalAssets()` reverts is NOT isolated here — unlike the
821
+ * quantity-gate views (`maxDeposit` / `maxWithdraw`) and the deposit / withdraw routing,
822
+ * which deliberately skip a faulting YieldGroup. The difference is the safety class: NAV is the
823
+ * share-price denominator that every mint and redeem converts against, so silently counting
824
+ * a YieldGroup as 0 would understate the price — under-paying redeemers and over-crediting
825
+ * depositors until its value reappeared. Failing to 0 is safe for a quantity gate (it only
826
+ * marks that YieldGroup temporarily unavailable); on the price it is a value leak, so halting is
827
+ * the correct bias.
828
+ *
829
+ * A reverting `totalAssets()` is fail-closed by design. It can be a YieldGroup-level bug, but it
830
+ * can also originate outside the protocol — e.g. a Fluid fToken's valuation reverts when its
831
+ * Liquidity-layer exchange price reads zero, on an unexpected exchange-price decrease, or if its
832
+ * rewards-rate model reverts. We deliberately do NOT mask this with a fallback or cached /
833
+ * governance-overridden NAV. For the resources where a valuation revert is reachable (Fluid today),
834
+ * the same exchange-price recompute backs both `totalAssets()` and `withdraw`, so a condition that
835
+ * breaks pricing also breaks exit: a substituted NAV could let redeemers exit against a position the
836
+ * Hub cannot liquidate (a value leak) while the remaining LPs absorb the loss. Where a resource
837
+ * instead has a known-correct fallback value, that is handled in its adapter (e.g. `AdapterFRV`
838
+ * values a Closed vault at 0), never here. A genuinely bricked YieldGroup is evicted via the
839
+ * emergency path of `removeYieldGroup` and brought back once healthy — see the recovery runbook in
840
+ * the README.
841
+ */
842
+ function totalAssets() public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256 total) {
843
+ address[] storage yieldGroups = _registeredYieldGroups;
844
+ uint256 len = yieldGroups.length;
845
+ for (uint256 i; i < len; ++i) {
846
+ total += IYieldGroupBase(yieldGroups[i]).totalAssets();
847
+ }
848
+ total += IERC20Upgradeable(asset()).balanceOf(address(this));
849
+ }
850
+
851
+ /// @inheritdoc IERC4626Upgradeable
852
+ function maxDeposit(address) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
853
+ if (_hubPaused) return 0;
854
+ return _aggregateDepositCapacity(totalAssets());
855
+ }
856
+
857
+ /// @inheritdoc IERC4626Upgradeable
858
+ function maxMint(address receiver) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
859
+ return convertToShares(maxDeposit(receiver));
860
+ }
861
+
862
+ /**
863
+ * @inheritdoc IERC4626Upgradeable
864
+ * @dev Returns 0 while paused. Otherwise clamped to the minimum of three constraints:
865
+ * (1) the owner's share-equivalent assets, (2) aggregate liquid funds across the
866
+ * withdraw queue plus Hub idle, and (3) the per-transaction cap `maxWithdrawalSize()`. The
867
+ * clamping makes ERC-4626's invariant `withdraw(maxWithdraw(owner), ...)` succeed only when
868
+ * the value is read and consumed against the same state. `withdraw`/`redeem` run
869
+ * `_accrueFees()` first, which mints fee shares and lowers PPS, and this figure is
870
+ * asset-denominated, so a value read before the block's first accrual can exceed the
871
+ * post-accrual ceiling and revert by a fee-proportional margin. For a full exit prefer
872
+ * `redeem(maxRedeem(owner))`, whose ceiling is share-denominated off the fee-invariant
873
+ * `balanceOf(owner)` and does not shrink on accrual.
874
+ */
875
+ function maxWithdraw(address owner)
876
+ public
877
+ view
878
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
879
+ returns (uint256)
880
+ {
881
+ if (_hubPaused) return 0;
882
+ // Net of the exit fee. At fee 0 this is `convertToAssets(balanceOf)` (unchanged); with a fee
883
+ // the fee is rounded UP so the net entitlement is conservative — `previewWithdraw` grosses
884
+ // up the other way (ceil), so this guarantees `withdraw(maxWithdraw(owner))` never tries to
885
+ // burn more shares than the owner holds.
886
+ uint256 gross = convertToAssets(balanceOf(owner));
887
+ uint16 redeemBps = _redeemFeeBps;
888
+ uint256 fromShares =
889
+ redeemBps == 0 ? gross : gross - (gross * redeemBps + BPS_DENOMINATOR - 1) / BPS_DENOMINATOR;
890
+ uint256 liquid = _aggregateLiquid();
891
+ uint256 perTxCap = _maxWithdrawalSize;
892
+ uint256 limit = fromShares < liquid ? fromShares : liquid;
893
+ return limit < perTxCap ? limit : perTxCap;
894
+ }
895
+
896
+ /**
897
+ * @inheritdoc IERC4626Upgradeable
898
+ * @dev Share-denominated off the fee-invariant `balanceOf(owner)`, so unlike {maxWithdraw} it
899
+ * does not shrink when `_accrueFees()` mints fee shares. A stale read can still bind on a
900
+ * liquidity/cap change or floor-rounding, never on fee accrual, so `redeem(maxRedeem(owner))`
901
+ * is the safe full-exit call.
902
+ */
903
+ function maxRedeem(address owner) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
904
+ if (_hubPaused) return 0;
905
+ uint256 ownerShares = balanceOf(owner);
906
+ // Net assets `redeem` would pay out for the full balance (exit fee floored, as `redeem` does).
907
+ uint256 ownerNet = previewRedeem(ownerShares);
908
+ if (ownerNet == 0) return 0;
909
+ // Outflow bound, in raw net-asset terms: liquidity and the per-tx cap. The retained fee stays
910
+ // in the vault, so only the net `assets` routed by `_withdraw` is constrained, and it is
911
+ // compared like-for-like against `ownerNet` (also a floored net). NOT `maxWithdraw`, which
912
+ // ceils the fee — clamping to it would under-report by dust and revert a full-balance `redeem`.
913
+ uint256 netLimit = _aggregateLiquid();
914
+ uint256 perTxCap = _maxWithdrawalSize;
915
+ if (perTxCap < netLimit) netLimit = perTxCap;
916
+ // Full balance fits the bound: redeem everything.
917
+ // solhint-disable-next-line gas-strict-inequalities
918
+ if (ownerNet <= netLimit) return ownerShares;
919
+ // Bound-limited: gross `netLimit` back up to shares. Floor both steps (fee gross-up, then
920
+ // asset-to-share) so the result's payout nets to <= `netLimit`, matching `redeem`'s floored
921
+ // fee. At `redeemBps == 0` the gross-up is a no-op, so this single path also covers the fee-off
922
+ // case. No iterative share-by-share correction: with a large `decimalsOffset` a share is worth
923
+ // < 1 asset unit, so it could run up to ~10^offset times.
924
+ uint16 bps = _redeemFeeBps;
925
+ uint256 maxGrossAssets = bps > 0 ? (netLimit * BPS_DENOMINATOR) / (BPS_DENOMINATOR - bps) : netLimit;
926
+ uint256 cappedShares = convertToShares(maxGrossAssets);
927
+ return cappedShares < ownerShares ? cappedShares : ownerShares;
928
+ }
929
+
930
+ /**
931
+ * @inheritdoc IERC4626Upgradeable
932
+ * @dev Net of the exit fee: the redeemer receives `convertToAssets(shares)` minus the retained
933
+ * fee. Returns the gross value when the fee is 0.
934
+ */
935
+ function previewRedeem(uint256 shares)
936
+ public
937
+ view
938
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
939
+ returns (uint256)
940
+ {
941
+ uint256 assets = super.previewRedeem(shares);
942
+ uint16 bps = _redeemFeeBps;
943
+ if (bps == 0) return assets;
944
+ // Fee carved out of the gross (floor, favouring the vault); the remainder is delivered.
945
+ return assets - (assets * bps) / BPS_DENOMINATOR;
946
+ }
947
+
948
+ /**
949
+ * @inheritdoc IERC4626Upgradeable
950
+ * @dev To RECEIVE `assets` net of the exit fee, the owner must burn shares covering the grossed-up
951
+ * amount `assets + fee`. The retained fee never leaves the vault (only `assets` is routed
952
+ * out), socialising it to remaining LPs. Returns the unmodified shares when the fee is 0.
953
+ */
954
+ function previewWithdraw(uint256 assets)
955
+ public
956
+ view
957
+ override(ERC4626Upgradeable, IERC4626Upgradeable)
958
+ returns (uint256)
959
+ {
960
+ uint16 bps = _redeemFeeBps;
961
+ if (bps != 0) {
962
+ uint256 denom = BPS_DENOMINATOR - bps;
963
+ // Gross up with a ceil division so the burned shares always cover the net + fee.
964
+ assets += (assets * bps + denom - 1) / denom;
965
+ }
966
+ return super.previewWithdraw(assets);
967
+ }
968
+
969
+ // ============================== Internal — mutating ======================
970
+
971
+ /// @inheritdoc ERC4626Upgradeable
972
+ function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override {
973
+ // Snapshot the pre-mint supply so a refill from an empty vault can be detected below.
974
+ uint256 preTs = totalSupply();
975
+
976
+ IERC20Upgradeable(asset()).safeTransferFrom(caller, address(this), assets);
977
+ _routeDeposit(assets);
978
+ _mint(receiver, shares);
979
+
980
+ // On a refill from empty, re-anchor the mark to this cohort's entry PPS (up or down). While
981
+ // totalSupply is 0 nothing else maintains it: `_withdraw`'s exit-fee credit needs a non-zero
982
+ // post-burn supply and `_accrueFees`'s zero-supply branch only advances the clock. Anchoring here
983
+ // keeps a residual left at supply 0 from reading as a gain on the next depositor, and keeps a prior
984
+ // cycle's high from shielding the new cohort. Two bounds keep the anchor dust-resistant: pricing it
985
+ // off the virtual-share-inclusive supply (`ts + 10^offset`) holds it at or below the real entry PPS,
986
+ // and the launch seed floors it (a dust deposit mints 10^offset shares against ~0 assets, which would
987
+ // otherwise drag a raw anchor toward 0). A clean first deposit anchors at the seed, so it is a no-op.
988
+ if (preTs == 0) {
989
+ uint256 ts = totalSupply();
990
+ if (ts != 0) {
991
+ uint256 entryPps = (totalAssets() * EXP_SCALE) / (ts + 10 ** uint256(_decimalsOffset()));
992
+ uint256 seedPps = EXP_SCALE / (10 ** uint256(_decimalsOffset()));
993
+ if (entryPps < seedPps) entryPps = seedPps;
994
+ uint256 hwm = _highWaterMarkPerShare;
995
+ if (entryPps != hwm) {
996
+ _highWaterMarkPerShare = entryPps;
997
+ emit HighWaterMarkUpdated(hwm, entryPps);
998
+ }
999
+ }
1000
+ }
1001
+
1002
+ emit Deposit(caller, receiver, assets, shares);
1003
+ }
1004
+
1005
+ /// @inheritdoc ERC4626Upgradeable
1006
+ function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares)
1007
+ internal
1008
+ override
1009
+ {
1010
+ if (assets > _maxWithdrawalSize) revert HubWithdrawCapExceeded(assets, _maxWithdrawalSize);
1011
+
1012
+ // A retained exit fee burns more share-value than the assets routed out, lifting PPS. That
1013
+ // uplift is socialised to remaining LPs by design and must be shielded from the performance
1014
+ // fee — otherwise the next `_accrueFees` would read it as a performance gain and mint a
1015
+ // slice of the retained exit fee to the fee recipient, contradicting its "retained for LPs"
1016
+ // purpose. Snapshot the pre-exit PPS so the uplift can be measured as a delta below.
1017
+ uint16 redeemBps = _redeemFeeBps;
1018
+ uint256 prePps;
1019
+ if (redeemBps != 0) {
1020
+ uint256 preTs = totalSupply();
1021
+ if (preTs != 0) {
1022
+ prePps = (totalAssets() * EXP_SCALE) / preTs;
1023
+ }
1024
+ }
1025
+
1026
+ if (caller != owner) {
1027
+ _spendAllowance(owner, caller, shares);
1028
+ }
1029
+ _burn(owner, shares);
1030
+ _routeWithdraw(assets);
1031
+ IERC20Upgradeable(asset()).safeTransfer(receiver, assets);
1032
+
1033
+ // Credit the uplift into the HWM as an increment (`hwm + delta`), NOT an absolute ratchet
1034
+ // to the post-exit PPS: during a drawdown the post-exit PPS is still below the prior mark,
1035
+ // so a `newPps > hwm` ratchet would skip and the uplift would sit below the stale mark,
1036
+ // getting taxed as performance on the next recovery past it. The delta form shields the
1037
+ // retained fee whether PPS sits above or below the mark, and stays monotonic (delta >= 0:
1038
+ // the fee-on burn always destroys at least as much share-value as the assets routed out).
1039
+ // Genuine gains stay taxable: the increment moves the mark by exactly what the fee moved
1040
+ // PPS, preserving the (hwm - pps) gap, so any yield PPS earned on top of the uplift —
1041
+ // including yield landed intra-block after the caller's accrual — is still charged at the
1042
+ // next accrual. No-op when the exit fee is 0 (a proportional burn leaves PPS unchanged).
1043
+ if (redeemBps != 0) {
1044
+ uint256 ts = totalSupply();
1045
+ if (ts != 0) {
1046
+ uint256 newPps = (totalAssets() * EXP_SCALE) / ts;
1047
+ if (newPps > prePps) {
1048
+ uint256 hwm = _highWaterMarkPerShare;
1049
+ uint256 newHwm = hwm + (newPps - prePps);
1050
+ _highWaterMarkPerShare = newHwm;
1051
+ emit HighWaterMarkUpdated(hwm, newHwm);
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ emit Withdraw(caller, receiver, owner, assets, shares);
1057
+ }
1058
+
1059
+ /**
1060
+ * @notice Cascade `assets` through the deposit queue. Reverts if total capacity is insufficient.
1061
+ * @param assets Asset units to place.
1062
+ */
1063
+ function _routeDeposit(uint256 assets) internal {
1064
+ // `totalAssets()` already includes the just-transferred-in `assets` (still idle on the
1065
+ // Hub), so it equals the post-deposit system TVL and stays constant across the loop as
1066
+ // funds move from idle to YieldGroups.
1067
+ uint256 taSnapshot = totalAssets();
1068
+ uint256 remaining = assets;
1069
+ address[] storage queue = _outerDepositQueue;
1070
+ uint256 len = queue.length;
1071
+
1072
+ for (uint256 i; i < len && remaining > 0; ++i) {
1073
+ remaining -= _tryDepositOneYieldGroup(queue[i], remaining, taSnapshot);
1074
+ }
1075
+
1076
+ if (remaining > 0) revert HubCapacityExceeded(assets, assets - remaining);
1077
+ }
1078
+
1079
+ /**
1080
+ * @notice Cascade-pull `assets` through the withdraw queue. Reverts on insufficient liquidity.
1081
+ * @param assets Asset units to pull.
1082
+ */
1083
+ function _routeWithdraw(uint256 assets) internal {
1084
+ uint256 remaining = assets;
1085
+
1086
+ // Consume any idle Hub balance first: it is already in custody, counts toward
1087
+ // liquidity, and is delivered by the caller's final transfer — so it is never pulled
1088
+ // from a YieldGroup. (Idle is normally zero; a direct asset() donation is the only thing
1089
+ // that persists here, since the underlying cannot be swept.)
1090
+ uint256 idle = IERC20Upgradeable(asset()).balanceOf(address(this));
1091
+ if (idle >= remaining) return;
1092
+ unchecked {
1093
+ remaining -= idle;
1094
+ }
1095
+
1096
+ address[] storage queue = _outerWithdrawQueue;
1097
+ uint256 len = queue.length;
1098
+
1099
+ for (uint256 i; i < len && remaining > 0; ++i) {
1100
+ remaining -= _tryWithdrawOneYieldGroup(queue[i], remaining);
1101
+ }
1102
+
1103
+ if (remaining > 0) revert HubInsufficientLiquidity(assets, assets - remaining);
1104
+ }
1105
+
1106
+ /**
1107
+ * @notice Apply pending management + performance fees by minting shares to the recipient.
1108
+ * @dev Idempotent within a block. Fees are skipped when supply is 0, the recipient is
1109
+ * unset, or the relevant rate is 0 — but the HWM is updated independently of those
1110
+ * conditions whenever PPS climbs, so a later perf-rate change cannot retroactively
1111
+ * claim prior gains. HWM tracks the PPS *before* fee dilution, matching the standard
1112
+ * Yearn / Morpho convention.
1113
+ */
1114
+ function _accrueFees() internal {
1115
+ // Idempotence keys off `block.number`, not the timestamp: resource NAV is block-lazy (a Core
1116
+ // vToken only accrues when `block.number` advances), and consecutive blocks can share a
1117
+ // timestamp on sub-second chains. A timestamp guard would then skip the refresh on a fresh
1118
+ // block and price shares on a stale exchange rate. The block guard refreshes every new block.
1119
+ uint64 nowBlock = uint64(block.number);
1120
+ if (nowBlock == _lastFeeAccrualBlock) return;
1121
+ _lastFeeAccrualBlock = nowBlock;
1122
+
1123
+ uint64 nowTs = uint64(block.timestamp);
1124
+ uint64 last = _lastFeeAccrualTimestamp;
1125
+
1126
+ // Settle resource interest before reading NAV so the fee is charged on interest-current
1127
+ // value, not a one cycle stale one. The block guard above bounds this to once per block.
1128
+ _accrueResources();
1129
+
1130
+ uint256 ts = totalSupply();
1131
+ if (ts == 0) {
1132
+ _lastFeeAccrualTimestamp = nowTs;
1133
+ return;
1134
+ }
1135
+
1136
+ uint256 ta = totalAssets();
1137
+ uint256 currentPps = (ta * EXP_SCALE) / ts;
1138
+ uint256 hwm = _highWaterMarkPerShare;
1139
+ address recipient = _feeRecipient;
1140
+
1141
+ // `chargedUntil` is normally `nowTs`. If the elapsed-window fee would exceed 100% of TA,
1142
+ // the clamp caps it at TA and advances the cursor only over the window producing 100%, so
1143
+ // the excess defers to the next call; that accrual is then skipped by the `totalFee < ta`
1144
+ // guard below. No fees are charged when the recipient is unset.
1145
+ uint256 mgmtAssetFee;
1146
+ uint256 perfAssetFee;
1147
+ uint64 chargedUntil = nowTs;
1148
+ if (recipient != address(0)) {
1149
+ (mgmtAssetFee, chargedUntil) = _pendingManagementFee(ta, last, nowTs);
1150
+ perfAssetFee = _pendingPerformanceFee(ts, currentPps, hwm);
1151
+ }
1152
+
1153
+ uint256 totalFee = mgmtAssetFee + perfAssetFee;
1154
+ if (totalFee > 0 && totalFee < ta) {
1155
+ uint256 totalShares = (totalFee * ts) / (ta - totalFee);
1156
+ if (totalShares > 0) {
1157
+ // mgmt/perf split is floor-rounded: in the dust regime one component can round
1158
+ // to 0 and the other absorbs the remainder. The total mint is exact; only the
1159
+ // off-chain mgmt-vs-perf attribution is approximate.
1160
+ uint256 mgmtShares = (totalShares * mgmtAssetFee) / totalFee;
1161
+ uint256 perfShares = totalShares - mgmtShares;
1162
+ _mint(recipient, totalShares);
1163
+ emit FeesAccrued(mgmtShares, perfShares, totalShares);
1164
+ }
1165
+ }
1166
+
1167
+ if (currentPps > hwm) {
1168
+ _highWaterMarkPerShare = currentPps;
1169
+ emit HighWaterMarkUpdated(hwm, currentPps);
1170
+ }
1171
+
1172
+ _lastFeeAccrualTimestamp = chargedUntil;
1173
+ }
1174
+
1175
+ /**
1176
+ * @notice Poke every registered YieldGroup to settle its resources' accrued interest up to the
1177
+ * current block, so the subsequent {totalAssets} read is interest-current.
1178
+ * @dev Each YieldGroup's `accrue` is best-effort (it swallows a resource that cannot accrue) and
1179
+ * is a no-op for families whose NAV is not a block-lazy exchange rate (Flux, FRV), so this
1180
+ * loop never reverts on a market condition. Calls only governance-registered YieldGroups
1181
+ * and resources, so it opens no untrusted reentrancy surface.
1182
+ */
1183
+ function _accrueResources() internal {
1184
+ address[] storage yieldGroups = _registeredYieldGroups;
1185
+ uint256 len = yieldGroups.length;
1186
+ for (uint256 i; i < len; ++i) {
1187
+ IYieldGroupBase(yieldGroups[i]).accrue();
1188
+ }
1189
+ }
1190
+
1191
+ // ============================== Internal — view ==========================
1192
+
1193
+ /// @inheritdoc ERC4626Upgradeable
1194
+ function _decimalsOffset() internal view override returns (uint8) {
1195
+ return _decimalsOffsetStored;
1196
+ }
1197
+
1198
+ /**
1199
+ * @notice Compute the effective cap for a YieldGroup given a TVL reference.
1200
+ * @dev When `percentageCapBps == BPS_DENOMINATOR` (100%), the percentage cap is treated
1201
+ * as disabled and only the absolute cap binds. Without this carve-out, every Hub
1202
+ * with any percentage-cap value is permanently un-bootstrappable: at TA = 0 the
1203
+ * percentage component collapses to 0, `maxDeposit()` returns 0, and OZ's
1204
+ * ERC-4626 wrapper rejects the very first deposit. The carve-out matches the
1205
+ * MetaMorpho convention and preserves dual-cap semantics for `pctBps < 10_000`.
1206
+ * @dev Cap basis: every cap check (deposit cascade, {maxDeposit}, and {reallocate}) compares this
1207
+ * ceiling against the YieldGroup's *current marked* `totalAssets()`, not the principal
1208
+ * deposited into it. Because a group's `totalAssets()` accrues over time, its cap headroom
1209
+ * shrinks even with no new deposit — most visibly for FRV, whose Hub-facing mark accrues the
1210
+ * forward coupon over the lock (see `AdapterFRV`) yet is non-withdrawable until the vault is
1211
+ * terminal. A large FRV position can thus reach or pass its group's cap on accrual alone,
1212
+ * blocking further deposits / reallocation into that group (even into a different resource
1213
+ * under it). Fail-closed: accrual only tightens capacity, never over-exposes. Size caps with
1214
+ * headroom for the coupon expected over the lock.
1215
+ * @param cfg YieldGroup config to evaluate.
1216
+ * @param ta TVL reference used for the percentage-cap component.
1217
+ * @return cap `pctBps == 10_000` ? `absoluteCap` : `min(absoluteCap, pctBps × ta / 10_000)`.
1218
+ */
1219
+ function _effectiveCap(IHub.YieldGroupConfig storage cfg, uint256 ta) internal view returns (uint256 cap) {
1220
+ if (cfg.percentageCapBps == BPS_DENOMINATOR) return cfg.absoluteCap;
1221
+ uint256 pctCap = (ta * cfg.percentageCapBps) / BPS_DENOMINATOR;
1222
+ cap = pctCap < cfg.absoluteCap ? pctCap : cfg.absoluteCap;
1223
+ }
1224
+
1225
+ /**
1226
+ * @notice Sum spare deposit capacity across the unpaused deposit-queue YieldGroups.
1227
+ * @param taSnapshot TVL reference used for percentage-cap math on each YieldGroup.
1228
+ * @return total Aggregate asset units that could be deposited right now.
1229
+ */
1230
+ function _aggregateDepositCapacity(uint256 taSnapshot) internal view returns (uint256 total) {
1231
+ address[] storage queue = _outerDepositQueue;
1232
+ uint256 len = queue.length;
1233
+ for (uint256 i; i < len; ++i) {
1234
+ address yieldGroup = queue[i];
1235
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
1236
+ if (cfg.registered && !cfg.paused) {
1237
+ // Isolate a misbehaving YieldGroup: a revert in either capacity view contributes 0
1238
+ // spare cap instead of bricking every deposit. Mirrors `totalAssets()`.
1239
+ uint256 currentBal;
1240
+ uint256 yieldGroupMax;
1241
+ bool ok = true;
1242
+ try IYieldGroupBase(yieldGroup).totalAssets() returns (uint256 v) {
1243
+ currentBal = v;
1244
+ } catch {
1245
+ ok = false;
1246
+ }
1247
+ if (ok) {
1248
+ try IYieldGroupBase(yieldGroup).maxDeposit() returns (uint256 v) {
1249
+ yieldGroupMax = v;
1250
+ } catch {
1251
+ ok = false;
1252
+ }
1253
+ }
1254
+ if (ok) {
1255
+ uint256 cap = _effectiveCap(cfg, taSnapshot);
1256
+ uint256 capRoom = cap > currentBal ? cap - currentBal : 0;
1257
+ total += capRoom < yieldGroupMax ? capRoom : yieldGroupMax;
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * @notice Sum liquid funds across the unpaused withdraw-queue YieldGroups, plus Hub idle.
1265
+ * @dev Hub idle is first-class liquidity: `_routeWithdraw` consumes it before touching any
1266
+ * YieldGroup, so it must be counted here for `maxWithdraw` to stay consistent with what a
1267
+ * withdrawal can actually deliver.
1268
+ * @return total Aggregate asset units that could be withdrawn right now.
1269
+ */
1270
+ function _aggregateLiquid() internal view returns (uint256 total) {
1271
+ address[] storage queue = _outerWithdrawQueue;
1272
+ uint256 len = queue.length;
1273
+ for (uint256 i; i < len; ++i) {
1274
+ address yieldGroup = queue[i];
1275
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
1276
+ if (cfg.registered && !cfg.paused) {
1277
+ // A revert in a YieldGroup's liquidity view contributes 0 instead of bricking every
1278
+ // withdraw. Mirrors `totalAssets()`.
1279
+ uint256 yieldGroupLiquid;
1280
+ try IYieldGroupBase(yieldGroup).maxWithdraw() returns (uint256 v) {
1281
+ yieldGroupLiquid = v;
1282
+ } catch {
1283
+ yieldGroupLiquid = 0;
1284
+ }
1285
+ total += yieldGroupLiquid;
1286
+ }
1287
+ }
1288
+ total += IERC20Upgradeable(asset()).balanceOf(address(this));
1289
+ }
1290
+
1291
+ // ============================== Private — mutating =======================
1292
+
1293
+ /**
1294
+ * @notice Shared net-zero reallocation core for {reallocate} and {emergencyReallocate}.
1295
+ * @dev Accrues fees first so the operation trades against current state — except while
1296
+ * the Hub is paused, when accrual is skipped so the pause window is excluded from fee
1297
+ * proration ({unpauseHub} later advances the accrual cursor past the frozen window).
1298
+ * Enforces `Σ withdraws == Σ deposits`.
1299
+ * @param withdraws Withdraw legs to execute first.
1300
+ * @param deposits Deposit legs to execute against the post-pull TVL snapshot.
1301
+ */
1302
+ function _reallocate(IHub.ReallocateLeg[] calldata withdraws, IHub.ReallocateLeg[] calldata deposits) private {
1303
+ if (!_hubPaused) {
1304
+ _accrueFees();
1305
+ }
1306
+
1307
+ uint256 withdrawSum = _reallocatePullAll(withdraws);
1308
+ // System TVL is conserved across a balanced reallocate, so a single TVL snapshot (taken
1309
+ // after pulls; idle now holds the withdrawn funds) is the correct cap reference for every
1310
+ // push leg. Normal reallocate uses the strict, fail-closed `totalAssets()`. The emergency
1311
+ // path (paused) instead uses a fail-open sum: `totalAssets()` reverts if ANY registered
1312
+ // YieldGroup's view reverts, so a single bricked YieldGroup — the very condition `emergencyReallocate`
1313
+ // exists to unwind — would otherwise block a rebalance even among the healthy YieldGroups.
1314
+ // Skipping a reverting YieldGroup only understates the reference, which makes percentage caps
1315
+ // strictly tighter (more conservative), never looser.
1316
+ uint256 taSnapshot = _hubPaused ? _totalAssetsFailOpen() : totalAssets();
1317
+ uint256 depositSum = _reallocatePushAll(deposits, taSnapshot);
1318
+
1319
+ if (withdrawSum != depositSum) revert ReallocateImbalanced(withdrawSum, depositSum);
1320
+ }
1321
+
1322
+ /**
1323
+ * @notice Execute every withdraw leg of a `reallocate()` plan.
1324
+ * @param withdraws Withdraw legs to execute in order.
1325
+ * @return sum Total asset units pulled into the Hub.
1326
+ */
1327
+ function _reallocatePullAll(IHub.ReallocateLeg[] calldata withdraws) private returns (uint256 sum) {
1328
+ IERC20Upgradeable assetToken = IERC20Upgradeable(asset());
1329
+ uint256 len = withdraws.length;
1330
+ for (uint256 i; i < len; ++i) {
1331
+ IHub.ReallocateLeg calldata withdrawLeg = withdraws[i];
1332
+ if (withdrawLeg.amount == 0) revert ZeroAmount();
1333
+ if (!_yieldGroups[withdrawLeg.yieldGroup].registered) {
1334
+ revert YieldGroupNotRegistered(withdrawLeg.yieldGroup);
1335
+ }
1336
+
1337
+ uint256 preBal = assetToken.balanceOf(address(this));
1338
+ if (withdrawLeg.resource == address(0)) {
1339
+ IYieldGroupBase(withdrawLeg.yieldGroup).withdraw(withdrawLeg.amount, address(this));
1340
+ } else {
1341
+ IYieldGroupBase(withdrawLeg.yieldGroup)
1342
+ .withdrawResource(withdrawLeg.resource, withdrawLeg.amount, address(this));
1343
+ }
1344
+ uint256 delivered = assetToken.balanceOf(address(this)) - preBal;
1345
+ if (delivered != withdrawLeg.amount) {
1346
+ revert YieldGroupUnderfilled(withdrawLeg.yieldGroup, withdrawLeg.amount, delivered);
1347
+ }
1348
+ sum += withdrawLeg.amount;
1349
+ emit WithdrawRouted(withdrawLeg.yieldGroup, withdrawLeg.amount);
1350
+ }
1351
+ }
1352
+
1353
+ /**
1354
+ * @notice Execute every deposit leg of a `reallocate()` plan.
1355
+ * @param deposits Deposit legs to execute in order.
1356
+ * @param taSnapshot `totalAssets()` snapshot used as the cap reference for every leg.
1357
+ * @return sum Total asset units pushed out of the Hub.
1358
+ */
1359
+ function _reallocatePushAll(IHub.ReallocateLeg[] calldata deposits, uint256 taSnapshot)
1360
+ private
1361
+ returns (uint256 sum)
1362
+ {
1363
+ uint256 len = deposits.length;
1364
+ for (uint256 i; i < len; ++i) {
1365
+ sum += _reallocatePushOne(deposits[i], taSnapshot);
1366
+ }
1367
+ }
1368
+
1369
+ /**
1370
+ * @notice Execute a single `reallocate()` deposit leg with cap and pause checks.
1371
+ * @param depositLeg Deposit leg to execute.
1372
+ * @param taSnapshot `totalAssets()` snapshot used as the cap reference.
1373
+ * @return placed Asset units placed into `depositLeg.yieldGroup` (always `depositLeg.amount`;
1374
+ * underfills revert).
1375
+ */
1376
+ function _reallocatePushOne(IHub.ReallocateLeg calldata depositLeg, uint256 taSnapshot)
1377
+ private
1378
+ returns (uint256 placed)
1379
+ {
1380
+ if (depositLeg.amount == 0) revert ZeroAmount();
1381
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[depositLeg.yieldGroup];
1382
+ if (!cfg.registered) revert YieldGroupNotRegistered(depositLeg.yieldGroup);
1383
+ if (cfg.paused) revert YieldGroupPaused(depositLeg.yieldGroup);
1384
+
1385
+ uint256 cap = _effectiveCap(cfg, taSnapshot);
1386
+ uint256 currentBal = IYieldGroupBase(depositLeg.yieldGroup).totalAssets();
1387
+ if (currentBal + depositLeg.amount > cap) {
1388
+ revert HubCapacityExceeded(depositLeg.amount, cap > currentBal ? cap - currentBal : 0);
1389
+ }
1390
+
1391
+ IERC20Upgradeable(asset()).forceApprove(depositLeg.yieldGroup, depositLeg.amount);
1392
+ uint256 deposited = depositLeg.resource == address(0)
1393
+ ? IYieldGroupBase(depositLeg.yieldGroup).deposit(depositLeg.amount)
1394
+ : IYieldGroupBase(depositLeg.yieldGroup).depositResource(depositLeg.resource, depositLeg.amount);
1395
+ if (deposited != depositLeg.amount) {
1396
+ revert YieldGroupUnderfilled(depositLeg.yieldGroup, depositLeg.amount, deposited);
1397
+ }
1398
+
1399
+ emit DepositRouted(depositLeg.yieldGroup, depositLeg.amount);
1400
+ return depositLeg.amount;
1401
+ }
1402
+
1403
+ /**
1404
+ * @notice Try to deposit up to `remaining` into one YieldGroup. Skips unregistered / paused.
1405
+ * @dev Accepts a partial fill: the YieldGroup places what its inner queue can absorb and
1406
+ * refunds the rest, so the returned amount may be less than `remaining`. The outer
1407
+ * cascade routes the unplaced remainder to the next YieldGroup; the final
1408
+ * {HubCapacityExceeded} backstop fires if no YieldGroup can absorb the full amount.
1409
+ * @param yieldGroup YieldGroup to deposit into.
1410
+ * @param remaining Asset units still needing placement.
1411
+ * @param taSnapshot TVL reference for percentage-cap math.
1412
+ * @return sent Asset units actually placed into `yieldGroup`.
1413
+ */
1414
+ function _tryDepositOneYieldGroup(address yieldGroup, uint256 remaining, uint256 taSnapshot)
1415
+ private
1416
+ returns (uint256 sent)
1417
+ {
1418
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
1419
+ if (!cfg.registered || cfg.paused) return 0;
1420
+
1421
+ uint256 toSend = _capRoomForDeposit(yieldGroup, cfg, taSnapshot, remaining);
1422
+ if (toSend == 0) return 0;
1423
+
1424
+ // Route around a YieldGroup whose mutating `deposit` reverts (e.g. its external protocol paused
1425
+ // it after the views certified capacity) instead of bubbling. The approval lives in the
1426
+ // Hub's own frame and is NOT rolled back by the YieldGroup's revert, so reset it on skip. The
1427
+ // `remaining` total is unchanged by a skip, so the unchanged backstop in `_routeDeposit`
1428
+ // still reverts the whole op atomically if no healthy YieldGroup can absorb the full amount.
1429
+ IERC20Upgradeable(asset()).forceApprove(yieldGroup, toSend);
1430
+ try IYieldGroupBase(yieldGroup).deposit(toSend) returns (uint256 deposited) {
1431
+ // `deposited` is the amount the YieldGroup placed; it refunds any remainder. Return it
1432
+ // so the cascade routes what is left onward to the next YieldGroup.
1433
+ if (deposited > 0) emit DepositRouted(yieldGroup, deposited);
1434
+ return deposited;
1435
+ } catch {
1436
+ IERC20Upgradeable(asset()).forceApprove(yieldGroup, 0);
1437
+ emit YieldGroupSkipped(yieldGroup, true);
1438
+ return 0;
1439
+ }
1440
+ }
1441
+
1442
+ /**
1443
+ * @notice Try to withdraw up to `remaining` from one YieldGroup. Skips unregistered / paused.
1444
+ * @dev Reverts {YieldGroupUnderfilled} if the YieldGroup delivers less than asked.
1445
+ * @param yieldGroup YieldGroup to pull from.
1446
+ * @param remaining Asset units still needing collection.
1447
+ * @return pulled Asset units actually pulled from `yieldGroup`.
1448
+ */
1449
+ function _tryWithdrawOneYieldGroup(address yieldGroup, uint256 remaining) private returns (uint256 pulled) {
1450
+ IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
1451
+ if (!cfg.registered || cfg.paused) return 0;
1452
+
1453
+ // A revert in the liquidity view is treated as "no liquidity" and skipped silently, like
1454
+ // the deposit side's `_capRoomForDeposit`.
1455
+ uint256 yieldGroupLiquid;
1456
+ try IYieldGroupBase(yieldGroup).maxWithdraw() returns (uint256 v) {
1457
+ yieldGroupLiquid = v;
1458
+ } catch {
1459
+ return 0;
1460
+ }
1461
+ uint256 toPull = remaining < yieldGroupLiquid ? remaining : yieldGroupLiquid;
1462
+ if (toPull == 0) return 0;
1463
+
1464
+ // Route around a YieldGroup whose mutating `withdraw` reverts (e.g. paused after the view
1465
+ // certified liquidity) instead of bubbling. `remaining` is unchanged by a skip, so the
1466
+ // unchanged backstop in `_routeWithdraw` still reverts the whole op atomically when no
1467
+ // healthy YieldGroup can deliver the full amount.
1468
+ IERC20Upgradeable assetToken = IERC20Upgradeable(asset());
1469
+ uint256 preBal = assetToken.balanceOf(address(this));
1470
+ try IYieldGroupBase(yieldGroup).withdraw(toPull, address(this)) {
1471
+ uint256 delivered = assetToken.balanceOf(address(this)) - preBal;
1472
+ if (delivered != toPull) revert YieldGroupUnderfilled(yieldGroup, toPull, delivered);
1473
+ emit WithdrawRouted(yieldGroup, toPull);
1474
+ return toPull;
1475
+ } catch {
1476
+ emit YieldGroupSkipped(yieldGroup, false);
1477
+ return 0;
1478
+ }
1479
+ }
1480
+
1481
+ /**
1482
+ * @notice Shared body for the per-transaction withdraw-cap setters; `raising` selects the
1483
+ * direction guard and the emitted event. Behavior matches the former separate setters.
1484
+ * @param newSize New cap in asset units.
1485
+ * @param raising True for the raise path (NotIncreasing guard); false for lower
1486
+ * (ZeroAmount + NotDecreasing guards).
1487
+ */
1488
+ function _setMaxWithdrawalSize(uint256 newSize, bool raising) private {
1489
+ uint256 old = _maxWithdrawalSize;
1490
+ if (raising) {
1491
+ if (newSize <= old) revert NotIncreasing();
1492
+ } else {
1493
+ if (newSize == 0) revert ZeroAmount();
1494
+ if (newSize >= old) revert NotDecreasing();
1495
+ }
1496
+ _maxWithdrawalSize = newSize;
1497
+ if (raising) {
1498
+ emit MaxWithdrawalSizeRaised(old, newSize);
1499
+ } else {
1500
+ emit MaxWithdrawalSizeLowered(old, newSize);
1501
+ }
1502
+ }
1503
+
1504
+ // ============================== Private — view ===========================
1505
+
1506
+ /**
1507
+ * @notice Fail-open NAV used ONLY as the emergency-reallocate push-cap reference: a YieldGroup whose
1508
+ * `totalAssets()` reverts contributes 0 instead of bricking the whole call. Understating
1509
+ * NAV only tightens percentage caps, so it is conservative for a push gate. Never used for
1510
+ * share pricing, which must stay fail-closed — see {totalAssets}.
1511
+ * @return total Sum of readable YieldGroup balances plus the Hub's idle `asset()` balance.
1512
+ */
1513
+ function _totalAssetsFailOpen() private view returns (uint256 total) {
1514
+ address[] storage yieldGroups = _registeredYieldGroups;
1515
+ uint256 len = yieldGroups.length;
1516
+ for (uint256 i; i < len; ++i) {
1517
+ // solhint-disable-next-line no-empty-blocks
1518
+ try IYieldGroupBase(yieldGroups[i]).totalAssets() returns (uint256 v) {
1519
+ total += v;
1520
+ } catch {
1521
+ // A bricked YieldGroup contributes 0 to the emergency cap reference instead of reverting.
1522
+ }
1523
+ }
1524
+ total += IERC20Upgradeable(asset()).balanceOf(address(this));
1525
+ }
1526
+
1527
+ /**
1528
+ * @notice Compute how much can be deposited into a YieldGroup this step.
1529
+ * @param yieldGroup YieldGroup under inspection.
1530
+ * @param cfg YieldGroup config (storage ref to avoid a second SLOAD-equivalent).
1531
+ * @param taSnapshot TVL reference for percentage-cap math.
1532
+ * @param remaining Asset units the caller still needs to place.
1533
+ * @return room `min(remaining, capRoom, yieldGroup.maxDeposit())`.
1534
+ */
1535
+ function _capRoomForDeposit(
1536
+ address yieldGroup,
1537
+ IHub.YieldGroupConfig storage cfg,
1538
+ uint256 taSnapshot,
1539
+ uint256 remaining
1540
+ ) private view returns (uint256 room) {
1541
+ // A revert in either capacity view of this single YieldGroup yields 0 room, so the deposit
1542
+ // cascade skips it (consistent with the mutating-leg skip below) instead of bubbling.
1543
+ uint256 currentBal;
1544
+ uint256 yieldGroupMax;
1545
+ try IYieldGroupBase(yieldGroup).totalAssets() returns (uint256 v) {
1546
+ currentBal = v;
1547
+ } catch {
1548
+ return 0;
1549
+ }
1550
+ try IYieldGroupBase(yieldGroup).maxDeposit() returns (uint256 v) {
1551
+ yieldGroupMax = v;
1552
+ } catch {
1553
+ return 0;
1554
+ }
1555
+ uint256 cap = _effectiveCap(cfg, taSnapshot);
1556
+ uint256 capRoom = cap > currentBal ? cap - currentBal : 0;
1557
+ uint256 boundedRoom = capRoom < yieldGroupMax ? capRoom : yieldGroupMax;
1558
+ room = remaining < boundedRoom ? remaining : boundedRoom;
1559
+ }
1560
+
1561
+ /**
1562
+ * @notice Management fee (in asset units) pending since `last`, capped per accrual, and the
1563
+ * timestamp the accrual cursor should advance to.
1564
+ * @dev The fee is linear in elapsed time and capped at `MAX_MGMT_FEE_PER_ACCRUAL_BPS` of
1565
+ * `ta` per call. When the cap binds, `chargedUntil` is the time the cap covers
1566
+ * (`last + dtCharged`), so only that window is consumed and any excess defers to the
1567
+ * next call.
1568
+ * @param ta `totalAssets()` snapshot.
1569
+ * @param last Timestamp of the previous accrual.
1570
+ * @param nowTs Current block timestamp.
1571
+ * @return mgmtAssetFee Management fee in asset units (capped).
1572
+ * @return chargedUntil Timestamp to store as the new accrual cursor.
1573
+ */
1574
+ function _pendingManagementFee(uint256 ta, uint64 last, uint64 nowTs)
1575
+ private
1576
+ view
1577
+ returns (uint256 mgmtAssetFee, uint64 chargedUntil)
1578
+ {
1579
+ chargedUntil = nowTs;
1580
+ uint16 mgmtBps = _managementFeeBps;
1581
+ if (mgmtBps == 0) return (0, chargedUntil);
1582
+
1583
+ uint256 dt = nowTs - last;
1584
+ mgmtAssetFee = (ta * mgmtBps * dt) / (uint256(BPS_DENOMINATOR) * SECONDS_PER_YEAR);
1585
+ uint256 mgmtCap = (ta * MAX_MGMT_FEE_PER_ACCRUAL_BPS) / BPS_DENOMINATOR;
1586
+ if (mgmtAssetFee > mgmtCap) {
1587
+ mgmtAssetFee = mgmtCap;
1588
+ // dt that exactly produces `mgmtCap`: independent of `ta`. The cap only binds when
1589
+ // `dt > dtCharged`, so `last + dtCharged < nowTs` — never advances past now.
1590
+ uint256 dtCharged = (uint256(MAX_MGMT_FEE_PER_ACCRUAL_BPS) * SECONDS_PER_YEAR) / mgmtBps;
1591
+ // Safe cast: dtCharged <= MAX_MGMT_FEE_PER_ACCRUAL_BPS * SECONDS_PER_YEAR (~3.2e11) << 2^64.
1592
+ // forge-lint: disable-next-line(unsafe-typecast)
1593
+ chargedUntil = last + uint64(dtCharged);
1594
+ }
1595
+ }
1596
+
1597
+ /**
1598
+ * @notice Performance fee (in asset units) on gains above the high-water mark.
1599
+ * @param ts `totalSupply()` snapshot.
1600
+ * @param currentPps Current per-share price (`ta * EXP_SCALE / ts`).
1601
+ * @param hwm Current high-water mark.
1602
+ * @return perfAssetFee Performance fee in asset units (0 when not above the HWM).
1603
+ */
1604
+ function _pendingPerformanceFee(uint256 ts, uint256 currentPps, uint256 hwm)
1605
+ private
1606
+ view
1607
+ returns (uint256 perfAssetFee)
1608
+ {
1609
+ uint16 perfBps = _performanceFeeBps;
1610
+ if (perfBps == 0 || currentPps <= hwm) return 0;
1611
+ uint256 profitAssets = ((currentPps - hwm) * ts) / EXP_SCALE;
1612
+ perfAssetFee = (profitAssets * perfBps) / BPS_DENOMINATOR;
1613
+ }
1614
+ }