@venusprotocol/liquidity-hub 2.1.0-dev.4 → 2.1.0-dev.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +111 -30
  2. package/contracts/Hub/Hub.sol +71 -63
  3. package/contracts/adapters/AdapterCentrifuge.sol +5 -0
  4. package/contracts/adapters/AdapterCoreV1.sol +6 -0
  5. package/contracts/adapters/AdapterFRV.sol +27 -7
  6. package/contracts/adapters/AdapterFlux.sol +6 -0
  7. package/contracts/adapters/AdapterSpokeV1.sol +428 -0
  8. package/contracts/interfaces/IHub.sol +6 -0
  9. package/contracts/interfaces/IHubLens.sol +71 -0
  10. package/contracts/interfaces/IResourceAdapter.sol +23 -7
  11. package/contracts/interfaces/IYieldGroupBase.sol +6 -1
  12. package/contracts/interfaces/external/ISpokeComptroller.sol +69 -0
  13. package/contracts/interfaces/external/IVTokenIsolated.sol +153 -0
  14. package/contracts/lens/HubCapacityMath.sol +610 -0
  15. package/contracts/lens/HubLens.sol +311 -0
  16. package/deployments/bscmainnet/AdapterCentrifuge.json +21 -2
  17. package/deployments/bscmainnet/AdapterCoreV1.json +21 -2
  18. package/deployments/bscmainnet/AdapterFRV.json +21 -2
  19. package/deployments/bscmainnet/AdapterFlux.json +21 -2
  20. package/deployments/bscmainnet/AdapterSpokeV1.json +339 -0
  21. package/deployments/bscmainnet/HubImpl.json +16 -3
  22. package/deployments/bscmainnet/HubLens.json +89 -0
  23. package/deployments/bscmainnet/SpokeBeacon.json +110 -0
  24. package/deployments/bscmainnet/SpokeSource_U.json +1083 -0
  25. package/deployments/bscmainnet/SpokeSource_USDC.json +1083 -0
  26. package/deployments/bscmainnet/SpokeSource_USDT.json +1083 -0
  27. package/deployments/bscmainnet/YieldGroupSpokeImpl.json +1083 -0
  28. package/deployments/bscmainnet_addresses.json +13 -6
  29. package/deployments/bsctestnet/AdapterCentrifuge.json +21 -2
  30. package/deployments/bsctestnet/AdapterCoreV1.json +21 -2
  31. package/deployments/bsctestnet/AdapterFRV.json +21 -2
  32. package/deployments/bsctestnet/AdapterFlux.json +21 -2
  33. package/deployments/bsctestnet/AdapterSpokeV1.json +339 -0
  34. package/deployments/bsctestnet/HubImpl.json +16 -3
  35. package/deployments/bsctestnet/HubLens.json +89 -0
  36. package/deployments/bsctestnet/SpokeBeacon.json +110 -0
  37. package/deployments/bsctestnet/SpokeSource_USDT.json +1083 -0
  38. package/deployments/bsctestnet/YieldGroupSpokeImpl.json +1083 -0
  39. package/deployments/bsctestnet_addresses.json +11 -6
  40. package/package.json +1 -1
