@venusprotocol/liquidity-hub 2.0.0-dev.1 → 2.1.0-dev.1

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.
@@ -0,0 +1,358 @@
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 { IAdapterCentrifuge } from "../interfaces/IAdapterCentrifuge.sol";
9
+ import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
10
+ import { IYieldGroupBase } from "../interfaces/IYieldGroupBase.sol";
11
+ import { IYieldGroupCentrifuge } from "../interfaces/IYieldGroupCentrifuge.sol";
12
+ import { ICentrifugeAsyncRequestManager } from "../interfaces/external/ICentrifugeAsyncRequestManager.sol";
13
+ import { ICentrifugeAsyncVault } from "../interfaces/external/ICentrifugeAsyncVault.sol";
14
+
15
+ /**
16
+ * @title AdapterCentrifuge
17
+ * @author Venus
18
+ * @notice Stateless adapter for Centrifuge ERC-7540 vaults such as JTRSY and JAAA.
19
+ * A single deployment can serve any YieldGroup that registers a compatible vault.
20
+ * @dev Mutating functions execute by delegatecall from the YieldGroup. Under delegatecall, the
21
+ * YieldGroup is used as the controller, owner, and claim receiver.
22
+ *
23
+ * A position includes the held share balance and six asynchronous request counters. Asset
24
+ * amounts are valued at face value, while share amounts are converted at the vault's NAV:
25
+ * - face value: `pendingDepositRequest`, `claimableCancelDepositRequest`, `maxWithdraw`;
26
+ * - NAV value: held shares, `maxMint`, `pendingRedeemRequest`,
27
+ * `claimableCancelRedeemRequest`.
28
+ * Idle assets are excluded because the YieldGroup adds them separately.
29
+ *
30
+ * Claims use the same denomination as the claimable counter: `mint(maxMint)` for shares and
31
+ * `withdraw(maxWithdraw)` for assets. Using `deposit` or `redeem` would convert from the
32
+ * opposite denomination and could leave one smallest unit unclaimed because of rounding.
33
+ *
34
+ * {totalAssets} values the position from Centrifuge's own counters and price. It rejects a
35
+ * zero share price whenever the position contains shares. The group clamps what comes back to
36
+ * that resource's NAV band, and it is the group's `totalAssets()` that the Hub sums. That band
37
+ * is configured there, since this adapter is stateless.
38
+ *
39
+ * Asset moves read the underlying from the calling YieldGroup, never from `resource.asset()`;
40
+ * see {_groupAsset}.
41
+ */
42
+ contract AdapterCentrifuge is IAdapterCentrifuge {
43
+ using SafeERC20 for IERC20;
44
+
45
+ // ============================== Constants =================================
46
+
47
+ /// @dev Centrifuge assigns request id 0 to every request. Requests for the same vault and
48
+ /// controller accumulate in one `AsyncInvestmentState` instead of receiving individual
49
+ /// ids, so cancellation and cancel-claim calls always target id 0.
50
+ uint256 private constant REQUEST_ID = 0;
51
+
52
+ // ============================== State (immutable only) ====================
53
+
54
+ /// @dev Adapter deployment address used to distinguish direct calls from delegatecalls.
55
+ address private immutable _ADAPTER_SELF;
56
+
57
+ // ============================== Errors ====================================
58
+
59
+ /// @notice Thrown when a delegatecall-only function is called directly on the adapter.
60
+ error NotDelegateCall();
61
+
62
+ /**
63
+ * @notice Thrown when a resource does not expose a share token.
64
+ * @param resource Resource that failed validation.
65
+ */
66
+ error NoShareToken(address resource);
67
+
68
+ /**
69
+ * @notice Thrown when a resource does not expose a request manager.
70
+ * @param resource Resource that failed validation.
71
+ */
72
+ error NoManager(address resource);
73
+
74
+ /**
75
+ * @notice Thrown when a position contains shares but the vault reports a zero share price.
76
+ * @dev `requestRedeem` does not clear this: it still works, but a pending redeem is counted as
77
+ * shares. This clears when Centrifuge settles that redeem and the value lands in
78
+ * `maxWithdraw`, which is in asset units. If Centrifuge never settles,
79
+ * `forceRemoveResource` writes the position off.
80
+ * @param resource Resource whose share price is zero.
81
+ */
82
+ error ZeroSharePrice(address resource);
83
+
84
+ // ============================== Modifiers =================================
85
+
86
+ /// @dev Restricts mutating adapter functions to delegatecall execution.
87
+ modifier onlyDelegateCall() {
88
+ if (address(this) == _ADAPTER_SELF) revert NotDelegateCall();
89
+ _;
90
+ }
91
+
92
+ // ============================== Constructor ===============================
93
+
94
+ /// @dev Stores the adapter address used by {onlyDelegateCall}.
95
+ constructor() {
96
+ _ADAPTER_SELF = address(this);
97
+ }
98
+
99
+ // ============================== IResourceAdapter — mutating ===============
100
+
101
+ /// @inheritdoc IResourceAdapter
102
+ /// @dev Sends assets to Centrifuge as an asynchronous deposit request. Shares can be collected
103
+ /// through {claimDeposit} after settlement. The temporary allowance supports vaults that
104
+ /// pull assets with `transferFrom`.
105
+ function deposit(address resource, uint256 amount) external override onlyDelegateCall returns (uint256) {
106
+ IERC20 assetToken = _groupAsset();
107
+ assetToken.forceApprove(resource, amount);
108
+ ICentrifugeAsyncVault(resource).requestDeposit(amount, address(this), address(this));
109
+ assetToken.forceApprove(resource, 0);
110
+ return amount;
111
+ }
112
+
113
+ /// @inheritdoc IResourceAdapter
114
+ /// @dev Withdraws assets already reserved in `maxWithdraw`. Invested or pending shares are not
115
+ /// liquid until a redeem request settles. The YieldGroup sizes normal calls against
116
+ /// `maxWithdraw`; the vault call remains the final enforcement point.
117
+ ///
118
+ /// The withdrawal uses two steps because the vault permission-checks the claim receiver.
119
+ /// Claiming directly to an arbitrary `to` may revert if that address is not on the share-class
120
+ /// memberlist. The adapter therefore claims to the group as receiver and controller, then
121
+ /// forwards the underlying with a regular ERC-20 transfer.
122
+ ///
123
+ /// This consumes the same `maxWithdraw` counter as {claimRedeem}, but the YieldGroup logs
124
+ /// it as `WithdrawRouted`, not `RedeemClaimed`.
125
+ function withdraw(address resource, uint256 amount, address to) external override onlyDelegateCall {
126
+ ICentrifugeAsyncVault vault = ICentrifugeAsyncVault(resource);
127
+ IERC20 assetToken = _groupAsset();
128
+
129
+ // Forward only what the vault delivered. Forwarding `amount` could use sufficient idle
130
+ // balance to hide a short delivery from the caller's recipient-balance check. Preserving the
131
+ // shortfall makes the caller revert instead.
132
+ uint256 preBal = assetToken.balanceOf(address(this));
133
+ vault.withdraw(amount, address(this), address(this));
134
+ uint256 received = assetToken.balanceOf(address(this)) - preBal;
135
+
136
+ if (to != address(this)) assetToken.safeTransfer(to, received);
137
+ }
138
+
139
+ /// @inheritdoc IResourceAdapter
140
+ /// @dev No-op because Centrifuge state is valued live. Claims occur through the YieldGroup's
141
+ /// explicit claim functions or when a withdrawal reaches a settled resource.
142
+ // solhint-disable-next-line no-empty-blocks
143
+ function accrue(address resource) external override { }
144
+
145
+ // ============================== Centrifuge-only — mutating ================
146
+
147
+ /// @inheritdoc IAdapterCentrifuge
148
+ /// @dev Sends shares to Centrifuge as an asynchronous redeem request. Assets can be collected
149
+ /// through {claimRedeem} after settlement. No share approval is needed: Centrifuge's
150
+ /// BalanceSheet moves the shares with `authTransferFrom`, a ward call that never reads
151
+ /// `allowance`. Deposits are different — there the vault runs a real `transferFrom` on the
152
+ /// asset, so that approval stays.
153
+ function requestRedeem(address resource, uint256 shares) external override onlyDelegateCall {
154
+ ICentrifugeAsyncVault(resource).requestRedeem(shares, address(this), address(this));
155
+ }
156
+
157
+ /// @inheritdoc IAdapterCentrifuge
158
+ function cancelDepositRequest(address resource) external override onlyDelegateCall {
159
+ ICentrifugeAsyncVault(resource).cancelDepositRequest(REQUEST_ID, address(this));
160
+ }
161
+
162
+ /// @inheritdoc IAdapterCentrifuge
163
+ function cancelRedeemRequest(address resource) external override onlyDelegateCall {
164
+ ICentrifugeAsyncVault(resource).cancelRedeemRequest(REQUEST_ID, address(this));
165
+ }
166
+
167
+ /// @inheritdoc IAdapterCentrifuge
168
+ /// @dev Requests the full `maxMint` amount to clear the claimable share balance.
169
+ function claimDeposit(address resource) external override onlyDelegateCall returns (uint256 shares) {
170
+ shares = _investments(resource, address(this)).maxMint;
171
+ if (shares > 0) {
172
+ ICentrifugeAsyncVault(resource).mint(shares, address(this), address(this));
173
+ }
174
+ }
175
+
176
+ /// @inheritdoc IAdapterCentrifuge
177
+ /// @dev Requests the full `maxWithdraw` amount to clear the claimable asset balance.
178
+ function claimRedeem(address resource) external override onlyDelegateCall returns (uint256 assets) {
179
+ assets = _investments(resource, address(this)).maxWithdraw;
180
+ if (assets > 0) {
181
+ ICentrifugeAsyncVault(resource).withdraw(assets, address(this), address(this));
182
+ }
183
+ }
184
+
185
+ /// @inheritdoc IAdapterCentrifuge
186
+ function claimCancelDeposit(address resource) external override onlyDelegateCall returns (uint256 assets) {
187
+ assets = _investments(resource, address(this)).claimableCancelDepositRequest;
188
+ if (assets > 0) {
189
+ ICentrifugeAsyncVault(resource).claimCancelDepositRequest(REQUEST_ID, address(this), address(this));
190
+ }
191
+ }
192
+
193
+ /// @inheritdoc IAdapterCentrifuge
194
+ function claimCancelRedeem(address resource) external override onlyDelegateCall returns (uint256 shares) {
195
+ shares = _investments(resource, address(this)).claimableCancelRedeemRequest;
196
+ if (shares > 0) {
197
+ ICentrifugeAsyncVault(resource).claimCancelRedeemRequest(REQUEST_ID, address(this), address(this));
198
+ }
199
+ }
200
+
201
+ // ============================== Views ======================================
202
+
203
+ /// @inheritdoc IResourceAdapter
204
+ function asset(address resource) external view override returns (address) {
205
+ return ICentrifugeAsyncVault(resource).asset();
206
+ }
207
+
208
+ /// @inheritdoc IResourceAdapter
209
+ /// @dev What Centrifuge's counters and price say the position is worth. `YieldGroupCentrifuge`
210
+ /// clamps this to the resource's NAV band on the way to the Hub, so read the group's
211
+ /// `totalAssets()` for the figure the Hub sums, and this one for what the band is clamping.
212
+ ///
213
+ /// Only the share-denominated buckets need a price, so a position holding none is valued
214
+ /// without the feed at all.
215
+ function totalAssets(address resource, address holder) external view override returns (uint256 value) {
216
+ (uint256 face, uint256 shares, address shareToken) = _assetAndShareTotals(resource, holder);
217
+ value = face;
218
+ if (shares > 0) {
219
+ uint256 price = _pricePerShareOrZero(resource);
220
+ // Reject an unset or unreadable price before it silently values a live position at zero.
221
+ if (price == 0) revert ZeroSharePrice(resource);
222
+ value += (shares * price) / (10 ** IERC20Metadata(shareToken).decimals());
223
+ }
224
+ }
225
+
226
+ /// @inheritdoc IResourceAdapter
227
+ /// @dev The uint128 limit Centrifuge's counters impose. Nothing calls this for Centrifuge:
228
+ /// `YieldGroupCentrifuge` sizes deposits through {depositRoom}, which also sees the holder.
229
+ /// Kept to satisfy `IResourceAdapter`.
230
+ function maxDeposit(address resource) external pure override returns (uint256) {
231
+ resource;
232
+ return type(uint128).max;
233
+ }
234
+
235
+ /// @inheritdoc IAdapterCentrifuge
236
+ function depositRoom(address resource, address holder) external view override returns (uint256) {
237
+ ICentrifugeAsyncRequestManager.AsyncInvestmentState memory inv = _investments(resource, holder);
238
+ if (inv.pendingCancelDepositRequest) return 0;
239
+ return type(uint128).max - inv.pendingDepositRequest;
240
+ }
241
+
242
+ /// @inheritdoc IAdapterCentrifuge
243
+ function pricePerShare(address resource) external view override returns (uint256 price) {
244
+ return ICentrifugeAsyncVault(resource).pricePerShare();
245
+ }
246
+
247
+ /// @inheritdoc IResourceAdapter
248
+ /// @dev Returns the vault's `maxWithdraw(holder)`, so the vault's permission checks are reflected
249
+ /// in reported liquidity. Valuation deliberately reads the raw investment state instead,
250
+ /// allowing permission changes to affect liquidity without changing NAV.
251
+ function maxWithdraw(address resource, address holder) external view override returns (uint256) {
252
+ return ICentrifugeAsyncVault(resource).maxWithdraw(holder);
253
+ }
254
+
255
+ /// @inheritdoc IResourceAdapter
256
+ /// @dev Centrifuge publishes no rate on-chain — a fund's return arrives as a new NAV — so the
257
+ /// number comes from the YieldGroup holding the position, where governance publishes it
258
+ /// with {IYieldGroupCentrifuge.setSpotAPYBps}. `YieldGroupBase.spotAPYBps` reaches this by
259
+ /// direct call rather than delegatecall, so `msg.sender` is that group.
260
+ ///
261
+ /// Any other caller gets `0`. A published rate belongs to one group's position and this
262
+ /// adapter is shared between groups, so there is no rate to hand someone holding nothing.
263
+ /// Answering rather than reverting keeps the read open to observers, like every other view
264
+ /// here, and that needs both guards below: Solidity checks the callee has code before it
265
+ /// makes the call, and that revert lands outside `try`, so an address with no code has to
266
+ /// be turned away first. The `catch` covers the rest — a contract that is not one of these
267
+ /// groups, which answers nothing this can decode.
268
+ function spotAPYBps(address resource, uint256) external view override returns (uint64) {
269
+ if (msg.sender.code.length == 0) return 0;
270
+ try IYieldGroupCentrifuge(msg.sender).resourceSpotAPYBps(resource) returns (uint64 apyBps) {
271
+ return apyBps;
272
+ } catch {
273
+ return 0;
274
+ }
275
+ }
276
+
277
+ /// @inheritdoc IResourceAdapter
278
+ /// @dev Reports whether this group still has value tied to the vault by adding its held, pending,
279
+ /// and claimable balances. Asset and share units are mixed intentionally because callers use
280
+ /// the result only as a zero/nonzero check; forced removal logs it as a diagnostic value.
281
+ ///
282
+ /// Idle assets are excluded because they are no longer tied to a vault. After redeem or
283
+ /// deposit-cancel proceeds are claimed, this can return zero while those assets remain in the
284
+ /// group. A complete wind-down must also move that idle balance back to the Hub.
285
+ function receiptBalance(address resource, address holder) external view override returns (uint256) {
286
+ ICentrifugeAsyncVault vault = ICentrifugeAsyncVault(resource);
287
+ ICentrifugeAsyncRequestManager.AsyncInvestmentState memory inv = _investments(resource, holder);
288
+ return IERC20(vault.share()).balanceOf(holder) + uint256(inv.maxMint) + inv.maxWithdraw
289
+ + inv.pendingDepositRequest + inv.pendingRedeemRequest + inv.claimableCancelDepositRequest
290
+ + inv.claimableCancelRedeemRequest;
291
+ }
292
+
293
+ /// @inheritdoc IResourceAdapter
294
+ /// @dev Verifies the vault exposes the share token, request manager, investment state, and price
295
+ /// reads required by this adapter.
296
+ function validateRegistration(address resource) external view override {
297
+ ICentrifugeAsyncVault vault = ICentrifugeAsyncVault(resource);
298
+ if (vault.share() == address(0)) revert NoShareToken(resource);
299
+ address mgr = vault.baseManager();
300
+ if (mgr == address(0)) revert NoManager(resource);
301
+ ICentrifugeAsyncRequestManager(mgr).investments(resource, address(this));
302
+ vault.pricePerShare();
303
+ }
304
+
305
+ // ============================== Internal — view ===========================
306
+
307
+ /// @dev The vault's share price, or `0` when the read reverts. Swallowing it here is what
308
+ /// turns a broken or unlinked feed into the named {ZeroSharePrice} on a share-holding
309
+ /// position, instead of an opaque revert from inside the vault.
310
+ function _pricePerShareOrZero(address resource) private view returns (uint256) {
311
+ try ICentrifugeAsyncVault(resource).pricePerShare() returns (uint256 price) {
312
+ return price;
313
+ } catch {
314
+ return 0;
315
+ }
316
+ }
317
+
318
+ /// @dev The asset the calling YieldGroup was initialised with and can never change
319
+ /// (`address(this)` is the group under delegatecall). Deliberately not `resource.asset()`:
320
+ /// the vault is upgradeable and outside Venus control, so if it named another token —
321
+ /// another resource's share token, say — approving or transferring it would hand the vault
322
+ /// the group's balance of that token.
323
+ function _groupAsset() private view returns (IERC20) {
324
+ return IERC20(IYieldGroupBase(address(this)).asset());
325
+ }
326
+
327
+ /// @dev Reads the position once and hands it back as two subtotals, because its parts are
328
+ /// denominated differently and so price differently:
329
+ /// - `face` is the part already in asset units. It is final; no share price can move it.
330
+ /// - `shares` is the part still measured in shares. A caller must price it before adding.
331
+ /// `shareToken` comes back too, so a caller needing its decimals does not re-read it. The
332
+ /// contract header lists which counter lands in which subtotal.
333
+ function _assetAndShareTotals(address resource, address holder)
334
+ private
335
+ view
336
+ returns (uint256 face, uint256 shares, address shareToken)
337
+ {
338
+ ICentrifugeAsyncRequestManager.AsyncInvestmentState memory inv = _investments(resource, holder);
339
+
340
+ // These counters are denominated in the underlying asset.
341
+ face = uint256(inv.pendingDepositRequest) + inv.claimableCancelDepositRequest + inv.maxWithdraw;
342
+
343
+ // These balances are denominated in shares and must be converted at the vault's NAV.
344
+ shareToken = ICentrifugeAsyncVault(resource).share();
345
+ shares = IERC20(shareToken).balanceOf(holder) + uint256(inv.pendingRedeemRequest) + inv.maxMint
346
+ + inv.claimableCancelRedeemRequest;
347
+ }
348
+
349
+ /// @dev Reads all pending and claimable balances for a holder from the vault's request manager.
350
+ function _investments(address resource, address holder)
351
+ internal
352
+ view
353
+ returns (ICentrifugeAsyncRequestManager.AsyncInvestmentState memory)
354
+ {
355
+ address manager = ICentrifugeAsyncVault(resource).baseManager();
356
+ return ICentrifugeAsyncRequestManager(manager).investments(resource, holder);
357
+ }
358
+ }
@@ -0,0 +1,64 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ import { IResourceAdapter } from "./IResourceAdapter.sol";
5
+
6
+ /**
7
+ * @title IAdapterCentrifuge
8
+ * @author Venus
9
+ * @notice `IResourceAdapter` plus the Centrifuge-only surface: the async request / cancel / claim
10
+ * primitives, which are delegatecall-only and run in the YieldGroup's context, and the
11
+ * holder-aware `depositRoom` view behind `YieldGroupCentrifuge._resourceDepositRoom`.
12
+ */
13
+ interface IAdapterCentrifuge is IResourceAdapter {
14
+ // ------------------------- Mutating (delegatecall-only) -------------------
15
+
16
+ /// @notice Open an async redeem request: `shares` move from the group into Centrifuge escrow.
17
+ function requestRedeem(address resource, uint256 shares) external;
18
+
19
+ /// @notice Ask Centrifuge to return the group's pending deposit; blocks new deposit requests
20
+ /// until fulfilled.
21
+ function cancelDepositRequest(address resource) external;
22
+
23
+ /// @notice Ask Centrifuge to return the group's pending redeem shares; blocks new redeem
24
+ /// requests until fulfilled.
25
+ function cancelRedeemRequest(address resource) external;
26
+
27
+ /// @notice Claim all issued shares (`investments().maxMint`) via exact-amount `mint`. Returns
28
+ /// 0 and does nothing when there is nothing to claim.
29
+ function claimDeposit(address resource) external returns (uint256 shares);
30
+
31
+ /// @notice Claim all reserved assets (`investments().maxWithdraw`) via exact-amount
32
+ /// `withdraw`. Returns 0 and does nothing when there is nothing to claim.
33
+ function claimRedeem(address resource) external returns (uint256 assets);
34
+
35
+ /// @notice Collect a fulfilled deposit cancel (`claimableCancelDepositRequest`) as idle assets.
36
+ function claimCancelDeposit(address resource) external returns (uint256 assets);
37
+
38
+ /// @notice Collect a fulfilled redeem cancel (`claimableCancelRedeemRequest`) as held shares.
39
+ function claimCancelRedeem(address resource) external returns (uint256 shares);
40
+
41
+ // --------------------------------- Views ----------------------------------
42
+
43
+ /// @notice Holder-aware deposit headroom: 0 while `holder` has a deposit cancel in flight,
44
+ /// since Centrifuge rejects new requests then; otherwise the uint128 counter headroom.
45
+ /// `IResourceAdapter.maxDeposit` has no holder parameter and cannot express this, so
46
+ /// `YieldGroupCentrifuge._resourceDepositRoom` routes through here instead.
47
+ function depositRoom(address resource, address holder) external view returns (uint256 room);
48
+
49
+ /**
50
+ * @notice The vault's current NAV price for one whole share, in asset units.
51
+ * @dev Plain CALL, not delegatecall — it reads no group state. A thin passthrough to
52
+ * `ICentrifugeAsyncVault.pricePerShare`, exposed so callers can read the mark without
53
+ * importing the vault interface itself. Returns the raw reading, `0` included: an unset
54
+ * price only matters when the position actually holds shares, which is
55
+ * {IResourceAdapter.totalAssets}'s call to make, not this one's.
56
+ *
57
+ * A diagnostic rather than part of valuation: `YieldGroupCentrifuge` bounds what the
58
+ * position is worth, so this is the number to read alongside a clamped valuation to see
59
+ * whether the price moved or the share count did.
60
+ * @param resource Centrifuge vault to price.
61
+ * @return price Asset units one whole share converts to at the vault's current NAV.
62
+ */
63
+ function pricePerShare(address resource) external view returns (uint256 price);
64
+ }