@venusprotocol/liquidity-hub 2.1.0-dev.5 → 2.1.0-dev.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +16 -12
  2. package/contracts/YieldGroup/YieldGroup.sol +1 -1
  3. package/contracts/YieldGroup/YieldGroupCentrifuge.sol +7 -0
  4. package/contracts/YieldGroup/YieldGroupFRV.sol +1 -0
  5. package/contracts/YieldGroup/base/YieldGroupBase.sol +95 -9
  6. package/contracts/YieldGroup/base/YieldGroupBaseStorage.sol +10 -2
  7. package/contracts/adapters/AdapterCentrifuge.sol +16 -1
  8. package/contracts/adapters/AdapterCoreV1.sol +29 -15
  9. package/contracts/adapters/AdapterFRV.sol +17 -7
  10. package/contracts/adapters/AdapterFlux.sol +15 -3
  11. package/contracts/adapters/AdapterSpokeV1.sol +15 -3
  12. package/contracts/interfaces/IResourceAdapter.sol +12 -3
  13. package/contracts/interfaces/IYieldGroupBase.sol +56 -7
  14. package/contracts/interfaces/external/IVToken.sol +9 -0
  15. package/deployments/bscmainnet/AdapterCoreV1.json +26 -2
  16. package/deployments/bscmainnet/AdapterFRV.json +26 -2
  17. package/deployments/bscmainnet/AdapterFlux.json +26 -2
  18. package/deployments/bscmainnet/AdapterSpokeV1.json +26 -2
  19. package/deployments/bscmainnet/YieldGroupCoreImpl.json +69 -2
  20. package/deployments/bscmainnet/YieldGroupFRVImpl.json +69 -2
  21. package/deployments/bscmainnet/YieldGroupFluxImpl.json +69 -2
  22. package/deployments/bscmainnet/YieldGroupSpokeImpl.json +69 -2
  23. package/deployments/bscmainnet_addresses.json +8 -8
  24. package/deployments/bsctestnet/AdapterCoreV1.json +26 -2
  25. package/deployments/bsctestnet/AdapterFRV.json +26 -2
  26. package/deployments/bsctestnet/AdapterFlux.json +26 -2
  27. package/deployments/bsctestnet/AdapterSpokeV1.json +26 -2
  28. package/deployments/bsctestnet/YieldGroupCoreImpl.json +69 -2
  29. package/deployments/bsctestnet/YieldGroupFRVImpl.json +69 -2
  30. package/deployments/bsctestnet/YieldGroupFluxImpl.json +69 -2
  31. package/deployments/bsctestnet/YieldGroupSpokeImpl.json +69 -2
  32. package/deployments/bsctestnet_addresses.json +8 -8
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -195,9 +195,12 @@ Hub.totalAssets()
195
195
  = Σ YieldGroup.totalAssets() + Hub idle balance
196
196
  │
197
197
  └─ YieldGroup.totalAssets()
198
- = Σ adapter.totalAssets(resource, yieldGroup) + YieldGroup idle balance
198
+ = Σ adapter.receiptValue(resource, trackedReceipt[resource]) + YieldGroup idle balance
199
+ (Centrifuge keeps receipt tracking off and sums adapter.totalAssets(resource, yieldGroup) instead)
199
200
  ```
200
201
 
202
+ - **Tracked receipts, not the wallet.** A tracking family prices only the receipts its own deposits minted. Receipt tokens that reach the YieldGroup any other way (a donation) sit in the wallet uncounted: they move neither NAV nor liquidity, never block removal, and are sweepable once the resource is removed. Governance can admit them into the ledger with `syncReceipt`, capped at the amount it reviewed, so receipts sent in while the VIP waits stay uncounted.
203
+
201
204
  - **Fails closed.** `totalAssets()` is the share-price denominator, so a YieldGroup whose `totalAssets()` view reverts is **not** isolated here — it halts share math rather than silently dropping that YieldGroup's funds from NAV (which would understate the price, under-pay redeemers and over-credit depositors until the value reappeared). The _quantity views_ (`maxWithdraw`, and `HubLens`'s capacity figure) and the deposit/withdraw routing **do** isolate a faulting YieldGroup (contribute 0 / skip) because failing conservatively there is safe — only on the price would it corrupt the conversion. A view-level revert is a YieldGroup-level bug (resource-level reverts are isolated inside the YieldGroup, so external pauses surface in `withdraw`/`deposit`, not here); recover via the runbook below.
202
205
  - Hub idle is normally zero (deposits route out atomically, withdrawals pull exact amounts, reallocate is balanced). A direct token donation persists and is intentionally counted into NAV (it accrues to LPs and is consumed first on withdraw) — the underlying cannot be swept.
203
206
  - YieldGroup idle accumulates from treasury-fee gross-up surplus and dust handling on past withdrawals; it is counted and consumed idle-first.
@@ -333,16 +336,16 @@ room = min( protocolHeadroom, resourceCap − ourBalanceInResource )
333
336
 
334
337
  Every privileged call is gated by `AccessControlManagerV8` — the role is `keccak256(targetContract, roleString)`, where `roleString` is the literal function signature shown below. The contracts do not hard-code an operator-vs-governance branch; the asymmetry is entirely a matter of which addresses governance grants each role to. In v1 the Operator is the Venus Core multisig.
335
338
 
336
- | Action class | Governance (VIP) | Operator |
337
- | -------------------------------------------------------------------------------------------- | :----------------: | :----------------: |
338
- | Add / remove YieldGroup or Resource, **unpause**, set fees, `updateResourceAdapter`, `sweep` | ✅ | ❌ |
339
- | **Raise** caps, `emergencyReallocate` | ✅ (also Guardian) | ❌ |
340
- | **Lower** caps, lower per-tx cap, **pause**, reorder queues | ✅ | ✅ |
341
- | `reallocate` between YieldGroups / resources | — | ✅ |
342
- | `pauseHub` | ✅ | ✅ (also Guardian) |
343
- | `deposit` / `mint` / `withdraw` / `redeem`, `accrueFees`, views | permissionless | permissionless |
339
+ | Action class | Governance (VIP) | Operator |
340
+ | ----------------------------------------------------------------------------------------------------------- | :----------------: | :----------------: |
341
+ | Add / remove YieldGroup or Resource, **unpause**, set fees, `updateResourceAdapter`, `sweep`, `syncReceipt` | ✅ | ❌ |
342
+ | **Raise** caps, `emergencyReallocate` | ✅ (also Guardian) | ❌ |
343
+ | **Lower** caps, lower per-tx cap, **pause**, reorder queues | ✅ | ✅ |
344
+ | `reallocate` between YieldGroups / resources | — | ✅ |
345
+ | `pauseHub` | ✅ | ✅ (also Guardian) |
346
+ | `deposit` / `mint` / `withdraw` / `redeem`, `accrueFees`, views | permissionless | permissionless |
344
347
 
345
- **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.
348
+ **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)`, `syncReceipt(address,uint256)`, 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.
346
349
 
