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

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
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Venus Liquidity Hub
2
2
 
3
- Per-asset ERC-4626 allocator vault. A lender deposits a single asset, the Hub routes it across Venus yield families (Core, Flux, FRV) under a governance-set policy, and returns a yield-bearing share token. Yield accrues through a **rising exchange rate** (one share = X underlying), never by rebasing — one Hub per asset, no cross-asset coupling.
3
+ Per-asset ERC-4626 allocator vault. A lender deposits a single asset, the Hub routes it across Venus yield families (Core, Flux, FRV, Spoke) under a governance-set policy, and returns a yield-bearing share token. Yield accrues through a **rising exchange rate** (one share = X underlying), never by rebasing — one Hub per asset, no cross-asset coupling.
4
4
 
5
- > **Scope.** v1 ships on BNB Chain with three yield families — **Core**, **Flux**, **FRV**. Isolated Pools and RWA (Ondo / BUIDL) are documented future YieldGroups, not v1. The fee machinery is fully implemented but launches at `0/0`.
5
+ > **Scope.** v1 ships on BNB Chain with three yield families — **Core**, **Flux**, **FRV**. **Spoke** (the liquidity side of hub-funded isolated pools) is built and follows once its markets are listed; RWA (Ondo / BUIDL) is a documented future YieldGroup. The fee machinery is fully implemented but launches at `0/0`.
6
6
 
7
7
  ---
8
8
 
@@ -25,53 +25,60 @@ flowchart TB
25
25
 
26
26
  Hub["Hub_USDT (ERC-4626, beacon proxy)<br/>dual caps · fees · per-tx cap"]:::hub
27
27
 
28
- Hub --> OuterQ["outer queue (per direction)<br/>[YieldGroup_Core, YieldGroup_Flux, YieldGroup_FRV]"]:::queue
28
+ Hub --> OuterQ["outer queue (per direction)<br/>[YieldGroup_Core, YieldGroup_Flux, YieldGroup_FRV, YieldGroup_Spoke]"]:::queue
29
29
 
30
30
  OuterQ --> CoreYG["YieldGroup_Core_USDT"]:::src
31
31
  OuterQ --> FluxYG["YieldGroup_Flux_USDT"]:::src
32
32
  OuterQ --> FrvYG["YieldGroup_FRV_USDT"]:::src
33
+ OuterQ --> SpokeYG["YieldGroup_Spoke_USDT"]:::src
33
34
 
34
35
  CoreYG --> CoreInner["inner queue [vUSDT_v1, ...]"]:::queue
35
36
  FluxYG --> FluxInner["inner queue [fUSDT, ...]"]:::queue
36
37
  FrvYG --> FrvInner["inner queue [FRV_A, FRV_B, ...]"]:::queue
38
+ SpokeYG --> SpokeInner["inner queue [vUSDT_poolA, vUSDT_poolB, ...]"]:::queue
37
39
 
38
40
  CoreInner -->|"delegatecall"| ACore["AdapterCoreV1<br/>(stateless singleton)"]:::adapter
39
41
  FluxInner -->|"delegatecall"| AFlux["AdapterFlux<br/>(stateless singleton)"]:::adapter
40
42
  FrvInner -->|"delegatecall"| AFrv["AdapterFRV<br/>(stateless singleton)"]:::adapter
43
+ SpokeInner -->|"delegatecall"| ASpoke["AdapterSpokeV1<br/>(stateless singleton)"]:::adapter
41
44
 
42
45
  ACore --> RCore["vUSDT (Venus Core vToken)"]:::resource
43
46
  AFlux --> RFlux["fUSDT (Fluid fToken)"]:::resource
44
47
  AFrv --> RFrv["FRV vault (fixed-rate, lifecycle)"]:::resource
