diff --git a/SUMMARY.md b/SUMMARY.md index a9d6dfeb..dab24c9d 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -19,6 +19,7 @@ * [TokenBuyback Contract](whats-new/token-converter.md) * [Venus Prime](whats-new/prime-yield.md) * [Liquidity Hub](whats-new/liquidity-hub.md) +* [Hub-Funded Spoke Pools](whats-new/hub-funded-spoke-pools.md) ## Governance @@ -123,6 +124,7 @@ * [WhitePaperModel](technical-reference/reference-core-pool/interestratemodels/whitepapermodel.md) * [InterestRateModelLens](technical-reference/reference-core-pool/interestratemodels/interestratemodellens.md) * [Liquidator](technical-reference/reference-core-pool/liquidator.md) + * [BStockLiquidator](technical-reference/reference-core-pool/bstock-liquidator.md) * [VTreasury](technical-reference/reference-core-pool/vtreasury.md) * [VenusLens](technical-reference/reference-core-pool/venus-lens.md) * [PSM](technical-reference/reference-core-pool/psm/peg-stability.md) @@ -131,6 +133,9 @@ * [Comptroller](technical-reference/reference-isolated-pools/comptroller/README.md) * [Comptroller](technical-reference/reference-isolated-pools/comptroller/comptroller.md) * [ComptrollerStorage](technical-reference/reference-isolated-pools/comptroller/comptroller-storage.md) + * [Hub-Funded Spoke Pools](technical-reference/reference-isolated-pools/spoke/README.md) + * [SpokeComptroller](technical-reference/reference-isolated-pools/spoke/spoke-comptroller.md) + * [SpokeComptrollerStorage](technical-reference/reference-isolated-pools/spoke/spoke-comptroller-storage.md) * [VenusERC4626](technical-reference/reference-isolated-pools/erc4626/venus-erc4626.md) * [VenusERC4626Factory](technical-reference/reference-isolated-pools/erc4626/venus-erc4626-factory.md) * [VToken](technical-reference/reference-isolated-pools/vtoken/README.md) @@ -225,6 +230,7 @@ * [vhToken](technical-reference/reference-liquidity-hub/vhtoken.md) * [Yield Groups](technical-reference/reference-liquidity-hub/yield-groups.md) * [Adapters](technical-reference/reference-liquidity-hub/adapters.md) + * [HubRouter](technical-reference/reference-liquidity-hub/hub-router.md) * [Interfaces](technical-reference/reference-liquidity-hub/interfaces.md) ## Deployed Contracts diff --git a/technical-reference/reference-core-pool/bstock-liquidator.md b/technical-reference/reference-core-pool/bstock-liquidator.md new file mode 100644 index 00000000..0690b8f8 --- /dev/null +++ b/technical-reference/reference-core-pool/bstock-liquidator.md @@ -0,0 +1,259 @@ +# BStockLiquidator + +`BStockLiquidator` is Venus's **atomic backstop liquidator for bStock collateral** — tokenized stock markets such as vTSLAB, vNVDAB or vSPCXB, whose underlying trades on request-for-quote venues rather than on an AMM. + +In one transaction it repays an undercollateralized borrow, seizes the bStock vToken, redeems it to the raw bStock, and sells that bStock to the debt asset. Because seizing and selling happen in the same transaction there is no price-drift window, and the realized proceeds must clear a caller-supplied floor or the whole call reverts — the protocol never ends up holding the RFQ-only asset. + +It is **not** a public utility. `liquidate` and `flashLiquidate` are operator-gated, because the contract custodies protocol capital and forwards a caller-supplied calldata blob to an external router. Anyone may still liquidate bStock through the ordinary permissionless Venus path with their own funds and their own offload; this contract exists so Venus itself can act as a backstop. + +## Two pools, two funding modes + +The same core path serves four combinations. Neither dimension is a flag the caller sets — the pool is resolved from on-chain state, and the funding mode is the entry point chosen. + +**Pools**, resolved in `_resolvePool` from the *collateral* market's own `comptroller()`: + +* **Core** — the Venus Core pool. The repay goes through the pool-wide Venus [Liquidator](liquidator.md) gate. +* **Isolated** — a [hub-funded spoke pool](../reference-isolated-pools/spoke/README.md) that is registered in the [`PoolRegistry`](../reference-isolated-pools/pool-registry/pool-registry.md) the owner configured. The repay goes straight to the debt market. + +**Funding modes:** + +* **INVENTORY** (`liquidate`) — the contract is pre-funded with the debt asset and repays from its own balance. +* **FLASH** (`flashLiquidate`) — the debt asset is flash-borrowed from Venus Core and repaid with its premium inside the same transaction, so no capital is locked in the contract. + +Only the repay differs between pools. Redeem, sale, `minOut` enforcement and `sweep` are shared. + +> **The isolated branch is unreachable until a registry is configured.** While `poolRegistry` is the zero address, a non-Core position reverts `PoolRegistryNotSet` and all the spoke support adds to a Core liquidation is one `vBStock.comptroller()` staticcall. + +### Liquidating in a spoke pool + +Three things differ from the Core path. + +**The pool is checked against *our* registry, then both legs against the pool.** `_resolvePool` reads the collateral market's `comptroller()`; anything other than the Core Comptroller has to round-trip through the configured registry — `getPoolByComptroller(pool).comptroller` must equal `pool`, since an unregistered pool reads back as a zero-filled struct rather than reverting. The registry consulted is the one the owner configured here, never one the pool names for itself: a hostile contract can claim any registry, but it cannot get itself into this one. A pool that is not there reverts `PoolNotRegistered`. + +Core then has a `Comptroller.liquidatorContract` gate that validates the markets on the way through; an isolated pool has none, and the repay approves a *caller-supplied* `vDebt`. So both legs are additionally checked against the pool's own storage (`isMarketListed`) rather than trusting what a market claims about itself. A hostile contract can forge `comptroller()`; it cannot forge an entry in the pool's storage. A market that fails this check reverts `MarketNotInPool`. + +**The flash source is configured, not derived.** Isolated pools have no flash lender of their own, but a Core flash loan is not tied to the liquidation target: `FlashLoanFacet` hands over a Core market's underlying and wants it back in the same transaction. So a spoke USDT debt is funded from the **Core** USDT market, named per debt token by `setCoreFlashSource`. Without an entry, `flashLiquidate` reverts `FlashSourceNotSet` and the position has to go through `liquidate` instead. The configured source's `underlying()` is re-asserted at call time, not just at configuration time, because the market is upgradeable. + +**The pool's liquidation allowlist gates *this contract*, not the operator.** If the spoke pool has [`isLiquidationAllowlistEnabled`](../reference-isolated-pools/spoke/spoke-comptroller.md#liquidation-allowlist) turned on, `BStockLiquidator`'s own address must be on it — it is the account that receives the seized collateral. + +Isolated pools are ERC20-only, so the vBNB and VAI branches described below are Core-only. + +## The swap + +Selling bStock takes one or two hops. + +* **Hop 1** sells bStock through an allowlisted RFQ router, using a pre-fetched, off-chain-signed `swapCalldata`. The contract is router-agnostic and just forwards the opaque blob. RFQ sources quote bStock → USDT only. +* **Hop 2** is optional. For a non-USDT debt it converts the intermediate (USDT) to the debt asset through a second allowlisted router — typically an AMM or aggregator. Set `router2 = address(0)` for a single hop. + +`minOut` is always the floor on the **final debt-asset** amount across the whole chain, and it must be non-zero (`ZeroMinOut`). `deadline` is a unix-timestamp expiry, so a transaction left sitting in the mempool cannot settle against a stale quote. + +Each hop approves the router's configured spender the exact amount being sold, and resets the approval to zero afterwards. The spender defaults to the router itself when unset — the RFQ shape, where the call target is the puller — and aggregators with a separate settlement contract get an entry through `setRouterSpender`. Hop 2 approves the *measured balance delta* of the intermediate rather than the whole balance, so pre-existing inventory is never exposed. + +If a hop pulls less than was approved (a partially filled RFQ quote), the unconsumed remainder stays in the contract and is announced via `PartialSwapLeftover`; recover it with `sweep`. `minOut` still bounds the realized proceeds either way. + +## Special debt assets (Core only) + +**Native BNB (vBNB).** Supported in both funding modes, with WBNB as the debt-accounting token. The repay must be native BNB, so exactly the repay amount of WBNB is unwrapped and forwarded to the gate's payable path — pre-existing WBNB inventory is untouched. The swap chain lands WBNB (bStock → USDT → WBNB) and `minOut` is measured in WBNB. FLASH mode borrows from **vWBNB, not vBNB**: vBNB cannot be flash-repaid, because its `doTransferIn` requires `msg.value`. + +**VAI.** INVENTORY mode only. VAI is not a vToken — a `vDebt` equal to `comptroller.vaiController()` is VAI — so the repay is a plain ERC20 approval to the gate, which burns it through `VAIController.liquidateVAI`. A VAI debt is inherently two-hop; hop 2 is expected to be the [Peg Stability Module](psm/peg-stability.md), allowlisted as `router2`, which mints VAI from USDT at the oracle rate. `flashLiquidate` rejects a VAI debt with `FlashNotSupportedForVai`: `executeFlashLoan` lends a vToken's underlying, and there is no vVAI market. + +## Loss floors differ by mode + +* **FLASH** — `executeOperation` requires the swap proceeds **alone** to cover principal plus premium (`InsufficientOut`). Without that check, any debt-asset inventory the contract happens to hold would silently backfill an underwater swap, since the flash repayment is pulled from the total balance rather than from the swap output. +* **INVENTORY** — there is no built-in floor tying proceeds to the repay, and that asymmetry is deliberate: a repay can legitimately out-cost its proceeds, for example because the Venus Liquidator keeps a treasury cut of the seized collateral. **`minOut` is the operator's chosen loss floor in this mode** — set it to the lowest acceptable debt-asset return. + +## Security model + +* `liquidate` and `flashLiquidate` are `onlyOperator` (the owner, or an allowlisted operator), and both are `nonReentrant`. +* **Both** swap targets must be allowlisted in `isRouter`, which is what defends the low-level `router.call(swapCalldata)` on each hop. The swap recipient lives inside that caller-supplied calldata, so an open entry point would let anyone route the proceeds to themselves. +* A router spender must be a deployed contract (`SpenderNotContract`) — it receives a live token approval, so an EOA spender is always a misconfiguration. +* `executeOperation` accepts calls only from the Core Comptroller, and only with `initiator == address(this)`, which proves the flash was started by this contract's own `flashLiquidate`. +* `renounceOwnership` is a deliberate no-op. The contract custodies capital and every admin function is `onlyOwner`, so renouncing would strand those funds permanently. Ownership is still transferable through the two-step `Ownable2Step` flow. +* Liquidatability is **not** pre-checked. The owning pool's own liquidate hook enforces it, and pre-checking shortfall would wrongly block forced liquidations, which liquidate healthy accounts by design. + +## Immutables + +Set in the constructor of the implementation, so changing any of them means a new implementation. + +| Immutable | Purpose | +| --- | --- | +| `comptroller` | Venus Core Comptroller (diamond): reads the liquidation gate, provides the flash loan | +| `vBNB` | the native BNB market; a `vDebt` equal to this is settled in native BNB | +| `vWBNB` | the flash-borrow source for BNB debt | +| `wbnb` | the debt-accounting token for BNB debt, unwrapped for the native repay | + +## Solidity API + +### LiquidationParams + +```solidity +struct LiquidationParams { + address borrower; // account to liquidate + IVBep20 vDebt; // borrowed market to repay (e.g. vUSDT) + IVBep20 vBStock; // bStock collateral market to seize (e.g. vTSLAB) + uint256 repayAmount; // debt underlying to repay, in its own decimals + address router; // hop-1 RFQ router; must be allowlisted + bytes swapCalldata; // hop-1 calldata (off-chain-signed RFQ order) + uint256 minOut; // minimum FINAL debt-asset amount; must be non-zero + address router2; // hop-2 router; address(0) = single hop + bytes swapCalldata2; // hop-2 calldata; the recipient inside it MUST be this contract + address intermediateToken; // token hop 1 outputs and hop 2 consumes (e.g. USDT) + uint256 deadline; // unix timestamp after which the call reverts +} +``` + +--- + +### liquidate + +Liquidate using the contract's own debt-asset inventory. + +```solidity +function liquidate(LiquidationParams calldata params) external returns (uint256 debtOut) +``` + +#### Return Values + +| Name | Type | Description | +| --- | --- | --- | +| debtOut | uint256 | Debt-asset proceeds of the swap chain | + +#### 📅 Events + +* Emits `Liquidated` with `flash = false`; may emit `PartialSwapLeftover` + +#### ⛔️ Access Requirements + +* Owner or an allowlisted operator + +--- + +### flashLiquidate + +Liquidate by flash-borrowing the repay amount from Venus Core, repaid with its premium in the same transaction. Profit stays in the contract; withdraw it with `sweep`. + +```solidity +function flashLiquidate(LiquidationParams calldata params) external +``` + +Requires this contract to be `authorizedFlashLoan` in the Core Comptroller. The market flash-borrowed from is `vDebt` itself in Core mode, vWBNB for a BNB debt, or `coreFlashSource[debtToken]` in isolated mode. + +#### 📅 Events + +* Emits `Liquidated` with `flash = true`; may emit `PartialSwapLeftover` + +#### ⛔️ Access Requirements + +* Owner or an allowlisted operator + +--- + +### setPoolRegistry + +Sets the `PoolRegistry` that decides which non-Core pools may be liquidated in. Gates the entire isolated branch. + +```solidity +function setPoolRegistry(address poolRegistry_) external +``` + +A pool is accepted only while that registry holds an entry for it, so **enabling a spoke pool is done by registering it there, not by a call here**. Passing `address(0)` clears the registry and closes the isolated branch. + +Spoke pools are listed in [a `PoolRegistry` instance of their own](../reference-isolated-pools/spoke/README.md#a-registry-of-its-own), separate from the isolated pools' registry, so that is the address to configure here — pointing this at the isolated-pools registry would not resolve a spoke pool. + +A non-zero address is validated on the way in: it must have code (`PoolRegistryNotContract`), and `getPoolByComptroller` is probed so a wrong address fails here rather than mid-liquidation. Only that the call answers and decodes is checked, not what it returns. + +#### 📅 Events + +* Emits `PoolRegistrySet` + +#### ⛔️ Access Requirements + +* Only the owner + +#### ❌ Errors + +* `PoolRegistryNotContract(address poolRegistry)` — the address has no code + +--- + +### setCoreFlashSource + +Set the Core market whose underlying flash-funds the repay of a non-Core pool's debt token. `vToken = address(0)` clears the entry. + +```solidity +function setCoreFlashSource(address debtToken, IVBep20 vToken) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| debtToken | address | The non-Core pool's debt underlying (e.g. USDT) | +| vToken | IVBep20 | The Core market to flash-borrow from; its `underlying()` must equal `debtToken` | + +#### 📅 Events + +* Emits `CoreFlashSourceSet` + +#### ⛔️ Access Requirements + +* Only the owner + +--- + +### Other admin + +| Function | Purpose | +| --- | --- | +| `setOperator(address operator, bool allowed)` | allowlist an address to trigger liquidations | +| `setRouter(address router, bool allowed)` | allowlist a swap target | +| `setRouterSpender(address router, address spender)` | set the approval target for a router whose puller differs from the call target; `address(0)` restores the default | +| `sweep(address token, address to, uint256 amount)` | withdraw profit, leftover inventory, or stuck dust | +| `sweepNative(address to, uint256 amount)` | withdraw stuck native BNB | + +All are `onlyOwner`. + +## Events + +| Event | Emitted when | +| --- | --- | +| `Liquidated(address indexed borrower, address indexed vBStock, address indexed vDebt, uint256 repayAmount, uint256 seizedBStock, uint256 debtOut, bool flash)` | a liquidation succeeds | +| `OperatorSet(address indexed operator, bool allowed)` | an operator is allowlisted or removed | +| `RouterSet(address indexed router, bool allowed)` | a router is allowlisted or removed | +| `RouterSpenderSet(address indexed router, address indexed spender)` | a router's approval target is set or cleared | +| `PoolRegistrySet(address indexed oldPoolRegistry, address indexed newPoolRegistry)` | the registry that decides non-Core pools is set or cleared | +| `CoreFlashSourceSet(address indexed debtToken, address indexed vToken)` | the Core flash source for a debt token is set or cleared | +| `PartialSwapLeftover(address indexed token, uint256 amount)` | a swap hop pulls less than was approved, leaving a recoverable residual | +| `Swept(address indexed token, address indexed to, uint256 amount)` / `SweptNative(address indexed to, uint256 amount)` | the owner withdraws | + +## Errors + +| Error | Meaning | +| --- | --- | +| `NotOperator()` | the caller is neither the owner nor an allowlisted operator | +| `RouterNotAllowed(address router)` | the supplied swap router is not allowlisted | +| `SpenderNotContract(address spender)` | the router spender being set is not a deployed contract | +| `RedeemFailed(uint256 errCode)` | `vBStock.redeem` returned a non-zero error code | +| `SwapFailed()` | the low-level call to the router reverted | +| `InsufficientOut(uint256 got, uint256 minOut)` | proceeds are below `minOut`, or below principal + premium in FLASH mode | +| `ZeroMinOut()` | `minOut` was zero, which would silently accept any proceeds | +| `InvalidIntermediate()` | a two-hop `intermediateToken` is zero, or equals the debt or bStock token | +| `OnlyComptroller()` | `executeOperation` was called by something other than the Core Comptroller | +| `BadInitiator(address initiator)` | the flash-loan initiator is not this contract | +| `WrongFlashAsset()` | the flashed asset does not match the debt market | +| `FlashNotSupportedForVai()` | `flashLiquidate` was called with a VAI debt | +| `DeadlineExpired(uint256 deadline, uint256 nowTs)` | the call was submitted after `params.deadline` | +| `PoolRegistryNotSet()` | the position is in a non-Core pool and no `PoolRegistry` is configured | +| `PoolNotRegistered(address comptroller)` | the pool owning the position has no entry in the configured `PoolRegistry` | +| `PoolRegistryNotContract(address poolRegistry)` | the address being set as the registry has no code | +| `MarketNotInPool(address comptroller, address market)` | a market is not listed in the registered pool it claims to belong to | +| `FlashSourceNotSet(address debtToken)` | `flashLiquidate` for a non-Core pool whose debt token has no configured Core flash source | +| `FlashSourceMismatch(address flashSource, address debtToken)` | the configured flash source's underlying is not the debt token | + +## Deployment + +| Network | Address | +| --- | --- | +| BNB Chain mainnet | `0x5974Badab6911a78Ba15229045514C2C1bD42343` (transparent proxy) | + +The spoke-pool support described on this page is **not yet deployed**: it requires an implementation upgrade, and no `PoolRegistry` has been configured through `setPoolRegistry`, so no spoke pool can be resolved yet. diff --git a/technical-reference/reference-isolated-pools/spoke/README.md b/technical-reference/reference-isolated-pools/spoke/README.md new file mode 100644 index 00000000..c050eb3c --- /dev/null +++ b/technical-reference/reference-isolated-pools/spoke/README.md @@ -0,0 +1,101 @@ +# Hub-Funded Spoke Pools + +A **spoke pool** is an isolated pool whose Comptroller is `SpokeComptroller` instead of the shared [`Comptroller`](../comptroller/comptroller.md), listed in a [`PoolRegistry`](../pool-registry/pool-registry.md) instance of its own. The rest of the machinery is unchanged: the same [`VToken`](../vtoken/vtoken.md) markets behind the same `VTokenBeacon`, the same [`RewardsDistributor`](../rewards/rewards-distributor.md), the same [Shortfall](../risk-fund-and-shortfall/shortfall.md) and [ProtocolShareReserve](../risk-fund-and-shortfall/protocol-share-reserve.md) plumbing. + +What the fork adds is policy that only makes sense for a pool the protocol funds itself: + +* a **per-market supply allowlist**, so the liquidity side of the pool is supplied only by the [Liquidity Hub](../../reference-liquidity-hub/README.md) (or whichever accounts governance names), +* an optional **pool-wide liquidation allowlist**, +* a **per-market liquidation incentive**, keyed on the collateral being seized rather than one value for the whole pool, +* **deviation-bounded collateral pricing** on the borrow-power path, using the [`DeviationBoundedOracle`](../../reference-oracle/deviation-bounded-oracle.md). + +For the product-level introduction — why the pool is shaped this way, and the retail / bulk scenarios it serves — see [Hub-Funded Spoke Pools](../../../whats-new/hub-funded-spoke-pools.md) under *What's New*. + +> **Not deployed.** At the time of writing `SpokeComptroller` is not deployed on any network and no spoke pool has been listed. There are no addresses to publish, and no VIP has wired the Liquidity Hub to a spoke market. + +## The two sides of a spoke pool + +A spoke pool separates the asset that gets borrowed from the assets that back the borrow. Both are ordinary markets in the same pool; the split is entirely a matter of configuration. + +| | **Liquidity side** | **Collateral side** | +| --- | --- | --- | +| Example asset | USDT, USDC, U | tokenized stock (bStock), trending or exotic assets | +| Who may supply | allowlisted accounts only — the Hub's Spoke YieldGroup | anyone (an allowlist is available but off by default) | +| Collateral factor | `0` — not usable as collateral | non-zero | +| Borrowable | yes | no (borrow cap `0`) | +| Interest rate model | its own IRM per market | not applicable | +| Caps | own supply cap and borrow cap | own supply cap | +| Liquidation incentive | never read (never seized) | its own, per market | + +Nothing in the contract labels a market as one side or the other. A market is on the liquidity side because its supply allowlist is enabled and its collateral factor is zero; it is on the collateral side because its borrow cap is zero and its collateral factor is not. Both are governance settings, and a market can carry a mix. + +Because each pool has its own Comptroller, a spoke pool is isolated from the Core pool and from every other isolated pool: no shared collateral, no cross-margin, and no path by which bad debt in a spoke pool reaches the Core pool. A borrower's health factor is shared across all of their collateral and all of their debt **within one spoke pool**, exactly as in any other isolated pool. + +## Why a fork rather than a change to `Comptroller` + +`contracts/Comptroller.sol` is the implementation every isolated pool in the repo shares, on BNB Chain and on every other chain isolated pools are deployed to. Adding allowlist code there would ship it to every core-pool-adjacent market at its next beacon upgrade. Inheritance is not an option either — no function in `Comptroller` is `virtual`, so the hooks cannot be overridden. + +`SpokeComptroller` is therefore a hand-maintained fork behind **its own beacon**. Existing pools and chains are untouched, and the two implementations drift independently: a change to `Comptroller` has to be reviewed and re-applied by hand. `diff contracts/Comptroller.sol contracts/Spoke/SpokeComptroller.sol` is the canonical statement of what the fork currently changes. + +**Prime is dropped.** Prime is a Core-pool rewards program and a hub-funded pool has no Prime users, so the fork removes the `prime` storage variable and `setPrimeToken`, and reduces the seven `*Verify` post-action hooks to no-ops. They cannot be deleted — `ComptrollerInterface` declares all seven and the `VToken` calls each of them with a plain external call — but they now do nothing. This also buys bytecode headroom: the shared `Comptroller` sits roughly 700 bytes under the 24,576-byte EIP-170 limit, and the allowlists had to fit somewhere. + +## Contracts + +* [**SpokeComptroller**](spoke-comptroller.md) — the fork: what it adds on top of `Comptroller`, the new setters and getters, the liquidation routing math, and the bounded-pricing path. +* [**SpokeComptrollerStorage**](spoke-comptroller-storage.md) — the storage layout, including where it deliberately diverges from `ComptrollerStorage`. + +## A registry of its own + +A spoke pool is **not** registered in the isolated-pools [`PoolRegistry`](../pool-registry/pool-registry.md). It gets a second, dedicated instance of the same contract, deployed under the name `SpokePoolRegistry`. + +The registry is the directory every consumer reads to answer which pools exist: `getAllPools` drives the indexer, the frontend pool list and the risk tooling, and `getVTokenForAsset` is what [ProtocolShareReserve](../risk-fund-and-shortfall/protocol-share-reserve.md) uses as a membership check. Putting a pool whose supply, borrow and liquidation sides are all restricted to known accounts into that directory would hand it to every one of those consumers, each of which would then need a special case keyed on its address. A separate registry gives them the separation for free. + +It also separates permissions. An [AccessControlManager](../../reference-governance/access-control-manager.md) role is `keccak256(contractAddress, roleString)`, so a grant on one registry cannot reach the other's pools, and the two products stay independently upgradeable. + +> **ProtocolShareReserve holds a single `poolRegistry` address.** It rejects any non-core pool whose vToken that one registry does not know, so pointing it at the spoke registry would break `reduceReserves` and every liquidation in the existing isolated pools, while leaving it where it is starves the spoke pool of income routing. Multi-registry support ships from the `protocol-reserve` repo and has to be live before the spoke registry is wired in. This is a hard prerequisite for listing, not a follow-up. + +## Deployment shape + +Two deploy scripts run, in order. + +**`SpokePoolRegistry`** deploys a `PoolRegistry` behind the chain's existing `DefaultProxyAdmin`, initializes it with the AccessControlManager, and *nominates* the Normal Timelock as owner. It is `Ownable2Step`, so the deployer stays the live owner until the VIP accepts. The script refuses to hand over a registry that is not empty or whose ACM address does not read back as expected. + +**`SpokeComptroller`** then: + +1. Deploys `SpokeComptrollerImpl` with that registry's address as a constructor argument. It is an immutable, so a wrong value can only be fixed by redeploying the implementation and re-pointing the beacon; the script asserts it did not resolve to the isolated-pools registry. +2. Deploys `SpokeComptrollerBeacon` pointing at that implementation. It is **separate from the shared `ComptrollerBeacon`**, so upgrading one family never touches the other. +3. Deploys and initializes a `BeaconProxy` against it, transfers the beacon to the Normal Timelock, and nominates the Timelock as the Comptroller's owner. + +Everything else is governance action, and the order matters: + +1. `acceptOwnership()` on both the registry and the Comptroller — before any owner-gated setter. +2. `setPriceOracle` and `setDeviationBoundedOracle` on the Comptroller. The bounded oracle is dereferenced without a zero check, so borrowing and redeeming fail closed until it is set — see [Bounded collateral pricing](spoke-comptroller.md#bounded-collateral-pricing). +3. `SpokePoolRegistry.addPool`, which requires a non-zero oracle and is also what sets the pool-wide liquidation incentive for the first time. +4. `SpokePoolRegistry.addMarket` per market, then the per-market configuration: caps, collateral factor, liquidation threshold, IRM, liquidation incentive, and the allowlists. + +### Roles the VIP has to grant + +None of the isolated pools' existing grants carry over, because each names the isolated-pools registry as the account. + +| On | Role string | Granted to | +| --- | --- | --- | +| `SpokePoolRegistry` | `addPool(string,address,uint256,uint256,uint256)` | governance | +| `SpokePoolRegistry` | `addMarket(AddMarketInput)` | governance | +| `SpokePoolRegistry` | `setPoolName(address,string)` | governance | +| `SpokePoolRegistry` | `updatePoolMetadata(address,VenusPoolMetaData)` | governance | +| `SpokeComptroller` | `setCloseFactor(uint256)` | `SpokePoolRegistry` | +| `SpokeComptroller` | `setLiquidationIncentive(uint256)` | `SpokePoolRegistry` | +| `SpokeComptroller` | `setMinLiquidatableCollateral(uint256)` | `SpokePoolRegistry` | +| `SpokeComptroller` | `setCollateralFactor(address,uint256,uint256)` | `SpokePoolRegistry` | +| `SpokeComptroller` | `setMarketSupplyCaps(address[],uint256[])` | `SpokePoolRegistry` | +| `SpokeComptroller` | `setMarketBorrowCaps(address[],uint256[])` | `SpokePoolRegistry` | + +The bottom six are the setters `addPool` and `addMarket` drive with the registry as the caller; without them `addPool` reverts at execution. The pool's own policy setters (the allowlists, the per-market incentive) and `enterMarketBehalf` are separate grants on top of these — see [SpokeComptroller](spoke-comptroller.md#solidity-api). + +## Integration notes + +* **Reading the pool through the lens.** [`PoolLens`](../lens/pool-lens.md) reports the spoke-only state alongside everything else, so a consumer does not need a separate code path. `PoolData` gains `deviationBoundedOracle` and `liquidationAllowlistEnabled`; `VTokenMetadata` gains `supplyAllowlistEnabled` and a `liquidationIncentiveMantissa` that is **per market**. Each is read with a `staticcall`, so an ordinary isolated pool reports absence (`address(0)` / `false`) rather than reverting the whole read, and the per-market incentive falls back to the pool-wide value there. The fields are appended rather than reordered, so a decoder built against the older shape still reads the fields it knows. `PoolData.minLiquidatableCollateral` remains pool-wide; the per-market discount is the one on `VTokenMetadata`. +* **Reading the pool directly.** `SpokeComptrollerViewInterface` collects the getters an integrator needs — the two allowlists, the effective liquidation incentive, the bounded oracle, plus `supplyCaps` and `actionPaused` repeated so that consuming a spoke pool takes one import rather than three. `actionPaused` is declared there with a `uint8` action so a consumer does not have to import this repo's `Action` enum; the encoding is identical. +* **Reading events and errors.** `SpokeComptrollerInterface` declares the full observable surface. Where an error means the same thing as in the shared `Comptroller`, it keeps the same name, arguments and selector; where the meaning changed, it was given a new name deliberately so the two do not collide. See [Errors](spoke-comptroller.md#errors). +* **The Liquidity Hub** supplies the liquidity side through `AdapterSpokeV1` and the Spoke YieldGroup — see [Adapters](../../reference-liquidity-hub/adapters.md#adapterspokev1). +* **Liquidating tokenized-stock collateral** in a spoke pool is covered by [`BStockLiquidator`](../../reference-core-pool/bstock-liquidator.md), which serves both the Core pool and allowlisted spoke pools from the same entry points. diff --git a/technical-reference/reference-isolated-pools/spoke/spoke-comptroller-storage.md b/technical-reference/reference-isolated-pools/spoke/spoke-comptroller-storage.md new file mode 100644 index 00000000..2935f205 --- /dev/null +++ b/technical-reference/reference-isolated-pools/spoke/spoke-comptroller-storage.md @@ -0,0 +1,157 @@ +# SpokeComptrollerStorage + +Storage layout for the [`SpokeComptroller`](spoke-comptroller.md) contract. A fork of [`ComptrollerStorage`](../comptroller/comptroller-storage.md), kept separate so the spoke layout and the `AccountLiquiditySnapshot` struct can change without touching the shared implementation. Like the implementation it accompanies, it is re-synced by hand when `ComptrollerStorage` changes. + +## Layout compatibility + +The two layouts are **not** interchangeable, in either direction. + +`SpokeComptroller` drops Prime, and rather than leaving a hole where `prime` sat, the fork reclaims that slot into the storage gap. Every variable declared after it therefore moves up by one: `approvedDelegates` here occupies the slot `ComptrollerStorage` gives to `prime`. Neither implementation can be swapped in under a pool running the other. + +That is fine in practice, because a spoke pool upgrades through its **own beacon** and never shares an implementation with a pooled `Comptroller`. `tests/hardhat/Spoke/storageLayout.ts` pins the slot list, the divergence, and the gap size. + +The contract occupies the same total number of slots as the one it was forked from: the 47 slots `ComptrollerStorage` reserves, plus the reclaimed Prime slot, minus the six slots the additions below take, leaves a `uint256[42]` gap. + +## What the fork adds + +| Variable | Type | Purpose | +| --- | --- | --- | +| `isSupplyAllowlistEnabled` | `mapping(address => bool)` | whether a market accepts supply only from allowlisted accounts; keyed by market, off by default | +| `isAllowedSupplier` | `mapping(address => mapping(address => bool))` | the accounts a market accepts supply from while its allowlist is enabled | +| `isLiquidationAllowlistEnabled` | `bool` | whether seizing collateral in this pool is restricted; pool-wide, off by default | +| `isAllowedLiquidator` | `mapping(address => bool)` | the accounts allowed to seize collateral while the pool's allowlist is enabled | +| `liquidationIncentives` | `mapping(address => uint256)` | per-collateral-market discount, scaled by 1e18; `0` means the pool-wide value applies | +| `deviationBoundedOracle` | `IDeviationBoundedOracle` | the oracle that bounds an asset's price against a recent window, read only on the collateral-factor path | + +## What the fork removes or renames + +* **`prime`** and its `NewPrimeToken` machinery are gone. +* **`liquidationIncentiveMantissa`** is renamed to `_poolLiquidationIncentiveMantissa` and made `internal`. The public getter of the same name cannot be a plain public variable any more: it has to resolve the caller's market before answering, because `VToken` reads it on itself and needs the discount that prices its own collateral, not the pool-wide default. See [Per-market liquidation incentive](spoke-comptroller.md#per-market-liquidation-incentive). + +## Solidity API + +```solidity +struct LiquidationOrder { + contract VToken vTokenCollateral; + contract VToken vTokenBorrowed; + uint256 repayAmount; +} +``` + +```solidity +struct AccountLiquiditySnapshot { + uint256 totalCollateral; + uint256 weightedCollateral; + uint256 borrows; + uint256 effects; + uint256 liquidity; + uint256 shortfall; + uint256 maxClearableDebt; +} +``` + +`maxClearableDebt` is the field this fork adds: the largest borrow value the account's collateral can clear without leaving bad debt behind, computed as the sum over the account's collateral markets of `collateralValue / liquidationIncentive`, each market at its own incentive. It routes an under-threshold account between `liquidateAccount` and `healAccount`. + +> `totalCollateral` and `maxClearableDebt` are only meaningful under the **liquidation-threshold** weighting, which is what every caller that reads them passes. Under the collateral factor, `totalCollateral` is derived from the deviation-bounded collateral price rather than spot, and `maxClearableDebt` is not accumulated at all and stays zero — and a zero `maxClearableDebt` would put `healAccount` on a repayment percentage of zero, forgiving the whole position as bad debt. + +```solidity +struct RewardSpeeds { + address rewardToken; + uint256 supplySpeed; + uint256 borrowSpeed; +} +``` + +```solidity +struct Market { + bool isListed; + uint256 collateralFactorMantissa; + uint256 liquidationThresholdMantissa; + mapping(address => bool) accountMembership; +} +``` + +The `Action` enum is imported from `ComptrollerInterface` and is unchanged: + +```solidity +enum Action { + MINT, + REDEEM, + BORROW, + REPAY, + SEIZE, + LIQUIDATE, + TRANSFER, + ENTER_MARKET, + EXIT_MARKET +} +``` + +### isSupplyAllowlistEnabled + +Whether a market accepts supply only from the accounts on its supply allowlist. Keyed by market, and disabled by default, so a newly listed market accepts supply from anyone. + +```solidity +mapping(address => bool) isSupplyAllowlistEnabled +``` + +--- + +### isAllowedSupplier + +The accounts a market accepts supply from while its supply allowlist is enabled. Keyed by market, then by account. + +```solidity +mapping(address => mapping(address => bool)) isAllowedSupplier +``` + +--- + +### isLiquidationAllowlistEnabled + +Whether seizing collateral in this pool is restricted to the accounts on the liquidation allowlist. Disabled by default. Pool-wide rather than per market, because `healAccount` seizes across every market the borrower is in and so cannot attribute a seizure to a single one of them. + +```solidity +bool isLiquidationAllowlistEnabled +``` + +--- + +### isAllowedLiquidator + +The accounts allowed to seize collateral in this pool while the liquidation allowlist is enabled. + +```solidity +mapping(address => bool) isAllowedLiquidator +``` + +--- + +### liquidationIncentives + +Per-market discount a liquidator receives on the collateral it seizes, scaled by 1e18. Keyed by the collateral market, since that is what the discount prices. `0` means no market value has been set, in which case the pool-wide value applies — which is always at least `1e18` for a listed market, so callers may divide by the effective value. + +```solidity +mapping(address => uint256) liquidationIncentives +``` + +--- + +### deviationBoundedOracle + +Oracle that bounds an asset's price against a recent window, so a deviating print cannot inflate borrowing capacity. Read only where the collateral factor weights the position; the liquidation-threshold paths stay on `oracle`, because they route liquidations. Zero until governance sets it, and while it is zero borrowing and redeeming fail closed. + +```solidity +contract IDeviationBoundedOracle deviationBoundedOracle +``` + +--- + +### Constants + +| Constant | Value | Purpose | +| --- | --- | --- | +| `MIN_CLOSE_FACTOR_MANTISSA` | `0.05e18` | lower bound on the close factor | +| `MAX_CLOSE_FACTOR_MANTISSA` | `0.9e18` | upper bound on the close factor | +| `MAX_COLLATERAL_FACTOR_MANTISSA` | `0.95e18` | upper bound on any market's collateral factor | +| `MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA` | `1.05e18` | lower bound on the pool-wide liquidation incentive — `1e18` plus `VToken`'s `DEFAULT_PROTOCOL_SEIZE_SHARE_MANTISSA`, restated here because that constant is internal to `VToken`. This is the one constant the shared `ComptrollerStorage` does not have. | diff --git a/technical-reference/reference-isolated-pools/spoke/spoke-comptroller.md b/technical-reference/reference-isolated-pools/spoke/spoke-comptroller.md new file mode 100644 index 00000000..2becb799 --- /dev/null +++ b/technical-reference/reference-isolated-pools/spoke/spoke-comptroller.md @@ -0,0 +1,555 @@ +# SpokeComptroller + +`SpokeComptroller` is the Comptroller of a hub-funded spoke pool. It is a fork of the shared isolated-pools [`Comptroller`](../comptroller/comptroller.md) and behaves identically except where this page says otherwise: the same hooks, the same account-liquidity model, the same market listing and pause machinery, the same rewards flywheel. + +Read [Hub-Funded Spoke Pools](README.md) first for why the fork exists and how a spoke pool is shaped. + +## What the fork changes + +| Area | Shared `Comptroller` | `SpokeComptroller` | +| --- | --- | --- | +| Supplying to a market | anyone, subject to the supply cap | optionally restricted to a **per-market allowlist**, checked in `preMintHook` | +| Seizing collateral | anyone | optionally restricted to a **pool-wide liquidation allowlist** | +| Liquidation incentive | one pool-wide value | **per collateral market**, falling back to the pool-wide value | +| Batch-liquidation routing | `totalCollateral` vs `borrows × incentive` | `borrows` vs `maxClearableDebt = Σ Cᵢ / incentiveᵢ` | +| Borrow-power pricing | `ResilientOracle` spot | [`DeviationBoundedOracle`](../../reference-oracle/deviation-bounded-oracle.md) bounded prices | +| Entering a market | `enterMarkets` only, always for `msg.sender` | adds ACM-gated **`enterMarketBehalf`**, so a router can enable collateral for the supplier it minted to | +| Prime | `prime` + `setPrimeToken`, seven `*Verify` hooks update Prime scores | removed; the seven hooks are no-ops | +| Revert reasons | mixture of custom errors and `require` strings | every revert is a custom error | + +Exit is never gated, in either implementation. Redeem, repay, withdraw and transfer are not access-controlled, and an account removed from an allowlist keeps the position it already holds and can still leave. Governance retains the usual market-level pause. + +## Supply allowlist + +Per market, and off by default, so a newly listed market accepts supply from anyone. + +```solidity +mapping(address vToken => bool) public isSupplyAllowlistEnabled; +mapping(address vToken => mapping(address account => bool)) public isAllowedSupplier; +``` + +It is enforced in exactly one place — `preMintHook`: + +```solidity +if (isSupplyAllowlistEnabled[vToken] && !isAllowedSupplier[vToken][minter]) { + revert SupplyNotAllowed(vToken, minter); +} +``` + +**The account metered is the one credited with the vTokens, not the one paying for them.** `VToken.mintBehalf` lets a third party fund a mint attributed to someone else, and only the recipient is checked. This is deliberate: metering the recipient is what bounds a market's supply, and a third party funding a mint credited to the Hub is a donation to the pool, not a way around the restriction. + +Redeeming is never restricted. Enabling the allowlist on a market that is already serving supply cuts off every account that is not on the list, including whoever seeded it — that is the intended effect, but it is worth knowing before flipping it on a live market. + +On the liquidity side of a hub-funded pool the single allowlisted account is the Liquidity Hub's Spoke YieldGroup. The listing VIP must call `setAllowedSupplier` **before** the Hub registers the market as a resource: `AdapterSpokeV1.validateRegistration` rejects a market that will not accept the registering YieldGroup. + +## Entering a market for a supplier + +`enterMarkets` reads `msg.sender`, so a router that supplies through `VToken.mintBehalf` enters *itself* rather than the account it credited. On the collateral side of a spoke pool the user supplies and the Hub lends, so that left a first-time supplier needing two transactions: one to supply, one to enable the market as collateral. + +`enterMarketBehalf` takes the account as an argument instead: + +```solidity +function enterMarketBehalf(address vToken, address account) external; +``` + +It takes a single market rather than an array, which is what keeps the fork inside the EIP-170 limit; a caller that needs several markets loops over it. `enterMarkets` is untouched, so entering a market for yourself stays permissionless. + +The function is gated by the [AccessControlManager](../../reference-governance/access-control-manager.md) under the role string `enterMarketBehalf(address,address)`. **Grant it only to a contract that passes its own caller as `account`** — a holder of this permission can enable a market as collateral for any address, and entering a market is what makes a balance eligible to be seized in a liquidation. The [`HubRouter`](../../reference-liquidity-hub/hub-router.md) is the intended holder; both of its spoke paths revert until the listing VIP grants it this role on the pool. + +## Liquidation allowlist + +Pool-wide, and off by default. + +```solidity +bool public isLiquidationAllowlistEnabled; +mapping(address account => bool) public isAllowedLiquidator; +``` + +It is pool-wide rather than per market because `healAccount` seizes across every market the borrower is in and cannot attribute a seizure to a single one of them. + +Two call sites enforce it: + +* **`preSeizeHook`** — every seizure reaches this hook carrying the account that receives the collateral, whichever entry point it came from, so this is the check that actually enforces the list. +* **`healAccount`**, at the entry — a borrower holding no vTokens at all takes a branch that only calls `healBorrow` and never reaches a hook carrying the caller. Without this check anyone could move the whole remaining principal into bad debt at no cost. + +`liquidateAccount` has no entry check of its own. Every one of its orders ends in a seizure, so `preSeizeHook` rejects a caller that is not on the list while the first order is being processed. + +Enabling the list also restricts `healAccount`, so any keeper relied on to record bad debt has to be allowlisted too. + +Note that when the collateral is a tokenized stock liquidated through [`BStockLiquidator`](../../reference-core-pool/bstock-liquidator.md), it is **that contract's** address that has to be on the list, not the operator's — the liquidator contract is the account that receives the seized collateral. + +## Per-market liquidation incentive + +```solidity +mapping(address vTokenCollateral => uint256) public liquidationIncentives; +``` + +Keyed on the **collateral market**, because the incentive is the discount on the collateral being seized. `0` is the "unset" sentinel, in which case the pool-wide value applies: + +```solidity +effectiveLiquidationIncentive(vToken) = liquidationIncentives[vToken] != 0 + ? liquidationIncentives[vToken] + : poolLiquidationIncentive +``` + +The motivation is risk steering: a higher incentive on high-volatility collateral pushes liquidators to seize the riskiest collateral first, which de-risks an account faster. A borrow-only market (collateral factor `0`, never seizable) never has its incentive read at all, so it does not need a meaningful value. + +**Two getters, deliberately different.** + +* `effectiveLiquidationIncentive(address vToken)` — takes the market as an argument. This is what a lens, a keeper or a liquidation bot should read. +* `liquidationIncentiveMantissa()` — takes no argument and answers **for `msg.sender`**. This is the getter `ComptrollerViewInterface` declares and the one `VToken` calls on itself: `VToken._seize` divides the protocol seize share by it and `VToken.setProtocolSeizeShare` bounds that share against it, and both need the discount that prices the calling market's own collateral. Any caller that is not a market of this pool reads the pool-wide value from it. + +**Floors.** An incentive is a multiplier on the debt repaid, and `VToken._seize` hands the protocol `protocolSeizeShareMantissa` of that debt out of the seized collateral. An incentive below `1e18 + protocolSeizeShareMantissa` therefore pays the liquidator less collateral than it repaid, and nobody liquidates that market. + +* `setMarketLiquidationIncentive` enforces `≥ 1e18 + vToken.protocolSeizeShareMantissa()` against the market's live share. +* `setLiquidationIncentive` (the pool-wide value) enforces `≥ MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA = 1.05e18`, which is `1e18` plus `VToken`'s default seize share. That covers a freshly listed market carrying the default share and no incentive of its own. This is stricter than the shared `Comptroller`, which stops at `1e18` and would let a pool be registered with a value that pays every default-share market's liquidator less than it repaid. +* `VToken.setProtocolSeizeShare` holds the same bound from the other side, reading the incentive back through `liquidationIncentiveMantissa()`. + +A market whose seize share is later raised above the default still needs an incentive of its own; the pool-wide floor is a backstop for the freshly-listed case, not a full guarantee. + +There is **no way back to "unset"** once a per-market value is stored: `setMarketLiquidationIncentive` rejects `0`, so a mistaken zero cannot silently move a market back onto the pool-wide value. Pass the pool-wide value explicitly to get the same effect. + +## Batch-liquidation routing + +The shared `Comptroller` routes an under-threshold account between `liquidateAccount` and `healAccount` using one pool-wide incentive. With per-market incentives that no longer works, so the account snapshot carries a new field: + +``` +maxClearableDebt = Σ over the account's collateral markets of ( Cᵢ / incentiveᵢ ) +``` + +where `Cᵢ` is the account's collateral value in market `i` and `incentiveᵢ` is that market's own effective incentive. It is the largest borrow value the account's collateral can clear without leaving bad debt behind. + +The two batch operations are the two sides of that one benchmark, so every under-threshold account routes to exactly one of them — no gap, no overlap: + +| | Condition | Reverts with | +| --- | --- | --- | +| `liquidateAccount` | `borrows < maxClearableDebt` | `DebtExceedsClearableAmount(borrows, maxClearableDebt)` | +| `healAccount` | `maxClearableDebt ≤ borrows` | `CollateralCoversDebt(borrows, maxClearableDebt)` | + +`healAccount`'s repayment share becomes: + +``` +percentage = maxClearableDebt / borrows +``` + +One blended share applies to every borrow, so what the caller pays in total is the sum over the collateral markets of each market's value at its own incentive. **The discount is exact in aggregate; it is not attributed per piece of collateral.** + +`liquidateAccount` needs no change beyond its gate — each order is a `(debt, collateral)` pair, and `liquidateCalculateSeizeTokens` already prices it at the collateral market's own incentive. + +`maxClearableDebt` is only accumulated on liquidation-threshold snapshots. A collateral-factor snapshot leaves it at zero, and reading it there would put `healAccount` on a repayment percentage of zero — forgiving the whole position as bad debt. + +Both batch operations still require the account's total collateral to be at or below `minLiquidatableCollateral`; above that threshold they revert `CollateralExceedsThreshold` and the position must go through the regular `VToken.liquidateBorrow` path. + +Both also accrue interest and push an oracle update for every market the borrower is in **before** taking the routing snapshot. The orders inside `liquidateAccount` run with the per-order liquidity check skipped, so that snapshot is the only thing separating a solvent account from a full liquidation, and it has to be taken on the same state the orders will execute against. + +## Bounded collateral pricing + +A spoke pool values **borrowing capacity** through the [`DeviationBoundedOracle`](../../reference-oracle/deviation-bounded-oracle.md) rather than at spot. This is the same mechanism the Core pool uses; see the [DeviationBoundedOracle article](../../reference-technical-articles/deviation-bounded-oracle.md) for the window, trigger and exit logic, and [Protection Mode](../../../risk/protection-mode.md) for the user-facing behaviour. + +Which oracle is read depends on which risk parameter weights the snapshot: + +| Weighting | Used by | Collateral price | Debt price | +| --- | --- | --- | --- | +| `USE_COLLATERAL_FACTOR` | `preBorrowHook`, `preRedeemHook`, `getBorrowingPower`, `getHypotheticalAccountLiquidity` | `deviationBoundedOracle` | `deviationBoundedOracle` | +| `USE_LIQUIDATION_THRESHOLD` | `preLiquidateHook`, `healAccount`, `liquidateAccount`, `getAccountLiquidity` | `oracle` (spot) | `oracle` (spot) | + +While protection is active for an asset the bounded oracle values collateral at the low end of its recent price window and debt at the high end; otherwise it returns spot on both legs, including for an asset it holds no configuration for. A deviating print can therefore only ever **shrink** an account's borrowing capacity, never inflate it. + +The liquidation paths stay on spot because they route liquidations and set how much of a position healing repays, which has to track the live price. `liquidateCalculateSeizeTokens` is likewise priced at spot. + +Before each of the two collateral-factor liquidity checks, the Comptroller calls `deviationBoundedOracle.updateProtectionState(vToken)` for every market the account is in, so a deviating print latches protection and starts its cooldown instead of evaporating once the price returns to the window. + +> **`deviationBoundedOracle` must be set before the pool serves any borrow or redeem.** Nothing falls back to spot when it is unset: both calls into the zero address revert, so borrowing and redeeming fail closed rather than running unbounded. Minting and repaying are unaffected — they never read a price — and so is a redeem by an account that is not a member of the market, since `preRedeemHook` returns before the liquidity check in that case. A supply-only account such as the Liquidity Hub's YieldGroup never becomes a market member, so it can still exit a market whose pool has no bounded oracle set. + +## Solidity API + +Only the surface this fork adds or changes is listed here. Everything else matches the shared [`Comptroller`](../comptroller/comptroller.md). + +### setMarketLiquidationIncentive + +Sets the discount a liquidator receives on the collateral it seizes from a single market. + +```solidity +function setMarketLiquidationIncentive(address vToken, uint256 newLiquidationIncentiveMantissa) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| vToken | address | The collateral market to set the incentive for | +| newLiquidationIncentiveMantissa | uint256 | New incentive for this market, scaled by 1e18, at least `1e18 + protocolSeizeShareMantissa` of the market | + +#### 📅 Events + +* Emits `NewMarketLiquidationIncentive` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `MarketNotListed` is thrown if the market is not listed +* `InvalidLiquidationIncentive` is thrown if the new incentive would leave the liquidator with less collateral than the debt it repaid, `0` included + +--- + +### setSupplyAllowlistEnabled + +Restricts supplying to a market to the accounts on its supply allowlist, or lifts the restriction. + +```solidity +function setSupplyAllowlistEnabled(address vToken, bool enabled) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| vToken | address | The market to change the setting for | +| enabled | bool | Whether the market should accept supply only from allowlisted accounts | + +#### 📅 Events + +* Emits `SupplyAllowlistEnabledUpdated` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `MarketNotListed` is thrown if the market is not listed + +--- + +### setAllowedSupplier + +Adds an account to a market's supply allowlist or removes it. Takes effect only while that market's allowlist is enabled. Setting an account to the value it already holds is not an error, so a governance action that overlaps an earlier one still executes. + +```solidity +function setAllowedSupplier(address vToken, address supplier, bool allowed) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| vToken | address | The market whose allowlist to update | +| supplier | address | The account to add or remove | +| allowed | bool | Whether the account should be allowed to supply | + +#### 📅 Events + +* Emits `AllowedSupplierUpdated` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `MarketNotListed` is thrown if the market is not listed +* `ZeroAddressNotAllowed` is thrown if the account is the zero address + +--- + +### setLiquidationAllowlistEnabled + +Restricts seizing collateral in this pool to the accounts on the liquidation allowlist, or lifts the restriction. Also restricts `healAccount`. + +```solidity +function setLiquidationAllowlistEnabled(bool enabled) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| enabled | bool | Whether seizing collateral should be restricted to allowlisted accounts | + +#### 📅 Events + +* Emits `LiquidationAllowlistEnabledUpdated` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +--- + +### setAllowedLiquidator + +Adds an account to the pool's liquidation allowlist or removes it. Takes effect only while the allowlist is enabled. + +```solidity +function setAllowedLiquidator(address liquidator, bool allowed) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| liquidator | address | The account to add or remove | +| allowed | bool | Whether the account should be allowed to seize collateral | + +#### 📅 Events + +* Emits `AllowedLiquidatorUpdated` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `ZeroAddressNotAllowed` is thrown if the account is the zero address + +--- + +### enterMarketBehalf + +Adds a market to another account's liquidity calculation, enabling it as collateral for that account. + +```solidity +function enterMarketBehalf(address vToken, address account) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| vToken | address | The market to enable | +| account | address | The account to enable it for | + +#### 📅 Events + +* Emits `MarketEntered` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `ActionPaused` is thrown if entering the market is paused +* `MarketNotListed` is thrown if the market is not listed +* `ZeroAddressNotAllowed` is thrown when the account address is zero + +--- + +### setDeviationBoundedOracle + +Sets the deviation-bounded oracle the pool prices borrowing capacity through. + +```solidity +function setDeviationBoundedOracle(IDeviationBoundedOracle newBoundedOracle) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| newBoundedOracle | IDeviationBoundedOracle | Address of the new deviation-bounded oracle | + +#### 📅 Events + +* Emits `NewDeviationBoundedOracle` on success + +#### ⛔️ Access Requirements + +* Only the owner (governance) + +#### ❌ Errors + +* `ZeroAddressNotAllowed` is thrown when the new oracle address is zero + +--- + +### setLiquidationIncentive + +Sets the liquidation incentive applied to any market that has no incentive of its own. Unchanged from the shared `Comptroller` except for the floor and the revert type. + +```solidity +function setLiquidationIncentive(uint256 newLiquidationIncentiveMantissa) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| newLiquidationIncentiveMantissa | uint256 | New pool-wide incentive, scaled by 1e18, at least `1.05e18` | + +#### 📅 Events + +* Emits `NewLiquidationIncentive` on success + +#### ⛔️ Access Requirements + +* Controlled by AccessControlManager + +#### ❌ Errors + +* `InvalidLiquidationIncentive` is thrown if the new incentive is below `MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA` + +--- + +### effectiveLiquidationIncentive + +The discount that applies to a market: its own if it has one, otherwise the pool-wide value. + +```solidity +function effectiveLiquidationIncentive(address vToken) external view returns (uint256) +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| vToken | address | The collateral market to read the discount of | + +#### Return Values + +| Name | Type | Description | +| --- | --- | --- | +| [0] | uint256 | The discount that prices this market's collateral, scaled by 1e18 | + +--- + +### liquidationIncentiveMantissa + +The discount a liquidator receives on the collateral it seizes **from the calling market**. Answers for `msg.sender` rather than taking the market as an argument — see [Per-market liquidation incentive](#per-market-liquidation-incentive). + +```solidity +function liquidationIncentiveMantissa() external view returns (uint256) +``` + +#### Return Values + +| Name | Type | Description | +| --- | --- | --- | +| [0] | uint256 | The discount that applies to the caller, scaled by 1e18 | + +--- + +### healAccount + +Seizes all of a given account's collateral, requiring the caller to repay `maxClearableDebt / borrows` of its debt in every market. The shortfall is recorded as `badDebt` on each market and can then be auctioned for the pool's risk reserves. + +```solidity +function healAccount(address user) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| user | address | The account to heal | + +#### ⛔️ Access Requirements + +* Not restricted while the liquidation allowlist is disabled, otherwise the caller has to be on it + +#### ❌ Errors + +* `LiquidationNotAllowed` is thrown if the liquidation allowlist is enabled and the caller is not on it +* `CollateralExceedsThreshold` is thrown when the account's collateral is above `minLiquidatableCollateral` +* `CollateralCoversDebt` is thrown when the collateral can clear the whole debt at each market's own incentive, which leaves nothing to heal — use `liquidateAccount` +* `InsufficientShortfall` is thrown when the account is not liquidatable + +--- + +### liquidateAccount + +Liquidates all of a borrower's positions in one call, executing the supplied orders. Skips the close-factor check. + +```solidity +function liquidateAccount(address borrower, struct LiquidationOrder[] orders) external +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| borrower | address | The account to liquidate | +| orders | LiquidationOrder[] | The `(vTokenCollateral, vTokenBorrowed, repayAmount)` orders to execute | + +#### ⛔️ Access Requirements + +* Not restricted while the liquidation allowlist is disabled, otherwise the caller has to be on it + +#### ❌ Errors + +* `LiquidationNotAllowed` is thrown if the liquidation allowlist is enabled and the caller is not on it +* `CollateralExceedsThreshold` is thrown when the account's collateral is above `minLiquidatableCollateral` +* `DebtExceedsClearableAmount` is thrown when the debt is too large for the collateral to clear — use `healAccount` +* `NonzeroBorrowBalanceAfterLiquidation` is thrown if a debt remains in any of the borrower's markets once every order has executed +* `InsufficientShortfall` is thrown when the account is not liquidatable + +--- + +### preMintHook + +Unchanged apart from the allowlist check. + +```solidity +function preMintHook(address vToken, address minter, uint256 mintAmount) external +``` + +#### ❌ Errors + +* `SupplyNotAllowed` is thrown if the market's supply allowlist is enabled and the **minter** — the account credited with the vTokens, not necessarily the payer — is not on it +* plus every error the shared `Comptroller` throws here + +--- + +### Post-action hooks + +`mintVerify`, `redeemVerify`, `borrowVerify`, `repayBorrowVerify`, `liquidateBorrowVerify`, `seizeVerify` and `transferVerify` are all **no-ops**. They cannot be removed: `ComptrollerInterface` declares all seven, and the `VToken` calls each of them with a plain external call against a Comptroller that has no fallback, so a missing function would revert the operation it belongs to. + +## Events + +Everything the shared `Comptroller` emits, plus: + +| Event | Emitted when | +| --- | --- | +| `NewMarketLiquidationIncentive(address indexed vToken, uint256 oldLiquidationIncentiveMantissa, uint256 newLiquidationIncentiveMantissa)` | a single market's liquidation incentive changes | +| `NewDeviationBoundedOracle(IDeviationBoundedOracle oldBoundedOracle, IDeviationBoundedOracle newBoundedOracle)` | the deviation-bounded oracle changes | +| `SupplyAllowlistEnabledUpdated(address indexed vToken, bool enabled)` | a market's supply allowlist is armed or disarmed | +| `AllowedSupplierUpdated(address indexed vToken, address indexed supplier, bool allowed)` | an account is added to or removed from a market's supply allowlist | +| `LiquidationAllowlistEnabledUpdated(bool enabled)` | the pool's liquidation allowlist is armed or disarmed | +| `AllowedLiquidatorUpdated(address indexed liquidator, bool allowed)` | an account is added to or removed from the liquidation allowlist | + +`NewPrimeToken` is **not** emitted — Prime is not part of a spoke pool. + +## Errors + +### New + +| Error | Meaning | +| --- | --- | +| `SupplyNotAllowed(address market, address supplier)` | the market's supply allowlist is enabled and the account being credited is not on it | +| `LiquidationNotAllowed(address liquidator)` | the liquidation allowlist is enabled and the account seizing is not on it | +| `InvalidLiquidationIncentive()` | an incentive below `1e18 + protocolSeizeShareMantissa` for a market, or below `MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA` for the pool-wide value | +| `CollateralCoversDebt(uint256 borrows, uint256 maxClearableDebt)` | `healAccount` on an account whose collateral can clear the whole debt | +| `DebtExceedsClearableAmount(uint256 borrows, uint256 maxClearableDebt)` | `liquidateAccount` on an account whose debt the collateral cannot clear | +| `NonzeroBorrowBalanceAfterLiquidation()` | the orders passed to `liquidateAccount` did not cover the whole position | + +### Replacing `require` strings + +The fork converts every remaining revert string to a custom error: + +| Error | Replaces | +| --- | --- | +| `InvalidCloseFactor()` | `"Close factor greater than maximum close factor"` / `"Close factor smaller than minimum close factor"` | +| `InvalidVToken()` | `"Comptroller: Invalid vToken"` | +| `InvalidArrayLength()` | `"invalid input"` / `"invalid number of markets"` | +| `RewardsDistributorAlreadyExists()` | `"already exists"` | +| `MarketNotListed(address)` | `"cannot pause a market that is not listed"` | +| `NonzeroBorrowBalanceAfterLiquidation()` | `"Nonzero borrow balance after liquidation"` | + +### Changed meaning + +* **`CollateralExceedsThreshold(uint256 expectedLessThanOrEqualTo, uint256 actual)`** keeps the shared `Comptroller`'s name, arguments and selector, and is thrown by both batch operations when the account's collateral is above `minLiquidatableCollateral`. In the shared `Comptroller` `healAccount` also uses it for the "collateral covers the debt" case; here that case is `CollateralCoversDebt`. +* **`InsufficientCollateral`** does not exist in this fork. `liquidateAccount` throws `DebtExceedsClearableAmount` instead — both of its arguments are debt values rather than collateral values, and reusing the old name would leave two incompatible versions sharing one selector. diff --git a/technical-reference/reference-liquidity-hub/README.md b/technical-reference/reference-liquidity-hub/README.md index 582807bc..f8bdcf83 100644 --- a/technical-reference/reference-liquidity-hub/README.md +++ b/technical-reference/reference-liquidity-hub/README.md @@ -1,6 +1,6 @@ # Liquidity Hub -The **Venus Liquidity Hub** is a per-asset ERC-4626 allocator vault. A lender deposits a single asset, the Hub routes it across Venus yield families (**Core**, **Flux**, **FRV**) under a governance-set policy, and returns a yield-bearing share token. Yield accrues through a **rising exchange rate** (one share = X underlying), never by rebasing. There is one Hub per asset, with no cross-asset coupling. +The **Venus Liquidity Hub** is a per-asset ERC-4626 allocator vault. A lender deposits a single asset, the Hub routes it across Venus yield families (**Core**, **Flux**, **FRV**, and **Spoke** once it is deployed) under a governance-set policy, and returns a yield-bearing share token. Yield accrues through a **rising exchange rate** (one share = X underlying), never by rebasing. There is one Hub per asset, with no cross-asset coupling. For the user-facing introduction, see [Liquidity Hub](../../whats-new/liquidity-hub.md) under *What's New*. @@ -13,28 +13,33 @@ Routing is three-tiered. The Hub depends only on the `IYieldGroupBase` interface * **Hub** — the ERC-4626 entry point (one beacon proxy per asset). Holds the Source registry, the two outer routing queues, dual caps, the per-transaction withdrawal cap, the fee parameters, and the multi-level pause flags. It exposes **no APY view** — spot APY is read per-Source from `YieldGroup.spotAPYBps()` and must be aggregated off-chain. * **YieldGroup (Source)** — aggregates one or more *resources* of a single protocol family behind the uniform `IYieldGroupBase` boundary, and owns its own inner deposit / withdraw queues and per-resource registry. * **Adapter** — a stateless singleton translating between a YieldGroup and one protocol ABI. Mutating calls run via **delegatecall** so receipt tokens land on the YieldGroup; one deployment per ABI family serves every YieldGroup. -* **Resource** — the underlying market or vault that holds the capital (a Venus Core vToken, a Fluid fToken, or a Fixed-Rate Vault share). +* **Resource** — the underlying market or vault that holds the capital (a Venus Core vToken, a Fluid fToken, a Fixed-Rate Vault share, or a spoke-pool vToken). > **Terminology.** The PRD calls the grouping layer a *Source*; the code names the contract a *YieldGroup* and the Hub-facing interface `IYieldGroupBase`. There is no `ISource` type in the Solidity — "Source" survives only in deployment-artifact aliases (`CoreSource_USDT`, `FluxSource_USDC`, `FRVSource_U`). PRD *Product / Vault* = code *Resource*. -## The three yield families +## The yield families -| | **Core** | **Flux** | **FRV** | -| ------------------------ | ----------------------------------- | ------------------------------------- | -------------------------------------- | -| Underlying protocol | Venus Core lending | Fluid Lending | Venus Fixed-Rate Vaults | -| Resource / receipt token | vToken (Compound-style) | fToken (ERC-4626 share) | FRV vault share (ERC-4626) | -| Deposit / withdraw call | `mint` / `redeemUnderlying` | `deposit` / `withdraw` | `deposit` / `withdraw` | -| Spot APY source | `supplyRatePerBlock` × `blocksPerYear` | Fluid `LendingResolver` | vault `fixedAPY` (Fundraising / Lock) | -| Lifecycle constraint | none | none | 11-state machine | -| Wired at launch | ✅ vToken registered | ✅ fToken registered | ❌ Source registered, no resource | +| | **Core** | **Flux** | **FRV** | **Spoke** | +| ------------------------ | ----------------------------------- | ------------------------------------- | -------------------------------------- | ------------------------------------------------ | +| Underlying protocol | Venus Core lending | Fluid Lending | Venus Fixed-Rate Vaults | Venus [hub-funded spoke pools](../reference-isolated-pools/spoke/README.md) | +| Resource / receipt token | vToken (Compound-style) | fToken (ERC-4626 share) | FRV vault share (ERC-4626) | vToken (isolated-pools) | +| Deposit / withdraw call | `mint` / `redeemUnderlying` | `deposit` / `withdraw` | `deposit` / `withdraw` | `mint` / `redeem` (by vToken count) | +| Spot APY source | `supplyRatePerBlock` × `blocksPerYear` | Fluid `LendingResolver` | vault `fixedAPY` (Fundraising / Lock) | `supplyRatePerBlock` × the market's own `blocksOrSecondsPerYear` | +| Who may supply | anyone | anyone | anyone | **allowlisted only** — the pool grants the YieldGroup address | +| Valuation basis | balance × exchange rate | `previewRedeem` | lifecycle-aware linear accrual | balance × exchange rate **excluding `badDebt`** | +| Lifecycle constraint | none | none | 11-state machine | none | +| Wired at launch | ✅ vToken registered | ✅ fToken registered | ❌ Source registered, no resource | ❌ not deployed | + +**Spoke is built but not deployed.** `AdapterSpokeV1` and the Spoke YieldGroup family exist in the codebase, but no `SpokeComptroller` is deployed on any network and no spoke market has been listed, so there is nothing to register. Onboarding one is a two-step governance action, in this order: `setAllowedSupplier(vToken, , true)` on the spoke pool, **then** `addResource` on the Hub — the adapter rejects a registration the market would not accept, and also rejects a market its own Comptroller does not list. **FRV carries no resource at launch.** An FRV Source is deployed and registered on every Hub with its caps set, but no Fixed-Rate Vault instance exists for USDT / USDC / U on BNB Chain yet, so the onboarding proposal calls `addResource` on the Core and Flux Sources only. FRV is therefore kept out of the outer deposit queue entirely and placed **last** in the outer withdraw queue — not because it can serve withdrawals, but because `setOuterWithdrawQueue` rejects a queue that omits a registered Source with non-zero `totalAssets()`, and that total counts idle balance: omitting FRV would let a 1-wei donation permanently block the Operator from reordering the queue. No capital routes to FRV until a follow-up proposal wires a vault. ## Contracts * [**Hub**](hub.md) — the ERC-4626 entry point: routing flows, dual caps, per-tx withdrawal cap, fees, multi-level pause, Operator reallocation, and the full Solidity API. -* [**Yield Groups**](yield-groups.md) — `YieldGroup` (the generic router, deployed twice: once as the Core family, once as Flux) and `YieldGroupFRV`: the `IYieldGroupBase` implementations, per-resource registry / queues / caps, and the FRV lifecycle. -* [**Adapters**](adapters.md) — `AdapterCoreV1`, `AdapterFlux`, `AdapterFRV`: the stateless, delegatecall-dispatched protocol translators. +* [**Yield Groups**](yield-groups.md) — `YieldGroup` (the generic router, deployed once per family: Core, Flux, and Spoke) and `YieldGroupFRV`: the `IYieldGroupBase` implementations, per-resource registry / queues / caps, and the FRV lifecycle. +* [**Adapters**](adapters.md) — `AdapterCoreV1`, `AdapterFlux`, `AdapterFRV`, `AdapterSpokeV1`: the stateless, delegatecall-dispatched protocol translators. +* [**HubRouter**](hub-router.md) — permissionless, immutable periphery that turns an underlying balance into collateral in one call: a Hub deposit supplied into its Core market, or a spoke market supplied and entered together. * [**Interfaces**](interfaces.md) — `IYieldGroupBase` (with its family extensions `IYieldGroup` and `IYieldGroupFRV`) and `IResourceAdapter`, the boundary contracts. * **`Migrator`** — a stateless, permissionless, non-upgradeable helper for one-click migration of a Venus Core position into a Hub (`migrateFromCore` / `migrateFromCoreBNB`). @@ -52,7 +57,7 @@ Routing is three-tiered. The Hub depends only on the `IYieldGroupBase` interface ## Deployment -The Hub uses a **beacon-proxy model**: one `UpgradeableBeacon` per family per chain (Hub, Core, FRV, Flux), each owned by governance — upgrading a beacon upgrades every vault of that family atomically; per-asset instances are beacon proxies. Deploy scripts only deploy and initialize the proxies; **ACM wiring, `addYieldGroup` / `addResource`, and queue configuration are separate governance (ACM-gated) actions**. +The Hub uses a **beacon-proxy model**: one `UpgradeableBeacon` per family per chain (Hub, Core, FRV, Flux, and Spoke once deployed), each owned by governance — upgrading a beacon upgrades every vault of that family atomically; per-asset instances are beacon proxies. Deploy scripts only deploy and initialize the proxies; **ACM wiring, `addYieldGroup` / `addResource`, and queue configuration are separate governance (ACM-gated) actions**. `HubRegistry` is the exception: it is a chain-level singleton behind a **`TransparentUpgradeableProxy`**, not a beacon proxy, so it is upgraded independently of every Hub. On BNB Chain mainnet that proxy is administered by Venus's shared `DefaultProxyAdmin`, the same one that administers the core pool and isolated pools; the BSC testnet deployment predates that decision and still carries a registry-specific `ProxyAdmin`. diff --git a/technical-reference/reference-liquidity-hub/adapters.md b/technical-reference/reference-liquidity-hub/adapters.md index a384d9df..96264657 100644 --- a/technical-reference/reference-liquidity-hub/adapters.md +++ b/technical-reference/reference-liquidity-hub/adapters.md @@ -2,20 +2,21 @@ An **adapter** translates between a [YieldGroup](yield-groups.md) and one protocol-specific ABI. Each adapter is a **stateless singleton**: a single deployment per ABI family serves every YieldGroup that registers a matching resource — there is no per-(YieldGroup, resource) instance, no proxy, and no clone factory. -There are three adapters in v1: +There are four adapters: * **`AdapterCoreV1`** — Venus Core V1 vTokens (Compound-style `mint` / `redeemUnderlying`). * **`AdapterFlux`** — Fluid Lending fTokens (ERC-4626). * **`AdapterFRV`** — Venus Fixed-Rate Vault shares (ERC-4626). +* **`AdapterSpokeV1`** — the liquidity side of a [hub-funded spoke pool](../reference-isolated-pools/spoke/README.md): an isolated-pools `VToken` governed by a `SpokeComptroller`. Built but **not yet deployed or wired**. -All three implement [`IResourceAdapter`](interfaces.md). +All four implement [`IResourceAdapter`](interfaces.md). ## Dispatch model The split between mutating and view dispatch is load-bearing for security: * **Mutating functions (`deposit`, `withdraw`) MUST be invoked via `delegatecall`** from the YieldGroup. They execute in the YieldGroup's storage context, so receipt-token credits and debits land on the YieldGroup, not the adapter. From the resource's perspective, `msg.sender` is the YieldGroup. -* **View functions are invoked via normal `call` / `staticcall`.** Where the answer depends on whose position is being queried, they take an explicit `holder` parameter (the YieldGroup). +* **View functions are invoked via normal `call` / `staticcall`.** Where the answer depends on whose position is being queried, they take an explicit `holder` parameter (the YieldGroup). `maxDeposit` and `validateRegistration` name no account and read `msg.sender` instead, which is the calling YieldGroup — see [Caller identity](#caller-identity). ### `onlyDelegateCall` guard @@ -27,7 +28,7 @@ Verified at audit for every adapter: 1. **Zero storage variables** — only `immutable` and `constant` values are declared; the contract never `sstore`s. 2. **No inline assembly performs `sstore`.** -3. **No arbitrary call targets** — external calls go only to the supplied `resource` and its trusted, chain-fixed dependencies, never a user-supplied address. (`AdapterCoreV1` calls the vToken's `underlying()` and `comptroller()`; `AdapterFlux` calls the fToken's `asset()` and the Fluid `LendingResolver`; `AdapterFRV` calls the vault's `asset()`.) +3. **No arbitrary call targets** — external calls go only to the supplied `resource` and its trusted, chain-fixed dependencies, never a user-supplied address. (`AdapterCoreV1` calls the vToken's `underlying()` and `comptroller()`; `AdapterFlux` calls the fToken's `asset()` and the Fluid `LendingResolver`; `AdapterFRV` calls the vault's `asset()`; `AdapterSpokeV1` calls the vToken's `underlying()` and `comptroller()`.) 4. **Mutating functions revert outside a delegatecall context** (the `onlyDelegateCall` guard). ## `IResourceAdapter` surface @@ -35,12 +36,12 @@ Verified at audit for every adapter: * **`deposit(address resource, uint256 amount)` → `uint256 deposited`** — deposit `amount` of underlying into `resource`; receipt tokens are credited to the YieldGroup. * **`withdraw(address resource, uint256 amount, address to)`** — redeem exactly `amount` of underlying from `resource` and deliver it to `to`. * **`asset(address resource)` → `address`** — the underlying ERC-20 accepted by `resource`. -* **`totalAssets(address resource, address holder)` → `uint256`** — underlying value `holder` holds via `resource`. The basis is per-adapter: `AdapterCoreV1` uses the stale (non-accruing) `exchangeRateStored` net of any Comptroller `treasuryPercent`; `AdapterFlux` uses the fToken's live `previewRedeem`; `AdapterFRV` uses a time-based linear coupon accrual. +* **`totalAssets(address resource, address holder)` → `uint256`** — underlying value `holder` holds via `resource`. The basis is per-adapter: `AdapterCoreV1` uses the stale (non-accruing) `exchangeRateStored` net of any Comptroller `treasuryPercent`; `AdapterFlux` uses the fToken's live `previewRedeem`; `AdapterFRV` uses a time-based linear coupon accrual; `AdapterSpokeV1` uses the market's backing with `badDebt` excluded. * **`maxDeposit(address resource)` → `uint256`** — spare deposit headroom on `resource` right now. * **`maxWithdraw(address resource, address holder)` → `uint256`** — underlying `holder` can withdraw right now, net of any redeem-time protocol fee. * **`spotAPYBps(address resource, uint256 blocksPerYear)` → `uint64`** — spot supply-side APY in BPS. * **`receiptBalance(address resource, address holder)` → `uint256`** — raw receipt-token balance (vToken / fToken / FRV shares), in receipt-token units — used by `removeResource` as a share-based emptiness gate. -* **`accrue(address resource)`** — settle `resource`'s own global interest state so a following `totalAssets` read prices the position at a fresh rate. Invoked via normal `call`, **not** delegatecall, and therefore carries no `onlyDelegateCall` guard: it mutates the resource's global index, not the holder's position. Only block-lazy adapters do real work — `AdapterCoreV1` calls the vToken's `accrueInterest()`; `AdapterFlux` and `AdapterFRV` are no-ops. +* **`accrue(address resource)`** — settle `resource`'s own global interest state so a following `totalAssets` read prices the position at a fresh rate. Invoked via normal `call`, **not** delegatecall, and therefore carries no `onlyDelegateCall` guard: it mutates the resource's global index, not the holder's position. Only block-lazy adapters do real work — `AdapterCoreV1` and `AdapterSpokeV1` call their vToken's `accrueInterest()`; `AdapterFlux` and `AdapterFRV` are no-ops. * **`validateRegistration(address resource)`** — reverts if `resource` fails a protocol-specific registration precondition. See [Interfaces](interfaces.md) for the full `IResourceAdapter` contract and its pre/post-conditions. @@ -83,6 +84,53 @@ Wraps Venus Fixed-Rate Vault shares (ERC-4626). **Errors:** `NotDelegateCall`. +## AdapterSpokeV1 + +Wraps the **liquidity side of a hub-funded spoke pool** — an isolated-pools `VToken` whose Comptroller is a [`SpokeComptroller`](../reference-isolated-pools/spoke/spoke-comptroller.md). One deployment serves every YieldGroup that registers a spoke market. + +Its selectors are nearly the same as `AdapterCoreV1`'s, and it disagrees with it on every number that matters. Four differences: + +* **NAV excludes `badDebt`.** See [Valuation](#valuation-excludes-written-off-debt) below — this is the important one. +* **Liquidity is cash *net of reserves*,** and both it and the position are floored to a whole number of vTokens, because `withdraw` redeems by vToken **count** rather than by underlying amount. +* **No exit fee to gross up.** Isolated pools have no `treasuryPercent`; their cut is the reserve factor, already netted out of the exchange rate. There is nothing unmodeled to reject at registration, so `validateRegistration` guards the market listing and the supply allowlist instead. +* **The supply cap has an uncapped sentinel** that Core does not (see `maxDeposit` below). + +Detail per function: + +* **`deposit`** — `forceApprove`s the market and calls `mint(amount)`. Under delegatecall the market sees the YieldGroup as `msg.sender`, so the vTokens are credited there — and that is also the account the market's supply allowlist checks. Reverts `DepositBelowOneVToken` rather than minting zero for a sub-one-vToken remainder, which hands the leg back to the YieldGroup's cascade to route around instead of stranding the amount silently. +* **`withdraw`** — **denominated in vTokens, not in underlying**: it calls `redeem(tokens)` with the fewest vTokens worth at least `amount`, rather than `redeemUnderlying(amount)`. `redeemUnderlying` derives the burn by rounding up and that derived count is not bounded by the caller's balance — `_redeemFresh` subtracts it in checked arithmetic and panics when it overshoots. Naming the burn removes the derivation. The payout is `truncate(exchangeRate × tokens)`, so it is at least `amount` and exceeds it by up to one vToken unit; the adapter forwards exactly `amount` and leaves the surplus as idle on the YieldGroup. **Unlike Flux and FRV, Spoke leaves a surplus on essentially every redeem.** +* **`accrue`** — pokes the market's `accrueInterest()`, like Core. +* **`maxDeposit`** — honors, in order: the market's supply allowlist, its `MINT` pause, and its supply cap. **The uncapped sentinel is one-sided**: here `type(uint256).max` skips the cap check entirely, while `0` is a real cap of zero (`preMintHook` compares `nextTotalSupply > supplyCap`, so every mint is rejected). The Core Comptroller has no uncapped sentinel, and there `0` is the value that disables minting. Uncapped markets report `type(uint128).max` rather than `type(uint256).max`, because `YieldGroupBase.maxDeposit()` sums the room of every queued resource in checked arithmetic and two uncapped markets would overflow that sum. Headroom is then trimmed by a **0.1% conservative margin**, as Core does, so a normal accrual between the view and the mint cannot push `deposit(maxDeposit())` over the cap. Room below one vToken unit is reported as zero. +* **`maxWithdraw`** — the lesser of the position's recoverable value and the market's *payable* cash (`getCash − totalReserves`; reserves sit inside cash but are not redeemable, and `_redeemFresh` gates on the difference). Subtracting reserves also makes the figure invariant across the reserve sweep `accrueInterest` performs. Both sides are floored to a whole number of vTokens and valued back into underlying, because `withdraw` redeems by count. The flooring is exact rather than conservative, so the last vToken stays withdrawable and a market can be drained to zero and deregistered. +* **`spotAPYBps`** — `supplyRatePerBlock × blocksOrSecondsPerYear`, read from the **market itself**. The `blocksPerYear` argument is ignored: an isolated-pools market carries its own annualiser as an immutable and may be block- or time-based, so a YieldGroup-level constant would misprice whichever kind it was not configured for. Deploy the Spoke YieldGroup with `blocksPerYear = 0`, as the Flux family already does. +* **`validateRegistration`** — rejects the two configurations `preMintHook` would reject on every deposit, either of which would leave the resource holding a queue slot it can never fill: + * a market its own Comptroller does not list (`MarketNotListed`), and + * a market whose supply allowlist is enabled without the registering YieldGroup on it (`SupplyNotAllowed`). **The listing VIP must therefore call `setAllowedSupplier(vToken, , true)` on the pool before `addResource`.** + + The two checks also establish what the market is. The listing check ties `resource` to the Comptroller it names — any contract can return a real `SpokeComptroller` from `comptroller()`, but only a market that Comptroller actually lists passes. The allowlist accessors then pin that Comptroller to the spoke fork, since they exist nowhere else and the call reverts against any other Comptroller. Nothing else is asserted — in particular an unset `deviationBoundedOracle` is *not* grounds for rejection: it blocks borrowing, and so the market's yield, but leaves the Hub's own paths intact. + +### Valuation excludes written-off debt + +An isolated-pools market keeps `badDebt` in the numerator of its exchange rate, so the rate does **not** fall when `healAccount` writes a loss off; the loss surfaces only as redemptions failing for want of cash. Valuing a Hub position at that rate would report unrecoverable value, and inside the Hub the loss would then land by exit order — early LPs out at the pre-loss share price, the last one absorbing everything. + +`totalAssets` therefore values the position at its pro-rata share of `cash + totalBorrows − totalReserves`, which is `exchangeRateStored` with `badDebt` dropped, capped at the market's own valuation of the same tokens. The mark is pro-rata (`balance / totalSupply`), so it stays correct whether or not the Hub is the market's only supplier, and it marks every LP down at the same instant. A [Shortfall](../reference-isolated-pools/risk-fund-and-shortfall/shortfall.md) auction that later recovers the debt raises cash and lowers `badDebt` by the same amount, so the mark recovers with no Hub-side action. + +The cap exists because of rounding, not risk: the adapter reaches its figure in one step where the market takes two, so with no `badDebt` the one-step figure can land a unit above `balanceOfUnderlying` — value no redeem can reach. Whenever `badDebt` is non-zero the cap is the looser of the two and the recoverable figure is what binds. + +Note that `maxDeposit` uses the market's **own** (badDebt-inclusive) exchange rate, not this valuation basis, because its headroom math has to mirror `preMintHook`'s own cap check. + +### Caller identity + +`maxDeposit` and `validateRegistration` name no `holder`, and both are invoked by the YieldGroup as a plain call — so `msg.sender` **is** the prospective supplier, and reading the market's allowlist against it is exact rather than a convention. (Every other view that depends on whose position is being queried takes the holder explicitly, and the mutating members arrive by delegatecall.) Operationally this means a YieldGroup whose grant is revoked reports zero room and is routed around, instead of advertising capacity that every deposit then reverts on. + +Fee-on-transfer underlyings are unsupported, matching the rest of the Hub. + +**Constants:** `EXP_SCALE` (`1e18`), `UNCAPPED_DEPOSIT_ROOM` (`type(uint128).max`), `MANTISSA_TO_BPS` (`1e14`), `CAP_TRIM_DIVISOR` (`1000`). + +**Errors:** `NotDelegateCall`, `VTokenUnderfilled`, `DepositBelowOneVToken`, `SupplyNotAllowed`, `MarketNotListed`. + +Unlike `AdapterCoreV1` there are no `VTokenMintFailed` / `VTokenRedeemFailed` / `VTokenAccrueFailed` variants: an isolated-pools `mint`, `redeem` or `accrueInterest` either returns `NO_ERROR` or reverts, so a failure arrives as the market's own revert rather than as an error code. Only the legacy Core pool still sets those codes. + ## Adding a new protocol family Because adapters are stateless and reached only through `IResourceAdapter`, supporting a new yield protocol (e.g. a future Core V2) requires only a new adapter deployment plus per-YieldGroup `addResource` calls — no changes to the Hub, the YieldGroups, the interfaces, or any existing adapter. diff --git a/technical-reference/reference-liquidity-hub/hub-router.md b/technical-reference/reference-liquidity-hub/hub-router.md new file mode 100644 index 00000000..b08904ef --- /dev/null +++ b/technical-reference/reference-liquidity-hub/hub-router.md @@ -0,0 +1,106 @@ +# HubRouter + +`HubRouter` turns an underlying balance into collateral in one call. It is periphery: permissionless, immutable, with no proxy and no admin, and it holds no funds and no authority between calls. + +It serves two different pools, and the reason it exists is different in each: + +* **Core pool** — deposit into a [Hub](hub.md) and land the resulting [vhToken](vhtoken.md) in that Hub's Core market, either from a wallet balance or by migrating an existing Core position. This is a convenience: it collapses `approve → deposit → approve → mint` into one transaction, and in the migration case it does something a user cannot do by hand at all. +* **Spoke pool** — supply collateral and enable it as collateral in the same call. This one is not just convenience; without it the flow is impossible to batch, for the reason below. + +> **Not deployed.** The contract is written and tested but has no deployment script yet, and its spoke paths additionally need a governance grant that no VIP has made. Nothing on this page is live. + +## Why the spoke half needs a contract change + +`Comptroller.enterMarkets` reads `msg.sender`. A router that supplies through `VToken.mintBehalf` credits the receipts to the user but enters *itself* into the market, which leaves the user supplied and not collateralised. No router, VToken hook or `Multicall` batch can close that gap, because every one of them is still the `msg.sender` the Comptroller sees. + +[`SpokeComptroller.enterMarketBehalf`](../reference-isolated-pools/spoke/spoke-comptroller.md#entermarketbehalf) takes the account as an argument instead. It is gated by the [AccessControlManager](../reference-governance/access-control-manager.md), and this router is the intended holder of the role. + +**Both spoke functions revert until the listing VIP grants this router `enterMarketBehalf(address,address)` on each spoke pool's Comptroller.** Until then users supply and enter in two transactions of their own, which still works — `enterMarkets` is untouched and stays permissionless. + +The Core pool has an `enterMarketBehalf` of its own, but it is gated on delegate approval rather than ACM, so using it would cost the user an `updateDelegate` transaction and grant the router borrow- and redeem-on-behalf rights it does not need. The Core paths therefore **do not** auto-enter: receipts from `supplyFromWallet` earn Hub yield immediately but are not collateral until the caller runs `enterMarkets` themselves. + +## Core: `supplyFromWallet` + +```solidity +function supplyFromWallet(address hub, uint256 assets, address vhMarket, uint256 minShares) + external returns (uint256 shares); +``` + +Pulls `assets` of `IHub(hub).asset()` from the caller, deposits into the Hub, and supplies the minted shares to `vhMarket` with the receipts credited to the caller. `minShares` is the slippage guard on the deposit leg. + +`vhMarket.underlying()` must equal `hub` — a Hub share token is exactly what its Core market wraps, so the pair is validated against each other rather than trusted. A full supply cap reverts inside the Comptroller, so a capped market fails the whole call and the caller keeps their underlying. + +## Core: `supplyFromCollateral` + +```solidity +function supplyFromCollateral( + address vToken, + uint256 vTokenAmount, + address hub, + address vhMarket, + uint256 minShares +) external returns (uint256 shares); +``` + +Migrates an existing Core position into the Hub: redeem `vTokenAmount`, deposit the underlying, supply the shares. `vToken.underlying()` must equal the Hub's asset. + +Which route it takes is decided by `getHypotheticalAccountLiquidity`, the same question the redeem itself will ask: + +| | Condition | What happens | +| --- | --- | --- | +| Direct | removing the collateral leaves no shortfall | redeem, deposit, supply | +| Flash-loan | it would leave the caller under water | borrow the position's worth from Core, supply the replacement collateral **first**, then redeem the old one and repay | + +The flash-loan route is what lets a **leveraged** position migrate in full rather than in slices — the replacement collateral exists before the old collateral leaves, so borrow power never dips. It additionally requires the caller to have already entered `vhMarket` (`MarketNotEntered`) and this router to be allow-listed for Core flash loans. + +The two routes deposit slightly different amounts, so **`minShares` is not directly comparable between them**. The direct route deposits the redeem proceeds. The flash route deposits the position's value at the market's last accrual net of any redeem fee, which is a floor on those proceeds; the difference is returned to the caller as underlying rather than migrated. + +## Spoke: `supplyAndEnterSpokeMarkets` and `enterSpokeMarkets` + +```solidity +function supplyAndEnterSpokeMarkets(address[] calldata vTokens, uint256[] calldata amounts) external; +function enterSpokeMarkets(address[] calldata vTokens) external; +``` + +The first supplies each amount into the matching market and enables every one of them as the caller's collateral. The second is for a caller who already holds the receipts and only needs the membership; entering a market the caller is already in changes nothing rather than reverting. + +Both take lists and read each market's Comptroller **from the market itself**, so a single call may span several spoke pools. Every market is supplied and entered or the whole call reverts. + +The supplied amount is measured as a balance delta rather than taken from the argument. The router is a hop the market does not know about, so a fee-on-transfer collateral arrives short and only what actually landed is supplied. + +## Safety model + +* **The router never names anyone but its caller.** Every `mintBehalf` and `enterMarketBehalf` is passed `msg.sender`, so holding the ACM role does not let it enter a market for a third party. This is the property that makes the grant safe to make, and it is what a reviewer should check first. +* **Nothing is held between calls.** Receipts go straight to the user through `mintBehalf` rather than sitting in the router, and each call is atomic — if any leg fails the whole thing reverts and the caller keeps their underlying. +* **Amounts are read back, never assumed.** `mintBehalf` and `redeem` report only an error code, so shares minted and receipts credited are both measured as balance deltas. +* **The flash-loan callback is gated on a migration being in flight** and validates the caller, the initiator, and the markets and amounts against what the router recorded before the external call. It is deliberately not `nonReentrant`: `supplyFromCollateral` already holds that guard when the Comptroller calls back. + +## Events + +| Event | Emitted when | +| --- | --- | +| `SuppliedFromWallet(address indexed user, address indexed hub, address indexed vhMarket, uint256 assets, uint256 shares, uint256 vTokens)` | a wallet balance is deposited and supplied | +| `SuppliedFromCollateral(address indexed user, address indexed vToken, address indexed hub, address vhMarket, uint256 vTokenAmount, uint256 shares, uint256 vTokens)` | a Core position is migrated into the Hub | +| `SuppliedToSpoke(address indexed user, address indexed vToken, uint256 assets, uint256 vTokens)` | a spoke market is supplied and entered | + +## Errors + +| Error | Meaning | +| --- | --- | +| `ZeroAddress()` / `ZeroAmount()` | a required address or amount was zero | +| `InvalidArrayLength()` | an empty list, or `vTokens` and `amounts` of different lengths | +| `MarketMismatch(address marketUnderlying, address hub)` | `vhMarket` does not wrap the given Hub | +| `AssetMismatch(address vTokenUnderlying, address hubAsset)` | the source market's underlying is not the Hub's asset | +| `InsufficientShares(uint256 shares, uint256 minShares)` | the deposit yielded fewer shares than `minShares` | +| `VTokenMintFailed(address vhMarket, uint256 errorCode)` | the Core market returned a failure code on mint | +| `VTokenRedeemFailed(address vToken, uint256 errorCode)` | the Core market returned a failure code on redeem | +| `NothingMinted()` / `NothingReceived()` | a leg completed but credited nothing | +| `MarketNotEntered(address vhMarket)` | the flash-loan migration route needs the caller already in `vhMarket` | +| `LiquidityCheckFailed(uint256 errorCode)` | the Comptroller could not answer the hypothetical-liquidity question | +| `UnexpectedCallback()` | the flash-loan callback did not match the migration in flight | + +## Further reading + +* [Hub](hub.md) and [vhToken](vhtoken.md) — what the Core paths deposit into and supply. +* [SpokeComptroller](../reference-isolated-pools/spoke/spoke-comptroller.md#entering-a-market-for-a-supplier) — the `enterMarketBehalf` the spoke paths depend on. +* [Hub-Funded Spoke Pools](../reference-isolated-pools/spoke/README.md) — the pool shape the spoke paths serve. diff --git a/technical-reference/reference-liquidity-hub/yield-groups.md b/technical-reference/reference-liquidity-hub/yield-groups.md index b8ea0910..4775ca9d 100644 --- a/technical-reference/reference-liquidity-hub/yield-groups.md +++ b/technical-reference/reference-liquidity-hub/yield-groups.md @@ -2,15 +2,16 @@ A **YieldGroup** is a Source implementation: it aggregates one or more *resources* of a single protocol family behind the uniform [`IYieldGroupBase`](interfaces.md) boundary the Hub depends on. Each YieldGroup is deployed per asset as a beacon proxy and owns its own inner deposit / withdraw queues, per-resource registry, and per-resource pause flags. -There are **two YieldGroup contracts**, deployed as three families in v1: +There are **two YieldGroup contracts**, deployed as four families: * **Core** — the generic `YieldGroup` contract behind the Core beacon, registering Venus Core-pool vTokens (`mint` / `redeemUnderlying`) via `AdapterCoreV1`, initialised with the chain's `blocksPerYear`. * **Flux** — the *same* `YieldGroup` contract behind the Flux beacon, registering Fluid Lending fTokens (ERC-4626 shares) via `AdapterFlux`, initialised with `blocksPerYear = 0`. +* **Spoke** — the *same* `YieldGroup` contract behind the Spoke beacon, registering the liquidity side of [hub-funded spoke pools](../reference-isolated-pools/spoke/README.md) via `AdapterSpokeV1`, also initialised with `blocksPerYear = 0` (each market carries its own annualiser). Built but **not yet deployed**. * **`YieldGroupFRV`** — a separate contract, for Venus Fixed-Rate Vaults (ERC-4626 with an 11-state lifecycle). -"Core" and "Flux" are **deployment identities, not contract names** — which adapter, resources, caps and `blocksPerYear` governance wires into the proxy is the only difference. There is no `YieldGroupCore` or `YieldGroupFlux` type to import. +"Core", "Flux" and "Spoke" are **deployment identities, not contract names** — which adapter, resources, caps and `blocksPerYear` governance wires into the proxy is the only difference. There is no `YieldGroupCore`, `YieldGroupFlux` or `YieldGroupSpoke` type to import. -All three share the same Hub-facing surface and the same registry / queue / pause admin surface; they differ only in the protocol-specific behavior delegated to their [adapter](adapters.md) and in a few family-specific rules noted below. +All four share the same Hub-facing surface and the same registry / queue / pause admin surface; they differ only in the protocol-specific behavior delegated to their [adapter](adapters.md) and in a few family-specific rules noted below. > **Terminology.** The PRD calls this layer a *Source*; the code names the contract a *YieldGroup* and the Hub-facing interface `IYieldGroupBase`. There is no `ISource` type — "Source" survives in the Solidity only as deployment-artifact aliases (`CoreSource_USDT`, `FluxSource_USDC`, `FRVSource_U`). A registered resource is the PRD's *Product / Vault*. @@ -22,7 +23,7 @@ Every YieldGroup implements `IYieldGroupBase`. The Hub depends only on these fun * **`withdraw(uint256 amount, address to)`** — pull `amount` from resources via the inner withdraw queue (idle-first) and deliver exactly `amount` to `to`, or revert. * **`depositResource(address resource, uint256 amount)` → `uint256 deposited`** — deposit the full `amount` into one specific resource, bypassing the inner queue (for Operator reallocation). Reverts if that resource cannot accept exactly `amount`. * **`withdrawResource(address resource, uint256 amount, address to)`** — redeem exactly `amount` from one specific resource (no idle-first, no cascade); pulling from a paused resource is permitted (wind-down). -* **`accrue()`** — `onlyHub`; the Hub pokes every registered Source before reading NAV so the management fee is charged on interest-current value. **All three families override the base no-op**, so it is unreachable in deployed code. Core and Flux share the `YieldGroup` loop, which calls `IResourceAdapter.accrue` on every registered resource — real work only for Core (`AdapterCoreV1` calls the vToken's `accrueInterest()`), an empty body for Flux. `YieldGroupFRV` instead pokes each vault's `updateVaultState()`, advancing its lifecycle. Every poke is best-effort per resource: one that reverts is isolated and reported via `ResourceAccrualFailed` rather than bubbling. +* **`accrue()`** — `onlyHub`; the Hub pokes every registered Source before reading NAV so the management fee is charged on interest-current value. **Every family overrides the base no-op**, so it is unreachable in deployed code. Core, Flux and Spoke share the `YieldGroup` loop, which calls `IResourceAdapter.accrue` on every registered resource — real work for Core and Spoke (both call their vToken's `accrueInterest()`), an empty body for Flux. `YieldGroupFRV` instead pokes each vault's `updateVaultState()`, advancing its lifecycle. Every poke is best-effort per resource: one that reverts is isolated and reported via `ResourceAccrualFailed` rather than bubbling. * **Views** — `asset()`, `totalAssets()`, `maxDeposit()`, `maxWithdraw()`, `spotAPYBps()`. See [Interfaces](interfaces.md) for the full `IYieldGroupBase` contract and its conventions. diff --git a/technical-reference/reference-technical-articles/deviation-bounded-oracle.md b/technical-reference/reference-technical-articles/deviation-bounded-oracle.md index 94fb66ce..f9941bea 100644 --- a/technical-reference/reference-technical-articles/deviation-bounded-oracle.md +++ b/technical-reference/reference-technical-articles/deviation-bounded-oracle.md @@ -1,6 +1,6 @@ # DeviationBoundedOracle -The DeviationBoundedOracle (DBO) is the contract that sits between the [ResilientOracle](../reference-oracle/resilient-oracle.md) and the Core Pool Comptroller on the borrow-power path. It maintains a per-asset rolling price window, detects when spot deviates beyond a configured threshold, and returns conservative bounded prices while the deviation persists. Its user-visible behaviour is the [Protection Mode](../../risk/protection-mode.md) feature; this article covers the contract itself — where it gets prices, what it stores, and how the Comptroller calls it. +The DeviationBoundedOracle (DBO) is the contract that sits between the [ResilientOracle](../reference-oracle/resilient-oracle.md) and a Comptroller on the borrow-power path — the Core Pool Comptroller, and the [`SpokeComptroller`](../reference-isolated-pools/spoke/spoke-comptroller.md#bounded-collateral-pricing) of a hub-funded spoke pool. It maintains a per-asset rolling price window, detects when spot deviates beyond a configured threshold, and returns conservative bounded prices while the deviation persists. Its user-visible behaviour is the [Protection Mode](../../risk/protection-mode.md) feature; this article covers the contract itself — where it gets prices, what it stores, and how the Comptroller calls it. For function-level signatures, structs, events, and errors see the [DeviationBoundedOracle reference](../reference-oracle/deviation-bounded-oracle.md). @@ -93,6 +93,12 @@ Liquidator → Comptroller (USE_LIQUIDATION_THRESHOLD) → ComptrollerLens → R The borrow-power path uses `WeightFunction.USE_COLLATERAL_FACTOR` and is the only path that calls `_updateProtectionStates`. The liquidation path uses `WeightFunction.USE_LIQUIDATION_THRESHOLD` and reads spot from the ResilientOracle directly. No bounded prices touch eligibility, seize-amount, or incentive calculations regardless of whether protection is active on either side of the position. +## Hub-funded spoke pools + +A [hub-funded spoke pool](../reference-isolated-pools/spoke/README.md) wires the DBO the same way, through its own `SpokeComptroller.setDeviationBoundedOracle`. The split is identical: the collateral-factor path prices borrowing capacity through the DBO and calls `updateProtectionState` for every market the account is in, while the liquidation-threshold path and `liquidateCalculateSeizeTokens` stay on the ResilientOracle's spot price. + +One difference is worth knowing: in a spoke pool the DBO is **not optional**. Nothing falls back to spot when `deviationBoundedOracle` is unset — both calls into the zero address revert, so borrowing and redeeming fail closed until governance sets it. Minting and repaying are unaffected, since neither reads a price. + ## Off-chain components - **Keeper.** Maintains the authoritative 15-minute rolling window off-chain and pushes corrected `minPrice` / `maxPrice` on-chain whenever the stored values have drifted past the 5% deadband. The on-chain writes are constrained by `newMin ≤ spot` and `newMax ≥ spot`. Once the on-chain exit conditions are satisfied — cooldown elapsed and window converged below `resetThreshold` — the keeper submits `exitProtectionMode(asset)`, or includes an `ExitProtectionMode` item inside a `syncPriceBoundsAndProtections` batch. @@ -103,6 +109,7 @@ The borrow-power path uses `WeightFunction.USE_COLLATERAL_FACTOR` and is the onl ## Further Reading - [Protection Mode (risk overview)](../../risk/protection-mode.md) +- [Hub-Funded Spoke Pools](../reference-isolated-pools/spoke/README.md) - [DeviationBoundedOracle contract reference](../reference-oracle/deviation-bounded-oracle.md) - [Resilient Price Oracle](../../risk/resilient-price-oracle.md) - [Repository](https://github.com/VenusProtocol/oracle) diff --git a/whats-new/hub-funded-spoke-pools.md b/whats-new/hub-funded-spoke-pools.md new file mode 100644 index 00000000..7e98211a --- /dev/null +++ b/whats-new/hub-funded-spoke-pools.md @@ -0,0 +1,121 @@ +# Hub-Funded Spoke Pools + +### Overview + +A **hub-funded spoke pool** is an isolated lending market that splits the *liquidity side* — what gets borrowed — from the *collateral side* — what backs the borrow. + +* The **liquidity side** is supplied only by an allowlist, by default the [Liquidity Hub](liquidity-hub.md). Because the protocol is the sole lender, it controls exactly how much liquidity a market holds and therefore where its utilization and rate sit. +* The **collateral side** is permissionless by default. Anyone can deposit a listed asset, use it as collateral, and borrow the liquidity asset against it. + +Borrowing is **open-term and variable-rate**: there is no maturity, and every market plugs in its own interest rate model, so the rate curve is customizable per asset. + +Every market is capped and every pool is isolated. A depeg, an oracle problem, or bad debt on an exotic collateral asset is contained inside that pool's capped exposure and cannot reach the Core pool. + +> **Not live yet.** The contracts are written and tested; no spoke pool has been deployed, listed, or wired to a Liquidity Hub. This page describes the design, not something you can use on-chain today. + +### Why + +Venus has two products for a borrower today: the Core pool, which is shared, mainstream and deliberately conservative, and [Fixed Term Vaults](fixed-rate-vaults.md), which are fixed-rate and fixed-term. Between them there is a gap: **flexible, open-term borrowing in a special scenario, with real customization and bounded exposure**. + +Listing a trending or exotic asset in the Core pool means putting the whole shared pool behind it, so listings are slow and conservative. A large counterparty that wants a market shaped around the collateral it actually holds cannot get one. Spoke pools close both gaps by making the blast radius of a listing equal to that one pool's caps. + +The Liquidity Hub is what makes it work: it gives Venus a metered, protocol-controlled source of liquidity to fund the borrowable side, instead of waiting for third-party suppliers to show up. + +### How a pool is shaped + +| | Liquidity side | Collateral side | +| --- | --- | --- | +| Typical asset | USDT, USDC, U | tokenized stock, trending or exotic assets | +| Who may supply | allowlist only (default: the Liquidity Hub) | anyone — an allowlist exists but is off by default | +| Borrowable | yes | no | +| Usable as collateral | no | yes | +| Interest rate model | its own, per asset | not applicable | +| Caps | own supply cap and borrow cap | own supply cap | + +Both sides are ordinary markets in the same pool; which side a market is on is a governance setting, not a contract type. + +A pool can carry **more than one liquidity asset**. Borrow power is a single shared USD budget across all of them: borrowing USDT and borrowing U draw on the same capacity, and one health factor covers all of an account's collateral and all of its debt within the pool. + +### Access and permissions + +| Action | Who | Default | Optional | +| --- | --- | --- | --- | +| Supply the liquidity asset | allowlist (default: the Hub) | allowlist on | can be turned off | +| Deposit / enable collateral | anyone | permissionless | per-market allowlist (off) | +| Borrow the liquidity asset | anyone with collateral | permissionless | — | +| Repay / redeem / withdraw / transfer | position owner | **never gated** | — (governance can still pause a market) | +| Liquidate | anyone | permissionless | pool-wide allowlist (off) | + +**Exit is never restricted.** No allowlist gates repaying, redeeming, withdrawing or transferring, and an account removed from an allowlist keeps the position it already holds and can still leave. Governance retains the same market-level pause it has everywhere else. + +There is no on-chain KYC and no external list sync. Any allowlist is written on-chain by governance, through a VIP. + +### Two go-to-market shapes + +The same primitive expresses both — they are ends of a spectrum, not separate products. + +| | **Retail** | **Bulk** | +| --- | --- | --- | +| Goal | list trending assets quickly and competitively | tailored borrowing for a large or institutional counterparty | +| Collateral side | trending / exotic assets, permissionless | the specific collateral the counterparty posts | +| Liquidity side | Hub-funded blue-chip or stable | Hub-funded, sized to the deal | +| Exposure | many small, tightly capped markets | sized per deal, ring-fenced | +| Rate model | standard IRM | custom IRM curve | + +### Worked example + +A pool with USDT on the liquidity side and five tokenized-stock markets on the collateral side. + +**Liquidity side** + +| Asset | Supplied | Available | Utilization | Borrow APY | Collateral factor | +| --- | --- | --- | --- | --- | --- | +| USDT | 10M | 3M | 70% | 5.6% | 0 | + +**Collateral side** + +| Asset | LTV | Liquidation threshold | Price | +| --- | --- | --- | --- | +| SPCXB | 70% | 75% | $200 | +| NVDAB | 75% | 80% | $210 | +| MUB | 75% | 80% | $1,000 | +| SNDKB | 70% | 75% | $1,900 | +| TSLAB | 75% | 80% | $375 | + +**A position** + +| Collateral | Amount | Value | Borrow power | Value at liquidation threshold | +| --- | --- | --- | --- | --- | +| SPCXB | 10 | $2,000 | $1,400 | $1,500 | +| NVDAB | 20 | $4,200 | $3,150 | $3,360 | +| MUB | 5 | $5,000 | $3,750 | $4,000 | +| SNDKB | 5 | $9,500 | $6,650 | $7,125 | +| TSLAB | 10 | $3,750 | $2,812.50 | $3,000 | +| **Total** | | **$24,450** | **$17,762.50** | **$18,985** | + +With $24,450 of collateral the account can borrow up to **17,762.50 USDT** — bounded by borrow power, far below the 3M of available liquidity — and becomes liquidatable once its debt reaches the $18,985 liquidation-threshold value. + +Add a second liquidity asset, U, and the picture does not change: the same $17,762.50 of borrow power is shared across USDT and U, and each borrow consumes it. + +### Risk controls + +* **Isolation.** Each pool has its own Comptroller. No shared collateral with the Core pool, no cross-margin, and no path by which a loss here reaches Core. +* **Per-market caps.** Supply cap, borrow cap, collateral factor and liquidation threshold are set per market, so exposure to any one asset is bounded before it is listed. +* **Per-market liquidation incentive.** The discount a liquidator earns is set per *collateral* market rather than once for the pool. Setting a higher incentive on more volatile collateral steers liquidators to seize the riskiest assets first, which de-risks an account faster. +* **Bounded collateral pricing.** Borrowing capacity is priced through the [DeviationBoundedOracle](../technical-reference/reference-technical-articles/deviation-bounded-oracle.md), the same mechanism [Protection Mode](../risk/protection-mode.md) uses in the Core pool: while an asset's price is deviating from its recent window, collateral is valued at the low end of that window and debt at the high end. A pumped print can only shrink an account's borrowing capacity, never inflate it. Liquidations themselves stay on live spot prices. +* **Oracle quality is part of the listing decision.** Every listed asset — exotic collateral above all — needs a reliable feed, and that review is part of per-market risk sign-off. +* **Governance owns every setting.** Listing, caps, risk parameters, allowlists and interest rate models are all set through a VIP, gated by the [AccessControlManager](../technical-reference/reference-governance/access-control-manager.md). + +### Where bad debt lands + +The Liquidity Hub is the lender, so a loss in a spoke pool lands on the Hub. + +An isolated-pools market keeps written-off debt inside its exchange rate — when an account is healed, the shortfall moves from `totalBorrows` into `badDebt` and the numerator does not change — so the rate does not fall when a loss is booked, and the loss would otherwise surface only as redemptions failing for lack of cash. Inside the Hub that would mean the loss lands by exit order: early lenders redeem at the pre-loss share price and whoever is last out absorbs everything. + +The Hub's Spoke adapter therefore values a spoke position with written-off debt **excluded**, so the loss hits every Hub lender at the same instant instead of becoming a race for the exit. If a [Shortfall](../technical-reference/reference-isolated-pools/risk-fund-and-shortfall/shortfall.md) auction later recovers the debt, the mark recovers on its own. + +### Learn more + +* [Hub-Funded Spoke Pools technical reference](../technical-reference/reference-isolated-pools/spoke/README.md) — the `SpokeComptroller` fork, its allowlists, liquidation routing and bounded pricing. +* [Liquidity Hub](liquidity-hub.md) — the vault that funds the liquidity side. +* [DeviationBoundedOracle](../technical-reference/reference-technical-articles/deviation-bounded-oracle.md) — how bounded pricing works. diff --git a/whats-new/liquidity-hub.md b/whats-new/liquidity-hub.md index 2590839d..386d0fc3 100644 --- a/whats-new/liquidity-hub.md +++ b/whats-new/liquidity-hub.md @@ -14,15 +14,18 @@ Today a lender has to choose between independent products — Venus Core lending The Hub is purely a routing layer. It does **not** modify the parameters or governance of the underlying Core / Flux / FRV products — it only moves capital into and out of them. -### The three yield families (Sources) +### The yield families (Sources) -A **Source** groups downstream products of the same kind behind one uniform interface. v1 ships three: +A **Source** groups downstream products of the same kind behind one uniform interface. v1 ships three, with a fourth built: -| Source | Underlying protocol | What the Hub holds | -| -------- | ------------------------ | ------------------------------------------ | -| **Core** | Venus Core lending | vTokens (Compound-style receipt tokens) | -| **Flux** | Fluid Lending (third-party) | fTokens (ERC-4626 shares) | -| **FRV** | Venus Fixed-Rate Vaults | Fixed-Rate Vault shares (ERC-4626) | +| Source | Underlying protocol | What the Hub holds | +| --------- | --------------------------- | --------------------------------------- | +| **Core** | Venus Core lending | vTokens (Compound-style receipt tokens) | +| **Flux** | Fluid Lending (third-party) | fTokens (ERC-4626 shares) | +| **FRV** | Venus Fixed-Rate Vaults | Fixed-Rate Vault shares (ERC-4626) | +| **Spoke** | Venus [hub-funded spoke pools](hub-funded-spoke-pools.md) | vTokens of the pool's liquidity side | + +The **Spoke** Source is what makes the Hub the funder of a hub-funded spoke pool: it supplies the borrowable side of an isolated pool that only the Hub is allowed to supply. It is written and tested but **not deployed** — no spoke pool exists yet for it to route into. At launch only **Core** and **Flux** hold a live product. The FRV Source is registered on every Hub with its caps set, but no Fixed-Rate Vault instance exists for these assets on BNB Chain yet, so it is wired to nothing and receives no capital until a follow-up proposal adds one. @@ -37,6 +40,15 @@ The Hub holds two independent, governance-configured ordered queues — a **depo At launch the queues are configured **Core-first in, Flux-first out** — deposit `[Core, Flux]`, withdraw `[Flux, Core, FRV]`. Core absorbs everyday inflows, so new capital lands in the deepest and most liquid market first; withdrawals are served from Flux ahead of Core, which keeps Core's balance intact as a buffer. FRV is out of the deposit queue entirely (nothing to route into) and sits last on the withdraw side, where an empty Source costs nothing to walk past. The two orders are set independently and governance can reorder either. +### Getting in and out in one transaction + +Depositing into the Hub and putting the resulting [vhToken](../technical-reference/reference-liquidity-hub/vhtoken.md) to work as Core collateral are two separate actions, and doing them by hand means four transactions. The **[HubRouter](../technical-reference/reference-liquidity-hub/hub-router.md)** collapses that into one call, and covers two cases a user cannot easily do themselves: + +* **Moving an existing Core position into the Hub.** A position with borrows against it cannot simply be redeemed — removing the collateral first would leave the account under water. The router borrows the position's worth from Core, supplies the replacement collateral *before* the old collateral leaves, and repays inside the same transaction, so a leveraged position migrates in full rather than in slices. +* **Supplying collateral to a spoke pool.** In a [hub-funded spoke pool](hub-funded-spoke-pools.md) the protocol lends and users post the collateral, and enabling a market as collateral is a second transaction of its own. The router does both at once, across several markets and even several pools in one call. + +The router is permissionless and immutable, holds no funds between calls, and can only ever act for the account that called it. It is written but not yet deployed, and its spoke paths additionally wait on a governance grant. + ### Operator rebalancing Beyond user-driven flow, a privileged **Operator** can proactively rebalance capital between *already-registered* Sources and products — for example, pulling funds back to Core when general-market utilization tightens, or seeding a newly onboarded product. Rebalancing is **net-zero** (the amount pulled equals the amount pushed; nothing enters or leaves the Hub) and is bounded by the same caps governance sets. The Operator can never create a new route or move funds outside the registered set.