347
350
  ## Invariants & safety
348
351
 
@@ -350,7 +353,7 @@ Every privileged call is gated by `AccessControlManagerV8` — the role is `kecc
350
353
  - **Net-zero reallocate.** `Σ withdraws == Σ deposits`; the Operator can only move funds among registered routes, never in or out.
351
354
  - **Stateless adapters.** Adapters declare zero storage (only immutables), never `sstore`, and call only the resource, its `underlying()`, and its `comptroller()`. Mutating entry points revert outside a delegatecall context.
352
355
  - **Fault isolation.** A YieldGroup with a reverting `totalAssets()` view contributes 0 instead of bricking the Hub; it can still be removed as an emergency eviction.
353
- - **Removal safety.** `removeResource` / `removeYieldGroup` gate on a _raw receipt-token_ balance (not underlying value) so a sub-unit residual can't be rounded to zero and orphan tokens.
356
+ - **Removal safety.** `removeResource` / `removeYieldGroup` gate on receipts the YieldGroup accounts for, in _receipt-token units_ (not underlying value) so a sub-unit residual can't be rounded to zero and orphan tokens. For a tracking family that is the tracked ledger, so donated receipts never block removal and are sweepable afterwards; for Centrifuge it is the wallet balance.
354
357
  - **Inflation defense.** ERC-4626 decimals offset (≤ 12) is set per asset at init; the deploy path is responsible for a non-zero value.
355
358
  - **Standard ERC-20 only.** Fee-on-transfer, deflationary, and rebasing underlyings are unsupported (deposits fail closed if a token delivers less than requested).
356
359
  - **Upgrade-safe storage.** Each contract isolates its state in a `*Storage` base with a reserved `__gap`; never reorder or prepend fields.
@@ -370,7 +373,7 @@ contracts/
370
373
  migrator/ Migrator.sol (immutable Core→Hub one-click migration)
371
374
  registry/ HubRegistry.sol (chain-level Hub directory, discovery root)
372
375
  interfaces/ IYieldGroupBase, IResourceAdapter, IHub, IHubLens, IHubRegistry, IYieldGroup*, IMigrator, IVToken, IVBep20, IWBNB, IFToken, IFRVVault, ...
373
- deploy/ Foundry deploy scripts 01–14 + DeployBase + config/<network>.json
376
+ deploy/ Foundry deploy scripts 01–15 + DeployBase + config/<network>.json
374
377
  tests/foundry/ unit (Hub/, YieldGroup/{core,flux,frv}/, Registry/, lens/), Integration/, fork/
375
378
  deployments/<network>/ committed registry of deployed addresses (source of truth)
376
379
  lib/ git-submodule dependencies
@@ -441,6 +444,7 @@ Beacon-proxy model: one `UpgradeableBeacon` **per family per chain** (Hub, Core,
441
444
  12_DeployHubImpl # upgrade only: new Hub impl behind the existing HubBeacon
442
445
  13_DeploySpoke # once per chain (Spoke adapter + impl + beacon)
443
446
  14_DeploySpokeSource # per asset, backfills existing Hubs
447
+ 15_DeployReceiptLedger # upgrade only: receipt-ledger YieldGroup impls + the four tracking adapters
444
448
  ```
445
449
 