48
+ ASpoke --> RSpoke["vUSDT (hub-funded spoke pool,<br/>liquidity side)"]:::resource
45
49
  ```
46
50
 
47
51
  ### Key contracts
48
52
 
49
- | Contract | Role |
50
- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
- | **Hub** | ERC-4626 entry point (beacon proxy). Routes deposits/withdrawals through the outer queue of YieldGroups, manages dual caps, the per-tx withdrawal cap, management + performance fees, and multi-level pause. Registry/queue logic is delegatecall-linked into **HubAdminLib** to stay under the 24 KB bytecode limit. |
52
- | **YieldGroupCore** | `IYieldGroupBase` over Venus **Core-pool vTokens** (`mint` / `redeemUnderlying`). Optional per-resource deposit caps. Grosses up redeems for a Comptroller treasury fee if one is ever enabled. |
53
- | **YieldGroupFlux** | `IYieldGroupBase` over **Fluid Lending fTokens** (ERC-4626 shares). No redeem-time fee. Per-resource caps are the primary deposit control (Fluid's protocol `maxDeposit` is effectively unbounded). |
54
- | **YieldGroupFRV** | `IYieldGroupBase` over Venus **Fixed-Rate Vaults** — ERC-4626 with an 11-state lifecycle. Pokes `updateVaultState()` before every routing decision; deposits only while Fundraising, withdrawals only when terminal. |
55
- | **AdapterCoreV1 / AdapterFlux / AdapterFRV** | Stateless singletons — zero storage, only immutables. One deployment per ABI family serves every YieldGroup. Mutating calls run via **delegatecall** so receipt tokens land on the YieldGroup; direct calls revert (`onlyDelegateCall`). |
56
- | **Migrator** | Immutable, permissionless periphery (no proxy/beacon), one shared deployment per chain. One-click migration of a Venus Core position into a Hub: pulls the caller's vToken, redeems it to the underlying, deposits into the matching Hub, and mints shares to the caller — atomic, with a `minShares` slippage guard. A separate `migrateFromCoreBNB` path wraps a redeemed vBNB position to WBNB before depositing into the WBNB Hub. |
57
- | **IYieldGroupBase** | Hub↔YieldGroup boundary. `deposit` / `withdraw` (queue) + `depositResource` / `withdrawResource` (targeted, reallocate-only) + `totalAssets` / `maxDeposit` / `maxWithdraw` / `spotAPYBps`. |
58
- | **IResourceAdapter** | YieldGroup↔adapter boundary. Mutating fns delegatecalled; view fns take an explicit `holder`. `validateRegistration` rejects resources with an unmodeled per-redeem fee. |
53
+ | Contract | Role |
54
+ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | **Hub** | ERC-4626 entry point (beacon proxy). Routes deposits/withdrawals through the outer queue of YieldGroups, manages dual caps, the per-tx withdrawal cap, management + performance fees, and multi-level pause. Registry/queue logic is delegatecall-linked into **HubAdminLib** to stay under the 24 KB bytecode limit. Its `maxDeposit` / `maxMint` return `type(uint256).max`; caps are enforced by the routing cascade and the real capacity figure lives on **HubLens**. |
56
+ | **YieldGroupCore** | `IYieldGroupBase` over Venus **Core-pool vTokens** (`mint` / `redeemUnderlying`). Optional per-resource deposit caps. Grosses up redeems for a Comptroller treasury fee if one is ever enabled. |
57
+ | **YieldGroupFlux** | `IYieldGroupBase` over **Fluid Lending fTokens** (ERC-4626 shares). No redeem-time fee. Per-resource caps are the primary deposit control (Fluid's protocol `maxDeposit` is effectively unbounded). |
58
+ | **YieldGroupFRV** | `IYieldGroupBase` over Venus **Fixed-Rate Vaults** — ERC-4626 with an 11-state lifecycle. Pokes `updateVaultState()` before every routing decision; deposits only while Fundraising, withdrawals only when terminal. |
59
+ | **HubLens** | Stateless, immutable, unowned read surface — no storage, no proxy, no registration. Takes the Hub as a call parameter, so one deployment serves every Hub. Exposes the deposit-capacity solve the Hub no longer runs (`maxDeposit`, `maxMint`, `depositCapacityBreakdown`) plus the Hub-level blended APY (`spotAPYBps`). Frontends and integrators size deposits from here. |
60
+ | **AdapterCoreV1 / AdapterFlux / AdapterFRV** | Stateless singletons — zero storage, only immutables. One deployment per ABI family serves every YieldGroup. Mutating calls run via **delegatecall** so receipt tokens land on the YieldGroup; direct calls revert (`onlyDelegateCall`). |
61
+ | **Migrator** | Immutable, permissionless periphery (no proxy/beacon), one shared deployment per chain. One-click migration of a Venus Core position into a Hub: pulls the caller's vToken, redeems it to the underlying, deposits into the matching Hub, and mints shares to the caller — atomic, with a `minShares` slippage guard. A separate `migrateFromCoreBNB` path wraps a redeemed vBNB position to WBNB before depositing into the WBNB Hub. |
62
+ | **IYieldGroupBase** | Hub↔YieldGroup boundary. `deposit` / `withdraw` (queue) + `depositResource` / `withdrawResource` (targeted, reallocate-only) + `totalAssets` / `maxDeposit` / `maxWithdraw` / `spotAPYBps`. |
63
+ | **IResourceAdapter** | YieldGroup↔adapter boundary. Mutating fns delegatecalled; view fns take an explicit `holder`. `validateRegistration` rejects resources with an unmodeled per-redeem fee. |
59
64
 
60
65
  ---
61
66
 
62
67
  > **Terminology.** A _YieldGroup_ is the grouping layer: the Hub depends only on the `IYieldGroupBase` interface and never reaches past a YieldGroup into the underlying market. A _Resource_ is one underlying market or vault held inside a YieldGroup.
63
68
 
64
- ## The three yield families
69
+ ## The four yield families
65
70
 
66
- | | **Core** | **Flux** | **FRV** |
67
- | ------------------------ | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------ |
68
- | Underlying protocol | Venus Core lending | Fluid Lending | Venus Fixed-Rate Vaults |
69
- | Resource / receipt token | vToken (Compound-style) | fToken (ERC-4626 share) | FRV vault share (ERC-4626) |
70
- | Deposit / withdraw call | `mint` / `redeemUnderlying` | `deposit` / `withdraw(assets)` | `deposit` / `withdraw(assets)` |
71
- | Redeem-time pool fee | only if Comptroller `treasuryPercent` is later enabled (grossed-up); registration blocked while non-zero | none (Fluid's cut is in the supply rate) | none (reserve factor taken once at settlement) |
72
- | Spot APY source | `supplyRatePerBlock` × `blocksPerYear` | Fluid `LendingResolver` (pre-annualised APR) | vault `fixedAPY` (only while Fundraising / Lock) |
73
- | Per-resource deposit cap | optional | optional (primary control) | not applicable (capacity comes from the vault) |
74
- | Lifecycle constraint | none | none | 11-state machine (see below) |
71
+ | | **Core** | **Flux** | **FRV** | **Spoke** |
72
+ | ------------------------ | -------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------- |
73
+ | Underlying protocol | Venus Core lending | Fluid Lending | Venus Fixed-Rate Vaults | Venus isolated pools (hub-funded spoke) |
74
+ | Resource / receipt token | vToken (Compound-style) | fToken (ERC-4626 share) | FRV vault share (ERC-4626) | vToken (isolated-pools) |
75
+ | Deposit / withdraw call | `mint` / `redeemUnderlying` | `deposit` / `withdraw(assets)` | `deposit` / `withdraw(assets)` | `mint` / `redeemUnderlying` |
76
+ | Redeem-time pool fee | only if Comptroller `treasuryPercent` is later enabled (grossed-up); registration blocked while non-zero | none (Fluid's cut is in the supply rate) | none (reserve factor taken once at settlement) | none (the cut is the reserve factor, already inside the rate) |
77
+ | Spot APY source | `supplyRatePerBlock` × `blocksPerYear` | Fluid `LendingResolver` (pre-annualised APR) | vault `fixedAPY` (only while Fundraising / Lock) | `supplyRatePerBlock` × the **market's own** `blocksOrSecondsPerYear` |
78
+ | Per-resource deposit cap | optional | optional (primary control) | not applicable (capacity comes from the vault) | optional (per spoke pool, alongside the pool's own supply cap) |
79
+ | Who may supply | anyone | anyone | anyone | **allowlisted only** — the pool grants the YieldGroup address |
80
+ | Valuation basis | balance × exchange rate | `previewRedeem` | lifecycle-aware linear accrual | balance × exchange rate (includes `badDebt`) |
81
+ | Lifecycle constraint | none | none | 11-state machine (see below) | none |
75
82
 
76
83
  ## Deposit flow
77
84
 
@@ -191,10 +198,11 @@ Hub.totalAssets()
191
198
  = Σ adapter.totalAssets(resource, yieldGroup) + YieldGroup idle balance
192
199
  ```
