@venusprotocol/liquidity-hub
v1.0.0
Published
Per-asset ERC-4626 allocator vault
Keywords
Readme
Venus Liquidity Hub
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 — one Hub per asset, no cross-asset coupling.
Scope. v1 ships on BNB Chain with three yield families — Core, Flux, FRV. Isolated Pools and RWA (Ondo / BUIDL) are documented future YieldGroups, not v1. The fee machinery is fully implemented but launches at
0/0.
Architecture
Three-tier routing: the Hub routes across per-asset YieldGroups, each YieldGroup owns an inner queue of one or more resources, and a stateless adapter speaks the protocol-specific ABI for each resource. The Hub depends only on the IYieldGroupBase interface and never reaches past a YieldGroup into the underlying market.
Hub > YieldGroup > Adapter (delegatecall) > Resource
flowchart TB
classDef hub fill:#e8f5e9,stroke:#2e7d32,color:#000,font-weight:bold
classDef src fill:#fff3e0,stroke:#ef6c00,color:#000,font-weight:bold
classDef queue fill:#e3f2fd,stroke:#1565c0,color:#000
classDef adapter fill:#fce4ec,stroke:#c2185b,color:#000
classDef resource fill:#e1f5fe,stroke:#0277bd,color:#000
User((User))
User -->|"deposit / withdraw"| Hub
Hub["Hub_USDT (ERC-4626, beacon proxy)<br/>dual caps · fees · per-tx cap"]:::hub
Hub --> OuterQ["outer queue (per direction)<br/>[YieldGroup_Core, YieldGroup_Flux, YieldGroup_FRV]"]:::queue
OuterQ --> CoreYG["YieldGroup_Core_USDT"]:::src
OuterQ --> FluxYG["YieldGroup_Flux_USDT"]:::src
OuterQ --> FrvYG["YieldGroup_FRV_USDT"]:::src
CoreYG --> CoreInner["inner queue [vUSDT_v1, ...]"]:::queue
FluxYG --> FluxInner["inner queue [fUSDT, ...]"]:::queue
FrvYG --> FrvInner["inner queue [FRV_A, FRV_B, ...]"]:::queue
CoreInner -->|"delegatecall"| ACore["AdapterCoreV1<br/>(stateless singleton)"]:::adapter
FluxInner -->|"delegatecall"| AFlux["AdapterFlux<br/>(stateless singleton)"]:::adapter
FrvInner -->|"delegatecall"| AFrv["AdapterFRV<br/>(stateless singleton)"]:::adapter
ACore --> RCore["vUSDT (Venus Core vToken)"]:::resource
AFlux --> RFlux["fUSDT (Fluid fToken)"]:::resource
AFrv --> RFrv["FRV vault (fixed-rate, lifecycle)"]:::resourceKey contracts
| Contract | Role |
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hub | ERC-4626 entry point (beacon proxy). Routes deposits/withdrawals through the outer queue of YieldGroups, manages dual caps, the per-tx withdrawal cap, management + performance fees, and multi-level pause. Registry/queue logic is delegatecall-linked into HubAdminLib to stay under the 24 KB bytecode limit. |
| YieldGroupCore | IYieldGroupBase over Venus Core-pool vTokens (mint / redeemUnderlying). Optional per-resource deposit caps. Grosses up redeems for a Comptroller treasury fee if one is ever enabled. |
| YieldGroupFlux | IYieldGroupBase over Fluid Lending fTokens (ERC-4626 shares). No redeem-time fee. Per-resource caps are the primary deposit control (Fluid's protocol maxDeposit is effectively unbounded). |
| YieldGroupFRV | IYieldGroupBase over Venus Fixed-Rate Vaults — ERC-4626 with an 11-state lifecycle. Pokes updateVaultState() before every routing decision; deposits only while Fundraising, withdrawals only when terminal. |
| AdapterCoreV1 / AdapterFlux / AdapterFRV | Stateless singletons — zero storage, only immutables. One deployment per ABI family serves every YieldGroup. Mutating calls run via delegatecall so receipt tokens land on the YieldGroup; direct calls revert (onlyDelegateCall). |
| Migrator | Immutable, permissionless periphery (no proxy/beacon), one shared deployment per chain. One-click migration of a Venus Core position into a Hub: pulls the caller's vToken, redeems it to the underlying, deposits into the matching Hub, and mints shares to the caller — atomic, with a minShares slippage guard. A separate migrateFromCoreBNB path wraps a redeemed vBNB position to WBNB before depositing into the WBNB Hub. |
| IYieldGroupBase | Hub↔YieldGroup boundary. deposit / withdraw (queue) + depositResource / withdrawResource (targeted, reallocate-only) + totalAssets / maxDeposit / maxWithdraw / spotAPYBps. |
| IResourceAdapter | YieldGroup↔adapter boundary. Mutating fns delegatecalled; view fns take an explicit holder. validateRegistration rejects resources with an unmodeled per-redeem fee. |
Terminology. A YieldGroup is the grouping layer: the Hub depends only on the
IYieldGroupBaseinterface and never reaches past a YieldGroup into the underlying market. A Resource is one underlying market or vault held inside a YieldGroup.
The three 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(assets) | deposit / withdraw(assets) |
| Redeem-time pool fee | only if Comptroller treasuryPercent is later enabled (grossed-up); registration blocked while non-zero | none (Fluid's cut is in the supply rate) | none (reserve factor taken once at settlement) |
| Spot APY source | supplyRatePerBlock × blocksPerYear | Fluid LendingResolver (pre-annualised APR) | vault fixedAPY (only while Fundraising / Lock) |
| Per-resource deposit cap | optional | optional (primary control) | not applicable (capacity comes from the vault) |
| Lifecycle constraint | none | none | 11-state machine (see below) |
Deposit flow
flowchart LR
classDef default fill:#f9f9f9,stroke:#333,color:#000
U((User)) -->|"assets"| H[Hub]
H -->|"outer deposit queue"| YG[YieldGroup]
YG -->|"inner deposit queue<br/>delegatecall"| A[Adapter]
A -->|"mint / deposit"| M["Resource<br/>(vToken / fToken / FRV)"]- User calls
Hub.deposit(assets, receiver)(ormint). - Hub accrues fees, then pulls
assetsfrom the user — so the caller trades against the post-accrual share price. - Hub snapshots
totalAssets()(already includes the just-transferred idle) and walks the outer deposit queue. - For each YieldGroup: skip if unregistered or paused; compute
capRoom = max(0, effectiveCap − yieldGroup.totalAssets()); sendmin(remaining, capRoom, yieldGroup.maxDeposit()); revertYieldGroupUnderfilledif the YieldGroup places less. - Each YieldGroup walks its inner deposit queue, delegatecalling its adapter per resource (respecting any per-resource cap), and mints receipt tokens into the YieldGroup.
- Hub mints share tokens to the receiver.
- If capacity across the whole queue is short → the entire tx reverts (
HubCapacityExceeded). No partial fill.
Withdrawal flow
flowchart RL
classDef default fill:#f9f9f9,stroke:#333,color:#000
M["Resource<br/>(vToken / fToken / FRV)"] -->|"redeem"| A[Adapter]
A -->|"delegatecall"| YG[YieldGroup]
YG -->|"outer withdraw queue"| H[Hub]
H -->|"assets"| U((User))- User calls
Hub.withdraw(assets, receiver, owner)(orredeem). - Hub enforces the per-tx withdrawal cap (
HubWithdrawCapExceeded), accrues fees, then burns shares fromowner. - Hub consumes its own idle balance first, then walks the outer withdraw queue (independent of the deposit queue).
- Hub calls
YieldGroup.withdraw(amount, hub); the YieldGroup uses its idle balance first, then walks its inner withdraw queue, delegatecalling the adapter'sredeemUnderlying/withdraw. - Hub measures the delivered balance delta (rather than trusting a return value) and reverts
YieldGroupUnderfilledon any shortfall. - Underlying flows Resource → YieldGroup → Hub → receiver.
- If liquidity is short → the entire tx reverts (
HubInsufficientLiquidity). No partial fill.
Operator invariant: deposit queue ⊆ withdraw queue
Keep every deposit-queue member (outer and inner) in the matching withdraw queue. Withdrawals walk
only the withdraw queues, so a target that is funded but off the withdraw queue holds value that
counts in totalAssets() yet is not reachable by the normal redeem path. This is an operator
responsibility, not an on-chain constraint. It is reached most often by ordinary deposits, not just
reallocate, and it is fully recoverable in one tx with no loss:
- (a) re-set the withdraw queue to include the target, or (b) drain it via
reallocate(a pull leg accepts any registered target, on-queue or not).
maxWithdraw() already excludes off-queue value, so it stays the honest redeemable figure throughout.
Reallocate flow
flowchart LR
classDef default fill:#f9f9f9,stroke:#333,color:#000
classDef op fill:#ffebee,stroke:#c62828,color:#000
Op((Operator)):::op -->|"reallocate"| H[Hub]
H -->|"pull (all legs)"| SrcA["YieldGroup A<br/>(withdraw legs)"]
H -->|"push (all legs)"| SrcB["YieldGroup B<br/>(deposit legs)"]Reallocate moves assets between YieldGroups — and, optionally, between specific resources within a YieldGroup — without funds entering or leaving the Hub. Both legs share one struct:
struct ReallocateLeg { address yieldGroup; address resource; uint256 amount; }
// resource == address(0) → route through the YieldGroup's inner queue (queue-decided)
// resource != address(0) → target that exact market/vault, bypassing the inner queueThe Hub treats resource as an opaque pass-through: it holds no resource registry and never reads resource state — it relays the address to the owning YieldGroup, which validates it against its own registry. A targeted leg moves the full amount into/out of that one resource or reverts (no partial fill). Setting both legs to the same yieldGroup performs an intra-YieldGroup move (e.g. vUSDT_v1 → vUSDT_v2).
- Operator calls
Hub.reallocate(withdraws[], deposits[]). - Hub accrues fees (skipped while paused).
- Pull phase: every withdraw leg runs first —
YieldGroup.withdraw(amount, hub)(queue) orYieldGroup.withdrawResource(resource, amount, hub)(targeted). Underlying returns to the Hub as idle. Pulling from a paused resource is allowed (wind-down). - Hub takes a single
totalAssets()snapshot after all pulls (the cap reference for every push — valid because a balanced reallocate conserves TVL). - Push phase: each deposit leg checks the YieldGroup is registered, unpaused, and within its effective cap, then
YieldGroup.deposit(amount)(queue) orYieldGroup.depositResource(resource, amount)(targeted). Depositing into a paused resource reverts. - Hub enforces
Σ withdraws == Σ deposits(ReallocateImbalancedotherwise). Net-zero invariant. - The Operator can only move funds among already-registered YieldGroups/resources — it can never create new routes.
emergencyReallocateis a separate, governance-gated function carrying its own ACM role. It does the same net-zero rebalance but remains callable while the Hub is paused, giving governance a wind-down lever without granting the Operator a pause bypass. It is a routing / containment tool, not an unlock: each pull leg is still bound by the resource's withdrawability, so it cannot extract FRV principal from a locked vault (maxWithdrawis 0 until the vault reaches a terminal state). It routes around FRV exposure and moves liquid resources; locked FRV principal is only reachable once that vault is Matured / Failed / Liquidated.
Example A — coarse move, 100k Core → FRV (resource = 0): the YieldGroup inner queues decide which markets are touched. Core walks its withdraw queue (idle-first, then v1, v2…); FRV walks its deposit queue, overflowing from one vault to the next as caps fill.
Example B — targeted intra-YieldGroup move, 100k vUSDT_v1 → vUSDT_v2: redeems exactly 100k from v1 (bypassing the queue and idle-first), then deposits exactly 100k into v2 or reverts if v2 lacks capacity or is paused.
FRV lifecycle
FRV vaults are ERC-4626 with an 11-state machine on top. The YieldGroup calls the vault's permissionless updateVaultState() before every capacity/liquidity check so routing always sees the current state — never a stale one.
WaitingForMargin → MarginDeposited → Fundraising → InstitutionConfirmation
→ Lock → PendingSettlement → Matured (happy path)
↘ SettlementDeadlineExceeded / Failed / Liquidated → Closed- Deposits are accepted only in
Fundraising(maxDepositis 0 in every other state). Each vault has aminSupplierDepositfloor: a sub-floor cascade leg is skipped to the next vault, a sub-floor targeteddepositResourcereverts cleanly — but a sub-floor amount that exactly fills the vault's remaining capacity (the residual tail) is always accepted. - Withdrawals are possible only in the terminal states
Matured,Failed, orLiquidated— capital is locked throughLockandPendingSettlement.Maturedadds the fixed-rate yield;Failed/Liquidatedcan return less than principal, which marks down bothmaxWithdrawandtotalAssets. - Valuation accrues linearly — no maturity cliff. While the vault sits in
Lockthe position is marked atprincipal + netInterest × elapsed / lockDuration— a smooth rise rather than a step at settlement, so there is no reprice for a sandwicher to capture. The mark accrues on lock state alone, independent of whether the institution has drawn the principal: the vault stampslockStartTimeat fundraising close and owes interest over the whole lock window regardless of the draw, so gating on the draw would instead introduce a jump in the single transaction that flips it.netInterestis full-term interest minus the floored reserve cut (mirroring the vault's own settlement math), so theLock-end mark equals the full-repaymentsettlementAmountexactly (continuity, not a discontinuity).PendingSettlement/SettlementDeadlineExceededhold flat atprincipal + full netInterest; terminal states price directly offpreviewRedeem. - A defaulting loan is marked above realizable value until it settles. Holding the full
principal + netInterestmark throughPendingSettlement/SettlementDeadlineExceededmeans a loan heading for a shortfall reads high until the terminal step-down. The over-mark is bounded by the Hub's per-YieldGroup cap (it limits how large the position can grow); the Hub exit fee adds friction against racing out ahead of that downward reprice. - A
Failed-on-under-collateralisation vault pays lender compensation in the collateral token, off the Hub's books. If fundraising meets its target but the institution has not posted full collateral byopenEndTime, the vault confiscates the institution's margin and refunds it to suppliers pro-rata in the collateral asset (a token distinct from the Hubasset()) on each withdrawal, via the vault's_afterWithdrawHook. The Hub is the withdrawal receiver, so that collateral lands on the Hub as an unaccounted balance: it is never counted intotalAssets(), does not move share price, and is recoverable only through the governancesweep. This is handled operationally, not in-contract, so no oracle or converter is added to the withdraw hot path. On a default, governance watches the vault'sMarginConfiscated/MarginCompensationClaimedevents, sweeps the collateral off the Hub, liquidates it off-chain, and donates the proceeds back to the Hub so the recovery accrues to current LPs through price-per-share. Nothing is at risk while it sits idle, since the balance is fully sweep-recoverable. - Because
maxDeposit()is a view it cannot advance state, so it can report stale non-zero capacity for a vault that is time-due to leaveFundraising; a permissionlessupdateVaultState()poke resolves it. This affects only the accuracy of the view, not fund safety.
totalAssets computation
Drives share pricing for every deposit/withdraw (convertToShares / convertToAssets).
Hub.totalAssets()
= Σ YieldGroup.totalAssets() + Hub idle balance
│
└─ YieldGroup.totalAssets()
= Σ adapter.totalAssets(resource, yieldGroup) + YieldGroup idle balance- Fails closed.
totalAssets()is the share-price denominator, so a YieldGroup whosetotalAssets()view reverts is not isolated here — it halts share math rather than silently dropping that YieldGroup's funds from NAV (which would understate the price, under-pay redeemers and over-credit depositors until the value reappeared). The quantity gates (maxDeposit/maxWithdraw) and the deposit/withdraw routing do isolate a faulting YieldGroup (contribute 0 / skip) because failing conservatively there is safe — only on the price would it corrupt the conversion. A view-level revert is a YieldGroup-level bug (resource-level reverts are isolated inside the YieldGroup, so external pauses surface inwithdraw/deposit, not here); recover via the runbook below. - Hub idle is normally zero (deposits route out atomically, withdrawals pull exact amounts, reallocate is balanced). A direct token donation persists and is intentionally counted into NAV (it accrues to LPs and is consumed first on withdraw) — the underlying cannot be swept.
- YieldGroup idle accumulates from treasury-fee gross-up surplus and dust handling on past withdrawals; it is counted and consumed idle-first.
- View paths use the stored exchange rate (
exchangeRateStored, stale up to one accrual cycle, no state mutation). Mutating paths trigger interest accrual on the resource during mint/redeem, so the rate is current by the time the operation completes.
Recovering a bricked YieldGroup (governance)
A YieldGroup is "bricked" when its own totalAssets() call reverts. This comes only from a bug in the YieldGroup — not from normal market trouble. A paused or empty market, a locked FRV, or a paused token all show up in withdraw/deposit, never in totalAssets(), so the Hub just skips that YieldGroup and keeps running. This section is only for the rare bricked-view case.
The Hub freezes by itself. totalAssets() prices every share and it fails closed, so the instant a YieldGroup's view reverts, every deposit / mint / withdraw / redeem reverts too. Governance does not have to do anything to stop trading — it has already stopped. The only question is how to get back to a correct price.
Two ways to recover:
- YieldGroup can be fixed → just wait; do nothing. When
totalAssets()works again, the Hub un-freezes on its own at the correct price. Nothing moved, nobody lost anything. This is the normal, safest path. - YieldGroup is permanently dead (funds truly gone) → remove it. Call
removeYieldGroup(bricked)(allowed even though its balance read reverts). The Hub then runs at the lower value — which is now the real value, since the money is actually gone — so unpausing is fair and the loss is shared evenly across all current LPs.
Why pause, if the brick already froze everything? Pausing is not what stops trading — the revert already did that. When a YieldGroup's totalAssets() reverts, call pauseHub() to turn that freeze into a clear, intentional halt: instead of every call failing with a confusing low-level YieldGroup revert, users and integrators see HubPaused, and the max* views return 0 instead of reverting.
Why not just
try/catchthe revert, or remove-then-re-add later? Both do the same unsafe thing — drop the YieldGroup's funds out of the price, then bring them back. While the funds are "out," the price is too low: redeemers are underpaid and depositors buy in cheap, and when the value returns those cheap depositors profit at everyone else's expense.try/catchdid this automatically on every revert (that is exactly why it was removed); a manual remove-then-re-add does it by hand. So if you ever remove a YieldGroup that still holds funds, keep the Hub paused for the whole remove → fix → re-add window, verify the re-added balance off-chain, and restore its withdraw-queue slot before unpausing.
Fees
Three fee levers. The management and performance fees are minted as dilution shares to a single feeRecipient (address(0) disables minting); accrual is idempotent within a block and runs before every deposit/withdraw/reallocate, and both rates are capped at MAX_FEE_BPS = 50%. The exit fee is separate — it is retained in the vault rather than minted to anyone.
- Management fee — linear time proration:
totalAssets × bps × Δt / (10_000 × 365 days). A single accrual is capped at 40% of TVL; when the cap binds, the uncharged remainder defers to the next accrual instead of being forfeited. - Performance fee — charged only on gains in price-per-share above a monotonic high-water mark (
totalAssets × 1e18 / totalSupply). The HWM advances whenever PPS climbs — independent of whether a fee is minted — so a later rate increase cannot retroactively claim past gains. - Exit fee (
redeemFeeBps, capped atMAX_REDEEM_FEE_BPS = 5%) — a per-withdraw/redeem fee carved out of the gross and retained in the vault (not routed out, not minted tofeeRecipient), so it lifts price-per-share for the LPs who stay. It is friction against racing out ahead of a downward settlement reprice (e.g. an FRV settling below its accrued mark on a shortfall). The withdraw path advances the HWM by the retained uplift so the performance fee never double-counts it as a gain.
v1 launches at 0/0/0; the machinery exists for governance to enable later.
Multi-level pause
Three independent scopes — a broader scope blocks everything beneath it; siblings keep operating. Pause is asymmetric: tightening (pause) is operator-accessible, loosening (unpause) is governance-only.
flowchart TB
classDef paused fill:#ffebee,stroke:#c62828,color:#000,font-weight:bold
classDef normal fill:#e8f5e9,stroke:#2e7d32,color:#000
H["Hub pause<br/>blocks ALL user-facing ops"]:::paused
H --> S1["YieldGroup pause (Hub flag)<br/>skipped in routing"]:::paused
H --> S2["YieldGroup B (unaffected)"]:::normal
S1 --> R1["Resource pause (YieldGroup flag)<br/>skipped in inner queue"]:::paused
S1 --> R2["Resource 2 (unaffected)"]:::normal- Hub paused — all deposits / withdrawals / mints / redeems,
reallocate, fee accrual, and the fee setters are blocked;emergencyReallocateandsweepstay callable; views stay readable. Underlying products keep operating. Accrual is skipped across the pause window so LPs are never charged for frozen time. - YieldGroup paused — the Hub-level flag makes routing skip the YieldGroup. Its balance still counts in
totalAssets(); it is reachable only viareallocate/emergencyReallocate. New deposit/push legs revertYieldGroupPaused. - Resource paused — the YieldGroup-level flag makes the inner queue skip the resource. Balance still counts.
depositResourceinto it reverts;withdrawResourcefrom it is allowed (wind-down). There is no global YieldGroup pause — the only granularity inside a YieldGroup is per-resource.
Cap enforcement
Caps bind at two levels.
YieldGroup level (Hub) — dual cap. Each YieldGroup carries an absolute amount AND a percentage of Hub TVL; the stricter binds:
effectiveCap = (percentageCapBps == 10_000)
? absoluteCap
: min(absoluteCap, percentageCapBps × hubTVL / 10_000)percentageCapBps == 10_000 (100%) is a sentinel that disables the percentage component — required so a fresh Hub at TVL = 0 can take its first deposit (otherwise pct × 0 collapses the cap to zero). absoluteCap == type(uint256).max is rejected; use the sentinel to disable the percentage dimension instead.
Resource level (YieldGroup) — optional, Core & Flux only. Each resource may carry an optional absolute deposit cap limiting how much underlying this YieldGroup holds in that one market, independent of the market's own protocol supply cap:
room = min( protocolHeadroom, resourceCap − ourBalanceInResource )resourceCap == 0means unset/unbounded — only the protocol cap binds. When unset, the balance lookup is skipped, so the feature is zero-cost when unused. (raiseResourceCapto 0 removes a cap;lowerResourceCapto 0 is rejected.)- The protocol cap is global/shared across all depositors; the resource cap is purely about this YieldGroup's position, so actual room can be below
resourceCapif others have filled the market. - Enforced everywhere deposits land: the inner deposit cascade,
maxDeposit(), and resource-targeted reallocate pushes. - YieldGroupFRV has no per-resource cap — FRV capacity comes entirely from the vault's own
maxBorrowCapand its Fundraising-only rule.
Per-transaction withdrawal cap (Hub). maxWithdrawalSize (asset units) bounds every single withdraw/redeem; exceeding it reverts HubWithdrawCapExceeded.
Permissions (asymmetric)
Every privileged call is gated by AccessControlManagerV8 — the role is keccak256(targetContract, roleString), where roleString is the literal function signature shown below. The contracts do not hard-code an operator-vs-governance branch; the asymmetry is entirely a matter of which addresses governance grants each role to. In v1 the Operator is the Venus Core multisig.
| Action class | Governance (VIP) | Operator |
| -------------------------------------------------------------------------------------------- | :----------------: | :----------------: |
| Add / remove YieldGroup or Resource, unpause, set fees, updateResourceAdapter, sweep | ✅ | ❌ |
| Raise caps, emergencyReallocate | ✅ (also Guardian) | ❌ |
| Lower caps, lower per-tx cap, pause, reorder queues | ✅ | ✅ |
| reallocate between YieldGroups / resources | — | ✅ |
| pauseHub | ✅ | ✅ (also Guardian) |
| deposit / mint / withdraw / redeem, accrueFees, views | permissionless | permissionless |
ACM role strings. Hub: addYieldGroup(address,uint256,uint16), removeYieldGroup(address), raiseYieldGroupCap(...), lowerYieldGroupCap(...), setOuterDepositQueue(address[]), setOuterWithdrawQueue(address[]), pauseHub(), unpauseHub(), pauseYieldGroup(address), unpauseYieldGroup(address), raiseMaxWithdrawalSize(uint256), lowerMaxWithdrawalSize(uint256), setManagementFeeBps(uint16), setPerformanceFeeBps(uint16), setRedeemFeeBps(uint16), setFeeRecipient(address), sweep(address,address), reallocate(...), emergencyReallocate(...). Each YieldGroup: addResource(address,address), removeResource(address), updateResourceAdapter(address,address) (governance-only, repoints a resource's delegatecall adapter), setInnerDepositQueue(address[]), setInnerWithdrawQueue(address[]), pauseResource(address), unpauseResource(address), sweep(address,address), and — Core & Flux only — raiseResourceCap(address,uint256), lowerResourceCap(address,uint256), setBlocksPerYear(uint256); and — FRV only — forceRemoveResource(address).
Invariants & safety
- Atomic-or-revert. Deposits, withdrawals, and reallocate fully complete or revert with a named error — no partial fills, no remainder returned.
- Net-zero reallocate.
Σ withdraws == Σ deposits; the Operator can only move funds among registered routes, never in or out. - Stateless adapters. Adapters declare zero storage (only immutables), never
sstore, and call only the resource, itsunderlying(), and itscomptroller(). Mutating entry points revert outside a delegatecall context. - Fault isolation. A YieldGroup with a reverting
totalAssets()view contributes 0 instead of bricking the Hub; it can still be removed as an emergency eviction. - Removal safety.
removeResource/removeYieldGroupgate on a raw receipt-token balance (not underlying value) so a sub-unit residual can't be rounded to zero and orphan tokens. - Inflation defense. ERC-4626 decimals offset (≤ 12) is set per asset at init; the deploy path is responsible for a non-zero value.
- Standard ERC-20 only. Fee-on-transfer, deflationary, and rebasing underlyings are unsupported (deposits fail closed if a token delivers less than requested).
- Upgrade-safe storage. Each contract isolates its state in a
*Storagebase with a reserved__gap; never reorder or prepend fields.
Repository layout
contracts/
Hub/ Hub.sol, HubStorage.sol, lib/HubAdminLib.sol
YieldGroup/
core/ YieldGroupCore + Storage, Adapters/AdapterCoreV1
flux/ YieldGroupFlux + Storage, Adapters/AdapterFlux
FRV/ YieldGroupFRV + Storage, Adapters/AdapterFRV
migrator/ Migrator.sol (immutable Core→Hub one-click migration)
registry/ HubRegistry.sol (chain-level Hub directory, discovery root)
interfaces/ IYieldGroupBase, IResourceAdapter, IHub, IHubRegistry, IYieldGroup*, IMigrator, IVToken, IVBep20, IWBNB, IFToken, IFRVVault, ...
deploy/ Foundry deploy scripts 01–07 + DeployBase + config/<network>.json
tests/foundry/ unit (Hub/, YieldGroup/{core,flux,frv}/, Registry/), Integration/, fork/
deployments/<network>/ committed registry of deployed addresses (source of truth)
lib/ git-submodule dependenciesBuild & test
Pure Foundry — no Hardhat. Node/Yarn is only used for tooling (husky, prettier, solhint, semantic-release). Solidity deps are vendored as git submodules, so clone with submodules first.
git submodule update --init --recursive # or: forge install
forge build --sizes # yarn build
forge test -vvv # yarn test
forge coverage --report lcov # yarn coverage
yarn lint:sol # solhint
forge fmt # formatting- Compiler: solc
0.8.25, optimizer on (200 runs),via_ir = false, EVMcancun. - Profiles:
default(light),ci(fuzz 1024 / invariant 256×32),intense(fuzz 10000 / invariant 1024×64) — select withFOUNDRY_PROFILE. - Submodules:
forge-std,openzeppelin-contracts,openzeppelin-contracts-upgradeable,@venusprotocol/governance-contracts,@venusprotocol/solidity-utilities.
Test tiers: unit (per Hub and per YieldGroup family), integration lifecycle over the real Hub + real adapters against faithful mocks, a stateful invariant fuzz (asserts backed / non-dilutive / conservative; FRV excluded so its state machine doesn't starve coverage), and BSC-mainnet fork tests that reuse the real deploy scripts against live Venus Core, Fluid, and FRV.
Consuming the npm package
Published as @venusprotocol/liquidity-hub. The tarball carries contracts/ and deployments/ only — there is no artifacts/ tree, so ABIs come from the deployment records.
npm i @venusprotocol/liquidity-hub # stable, cut from main
npm i @venusprotocol/liquidity-hub@develop # prerelease, cut from developSolidity. Import the sources directly. OpenZeppelin 4.9+ is pulled in as a dependency. @venusprotocol/governance-contracts is an optional peer: only Hub.sol, HubRegistry.sol and YieldGroupBase.sol need it, since they inherit AccessControlledV8. Interface-only consumers need nothing beyond the install.
import { IHub } from "@venusprotocol/liquidity-hub/contracts/interfaces/IHub.sol";Addresses and ABIs. deployments/<network>_addresses.json is a name → address map; each deployments/<network>/<Name>.json carries address, block, chainId, contract and the deployed abi.
import addresses from "@venusprotocol/liquidity-hub/deployments/bscmainnet_addresses.json";
import hub from "@venusprotocol/liquidity-hub/deployments/bscmainnet/Hub_USDT.json";Deployment
Beacon-proxy model: one UpgradeableBeacon per family per chain (Hub, Core, FRV, Flux), each owned by governance — beacon.upgradeTo upgrades every vault of that family atomically. Per-asset instances are BeaconProxys.
01_DeployHubBeacon 02_DeployCore 03_DeployFRV 04_DeployFlux # once per chain (impl + adapter + beacon)
05_DeployHubRegistry # once per chain (upgradeable singleton, discovery root)
06_DeployAssetVault # per asset, idempotent
07_DeployMigrator # once per chain (immutable, permissionless periphery)Scripts run in numeric order: beacons first, then the registry, then per-asset instances against both. Scripts only deploy + initialize — ACM wiring, addYieldGroup / addResource, and queue configuration are governance (ACM-gated) actions done separately.
Script 05 deploys the HubRegistry (implementation + TransparentUpgradeableProxy behind a governance-owned ProxyAdmin) — the single on-chain discovery root: indexers watch its HubAdded/HubRemoved events, then follow each Hub's own YieldGroupAdded/YieldGroupRemoved. It also starts the registry's Ownable2Step transfer to governance (completed with acceptOwnership() in the onboarding VIP), so the deployer never keeps the owner key that gates setAccessControlManager.
Script 06 deploys a Hub proxy per asset (YieldGroups read IHub.asset() and revert on mismatch), then the three YieldGroup proxies, all initialized through their beacons, and starts each Hub's Ownable2Step transfer to governance (completed with acceptOwnership() in the onboarding VIP — the deployer never keeps the owner key). It refuses assets the registry already serves. Every Hub it deploys is registered by governance via hubRegistry.addHub(hub) — ordered before the Hub's addYieldGroup calls in the same onboarding VIP, so HubAdded precedes every YieldGroupAdded in one atomic transaction. For Hubs configured before registration (e.g. pre-registry deployments), indexers seed the YieldGroup set from state (registeredYieldGroups()), since those events predate HubAdded.
Script 07 deploys the immutable Migrator (no beacon, no proxy); it is permissionless and needs no ACM wiring — a user calls it directly after approving their vToken.
Hub onboarding flow (deploy + one-tx registration VIP)
Onboarding an asset: the deployer EOA runs the scripts in numeric order (no ACM roles needed), then one governance proposal — a single hop — registers and wires the Hub atomically.
PHASE 1 · Deployer EOA (deploy-only, no roles — scripts in numeric order)
─────────────────────────────────────────────────────────────────────────
01_DeployHubBeacon ──► HubBeacon ─┐
02_DeployCore ──► CoreBeacon ├─ beacons (owner → timelock)
03_DeployFRV ──► FRVBeacon ─┘
05_DeployHubRegistry ──► HubRegistry proxy (init: acm; owner → timelock)
│
▼
06_DeployAssetVault
├─ checks registry.hubForAsset(asset) == 0 (no shadow second Hub)
├─ new BeaconProxy ──► Hub (owner = EOA)
├─ Hub.transferOwnership(timelock) (pendingOwner = timelock)
└─ new BeaconProxy ×3 ──► Core / FRV / Flux YieldGroups (bound to Hub)
Hub is LIVE but: unregistered, unwired, owner-pending
│
▼
PHASE 2 · Governance (propose → vote → queue → execute)
────────────────────────────────────────────────────────
VIP proposal = ordered call array; the vips-repo fork simulation gates it
before execution, asserting: HubAdded.logIndex < min(YieldGroupAdded
.logIndex) per Hub, registry.owner() == timelock, Hub.owner() == timelock,
accessControlManager == canonical ACM on the registry and every Hub
(deployer is the LIVE owner until acceptOwnership — this catches an ACM
swapped in the deploy→VIP window), isHub, group count.
GovernorBravo.execute(id) ◄══ ONE TRANSACTION ══►
┌───────────────────────────────────────────────────────────────┐
│ [0] acm.giveCallPermission(registry, "addHub(address)", ...) │
│ [1] acm.giveCallPermission(hub, "addYieldGroup(...)", ...) │
│ [2] registry.acceptOwnership() ──► registry owner = timelock │
│ [3] registry.addHub(hub) ──► HubAdded log i │
│ [4] hub.acceptOwnership() ──► owner = timelock │
│ [5] hub.addYieldGroup(core, …) ──► YieldGroupAdded log i+1 │
│ [6] hub.addYieldGroup(frv, …) ──► YieldGroupAdded log i+2 │
│ [7] addResource / queue configuration … │
│ │
│ any step reverts ──► WHOLE tx reverts (no partial state) │
└───────────────────────────────────────────────────────────────┘
│
▼
PHASE 3 · Indexer (single discovery root)
──────────────────────────────────────────
registry: HubAdded (log i)
│ spawns the Hub data-source template
▼
same tx, higher log index: YieldGroupAdded ×N ── caught in order
│
└─ NORMATIVE: the HubAdded handler MUST state-seed
hub.registeredYieldGroups() — event ordering is a latency
optimization; the state-seed is what guarantees correctness
(and covers Hubs configured pre-registration)
Result: asset ──► hub ──► [core, frv, flux] reconstructed from one addressOrdering is guaranteed by the call-array index (addHub before any addYieldGroup), atomicity by EVM revert semantics (one execute transaction), and both are pinned by the fork simulation before the proposal can execute. On remote chains (Omnichain executor) the per-chain batch executes as one ordered transaction per chain, so the property holds chain-by-chain.
Reconciliation: a Hub is live the moment script 06 runs but discoverable only after its VIP — deploy/ReconcileHubRegistry.s.sol (read-only, unnumbered) diffs the committed deployments/<network>/Hub_*.json set against registry.getHubs() in both directions and reverts on any mismatch (shadow Hubs deployed-but-unregistered, or registered-but-untracked). Run it after every onboarding VIP and periodically from monitoring.
deploy/config/<network>.json holds only public addresses and parameters — acm, governance, fluxLendingResolver, optional wbnb / vBNB (the Migrator's native path; omit or zero to disable it), and per-asset key / asset / name / symbol / decimalsOffset / initialMaxWithdrawalSize / blocksPerYear / feeRecipient. The committed bscmainnet.json ships with 0x0 placeholders for governance/acm/resolver/recipient and must be filled before a real deploy. Secrets (deployer key, RPC URLs, Etherscan key) live only in .env (see .env.example). Deployed addresses are committed under deployments/<network>/<name>.json; broadcast/ is gitignored and is not the source of truth.
Networks: RPC aliases are pre-wired for bsc_mainnet, bsc_testnet, ethereum, sepolia (one Etherscan V2 key across chains). v1 targets BNB Chain.