446
450
  Scripts run in numeric order: beacons first, then the registry, then per-asset instances against both. Scripts only deploy + initialize — **ACM wiring, `addYieldGroup` / `addResource`, and queue configuration are governance (ACM-gated) actions done separately.**
@@ -110,7 +110,7 @@ contract YieldGroup is YieldGroupBase, IYieldGroup {
110
110
  uint256 cap = _resourceCap[resource];
111
111
  if (cap == 0) return protocolRoom;
112
112
 
113
- uint256 held = IResourceAdapter(adapter).totalAssets(resource, address(this));
113
+ uint256 held = _accountedAssetValue(resource, adapter);
114
114
  uint256 operatorRoom = cap > held ? cap - held : 0;
115
115
  return protocolRoom < operatorRoom ? protocolRoom : operatorRoom;
116
116
  }
@@ -364,6 +364,13 @@ contract YieldGroupCentrifuge is YieldGroupBase, NavGuard, IYieldGroupCentrifuge
364
364
 
365
365
  // ============================== Internal — hooks ==========================
366
366
 
367
+ /// @dev Off for this family: Centrifuge mints the shares when it settles a request, outside our
368
+ /// deposit call, so the base's balance delta around the dispatch would never see them. The
369
+ /// NavGuard band bounds this family's valuation instead.
370
+ function _tracksReceipts() internal pure override returns (bool) {
371
+ return false;
372
+ }
373
+
367
374
  /// @dev Unlike `IResourceAdapter.maxDeposit`, `depositRoom` can inspect this YieldGroup's state
368
375
  /// for the vault. It returns zero while this group has a deposit cancellation pending, so
369
376
  /// the Hub does not advertise capacity that Centrifuge would reject.
@@ -83,6 +83,7 @@ contract YieldGroupFRV is YieldGroupBase, IYieldGroupFRV {
83
83
  _removeFromQueue(_innerDepositQueue, resource);
84
84
  _removeFromQueue(_innerWithdrawQueue, resource);
85
85
 
86
+ delete _trackedReceipt[resource];
86
87
  delete _resourcesConfig[resource];
87
88
 
88
89
  emit ResourceForceRemoved(resource, orphaned);
@@ -140,9 +140,9 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
140
140
  ResourceConfig storage cfg = _resourcesConfig[resource];
141
141
  if (!cfg.registered) revert ResourceNotRegistered(resource);
142
142
 
143
- // Gate on the raw receipt-token balance, not the underlying value: a value-based check
144
- // rounds a sub-unit residual to 0 and would let removal orphan dust receipt tokens.
145
- uint256 held = IResourceAdapter(cfg.adapter).receiptBalance(resource, address(this));
143
+ // Gate on receipts the YieldGroup accounts for, not the wallet. Donated receipts were never counted and cannot
144
+ // be withdrawn, so they would pin a drained resource here; once unregistered, `sweep` recovers them.
145
+ uint256 held = _accountedReceipts(resource, cfg.adapter);
146
146
  if (held != 0) revert ResourceHasBalance(resource, held);
147
147
 
148
148
  uint256 idxPlusOne = _resourceIndex[resource];
@@ -163,6 +163,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
163
163
  // same address silently inherit a stale value, since `addResource` never writes it and the
164
164
  // setter is registered-only. Removal restores a clean slate.
165
165
  delete _resourceCap[resource];
166
+ delete _trackedReceipt[resource];
166
167
  delete _resourcesConfig[resource];
167
168
  _onResourceRemoved(resource);
168
169
 
@@ -255,6 +256,24 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
255
256
  }
256
257
  }
257
258
 
259
+ /// @inheritdoc IYieldGroupBase
260
+ /// @dev This is the only way a receipt-token donation reaches the Hub's share price, so it is
261
+ /// governance deciding the tokens are worth what the resource says. A vToken of a market
262
+ /// carrying unsocialised bad debt is not, and syncing it hands that loss to the Hub's LPs.
263
+ function syncReceipt(address resource, uint256 maxAdded) external override {
264
+ _checkAccessAllowed("syncReceipt(address,uint256)");
265
+ if (!_tracksReceipts()) revert ReceiptTrackingOff();
266
+ ResourceConfig storage cfg = _resourcesConfig[resource];
267
+ if (!cfg.registered) revert ResourceNotRegistered(resource);
268
+
269
+ uint256 held = IResourceAdapter(cfg.adapter).receiptBalance(resource, address(this));
270
+ uint256 old = _trackedReceipt[resource];
271
+ uint256 synced = held;
272
+ if (held > old && held - old > maxAdded) synced = old + maxAdded;
273
+ _trackedReceipt[resource] = synced;
274
+ emit ReceiptSynced(resource, old, synced);
275
+ }
276
+
258
277
  // -- IYieldGroupBase: deposit / withdraw --
259
278
 
260
279
  /// @inheritdoc IYieldGroupBase
@@ -367,7 +386,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
367
386
  _prepareResource(resource);
368
387
 
369
388
  address adapter = cfg.adapter;
370
- uint256 liquid = IResourceAdapter(adapter).maxWithdraw(resource, address(this));
389
+ uint256 liquid = _liquidAssetValue(resource, adapter);
371
390
  if (amount > liquid) revert ResourceLiquidityInsufficient(amount, liquid);