193
200
 
194
- - **Fails closed.** `totalAssets()` is the share-price denominator, so a YieldGroup whose `totalAssets()` view reverts is **not** isolated here — it halts share math rather than silently dropping that YieldGroup's funds from NAV (which would understate the price, under-pay redeemers and over-credit depositors until the value reappeared). The _quantity gates_ (`maxDeposit` / `maxWithdraw`) and the deposit/withdraw routing **do** isolate a faulting YieldGroup (contribute 0 / skip) because failing conservatively there is safe — only on the price would it corrupt the conversion. A view-level revert is a YieldGroup-level bug (resource-level reverts are isolated inside the YieldGroup, so external pauses surface in `withdraw`/`deposit`, not here); recover via the runbook below.
201
+ - **Fails closed.** `totalAssets()` is the share-price denominator, so a YieldGroup whose `totalAssets()` view reverts is **not** isolated here — it halts share math rather than silently dropping that YieldGroup's funds from NAV (which would understate the price, under-pay redeemers and over-credit depositors until the value reappeared). The _quantity views_ (`maxWithdraw`, and `HubLens`'s capacity figure) and the deposit/withdraw routing **do** isolate a faulting YieldGroup (contribute 0 / skip) because failing conservatively there is safe — only on the price would it corrupt the conversion. A view-level revert is a YieldGroup-level bug (resource-level reverts are isolated inside the YieldGroup, so external pauses surface in `withdraw`/`deposit`, not here); recover via the runbook below.
195
202
  - Hub idle is normally zero (deposits route out atomically, withdrawals pull exact amounts, reallocate is balanced). A direct token donation persists and is intentionally counted into NAV (it accrues to LPs and is consumed first on withdraw) — the underlying cannot be swept.
196
203
  - YieldGroup idle accumulates from treasury-fee gross-up surplus and dust handling on past withdrawals; it is counted and consumed idle-first.
197
204
  - View paths use the **stored** exchange rate (`exchangeRateStored`, stale up to one accrual cycle, no state mutation). Mutating paths trigger interest accrual on the resource during mint/redeem, so the rate is current by the time the operation completes.
205
+ - **Spoke marks at the market's own exchange rate, and that rate includes written-off debt.** An isolated-pools `VToken` keeps written-off debt inside its rate: `healAccount` moves the shortfall from `totalBorrows` into `badDebt`, leaving the numerator unchanged, so the rate does not fall when a loss is booked. `AdapterSpokeV1` values a position at that same rate, as `AdapterCoreV1` does. The written-off amount is therefore cash the market does not have, not a lower share price, and while it is outstanding LPs who exit first are paid in full out of the cash that is left. The market's `shortfall` pays the debt down through `badDebtRecovered`, which lowers `badDebt` and raises cash by the same amount: more can be withdrawn and the share price does not move.
198
206
 
199
207
  ### Recovering a bricked YieldGroup (governance)
200
208
 
@@ -253,7 +261,60 @@ effectiveCap = (percentageCapBps == 10_000)
253
261
  : min(absoluteCap, percentageCapBps × hubTVL / 10_000)
