@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.
- package/LICENSE +98 -0
- package/README.md +422 -0
- package/contracts/Hub/Hub.sol +1614 -0
- package/contracts/Hub/HubStorage.sol +106 -0
- package/contracts/Hub/lib/HubAdminLib.sol +292 -0
- package/contracts/YieldGroup/YieldGroup.sol +128 -0
- package/contracts/YieldGroup/YieldGroupFRV.sol +107 -0
- package/contracts/YieldGroup/base/YieldGroupBase.sol +797 -0
- package/contracts/YieldGroup/base/YieldGroupBaseStorage.sol +73 -0
- package/contracts/adapters/AdapterCoreV1.sol +309 -0
- package/contracts/adapters/AdapterFRV.sol +276 -0
- package/contracts/adapters/AdapterFlux.sol +173 -0
- package/contracts/interfaces/IHub.sol +551 -0
- package/contracts/interfaces/IHubRegistry.sol +120 -0
- package/contracts/interfaces/IMigrator.sol +134 -0
- package/contracts/interfaces/IResourceAdapter.sol +154 -0
- package/contracts/interfaces/IYieldGroup.sol +119 -0
- package/contracts/interfaces/IYieldGroupBase.sol +507 -0
- package/contracts/interfaces/IYieldGroupFRV.sol +61 -0
- package/contracts/interfaces/external/IComptroller.sol +57 -0
- package/contracts/interfaces/external/IFRVVault.sol +113 -0
- package/contracts/interfaces/external/IFToken.sol +46 -0
- package/contracts/interfaces/external/IFluidLendingResolver.sol +63 -0
- package/contracts/interfaces/external/IVBep20.sol +39 -0
- package/contracts/interfaces/external/IVToken.sol +125 -0
- package/contracts/interfaces/external/IWBNB.sol +16 -0
- package/contracts/migrator/Migrator.sol +182 -0
- package/contracts/registry/HubRegistry.sol +179 -0
- package/deployments/bscmainnet/AdapterCoreV1.json +336 -0
- package/deployments/bscmainnet/AdapterFRV.json +225 -0
- package/deployments/bscmainnet/AdapterFlux.json +249 -0
- package/deployments/bscmainnet/CoreBeacon.json +110 -0
- package/deployments/bscmainnet/CoreSource_U.json +1083 -0
- package/deployments/bscmainnet/CoreSource_USDC.json +1083 -0
- package/deployments/bscmainnet/CoreSource_USDT.json +1083 -0
- package/deployments/bscmainnet/FRVBeacon.json +110 -0
- package/deployments/bscmainnet/FRVSource_U.json +978 -0
- package/deployments/bscmainnet/FRVSource_USDC.json +978 -0
- package/deployments/bscmainnet/FRVSource_USDT.json +978 -0
- package/deployments/bscmainnet/FluxBeacon.json +110 -0
- package/deployments/bscmainnet/FluxSource_U.json +1083 -0
- package/deployments/bscmainnet/FluxSource_USDC.json +1083 -0
- package/deployments/bscmainnet/FluxSource_USDT.json +1083 -0
- package/deployments/bscmainnet/HubBeacon.json +110 -0
- package/deployments/bscmainnet/HubImpl.json +2216 -0
- package/deployments/bscmainnet/HubRegistry.json +386 -0
- package/deployments/bscmainnet/HubRegistryImpl.json +386 -0
- package/deployments/bscmainnet/Hub_U.json +2216 -0
- package/deployments/bscmainnet/Hub_USDC.json +2216 -0
- package/deployments/bscmainnet/Hub_USDT.json +2216 -0
- package/deployments/bscmainnet/Migrator.json +375 -0
- package/deployments/bscmainnet/YieldGroupCoreImpl.json +1083 -0
- package/deployments/bscmainnet/YieldGroupFRVImpl.json +978 -0
- package/deployments/bscmainnet/YieldGroupFluxImpl.json +1083 -0
- package/deployments/bscmainnet_addresses.json +32 -0
- package/deployments/bsctestnet/AdapterCoreV1.json +336 -0
- package/deployments/bsctestnet/AdapterFRV.json +225 -0
- package/deployments/bsctestnet/AdapterFlux.json +249 -0
- package/deployments/bsctestnet/CoreBeacon.json +110 -0
- package/deployments/bsctestnet/CoreSource_USDT.json +1083 -0
- package/deployments/bsctestnet/FRVBeacon.json +110 -0
- package/deployments/bsctestnet/FRVSource_USDT.json +978 -0
- package/deployments/bsctestnet/FluxBeacon.json +110 -0
- package/deployments/bsctestnet/FluxSource_USDT.json +1083 -0
- package/deployments/bsctestnet/HubBeacon.json +110 -0
- package/deployments/bsctestnet/HubImpl.json +2216 -0
- package/deployments/bsctestnet/HubRegistry.json +386 -0
- package/deployments/bsctestnet/HubRegistryImpl.json +386 -0
- package/deployments/bsctestnet/HubRegistryProxyAdmin.json +157 -0
- package/deployments/bsctestnet/Hub_USDT.json +2216 -0
- package/deployments/bsctestnet/Migrator.json +375 -0
- package/deployments/bsctestnet/YieldGroupCoreImpl.json +1083 -0
- package/deployments/bsctestnet/YieldGroupFRVImpl.json +978 -0
- package/deployments/bsctestnet/YieldGroupFluxImpl.json +1083 -0
- package/deployments/bsctestnet_addresses.json +25 -0
- 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
|
+
}
|