@venusprotocol/liquidity-hub 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +422 -0
  3. package/contracts/Hub/Hub.sol +1614 -0
  4. package/contracts/Hub/HubStorage.sol +106 -0
  5. package/contracts/Hub/lib/HubAdminLib.sol +292 -0
  6. package/contracts/YieldGroup/YieldGroup.sol +128 -0
  7. package/contracts/YieldGroup/YieldGroupFRV.sol +107 -0
  8. package/contracts/YieldGroup/base/YieldGroupBase.sol +797 -0
  9. package/contracts/YieldGroup/base/YieldGroupBaseStorage.sol +73 -0
  10. package/contracts/adapters/AdapterCoreV1.sol +309 -0
  11. package/contracts/adapters/AdapterFRV.sol +276 -0
  12. package/contracts/adapters/AdapterFlux.sol +173 -0
  13. package/contracts/interfaces/IHub.sol +551 -0
  14. package/contracts/interfaces/IHubRegistry.sol +120 -0
  15. package/contracts/interfaces/IMigrator.sol +134 -0
  16. package/contracts/interfaces/IResourceAdapter.sol +154 -0
  17. package/contracts/interfaces/IYieldGroup.sol +119 -0
  18. package/contracts/interfaces/IYieldGroupBase.sol +507 -0
  19. package/contracts/interfaces/IYieldGroupFRV.sol +61 -0
  20. package/contracts/interfaces/external/IComptroller.sol +57 -0
  21. package/contracts/interfaces/external/IFRVVault.sol +113 -0
  22. package/contracts/interfaces/external/IFToken.sol +46 -0
  23. package/contracts/interfaces/external/IFluidLendingResolver.sol +63 -0
  24. package/contracts/interfaces/external/IVBep20.sol +39 -0
  25. package/contracts/interfaces/external/IVToken.sol +125 -0
  26. package/contracts/interfaces/external/IWBNB.sol +16 -0
  27. package/contracts/migrator/Migrator.sol +182 -0
  28. package/contracts/registry/HubRegistry.sol +179 -0
  29. package/deployments/bscmainnet/AdapterCoreV1.json +336 -0
  30. package/deployments/bscmainnet/AdapterFRV.json +225 -0
  31. package/deployments/bscmainnet/AdapterFlux.json +249 -0
  32. package/deployments/bscmainnet/CoreBeacon.json +110 -0
  33. package/deployments/bscmainnet/CoreSource_U.json +1083 -0
  34. package/deployments/bscmainnet/CoreSource_USDC.json +1083 -0
  35. package/deployments/bscmainnet/CoreSource_USDT.json +1083 -0
  36. package/deployments/bscmainnet/FRVBeacon.json +110 -0
  37. package/deployments/bscmainnet/FRVSource_U.json +978 -0
  38. package/deployments/bscmainnet/FRVSource_USDC.json +978 -0
  39. package/deployments/bscmainnet/FRVSource_USDT.json +978 -0
  40. package/deployments/bscmainnet/FluxBeacon.json +110 -0
  41. package/deployments/bscmainnet/FluxSource_U.json +1083 -0
  42. package/deployments/bscmainnet/FluxSource_USDC.json +1083 -0
  43. package/deployments/bscmainnet/FluxSource_USDT.json +1083 -0
  44. package/deployments/bscmainnet/HubBeacon.json +110 -0
  45. package/deployments/bscmainnet/HubImpl.json +2216 -0
  46. package/deployments/bscmainnet/HubRegistry.json +386 -0
  47. package/deployments/bscmainnet/HubRegistryImpl.json +386 -0
  48. package/deployments/bscmainnet/Hub_U.json +2216 -0
  49. package/deployments/bscmainnet/Hub_USDC.json +2216 -0
  50. package/deployments/bscmainnet/Hub_USDT.json +2216 -0
  51. package/deployments/bscmainnet/Migrator.json +375 -0
  52. package/deployments/bscmainnet/YieldGroupCoreImpl.json +1083 -0
  53. package/deployments/bscmainnet/YieldGroupFRVImpl.json +978 -0
  54. package/deployments/bscmainnet/YieldGroupFluxImpl.json +1083 -0
  55. package/deployments/bscmainnet_addresses.json +32 -0
  56. package/deployments/bsctestnet/AdapterCoreV1.json +336 -0
  57. package/deployments/bsctestnet/AdapterFRV.json +225 -0
  58. package/deployments/bsctestnet/AdapterFlux.json +249 -0
  59. package/deployments/bsctestnet/CoreBeacon.json +110 -0
  60. package/deployments/bsctestnet/CoreSource_USDT.json +1083 -0
  61. package/deployments/bsctestnet/FRVBeacon.json +110 -0
  62. package/deployments/bsctestnet/FRVSource_USDT.json +978 -0
  63. package/deployments/bsctestnet/FluxBeacon.json +110 -0
  64. package/deployments/bsctestnet/FluxSource_USDT.json +1083 -0
  65. package/deployments/bsctestnet/HubBeacon.json +110 -0
  66. package/deployments/bsctestnet/HubImpl.json +2216 -0
  67. package/deployments/bsctestnet/HubRegistry.json +386 -0
  68. package/deployments/bsctestnet/HubRegistryImpl.json +386 -0
  69. package/deployments/bsctestnet/HubRegistryProxyAdmin.json +157 -0
  70. package/deployments/bsctestnet/Hub_USDT.json +2216 -0
  71. package/deployments/bsctestnet/Migrator.json +375 -0
  72. package/deployments/bsctestnet/YieldGroupCoreImpl.json +1083 -0
  73. package/deployments/bsctestnet/YieldGroupFRVImpl.json +978 -0
  74. package/deployments/bsctestnet/YieldGroupFluxImpl.json +1083 -0
  75. package/deployments/bsctestnet_addresses.json +25 -0
  76. package/package.json +46 -0