254
262
  ```
255
263
 
256
- `percentageCapBps == 10_000` (100%) is a sentinel that **disables** the percentage component — required so a fresh Hub at TVL = 0 can take its first deposit (otherwise `pct × 0` collapses the cap to zero). `absoluteCap == type(uint256).max` is rejected; use the sentinel to disable the percentage dimension instead.
264
+ `percentageCapBps == 10_000` (100%) is a sentinel that **disables** the percentage component, leaving only the absolute cap at every TVL. `absoluteCap == type(uint256).max` is rejected; use the sentinel to disable the percentage dimension instead.
265
+
266
+ **Enforcement is the routing cascade, not a quantity gate.** The Hub does not override `maxDeposit` / `maxMint`, so both inherit OpenZeppelin's bodies and return `type(uint256).max`. Solving the caps means solving every YieldGroup's dual cap against the TVL the deposit itself produces, and ERC-4626 calls those views on every `deposit` and every `mint` — that solve does not belong on the hot path. Instead `_routeDeposit` reverts with `HubCapacityExceeded(assets, placed)` on anything the cascade cannot place, which also names how much would have fit.
267
+
268
+ **The percentage cap is priced at the post-deposit TVL.** Incoming assets sit idle on the Hub for the whole cascade, so they are already inside `totalAssets()` when each `effectiveCap` is computed — a deposit lifts the very caps it is measured against. Capacity therefore cannot be priced at today's TVL. `HubLens.maxDeposit` solves for the largest TVL that every cap simultaneously admits and reports the distance to it: a book of Core `20,000 / 90%` and FRV `8,000 / 30%` tops out at `28,000`, and an empty Hub reports exactly that, not the `0` that pricing both caps at TVL = 0 produces. A frontend or integrator reads that figure before sizing a deposit; nothing on-chain requires it.
269
+
270
+ **Solving for the reachable TVL.** Let `X` be the post-deposit TVL. For each unpaused deposit-queue YieldGroup `i`, write `bal_i` for what it currently holds and
271
+
272
+ ```
273
+ absBound_i = min( absoluteCap_i , bal_i + yieldGroup_i.maxDeposit() )
274
+ ```
275
+
276
+ for the most it could ever hold — its absolute cap, further bounded by what its venue will accept on top of that balance. At TVL `X` it holds
277
+
278
+ ```
279
+ hold_i(X) = max( bal_i , min( absBound_i , pct_i × X / 10_000 ) )
280
+ ```
281
+
282
+ The inner `min` is the dual cap. The outer `max` floors it at what the YieldGroup already holds, since a deposit cannot make it give money back. Writing `C` for the assets that are not free to move — Hub idle, paused YieldGroups, YieldGroups outside the deposit queue, and any whose capacity views revert — the reachable TVL is the largest `X` satisfying
283
+
284
+ ```
285
+ X ≤ C + Σ hold_i(X)
286
+ ```
287
+
288
+ and `HubLens.maxDeposit` returns `X − totalAssets()`.
289
+
290
+ Each `hold_i` is flat, then sloped, then flat, with a breakpoint at each turn:
291
+
292
+ ```
293
+ hold_i
294
+ ^
295
+ | +----------- absBound_i absolute bound binds
296
+ | /
297
+ | / percentage cap binds, slope pct_i
298
+ | +------------+ bal_i balance floor binds
299
+ +--------+------------+--------------> TVL (X)
300
+ breakLow_i breakHigh_i
301
+ ```
302
+
303
+ `breakLow_i` is the TVL at which the percentage cap catches up with the balance, `breakHigh_i` the TVL at which it reaches `absBound_i`.
304
+
305
+ Summing over the queue gives at most `2n` breakpoints. Between two consecutive ones no YieldGroup changes which bound it sits on, so the sum is `base + slope × X / 10_000` there and the segment crosses in closed form:
306
+
307
+ ```
308
+ X = base × 10_000 / (10_000 − slope)
309
+ ```
310
+
311
+ `HubCapacityMath` collects the breakpoints, sorts them, and keeps the largest crossing that lands inside its own segment. A segment whose `slope ≥ 10_000` has no crossing at all: its right-hand side grows at least as fast as `X`, so every TVL in it satisfies the inequality and the answer there is the segment's top edge, not a fixed point. That edge is snapped down to a multiple of `10_000`, where every percentage cap divides exactly and the cascade's per-YieldGroup flooring sheds nothing. Only a segment with no finite top edge, the last one, is passed over.
312
+
313
+ Membership is decided on the segment's own crossing, before any adjustment. Charging an adjustment first would push a crossing that lands exactly on a segment boundary just below it and discard the segment that holds the answer.
314
+
315
+ The crossing is then checked against the room the cascade would really leave at that TVL, and returned unchanged when it fits, which is the usual path. Only when it does not fit is the flooring pre-paid: the closed form floors once where the cascade floors each cap separately, so one wei is taken off `base` for each of the `k` YieldGroups the percentage cap binds on that segment. That pre-payment lowers the reported figure by `k × 10_000 / (10_000 − slope)` wei.
316
+
317
+ The figure promises only that depositing it succeeds, not that every smaller amount does. In a book with no absolute-only YieldGroup a dust deposit still reverts, because every `pctBps × TVL / 10_000` floors to zero while TVL is tiny.
257
318
 
258
319
  **Resource level (YieldGroup) — optional, Core & Flux only.** Each resource may carry an optional absolute deposit cap limiting how much underlying _this YieldGroup_ holds in that one market, independent of the market's own protocol supply cap:
259
320
 
@@ -263,7 +324,7 @@ room = min( protocolHeadroom, resourceCap − ourBalanceInResource )
263
324
 
264
325
  - `resourceCap == 0` means **unset/unbounded** — only the protocol cap binds. When unset, the balance lookup is skipped, so the feature is zero-cost when unused. (`raiseResourceCap` to 0 removes a cap; `lowerResourceCap` to 0 is rejected.)
265
326
  - The protocol cap is **global/shared** across all depositors; the resource cap is purely about _this YieldGroup's_ position, so actual room can be below `resourceCap` if others have filled the market.
266
- - Enforced everywhere deposits land: the inner deposit cascade, `maxDeposit()`, and resource-targeted reallocate pushes.
327
+ - Enforced everywhere deposits land: the inner deposit cascade, `YieldGroup.maxDeposit()`, and resource-targeted reallocate pushes.
267
328
  - **YieldGroupFRV has no per-resource cap** — FRV capacity comes entirely from the vault's own `maxBorrowCap` and its Fundraising-only rule.
268
329
 
269
330
  **Per-transaction withdrawal cap (Hub).** `maxWithdrawalSize` (asset units) bounds every single withdraw/redeem; exceeding it reverts `HubWithdrawCapExceeded`.
@@ -305,11 +366,12 @@ contracts/
305
366
  core/ YieldGroupCore + Storage, Adapters/AdapterCoreV1
306
367
  flux/ YieldGroupFlux + Storage, Adapters/AdapterFlux
307
368
  FRV/ YieldGroupFRV + Storage, Adapters/AdapterFRV
369
+ lens/ HubLens.sol + HubCapacityMath.sol (off-chain read surface, no storage)
308
370
  migrator/ Migrator.sol (immutable Core→Hub one-click migration)
309
371
  registry/ HubRegistry.sol (chain-level Hub directory, discovery root)
310
- interfaces/ IYieldGroupBase, IResourceAdapter, IHub, IHubRegistry, IYieldGroup*, IMigrator, IVToken, IVBep20, IWBNB, IFToken, IFRVVault, ...
311
- deploy/ Foundry deploy scripts 01–07 + DeployBase + config/<network>.json
312
- tests/foundry/ unit (Hub/, YieldGroup/{core,flux,frv}/, Registry/), Integration/, fork/
372
+ interfaces/ IYieldGroupBase, IResourceAdapter, IHub, IHubLens, IHubRegistry, IYieldGroup*, IMigrator, IVToken, IVBep20, IWBNB, IFToken, IFRVVault, ...
373
+ deploy/ Foundry deploy scripts 01–14 + DeployBase + config/<network>.json
374
+ tests/foundry/ unit (Hub/, YieldGroup/{core,flux,frv}/, Registry/, lens/), Integration/, fork/
313
375
  deployments/<network>/ committed registry of deployed addresses (source of truth)
314
376
  lib/ git-submodule dependencies
315
377
  ```
@@ -348,6 +410,16 @@ npm i @venusprotocol/liquidity-hub@develop # prerelease, cut from develop
348
410
  import { IHub } from "@venusprotocol/liquidity-hub/contracts/interfaces/IHub.sol";
