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

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 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 — FRV only — `forceRemoveResource(address)`.
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
 
@@ -26,6 +26,8 @@ import { HubAdminLib } from "./lib/HubAdminLib.sol";
26
26
  * Inheritance order is load-bearing for upgrade safety — do not reorder.
27
27
  * Function ordering follows the Solidity style guide: external → public → internal →
28
28
  * private, mutating before view within each group.
29
+ * {HubAdminLib} is linked for EIP-170 headroom; a library declares no state, so it cannot shift this layout.
30
+ * @custom:oz-upgrades-unsafe-allow external-library-linking
29
31
  */
30
32
  contract Hub is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeable, HubStorage, IHub {
31
33
  using SafeERC20Upgradeable for IERC20Upgradeable;
@@ -0,0 +1,442 @@
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
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
124
+ function requestRedeem(address resource, uint256 shares) external override nonReentrant {
125
+ _checkAccessAllowed("requestRedeem(address,uint256)");
126
+ if (shares == 0) revert ZeroShares();
127
+ address adapter = _requireRegistered(resource);
128
+ adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.requestRedeem, (resource, shares)));
129
+ emit RedeemRequested(resource, shares);
130
+ }
131
+
132
+ /// @inheritdoc IYieldGroupCentrifuge
133
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
134
+ function cancelDepositRequest(address resource) external override nonReentrant {
135
+ _checkAccessAllowed("cancelDepositRequest(address)");
136
+ address adapter = _requireRegistered(resource);
137
+ adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.cancelDepositRequest, (resource)));
138
+ emit DepositCancelRequested(resource);
139
+ }
140
+
141
+ /// @inheritdoc IYieldGroupCentrifuge
142
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
143
+ function cancelRedeemRequest(address resource) external override nonReentrant {
144
+ _checkAccessAllowed("cancelRedeemRequest(address)");
145
+ address adapter = _requireRegistered(resource);
146
+ adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.cancelRedeemRequest, (resource)));
147
+ emit RedeemCancelRequested(resource);
148
+ }
149
+
150
+ // -- Keeper: claims (all idempotent; zero-claimable is a silent no-op) --
151
+
152
+ /// @inheritdoc IYieldGroupCentrifuge
153
+ function claimDeposit(address resource) external override nonReentrant returns (uint256 shares) {
154
+ _checkAccessAllowed("claimDeposit(address)");
155
+ return _claimDeposit(resource, _requireRegistered(resource));
156
+ }
157
+
158
+ /// @inheritdoc IYieldGroupCentrifuge
159
+ function claimRedeem(address resource) external override nonReentrant returns (uint256 assets) {
160
+ _checkAccessAllowed("claimRedeem(address)");
161
+ return _claimRedeem(resource, _requireRegistered(resource));
162
+ }
163
+
164
+ /// @inheritdoc IYieldGroupCentrifuge
165
+ function claimCancelDeposit(address resource) external override nonReentrant returns (uint256 assets) {
166
+ _checkAccessAllowed("claimCancelDeposit(address)");
167
+ return _claimCancelDeposit(resource, _requireRegistered(resource));
168
+ }
169
+
170
+ /// @inheritdoc IYieldGroupCentrifuge
171
+ function claimCancelRedeem(address resource) external override nonReentrant returns (uint256 shares) {
172
+ _checkAccessAllowed("claimCancelRedeem(address)");
173
+ return _claimCancelRedeem(resource, _requireRegistered(resource));
174
+ }
175
+
176
+ // -- Hub: per-block poke --
177
+
178
+ /// @inheritdoc IYieldGroupBase
179
+ /// @dev There's no interest to settle here — Centrifuge reports yield as NAV, not a
180
+ /// block-lazy exchange rate — so this re-anchors each resource's NAV band instead, and
181
+ /// logs a clamp the band is applying.
182
+ ///
183
+ /// The Hub only reaches this once per block, because `Hub._accrueFees` returns early on
184
+ /// any later call in the same block. That is fine: a band moves at most once per its own
185
+ /// `interval`, which is orders of magnitude longer. It also means this is not where the
186
+ /// band is enforced — Centrifuge could report a different value later in the same block
187
+ /// and this function would never see it. Clamping is `totalAssets()`'s job, on every read.
188
+ ///
189
+ /// MUST NOT REVERT: `Hub._accrueResources` has no try/catch, so a revert here takes down
190
+ /// every Hub flow. `NavGuard._updateNavGuard` absorbs its own failures, and
191
+ /// {_observedNav} swallows an unreadable position rather than propagating it.
192
+ function accrue() external override(YieldGroupBase, IYieldGroupBase) onlyHub {
193
+ address[] storage list = _resources;
194
+ uint256 len = list.length;
195
+ for (uint256 i; i < len; ++i) {
196
+ _updateNavGuard(list[i]);
197
+ }
198
+ }
199
+
200
+ // -- Governance: write-off removal --
201
+
202
+ /// @inheritdoc IYieldGroupCentrifuge
203
+ function forceRemoveResource(address resource) external override nonReentrant {
204
+ _checkAccessAllowed("forceRemoveResource(address)");
205
+ ResourceConfig storage cfg = _resourcesConfig[resource];
206
+ if (!cfg.registered) revert ResourceNotRegistered(resource);
207
+ // Normal removal requires an empty position. This escape hatch lets governance write off a
208
+ // position that Centrifuge has frozen or unlinked and that can no longer be claimed. Nothing
209
+ // on-chain proves the position is unrecoverable, so removing a funded resource immediately
210
+ // removes its remaining value from the Hub's NAV.
211
+ //
212
+ // The pause keeps that drop untradeable, and out of the exchange-rate monitoring that would
213
+ // otherwise read it as an incident. `pauseHub` makes no external call, so a bricked
214
+ // `totalAssets()` cannot block pause -> remove -> unpause.
215
+ if (!IHub(_hub).hubPaused()) revert HubNotPaused();
216
+
217
+ address adapter = cfg.adapter;
218
+ // These values are only used for the event, and the value is the raw one — what the
219
+ // position is actually worth, not what the band would have reported. A broken price feed or
220
+ // unlinked vault may make either read fail, but must not prevent removal. A recorded zero
221
+ // may therefore mean that the value could not be read.
222
+ uint256 writtenOffValue;
223
+ try IResourceAdapter(adapter).totalAssets(resource, address(this)) returns (uint256 value) {
224
+ writtenOffValue = value;
225
+ } catch { }
226
+ uint256 orphanedReceipt;
227
+ try IResourceAdapter(adapter).receiptBalance(resource, address(this)) returns (uint256 held) {
228
+ orphanedReceipt = held;
229
+ } catch { }
230
+
231
+ uint256 idxPlusOne = _resourceIndex[resource];
232
+ uint256 idx = idxPlusOne - 1;
233
+ uint256 lastIdx = _resources.length - 1;
234
+ if (idx != lastIdx) {
235
+ address last = _resources[lastIdx];
236
+ _resources[idx] = last;
237
+ _resourceIndex[last] = idxPlusOne;
238
+ }
239
+ _resources.pop();
240
+ delete _resourceIndex[resource];
241
+
242
+ _removeFromQueue(_innerDepositQueue, resource);
243
+ _removeFromQueue(_innerWithdrawQueue, resource);
244
+
245
+ delete _resourcesConfig[resource];
246
+ _onResourceRemoved(resource);
247
+
248
+ emit ResourceForceRemoved(resource, writtenOffValue, orphanedReceipt);
249
+ }
250
+
251
+ // -- Governance: published spot APY --
252
+
253
+ /// @inheritdoc IYieldGroupCentrifuge
254
+ function setSpotAPYBps(address resource, uint64 apyBps) external override {
255
+ _checkAccessAllowed("setSpotAPYBps(address,uint64)");
256
+ _requireRegistered(resource);
257
+ uint64 oldApyBps = _spotAPYBps[resource];
258
+ _spotAPYBps[resource] = apyBps;
259
+ emit SpotAPYBpsSet(resource, oldApyBps, apyBps);
260
+ }
261
+
262
+ // ============================== External — view ============================
263
+
264
+ /// @inheritdoc IYieldGroupBase
265
+ /// @dev Sums each resource's value from its adapter, clamped to that resource's NAV band, plus
266
+ /// the group's idle assets. The band is applied here rather than in the adapter because
267
+ /// the adapter is stateless and shared; nothing else calls the adapter for a valuation the
268
+ /// Hub will use, so nothing can reach the Hub around the band.
269
+ ///
270
+ /// Idle assets are added unclamped. They are this contract's own ERC-20 balance, not a
271
+ /// number Centrifuge reports, so there is nothing about them to disbelieve.
272
+ ///
273
+ /// One thing makes this revert, on purpose, and it comes from the adapter: a missing or
274
+ /// zero share price ({IAdapterCentrifuge.ZeroSharePrice}). It only reaches a position
275
+ /// holding something denominated in shares, because that is the only case where a price is
276
+ /// read; one made up entirely of asset-denominated balances is valued without touching the
277
+ /// feed. The Hub sums every group's `totalAssets()`, so it halts every Hub flow —
278
+ /// including for depositors with no Centrifuge exposure. A fresh price clears it.
279
+ ///
280
+ /// The band never reverts. A value outside it is reported at the bound, and only
281
+ /// `navGuardStatus` says so.
282
+ function totalAssets() external view override(YieldGroupBase, IYieldGroupBase) returns (uint256 total) {
283
+ address[] storage list = _resources;
284
+ uint256 len = list.length;
285
+ for (uint256 i; i < len; ++i) {
286
+ address resource = list[i];
287
+ address adapter = _resourcesConfig[resource].adapter;
288
+ total += _guardedNav(resource, IResourceAdapter(adapter).totalAssets(resource, address(this)));
289
+ }
290
+ total += IERC20(_assetToken).balanceOf(address(this));
291
+ }
292
+
293
+ /// @inheritdoc IYieldGroupCentrifuge
294
+ function resourceSpotAPYBps(address resource) external view override returns (uint64) {
295
+ return _spotAPYBps[resource];
296
+ }
297
+
298
+ // ============================== Internal — value bounds ==================
299
+
300
+ /// @inheritdoc YieldGroupBase
301
+ /// @dev Clears the vault's NAV band and its published rate. Re-adding the resource means arming
302
+ /// the band and publishing the rate again.
303
+ function _onResourceRemoved(address resource) internal override {
304
+ _clearNavGuard(resource);
305
+ delete _spotAPYBps[resource];
306
+ }
307
+
308
+ /// @dev Gate for the three governance setters inherited from `NavGuard`. Each gets the same ACM
309
+ /// check every other function here uses, so a VIP grants their roles the way it grants
310
+ /// {forceRemoveResource}'s, then {ResourceNotRegistered} on a resource this group doesn't
311
+ /// manage. Auth first, as the hook requires.
312
+ function _requireGuardConfig(string memory signature, address resource) internal view override {
313
+ _checkAccessAllowed(signature);
314
+ _requireRegistered(resource);
315
+ }
316
+
317
+ /// @dev The value `NavGuard` bounds: what the adapter says the position is worth right now,
318
+ /// unclamped. Not read by valuation, which clamps the read `totalAssets()` already holds.
319
+ /// A reverting read answers `readable = false` rather than propagating, which is what
320
+ /// stops an unreachable vault taking down every Hub flow.
321
+ ///
322
+ /// The adapter reverts {IAdapterCentrifuge.ZeroSharePrice} whenever the position holds
323
+ /// shares and the published price is zero, so this is not only an unreachable vault: a
324
+ /// price the publisher has not set yet lands here too.
325
+ function _observedNav(address resource) internal view override returns (uint256, bool) {
326
+ try IResourceAdapter(_resourcesConfig[resource].adapter).totalAssets(resource, address(this)) returns (
327
+ uint256 value
328
+ ) {
329
+ return (value, true);
330
+ } catch {
331
+ return (0, false);
332
+ }
333
+ }
334
+
335
+ /// @dev Tell the band that Venus itself put `amount` into the vault, on top of routing it.
336
+ /// Without this it reads our own deposit as Centrifuge inflating the position: routing 1M
337
+ /// moves the position's value by 1M in one transaction, past any plausible band.
338
+ ///
339
+ /// Route first, record second. Either one failing unwinds the other, so the band is never
340
+ /// told about value that did not move, and value never moves without the band being told.
341
+ function _dispatchDeposit(address adapter, address resource, uint256 amount) internal override {
342
+ IERC20 assetToken = IERC20(_assetToken);
343
+ uint256 balBefore = assetToken.balanceOf(address(this));
344
+
345
+ super._dispatchDeposit(adapter, resource, amount);
346
+
347
+ // Record what left, not what we asked: a vault booking a request without pulling widens the cap for free.
348
+ uint256 balAfter = assetToken.balanceOf(address(this));
349
+ _recordInflow(resource, balBefore > balAfter ? balBefore - balAfter : 0);
350
+ }
351
+
352
+ /// @dev The mirror of {_dispatchDeposit}. `AdapterCentrifuge.withdraw` opens no request — it
353
+ /// only collects claims Centrifuge has already settled into `maxWithdraw` and forwards
354
+ /// them to `to`. So the position shrinks by what it delivered, which is why the band is
355
+ /// told here, and on `received` rather than `amount`.
356
+ function _dispatchWithdraw(address adapter, address resource, uint256 amount, address to)
357
+ internal
358
+ override
359
+ returns (uint256 received)
360
+ {
361
+ received = super._dispatchWithdraw(adapter, resource, amount, to);
362
+ _recordOutflow(resource, received);
363
+ }
364
+
365
+ // ============================== Internal — hooks ==========================
366
+
367
+ /// @dev Unlike `IResourceAdapter.maxDeposit`, `depositRoom` can inspect this YieldGroup's state
368
+ /// for the vault. It returns zero while this group has a deposit cancellation pending, so
369
+ /// the Hub does not advertise capacity that Centrifuge would reject.
370
+ function _resourceDepositRoom(address resource, address adapter) internal view override returns (uint256) {
371
+ return IAdapterCentrifuge(adapter).depositRoom(resource, address(this));
372
+ }
373
+
374
+ // ============================== Private ===================================
375
+
376
+ /// @dev Verifies registration and returns the resource's adapter.
377
+ function _requireRegistered(address resource) private view returns (address adapter) {
378
+ ResourceConfig storage cfg = _resourcesConfig[resource];
379
+ if (!cfg.registered) revert ResourceNotRegistered(resource);
380
+ return cfg.adapter;
381
+ }
382
+
383
+ /// @dev Claims available deposit shares. Does nothing and emits no event when the amount is zero.
384
+ /// The band already counted this deposit at {_dispatchDeposit}, while it was still pending.
385
+ /// Settling it and claiming it only move it between buckets at the same value, so recording
386
+ /// it again here would count the same assets twice.
387
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
388
+ function _claimDeposit(address resource, address adapter) private returns (uint256 shares) {
389
+ bytes memory ret = adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.claimDeposit, (resource)));
390
+ shares = abi.decode(ret, (uint256));
391
+ if (shares > 0) emit DepositClaimed(resource, shares);
392
+ }
393
+
394
+ /// @dev Runs an asset-delivering claim and returns what actually landed on this contract's
395
+ /// balance. The adapter's own return value is skipped: it is the counter Centrifuge said
396
+ /// it would pay out, and the band and the events have to carry what arrived.
397
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
398
+ function _claimMeasuredAssets(address adapter, bytes memory data) private returns (uint256 assets) {
399
+ IERC20 assetToken = IERC20(_assetToken);
400
+ uint256 before = assetToken.balanceOf(address(this));
401
+ adapter.functionDelegateCall(data);
402
+ assets = assetToken.balanceOf(address(this)) - before;
403
+ }
404
+
405
+ /// @dev Claims available redemption assets. Does nothing and emits no event when the amount is zero.
406
+ ///
407
+ /// Like {_dispatchWithdraw}, this collects claims Centrifuge has already confirmed into
408
+ /// `maxWithdraw`. The difference is only where they stop: here they become this contract's
409
+ /// idle balance instead of going on to a recipient. `totalAssets()` counts idle separately
410
+ /// from the resource, so the band still has to be told the resource shed that amount.
411
+ function _claimRedeem(address resource, address adapter) private returns (uint256 assets) {
412
+ assets = _claimMeasuredAssets(adapter, abi.encodeCall(IAdapterCentrifuge.claimRedeem, (resource)));
413
+ if (assets > 0) {
414
+ _recordOutflow(resource, assets);
415
+ emit RedeemClaimed(resource, assets);
416
+ }
417
+ }
418
+
419
+ /// @dev Claims assets from a cancelled deposit. Does nothing and emits no event when the amount is zero.
420
+ /// Moves value out of the resource the same way {_claimRedeem} does, and tells the band for
421
+ /// the same reason.
422
+ function _claimCancelDeposit(address resource, address adapter) private returns (uint256 assets) {
423
+ assets = _claimMeasuredAssets(adapter, abi.encodeCall(IAdapterCentrifuge.claimCancelDeposit, (resource)));
424
+ if (assets > 0) {
425
+ _recordOutflow(resource, assets);
426
+ emit DepositCancelClaimed(resource, assets);
427
+ }
428
+ }
429
+
430
+ /// @dev Claims shares from a cancelled redemption. Does nothing and emits no event when the amount is zero.
431
+ /// The band is not told, because {requestRedeem} never told it either: that only moved the
432
+ /// shares from the group's balance into `pendingRedeemRequest`, both inside this resource.
433
+ /// Undoing the request therefore has nothing to undo. What moves the band is the CLAIM, in
434
+ /// {_claimRedeem}, since that is where assets actually leave the resource.
435
+ /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
436
+ function _claimCancelRedeem(address resource, address adapter) private returns (uint256 shares) {
437
+ bytes memory ret =
438
+ adapter.functionDelegateCall(abi.encodeCall(IAdapterCentrifuge.claimCancelRedeem, (resource)));
439
+ shares = abi.decode(ret, (uint256));
440
+ if (shares > 0) emit RedeemCancelClaimed(resource, shares);
441
+ }
442
+ }