@@ -0,0 +1,73 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ import { IYieldGroupBase } from "../../interfaces/IYieldGroupBase.sol";
5
+
6
+ /**
7
+ * @title YieldGroupBaseStorage
8
+ * @notice Shared state for every concrete YieldGroup (the generic `YieldGroup` serving Core / Flux
9
+ * and the `YieldGroupFRV` subclass), isolated in a base contract so upgrades can extend
10
+ * without disturbing the inheritance linearisation. Add new fields after `__gap` is shrunk
11
+ * by the same number of slots; never reorder, never prepend.
12
+ *
13
+ * @dev This is the UNION layout: every concrete shares this one slot table so a per-family beacon
14
+ * can be repointed at a different implementation without a storage migration. `_blocksPerYear`
15
+ * and `_resourceCap` are part of the union for layout stability even though a given family may
16
+ * not use them — the generic `YieldGroup` writes them; `YieldGroupFRV` leaves them at `0`.
17
+ *
18
+ * Slot layout (this contract's own contribution):
19
+ *
20
+ * Slot Used (bytes) Field
21
+ * ---- ------------ -----
22
+ * 0 20 _hub
23
+ * 1 20 _assetToken
24
+ * 2 32 _blocksPerYear (generic only; 0 for FRV / Flux deploys)
25
+ * 3 20 _accessControlManager
26
+ * 4 32 _resources
27
+ * 5 32 _resourceIndex
28
+ * 6 32 _resourcesConfig
29
+ * 7 32 _innerDepositQueue
30
+ * 8 32 _innerWithdrawQueue
31
+ * 9 32 _resourceCap (generic only; never written by FRV)
32
+ * 10..48 32 × 39 __gap
33
+ */
34
+ abstract contract YieldGroupBaseStorage {
35
+ /// @notice Hub authorised to call `deposit` / `withdraw`. Set at initialisation.
36
+ address internal _hub;
37
+
38
+ /// @notice Underlying ERC-20 (matches `Hub.asset()`). Set at initialisation.
39
+ address internal _assetToken;
40
+
41
+ /// @notice Blocks-per-year constant used to annualise per-block supply rates. Set at
42
+ /// initialisation; per-chain (BNB Chain ≈ 10_512_000). `0` for families whose adapter
43
+ /// pre-annualises and ignores it (Flux), and for FRV.
44
+ uint256 internal _blocksPerYear;
45
+
46
+ /// @notice AccessControlManagerV8 gating governance functions. Set at initialisation.
47
+ address internal _accessControlManager;
48
+
49
+ /// @notice Canonical set of registered yield resources.
50
+ address[] internal _resources;
51
+
52
+ /// @notice 1-indexed position in `_resources` (0 = not registered). Enables O(1) removal.
53
+ mapping(address resource => uint256 indexPlusOne) internal _resourceIndex;
54
+
55
+ /// @notice Per-resource config (registration + pause + adapter).
56
+ mapping(address resource => IYieldGroupBase.ResourceConfig config) internal _resourcesConfig;
57
+
58
+ /// @notice Inner deposit cascade order. Set by Operator via ACM.
59
+ address[] internal _innerDepositQueue;
60
+
61
+ /// @notice Inner withdraw cascade order. Independent of the deposit queue.
62
+ address[] internal _innerWithdrawQueue;
63
+
64
+ /// @notice Optional per-resource deposit cap, in asset units. `0` = unset/unbounded
65
+ /// (only the resource's protocol-level supply cap binds). When non-zero, the
66
+ /// effective deposit room is `min(protocol headroom, cap − our balance)`. Only the
67
+ /// generic `YieldGroup` reads / writes this; FRV leaves it `0`.
68
+ mapping(address resource => uint256 cap) internal _resourceCap;
69
+
70
+ /// @dev Reserved slots for future state. Shrink by the number of slots a new field
71
+ /// consumes when extending.
72
+ uint256[39] private __gap;
73
+ }
@@ -0,0 +1,309 @@
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 { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
+
7
+ import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
8
+ import { IVToken } from "../interfaces/external/IVToken.sol";
9
+ import { IComptroller } from "../interfaces/external/IComptroller.sol";
10
+
11
+ /**
12
+ * @title AdapterCoreV1
13
+ * @author Venus
14
+ * @notice Stateless adapter for Venus Core V1 vTokens. ONE deployment serves every
15
+ * YieldGroup that registers a Core V1 market — there is no per-(YieldGroup, vToken)
16
+ * instance, no proxy, no clone factory.
17
+ * @dev Dispatch model: mutating functions (`deposit`, `withdraw`) MUST be invoked via
18
+ * DELEGATECALL from a YieldGroup. They execute in the YieldGroup's storage context,
19
+ * so receipt-token credits and debits land on the YieldGroup, not this adapter.
20
+ * View functions are invoked via normal CALL / STATICCALL with an explicit `holder`
21
+ * argument.
22
+ *
23
+ * Storage-safety invariants enforced by this contract:
24
+ * 1. Zero `internal`/`private`/`public` state variables are declared. Only `immutable`
25
+ * values are used.
26
+ * 2. No inline assembly performs `sstore`.
27
+ * 3. All external calls go to the supplied `resource` (vToken), its `underlying()`, or
28
+ * its `comptroller()`. No arbitrary user-supplied call target.
29
+ * 4. {onlyDelegateCall} reverts when a mutating function is invoked directly on the
30
+ * adapter, preventing accidents that would orphan vTokens here.
31
+ *
32
+ * Treasury-fee handling: when the Comptroller's `treasuryPercent != 0`,
33
+ * `redeemUnderlying(X)` delivers only `X − fee` to the caller. `withdraw` grosses up
34
+ * the request via ceil-division so the YieldGroup receives at least `amount`; any
35
+ * surplus remains as idle on the YieldGroup (counted in `totalAssets`).
36
+ *
37
+ * vBNB is unsupported by design: `IVToken(resource).underlying()` reverts on vBNB,
38
+ * which trips `asset()` and prevents the YieldGroup from registering it.
39
+ */
40
+ contract AdapterCoreV1 is IResourceAdapter {
41
+ using SafeERC20 for IERC20;
42
+
43
+ // ============================== State (immutable only) ====================
44
+
45
+ /// @notice This adapter's own address, captured at deployment. Used by
46
+ /// {onlyDelegateCall} to detect direct invocation. Immutables live in bytecode,
47
+ /// not storage, so they're delegatecall-safe.
48
+ address private immutable _ADAPTER_SELF;
49
+
50
+ // ============================== State (constants) =========================
51
+
52
+ /// @notice Mantissa one used in treasury-fee math (`1e18`).
53
+ uint256 public constant MANTISSA_ONE = 1e18;
54
+
55
+ /// @notice Fixed-point scale matching Compound's mantissa (`1e18`).
56
+ uint256 public constant EXP_SCALE = 1e18;
57
+
58
+ /// @notice Scaling factor between mantissa-rate (`1e18`) and BPS (`1e4`).
59
+ uint256 internal constant MANTISSA_TO_BPS = 1e14;
60
+
61
+ // ============================== Errors ===================================
62
+
63
+ /// @notice A mutating function was invoked directly (not via delegatecall).
64
+ error NotDelegateCall();
65
+
66
+ /// @notice A vToken `mint` call returned a non-zero error code.
67
+ /// @param resource vToken that rejected the mint.
68
+ /// @param errorCode Compound-style error code returned by the vToken.
69
+ error VTokenMintFailed(address resource, uint256 errorCode);
70
+
71
+ /// @notice A vToken `redeemUnderlying` call returned a non-zero error code.
72
+ /// @param resource vToken that rejected the redeem.
73
+ /// @param errorCode Compound-style error code returned by the vToken.
74
+ error VTokenRedeemFailed(address resource, uint256 errorCode);
75
+
76
+ /// @notice A vToken `accrueInterest` call returned a non-zero error code.
77
+ /// @param resource vToken that failed to accrue.
78
+ /// @param errorCode Compound-style error code returned by the vToken.
79
+ error VTokenAccrueFailed(address resource, uint256 errorCode);
80
+
81
+ /**
82
+ * @notice A redeem succeeded but delivered less than the requested net amount. Indicates
83
+ * a fee-on-transfer underlying, an unexpected treasury cut, or a vToken bug.
84
+ * @param resource Resource that under-delivered.
85
+ * @param requested Net underlying units expected.
86
+ * @param actual Underlying units actually received.
87
+ */
88
+ error VTokenUnderfilled(address resource, uint256 requested, uint256 actual);
89
+
90
+ /**
91
+ * @notice A vToken's Comptroller charges a non-zero `treasuryPercent`. That exit fee leaves
92
+ * the pool on every redeem without burning shares, so it is unmodeled in the Hub's
93
+ * gross NAV and would overstate PPS — rejected at registration.
94
+ * @param resource vToken whose Comptroller charges the fee.
95
+ * @param treasuryPercent Offending treasury percent (mantissa).
96
+ */
97
+ error TreasuryFeeUnsupported(address resource, uint256 treasuryPercent);
98
+
99
+ // ============================== Modifiers ================================
100
+
101
+ /// @notice Reverts {NotDelegateCall} when the call is not arriving via delegatecall.
102
+ /// @dev During a delegatecall, `address(this)` is the caller's address; during a direct
103
+ /// call, it equals `_ADAPTER_SELF` (the address captured at construction).
104
+ modifier onlyDelegateCall() {
105
+ if (address(this) == _ADAPTER_SELF) revert NotDelegateCall();
106
+ _;
107
+ }
108
+
109
+ // ============================== Constructor ==============================
110
+
111
+ /// @notice Captures the adapter's own address into an immutable for {onlyDelegateCall}.
112
+ constructor() {
113
+ _ADAPTER_SELF = address(this);
114
+ }
115
+
116
+ // ============================== External — mutating =======================
117
+
118
+ /// @inheritdoc IResourceAdapter
119
+ function deposit(address resource, uint256 amount) external override onlyDelegateCall returns (uint256 deposited) {
120
+ // `address(this)` here is the YieldGroup (delegatecall context). The YieldGroup
121
+ // already holds `amount` of `underlying`; we just approve the vToken and mint —
122
+ // vTokens are credited to `msg.sender` which, from the vToken's perspective, is
123
+ // the YieldGroup (delegatecall preserves caller context to the next call).
124
+ IERC20 assetToken = IERC20(IVToken(resource).underlying());
125
+ SafeERC20.forceApprove(assetToken, resource, amount);
126
+ uint256 errCode = IVToken(resource).mint(amount);
127
+ if (errCode != 0) revert VTokenMintFailed(resource, errCode);
128
+ return amount;
129
+ }
130
+
131
+ /// @inheritdoc IResourceAdapter
132
+ function withdraw(address resource, uint256 amount, address to) external override onlyDelegateCall {
133
+ // `address(this)` here is the YieldGroup. `vToken.redeemUnderlying` burns its
134
+ // (YieldGroup's) vTokens and credits the underlying back to the YieldGroup.
135
+ IComptroller comptrollerContract = IComptroller(IVToken(resource).comptroller());
136
+ IERC20 assetToken = IERC20(IVToken(resource).underlying());
137
+
138
+ uint256 grossRedeem = _floorRedeemToOneVToken(resource, _grossUpForTreasury(comptrollerContract, amount));
139
+ uint256 preBal = assetToken.balanceOf(address(this));
140
+ uint256 errCode = IVToken(resource).redeemUnderlying(grossRedeem);
141
+ if (errCode != 0) revert VTokenRedeemFailed(resource, errCode);
142
+ uint256 received = assetToken.balanceOf(address(this)) - preBal;
143
+ if (received < amount) revert VTokenUnderfilled(resource, amount, received);
144
+
145
+ // Transfer exactly `amount` to `to`. Any gross-up surplus stays on the YieldGroup
146
+ // as idle balance — it's counted in `YieldGroup.totalAssets` and consumed on the
147
+ // next withdrawal's idle-first path, so dust is never socialised across YieldGroups.
148
+ assetToken.safeTransfer(to, amount);
149
+ }
150
+
151
+ /// @inheritdoc IResourceAdapter
152
+ /// @dev Plain CALL, no `onlyDelegateCall`: `accrueInterest` mutates the vToken's own global
153
+ /// index, not this caller's position, so it is safe (and cheaper) to run in the adapter's
154
+ /// context. After it returns, `exchangeRateStored()` is fresh to the current block.
155
+ function accrue(address resource) external override {
156
+ uint256 errCode = IVToken(resource).accrueInterest();
157
+ if (errCode != 0) revert VTokenAccrueFailed(resource, errCode);
158
+ }
159
+
160
+ // ============================== External — view ==========================
161
+
162
+ /// @inheritdoc IResourceAdapter
163
+ function asset(address resource) external view override returns (address) {
164
+ return IVToken(resource).underlying();
165
+ }
166
+
167
+ /// @inheritdoc IResourceAdapter
168
+ /// @dev Values the position at REALIZABLE (net-of-treasury) terms so NAV/PPS equal the value the
169
+ /// position can actually deliver. `withdraw` already nets the Venus treasury exit cut, so
170
+ /// valuing gross here would overstate NAV and let a first-out LP exit at the pre-fee price
171
+ /// while the shortfall socialises onto the last-out LP. When the Comptroller fee is off
172
+ /// (every registered market today) this returns the gross value unchanged.
173
+ function totalAssets(address resource, address holder) external view override returns (uint256) {
174
+ uint256 vBal = IVToken(resource).balanceOf(holder);
175
+ if (vBal == 0) return 0;
176
+ uint256 gross = (vBal * IVToken(resource).exchangeRateStored()) / EXP_SCALE;
177
+
178
+ uint256 treasuryPct = IComptroller(IVToken(resource).comptroller()).treasuryPercent();
179
+ if (treasuryPct == 0) return gross;
180
+ // The Comptroller setter enforces `treasuryPercent < 1e18`, so the subtraction cannot underflow.
181
+ return (gross * (MANTISSA_ONE - treasuryPct)) / MANTISSA_ONE;
182
+ }
183
+
184
+ /// @inheritdoc IResourceAdapter
185
+ /// @dev Honors Comptroller mint pause and supply cap. `supplyCap == 0` rejects mint
186
+ /// outright per PolicyFacet, so we surface that as 0 capacity. The headroom math
187
+ /// mirrors PolicyFacet's `nextTotalSupply = totalSupply × ER + mintAmount` check.
188
+ function maxDeposit(address resource) external view override returns (uint256) {
189
+ IComptroller comptrollerContract = IComptroller(IVToken(resource).comptroller());
190
+ // Action.MINT == 0 in the Venus Comptroller's enum.
191
+ if (comptrollerContract.actionPaused(resource, uint8(0))) return 0;
192
+ uint256 supplyCap = comptrollerContract.supplyCaps(resource);
193
+ if (supplyCap == 0) return 0;
194
+
195
+ uint256 totalSupplied = (IVToken(resource).totalSupply() * IVToken(resource).exchangeRateStored()) / EXP_SCALE;
196
+ uint256 raw = supplyCap > totalSupplied ? supplyCap - totalSupplied : 0;
197
+ // `exchangeRateStored` is pre-accrual, but Venus re-checks the supply cap at mint AFTER
198
+ // `accrueInterest` raises the rate, so the raw headroom is slightly optimistic on an
199
+ // interest-accruing near-cap market. Trim a small conservative margin so a normal accrual
200
+ // between this view and execution cannot push `deposit(maxDeposit())` over the cap. The
201
+ // routing cascade is the backstop for larger staleness (an over-cap leg is skipped, not
202
+ // bubbled), so this only needs to absorb a typical inter-block accrual.
203
+ return raw - (raw / 1000);
204
+ }
205
+
206
+ /// @inheritdoc IResourceAdapter
207
+ /// @dev Three constraints: (a) what `holder` holds in the resource (vToken balance × ER),
208
+ /// (b) what the vToken can pay out in cash (`getCash`), and (c) the treasury cut
209
+ /// (`treasuryPercent`). The min of (a) and (b) is the gross redeemable; multiplying
210
+ /// by `(1 − treasuryPct)` reflects what actually arrives at the recipient.
211
+ function maxWithdraw(address resource, address holder) external view override returns (uint256) {
212
+ IComptroller comptrollerContract = IComptroller(IVToken(resource).comptroller());
213
+ // Action.REDEEM == 1 in the Venus Comptroller's enum.
214
+ if (comptrollerContract.actionPaused(resource, uint8(1))) return 0;
215
+
216
+ uint256 vBal = IVToken(resource).balanceOf(holder);
217
+ if (vBal == 0) return 0;
218
+ uint256 exchangeRate = IVToken(resource).exchangeRateStored();
219
+ uint256 ourValue = (vBal * exchangeRate) / EXP_SCALE;
220
+ if (ourValue == 0) return 0;
221
+
222
+ uint256 cash = IVToken(resource).getCash();
223
+ // A redeem that would burn 0 vTokens is bumped to one whole vToken unit of underlying by
224
+ // `_floorRedeemToOneVToken`. If the market's cash is below that unit, every non-zero redeem
225
+ // the cascade could try exceeds cash and reverts in Venus `redeemFresh`. Report 0 liquidity
226
+ // rather than certify a withdraw the market cannot settle.
227
+ uint256 oneVTokenUnit = (exchangeRate + EXP_SCALE - 1) / EXP_SCALE;
228
+ if (cash < oneVTokenUnit) return 0;
229
+
230
+ uint256 redeemableGross = ourValue < cash ? ourValue : cash;
231
+ if (redeemableGross == 0) return 0;
232
+
233
+ uint256 treasuryPct = comptrollerContract.treasuryPercent();
234
+ if (treasuryPct == 0) return redeemableGross;
235
+ // The Comptroller setter enforces `treasuryPercent < 1e18`, so the subtraction cannot underflow.
236
+ return (redeemableGross * (MANTISSA_ONE - treasuryPct)) / MANTISSA_ONE;
237
+ }
238
+
239
+ /// @inheritdoc IResourceAdapter
240
+ function spotAPYBps(address resource, uint256 blocksPerYear) external view override returns (uint64) {
241
+ uint256 annualised = (IVToken(resource).supplyRatePerBlock() * blocksPerYear) / MANTISSA_TO_BPS;
242
+ // forge-lint: disable-next-line(unsafe-typecast)
243
+ return annualised > type(uint64).max ? type(uint64).max : uint64(annualised);
244
+ }
245
+
246
+ /// @inheritdoc IResourceAdapter
247
+ function receiptBalance(address resource, address holder) external view override returns (uint256) {
248
+ return IVToken(resource).balanceOf(holder);
249
+ }
250
+
251
+ /// @inheritdoc IResourceAdapter
252
+ /// @dev Rejects a market whose Comptroller charges a non-zero `treasuryPercent`: that exit fee
253
+ /// is unmodeled in the Hub's gross NAV (see the treasury-fee note above). The withdraw
254
+ /// gross-up still keeps individual redeems whole if Venus governance turns the fee on
255
+ /// *after* registration; this guard only blocks registering a market that already has it.
256
+ function validateRegistration(address resource) external view override {
257
+ uint256 treasuryPct = IComptroller(IVToken(resource).comptroller()).treasuryPercent();
258
+ if (treasuryPct != 0) revert TreasuryFeeUnsupported(resource, treasuryPct);
259
+ }
260
+
261
+ // ============================== Private — view ===========================
262
+
263
+ /**
264
+ * @notice Gross-up a desired net delivery amount for the Comptroller's treasury cut.
265
+ * @dev Ceil-division ensures `received >= netAmount`; any rounding surplus stays as
266
+ * idle on the YieldGroup, never on this adapter.
267
+ * @param comptrollerContract Comptroller to read `treasuryPercent` from.
268
+ * @param netAmount Underlying units the caller wants delivered.
269
+ * @return grossAmount Underlying units to request from `redeemUnderlying`.
270
+ */
271
+ function _grossUpForTreasury(IComptroller comptrollerContract, uint256 netAmount)
272
+ private
273
+ view
274
+ returns (uint256 grossAmount)
275
+ {
276
+ uint256 treasuryPct = comptrollerContract.treasuryPercent();
277
+ if (treasuryPct == 0) return netAmount;
278
+ // The Comptroller setter enforces `treasuryPercent < 1e18`, so the denominator is always non-zero.
279
+ uint256 denominator = MANTISSA_ONE - treasuryPct;
280
+ return (netAmount * MANTISSA_ONE + denominator - 1) / denominator;
281
+ }
282
+
283
+ /**
284
+ * @notice Raise a sub-one-vToken redeem up to exactly one vToken unit of underlying.
285
+ * @dev Compound computes `redeemTokens = floor(redeemAmount * 1e18 / exchangeRate)` and reverts
286
+ * `"redeemTokens zero"` when that floors to 0. A withdraw cascade can legitimately hand this
287
+ * adapter a dust `amount` — e.g. a 1-wei remainder left after an upstream ERC-4626 yieldGroup
288
+ * (Flux/FRV) rounds its own redeem down — which would otherwise revert an entirely valid,
289
+ * within-`maxWithdraw` withdrawal. When the requested redeem would burn 0 tokens, redeem one
290
+ * whole vToken unit instead; `withdraw` then transfers exactly `amount` and leaves the
291
+ * surplus idle on the YieldGroup (counted in `totalAssets`, consumed idle-first on the next
292
+ * withdrawal) — identical to how the treasury gross-up surplus is handled. A no-op for
293
+ * normal-sized redeems and for unit exchange rates (`amount >= 1` never floors to 0 there).
294
+ * @param resource vToken being redeemed from.
295
+ * @param grossRedeem Underlying units the caller intends to redeem.
296
+ * @return bumped `grossRedeem`, raised to `ceil(exchangeRate / 1e18)` only if it would burn 0 tokens.
297
+ */
298
+ function _floorRedeemToOneVToken(address resource, uint256 grossRedeem) private view returns (uint256 bumped) {
299
+ uint256 exchangeRate = IVToken(resource).exchangeRateStored();
300
+ // Venus burns `redeemTokens = floor(grossRedeem * 1e18 / exchangeRate)` and reverts when that
301
+ // is 0 — i.e. when `grossRedeem * 1e18 < exchangeRate`. In that case redeem exactly one vToken
302
+ // unit of underlying (`ceil(exchangeRate / 1e18)`) so the burn is non-zero; the caller still
303
+ // transfers only `amount` and leaves the surplus idle on the YieldGroup.
304
+ if (exchangeRate != 0 && grossRedeem * EXP_SCALE < exchangeRate) {
305
+ return (exchangeRate + EXP_SCALE - 1) / EXP_SCALE;
306
+ }
307
+ return grossRedeem;
308
+ }
309
+ }
@@ -0,0 +1,276 @@
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 { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
+ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
7
+
8
+ import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
9
+ import { IFRVVault } from "../interfaces/external/IFRVVault.sol";
10
+
11
+ /**
12
+ * @title AdapterFRV
13
+ * @author Venus
14
+ * @notice Stateless adapter for Venus Fixed Rate Vaults (ERC-4626). ONE deployment serves
15
+ * every YieldGroupFRV that registers an FRV vault — there is no per-(YieldGroup, vault)
16
+ * instance, no proxy, no clone factory.
17
+ * @dev Dispatch model: mutating functions (`deposit`, `withdraw`) MUST be invoked via
18
+ * DELEGATECALL from a YieldGroupFRV. They execute in the YieldGroup's storage context,
19
+ * so ERC-4626 share credits and debits land on the YieldGroup, not this adapter.
20
+ * View functions are invoked via normal CALL / STATICCALL with an explicit `holder`
21
+ * argument.
22
+ *
23
+ * Storage-safety invariants enforced by this contract:
24
+ * 1. Zero `internal`/`private`/`public` state variables are declared. Only `immutable`
25
+ * values (and `constant`s, which live in bytecode) are used.
26
+ * 2. No inline assembly performs `sstore`.
27
+ * 3. All external calls go to the supplied `resource` (FRV vault) or its `asset()`.
28
+ * 4. {onlyDelegateCall} reverts when a mutating function is invoked directly on the
29
+ * adapter, preventing accidents that would orphan shares here.
30
+ *
31
+ * No treasury-fee handling: FRV takes its `reserveFactor` cut once at settlement
32
+ * (`_settleProtocolShare`), not per-redeem. No gross-up needed on withdraw.
33
+ *
34
+ * Valuation model (time-based linear accrual): a live FRV position in Lock is valued at its
35
+ * principal plus the coupon accrued so far on a straight line over the lock — `principal +
36
+ * netInterest * elapsed / lockDuration`, where `netInterest` is the full-term coupon net of the
37
+ * vault's `reserveFactor`. The arithmetic mirrors `BaseVault` exactly so that at `elapsed ==
38
+ * lockDuration` the value equals the vault's full-repayment `settlementAmount`. The Lock ->
39
+ * Matured transition is therefore continuous ONLY on full repayment; on a partial / principal
40
+ * shortfall the realized `settlementAmount` is lower, so the mark steps DOWN at settlement (and
41
+ * a defaulting position in PendingSettlement / SettlementDeadlineExceeded is marked above its
42
+ * realizable value). That over-mark is by design a forward-looking accrual; a principal shortfall
43
+ * on a defaulting loan is bounded by governance's per-source cap (the Hub may additionally apply
44
+ * an exit fee as anti-sandwich friction). `totalAssets` returns this accrued value directly as
45
+ * the position's Hub-facing NAV.
46
+ *
47
+ * Cap interaction: this mark rises over the lock while the coupon stays non-withdrawable
48
+ * (`maxWithdraw` is 0 until the vault is terminal), so it eats into the YieldGroup's Hub-side cap
49
+ * headroom over time — a group can reach or exceed its cap on accrual alone and block further
50
+ * deposits / reallocation into it. Fail-closed: it only tightens capacity. See `Hub._effectiveCap`.
51
+ *
52
+ * Basis invariant: the principal / gross slices use the vault's `+1 / +1` floored ERC-4626
53
+ * conversion (`mulDiv(shares, assets + 1, supply + 1, Down)`), which is exact ONLY while the FRV
54
+ * vault leaves `_decimalsOffset()` at 0 (it does, and defines no override). If a future FRV
55
+ * family overrides it, this adapter must be updated in lockstep.
56
+ *
57
+ * FRV state machine (`updateVaultState()`) is NOT called here — `YieldGroupFRV` calls
58
+ * it before invoking the adapter so that state-dependent reads reflect the latest state.
59
+ * The valuation is time-based, so it needs no state poke of its own; `accrue` is a no-op.
60
+ */
61
+ contract AdapterFRV is IResourceAdapter {
62
+ using SafeERC20 for IERC20;
63
+
64
+ // ============================== State (immutable only) ====================
65
+
66
+ address private immutable _ADAPTER_SELF;
67
+
68
+ // ============================== Constants =================================
69
+ // Mirror `BaseVault` (BPS = 10_000, MANTISSA_ONE = 1e18, YEAR = 365 days) so the accrued value
70
+ // is computed on the identical basis as the vault's own interest and settlement math.
71
+
72
+ uint256 private constant BPS = 10_000;
73
+ uint256 private constant MANTISSA_ONE = 1e18;
74
+ uint256 private constant YEAR = 365 days;
75
+
76
+ // ============================== Errors ===================================
77
+
78
+ error NotDelegateCall();
79
+
80
+ // ============================== Modifiers ================================
81
+
82
+ modifier onlyDelegateCall() {
83
+ if (address(this) == _ADAPTER_SELF) revert NotDelegateCall();
84
+ _;
85
+ }
86
+
87
+ // ============================== Constructor ==============================
88
+
89
+ constructor() {
90
+ _ADAPTER_SELF = address(this);
91
+ }
92
+
93
+ // ============================== External — mutating =======================
94
+
95
+ /// @inheritdoc IResourceAdapter
96
+ function deposit(address resource, uint256 amount) external override onlyDelegateCall returns (uint256 deposited) {
97
+ IERC20 assetToken = IERC20(IFRVVault(resource).asset());
98
+ assetToken.forceApprove(resource, amount);
99
+ IFRVVault(resource).deposit(amount, address(this));
100
+ return amount;
101
+ }
102
+
103
+ /// @inheritdoc IResourceAdapter
104
+ function withdraw(address resource, uint256 amount, address to) external override onlyDelegateCall {
105
+ IFRVVault(resource).withdraw(amount, to, address(this));
106
+ }
107
+
108
+ /// @inheritdoc IResourceAdapter
109
+ /// @dev No-op: the FRV valuation is time-based and computed on-demand in {totalAssets} from the
110
+ /// vault's lock window, so there is no block-lazy exchange-rate state to freshen here.
111
+ // solhint-disable-next-line no-empty-blocks
112
+ function accrue(address resource) external override { }
113
+
114
+ // ============================== External — view ==========================
115
+
116
+ /// @inheritdoc IResourceAdapter
117
+ function asset(address resource) external view override returns (address) {
118
+ return IFRVVault(resource).asset();
119
+ }
120
+
121
+ /// @inheritdoc IResourceAdapter
122
+ /// @dev Position value = principal slice + accrued-coupon slice; this is the Hub-facing NAV for
123
+ /// the position. See the contract NatSpec for the valuation model.
124
+ function totalAssets(address resource, address holder) external view override returns (uint256) {
125
+ (uint256 principalValue, uint256 couponValueGross) = _breakdown(resource, holder);
126
+ return principalValue + couponValueGross;
127
+ }
128
+
129
+ /// @inheritdoc IResourceAdapter
130
+ /// @dev FRV's `maxDeposit()` returns 0 outside Fundraising state. The receiver param
131
+ /// is ignored by the FRV vault (capacity is global, not per-address).
132
+ /// Pause-blind, like the vault's own `maxDeposit`; a paused vault is caught and skipped by
133
+ /// the YieldGroup deposit cascade, not gated here.
134
+ function maxDeposit(address resource) external view override returns (uint256) {
135
+ return IFRVVault(resource).maxDeposit(address(this));
136
+ }
137
+
138
+ /// @inheritdoc IResourceAdapter
139
+ /// @dev FRV's `maxWithdraw()` returns 0 outside terminal states (Matured/Failed/Liquidated).
140
+ /// No treasury-fee deduction needed — FRV takes its cut at settlement, not per-redeem.
141
+ /// The accrued coupon surfaced by {totalAssets} is NOT withdrawable mid-lock; this returning
142
+ /// 0 during Lock is what keeps the rising NAV non-deliverable until settlement.
143
+ /// Pause-blind, like the vault's own `maxWithdraw` (it ignores `pauseLevel`): a completely
144
+ /// paused vault is caught and skipped by the YieldGroup withdraw cascade's `try/catch`, not
145
+ /// gated here.
146
+ function maxWithdraw(address resource, address holder) external view override returns (uint256) {
147
+ return IFRVVault(resource).maxWithdraw(holder);
148
+ }
149
+
150
+ /// @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.
157
+ function spotAPYBps(
158
+ address resource,
159
+ uint256 /* blocksPerYear */
160
+ )
161
+ external
162
+ view
163
+ override
164
+ returns (uint64)
165
+ {
166
+ IFRVVault.FRVVaultState state = IFRVVault(resource).state();
167
+ if (state != IFRVVault.FRVVaultState.Fundraising && state != IFRVVault.FRVVaultState.Lock) {
168
+ return 0;
169
+ }
170
+ IFRVVault.FRVVaultConfig memory cfg = IFRVVault(resource).config();
171
+ // forge-lint: disable-next-line(unsafe-typecast)
172
+ return cfg.fixedAPY > type(uint64).max ? type(uint64).max : uint64(cfg.fixedAPY);
173
+ }
174
+
175
+ /// @inheritdoc IResourceAdapter
176
+ function receiptBalance(address resource, address holder) external view override returns (uint256) {
177
+ return IFRVVault(resource).balanceOf(holder);
178
+ }
179
+
180
+ /// @inheritdoc IResourceAdapter
181
+ /// @dev No-op: FRV takes its `reserveFactor` cut once at settlement, not per-redeem, so there
182
+ /// is no unmodeled exit fee to guard against at registration.
183
+ function validateRegistration(address resource) external pure override {
184
+ // No FRV-specific registration precondition. `resource` validity is established by the
185
+ // YieldGroup's `asset()` probe and `code.length` check at registration time.
186
+ resource;
187
+ }
188
+
189
+ // ============================== Internal — view ==========================
190
+
191
+ /// @dev Split the holder's position into (principalValue, couponValueGross), both in underlying.
192
+ /// Branches by lifecycle state:
193
+ /// - no shares -> (0, 0)
194
+ /// - Closed -> (0, 0) (stuck residual; valued at 0 = deliverable value)
195
+ /// - terminal (Matured / Failed / Liquidated) -> (previewRedeem(shares), 0) (realized)
196
+ /// - otherwise -> principal slice + accrued-coupon slice
197
+ /// Both slices use the vault's own +1/+1 floored ERC-4626 basis (decimalsOffset 0), so the
198
+ /// principal slice equals `previewRedeem(shares)` during non-terminal states and the gross
199
+ /// sum equals the post-Matured `previewRedeem` at `elapsed == lockDuration`.
200
+ function _breakdown(address resource, address holder)
201
+ internal
202
+ view
203
+ returns (uint256 principalValue, uint256 couponValueGross)
204
+ {
205
+ IFRVVault vault = IFRVVault(resource);
206
+ uint256 shares = vault.balanceOf(holder);
207
+ if (shares == 0) return (0, 0);
208
+
209
+ IFRVVault.FRVVaultRuntime memory rt = vault.runtime();
210
+ IFRVVault.FRVVaultState state = rt.state;
211
+
212
+ // Closed is not loss-adjusted by the vault (its `totalAssets()` resurrects `totalRaised`),
213
+ // yet the position is non-withdrawable — value it at 0 so reported NAV == deliverable value.
214
+ if (state == IFRVVault.FRVVaultState.Closed) return (0, 0);
215
+
216
+ // Terminal states have settled to a realized `settlementAmount`; `previewRedeem` is the
217
+ // deliverable value (can be below principal on a loss). No unrealized coupon remains.
218
+ if (
219
+ state == IFRVVault.FRVVaultState.Matured || state == IFRVVault.FRVVaultState.Failed
220
+ || state == IFRVVault.FRVVaultState.Liquidated
221
+ ) {
222
+ return (vault.previewRedeem(shares), 0);
223
+ }
224
+
225
+ uint256 supply = vault.totalSupply();
226
+ if (supply == 0) return (0, 0); // defensive; a holder with shares implies supply > 0
227
+
228
+ // Principal slice on the vault's own +1/+1 floored basis. During Lock the vault reports
229
+ // `totalAssets() == totalRaised`, so this equals `previewRedeem(shares)` exactly.
230
+ principalValue = Math.mulDiv(shares, rt.totalRaised + 1, supply + 1, Math.Rounding.Down);
231
+
232
+ uint256 accruedTotalAssets = _accruedTotalAssets(vault, rt);
233
+ // accruedTotalAssets >= totalRaised, so grossValue >= principalValue (mulDiv is monotonic).
234
+ uint256 grossValue = Math.mulDiv(shares, accruedTotalAssets + 1, supply + 1, Math.Rounding.Down);
235
+ couponValueGross = grossValue - principalValue;
236
+ }
237
+
238
+ /// @dev Vault-wide accrued total assets used to price the holder's slice. Returns flat
239
+ /// `totalRaised` (no coupon) except while the vault is in Lock / PendingSettlement /
240
+ /// SettlementDeadlineExceeded, where the coupon accrues on lock state alone:
241
+ /// - Lock: principal + netInterest * clamp(now - lockStartTime, 0, lockDuration) / lockDuration
242
+ /// - PendingSettlement / SettlementDeadlineExceeded: principal + full netInterest (term is
243
+ /// over; held flat into settlement so there is no Lock-end cliff back to principal)
244
+ /// Accrual deliberately does NOT key on `fundsWithdrawn`. The vault stamps `lockStartTime` at
245
+ /// fundraising close and owes interest over the whole lock window regardless of when, or
246
+ /// whether, the institution draws the principal. Gating the mark on the draw would (a)
247
+ /// under-mark a locked position versus the vault's own settlement and (b) make the mark jump
248
+ /// in the single transaction that flips `fundsWithdrawn` — a sandwichable NAV discontinuity,
249
+ /// since `lockStartTime` (and therefore `elapsed`) is unchanged by the draw. Keying on lock
250
+ /// state alone keeps the mark continuous from `lockStartTime`.
251
+ /// `netInterest` mirrors `BaseVault`: full-term interest then the reserve fee floored and
252
+ /// subtracted (NOT `interest * (1 - reserveFactor)`, which rounds differently).
253
+ function _accruedTotalAssets(IFRVVault vault, IFRVVault.FRVVaultRuntime memory rt) internal view returns (uint256) {
254
+ bool isLock = rt.state == IFRVVault.FRVVaultState.Lock;
255
+ bool isPostTerm = rt.state == IFRVVault.FRVVaultState.PendingSettlement
256
+ || rt.state == IFRVVault.FRVVaultState.SettlementDeadlineExceeded;
257
+ if (!isLock && !isPostTerm) return rt.totalRaised;
258
+
259
+ IFRVVault.FRVVaultConfig memory cfg = vault.config();
260
+ uint256 lockDuration = cfg.lockDuration;
261
+ if (lockDuration == 0) return rt.totalRaised; // defensive; config enforces non-zero
262
+
263
+ uint256 totalInterest = (rt.totalRaised * cfg.fixedAPY * lockDuration) / (BPS * YEAR);
264
+ // `reserveFactor <= MANTISSA_ONE` is enforced by the FRV vault controller at config time, so
265
+ // the fee never exceeds `totalInterest` and this subtraction cannot underflow.
266
+ uint256 netInterest = totalInterest - (totalInterest * cfg.reserveFactor) / MANTISSA_ONE;
267
+
268
+ uint256 elapsed = lockDuration;
269
+ if (isLock) {
270
+ uint256 raw = block.timestamp > rt.lockStartTime ? block.timestamp - rt.lockStartTime : 0;
271
+ elapsed = raw < lockDuration ? raw : lockDuration;
272
+ }
273
+
274
+ return rt.totalRaised + (netInterest * elapsed) / lockDuration;
275
+ }
276
+ }