349
411
  ```
350
412
 
413
+ **Sizing a deposit.** `Hub.maxDeposit` / `Hub.maxMint` return `type(uint256).max` — the Hub does not compute a ceiling, because doing so would run the cap solve on every deposit. That holds while the Hub is paused too, so anything that sizes off them reverts on deposit. Read the real figure from `HubLens` instead:
414
+
415
+ ```solidity
416
+ import { IHubLens } from "@venusprotocol/liquidity-hub/contracts/interfaces/IHubLens.sol";
417
+
418
+ uint256 room = IHubLens(lens).maxDeposit(hub); // 0 while the Hub is paused
419
+ ```
420
+
421
+ `HubLens` is stateless and takes the Hub as a parameter, so one deployment serves every Hub on the chain; its address is in `deployments/<network>_addresses.json`. It also exposes `maxMint`, `depositCapacityBreakdown` (the same ceiling split by receiving YieldGroup) and `spotAPYBps` (the Hub's blended yield, weighted by balance, with idle counted at 0%). Depositing more than `maxDeposit` reverts `HubCapacityExceeded(assets, placed)`, which names how much would have fit.
422
+
351
423
  **Addresses and ABIs.** `deployments/<network>_addresses.json` is a name → address map; each `deployments/<network>/<Name>.json` carries `address`, `block`, `chainId`, `contract` and the deployed `abi`.
352
424
 
353
425
  ```ts
@@ -357,13 +429,18 @@ import hub from "@venusprotocol/liquidity-hub/deployments/bscmainnet/Hub_USDT.js
357
429
 
358
430
  ## Deployment
359
431
 
360
- Beacon-proxy model: one `UpgradeableBeacon` **per family per chain** (Hub, Core, FRV, Flux), each owned by governance — `beacon.upgradeTo` upgrades every vault of that family atomically. Per-asset instances are `BeaconProxy`s.
432
+ Beacon-proxy model: one `UpgradeableBeacon` **per family per chain** (Hub, Core, FRV, Flux, Spoke), each owned by governance — `beacon.upgradeTo` upgrades every vault of that family atomically. Per-asset instances are `BeaconProxy`s.
361
433
 
362
434
  ```
363
435
  01_DeployHubBeacon 02_DeployCore 03_DeployFRV 04_DeployFlux # once per chain (impl + adapter + beacon)
364
436
  05_DeployHubRegistry # once per chain (upgradeable singleton, discovery root)
365
437
  06_DeployAssetVault # per asset, idempotent
366
438
  07_DeployMigrator # once per chain (immutable, permissionless periphery)
439
+ 10_DeployHubLens # once per chain, before 12 (stateless read surface; re-run to ship a new version)
440
+ 11_DeployAdapters # upgrade only: new Core / Flux / FRV / Centrifuge / Spoke adapters
441
+ 12_DeployHubImpl # upgrade only: new Hub impl behind the existing HubBeacon
442
+ 13_DeploySpoke # once per chain (Spoke adapter + impl + beacon)
443
+ 14_DeploySpokeSource # per asset, backfills existing Hubs
367
444
  ```
368
445
 
369
446
  Scripts run in numeric order: beacons first, then the registry, then per-asset instances against both. Scripts only deploy + initialize — **ACM wiring, `addYieldGroup` / `addResource`, and queue configuration are governance (ACM-gated) actions done separately.**