372
391
 
373
392
  uint256 received = _dispatchWithdraw(adapter, resource, amount, to);
@@ -402,6 +421,11 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
402
421
  return (cfg.registered, cfg.paused, cfg.adapter);
403
422
  }
404
423
 
424
+ /// @inheritdoc IYieldGroupBase
425
+ function trackedReceipt(address resource) external view override returns (uint256) {
426
+ return _trackedReceipt[resource];
427
+ }
428
+
405
429
  /// @inheritdoc IYieldGroupBase
406
430
  function innerDepositQueue() external view override returns (address[] memory) {
407
431
  return _innerDepositQueue;
@@ -429,7 +453,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
429
453
  uint256 len = list.length;
430
454
  for (uint256 i; i < len; ++i) {
431
455
  address resource = list[i];
432
- total += IResourceAdapter(_resourcesConfig[resource].adapter).totalAssets(resource, address(this));
456
+ total += _accountedAssetValue(resource, _resourcesConfig[resource].adapter);
433
457
  }
434
458
  total += IERC20(_assetToken).balanceOf(address(this));
435
459
  }
@@ -465,7 +489,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
465
489
  address resource = queue[i];
466
490
  ResourceConfig storage cfg = _resourcesConfig[resource];
467
491
  if (cfg.registered && !cfg.paused) {
468
- total += IResourceAdapter(cfg.adapter).maxWithdraw(resource, address(this));
492
+ total += _liquidAssetValue(resource, cfg.adapter);
469
493
  }
470
494
  }
471
495
  total += IERC20(_assetToken).balanceOf(address(this));
