@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,412 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ pragma solidity 0.8.25;
3
+
4
+ import { INavGuard } from "../../interfaces/INavGuard.sol";
5
+
6
+ /**
7
+ * @title NavGuard
8
+ * @author Venus
9
+ * @notice Reusable per-resource band on a position's own value. Extend this when a position's
10
+ * value is reported by a counterparty that nothing else validates.
11
+ * @dev The band vouches for a value: the last counterparty figure we accepted, grown at `driftBps`
12
+ * a year, plus whatever we have moved in or out since. {_guardedNav} holds valuation a gap
13
+ * either side of that and never reverts. The gaps are a percentage of the counterparty's part
14
+ * only, so our own moves never widen them. Take everything out and the band closes; a deposit
15
+ * or {setNavGuardSnapshot} reopens it.
16
+ *
17
+ * Two stored numbers carry that, and {_currentBand} is the only place they are combined:
18
+ *
19
+ * - `anchor` is the counterparty's part: the last figure they reported that we accepted, less
20
+ * anything we have withdrawn beyond our own deposits. Both gaps are sized off it alone, and
21
+ * {_updateNavGuard} moves it once per `interval`.
22
+ * - `centre` is where the band sits: the anchor plus our own deposits since. {_recordInflow}
23
+ * and {_recordOutflow} move it.
24
+ *
25
+ * Two clocks, because the two things they time are unrelated. `anchoredAt` is the re-anchor
26
+ * clock and only a re-anchor moves it, or one dust deposit per interval would hold the band
27
+ * frozen. `driftFrom` is the drift baseline and every flow resets it, having first written the
28
+ * drift accrued so far into both numbers. That is what lets our deposits earn drift from when
29
+ * they arrived without earning any for the period before.
30
+ *
31
+ * Clamping down over-reports a real loss, so the floor rate-limits marking down rather than
32
+ * preventing it. Only {_guardedNav} reads `capEnabled` and `floorEnabled`; the re-anchor and
33
+ * both flow calls run whether or not a side is armed, so the band keeps tracking while off.
34
+ *
35
+ * Extenders supply {_observedNav} (must not revert; it reports whether it could read) and
36
+ * {_requireGuardConfig}, which gates all three governance setters.
37
+ *
38
+ * Storage: one mapping plus a gap, 20 slots total. Extenders are upgradeable proxies — do not
39
+ * change this contract's position in the inheritance chain.
40
+ */
41
+ abstract contract NavGuard is INavGuard {
42
+ // ============================== Constants =================================
43
+
44
+ /// @dev Basis-point denominator for the drift rate and both gaps.
45
+ uint256 private constant BPS = 10_000;
46
+
47
+ /// @dev One year, for annualizing `driftBps`.
48
+ uint256 private constant SECONDS_PER_YEAR = 365 days;
49
+
50
+ // ============================== State =====================================
51
+
52
+ /// @notice Optional value band for each resource. Never configured when `interval == 0`.
53
+ /// @dev The extender's removal paths must clear this with {_clearNavGuard}, or a re-added
54
+ /// resource inherits the old band and {setNavGuardRate} holds its new anchor inside it.
55
+ mapping(address resource => NavBand band) internal _navGuard;
56
+
57
+ /// @dev Reserved slots for future fields. Shrink this when adding one.
58
+ uint256[19] private __gap;
59
+
60
+ // ============================== External — mutating ========================
61
+
62
+ /// @inheritdoc INavGuard
63
+ /// @dev The ACM role each of these three setters checks is its own signature, verbatim. So
64
+ /// they are also the three strings an onboarding VIP has to grant.
65
+ function setNavGuardRate(
66
+ address resource,
67
+ uint16 driftBps,
68
+ uint16 upGapBps,
69
+ uint16 downGapBps,
70
+ uint32 interval,
71
+ bool capEnabled,
72
+ bool floorEnabled
73
+ ) external virtual override {
74
+ _requireGuardConfig("setNavGuardRate(address,uint16,uint16,uint16,uint32,bool,bool)", resource);
75
+ _setNavGuardRate(resource, driftBps, upGapBps, downGapBps, interval, capEnabled, floorEnabled);
76
+ }
77
+
78
+ /// @inheritdoc INavGuard
79
+ function setNavGuardSnapshot(address resource, uint128 snapshot, uint64 timestamp) external virtual override {
80
+ _requireGuardConfig("setNavGuardSnapshot(address,uint128,uint64)", resource);
81
+ _setNavGuardSnapshot(resource, snapshot, timestamp);
82
+ }
83
+
84
+ /// @inheritdoc INavGuard
85
+ function setNavGuardEnabled(address resource, bool capEnabled, bool floorEnabled) external virtual override {
86
+ _requireGuardConfig("setNavGuardEnabled(address,bool,bool)", resource);
87
+ _setNavGuardEnabled(resource, capEnabled, floorEnabled);
88
+ }
89
+
90
+ // ============================== External — view ============================
91
+
92
+ /// @inheritdoc INavGuard
93
+ function navGuard(address resource) external view override returns (NavBand memory) {
94
+ return _navGuard[resource];
95
+ }
96
+
97
+ /// @inheritdoc INavGuard
98
+ function navGuardStatus(address resource)
99
+ external
100
+ view
101
+ override
102
+ returns (
103
+ uint256 observedValue,
104
+ uint256 minAllowedValue,
105
+ uint256 maxAllowedValue,
106
+ bool isClamped,
107
+ uint256 clampedValue
108
+ )
109
+ {
110
+ NavBand storage band = _navGuard[resource];
111
+ if (band.interval == 0) return (0, 0, 0, false, 0);
112
+
113
+ (observedValue,) = _observedNav(resource);
114
+
115
+ // Both bounds are returned even when their side is switched off, so monitoring can see
116
+ // where an unarmed side would bind if it were armed.
117
+ (, minAllowedValue, maxAllowedValue) = _currentBand(band);
118
+
119
+ // `_guardedNav` does the clamping, so this only checks whether it clamped or returned `observedValue`.
120
+ uint256 guarded = _guardedNav(resource, observedValue);
121
+ if (guarded == observedValue) return (observedValue, minAllowedValue, maxAllowedValue, false, 0);
122
+ return (observedValue, minAllowedValue, maxAllowedValue, true, guarded);
123
+ }
124
+
125
+ // ============================== Internal — configuration ==================
126
+
127
+ /// @dev Validation and storage behind {setNavGuardRate}; auth and registration have already
128
+ /// run in {_requireGuardConfig}.
129
+ function _setNavGuardRate(
130
+ address resource,
131
+ uint16 driftBps,
132
+ uint16 upGapBps,
133
+ uint16 downGapBps,
134
+ uint32 interval,
135
+ bool capEnabled,
136
+ bool floorEnabled
137
+ ) internal {
138
+ // A stored zero interval would read as "no band" in every other path here, and both gaps
139
+ // stay under 100% so `_currentBand`'s floor subtraction cannot underflow.
140
+ if (interval == 0 || upGapBps >= BPS || downGapBps >= BPS) revert InvalidNavGuardRate();
141
+
142
+ // Arming on a failed read would centre the band at zero and value a live position at nothing.
143
+ (uint256 anchor, bool readable) = _observedNav(resource);
144
+ if (!readable) revert NavGuardUnreadable(resource);
145
+
146
+ // A retune re-anchors on what the vault reports now, held inside the band already in force,
147
+ // so a gap change cannot adopt a value the old band was refusing. A zero reading skips that
148
+ // hold, or `_clamp` would raise it to the old floor and centre the new band on value that is gone.
149
+ NavBand storage current = _navGuard[resource];
150
+ if (current.interval != 0 && anchor != 0) {
151
+ (, uint256 floor, uint256 cap) = _currentBand(current);
152
+ anchor = _clamp(anchor, floor, cap);
153
+ }
154
+ if (anchor > type(uint128).max) revert InvalidNavGuardRate();
155
+
156
+ _navGuard[resource] = NavBand({
157
+ anchor: uint128(anchor),
158
+ centre: uint128(anchor),
159
+ anchoredAt: uint64(block.timestamp),
160
+ driftFrom: uint64(block.timestamp),
161
+ interval: interval,
162
+ driftBps: driftBps,
163
+ upGapBps: upGapBps,
164
+ downGapBps: downGapBps,
165
+ capEnabled: capEnabled,
166
+ floorEnabled: floorEnabled
167
+ });
168
+
169
+ emit NavGuardConfigured(
170
+ resource, uint128(anchor), driftBps, upGapBps, downGapBps, interval, capEnabled, floorEnabled
171
+ );
172
+ }
173
+
174
+ /// @dev Validation and storage behind {setNavGuardSnapshot}; auth and registration have
175
+ /// already run.
176
+ function _setNavGuardSnapshot(address resource, uint128 snapshot, uint64 timestamp) internal {
177
+ NavBand storage band = _navGuard[resource];
178
+ if (band.interval == 0) revert InvalidNavGuardSnapshot();
179
+ if (snapshot == 0) revert InvalidNavGuardSnapshot();
180
+ if (timestamp == 0 || timestamp > block.timestamp) revert InvalidNavGuardSnapshot();
181
+
182
+ band.anchor = snapshot;
183
+ band.centre = snapshot;
184
+ band.anchoredAt = timestamp;
185
+ band.driftFrom = timestamp;
186
+
187
+ emit NavGuardReanchored(resource, snapshot, timestamp);
188
+ }
189
+
190
+ /// @dev Validation and storage behind {setNavGuardEnabled}; auth and registration have already
191
+ /// run.
192
+ function _setNavGuardEnabled(address resource, bool capEnabled, bool floorEnabled) internal {
193
+ NavBand storage band = _navGuard[resource];
194
+ if ((capEnabled || floorEnabled) && band.interval == 0) revert NavGuardNotConfigured(resource);
195
+ if (band.capEnabled == capEnabled && band.floorEnabled == floorEnabled) return;
196
+
197
+ band.capEnabled = capEnabled;
198
+ band.floorEnabled = floorEnabled;
199
+ emit NavGuardEnabledSet(resource, capEnabled, floorEnabled);
200
+ }
201
+
202
+ /// @dev Clears the band for `resource`. Used by the extender's write-off / removal paths.
203
+ function _clearNavGuard(address resource) internal {
204
+ delete _navGuard[resource];
205
+ }
206
+
207
+ // ============================== Internal — accrual =========================
208
+
209
+ /// @dev Must not revert: this runs inside accrual. Every failure to read returns early and
210
+ /// leaves the band where it was.
211
+ ///
212
+ /// The clamp log lives here because {_guardedNav} is a view and cannot emit. It comes after
213
+ /// the re-anchor, which moves the band, and `Hub._accrueFees` reads NAV later in the same
214
+ /// call. Logging any earlier would name a bound valuation no longer uses.
215
+ function _updateNavGuard(address resource) internal {
216
+ NavBand storage band = _navGuard[resource];
217
+ if (band.interval == 0) return;
218
+
219
+ // A failed read is no information, and a zero the counterparty publishes must not close the
220
+ // band: only our own withdrawals do that, through `_recordOutflow`.
221
+ (uint256 observed, bool readable) = _observedNav(resource);
222
+ if (!readable || observed == 0) return;
223
+
224
+ if (block.timestamp - band.anchoredAt >= band.interval) _reanchor(resource, band, observed);
225
+
226
+ uint256 guarded = _guardedNav(resource, observed);
227
+ if (guarded != observed) emit NavGuardClamped(resource, observed, guarded);
228
+ }
229
+
230
+ /// @dev Once per `interval` the band moves onto what the counterparty reports now, so real
231
+ /// growth does not eventually push a healthy position through the cap. `observed` is held
232
+ /// inside the band in force before it is stored, which is what stops a single bad reading
233
+ /// being adopted whole: 500 against a cap of 102 stores 102, the next band is built around
234
+ /// 102, and valuation can climb only one gap per interval.
235
+ function _reanchor(address resource, NavBand storage band, uint256 observed) private {
236
+ (uint256 centre, uint256 floor, uint256 cap) = _currentBand(band);
237
+ if (centre == 0) return;
238
+
239
+ uint256 anchor = _clamp(observed, floor, cap);
240
+ if (anchor > type(uint128).max) return;
241
+
242
+ band.anchor = uint128(anchor);
243
+ band.centre = uint128(anchor);
244
+ band.anchoredAt = uint64(block.timestamp);
245
+ band.driftFrom = uint64(block.timestamp);
246
+ emit NavGuardReanchored(resource, uint128(anchor), uint64(block.timestamp));
247
+ }
248
+
249
+ // ============================== Internal — our own moves ===================
250
+
251
+ /// @dev Moves the centre up by what we put into `resource`. No part of the deposit reaches the
252
+ /// anchor, or it would buy itself a gap on the value it just added, and `anchoredAt` stays
253
+ /// where it was, or one dust deposit per interval would hold the band frozen for good. The
254
+ /// anchor does take the drift {_settleDrift} writes in first, which is growth it had already
255
+ /// earned.
256
+ ///
257
+ /// A position now worth exactly what we sent was empty a moment before, so whatever the band
258
+ /// still had stored is value that left without {_recordOutflow} being told, by a sweep or a
259
+ /// write-off. The read is what catches that: left in place, a centre of 48 plus a deposit of
260
+ /// 10 would report 58 over a position holding 10.
261
+ ///
262
+ /// Reverts {NavGuardCentreOverflow} past `uint128`. Reverting unwinds the deposit instead
263
+ /// of routing value the band cannot record.
264
+ function _recordInflow(address resource, uint256 amount) internal {
265
+ NavBand storage band = _navGuard[resource];
266
+ if (band.interval == 0 || amount == 0) return;
267
+
268
+ uint256 centre = _settleDrift(band) + amount;
269
+ if (centre > type(uint128).max) revert NavGuardCentreOverflow(resource, centre);
270
+
271
+ (uint256 value, bool readable) = _observedNav(resource);
272
+ if (readable && value == amount && centre != amount) {
273
+ emit NavGuardClosed(resource, band.centre, 0);
274
+ band.anchor = 0;
275
+ centre = amount;
276
+ }
277
+
278
+ band.centre = uint128(centre);
279
+ emit NavGuardCentreMoved(resource, band.anchor, uint128(centre));
280
+ }
281
+
282
+ /// @dev Moves the centre down by what left `resource`, or closes the band. A withdrawal comes out
283
+ /// of our own deposits first, so once the centre falls below the anchor the anchor comes
284
+ /// down with it. Left high, the gaps would go on being sized to money that has gone.
285
+ ///
286
+ /// The centre it is measured against is the one {_settleDrift} has just brought up to date,
287
+ /// so a payout is compared with what the band vouches for now rather than with a figure
288
+ /// that stopped at the last re-anchor.
289
+ ///
290
+ /// The band closes when the withdrawal reaches the centre, or when the position reads empty
291
+ /// afterwards. The amount alone cannot show the second while the floor holds the band above
292
+ /// a loss: take 50 out of a position worth 50 that the band reports at 98 and the centre
293
+ /// would be left at 48 over nothing, for the next deposit to be reported 48 above what went
294
+ /// in. An unreadable read does not close the band, since an empty position needs no price.
295
+ function _recordOutflow(address resource, uint256 amount) internal {
296
+ NavBand storage band = _navGuard[resource];
297
+ if (band.interval == 0 || amount == 0 || band.centre == 0) return;
298
+
299
+ uint256 centre = _settleDrift(band);
300
+ (uint256 left, bool readable) = _observedNav(resource);
301
+ if (amount >= centre || (readable && left == 0)) {
302
+ band.anchor = 0;
303
+ band.centre = 0;
304
+ emit NavGuardClosed(resource, centre, amount);
305
+ return;
306
+ }
307
+
308
+ centre -= amount;
309
+ band.centre = uint128(centre);
310
+ if (band.anchor > centre) band.anchor = uint128(centre);
311
+ emit NavGuardCentreMoved(resource, band.anchor, uint128(centre));
312
+ }
313
+
314
+ // ============================== Internal — view ============================
315
+
316
+ /// @dev Revert unless the caller may call `signature` and `resource` is one the extender manages.
317
+ /// Check the caller first, or an unauthorized one learns what this extender has registered.
318
+ ///
319
+ /// One hook for both, so a function added later cannot run the first check and forget the
320
+ /// second. It is abstract because the extender already holds an access-control manager.
321
+ function _requireGuardConfig(string memory signature, address resource) internal view virtual;
322
+
323
+ /// @dev What `resource` is worth to the extender right now, in asset units and unclamped.
324
+ /// Must not revert: answer `(0, false)` for a read that failed.
325
+ function _observedNav(address resource) internal view virtual returns (uint256 value, bool readable);
326
+
327
+ /// @dev The band applied to `observedNav`, on whichever sides are armed: the floor below it,
328
+ /// the cap above it, `observedNav` itself in between. Never reverts.
329
+ ///
330
+ /// A zero `observedNav` passes straight through rather than being clamped up to the
331
+ /// floor: that would report value for a resource that demonstrably has none, and a
332
+ /// resource can only be removed once it is empty.
333
+ function _guardedNav(address resource, uint256 observedNav) internal view returns (uint256 value) {
334
+ if (observedNav == 0) return 0;
335
+
336
+ NavBand storage band = _navGuard[resource];
337
+ if (band.interval == 0) return observedNav;
338
+
339
+ bool capOn = band.capEnabled;
340
+ bool floorOn = band.floorEnabled;
341
+ if (!capOn && !floorOn) return observedNav;
342
+
343
+ (, uint256 floor, uint256 cap) = _currentBand(band);
344
+ if (floorOn && observedNav < floor) return floor;
345
+ if (capOn && observedNav > cap) return cap;
346
+ return observedNav;
347
+ }
348
+
349
+ /// @dev The band right now. Both numbers grow at `driftBps` since `driftFrom`, because
350
+ /// Centrifuge prices the whole position with one share price and our own deposits sit in
351
+ /// the same fund as the counterparty's part. Crediting drift to the anchor alone left a
352
+ /// deposit with no growth allowance until the next re-anchor: 100 armed, 400 deposited and
353
+ /// thirty days at exactly the configured 8% put the cap at 502.67 over a position worth
354
+ /// 503.29, so an honest fund was clamped.
355
+ ///
356
+ /// The gaps are sized to the anchor, not to the centre, and that asymmetry is the point. A
357
+ /// deposit raises only the centre, so it buys no headroom on the value it just added. A
358
+ /// withdrawal past our own deposits lowers the anchor with the centre, so the gaps resize
359
+ /// to what is left: take 99 out of 100 with a 2% gap and the cap is 1.02, where an anchor
360
+ /// left at 100 would have allowed 3.
361
+ ///
362
+ /// Nothing here can underflow. The anchor never exceeds the centre and {_grown} is
363
+ /// monotonic, so it does not after drifting either; both gaps are held under 100% at
364
+ /// configuration; and `driftFrom` is never in the future because every writer uses
365
+ /// `block.timestamp` or rejects a later stamp. A closed band returns zeros.
366
+ function _currentBand(NavBand storage band) internal view returns (uint256 centre, uint256 floor, uint256 cap) {
367
+ uint256 elapsed = block.timestamp - band.driftFrom;
368
+ uint256 driftBps = band.driftBps;
369
+ uint256 anchor = _grown(band.anchor, driftBps, elapsed);
370
+ centre = _grown(band.centre, driftBps, elapsed);
371
+
372
+ floor = centre - (anchor * band.downGapBps) / BPS;
373
+ cap = centre + (anchor * band.upGapBps) / BPS;
374
+ }
375
+
376
+ /// @dev `value` after `elapsed` seconds at `driftBps` a year, capped where it would stop fitting
377
+ /// the slot it is stored in. The cap is unreachable in practice: it needs a position near
378
+ /// `uint128` that has also gone a decade without a re-anchor. Capping rather than reverting
379
+ /// keeps every caller non-reverting, and it can only lower the band.
380
+ function _grown(uint256 value, uint256 driftBps, uint256 elapsed) private pure returns (uint256) {
381
+ uint256 grown = value + (value * driftBps * elapsed) / (BPS * SECONDS_PER_YEAR);
382
+ return grown > type(uint128).max ? type(uint128).max : grown;
383
+ }
384
+
385
+ /// @dev Writes the drift accrued since `driftFrom` into both stored numbers and restarts that
386
+ /// clock, then returns the centre. Every flow calls this before moving anything, so the
387
+ /// flow is measured against the band as it stands at that moment and money arriving now
388
+ /// earns drift only from now. Without it, growing the centre would hand a deposit the drift
389
+ /// of the whole period since the last re-anchor, which is headroom bought with deposit size.
390
+ ///
391
+ /// `anchoredAt` is untouched. The re-anchor clock belongs to the counterparty, and letting
392
+ /// a flow move it would let one dust deposit per interval hold the band frozen.
393
+ function _settleDrift(NavBand storage band) private returns (uint256 centre) {
394
+ centre = band.centre;
395
+ uint256 elapsed = block.timestamp - band.driftFrom;
396
+ uint256 driftBps = band.driftBps;
397
+
398
+ if (elapsed != 0 && driftBps != 0 && centre != 0) {
399
+ centre = _grown(centre, driftBps, elapsed);
400
+ band.centre = uint128(centre);
401
+ band.anchor = uint128(_grown(band.anchor, driftBps, elapsed));
402
+ }
403
+ band.driftFrom = uint64(block.timestamp);
404
+ }
405
+
406
+ /// @dev `value` held inside `[floor, cap]`.
407
+ function _clamp(uint256 value, uint256 floor, uint256 cap) private pure returns (uint256) {
408
+ if (value < floor) return floor;
409
+ if (value > cap) return cap;
410
+ return value;
411
+ }
412
+ }
@@ -164,6 +164,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
164
164
  // setter is registered-only. Removal restores a clean slate.