@@ -374,6 +451,10 @@ Script 06 deploys a Hub proxy per asset (YieldGroups read `IHub.asset()` and rev
374
451
 
375
452
  Script 07 deploys the immutable **Migrator** (no beacon, no proxy); it is permissionless and needs **no** ACM wiring — a user calls it directly after approving their vToken.
376
453
 
454
+ Scripts 13 and 14 add the **Spoke** family. They are split from 02–04/06 because script 06 skips any asset whose Hub already exists, so a Hub deployed before this family could never receive a Spoke source through it; 14 is keyed on the source instead and therefore backfills existing Hubs and covers new ones alike. The listing VIP must list the market and call `setAllowedSupplier(vToken, <SpokeSource>, true)` on each spoke pool **before** `addResource` — `AdapterSpokeV1.validateRegistration` rejects the registration otherwise, which is deliberate: a market that will not accept this source would otherwise occupy a queue slot it can never fill.
455
+
456
+ Scripts 11 and 12 ship new code to a chain whose families are already deployed, and are not part of onboarding. The family scripts do not fit that case: 01, 02 and 03 also deploy a new beacon and overwrite its record, 04 and 13 skip an adapter that is already recorded, and 08_DeployCentrifuge also deploys a YieldGroup implementation. Script 11 deploys only the five adapters; the YieldGroups keep the old adapter until governance calls `updateResourceAdapter(resource, adapter)` for each resource. Script 12 deploys only a Hub implementation and requires the `HubBeacon` record; the Hubs move to it when governance calls `HubBeacon.upgradeTo(impl)`. Both overwrite their registry records, so a dry run (no `--broadcast`) leaves `deployments/` modified; restore it with `git checkout deployments/`.
457
+
377
458
  ### Hub onboarding flow (deploy + one-tx registration VIP)
378
459
 
379
460
  Onboarding an asset: the deployer EOA runs the scripts in numeric order (no ACM roles needed), then **one** governance proposal — a single hop — registers and wires the Hub atomically.
@@ -26,6 +26,10 @@ import { HubAdminLib } from "./lib/HubAdminLib.sol";
26
26
  * Inheritance order is load-bearing for upgrade safety — do not reorder.
27
27
  * Function ordering follows the Solidity style guide: external → public → internal →
28
28
  * private, mutating before view within each group.
29
+ * Capacity: `maxDeposit` / `maxMint` return `type(uint256).max`, paused or not, and do not
30
+ * reflect the caps. Size deposits and mints from `HubLens.maxDeposit` / `HubLens.maxMint`,
31
+ * which return the real figure and 0 while paused. The caps themselves are enforced by the
32
+ * deposit routing, which reverts `HubCapacityExceeded` on anything it cannot place.
29
33
  * {HubAdminLib} is linked for EIP-170 headroom; a library declares no state, so it cannot shift this layout.
30
34
  * @custom:oz-upgrades-unsafe-allow external-library-linking
31
35
  */
@@ -819,9 +823,9 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
819
823
  * donation. It is folded into NAV (the underlying cannot be swept) so it accrues to LPs and
820
824
  * is consumed first on withdraw.
821
825
  *
822
- * Fails closed. A YieldGroup whose `totalAssets()` reverts is NOT isolated here — unlike the
823
- * quantity-gate views (`maxDeposit` / `maxWithdraw`) and the deposit / withdraw routing,
824
- * which deliberately skip a faulting YieldGroup. The difference is the safety class: NAV is the
826
+ * Fails closed. A YieldGroup whose `totalAssets()` reverts is NOT isolated here — unlike
827
+ * `maxWithdraw` and the deposit / withdraw routing, which deliberately skip a faulting
828
+ * YieldGroup. The difference is the safety class: NAV is the
825
829
  * share-price denominator that every mint and redeem converts against, so silently counting
826
830
  * a YieldGroup as 0 would understate the price — under-paying redeemers and over-crediting
827
831
  * depositors until its value reappeared. Failing to 0 is safe for a quantity gate (it only
@@ -850,16 +854,29 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
850
854
  total += IERC20Upgradeable(asset()).balanceOf(address(this));
851
855
  }
852
856
 
853
- /// @inheritdoc IERC4626Upgradeable
854
- function maxDeposit(address) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
855
- if (_hubPaused) return 0;
856
- return _aggregateDepositCapacity(totalAssets());
857
- }
858
-
859
- /// @inheritdoc IERC4626Upgradeable
860
- function maxMint(address receiver) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
861
- return convertToShares(maxDeposit(receiver));
862
- }
857
+ // `maxDeposit` and `maxMint` are deliberately NOT overridden, so both inherit OpenZeppelin's
858
+ // bodies and return `type(uint256).max`, including while the Hub is paused (the override this
859
+ // replaced returned 0 then; deposits still revert, via `whenNotHubPaused`). Computing the real
860
+ // ceiling means solving every YieldGroup's dual cap against the TVL the deposit itself
861
+ // produces, and `deposit` / `mint` call these views on every single call — that solve does not
862
+ // belong on the hot path.
863
+ //
864
+ // Caps are still enforced, by `_routeDeposit`: whatever the cascade cannot place reverts with
865
+ // `HubCapacityExceeded(assets, placed)`, which also names how much would have fit. Restoring an
866
+ // override here would put the solve back on the hot path without tightening anything.
867
+ //
868
+ // The real figures live on `HubLens` (`maxDeposit`, `maxMint`, `depositCapacityBreakdown`),
869
+ // which is what a frontend or an integrator reads to size a deposit.
870
+ //
871
+ // What is inherited, kept here so the removal reads as deliberate rather than an oversight:
872
+ //
873
+ // function maxDeposit(address) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
874
+ // return type(uint256).max;
875
+ // }
876
+ //
877
+ // function maxMint(address) public view override(ERC4626Upgradeable, IERC4626Upgradeable) returns (uint256) {
878
+ // return type(uint256).max;
879
+ // }
863
880
 
864
881
  /**
865
882
  * @inheritdoc IERC4626Upgradeable
@@ -1038,10 +1055,11 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
1038
1055
  // getting taxed as performance on the next recovery past it. The delta form shields the
1039
1056
  // retained fee whether PPS sits above or below the mark, and stays monotonic (delta >= 0:
1040
1057
  // the fee-on burn always destroys at least as much share-value as the assets routed out).
1041
- // Genuine gains stay taxable: the increment moves the mark by exactly what the fee moved
1042
- // PPS, preserving the (hwm - pps) gap, so any yield PPS earned on top of the uplift —
1043
- // including yield landed intra-block after the caller's accrual — is still charged at the
1044
- // next accrual. No-op when the exit fee is 0 (a proportional burn leaves PPS unchanged).
1058
+ // Genuine gains stay taxable: the increment preserves the (hwm - pps) gap, so any yield
1059
+ // PPS earned on top of the uplift, including yield landed intra-block after the caller's
1060
+ // accrual, is still charged at the next accrual. `prePps` is taken before `_routeWithdraw`,
1061
+ // so a resource that redeems above its reported value has that surplus shielded too.
1062
+ // No-op when the exit fee is 0 (a proportional burn leaves PPS unchanged).
1045
1063
  if (redeemBps != 0) {
1046
1064
  uint256 ts = totalSupply();
1047
1065
  if (ts != 0) {
@@ -1202,10 +1220,10 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
1202
1220
  * @dev When `percentageCapBps == BPS_DENOMINATOR` (100%), the percentage cap is treated
1203
1221
  * as disabled and only the absolute cap binds. Without this carve-out, every Hub
1204
1222
  * with any percentage-cap value is permanently un-bootstrappable: at TA = 0 the
1205
- * percentage component collapses to 0, `maxDeposit()` returns 0, and OZ's
1206
- * ERC-4626 wrapper rejects the very first deposit. The carve-out matches the
1223
+ * percentage component collapses to 0, so the cascade can place nothing and
1224
+ * `_routeDeposit` reverts on the very first deposit. The carve-out matches the
1207
1225
  * MetaMorpho convention and preserves dual-cap semantics for `pctBps < 10_000`.
1208
- * @dev Cap basis: every cap check (deposit cascade, {maxDeposit}, and {reallocate}) compares this
1226
+ * @dev Cap basis: every cap check (deposit cascade and {reallocate}, plus `HubLens`) compares this
1209
1227
  * ceiling against the YieldGroup's *current marked* `totalAssets()`, not the principal
1210
1228
  * deposited into it. Because a group's `totalAssets()` accrues over time, its cap headroom
1211
1229
  * shrinks even with no new deposit — most visibly for FRV, whose Hub-facing mark accrues the
@@ -1216,52 +1234,16 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
1216
1234
  * headroom for the coupon expected over the lock.
1217
1235
  * @param cfg YieldGroup config to evaluate.
1218
1236
  * @param ta TVL reference used for the percentage-cap component.
1219
- * @return cap `pctBps == 10_000` ? `absoluteCap` : `min(absoluteCap, pctBps × ta / 10_000)`.
1237
+ * @return cap `pctBps == 10_000` ? `absoluteCap` : `min(absoluteCap, ⌈pctBps × ta / 10_000⌉)`.
1220
1238
  */