@@ -0,0 +1,428 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
6
+ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7
+
8
+ import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
9
+ import { IVTokenIsolated } from "../interfaces/external/IVTokenIsolated.sol";
10
+ import { ISpokeComptroller } from "../interfaces/external/ISpokeComptroller.sol";
11
+
12
+ /**
13
+ * @title AdapterSpokeV1
14
+ * @author Venus
15
+ * @notice Stateless adapter for the liquidity side of a hub-funded spoke pool — an isolated-pools
16
+ * `VToken` governed by a `SpokeComptroller`. ONE deployment serves every YieldGroup that
17
+ * registers a spoke market; there is no per-(YieldGroup, market) instance, no proxy, no
18
+ * clone factory.
19
+ * @dev Dispatch model: mutating functions (`deposit`, `withdraw`) MUST be invoked via DELEGATECALL
20
+ * from a YieldGroup. They execute in the YieldGroup's storage context, so vTokens are credited
21
+ * to / burned from the YieldGroup, not this adapter. View functions are invoked via normal
22
+ * CALL / STATICCALL; those that name a `holder` read the position from that argument, while
23
+ * {maxDeposit} and {validateRegistration} read `msg.sender` instead (see below).
24
+ *
25
+ * Storage-safety invariants enforced by this contract:
26
+ * 1. Zero `internal`/`private`/`public` state variables are declared. Only `immutable` values
27
+ * are used. Immutables live in bytecode, not storage slots, so they're delegatecall-safe.
28
+ * 2. No inline assembly performs `sstore`.
29
+ * 3. All external calls go to the supplied `resource` (vToken), its `underlying()`, or its
30
+ * `comptroller()`. No arbitrary user-supplied call target.
31
+ * 4. {onlyDelegateCall} reverts when a mutating function is invoked directly on the adapter,
32
+ * preventing accidents that would orphan vTokens here.
33
+ *
34
+ * **Written-off debt stays in the mark.** {totalAssets} values the position at the market's own
35
+ * exchange rate, as `AdapterCoreV1` does. That rate keeps `badDebt` in its numerator, so a
36
+ * write-off in `healAccount` does not lower it. The written-off amount is not cash, so the
37
+ * market cannot pay every supplier in full until its `shortfall` calls `badDebtRecovered`,
38
+ * which lowers `badDebt` and raises cash by the same amount without moving the rate. Until
39
+ * then, suppliers who exit first are paid at the full rate out of the cash that remains, and
40
+ * {maxWithdraw} is bounded by that cash.
41
+ *
42
+ * **Why this is not `AdapterCoreV1`.** The two speak nearly the same selectors and disagree on
43
+ * the numbers below. Four differences, each verified against `isolated-pools`:
44
+ *
45
+ * - **Liquidity is cash NET of reserves**, and both it and the position are floored to a whole
46
+ * number of vTokens, because {withdraw} redeems by vToken COUNT rather than by underlying
47
+ * amount. Naming the burn instead of letting `redeemUnderlying` derive it is what keeps a
48
+ * certified amount inside the position's own balance: the derived burn rounds UP and is
49
+ * subtracted in checked arithmetic, so an amount one unit too large panics rather than
50
+ * failing gracefully.
51
+ * - **No exit fee to gross up.** Isolated pools have no `treasuryPercent`; their cut is the
52
+ * reserve factor, already netted out of the exchange rate. There is nothing unmodeled to
53
+ * reject at registration, so {validateRegistration} guards the market listing and the supply
54
+ * allowlist instead.
55
+ * - **The supply cap has an uncapped sentinel**, `type(uint256).max` (see
56
+ * {ISpokeComptroller-supplyCaps}).
57
+ * - **Mutating calls carry no error code to check.** `mint`, `redeem` and `accrueInterest`
58
+ * return `NO_ERROR` or revert; the Compound-style codes `AdapterCoreV1` inspects are set by
59
+ * the legacy Core pool alone.
60
+ *
61
+ * **The supply allowlist and caller identity.** A spoke market can restrict minting to
62
+ * allowlisted accounts, and the account it checks is the one CREDITED with the vTokens. Under
63
+ * delegatecall that is the YieldGroup. {maxDeposit} and {validateRegistration} take no `holder`,
64
+ * and both are invoked by the YieldGroup as a plain call, so `msg.sender` IS the prospective
65
+ * supplier and reading the allowlist against it is exact rather than a convention. This matters
66
+ * operationally: a YieldGroup whose grant is revoked reports zero room and is routed around,
67
+ * instead of advertising capacity that every deposit then reverts on.
68
+ *
69
+ * Fee-on-transfer underlyings are unsupported, matching the Hub. The market itself tolerates
70
+ * them (it mints against the measured delta) but {deposit} reports the requested amount, so the
71
+ * YieldGroup's accounting would drift.
72
+ */
73
+ contract AdapterSpokeV1 is IResourceAdapter {
74
+ using SafeERC20 for IERC20;
75
+
76
+ // ============================== State (immutable only) ====================
77
+
78
+ /// @notice This adapter's own address, captured at deployment. Used by {onlyDelegateCall} to
79
+ /// detect direct invocation. Immutables live in bytecode, not storage.
80
+ address private immutable _ADAPTER_SELF;
81
+
82
+ // ============================== State (constants) =========================
83
+
84
+ /// @notice Fixed-point scale matching Compound's mantissa (`1e18`).
85
+ uint256 public constant EXP_SCALE = 1e18;
86
+
87
+ /// @notice Deposit room reported for a market whose supply cap is the isolated-pools "uncapped"
88
+ /// sentinel (`type(uint256).max`).
89
+ /// @dev Finite on purpose. `YieldGroupBase.maxDeposit()` SUMS the room of every queued resource
90
+ /// in checked arithmetic, so reporting `type(uint256).max` would overflow that sum the
91
+ /// moment a second uncapped market joined the queue — the YieldGroup's whole capacity view
92
+ /// would revert and the Hub would read it as zero capacity with an immovable balance. At
93
+ /// `2^128 - 1` the sum cannot overflow for any realistic queue, while the value is still
94
+ /// unreachable for any real token (~3.4e20 whole units at 18 decimals). Mirrors the Flux
95
+ /// family, where Fluid reports `int128.max` for the same reason. The binding controls on an
96
+ /// uncapped market are the YieldGroup's per-resource cap and the Hub's dual cap, not this.
97
+ uint256 public constant UNCAPPED_DEPOSIT_ROOM = type(uint128).max;
98
+
99
+ /// @notice Scaling factor between mantissa-rate (`1e18`) and BPS (`1e4`).
100
+ uint256 internal constant MANTISSA_TO_BPS = 1e14;
101
+
102
+ /// @notice `Action.MINT` in the isolated-pools Comptroller enum.
103
+ uint8 internal constant ACTION_MINT = 0;
104
+
105
+ /// @notice `Action.REDEEM` in the isolated-pools Comptroller enum.
106
+ uint8 internal constant ACTION_REDEEM = 1;
107
+
108
+ /// @notice Fraction of raw supply-cap headroom withheld to absorb interest accrued between a
109
+ /// `maxDeposit` read and the mint it sizes (`1/1000` = 0.1%).
110
+ uint256 internal constant CAP_TRIM_DIVISOR = 1000;
111
+
112
+ // ============================== Errors ===================================
113
+
114
+ /// @notice A mutating function was invoked directly (not via delegatecall).
115
+ error NotDelegateCall();
116
+
117
+ /**
118
+ * @notice A redeem succeeded but delivered less than the requested amount. Indicates a
119
+ * fee-on-transfer underlying or a market bug — an isolated-pools redeem otherwise
120
+ * delivers at least what was asked for.
121
+ * @param resource Resource that under-delivered.
122
+ * @param requested Underlying units expected.
123
+ * @param actual Underlying units actually received.
124
+ */
125
+ error VTokenUnderfilled(address resource, uint256 requested, uint256 actual);
126
+
127
+ /**
128
+ * @notice The deposit is too small to mint a single vToken, so the market would keep the
129
+ * underlying and issue nothing for it.
130
+ * @dev Reverting hands the leg back to the YieldGroup's deposit cascade, which routes around it;
131
+ * minting zero would strand the amount silently. Reachable when the Hub cascade leaves this
132
+ * market a remainder under one vToken unit (about 1e10 wei for an 18-decimal underlying).
133
+ * @param resource Market that would have minted zero.
134
+ * @param amount Underlying units offered.
135
+ * @param minimum Underlying units needed to mint one vToken.
136
+ */
137
+ error DepositBelowOneVToken(address resource, uint256 amount, uint256 minimum);
138
+
139
+ /**
140
+ * @notice The market's supply allowlist is enabled and the prospective supplier is not on it.
141
+ * @param resource Market whose allowlist rejected the supplier.
142
+ * @param supplier Account that would be credited with the vTokens (the YieldGroup).
143
+ */
144
+ error SupplyNotAllowed(address resource, address supplier);
145
+
146
+ /**
147
+ * @notice The market is not listed by the Comptroller it names, so it can never be minted.
148
+ * @param resource Market that its own Comptroller does not list.
149
+ */
150
+ error MarketNotListed(address resource);
151
+
152
+ // ============================== Modifiers ================================
153
+
154
+ /// @notice Reverts {NotDelegateCall} when the call is not arriving via delegatecall.
155
+ /// @dev During a delegatecall, `address(this)` is the caller's address; during a direct call it
156
+ /// equals `_ADAPTER_SELF` (the address captured at construction).
157
+ modifier onlyDelegateCall() {
158
+ if (address(this) == _ADAPTER_SELF) revert NotDelegateCall();
159
+ _;
160
+ }
161
+
162
+ // ============================== Constructor ==============================
163
+
164
+ /// @notice Captures the adapter's own address into an immutable for {onlyDelegateCall}.
165
+ constructor() {
166
+ _ADAPTER_SELF = address(this);
167
+ }
168
+
169
+ // ============================== External — mutating =======================
170
+
171
+ /// @inheritdoc IResourceAdapter
172
+ /// @dev `address(this)` here is the YieldGroup (delegatecall context), which already holds
173
+ /// `amount` of underlying. We approve the market and mint; delegatecall preserves the caller
174
+ /// context, so the market sees the YieldGroup as `msg.sender` and credits the vTokens there.
175
+ /// That is also the account its supply allowlist checks.
176
+ function deposit(address resource, uint256 amount) external override onlyDelegateCall returns (uint256 deposited) {
177
+ uint256 minimum = _oneVTokenUnit(IVTokenIsolated(resource).exchangeRateStored());
178
+ if (amount < minimum) revert DepositBelowOneVToken(resource, amount, minimum);
179
+
180
+ IERC20 assetToken = IERC20(IVTokenIsolated(resource).underlying());
181
+ assetToken.forceApprove(resource, amount);
182
+ IVTokenIsolated(resource).mint(amount);
183
+ return amount;
184
+ }
185
+
186
+ /// @inheritdoc IResourceAdapter
187
+ /// @dev `address(this)` here is the YieldGroup, so the redeem burns its vTokens and credits the
188
+ /// underlying back to it.
189
+ ///
190
+ /// Denominated in vTokens, not in underlying. `redeemUnderlying` DERIVES the burn from the
191
+ /// request by rounding up, and that derived count is not bounded by the caller's balance —
192
+ /// `_redeemFresh` subtracts it in checked arithmetic and panics when it overshoots. Naming
193
+ /// the burn removes the derivation: {_burnFor} is the fewest vTokens worth at least
194
+ /// `amount`, and {maxWithdraw} certifies nothing the position cannot cover that many times
195
+ /// over.
196
+ ///
197
+ /// The payout is `truncate(exchangeRate x tokens)`, so it is `>= amount` and exceeds it by
198
+ /// up to one vToken unit. We forward exactly `amount` and leave the surplus as idle on the
199
+ /// YieldGroup, where `totalAssets` counts it and the next withdrawal consumes it
200
+ /// idle-first — the same treatment `AdapterCoreV1` gives its gross-up surplus, and what
201
+ /// keeps the YieldGroup's `received == requested` invariant true.
202
+ ///
203
+ /// Burns the whole balance instead when the vTokens left after the burn would be worth 0
204
+ /// underlying and the market's payable cash covers the full redeem. Below a rate of `1e18`
205
+ /// a few vTokens can be worth less than one unit, and the market refuses to redeem them on
206
+ /// their own, so a drain would otherwise leave them stuck. The extra payout is at most one
207
+ /// unit and stays idle like the surplus above.
208
+ function withdraw(address resource, uint256 amount, address to) external override onlyDelegateCall {
209
+ IERC20 assetToken = IERC20(IVTokenIsolated(resource).underlying());
210
+
211
+ // Settle first so the rate and cash read below are the ones the redeem will use.
212
+ IVTokenIsolated(resource).accrueInterest();
213
+ uint256 exchangeRate = IVTokenIsolated(resource).exchangeRateStored();
214
+ uint256 tokens = _burnFor(exchangeRate, amount);
215
+ uint256 vBal = IVTokenIsolated(resource).balanceOf(address(this));
216
+ if (tokens < vBal && ((vBal - tokens) * exchangeRate) / EXP_SCALE == 0) {
217
+ uint256 payableCash = IVTokenIsolated(resource).getCash() - IVTokenIsolated(resource).totalReserves();
218
+ if ((vBal * exchangeRate) / EXP_SCALE <= payableCash) tokens = vBal;
219
+ }
220
+
221
+ uint256 preBal = assetToken.balanceOf(address(this));
222
+ IVTokenIsolated(resource).redeem(tokens);
223
+ uint256 received = assetToken.balanceOf(address(this)) - preBal;
224
+ if (received < amount) revert VTokenUnderfilled(resource, amount, received);
225
+
226
+ assetToken.safeTransfer(to, amount);
227
+ }
228
+
229
+ /// @inheritdoc IResourceAdapter
230
+ /// @dev Plain CALL, no `onlyDelegateCall`: `accrueInterest` mutates the market's own global
231
+ /// index, not this caller's position, so it is safe (and cheaper) to run in the adapter's
232
+ /// context. After it returns, every view below is fresh to the current block or timestamp.
233
+ function accrue(address resource) external override {
234
+ IVTokenIsolated(resource).accrueInterest();
235
+ }
236
+
237
+ // ============================== External — view ==========================
238
+
239
+ /// @inheritdoc IResourceAdapter
240
+ function asset(address resource) external view override returns (address) {
241
+ return IVTokenIsolated(resource).underlying();
242
+ }
243
+
244
+ /// @inheritdoc IResourceAdapter
245
+ /// @dev `balance x exchangeRateStored`: the market's own valuation of the tokens, and what
246
+ /// redeeming all of them would pay at the stored rate if the market had the cash. Includes
247
+ /// the position's share of `badDebt`; see the contract NatSpec.
248
+ function totalAssets(address resource, address holder) external view override returns (uint256) {
249
+ uint256 vBal = IVTokenIsolated(resource).balanceOf(holder);
250
+ if (vBal == 0) return 0;
251
+ return (vBal * IVTokenIsolated(resource).exchangeRateStored()) / EXP_SCALE;
252
+ }
253
+
254
+ /// @inheritdoc IResourceAdapter
255
+ /// @dev Honors, in order: the market's supply allowlist (against `msg.sender`, the prospective
256
+ /// supplier — see the contract NatSpec), its MINT pause, and its supply cap. The headroom
257
+ /// math mirrors `preMintHook`'s `nextTotalSupply = totalSupply x exchangeRate + mintAmount`
258
+ /// check. Room below one vToken unit is reported as zero, because a deposit of it would
259
+ /// mint nothing.
260
+ function maxDeposit(address resource) external view override returns (uint256) {
261
+ ISpokeComptroller comptrollerContract = ISpokeComptroller(IVTokenIsolated(resource).comptroller());
262
+ if (
263
+ comptrollerContract.isSupplyAllowlistEnabled(resource)
264
+ && !comptrollerContract.isAllowedSupplier(resource, msg.sender)
265
+ ) {
266
+ return 0;
267
+ }
268
+ if (comptrollerContract.actionPaused(resource, ACTION_MINT)) return 0;
269
+
270
+ uint256 supplyCap = comptrollerContract.supplyCaps(resource);
271
+ // A cap of zero is a real cap here, not an "unset" sentinel: `preMintHook` rejects every
272
+ // non-zero mint against it. The uncapped sentinel is `type(uint256).max`.
273
+ if (supplyCap == 0) return 0;
274
+ if (supplyCap == type(uint256).max) return UNCAPPED_DEPOSIT_ROOM;
275
+
276
+ uint256 exchangeRate = IVTokenIsolated(resource).exchangeRateStored();
277
+ uint256 supplied = (IVTokenIsolated(resource).totalSupply() * exchangeRate) / EXP_SCALE;
278
+ if (supplied >= supplyCap) return 0;
279
+
280
+ uint256 room = supplyCap - supplied;
281
+ // `exchangeRateStored` is pre-accrual, but the market re-checks the cap at mint AFTER
282
+ // `accrueInterest` raises the rate, so the raw headroom is slightly optimistic on an
283
+ // interest-accruing near-cap market. Withhold a small conservative margin so a normal accrual
284
+ // between this view and execution cannot push `deposit(maxDeposit())` over the cap. The
285
+ // routing cascade is the backstop for larger staleness (an over-cap leg is skipped, not
286
+ // bubbled), so this only needs to absorb a typical inter-slot accrual.
287
+ room -= room / CAP_TRIM_DIVISOR;
288
+ return room < _oneVTokenUnit(exchangeRate) ? 0 : room;
289
+ }
290
+
291
+ /// @inheritdoc IResourceAdapter
292
+ /// @dev Two constraints: the position's value, and the market's payable cash
293
+ /// (`getCash - totalReserves` — reserves sit inside cash but are not redeemable, and
294
+ /// `_redeemFresh` gates on the difference). Subtracting reserves also makes this figure
295
+ /// invariant across the reserve sweep `accrueInterest` performs, which lowers cash and
296
+ /// reserves by the same amount.
297
+ ///
298
+ /// Both are expressed as a whole number of vTokens and valued back into underlying, because
299
+ /// {withdraw} redeems by count: an amount at or below `truncate(exchangeRate x t)` is
300
+ /// coverable by `t` tokens, so flooring at `t` is what makes the bound executable rather
301
+ /// than merely arithmetically true. The position side is `truncate(exchangeRate x vBal)`,
302
+ /// already floored at the tokens held; the cash side is floored to the tokens the market
303
+ /// can pay for. Flooring is exact rather than conservative — no margin is withheld — so the
304
+ /// last vToken stays withdrawable, which is what lets a market be drained to zero and
305
+ /// deregistered.
306
+ ///
307
+ /// Reads stored state, so the figure is exact only for a caller that has already settled
308
+ /// this market's interest in the same transaction — the Hub's routing paths all do, via
309
+ /// {accrue}. Against an unaccrued market it is optimistic by the reserves the next accrual
310
+ /// will book, since those come out of the payable cash.
311
+ function maxWithdraw(address resource, address holder) external view override returns (uint256) {
312
+ ISpokeComptroller comptrollerContract = ISpokeComptroller(IVTokenIsolated(resource).comptroller());
313
+ if (comptrollerContract.actionPaused(resource, ACTION_REDEEM)) return 0;
314
+
315
+ uint256 vBal = IVTokenIsolated(resource).balanceOf(holder);
316
+ if (vBal == 0) return 0;
317
+
318
+ uint256 exchangeRate = IVTokenIsolated(resource).exchangeRateStored();
319
+ if (exchangeRate == 0) return 0;
320
+
321
+ uint256 ourValue = (vBal * exchangeRate) / EXP_SCALE;
322
+ if (ourValue == 0) return 0;
323
+
324
+ uint256 cash = IVTokenIsolated(resource).getCash();
325
+ uint256 reserves = IVTokenIsolated(resource).totalReserves();
326
+ if (cash <= reserves) return 0;
327
+
328
+ uint256 payableTokens = ((cash - reserves) * EXP_SCALE) / exchangeRate;
329
+ uint256 cashBound = (payableTokens * exchangeRate) / EXP_SCALE;
330
+
331
+ return ourValue < cashBound ? ourValue : cashBound;
332
+ }
333
+
334
+ /// @inheritdoc IResourceAdapter
335
+ /// @dev The `blocksPerYear` parameter is IGNORED. An isolated-pools market carries its own
336
+ /// annualiser as an immutable and can be block-based or time-based, so a YieldGroup-level
337
+ /// constant would misprice whichever kind it was not configured for; the market's own value
338
+ /// always matches the unit of its own rate. Deploy the spoke YieldGroup with
339
+ /// `blocksPerYear = 0`, as the Flux family already does.
340
+ ///
341
+ /// An emptied market reports 0 rather than reverting: `JumpRateModelV2.getSupplyRate`
342
+ /// divides by `cash + borrows + badDebt - reserves` with no zero guard. `PoolLens` guards
343
+ /// the same read.
344
+ function spotAPYBps(
345
+ address resource,
346
+ uint256 /* blocksPerYear */
347
+ )
348
+ external
349
+ view
350
+ override
351
+ returns (uint64)
352
+ {
353
+ if (IVTokenIsolated(resource).totalSupply() == 0) return 0;
354
+
355
+ uint256 annualised =
356
+ (IVTokenIsolated(resource).supplyRatePerBlock() * IVTokenIsolated(resource).blocksOrSecondsPerYear())
357
+ / MANTISSA_TO_BPS;
358
+ // forge-lint: disable-next-line(unsafe-typecast)
359
+ return annualised > type(uint64).max ? type(uint64).max : uint64(annualised);
360
+ }
361
+
362
+ /// @inheritdoc IResourceAdapter
363
+ function receiptBalance(address resource, address holder) external view override returns (uint256) {
364
+ return IVTokenIsolated(resource).balanceOf(holder);
365
+ }
366
+
367
+ /// @inheritdoc IResourceAdapter
368
+ function resourceName(address resource) external view override returns (string memory) {
369
+ return IERC20Metadata(resource).name();
370
+ }
371
+
372
+ /// @inheritdoc IResourceAdapter
373
+ /// @dev Rejects the two configurations `preMintHook` would reject on every deposit, either of
374
+ /// which would leave the resource holding a queue slot it can never fill: a market its own
375
+ /// Comptroller does not list, and a market whose supply allowlist is enabled without the
376
+ /// registering YieldGroup on it. `msg.sender` is the YieldGroup (see the contract NatSpec),
377
+ /// which is the account the market's allowlist gates.
378
+ ///
379
+ /// The listing check is also what ties `resource` to the Comptroller it names: any contract
380
+ /// can return a real `SpokeComptroller` from `comptroller()`, but only a market that
381
+ /// Comptroller actually lists passes here. The allowlist accessors then pin the Comptroller
382
+ /// to the spoke fork, since they exist nowhere else and the call reverts against any other
383
+ /// Comptroller. Nothing else is asserted. In particular an unset `deviationBoundedOracle`
384
+ /// is NOT grounds for rejection: it blocks borrowing, and so the market's yield, but leaves
385
+ /// the Hub's own paths intact — `preMintHook` never reads a price, and `preRedeemHook`
386
+ /// returns before the priced liquidity check for an account that is not a market member,
387
+ /// which a supply-only YieldGroup never becomes.
388
+ function validateRegistration(address resource) external view override {
389
+ ISpokeComptroller comptrollerContract = ISpokeComptroller(IVTokenIsolated(resource).comptroller());
390
+ if (!comptrollerContract.isMarketListed(resource)) revert MarketNotListed(resource);
391
+ if (
392
+ comptrollerContract.isSupplyAllowlistEnabled(resource)
393
+ && !comptrollerContract.isAllowedSupplier(resource, msg.sender)
394
+ ) {
395
+ revert SupplyNotAllowed(resource, msg.sender);
396
+ }
397
+ }
398
+
399
+ // ============================== Private — pure ===========================
400
+
401
+ /**
402
+ * @notice Underlying value of exactly one vToken, rounded up.
403
+ * @param exchangeRate Market's stored exchange rate, scaled by `1e18`.
404
+ * @return unit `ceil(exchangeRate / 1e18)`.
405
+ */
406
+ function _oneVTokenUnit(uint256 exchangeRate) private pure returns (uint256 unit) {
407
+ return (exchangeRate + EXP_SCALE - 1) / EXP_SCALE;
408
+ }
409
+
410
+ /**
411
+ * @notice Fewest vTokens worth at least `amount` of underlying.
412
+ * @dev `ceil(amount x 1e18 / exchangeRate)`. A token-denominated redeem pays
413
+ * `truncate(exchangeRate x tokens)`, and this ceiling is the smallest count whose payout
414
+ * reaches `amount` — so {withdraw} never under-delivers, and never burns more than
415
+ * {maxWithdraw} certified the position and the market's cash can cover.
416
+ *
417
+ * Non-increasing in the exchange rate. The market accrues before redeeming and that rate
418
+ * only ever rises, so a count sized here stays inside the balance at execution and its
419
+ * payout stays at or above `amount`.
420
+ * @param exchangeRate Market's stored exchange rate, scaled by `1e18`.
421
+ * @param amount Underlying units the caller intends to redeem.
422
+ * @return tokens vToken units to burn.
423
+ */
424
+ function _burnFor(uint256 exchangeRate, uint256 amount) private pure returns (uint256 tokens) {
425
+ if (exchangeRate == 0) return 0;
426
+ return (amount * EXP_SCALE + exchangeRate - 1) / exchangeRate;
427
+ }
428
+ }
@@ -129,6 +129,12 @@ interface IHub is IERC4626Upgradeable {
129
129
  /// @param isDeposit True if the skipped leg was a deposit, false for a withdraw
130
130
  event YieldGroupSkipped(address indexed yieldGroup, bool isDeposit);
131
131
 
132
+ /// @notice Emitted when an emergency reallocation could not settle a YieldGroup's resources because the
133
+ /// group's own `accrue()` reverted. The wind-down continues over the remaining groups rather
134
+ /// than bubbling the revert; a healthy system emits this zero times.
135
+ /// @param yieldGroup YieldGroup that could not be settled
136
+ event YieldGroupAccrualFailed(address indexed yieldGroup);
137
+
132
138
  /// @notice Emitted when the management fee rate is changed; pending fees accrued at OLD rate first
133
139
  /// @param oldBps Previous rate in BPS
134
140
  /// @param newBps New rate in BPS
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ /**
5
+ * @title IHubLens
6
+ * @author Venus
7
+ * @notice Read-only view surface for a {Hub}: the numbers the Hub itself no longer computes.
8
+ * @dev The Hub's own `maxDeposit` / `maxMint` return `type(uint256).max`, because computing the
9
+ * real ceiling on every `deposit` / `mint` puts the whole cap solve on the hot path. Caps are
10
+ * still enforced — `Hub._routeDeposit` reverts with `HubCapacityExceeded` on anything the
11
+ * cascade cannot place. This interface is how a frontend or an integrator sizes a deposit so
12
+ * that revert never happens.
13
+ *
14
+ * Every function takes the Hub as a parameter, so one deployment serves every Hub.
15
+ *
16
+ * Fault behaviour follows the Hub's own split. A YieldGroup whose quantity views revert
17
+ * contributes 0 and the rest of the figure still comes back; a YieldGroup whose
18
+ * `totalAssets()` reverts halts every function here, because `Hub.totalAssets()` halts first
19
+ * and a figure priced against a wrong NAV is worse than none.
20
+ */
21
+ interface IHubLens {
22
+ /**
23
+ * @notice Asset units `hub` would accept in a single deposit right now.
24
+ * @dev 0 while the Hub is paused. Priced at the TVL the deposit itself produces, because
25
+ * `Hub._routeDeposit` prices the percentage caps there too. Each YieldGroup's room is
26
+ * trimmed by 0.01% of its mark, covering the interest the deposit settles before it prices
27
+ * caps; an accrual past that margin reverts rather than over-filling a cap.
28
+ * @param hub Hub to measure.
29
+ * @return assets Asset units that could be deposited right now.
30
+ */
31
+ function maxDeposit(address hub) external view returns (uint256 assets);
32
+
33
+ /**
34
+ * @notice Shares `hub` would mint for {maxDeposit}.
35
+ * @dev Carries the same 0.01% margin twice over: once through {maxDeposit}, and once on the
36
+ * shares themselves, because the interest the deposit settles also raises the price the
37
+ * mint is charged at.
38
+ * @param hub Hub to measure.
39
+ * @return shares `hub.convertToShares(maxDeposit(hub))`, less that margin.
40
+ */
41
+ function maxMint(address hub) external view returns (uint256 shares);
42
+
43
+ /**
44
+ * @notice {maxDeposit} split by the YieldGroup that would receive it.
45
+ * @dev Where a maximum-sized deposit would land, in cascade order, so `Σ room == maxDeposit`.
46
+ * Not "how much could this YieldGroup take on its own": a YieldGroup late in the deposit
47
+ * queue reads 0 whenever the ones ahead of it absorb everything. Both arrays are empty
48
+ * while the Hub is paused.
49
+ * @param hub Hub to measure.
50
+ * @return groups Routable YieldGroups, in deposit-queue order. Paused, unregistered and
51
+ * view-reverting YieldGroups are left out, as is one registered but not queued for
52
+ * deposits, which {spotAPYBps} still counts.
53
+ * @return room Each YieldGroup's share of the ceiling, aligned index-for-index with `groups`.
54
+ */
55
+ function depositCapacityBreakdown(address hub)
56
+ external
57
+ view
58
+ returns (address[] memory groups, uint256[] memory room);
59
+
60
+ /**
61
+ * @notice Blended spot APY across everything `hub` holds, in BPS.
62
+ * @dev A weighted average, not a sum: each YieldGroup's `spotAPYBps()` weighted by the part of
63
+ * it that is invested, over the Hub's whole `totalAssets()`. Idle assets therefore count
64
+ * at 0% rather than being dropped, whether they sit on the Hub or on a YieldGroup, so the
65
+ * figure is what an LP actually earns. A YieldGroup whose views revert is skipped,
66
+ * contributing 0.
67
+ * @param hub Hub to measure.
68
+ * @return apyBps Blended APY in BPS, 0 when the Hub holds nothing.
69
+ */
70
+ function spotAPYBps(address hub) external view returns (uint64 apyBps);
71
+ }
@@ -60,9 +60,11 @@ interface IResourceAdapter {
60
60
  * Post-conditions:
61
61
  * - `to` receives exactly `amount` of underlying.
62
62
  * - Some adapters redeem slightly more than `amount` and leave the excess as idle on
63
- * the YieldGroup (e.g. AdapterCoreV1's treasury-fee gross-up and its sub-one-vToken
64
- * dust bump). Such surplus is counted in `totalAssets` and consumed idle-first on the
65
- * next call. Fee-free adapters (Flux, FRV) leave no surplus.
63
+ * the YieldGroup (AdapterCoreV1's treasury-fee gross-up and its sub-one-vToken dust
64
+ * bump; AdapterSpokeV1 on every redeem, because an isolated-pools market rounds its
65
+ * burn up and then recomputes the payout from the rounded-up token count). Such
66
+ * surplus is counted in `totalAssets` and consumed idle-first on the next call.
67
+ * Fee-free adapters (Flux, FRV) leave no surplus.
66
68
  * @param resource Underlying yield protocol address.
67
69
  * @param amount Net underlying units to deliver to `to`.
68
70
  * @param to Recipient of the underlying.
@@ -75,8 +77,9 @@ interface IResourceAdapter {
75
77
  * one-cycle-stale one.
76
78
  * @dev Invoked via normal CALL (not delegatecall): it mutates the resource's own global
77
79
  * interest state, not the holder's position, so receipt tokens never move. Only
78
- * block-lazy adapters do real work here — Core pokes the vToken's `accrueInterest`;
79
- * Flux and FRV implement it as a no-op (their NAV is not a block-lazy exchange rate).
80
+ * block-lazy adapters do real work here — Core and Spoke poke their vToken's
81
+ * `accrueInterest`; Flux and FRV implement it as a no-op (their NAV is not a block-lazy
82
+ * exchange rate).
80
83
  * @param resource Underlying yield protocol address to settle interest on.
81
84
  */
82
85
  function accrue(address resource) external;
@@ -122,7 +125,8 @@ interface IResourceAdapter {
122
125
  * @notice Spot supply-side APY for `resource` in BPS.
123
126
  * @dev `blocksPerYear` is consumed only by block-rate adapters (e.g. Core annualises a
124
127
  * per-block supply rate with it). Adapters whose yieldGroup is already annualised — Flux
125
- * (Fluid APR) and FRV (time-based fixed APY) — ignore the parameter.
128
+ * (Fluid APR) and FRV (time-based fixed APY) — ignore the parameter, as does Spoke, whose
129
+ * markets each carry their own annualiser and may be block- or time-based.
126
130
  * @param resource Underlying yield protocol address.
127
131
  * @param blocksPerYear Per-chain block cadence (chain-dependent; ~10_512_000 on BNB Chain);
128
132
  * used only by block-rate adapters, ignored by the rest.
@@ -142,12 +146,24 @@ interface IResourceAdapter {
142
146
  */
143
147
  function receiptBalance(address resource, address holder) external view returns (uint256 shares);
144
148
 
149
+ /**
150
+ * @notice Human-readable name of `resource`, for display by off-chain consumers.
151
+ * @dev Not every resource is an ERC-20 with its own `name()` (e.g. an ERC-7540 vault keeps it on
152
+ * its share token), so each adapter reads it from wherever its protocol exposes it.
153
+ * @param resource Underlying yield protocol address.
154
+ * @return name Display name of the resource.
155
+ */
156
+ function resourceName(address resource) external view returns (string memory name);
157
+
145
158
  /**
146
159
  * @notice Revert if `resource` fails a protocol-specific precondition for registration.
147
160
  * @dev Called by `YieldGroup.addResource`. AdapterCoreV1 rejects a vToken whose Comptroller
148
161
  * charges a non-zero `treasuryPercent` — that exit fee leaves the pool on every redeem
149
162
  * without burning shares, so it is unmodeled in the Hub's gross NAV and must not be
150
- * registered. FRV and Flux have no per-redeem pool fee and implement this as a no-op.
163
+ * registered. AdapterSpokeV1 rejects a market whose supply allowlist is enabled without
164
+ * the registering YieldGroup on it, since every deposit would revert; that call also
165
+ * proves the market belongs to a spoke pool, because no other Comptroller exposes the
166
+ * allowlist. FRV and Flux have no per-redeem pool fee and implement this as a no-op.
151
167
  * @param resource Underlying yield protocol address being registered.
152
168
  */
153
169
  function validateRegistration(address resource) external view;
@@ -455,7 +455,12 @@ interface IYieldGroupBase {
455
455
  * lifecycle clock (FRV, whose vaults exit `Fundraising` by time) can report stale
456
456
  * non-zero capacity from this view until a state-advancing call refreshes it; the
457
457
  * honesty contract holds against current state, not a time-due future one.
458
- * @return capacity Spare deposit capacity in asset units.
458
+ * @dev Venue room only — NOT the Hub's cap on this YieldGroup, which the Hub applies itself in
459
+ * both places that matter. Do not clamp this to the Hub's cap: a YieldGroup can only read
460
+ * it at the Hub's current TVL, but a deposit is sized against the TVL it will produce, so
461
+ * clamping makes a percentage-capped Hub un-bootstrappable — at TVL 0 every percentage cap
462
+ * is 0. Pinned by `tests/foundry/Integration/PercentageCapCapacity.t.sol`.
463
+ * @return capacity Spare deposit capacity in asset units, before the Hub's own cap.
459
464
  */
460
465
  function maxDeposit() external view returns (uint256 capacity);
461
466