@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.
- package/README.md +1 -1
- package/contracts/YieldGroup/YieldGroupCentrifuge.sol +436 -0
- package/contracts/YieldGroup/base/NavGuard.sol +412 -0
- package/contracts/YieldGroup/base/YieldGroupBase.sol +22 -2
- package/contracts/adapters/AdapterCentrifuge.sol +358 -0
- package/contracts/interfaces/IAdapterCentrifuge.sol +64 -0
- package/contracts/interfaces/INavGuard.sol +339 -0
- package/contracts/interfaces/IYieldGroupCentrifuge.sol +248 -0
- package/contracts/interfaces/external/ICentrifugeAsyncRequestManager.sol +43 -0
- package/contracts/interfaces/external/ICentrifugeAsyncVault.sol +94 -0
- package/deployments/bscmainnet/AdapterCentrifuge.json +421 -0
- package/deployments/bscmainnet/CentrifugeBeacon.json +110 -0
- package/deployments/bscmainnet/CentrifugeSource_USDT.json +1708 -0
- package/deployments/bscmainnet/YieldGroupCentrifugeImpl.json +1708 -0
- package/deployments/bscmainnet_addresses.json +4 -0
- package/deployments/bsctestnet/AdapterCentrifuge.json +421 -0
- package/deployments/bsctestnet/CentrifugeBeacon.json +110 -0
- package/deployments/bsctestnet/CentrifugeSource_USDT.json +1708 -0
- package/deployments/bsctestnet/MockCentrifugeShare_USDT.json +472 -0
- package/deployments/bsctestnet/MockCentrifugeVault_USDT.json +1284 -0
- package/deployments/bsctestnet/YieldGroupCentrifugeImpl.json +1708 -0
- package/deployments/bsctestnet_addresses.json +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -281,7 +281,7 @@ Every privileged call is gated by `AccessControlManagerV8` — the role is `kecc
|
|
|
281
281
|
| `pauseHub` | ✅ | ✅ (also Guardian) |
|
|
282
282
|
| `deposit` / `mint` / `withdraw` / `redeem`, `accrueFees`, views | permissionless | permissionless |
|
|
283
283
|
|
|
284
|
-
**ACM role strings.** Hub: `addYieldGroup(address,uint256,uint16)`, `removeYieldGroup(address)`, `raiseYieldGroupCap(...)`, `lowerYieldGroupCap(...)`, `setOuterDepositQueue(address[])`, `setOuterWithdrawQueue(address[])`, `pauseHub()`, `unpauseHub()`, `pauseYieldGroup(address)`, `unpauseYieldGroup(address)`, `raiseMaxWithdrawalSize(uint256)`, `lowerMaxWithdrawalSize(uint256)`, `setManagementFeeBps(uint16)`, `setPerformanceFeeBps(uint16)`, `setRedeemFeeBps(uint16)`, `setFeeRecipient(address)`, `sweep(address,address)`, `reallocate(...)`, `emergencyReallocate(...)`. Each YieldGroup: `addResource(address,address)`, `removeResource(address)`, `updateResourceAdapter(address,address)` (governance-only, repoints a resource's delegatecall adapter), `setInnerDepositQueue(address[])`, `setInnerWithdrawQueue(address[])`, `pauseResource(address)`, `unpauseResource(address)`, `sweep(address,address)`, and — Core & Flux only — `raiseResourceCap(address,uint256)`, `lowerResourceCap(address,uint256)`, `setBlocksPerYear(uint256)`; and —
|
|
284
|
+
**ACM role strings.** Hub: `addYieldGroup(address,uint256,uint16)`, `removeYieldGroup(address)`, `raiseYieldGroupCap(...)`, `lowerYieldGroupCap(...)`, `setOuterDepositQueue(address[])`, `setOuterWithdrawQueue(address[])`, `pauseHub()`, `unpauseHub()`, `pauseYieldGroup(address)`, `unpauseYieldGroup(address)`, `raiseMaxWithdrawalSize(uint256)`, `lowerMaxWithdrawalSize(uint256)`, `setManagementFeeBps(uint16)`, `setPerformanceFeeBps(uint16)`, `setRedeemFeeBps(uint16)`, `setFeeRecipient(address)`, `sweep(address,address)`, `reallocate(...)`, `emergencyReallocate(...)`. Each YieldGroup: `addResource(address,address)`, `removeResource(address)`, `updateResourceAdapter(address,address)` (governance-only, repoints a resource's delegatecall adapter), `setInnerDepositQueue(address[])`, `setInnerWithdrawQueue(address[])`, `pauseResource(address)`, `unpauseResource(address)`, `sweep(address,address)`, and — Core & Flux only — `raiseResourceCap(address,uint256)`, `lowerResourceCap(address,uint256)`, `setBlocksPerYear(uint256)`; — FRV & Centrifuge — `forceRemoveResource(address)`; and — Centrifuge only — `requestRedeem(address,uint256)`, `cancelDepositRequest(address)`, `cancelRedeemRequest(address)`, `claimDeposit(address)`, `claimRedeem(address)`, `claimCancelDeposit(address)`, `claimCancelRedeem(address)`, `setNavGuardRate(address,uint16,uint16,uint16,uint32,bool,bool)`, `setNavGuardSnapshot(address,uint128,uint64)`, `setNavGuardEnabled(address,bool,bool)`, `setSpotAPYBps(address,uint64)`. An onboarding VIP that skips the three `setNavGuard*` roles leaves that resource's NAV band unarmed for good: nothing else can arm it, and an unarmed band reports the counterparty's value to the Hub unclamped.
|
|
285
285
|
|
|
286
286
|
## Invariants & safety
|
|
287
287
|
|
|
@@ -0,0 +1,436 @@
|
|
|
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 { Address } from "@openzeppelin/contracts/utils/Address.sol";
|
|
6
|
+
|
|
7
|
+
import { IAdapterCentrifuge } from "../interfaces/IAdapterCentrifuge.sol";
|
|
8
|
+
import { IHub } from "../interfaces/IHub.sol";
|
|
9
|
+
import { IResourceAdapter } from "../interfaces/IResourceAdapter.sol";
|
|
10
|
+
import { IYieldGroupBase } from "../interfaces/IYieldGroupBase.sol";
|
|
11
|
+
import { IYieldGroupCentrifuge } from "../interfaces/IYieldGroupCentrifuge.sol";
|
|
12
|
+
import { NavGuard } from "./base/NavGuard.sol";
|
|
13
|
+
import { YieldGroupBase } from "./base/YieldGroupBase.sol";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @title YieldGroupCentrifuge
|
|
17
|
+
* @author Venus
|
|
18
|
+
* @notice Connects a Hub to one or more Centrifuge ERC-7540 vaults, such as JTRSY and JAAA,
|
|
19
|
+
* that use the same underlying asset. Each proxy belongs to one Hub and asset.
|
|
20
|
+
* @dev The Hub routes assets synchronously, while Centrifuge fulfils requests asynchronously. This
|
|
21
|
+
* contract adds the missing lifecycle operations: redeem and cancellation requests, claims
|
|
22
|
+
* after settlement, holder-specific deposit capacity, an optional valuation band, and
|
|
23
|
+
* forced removal, while the Hub is paused, when governance writes off a resource.
|
|
24
|
+
*
|
|
25
|
+
* PRICING. Centrifuge is a separate protocol with its own accounting, and a position here is
|
|
26
|
+
* worth `face + shares x price` — every term of which Centrifuge computes off-chain and
|
|
27
|
+
* publishes to BSC. The guards below are how those published numbers are carried into the
|
|
28
|
+
* Hub's NAV safely: the Hub's share price is built on them and every Hub flow reads it, so an
|
|
29
|
+
* unexpected value in any term has to be bounded before it reaches that far. Two defences:
|
|
30
|
+
*
|
|
31
|
+
* 1. Is there a price at all? The adapter reverts on a zero share price
|
|
32
|
+
* (`AdapterCentrifuge.ZeroSharePrice`). Always on, not configurable.
|
|
33
|
+
* 2. Is the VALUE plausible? {setNavGuardRate} arms a band around what the position is worth,
|
|
34
|
+
* and `totalAssets()` reports it clamped into that band. Opt-in per resource, and it never
|
|
35
|
+
* reverts.
|
|
36
|
+
*
|
|
37
|
+
* Defence 1 binds only where a price is actually read, so it never fires for a position
|
|
38
|
+
* holding nothing measured in shares — it is worth the same at any price. Defence 2 binds on
|
|
39
|
+
* every valuation, priced or not, since the face counters are published the same way.
|
|
40
|
+
*
|
|
41
|
+
* What the band does on each side is not symmetric, and the difference matters. Above the cap
|
|
42
|
+
* it protects the Hub: an inflated position is reported lower than Centrifuge published it, so
|
|
43
|
+
* nobody withdraws against value that is not there. Below the floor it does the opposite — a
|
|
44
|
+
* real loss is reported higher than it is, so the markdown is rate-limited rather than landing
|
|
45
|
+
* at once, and whoever exits during that walk-down is paid ahead of whoever exits after.
|
|
46
|
+
* Accepted deliberately: the band is denominated in the underlying asset, not a reference
|
|
47
|
+
* currency, so what it can under-report by is a fund's own drawdown rather than a token price
|
|
48
|
+
* collapsing, and `downGapBps` is sized against that. See `INavGuard.setNavGuardRate`.
|
|
49
|
+
*
|
|
50
|
+
* The band covers the price risk too: with a resource's value bounded, its shares cannot be
|
|
51
|
+
* valued above the band whatever price is published. What it bounds is the RATE at which a
|
|
52
|
+
* wrong value can move valuation, not the total, and that is what buys the time to notice it.
|
|
53
|
+
*
|
|
54
|
+
* State preparation: the adapter includes held, pending, and claimable balances in valuation,
|
|
55
|
+
* so `_prepareResource` does not need to update the vault before a read.
|
|
56
|
+
*
|
|
57
|
+
* Claims: keepers explicitly collect each claimable bucket. A resource-targeted withdrawal can
|
|
58
|
+
* also collect settled redeem assets through `AdapterCentrifuge.withdraw`. {accrue} claims
|
|
59
|
+
* nothing — it only re-anchors the NAV bands — so Hub fee accrual does not proactively move
|
|
60
|
+
* claimable value into this contract. A claim preserves the position's value but may move it from a resource-specific
|
|
61
|
+
* bucket to idle assets, which changes the withdrawal path that can reach it. The two claims
|
|
62
|
+
* that move value out of the vault also tell the band, for the reason given on {_dispatchDeposit}.
|
|
63
|
+
*
|
|
64
|
+
* Deposit limits: `_minDeposit` remains zero because the current Centrifuge vault does not
|
|
65
|
+
* expose or enforce an on-chain minimum.
|
|
66
|
+
*
|
|
67
|
+
* Deposit cancellation: Centrifuge rejects new deposits while this group's cancellation is
|
|
68
|
+
* pending. `_resourceDepositRoom` reads that holder-specific state and reports zero capacity
|
|
69
|
+
* until the cancellation settles, so the Hub does not route another deposit to the vault.
|
|
70
|
+
*
|
|
71
|
+
* Reallocation withdrawals depend on the leg's `resource`. A nonzero resource targets one
|
|
72
|
+
* vault through `withdrawResource`. This path can pull only redeem assets that Centrifuge has
|
|
73
|
+
* already settled and exposed through `maxWithdraw`; it does not open a new redeem request or
|
|
74
|
+
* use assets held idle by this contract.
|
|
75
|
+
*
|
|
76
|
+
* Settled redeem assets stay in Centrifuge as `maxWithdraw` until something claims them.
|
|
77
|
+
* `AdapterCentrifuge.withdraw` claims them on the way out, so a targeted leg can reach them
|
|
78
|
+
* without a keeper call. Once a keeper claims them into this contract, they become idle and
|
|
79
|
+
* are no longer attributable to one resource.
|
|
80
|
+
*
|
|
81
|
+
* A processed deposit cancellation remains in `claimableCancelDepositRequest` until a keeper
|
|
82
|
+
* claims it, after which it also becomes idle. It never enters `maxWithdraw`, so a targeted
|
|
83
|
+
* withdrawal cannot reach it before or after the claim.
|
|
84
|
+
*
|
|
85
|
+
* To withdraw idle assets, the reallocation leg must use `resource == address(0)`. The Hub then
|
|
86
|
+
* calls the group-level `withdraw`, which spends idle assets before using the withdraw queue.
|
|
87
|
+
*
|
|
88
|
+
* Sweep bars `asset()` and every registered resource. Here the resource is the vault, while
|
|
89
|
+
* the tokens this contract holds are the vault's `share()`, so `sweep` can move the whole fund
|
|
90
|
+
* position out. That is deliberate, not an oversight. It tells the band nothing, though, so a
|
|
91
|
+
* sweep that leaves anything behind leaves the floor still reporting the swept value.
|
|
92
|
+
* Re-anchor with {INavGuard.setNavGuardSnapshot} in the same transaction.
|
|
93
|
+
*/
|
|
94
|
+
contract YieldGroupCentrifuge is YieldGroupBase, NavGuard, IYieldGroupCentrifuge {
|
|
95
|
+
using Address for address;
|
|
96
|
+
|
|
97
|
+
// ============================== State =====================================
|
|
98
|
+
|
|
99
|
+
/// @notice The annual rate governance has published for each resource, in basis points.
|
|
100
|
+
/// @dev Centrifuge reports a fund's return as a new NAV and publishes no rate on-chain, so
|
|
101
|
+
/// nothing here can derive or check this figure — it is whatever governance last set.
|
|
102
|
+
/// Cleared on removal, or a re-added vault would come back advertising a stale rate.
|
|
103
|
+
mapping(address resource => uint64 apyBps) internal _spotAPYBps;
|
|
104
|
+
|
|
105
|
+
/// @dev Reserved slots for future Centrifuge-only state. Shrink by the number of slots a new
|
|
106
|
+
/// field consumes when extending.
|
|
107
|
+
uint256[19] private __gap;
|
|
108
|
+
|
|
109
|
+
// ============================== External — mutating =======================
|
|
110
|
+
|
|
111
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
112
|
+
function initialize(address hub_, address asset_, address acm_) external initializer {
|
|
113
|
+
// No per-block rate to annualise: Centrifuge reports yield as NAV changes, and the rate the
|
|
114
|
+
// group advertises is set whole by {setSpotAPYBps}, already annual.
|
|
115
|
+
__YieldGroupBase_init(hub_, asset_, 0, acm_);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -- Operator: async redeem / cancels --
|
|
119
|
+
|
|
120
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
121
|
+
/// @dev Allowed while the resource is paused as a wind-down action. Cancelling a redeem request
|
|
122
|
+
/// is asynchronous and may block another redeem request until Centrifuge settles the cancellation.
|
|
123
|
+
function requestRedeem(address resource, uint256 shares) external override nonReentrant {
|
|
124
|
+
_checkAccessAllowed("requestRedeem(address,uint256)");
|
|
125
|
+
if (shares == 0) revert ZeroShares();
|
|
126
|
+
address adapter = _requireRegistered(resource);
|
|
127
|
+
adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.requestRedeem, (resource, shares)));
|
|
128
|
+
emit RedeemRequested(resource, shares);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
132
|
+
function cancelDepositRequest(address resource) external override nonReentrant {
|
|
133
|
+
_checkAccessAllowed("cancelDepositRequest(address)");
|
|
134
|
+
address adapter = _requireRegistered(resource);
|
|
135
|
+
adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.cancelDepositRequest, (resource)));
|
|
136
|
+
emit DepositCancelRequested(resource);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
140
|
+
function cancelRedeemRequest(address resource) external override nonReentrant {
|
|
141
|
+
_checkAccessAllowed("cancelRedeemRequest(address)");
|
|
142
|
+
address adapter = _requireRegistered(resource);
|
|
143
|
+
adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.cancelRedeemRequest, (resource)));
|
|
144
|
+
emit RedeemCancelRequested(resource);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// -- Keeper: claims (all idempotent; zero-claimable is a silent no-op) --
|
|
148
|
+
|
|
149
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
150
|
+
function claimDeposit(address resource) external override nonReentrant returns (uint256 shares) {
|
|
151
|
+
_checkAccessAllowed("claimDeposit(address)");
|
|
152
|
+
return _claimDeposit(resource, _requireRegistered(resource));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
156
|
+
function claimRedeem(address resource) external override nonReentrant returns (uint256 assets) {
|
|
157
|
+
_checkAccessAllowed("claimRedeem(address)");
|
|
158
|
+
return _claimRedeem(resource, _requireRegistered(resource));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
162
|
+
function claimCancelDeposit(address resource) external override nonReentrant returns (uint256 assets) {
|
|
163
|
+
_checkAccessAllowed("claimCancelDeposit(address)");
|
|
164
|
+
return _claimCancelDeposit(resource, _requireRegistered(resource));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
168
|
+
function claimCancelRedeem(address resource) external override nonReentrant returns (uint256 shares) {
|
|
169
|
+
_checkAccessAllowed("claimCancelRedeem(address)");
|
|
170
|
+
return _claimCancelRedeem(resource, _requireRegistered(resource));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// -- Hub: per-block poke --
|
|
174
|
+
|
|
175
|
+
/// @inheritdoc IYieldGroupBase
|
|
176
|
+
/// @dev There's no interest to settle here — Centrifuge reports yield as NAV, not a
|
|
177
|
+
/// block-lazy exchange rate — so this re-anchors each resource's NAV band instead, and
|
|
178
|
+
/// logs a clamp the band is applying.
|
|
179
|
+
///
|
|
180
|
+
/// The Hub only reaches this once per block, because `Hub._accrueFees` returns early on
|
|
181
|
+
/// any later call in the same block. That is fine: a band moves at most once per its own
|
|
182
|
+
/// `interval`, which is orders of magnitude longer. It also means this is not where the
|
|
183
|
+
/// band is enforced — Centrifuge could report a different value later in the same block
|
|
184
|
+
/// and this function would never see it. Clamping is `totalAssets()`'s job, on every read.
|
|
185
|
+
///
|
|
186
|
+
/// MUST NOT REVERT: `Hub._accrueResources` has no try/catch, so a revert here takes down
|
|
187
|
+
/// every Hub flow. `NavGuard._updateNavGuard` absorbs its own failures, and
|
|
188
|
+
/// {_observedNav} swallows an unreadable position rather than propagating it.
|
|
189
|
+
function accrue() external override(YieldGroupBase, IYieldGroupBase) onlyHub {
|
|
190
|
+
address[] storage list = _resources;
|
|
191
|
+
uint256 len = list.length;
|
|
192
|
+
for (uint256 i; i < len; ++i) {
|
|
193
|
+
_updateNavGuard(list[i]);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// -- Governance: write-off removal --
|
|
198
|
+
|
|
199
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
200
|
+
function forceRemoveResource(address resource) external override nonReentrant {
|
|
201
|
+
_checkAccessAllowed("forceRemoveResource(address)");
|
|
202
|
+
ResourceConfig storage cfg = _resourcesConfig[resource];
|
|
203
|
+
if (!cfg.registered) revert ResourceNotRegistered(resource);
|
|
204
|
+
// Normal removal requires an empty position. This escape hatch lets governance write off a
|
|
205
|
+
// position that Centrifuge has frozen or unlinked and that can no longer be claimed. Nothing
|
|
206
|
+
// on-chain proves the position is unrecoverable, so removing a funded resource immediately
|
|
207
|
+
// removes its remaining value from the Hub's NAV.
|
|
208
|
+
//
|
|
209
|
+
// The pause keeps that drop untradeable, and out of the exchange-rate monitoring that would
|
|
210
|
+
// otherwise read it as an incident. `pauseHub` makes no external call, so a bricked
|
|
211
|
+
// `totalAssets()` cannot block pause -> remove -> unpause.
|
|
212
|
+
if (!IHub(_hub).hubPaused()) revert HubNotPaused();
|
|
213
|
+
|
|
214
|
+
address adapter = cfg.adapter;
|
|
215
|
+
// These values are only used for the event, and the value is the raw one — what the
|
|
216
|
+
// position is actually worth, not what the band would have reported. A broken price feed or
|
|
217
|
+
// unlinked vault may make either read fail, but must not prevent removal. A recorded zero
|
|
218
|
+
// may therefore mean that the value could not be read.
|
|
219
|
+
uint256 writtenOffValue;
|
|
220
|
+
try IResourceAdapter(adapter).totalAssets(resource, address(this)) returns (uint256 value) {
|
|
221
|
+
writtenOffValue = value;
|
|
222
|
+
} catch { }
|
|
223
|
+
uint256 orphanedReceipt;
|
|
224
|
+
try IResourceAdapter(adapter).receiptBalance(resource, address(this)) returns (uint256 held) {
|
|
225
|
+
orphanedReceipt = held;
|
|
226
|
+
} catch { }
|
|
227
|
+
|
|
228
|
+
uint256 idxPlusOne = _resourceIndex[resource];
|
|
229
|
+
uint256 idx = idxPlusOne - 1;
|
|
230
|
+
uint256 lastIdx = _resources.length - 1;
|
|
231
|
+
if (idx != lastIdx) {
|
|
232
|
+
address last = _resources[lastIdx];
|
|
233
|
+
_resources[idx] = last;
|
|
234
|
+
_resourceIndex[last] = idxPlusOne;
|
|
235
|
+
}
|
|
236
|
+
_resources.pop();
|
|
237
|
+
delete _resourceIndex[resource];
|
|
238
|
+
|
|
239
|
+
_removeFromQueue(_innerDepositQueue, resource);
|
|
240
|
+
_removeFromQueue(_innerWithdrawQueue, resource);
|
|
241
|
+
|
|
242
|
+
delete _resourcesConfig[resource];
|
|
243
|
+
_onResourceRemoved(resource);
|
|
244
|
+
|
|
245
|
+
emit ResourceForceRemoved(resource, writtenOffValue, orphanedReceipt);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// -- Governance: published spot APY --
|
|
249
|
+
|
|
250
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
251
|
+
function setSpotAPYBps(address resource, uint64 apyBps) external override {
|
|
252
|
+
_checkAccessAllowed("setSpotAPYBps(address,uint64)");
|
|
253
|
+
_requireRegistered(resource);
|
|
254
|
+
uint64 oldApyBps = _spotAPYBps[resource];
|
|
255
|
+
_spotAPYBps[resource] = apyBps;
|
|
256
|
+
emit SpotAPYBpsSet(resource, oldApyBps, apyBps);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ============================== External — view ============================
|
|
260
|
+
|
|
261
|
+
/// @inheritdoc IYieldGroupBase
|
|
262
|
+
/// @dev Sums each resource's value from its adapter, clamped to that resource's NAV band, plus
|
|
263
|
+
/// the group's idle assets. The band is applied here rather than in the adapter because
|
|
264
|
+
/// the adapter is stateless and shared; nothing else calls the adapter for a valuation the
|
|
265
|
+
/// Hub will use, so nothing can reach the Hub around the band.
|
|
266
|
+
///
|
|
267
|
+
/// Idle assets are added unclamped. They are this contract's own ERC-20 balance, not a
|
|
268
|
+
/// number Centrifuge reports, so there is nothing about them to disbelieve.
|
|
269
|
+
///
|
|
270
|
+
/// One thing makes this revert, on purpose, and it comes from the adapter: a missing or
|
|
271
|
+
/// zero share price ({IAdapterCentrifuge.ZeroSharePrice}). It only reaches a position
|
|
272
|
+
/// holding something denominated in shares, because that is the only case where a price is
|
|
273
|
+
/// read; one made up entirely of asset-denominated balances is valued without touching the
|
|
274
|
+
/// feed. The Hub sums every group's `totalAssets()`, so it halts every Hub flow —
|
|
275
|
+
/// including for depositors with no Centrifuge exposure. A fresh price clears it.
|
|
276
|
+
///
|
|
277
|
+
/// The band never reverts. A value outside it is reported at the bound, and only
|
|
278
|
+
/// `navGuardStatus` says so.
|
|
279
|
+
function totalAssets() external view override(YieldGroupBase, IYieldGroupBase) returns (uint256 total) {
|
|
280
|
+
address[] storage list = _resources;
|
|
281
|
+
uint256 len = list.length;
|
|
282
|
+
for (uint256 i; i < len; ++i) {
|
|
283
|
+
address resource = list[i];
|
|
284
|
+
address adapter = _resourcesConfig[resource].adapter;
|
|
285
|
+
total += _guardedNav(resource, IResourceAdapter(adapter).totalAssets(resource, address(this)));
|
|
286
|
+
}
|
|
287
|
+
total += IERC20(_assetToken).balanceOf(address(this));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// @inheritdoc IYieldGroupCentrifuge
|
|
291
|
+
function resourceSpotAPYBps(address resource) external view override returns (uint64) {
|
|
292
|
+
return _spotAPYBps[resource];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ============================== Internal — value bounds ==================
|
|
296
|
+
|
|
297
|
+
/// @inheritdoc YieldGroupBase
|
|
298
|
+
/// @dev Clears the vault's NAV band and its published rate. Re-adding the resource means arming
|
|
299
|
+
/// the band and publishing the rate again.
|
|
300
|
+
function _onResourceRemoved(address resource) internal override {
|
|
301
|
+
_clearNavGuard(resource);
|
|
302
|
+
delete _spotAPYBps[resource];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// @dev Gate for the three governance setters inherited from `NavGuard`. Each gets the same ACM
|
|
306
|
+
/// check every other function here uses, so a VIP grants their roles the way it grants
|
|
307
|
+
/// {forceRemoveResource}'s, then {ResourceNotRegistered} on a resource this group doesn't
|
|
308
|
+
/// manage. Auth first, as the hook requires.
|
|
309
|
+
function _requireGuardConfig(string memory signature, address resource) internal view override {
|
|
310
|
+
_checkAccessAllowed(signature);
|
|
311
|
+
_requireRegistered(resource);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/// @dev The value `NavGuard` bounds: what the adapter says the position is worth right now,
|
|
315
|
+
/// unclamped. Not read by valuation, which clamps the read `totalAssets()` already holds.
|
|
316
|
+
/// A reverting read answers `readable = false` rather than propagating, which is what
|
|
317
|
+
/// stops an unreachable vault taking down every Hub flow.
|
|
318
|
+
///
|
|
319
|
+
/// The adapter reverts {IAdapterCentrifuge.ZeroSharePrice} whenever the position holds
|
|
320
|
+
/// shares and the published price is zero, so this is not only an unreachable vault: a
|
|
321
|
+
/// price the publisher has not set yet lands here too.
|
|
322
|
+
function _observedNav(address resource) internal view override returns (uint256, bool) {
|
|
323
|
+
try IResourceAdapter(_resourcesConfig[resource].adapter).totalAssets(resource, address(this)) returns (
|
|
324
|
+
uint256 value
|
|
325
|
+
) {
|
|
326
|
+
return (value, true);
|
|
327
|
+
} catch {
|
|
328
|
+
return (0, false);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// @dev Tell the band that Venus itself put `amount` into the vault, on top of routing it.
|
|
333
|
+
/// Without this it reads our own deposit as Centrifuge inflating the position: routing 1M
|
|
334
|
+
/// moves the position's value by 1M in one transaction, past any plausible band.
|
|
335
|
+
///
|
|
336
|
+
/// Route first, record second. Either one failing unwinds the other, so the band is never
|
|
337
|
+
/// told about value that did not move, and value never moves without the band being told.
|
|
338
|
+
function _dispatchDeposit(address adapter, address resource, uint256 amount) internal override {
|
|
339
|
+
IERC20 assetToken = IERC20(_assetToken);
|
|
340
|
+
uint256 balBefore = assetToken.balanceOf(address(this));
|
|
341
|
+
|
|
342
|
+
super._dispatchDeposit(adapter, resource, amount);
|
|
343
|
+
|
|
344
|
+
// Record what left, not what we asked: a vault booking a request without pulling widens the cap for free.
|
|
345
|
+
uint256 balAfter = assetToken.balanceOf(address(this));
|
|
346
|
+
_recordInflow(resource, balBefore > balAfter ? balBefore - balAfter : 0);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/// @dev The mirror of {_dispatchDeposit}. `AdapterCentrifuge.withdraw` opens no request — it
|
|
350
|
+
/// only collects claims Centrifuge has already settled into `maxWithdraw` and forwards
|
|
351
|
+
/// them to `to`. So the position shrinks by what it delivered, which is why the band is
|
|
352
|
+
/// told here, and on `received` rather than `amount`.
|
|
353
|
+
function _dispatchWithdraw(address adapter, address resource, uint256 amount, address to)
|
|
354
|
+
internal
|
|
355
|
+
override
|
|
356
|
+
returns (uint256 received)
|
|
357
|
+
{
|
|
358
|
+
received = super._dispatchWithdraw(adapter, resource, amount, to);
|
|
359
|
+
_recordOutflow(resource, received);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ============================== Internal — hooks ==========================
|
|
363
|
+
|
|
364
|
+
/// @dev Unlike `IResourceAdapter.maxDeposit`, `depositRoom` can inspect this YieldGroup's state
|
|
365
|
+
/// for the vault. It returns zero while this group has a deposit cancellation pending, so
|
|
366
|
+
/// the Hub does not advertise capacity that Centrifuge would reject.
|
|
367
|
+
function _resourceDepositRoom(address resource, address adapter) internal view override returns (uint256) {
|
|
368
|
+
return IAdapterCentrifuge(adapter).depositRoom(resource, address(this));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ============================== Private ===================================
|
|
372
|
+
|
|
373
|
+
/// @dev Verifies registration and returns the resource's adapter.
|
|
374
|
+
function _requireRegistered(address resource) private view returns (address adapter) {
|
|
375
|
+
ResourceConfig storage cfg = _resourcesConfig[resource];
|
|
376
|
+
if (!cfg.registered) revert ResourceNotRegistered(resource);
|
|
377
|
+
return cfg.adapter;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/// @dev Claims available deposit shares. Does nothing and emits no event when the amount is zero.
|
|
381
|
+
/// The band already counted this deposit at {_dispatchDeposit}, while it was still pending.
|
|
382
|
+
/// Settling it and claiming it only move it between buckets at the same value, so recording
|
|
383
|
+
/// it again here would count the same assets twice.
|
|
384
|
+
function _claimDeposit(address resource, address adapter) private returns (uint256 shares) {
|
|
385
|
+
bytes memory ret = adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.claimDeposit, (resource)));
|
|
386
|
+
shares = abi.decode(ret, (uint256));
|
|
387
|
+
if (shares > 0) emit DepositClaimed(resource, shares);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// @dev Runs an asset-delivering claim and returns what actually landed on this contract's
|
|
391
|
+
/// balance. The adapter's own return value is skipped: it is the counter Centrifuge said
|
|
392
|
+
/// it would pay out, and the band and the events have to carry what arrived.
|
|
393
|
+
function _claimMeasuredAssets(address adapter, bytes memory data) private returns (uint256 assets) {
|
|
394
|
+
IERC20 assetToken = IERC20(_assetToken);
|
|
395
|
+
uint256 before = assetToken.balanceOf(address(this));
|
|
396
|
+
adapter.functionDelegateCall(data);
|
|
397
|
+
assets = assetToken.balanceOf(address(this)) - before;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/// @dev Claims available redemption assets. Does nothing and emits no event when the amount is zero.
|
|
401
|
+
///
|
|
402
|
+
/// Like {_dispatchWithdraw}, this collects claims Centrifuge has already confirmed into
|
|
403
|
+
/// `maxWithdraw`. The difference is only where they stop: here they become this contract's
|
|
404
|
+
/// idle balance instead of going on to a recipient. `totalAssets()` counts idle separately
|
|
405
|
+
/// from the resource, so the band still has to be told the resource shed that amount.
|
|
406
|
+
function _claimRedeem(address resource, address adapter) private returns (uint256 assets) {
|
|
407
|
+
assets = _claimMeasuredAssets(adapter, abi.encodeCall(IAdapterCentrifuge.claimRedeem, (resource)));
|
|
408
|
+
if (assets > 0) {
|
|
409
|
+
_recordOutflow(resource, assets);
|
|
410
|
+
emit RedeemClaimed(resource, assets);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/// @dev Claims assets from a cancelled deposit. Does nothing and emits no event when the amount is zero.
|
|
415
|
+
/// Moves value out of the resource the same way {_claimRedeem} does, and tells the band for
|
|
416
|
+
/// the same reason.
|
|
417
|
+
function _claimCancelDeposit(address resource, address adapter) private returns (uint256 assets) {
|
|
418
|
+
assets = _claimMeasuredAssets(adapter, abi.encodeCall(IAdapterCentrifuge.claimCancelDeposit, (resource)));
|
|
419
|
+
if (assets > 0) {
|
|
420
|
+
_recordOutflow(resource, assets);
|
|
421
|
+
emit DepositCancelClaimed(resource, assets);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// @dev Claims shares from a cancelled redemption. Does nothing and emits no event when the amount is zero.
|
|
426
|
+
/// The band is not told, because {requestRedeem} never told it either: that only moved the
|
|
427
|
+
/// shares from the group's balance into `pendingRedeemRequest`, both inside this resource.
|
|
428
|
+
/// Undoing the request therefore has nothing to undo. What moves the band is the CLAIM, in
|
|
429
|
+
/// {_claimRedeem}, since that is where assets actually leave the resource.
|
|
430
|
+
function _claimCancelRedeem(address resource, address adapter) private returns (uint256 shares) {
|
|
431
|
+
bytes memory ret =
|
|
432
|
+
adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.claimCancelRedeem, (resource)));
|
|
433
|
+
shares = abi.decode(ret, (uint256));
|
|
434
|
+
if (shares > 0) emit RedeemCancelClaimed(resource, shares);
|
|
435
|
+
}
|
|
436
|
+
}
|