1221
1239
  function _effectiveCap(IHub.YieldGroupConfig storage cfg, uint256 ta) internal view returns (uint256 cap) {
1222
1240
  if (cfg.percentageCapBps == BPS_DENOMINATOR) return cfg.absoluteCap;
1223
- uint256 pctCap = (ta * cfg.percentageCapBps) / BPS_DENOMINATOR;
1241
+ // Rounded up: percentage caps summing to 100% leave no slack, so a floored cap would leave
1242
+ // the room a wei short of the deposit that set it.
1243
+ uint256 pctCap = (ta * cfg.percentageCapBps + BPS_DENOMINATOR - 1) / BPS_DENOMINATOR;
1224
1244
  cap = pctCap < cfg.absoluteCap ? pctCap : cfg.absoluteCap;
1225
1245
  }
1226
1246
 
1227
- /**
1228
- * @notice Sum spare deposit capacity across the unpaused deposit-queue YieldGroups.
1229
- * @param taSnapshot TVL reference used for percentage-cap math on each YieldGroup.
1230
- * @return total Aggregate asset units that could be deposited right now.
1231
- */
1232
- function _aggregateDepositCapacity(uint256 taSnapshot) internal view returns (uint256 total) {
1233
- address[] storage queue = _outerDepositQueue;
1234
- uint256 len = queue.length;
1235
- for (uint256 i; i < len; ++i) {
1236
- address yieldGroup = queue[i];
1237
- IHub.YieldGroupConfig storage cfg = _yieldGroups[yieldGroup];
1238
- if (cfg.registered && !cfg.paused) {
1239
- // Isolate a misbehaving YieldGroup: a revert in either capacity view contributes 0
1240
- // spare cap instead of bricking every deposit. Mirrors `totalAssets()`.
1241
- uint256 currentBal;
1242
- uint256 yieldGroupMax;
1243
- bool ok = true;
1244
- try IYieldGroupBase(yieldGroup).totalAssets() returns (uint256 v) {
1245
- currentBal = v;
1246
- } catch {
1247
- ok = false;
1248
- }
1249
- if (ok) {
1250
- try IYieldGroupBase(yieldGroup).maxDeposit() returns (uint256 v) {
1251
- yieldGroupMax = v;
1252
- } catch {
1253
- ok = false;
1254
- }
1255
- }
1256
- if (ok) {
1257
- uint256 cap = _effectiveCap(cfg, taSnapshot);
1258
- uint256 capRoom = cap > currentBal ? cap - currentBal : 0;
1259
- total += capRoom < yieldGroupMax ? capRoom : yieldGroupMax;
1260
- }
1261
- }
1262
- }
1263
- }
1264
-
1265
1247
  /**
1266
1248
  * @notice Sum liquid funds across the unpaused withdraw-queue YieldGroups, plus Hub idle.
1267
1249
  * @dev Hub idle is first-class liquidity: `_routeWithdraw` consumes it before touching any
@@ -1292,11 +1274,35 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
1292
1274
 
1293
1275
  // ============================== Private — mutating =======================
1294
1276
 
1277
+ /**
1278
+ * @notice Fail-open resource settlement used ONLY by the paused emergency path: a YieldGroup whose
1279
+ * `accrue()` reverts is skipped instead of bricking the whole call.
1280
+ * @dev Pairs with {_totalAssetsFailOpen}, the cap reference the same paused branch reads: a YieldGroup
1281
+ * that has stopped answering the Hub is what {emergencyReallocate} exists to unwind, so it must
1282
+ * not block the wind-down of the YieldGroups that still work. A skipped YieldGroup only leaves its
1283
+ * resources unsettled, and {_reallocatePullAll} still requires the delivered amount to equal the
1284
+ * leg, so an under-settled market reverts that leg. Share pricing keeps the strict
1285
+ * {_accrueResources} via {_accrueFees}.
1286
+ */
1287
+ function _accrueResourcesFailOpen() private {
1288
+ address[] storage yieldGroups = _registeredYieldGroups;
1289
+ uint256 len = yieldGroups.length;
1290
+ for (uint256 i; i < len; ++i) {
1291
+ // solhint-disable-next-line no-empty-blocks
1292
+ try IYieldGroupBase(yieldGroups[i]).accrue() { }
1293
+ catch {
1294
+ emit YieldGroupAccrualFailed(yieldGroups[i]);
1295
+ }
1296
+ }
1297
+ }
1298
+
1295
1299
  /**
1296
1300
  * @notice Shared net-zero reallocation core for {reallocate} and {emergencyReallocate}.
1297
- * @dev Accrues fees first so the operation trades against current state — except while
1298
- * the Hub is paused, when accrual is skipped so the pause window is excluded from fee
1299
- * proration ({unpauseHub} later advances the accrual cursor past the frozen window).
1301
+ * @dev Accrues fees first so the operation trades against current state. A pause skips only the
1302
+ * FEE half, so the frozen window stays out of fee proration ({unpauseHub} later advances
1303
+ * the cursor past it). Resource settlement still runs — adapters size withdrawals off
1304
+ * stored resource state — and charges nothing, moving neither the HWM nor the cursor. Under
1305
+ * the freeze that settlement is fail-open, matching the NAV read it feeds.
1300
1306
  * Enforces `Σ withdraws == Σ deposits`.
1301
1307
  * @param withdraws Withdraw legs to execute first.
1302
1308
  * @param deposits Deposit legs to execute against the post-pull TVL snapshot.
@@ -1304,6 +1310,8 @@ contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeab
1304
1310
  function _reallocate(IHub.ReallocateLeg[] calldata withdraws, IHub.ReallocateLeg[] calldata deposits) private {
1305
1311
  if (!_hubPaused) {
1306
1312
  _accrueFees();
1313
+ } else {
1314
+ _accrueResourcesFailOpen();
1307
1315
  }
1308
1316
 
1309
1317
  uint256 withdrawSum = _reallocatePullAll(withdraws);
@@ -290,6 +290,11 @@ contract AdapterCentrifuge is IAdapterCentrifuge {
290
290
  + inv.claimableCancelRedeemRequest;
291
291
  }
292
292
 
293
+ /// @inheritdoc IResourceAdapter
294
+ function resourceName(address resource) external view override returns (string memory) {
295
+ return IERC20Metadata(ICentrifugeAsyncVault(resource).share()).name();
296
+ }
297
+
293
298
  /// @inheritdoc IResourceAdapter
294
299
  /// @dev Verifies the vault exposes the share token, request manager, investment state, and price
295
300
  /// reads required by this adapter.
@@ -2,6 +2,7 @@
2
2
  pragma solidity 0.8.25;
3
3
 
4
4
  import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
5
6
  import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
7
 
7
8
  import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
@@ -248,6 +249,11 @@ contract AdapterCoreV1 is IResourceAdapter {
248
249
  return IVToken(resource).balanceOf(holder);
249
250
  }
250
251
 
252
+ /// @inheritdoc IResourceAdapter
253
+ function resourceName(address resource) external view override returns (string memory) {
254
+ return IERC20Metadata(resource).name();
255
+ }
256
+
251
257
  /// @inheritdoc IResourceAdapter
252
258
  /// @dev Rejects a market whose Comptroller charges a non-zero `treasuryPercent`: that exit fee
253
259
  /// is unmodeled in the Hub's gross NAV (see the treasury-fee note above). The withdraw
@@ -2,6 +2,7 @@
2
2
  pragma solidity 0.8.25;
3
3
 
4
4
  import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
5
6
  import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
7
  import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
7
8
 
@@ -148,12 +149,17 @@ contract AdapterFRV is IResourceAdapter {
148
149
  }
149
150
 
150
151
  /// @inheritdoc IResourceAdapter
151
- /// @dev Returns the vault's `fixedAPY` (already annualised in BPS) only while the vault is
152
- /// actually earning — `Fundraising` or `Lock`. In any other state the capital earns
153
- /// nothing (matured/idle awaiting redemption, failed, liquidated, or pre-fundraising),
154
- /// so report 0 rather than the contractual rate; otherwise dead capital would inflate
155
- /// the YieldGroup's TVL-weighted spot APY. The `blocksPerYear` parameter is unused since FRV
156
- /// yields are time-based, not block-based.
152
+ /// @dev Returns the vault's `fixedAPY` (already annualised in BPS) net of `reserveFactor`, only
153
+ /// while the vault is actually earning — `Fundraising` or `Lock`. In any other state the
154
+ /// capital earns nothing (matured/idle awaiting redemption, failed, liquidated, or
155
+ /// pre-fundraising), so report 0 rather than the contractual rate; otherwise dead capital
156
+ /// would inflate the YieldGroup's TVL-weighted spot APY. The `blocksPerYear` parameter is
157
+ /// unused since FRV yields are time-based, not block-based.
158
+ ///
159
+ /// `fixedAPY` is the rate the institution pays; the vault keeps `reserveFactor` of the
160
+ /// interest for the protocol at settlement, so a supplier earns the remainder. This view is
161
+ /// defined as the supply-side rate, so it reports that remainder, netted the same way
162
+ /// {_accruedTotalAssets} nets the value it marks: floor the cut, then subtract it.
157
163
  function spotAPYBps(
158
164
  address resource,
159
165
  uint256 /* blocksPerYear */
