npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@venusprotocol/liquidity-hub

v1.0.0

Published

Per-asset ERC-4626 allocator vault

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)"]:::resource

Key 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 IYieldGroupBase interface 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)"]
  1. User calls Hub.deposit(assets, receiver) (or mint).
  2. Hub accrues fees, then pulls assets from the user — so the caller trades against the post-accrual share price.
  3. Hub snapshots totalAssets() (already includes the just-transferred idle) and walks the outer deposit queue.
  4. For each YieldGroup: skip if unregistered or paused; compute capRoom = max(0, effectiveCap − yieldGroup.totalAssets()); send min(remaining, capRoom, yieldGroup.maxDeposit()); revert YieldGroupUnderfilled if the YieldGroup places less.
  5. Each YieldGroup walks its inner deposit queue, delegatecalling its adapter per resource (respecting any per-resource cap), and mints receipt tokens into the YieldGroup.
  6. Hub mints share tokens to the receiver.
  7. 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))
  1. User calls Hub.withdraw(assets, receiver, owner) (or redeem).
  2. Hub enforces the per-tx withdrawal cap (HubWithdrawCapExceeded), accrues fees, then burns shares from owner.
  3. Hub consumes its own idle balance first, then walks the outer withdraw queue (independent of the deposit queue).
  4. Hub calls YieldGroup.withdraw(amount, hub); the YieldGroup uses its idle balance first, then walks its inner withdraw queue, delegatecalling the adapter's redeemUnderlying / withdraw.
  5. Hub measures the delivered balance delta (rather than trusting a return value) and reverts YieldGroupUnderfilled on any shortfall.
  6. Underlying flows Resource → YieldGroup → Hub → receiver.
  7. 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 queue

The 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).

  1. Operator calls Hub.reallocate(withdraws[], deposits[]).
  2. Hub accrues fees (skipped while paused).
  3. Pull phase: every withdraw leg runs first — YieldGroup.withdraw(amount, hub) (queue) or YieldGroup.withdrawResource(resource, amount, hub) (targeted). Underlying returns to the Hub as idle. Pulling from a paused resource is allowed (wind-down).
  4. Hub takes a single totalAssets() snapshot after all pulls (the cap reference for every push — valid because a balanced reallocate conserves TVL).
  5. Push phase: each deposit leg checks the YieldGroup is registered, unpaused, and within its effective cap, then YieldGroup.deposit(amount) (queue) or YieldGroup.depositResource(resource, amount) (targeted). Depositing into a paused resource reverts.
  6. Hub enforces Σ withdraws == Σ deposits (ReallocateImbalanced otherwise). Net-zero invariant.
  7. The Operator can only move funds among already-registered YieldGroups/resources — it can never create new routes.

emergencyReallocate is 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 (maxWithdraw is 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 (maxDeposit is 0 in every other state). Each vault has a minSupplierDeposit floor: a sub-floor cascade leg is skipped to the next vault, a sub-floor targeted depositResource reverts 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, or Liquidated — capital is locked through Lock and PendingSettlement. Matured adds the fixed-rate yield; Failed / Liquidated can return less than principal, which marks down both maxWithdraw and totalAssets.
  • Valuation accrues linearly — no maturity cliff. While the vault sits in Lock the position is marked at principal + 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 stamps lockStartTime at 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. netInterest is full-term interest minus the floored reserve cut (mirroring the vault's own settlement math), so the Lock-end mark equals the full-repayment settlementAmount exactly (continuity, not a discontinuity). PendingSettlement / SettlementDeadlineExceeded hold flat at principal + full netInterest; terminal states price directly off previewRedeem.
  • A defaulting loan is marked above realizable value until it settles. Holding the full principal + netInterest mark through PendingSettlement / SettlementDeadlineExceeded means 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 by openEndTime, the vault confiscates the institution's margin and refunds it to suppliers pro-rata in the collateral asset (a token distinct from the Hub asset()) 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 in totalAssets(), does not move share price, and is recoverable only through the governance sweep. 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's MarginConfiscated / MarginCompensationClaimed events, 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 leave Fundraising; a permissionless updateVaultState() 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 whose totalAssets() view reverts is not isolated here — it halts share math rather than silently dropping that YieldGroup's funds from NAV (which would understate the price, under-pay redeemers and over-credit depositors until the value reappeared). The quantity 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 in withdraw/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:

  1. 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.
  2. 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/catch the 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/catch did 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 at MAX_REDEEM_FEE_BPS = 5%) — a per-withdraw/redeem fee carved out of the gross and retained in the vault (not routed out, not minted to feeRecipient), 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; emergencyReallocate and sweep stay 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 via reallocate / emergencyReallocate. New deposit/push legs revert YieldGroupPaused.
  • Resource paused — the YieldGroup-level flag makes the inner queue skip the resource. Balance still counts. depositResource into it reverts; withdrawResource from 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 == 0 means unset/unbounded — only the protocol cap binds. When unset, the balance lookup is skipped, so the feature is zero-cost when unused. (raiseResourceCap to 0 removes a cap; lowerResourceCap to 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 resourceCap if 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 maxBorrowCap and 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, its underlying(), and its comptroller(). 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 / removeYieldGroup gate on a raw receipt-token balance (not underlying value) so a sub-unit residual can't be rounded to zero and orphan tokens.
  • 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 *Storage base 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 dependencies

Build & 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, EVM cancun.
  • Profiles: default (light), ci (fuzz 1024 / invariant 256×32), intense (fuzz 10000 / invariant 1024×64) — select with FOUNDRY_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 develop

Solidity. 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 address

Ordering 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.