165
165
  delete _resourceCap[resource];
166
166
  delete _resourcesConfig[resource];
167
+ _onResourceRemoved(resource);
167
168
 
168
169
  emit ResourceRemoved(resource);
169
170
  }
@@ -604,11 +605,26 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
604
605
  * Callers own the registration / pause / capacity checks and must ensure this
605
606
  * YieldGroup already holds `amount` of underlying. Delegatecall credits receipt
606
607
  * tokens to this YieldGroup; `Address.functionDelegateCall` bubbles adapter reverts.
608
+ *
609
+ * `virtual` so a family that has to account for its own value moves can hook here: this
610
+ * and {_dispatchWithdraw} are the only two places value enters or leaves a resource.
607
611
  * @param adapter Adapter bound to `resource`.
608
612
  * @param resource Resource to deposit into.
609
613
  * @param amount Underlying units to place (exact).
610
614
  */
611
- function _dispatchDeposit(address adapter, address resource, uint256 amount) private {
615
+ /**
616
+ * @notice Clear whatever per-resource state a family keeps outside this contract, called by
617
+ * {removeResource} once the resource is out of the registry and both queues.
618
+ * @dev Same reason {removeResource} clears `_resourceCap`: `addResource` never writes these,
619
+ * and the setters are registered-only, so anything left behind is silently inherited by a
620
+ * later re-`addResource` of the same address. A family with no extra state leaves this
621
+ * empty. Must not revert, or removal becomes unreachable.
622
+ * @param resource Resource being removed.
623
+ */
624
+ // solhint-disable-next-line no-empty-blocks
625
+ function _onResourceRemoved(address resource) internal virtual { }
626
+
627
+ function _dispatchDeposit(address adapter, address resource, uint256 amount) internal virtual {
612
628
  adapter.functionDelegateCall(abi.encodeCall(IResourceAdapter.deposit, (resource, amount)));
613
629
  emit DepositRouted(resource, amount);
614
630
  }
@@ -623,6 +639,9 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
623
639
  * a shortfall itself — doing so inside the cascade's external self-call frame would blur
624
640
  * our own invariant with an external-protocol revert and force selector-matching to tell
625
641
  * them apart. The delta is cheap defensive verification against a future-adapter regression.
642
+ *
643
+ * `virtual` for the reason on {_dispatchDeposit}. An override should use `received`, not
644
+ * `amount`.
626
645
  * @param adapter Adapter bound to `resource`.
627
646
  * @param resource Resource to pull from.
628
647
  * @param amount Underlying units to deliver to `to` (exact).
@@ -630,7 +649,8 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
630
649
  * @return received Underlying units actually delivered to `to`, measured by balance delta.
631
650
  */
632
651
  function _dispatchWithdraw(address adapter, address resource, uint256 amount, address to)
633
- private
652
+ internal
653
+ virtual
634
654
  returns (uint256 received)
635
655
  {
636
656
  IERC20 assetToken = IERC20(_assetToken);