@@ -484,7 +508,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
484
508
  for (uint256 i; i < len; ++i) {
485
509
  address resource = list[i];
486
510
  address adapter = _resourcesConfig[resource].adapter;
487
- uint256 resVal = IResourceAdapter(adapter).totalAssets(resource, address(this));
511
+ uint256 resVal = _accountedAssetValue(resource, adapter);
488
512
  if (resVal > 0) {
489
513
  uint256 apy = IResourceAdapter(adapter).spotAPYBps(resource, bpy);
490
514
  weighted += apy * resVal;
@@ -579,7 +603,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
579
603
  _prepareResource(resource);
580
604
 
581
605
  address adapter = cfg.adapter;
582
- uint256 liquid = IResourceAdapter(adapter).maxWithdraw(resource, address(this));
606
+ uint256 liquid = _liquidAssetValue(resource, adapter);
583
607
  uint256 toDeliver = remaining < liquid ? remaining : liquid;
584
608
  if (toDeliver == 0) return 0;
585
609
 
@@ -626,7 +650,10 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
626
650
 
627
651
  /// @custom:oz-upgrades-unsafe-allow-reachable delegatecall
628
652
  function _dispatchDeposit(address adapter, address resource, uint256 amount) internal virtual {
653
+ bool tracks = _tracksReceipts();
654
+ uint256 before = tracks ? _totalReceipts(resource, adapter) : 0;
629
655
  adapter.functionDelegateCall(abi.encodeCall(IResourceAdapter.deposit, (resource, amount)));
656
+ if (tracks) _trackedReceipt[resource] += _totalReceipts(resource, adapter) - before;
630
657
  emit DepositRouted(resource, amount);
631
658
  }
632
659
 
@@ -657,9 +684,26 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
657
684
  {
658
685
  IERC20 assetToken = IERC20(_assetToken);
659
686
  uint256 preBalTo = assetToken.balanceOf(to);
687
+ bool tracks = _tracksReceipts();
688
+ uint256 preHeld = tracks ? _totalReceipts(resource, adapter) : 0;
660
689
  adapter.functionDelegateCall(abi.encodeCall(IResourceAdapter.withdraw, (resource, amount, to)));
661
690
  received = assetToken.balanceOf(to) - preBalTo;
662
691
 
692
+ // The vault burns from the wallet, which can hold receipts the ledger never counted. Callers
693
+ // size `amount` against the tracked value, so the burn stays within the tracked count unless
694
+ // the adapter burns more than asked: Spoke sweeps a worthless remainder whole, donated dust
695
+ // included. Floor at zero rather than revert, or that dust would block the withdrawal.
696
+ if (tracks) {
697
+ uint256 burned = preHeld - _totalReceipts(resource, adapter);
698
+ uint256 tracked = _trackedReceipt[resource];
699
+ uint256 left = burned < tracked ? tracked - burned : 0;
700
+ // Spoke decides whether to burn a worthless remainder by looking at the wallet, so a donation
701
+ // can leave receipts counted here that are worth 0. No withdrawal reaches them and they would
702
+ // block `removeResource`, so drop them; `sweep` recovers them from the wallet after removal.
703
+ if (left != 0 && IResourceAdapter(adapter).receiptValue(resource, left) == 0) left = 0;
704
+ _trackedReceipt[resource] = left;
705
+ }
706
+
663
707
  emit WithdrawRouted(resource, amount);
664
708
  }
665
709
 
@@ -749,7 +793,7 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
749
793
  uint256 len = list.length;
750
794
  for (uint256 i; i < len; ++i) {
751
795
  address resource = list[i];
752
- uint256 held = IResourceAdapter(_resourcesConfig[resource].adapter).receiptBalance(resource, address(this));
796
+ uint256 held = _accountedReceipts(resource, _resourcesConfig[resource].adapter);
753
797
  if (held != 0 && !_queueContains(queue, resource)) {
754
798
  revert WithdrawQueueOmitsFundedResource(resource);
755
799
  }
@@ -816,4 +860,46 @@ abstract contract YieldGroupBase is ReentrancyGuardUpgradeable, YieldGroupBaseSt
816
860
  function _resourceDepositRoom(address resource, address adapter) internal view virtual returns (uint256 room) {
817
861
  return IResourceAdapter(adapter).maxDeposit(resource);
818
862
  }
863
+
864
+ /**
865
+ * @notice Whether valuation counts only the receipt tokens this YieldGroup minted itself.
866
+ * @dev Base default: true. Holds for every family whose receipt balance moves only when this
867
+ * contract deposits or redeems (Core, Spoke, Flux, FRV). `YieldGroupCentrifuge` overrides to
868
+ * false: its shares are minted by Centrifuge when it settles a request, not inside our
869
+ * deposit call, so a balance delta measured around the dispatch would miss them.
870
+ * @return tracks True to price `_trackedReceipt` through the adapter's `receiptValue` instead of
871
+ * its holder-wide `totalAssets`.
872
+ */
873
+ function _tracksReceipts() internal view virtual returns (bool tracks) {
874
+ return true;
875
+ }
876
+
877
+ /// @dev Value of our position in `resource`: the tracked receipts priced by the adapter, or the
878
+ /// whole wallet when this family does not track.
879
+ function _accountedAssetValue(address resource, address adapter) internal view returns (uint256) {
880
+ if (_tracksReceipts()) return IResourceAdapter(adapter).receiptValue(resource, _trackedReceipt[resource]);
881
+ return IResourceAdapter(adapter).totalAssets(resource, address(this));
882
+ }
883
+
884
+ /// @dev Withdrawable underlying from `resource` right now. The adapter's figure is what the vault
885
+ /// will pay this wallet, so it is capped at what our tracked receipts are worth; untracked
886
+ /// receipts never certify liquidity.
887
+ function _liquidAssetValue(address resource, address adapter) private view returns (uint256) {
888
+ uint256 liquid = IResourceAdapter(adapter).maxWithdraw(resource, address(this));
889
+ if (!_tracksReceipts()) return liquid;
890
+ uint256 worth = _accountedAssetValue(resource, adapter);
891
+ return worth < liquid ? worth : liquid;
892
+ }
893
+
894
+ /// @dev Every receipt in the wallet, counted or not.
895
+ function _totalReceipts(address resource, address adapter) private view returns (uint256) {
896
+ return IResourceAdapter(adapter).receiptBalance(resource, address(this));
897
+ }
898
+
899
+ /// @dev Receipts the Hub accounts for: the tracked ledger when the family tracks, the wallet otherwise.
900
+ /// The registry gates read this so they agree with valuation on what "funded" means.
901
+ function _accountedReceipts(address resource, address adapter) private view returns (uint256) {
902
+ if (_tracksReceipts()) return _trackedReceipt[resource];
903
+ return _totalReceipts(resource, adapter);
904
+ }
819
905
  }
@@ -29,7 +29,8 @@ import { IYieldGroupBase } from "../../interfaces/IYieldGroupBase.sol";
29
29
  * 7 32 _innerDepositQueue
30
30
  * 8 32 _innerWithdrawQueue
31
31
  * 9 32 _resourceCap (generic only; never written by FRV)
32
- * 10..48 32 × 39 __gap
32
+ * 10 32 _trackedReceipt
33
+ * 11..48 32 × 38 __gap
33
34
  */
34
35
  abstract contract YieldGroupBaseStorage {
35
36
  /// @notice Hub authorised to call `deposit` / `withdraw`. Set at initialisation.
@@ -67,7 +68,14 @@ abstract contract YieldGroupBaseStorage {
67
68
  /// generic `YieldGroup` reads / writes this; FRV leaves it `0`.
68
69
  mapping(address resource => uint256 cap) internal _resourceCap;
69
70
 
71
+ /// @notice Receipt tokens (vTokens, vault shares) this YieldGroup minted itself, per resource.
72
+ /// Valuation counts these and ignores any surplus the resource's `balanceOf` shows, so
73
+ /// receipt tokens sent here directly do not move the Hub's share price. Written only by
74
+ /// the deposit / withdraw dispatch and `syncReceipt`, and cleared on removal; families
75
+ /// whose receipts arrive asynchronously (Centrifuge) leave it unused.
76
+ mapping(address resource => uint256 shares) internal _trackedReceipt;
77
+
70
78
  /// @dev Reserved slots for future state. Shrink by the number of slots a new field
71
79
  /// consumes when extending.
72
- uint256[39] private __gap;
80
+ uint256[38] private __gap;
73
81
  }
@@ -81,6 +81,15 @@ contract AdapterCentrifuge is IAdapterCentrifuge {
81
81
  */
82
82
  error ZeroSharePrice(address resource);
83
83
 
84
+ /**
85
+ * @notice Thrown by {receiptValue}, which this family implements only to satisfy `IResourceAdapter`.
86
+ * @dev `YieldGroupCentrifuge` turns receipt tracking off because deposits and withdrawals are
87
+ * asynchronous: Centrifuge mints the shares at settlement, after our deposit call has
88
+ * returned, so the base never calls {receiptValue} here.
89
+ * @param resource Resource that was queried.
90
+ */
91
+ error ShareCountNotPriceable(address resource);
92
+
84
93
  // ============================== Modifiers =================================
85
94
 
86
95
  /// @dev Restricts mutating adapter functions to delegatecall execution.
@@ -211,7 +220,8 @@ contract AdapterCentrifuge is IAdapterCentrifuge {
211
220
  /// `totalAssets()` for the figure the Hub sums, and this one for what the band is clamping.
212
221
  ///
213
222
  /// Only the share-denominated buckets need a price, so a position holding none is valued
214
- /// without the feed at all.
223
+ /// without the feed at all. Prices everything `holder` has at the vault; this family keeps
224
+ /// receipt tracking off, so there is no tracked figure.
215
225
  function totalAssets(address resource, address holder) external view override returns (uint256 value) {
216
226
  (uint256 face, uint256 shares, address shareToken) = _assetAndShareTotals(resource, holder);
217
227
  value = face;
@@ -252,6 +262,11 @@ contract AdapterCentrifuge is IAdapterCentrifuge {
252
262
  return ICentrifugeAsyncVault(resource).maxWithdraw(holder);
253
263
  }
254
264
 
265
+ /// @inheritdoc IResourceAdapter
266
+ function receiptValue(address resource, uint256) external pure override returns (uint256) {
267
+ revert ShareCountNotPriceable(resource);
268
+ }
269
+
255
270
  /// @inheritdoc IResourceAdapter
256
271
  /// @dev Centrifuge publishes no rate on-chain — a fund's return arrives as a new NAV — so the
257
272
  /// number comes from the YieldGroup holding the position, where governance publishes it
@@ -166,20 +166,17 @@ contract AdapterCoreV1 is IResourceAdapter {
166
166
  }
167
167
 
168
168
  /// @inheritdoc IResourceAdapter
169
- /// @dev Values the position at REALIZABLE (net-of-treasury) terms so NAV/PPS equal the value the
170
- /// position can actually deliver. `withdraw` already nets the Venus treasury exit cut, so
171
- /// valuing gross here would overstate NAV and let a first-out LP exit at the pre-fee price
172
- /// while the shortfall socialises onto the last-out LP. When the Comptroller fee is off
173
- /// (every registered market today) this returns the gross value unchanged.
169
+ /// @dev Priced net of the Comptroller's treasury exit fee, because `withdraw` burns extra vTokens
170
+ /// to cover it. Valuing gross would overstate NAV and let early withdrawers leave the fee to
171
+ /// the last ones out. With the fee at zero this is the plain stored-rate value.
172
+ /// Prices everything `holder` has in the wallet, not the YieldGroup's tracked receipts.
174
173
  function totalAssets(address resource, address holder) external view override returns (uint256) {
175
- uint256 vBal = IVToken(resource).balanceOf(holder);
176
- if (vBal == 0) return 0;
177
- uint256 gross = (vBal * IVToken(resource).exchangeRateStored()) / EXP_SCALE;
174
+ return _receiptValue(resource, IVToken(resource).balanceOf(holder));
175
+ }
178
176
 
179
- uint256 treasuryPct = IComptroller(IVToken(resource).comptroller()).treasuryPercent();
180
- if (treasuryPct == 0) return gross;
181
- // The Comptroller setter enforces `treasuryPercent < 1e18`, so the subtraction cannot underflow.
182
- return (gross * (MANTISSA_ONE - treasuryPct)) / MANTISSA_ONE;
177
+ /// @inheritdoc IResourceAdapter
178
+ function receiptValue(address resource, uint256 vBal) external view override returns (uint256) {
179
+ return _receiptValue(resource, vBal);
183
180
  }
184
181
 
185
182
  /// @inheritdoc IResourceAdapter
@@ -206,9 +203,9 @@ contract AdapterCoreV1 is IResourceAdapter {
206
203
 
207
204
  /// @inheritdoc IResourceAdapter
208
205
  /// @dev Three constraints: (a) what `holder` holds in the resource (vToken balance × ER),
209
- /// (b) what the vToken can pay out in cash (`getCash`), and (c) the treasury cut
210
- /// (`treasuryPercent`). The min of (a) and (b) is the gross redeemable; multiplying
211
- /// by `(1 − treasuryPct)` reflects what actually arrives at the recipient.
206
+ /// (b) what the vToken can pay out in cash (`getCash` net of `totalReserves`), and (c)
207
+ /// the treasury cut (`treasuryPercent`). The min of (a) and (b) is the gross redeemable;
208
+ /// multiplying by `(1 − treasuryPct)` reflects what actually arrives at the recipient.
212
209
  function maxWithdraw(address resource, address holder) external view override returns (uint256) {
213
210
  IComptroller comptrollerContract = IComptroller(IVToken(resource).comptroller());
214
211
  // Action.REDEEM == 1 in the Venus Comptroller's enum.
@@ -221,6 +218,10 @@ contract AdapterCoreV1 is IResourceAdapter {
221
218
  if (ourValue == 0) return 0;
222
219
 
223
220
  uint256 cash = IVToken(resource).getCash();
221
+ // `accrueInterest` sends the reserves out of cash when a reduction is due.
222
+ uint256 reserves = IVToken(resource).totalReserves();
223
+ if (cash <= reserves) return 0;
224
+ cash -= reserves;
224
225
  // A redeem that would burn 0 vTokens is bumped to one whole vToken unit of underlying by
225
226
  // `_floorRedeemToOneVToken`. If the market's cash is below that unit, every non-zero redeem
226
227
  // the cascade could try exceeds cash and reverts in Venus `redeemFresh`. Report 0 liquidity
@@ -264,6 +265,19 @@ contract AdapterCoreV1 is IResourceAdapter {
264
265
  if (treasuryPct != 0) revert TreasuryFeeUnsupported(resource, treasuryPct);
265
266
  }
266
267
 
268
+ // ============================== Internal — view ==========================
269
+
270
+ /// @dev Realizable value of `vBal` vTokens: stored-rate gross, net of the Comptroller treasury cut.
271
+ function _receiptValue(address resource, uint256 vBal) internal view returns (uint256) {
272
+ if (vBal == 0) return 0;
273
+ uint256 gross = (vBal * IVToken(resource).exchangeRateStored()) / EXP_SCALE;
274
+
275
+ uint256 treasuryPct = IComptroller(IVToken(resource).comptroller()).treasuryPercent();
276
+ if (treasuryPct == 0) return gross;
277
+ // The Comptroller setter enforces `treasuryPercent < 1e18`, so the subtraction cannot underflow.
278
+ return (gross * (MANTISSA_ONE - treasuryPct)) / MANTISSA_ONE;
279
+ }
280
+
267
281
  // ============================== Private — view ===========================
268
282
 
269
283
  /**
@@ -120,11 +120,16 @@ contract AdapterFRV is IResourceAdapter {
120
120
  }
121
121
 
122
122
  /// @inheritdoc IResourceAdapter
123
- /// @dev Position value = principal slice + accrued-coupon slice; this is the Hub-facing NAV for
124
- /// the position. See the contract NatSpec for the valuation model.
123
+ /// @dev Position value = principal slice + accrued-coupon slice. See the contract NatSpec for
124
+ /// the valuation model. Prices everything `holder` has in the wallet, not the YieldGroup's
125
+ /// tracked receipts.
125
126
  function totalAssets(address resource, address holder) external view override returns (uint256) {
126
- (uint256 principalValue, uint256 couponValueGross) = _breakdown(resource, holder);
127
- return principalValue + couponValueGross;
127
+ return _receiptValue(resource, IFRVVault(resource).balanceOf(holder));
128
+ }
129
+
130
+ /// @inheritdoc IResourceAdapter
131
+ function receiptValue(address resource, uint256 shares) external view override returns (uint256) {
132
+ return _receiptValue(resource, shares);
128
133
  }
129
134
 
130
135
  /// @inheritdoc IResourceAdapter
@@ -208,7 +213,13 @@ contract AdapterFRV is IResourceAdapter {
208
213
 
209
214
  // ============================== Internal — view ==========================
210
215
 
211
- /// @dev Split the holder's position into (principalValue, couponValueGross), both in underlying.
216
+ /// @dev Principal slice plus accrued-coupon slice of `shares`, in underlying.
217
+ function _receiptValue(address resource, uint256 shares) internal view returns (uint256) {
218
+ (uint256 principalValue, uint256 couponValueGross) = _breakdown(resource, shares);
219
+ return principalValue + couponValueGross;
220
+ }
221
+
222
+ /// @dev Split `shares` of the vault into (principalValue, couponValueGross), both in underlying.
212
223
  /// Branches by lifecycle state:
213
224
  /// - no shares -> (0, 0)
214
225
  /// - Closed -> (0, 0) (stuck residual; valued at 0 = deliverable value)
@@ -217,13 +228,12 @@ contract AdapterFRV is IResourceAdapter {
217
228
  /// Both slices use the vault's own +1/+1 floored ERC-4626 basis (decimalsOffset 0), so the
218
229
  /// principal slice equals `previewRedeem(shares)` during non-terminal states and the gross
219
230
  /// sum equals the post-Matured `previewRedeem` at `elapsed == lockDuration`.
220
- function _breakdown(address resource, address holder)
231
+ function _breakdown(address resource, uint256 shares)
221
232
  internal
222
233
  view
223
234
  returns (uint256 principalValue, uint256 couponValueGross)
224
235
  {
225
236
  IFRVVault vault = IFRVVault(resource);
226
- uint256 shares = vault.balanceOf(holder);
227
237
  if (shares == 0) return (0, 0);
228
238
 
229
239
  IFRVVault.FRVVaultRuntime memory rt = vault.runtime();
@@ -112,10 +112,14 @@ contract AdapterFlux is IResourceAdapter {
112
112
  /// @inheritdoc IResourceAdapter
113
113
  /// @dev `previewRedeem(balanceOf(holder))` — the per-holder position value (shares × token
114
114
  /// exchange price, including accrued rewards).
115
+ /// Prices everything `holder` has in the wallet, not the YieldGroup's tracked receipts.
115
116
  function totalAssets(address resource, address holder) external view override returns (uint256) {
116
- uint256 shares = IFToken(resource).balanceOf(holder);
117
- if (shares == 0) return 0;
118
- return IFToken(resource).previewRedeem(shares);
117
+ return _receiptValue(resource, IFToken(resource).balanceOf(holder));
118
+ }
119
+
120
+ /// @inheritdoc IResourceAdapter
121
+ function receiptValue(address resource, uint256 shares) external view override returns (uint256) {
122
+ return _receiptValue(resource, shares);
119
123
  }
120
124
 
121
125
  /// @inheritdoc IResourceAdapter
@@ -176,4 +180,12 @@ contract AdapterFlux is IResourceAdapter {
176
180
  // YieldGroup's `asset()` probe and `code.length` check at registration time.
177
181
  resource;
178
182
  }
183
+
184
+ // ============================== Internal — view ==========================
185
+
186
+ /// @dev `previewRedeem(shares)`: shares at the token's exchange price, accrued rewards included.
187
+ function _receiptValue(address resource, uint256 shares) internal view returns (uint256) {
188
+ if (shares == 0) return 0;
189
+ return IFToken(resource).previewRedeem(shares);
190
+ }
179
191
  }
@@ -245,10 +245,14 @@ contract AdapterSpokeV1 is IResourceAdapter {
245
245
  /// @dev `balance x exchangeRateStored`: the market's own valuation of the tokens, and what
246
246
  /// redeeming all of them would pay at the stored rate if the market had the cash. Includes
247
247
  /// the position's share of `badDebt`; see the contract NatSpec.
248
+ /// Prices everything `holder` has in the wallet, not the YieldGroup's tracked receipts.
248
249
  function totalAssets(address resource, address holder) external view override returns (uint256) {
249
- uint256 vBal = IVTokenIsolated(resource).balanceOf(holder);
250
- if (vBal == 0) return 0;
251
- return (vBal * IVTokenIsolated(resource).exchangeRateStored()) / EXP_SCALE;
250
+ return _receiptValue(resource, IVTokenIsolated(resource).balanceOf(holder));
251
+ }
252
+
253
+ /// @inheritdoc IResourceAdapter
254
+ function receiptValue(address resource, uint256 vBal) external view override returns (uint256) {
255
+ return _receiptValue(resource, vBal);
252
256
  }
253
257
 
254
258
  /// @inheritdoc IResourceAdapter
@@ -396,6 +400,14 @@ contract AdapterSpokeV1 is IResourceAdapter {
396
400
  }
397
401
  }
398
402
 
403
+ // ============================== Internal — view ==========================
404
+
405
+ /// @dev `vBal` vTokens at the market's stored exchange rate.
406
+ function _receiptValue(address resource, uint256 vBal) internal view returns (uint256) {
407
+ if (vBal == 0) return 0;
408
+ return (vBal * IVTokenIsolated(resource).exchangeRateStored()) / EXP_SCALE;
409
+ }
410
+
399
411
  // ============================== Private — pure ===========================
400
412
 
401
413
  /**
@@ -103,6 +103,16 @@ interface IResourceAdapter {
103
103
  */
104
104
  function totalAssets(address resource, address holder) external view returns (uint256 value);
105
105
 
106
+ /**
107
+ * @notice Underlying value of `shares` receipt tokens of `resource`, priced exactly as
108
+ * {totalAssets} prices a holder's balance. Lets the YieldGroup value the receipts it
109
+ * minted itself rather than everything its wallet happens to hold.
110
+ * @param resource Underlying yield protocol address.
111
+ * @param shares Receipt-token amount to price, in the resource's own units.
112
+ * @return value Underlying units `shares` are worth.
113
+ */
114
+ function receiptValue(address resource, uint256 shares) external view returns (uint256 value);
115
+
106
116
  /**
107
117
  * @notice Spare deposit headroom on `resource` right now (capacity remaining before the
108
118
  * resource rejects mint via its own caps or pause).
@@ -137,9 +147,8 @@ interface IResourceAdapter {
137
147
  /**
138
148
  * @notice Raw receipt-token balance `holder` owns via `resource` (vToken / fToken / FRV
139
149
  * shares), in receipt-token units — NOT underlying value.
140
- * @dev Used by `YieldGroup.removeResource` as a share-based emptiness gate. A value-based
141
- * check (`totalAssets`) rounds a small-but-non-zero receipt balance down to 0, which
142
- * would let removal orphan those receipt tokens; reading the raw balance closes that gap.
150
+ * @dev In share units so a sub-unit residual still reads as non-zero where a value read would
151
+ * round it to 0.
143
152
  * @param resource Underlying yield protocol address.
144
153
  * @param holder Address whose receipt balance to read (the YieldGroup).
145
154
  * @return shares Receipt-token units `holder` holds in this resource.