@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.
- package/README.md +111 -30
- package/contracts/Hub/Hub.sol +71 -63
- package/contracts/adapters/AdapterCentrifuge.sol +5 -0
- package/contracts/adapters/AdapterCoreV1.sol +6 -0
- package/contracts/adapters/AdapterFRV.sol +27 -7
- package/contracts/adapters/AdapterFlux.sol +6 -0
- package/contracts/adapters/AdapterSpokeV1.sol +428 -0
- package/contracts/interfaces/IHub.sol +6 -0
- package/contracts/interfaces/IHubLens.sol +71 -0
- package/contracts/interfaces/IResourceAdapter.sol +23 -7
- package/contracts/interfaces/IYieldGroupBase.sol +6 -1
- package/contracts/interfaces/external/ISpokeComptroller.sol +69 -0
- package/contracts/interfaces/external/IVTokenIsolated.sol +153 -0
- package/contracts/lens/HubCapacityMath.sol +610 -0
- package/contracts/lens/HubLens.sol +311 -0
- package/deployments/bscmainnet/AdapterCentrifuge.json +21 -2
- package/deployments/bscmainnet/AdapterCoreV1.json +21 -2
- package/deployments/bscmainnet/AdapterFRV.json +21 -2
- package/deployments/bscmainnet/AdapterFlux.json +21 -2
- package/deployments/bscmainnet/AdapterSpokeV1.json +339 -0
- package/deployments/bscmainnet/HubImpl.json +16 -3
- package/deployments/bscmainnet/HubLens.json +89 -0
- package/deployments/bscmainnet/SpokeBeacon.json +110 -0
- package/deployments/bscmainnet/SpokeSource_U.json +1083 -0
- package/deployments/bscmainnet/SpokeSource_USDC.json +1083 -0
- package/deployments/bscmainnet/SpokeSource_USDT.json +1083 -0
- package/deployments/bscmainnet/YieldGroupSpokeImpl.json +1083 -0
- package/deployments/bscmainnet_addresses.json +13 -6
- package/deployments/bsctestnet/AdapterCentrifuge.json +21 -2
- package/deployments/bsctestnet/AdapterCoreV1.json +21 -2
- package/deployments/bsctestnet/AdapterFRV.json +21 -2
- package/deployments/bsctestnet/AdapterFlux.json +21 -2
- package/deployments/bsctestnet/AdapterSpokeV1.json +339 -0
- package/deployments/bsctestnet/HubImpl.json +16 -3
- package/deployments/bsctestnet/HubLens.json +89 -0
- package/deployments/bsctestnet/SpokeBeacon.json +110 -0
- package/deployments/bsctestnet/SpokeSource_USDT.json +1083 -0
- package/deployments/bsctestnet/YieldGroupSpokeImpl.json +1083 -0
- package/deployments/bsctestnet_addresses.json +11 -6
- 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 (
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
79
|
-
* Flux and FRV implement it as a no-op (their NAV is not a block-lazy
|
|
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.
|
|
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
|
-
* @
|
|
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
|
|