@@ -168,8 +174,17 @@ contract AdapterFRV is IResourceAdapter {
168
174
  return 0;
169
175
  }
170
176
  IFRVVault.FRVVaultConfig memory cfg = IFRVVault(resource).config();
177
+
178
+ // Saturate before netting, not after: a misreporting vault can carry a `fixedAPY` large
179
+ // enough that `fixedAPY * reserveFactor` overflows, and this view must not revert. The
180
+ // YieldGroup sums it across every resource, so one bad vault would take the whole reading
181
+ // down. Below `uint64.max` the clamp is a no-op and the arithmetic is exact.
182
+ uint256 grossApy = cfg.fixedAPY > type(uint64).max ? type(uint64).max : cfg.fixedAPY;
183
+
184
+ // `reserveFactor <= MANTISSA_ONE` is enforced by the FRV vault controller at config time, so
185
+ // the cut never exceeds `grossApy` and this subtraction cannot underflow.
171
186
  // forge-lint: disable-next-line(unsafe-typecast)
172
- return cfg.fixedAPY > type(uint64).max ? type(uint64).max : uint64(cfg.fixedAPY);
187
+ return uint64(grossApy - (grossApy * cfg.reserveFactor) / MANTISSA_ONE);
173
188
  }
174
189
 
175
190
  /// @inheritdoc IResourceAdapter
@@ -177,6 +192,11 @@ contract AdapterFRV is IResourceAdapter {
177
192
  return IFRVVault(resource).balanceOf(holder);
178
193
  }
179
194
 
195
+ /// @inheritdoc IResourceAdapter
196
+ function resourceName(address resource) external view override returns (string memory) {
197
+ return IERC20Metadata(resource).name();
198
+ }
199
+
180
200
  /// @inheritdoc IResourceAdapter
181
201
  /// @dev No-op: FRV takes its `reserveFactor` cut once at settlement, not per-redeem, so there
182
202
  /// is no unmodeled exit fee to guard against at registration.
@@ -2,6 +2,7 @@
2
2
  pragma solidity 0.8.25;
3
3
 
4
4
  import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
5
6
  import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
7
 
7
8
  import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
@@ -162,6 +163,11 @@ contract AdapterFlux is IResourceAdapter {
162
163
  return IFToken(resource).balanceOf(holder);
163
164
  }
164
165
 
166
+ /// @inheritdoc IResourceAdapter
167
+ function resourceName(address resource) external view override returns (string memory) {
168
+ return IERC20Metadata(resource).name();
169
+ }
170
+
165
171
  /// @inheritdoc IResourceAdapter
166
172
  /// @dev No-op: a Fluid fToken takes its revenue cut in the supply rate, not per-redeem, so
167
173
  /// there is no unmodeled exit fee to guard against at registration.