Hub
The Hub is the per-asset ERC-4626 entry point of the Liquidity Hub. It accepts a single underlying asset, mints share tokens, and routes capital through a registry of Sources under a governance-set policy. One Hub is deployed per asset as a beacon proxy.
A Hub extends ERC-4626 with a Source registry, dual caps, an asymmetric multi-level pause, Operator reallocation, and management, performance and redeem fees. Registry and queue admin logic is delegatecall-linked into the HubAdminLib library to keep the Hub under the 24 KB bytecode limit; the library executes in the Hub’s storage context.
Inheritance
Section titled “Inheritance”The Hub is built on the Venus upgradeable stack:
ERC4626Upgradeable— OpenZeppelin’s ERC-4626 base (deposit / mint / withdraw / redeem, share accounting).AccessControlledV8— every privileged setter is gated throughAccessControlManagerV8via_checkAccessAllowed("<function signature>"). The role string is the literal signature text (e.g."addYieldGroup(address,uint256,uint16)"), never the 4-byte selector. This base also brings inOwnable2StepUpgradeable, which supplies the ownership surface documented below.ReentrancyGuardUpgradeable— supplies thenonReentrantguard on the asset-moving entry points (see Invariants and safety).HubStorage— isolates all Hub state in a base contract with a reserved__gapfor upgrade safety.IHub— external interface (see Interfaces for the boundary types it shares with YieldGroups).
The declaration order is ERC4626Upgradeable, AccessControlledV8, ReentrancyGuardUpgradeable, HubStorage, IHub and is load-bearing for upgrade safety — never reorder it.
Storage lives entirely in HubStorage; never reorder or prepend its fields. New fields are appended and the __gap is shrunk by the same number of slots.
Operation flows
Section titled “Operation flows”All user-facing mutating operations are atomic-or-revert — they complete in full or revert with a named error. There is no partial fill and no remainder returned.
Deposit
Section titled “Deposit”- User calls
deposit(assets, receiver)(ormint). - The Hub accrues fees, then pulls
assetsfrom the user — so the caller trades against the post-accrual share price. - The Hub snapshots
totalAssets()(which already includes the just-transferred idle) and walks the outer deposit queue. - For each Source: skip if unregistered or paused; compute
capRoom = max(0, effectiveCap − source.totalAssets()); sendmin(remaining, capRoom, source.maxDeposit()). A partial fill is accepted here — the Source places what its inner queue can absorb, refunds the unplaced remainder to the Hub, and returns the placed amount; the cascade routes that remainder to the next Source. A Source whosedepositcall reverts outright is caught, skipped (YieldGroupSkipped), and routing continues.YieldGroupUnderfilledis areallocate-only error, not a deposit-cascade one. - Each Source walks its inner deposit queue, delegatecalling its adapter per resource (respecting any per-resource cap), and credits receipt tokens to the YieldGroup for synchronous families. A Centrifuge allocation instead creates an asynchronous deposit request, with shares collected after settlement; live Centrifuge Sources are funded through targeted reallocation rather than the lender deposit queue.
- The Hub mints share tokens to the receiver.
- If capacity across the whole queue is short, the entire transaction reverts (
HubCapacityExceeded).
Withdrawal
Section titled “Withdrawal”- User calls
withdraw(assets, receiver, owner)(orredeem). - The Hub accrues fees, enforces the per-transaction withdrawal cap (
HubWithdrawCapExceeded), then burns shares fromowner. - The Hub consumes its own idle balance first, then walks the outer withdraw queue (independent of the deposit queue).
- For each Source, the Hub calls
Source.withdraw(amount, hub); the Source uses its own idle balance first, then walks its inner withdraw queue, delegatecalling the adapter’sredeemUnderlying/withdraw. - The 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 transaction reverts (
HubInsufficientLiquidity).
Reallocate
Section titled “Reallocate”Reallocate moves assets between Sources — and, optionally, between specific resources within a Source — without funds entering or leaving the Hub. Both legs share the ReallocateLeg struct. 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 Source, which validates it against its own registry.
- The Operator calls
reallocate(withdraws, deposits). - The Hub accrues fees. While paused (the
emergencyReallocatepath) it charges no fee but still settles every Source’s resources, and that settlement is fail-open: a Source whoseaccrue()reverts is skipped and emitsYieldGroupAccrualFailed, so it cannot block the wind-down of the healthy ones. A leg that then pulls from an unsettled Source still has to deliver its full amount or it reverts. - Pull phase — every withdraw leg runs first:
Source.withdraw(queue) orSource.withdrawResource(targeted). Underlying returns to the Hub as idle. Pulling from a paused resource is allowed (wind-down). Every leg on both sides must carry a non-zeroamount— a zero-amount leg revertsZeroAmountrather than being skipped, which matters when the plan is generated programmatically and a no-op leg is a natural artifact. - The Hub takes a single TVL snapshot after all pulls — the cap reference for every push (valid because a balanced reallocate conserves TVL). While unpaused this is the strict, fail-closed
totalAssets(). While paused (theemergencyReallocatepath) it is a fail-open sum that skips any Source whosetotalAssets()reverts, so a single bricked Source cannot block a rebalance among the healthy ones. - Push phase — each deposit leg checks the Source is registered, unpaused, and within its effective cap, then
Source.deposit(queue) orSource.depositResource(targeted). Depositing into a paused resource reverts. - The Hub enforces
Σ withdraws == Σ deposits(ReallocateImbalancedotherwise). Net-zero invariant.
A targeted leg (non-zero resource) moves the full amount into / out of that one resource or reverts. Setting both legs to the same source performs an intra-Source move (e.g. vUSDT_v1 → vUSDT_v2). emergencyReallocate performs 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.
totalAssets computation
Section titled “totalAssets computation”totalAssets() drives share pricing for every deposit / withdraw (convertToShares / convertToAssets).
Hub.totalAssets() = Σ Source.totalAssets() + Hub idle balance │ └─ Source.totalAssets() = Σ adapter.totalAssets(resource, yieldGroup) + Source idle balancetotalAssets()fails closed. It sums every registered Source with no error isolation, so a Source whosetotalAssets()view reverts makes the whole NAV read revert — and with it everyconvertTo*/preview*view and every deposit / mint / withdraw / redeem /accrueFees/reallocate— until the Source is fixed or evicted. This is deliberate: NAV is the share-price denominator, so silently counting a live Source as 0 would under-pay redeemers and over-credit depositors. Fault isolation covers the other failures:maxWithdrawand the withdraw routing skip a Source whose ownmaxWithdraw()reverts, the withdraw routing also skips one whosewithdrawreverts (emittingYieldGroupSkipped), and the deposit routing skips a Source whosemaxDeposit()view ordepositreverts. The deposit-side view lives onHubLensrather than the Hub, see Sizing a deposit. Recovery is the emergency path ofremoveYieldGroup, which permits removal precisely when the view reverts.- 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.
- 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.
Cap enforcement
Section titled “Cap enforcement”Source level — dual cap. Each Source carries an absolute amount AND a percentage of Hub TVL; the stricter binds:
effectiveCap = (percentageCapBps == 10_000) ? absoluteCap : min(absoluteCap, ceil(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.
Per-transaction withdrawal cap. maxWithdrawalSize (asset units) bounds every single withdraw / redeem so one transaction cannot drain a downstream product’s liquidity; exceeding it reverts HubWithdrawCapExceeded.
The optional per-resource deposit cap (Core, Flux and Spoke) binds one level down, inside the YieldGroup — see Yield Groups.
Sizing a deposit
Section titled “Sizing a deposit”Hub.maxDeposit and Hub.maxMint are not limits. They return type(uint256).max and reflect neither the caps nor the pause state. Sizing a deposit off either one gives no signal that the deposit will revert, exactly as with previewDeposit.
The real ceiling lives on HubLens, a stateless, unowned, non-upgradeable periphery contract. It takes the Hub as a parameter, so a single deployment serves every Hub:
// IHubLens is not in a published @venusprotocol/liquidity-hub release yet.// Declare what you call, or take the full interface from the deployed contract.interface IHubLens { function maxDeposit(address hub) external view returns (uint256 assets);}
uint256 room = IHubLens(lens).maxDeposit(hub); // 0 while the Hub is pausedThe figure is priced against the TVL the deposit itself produces, because the cascade prices the percentage caps there too. Each Source’s room is then trimmed by 0.01% of its mark, to cover the interest the deposit settles on its way in before caps are priced. So hub.deposit(room, receiver) fits in the ordinary case, but it is a margin and not a proof: an accrual larger than 0.01% between the read and the deposit reverts rather than over-filling a cap. maxMint carries that margin twice, once through maxDeposit and once on the share price the mint is charged at.
A floor changes how the figure is produced. When any resource in a Source’s inner deposit queue carries a minimum deposit, today only an FRV vault’s minSupplierDeposit, HubLens stops solving in closed form and dry-runs the cascade instead, offering each floored Source its room candidates largest first and keeping the first amount that routes in full. That answer can sit well below the cap room, and it is 0 when every candidate leaves a leg under its floor, since such a deposit would revert rather than part-fill. depositCapacityBreakdown splits the same solved figure and maxMint runs the solve against the double-trimmed budget. Every FRV vault live on either network sets minSupplierDeposit to 0, so the closed-form path is what answers today.
depositCapacityBreakdown(hub) returns that same total split by the Source that would receive it, in deposit-queue order, so the parts sum to maxDeposit. It answers “where would my deposit land”, not “how much could this Source take on its own”: a Source late in the queue reads 0 whenever the ones ahead of it absorb everything. Both arrays come back empty while the Hub is paused, and a Source that is paused, unregistered, view-reverting, or registered but absent from the deposit queue is left out of them entirely.
spotAPYBps(hub) is on the same contract: a blended spot APY in BPS, the weighted average of each Source’s own spotAPYBps() over the Hub’s whole totalAssets(). It is not a sum. Idle assets are counted at 0% rather than dropped, whether they sit on the Hub or on a Source, so the figure is what a lender actually earns. A Source whose spotAPYBps() reverts contributes 0.
If a deposit is sent anyway and does not fit, it reverts HubCapacityExceeded(uint256 requested, uint256 maxAvailable). On a deposit the second argument is the amount the failed attempt routed before reverting. It is not a safe size to retry with. The cascade priced every percentage cap against a TVL that included the whole attempted deposit, so a smaller retry lowers that TVL and the caps with it, and can revert again. For example, ignoring fees and interest: the Hub’s NAV is 100, and the only Source that can take deposits holds 40 under a 50% cap. Depositing 100 prices that cap at 100 and places 60, so it reverts with (100, 60). Retrying 60 prices the cap at 80 and places only 40, so it reverts too; 20 succeeds. Size a deposit with HubLens and simulate the transaction rather than retrying with the reported figure.
HubLens inherits the Hub’s fault split rather than escaping it. A Source whose quantity views revert contributes 0 and the rest of the figure still comes back; a Source whose totalAssets() reverts halts every HubLens function too, because Hub.totalAssets() halts first and a figure priced against a wrong NAV is worse than no figure.
Nothing on chain points at HubLens: it is not registered in the HubRegistry and no Hub exposes its address. Take it from Deployed contracts.
Three fee types. Management and performance fees are minted as dilution shares to a single feeRecipient (address(0) disables minting); the redeem fee is taken from the withdrawing lender. Accrual is idempotent within a block and runs before every deposit / withdraw / reallocate. The management and performance rates are capped at MAX_FEE_BPS (50%); the redeem fee is capped at MAX_REDEEM_FEE_BPS (5%).
-
Management fee — linear time proration:
totalAssets × bps × Δt / (10_000 × 365 days). A single accrual is capped atMAX_MGMT_FEE_PER_ACCRUAL_BPS(100%) of TVL. When the cap binds, the accrual cursor advances only over the window that produced the cap, so the remaining elapsed time defers to the next accrual — but the clamped fee itself is not minted: a separatetotalFee < totalAssets()guard skips any accrual whose combined management + performance fee would reach 100% of TVL. At the launch rate of0neither path is reachable. -
Performance fee — charged only on gains in price-per-share above a high-water mark (
totalAssets × 1e18 / totalSupply). The mark moves on three paths: it ratchets up to PPS on a new high during accrual (independent of whether a fee is minted, so a later rate increase cannot retroactively claim past gains); it is incremented by the retained exit-fee PPS uplift onwithdraw/redeem, so the redeem fee is not taxed as performance; and it is re-anchored to the entry PPS — possibly downward — on a refill from an empty vault, so a new cohort is neither shielded by nor charged for a prior cohort’s high. It is therefore non-decreasing for any continuously-held cohort, but not monotonic across a full emptying. -
Redeem fee — an exit fee in BPS applied to
withdraw/redeem, charged directly to the withdrawing lender rather than diluting the pool. Capped atMAX_REDEEM_FEE_BPS(500= 5%).
v1 launches with all three set to 0; the machinery exists for governance to enable them later.
Multi-level pause
Section titled “Multi-level pause”Three independent scopes — a broader scope blocks everything beneath it; siblings keep operating. The Operator and Guardian can pause at every scope and unpause individual resources. Only governance can unpause a Hub or YieldGroup; unpausing a resource does not clear either broader pause.
- Hub paused — all deposits / withdrawals / mints / redeems,
reallocate, and fee accrual and the fee setters are blocked;emergencyReallocateandsweepstay callable; views stay readable, thoughmaxWithdraw/maxRedeemreturn0andHubLens.maxDepositreports0(maxDeposit/maxMinton the Hub are not overridden, so they keep returningtype(uint256).maxeven while paused). Underlying products keep operating. Only the time-based management fee is excluded from the pause window —unpauseHubadvances the accrual cursor by the pause duration, so LPs are not charged rent for frozen time. The performance fee is not excluded: the high-water mark is deliberately left unchanged, so per-share gains the underlying products earn during the freeze are charged on the first post-resume accrual. - Source paused — the Hub-level flag makes routing skip the Source silently in both directions: a user
deposit/mintcascades to the next Source and a userwithdraw/redeempulls from elsewhere, with noYieldGroupPausedrevert (onlyHubCapacityExceeded/HubInsufficientLiquidityfire if the rest of the queue cannot cover the amount). Its balance still counts intotalAssets()but is excluded frommaxWithdraw()and from theHubLensdeposit figure. Funds remain reachable viareallocate/emergencyReallocate— a pull leg from a paused Source is allowed, while a push leg into one revertsYieldGroupPaused. - Resource paused — set on the YieldGroup, not the Hub; see Yield Groups.
Automatic pause on a NAV break
Section titled “Automatic pause on a NAV break”For a resource whose value is published by a counterparty, such as a Centrifuge vault, a trusted keeper (set with setTrustedKeeper, and separate from the Centrifuge Keeper role) can pause the whole Hub when that value moves too far from the centre of the resource’s NAV band. The check lives in HubNavDeviationSentinel, a contract of its own rather than part of DeviationSentinel, and the pause goes through EBrake, the same path as the existing price-deviation check.
- Governance sets a threshold per (YieldGroup, resource) pair with
setHubNavConfig, in basis points of the band’s anchor, measured above and below the band’s centre (a pause is due pastcentre ± anchor × bps / 10_000), and switches monitoring on separately withsetNavMonitoringEnabled. A threshold of zero leaves that side unwatched; both at zero is rejected. - The same call takes a
minPauseGapin asset units, which has to be above zero. A band’s anchor is zero from a resource’s first deposit until its first re-anchor, which puts both trip points on the centre, so without that floor a routine gap would pause the Hub. - Anyone can read whether a pause is due with
checkNavGuardDeviation(yieldGroup, resource), which returns a status rather than reverting. The keeper acts withhandleNavGuardDeviation, which reverts when no action is due. - The Hub is paused, not only the resource, because the Hub’s
totalAssets()still counts a paused resource, so the wrong value would stay in the share price. - The pause only tightens. Unpausing stays a governance action.
Permissions
Section titled “Permissions”Every ACM-gated call is authorised through AccessControlManagerV8 — the role is keccak256(abi.encodePacked(targetContract, roleString)), where roleString is the literal function signature. The ownership functions (transferOwnership / acceptOwnership / setAccessControlManager) sit outside the ACM and are owner-only; see Ownership and access control. 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.
The main role holders are governance (a VIP, behind the Normal Timelock), the Operator (a Venus operations multisig acting as the routine keeper, distinct from the Guardian), and the Guardian (a multisig that acts with no timelock delay, so it can contain an incident immediately). The Guardian can pause, perform emergency reallocation and replace resource adapters. It can also unpause resources, but cannot unpause a Hub or YieldGroup. The Centrifuge Sources add a fourth holder, a Keeper limited to the four claim functions. All role holders are addresses granted by proposal, not roles baked into the bytecode; the granted addresses are listed under Deployment.
| Action class | Governance (VIP) | Operator | Guardian |
|---|---|---|---|
| Add / remove YieldGroup, add / remove a resource | ✅ | ❌ | ❌ |
Re-adapter a resource (updateResourceAdapter) | ✅ | ❌ | ✅ |
Set fees and fee recipient, sweep, setBlocksPerYear | ✅ | ❌ | ❌ |
| Raise the per-transaction withdrawal cap | ✅ | ❌ | ❌ |
| Unpause a Hub or YieldGroup | ✅ | ❌ | ❌ |
| Unpause a resource | ✅ | ✅ | ✅ |
| Raise or lower YieldGroup caps and per-resource caps | ✅ | ✅ | ❌ |
| Lower the per-transaction withdrawal cap, reorder outer / inner queues | ✅ | ✅ | ❌ |
| Pause — Hub, YieldGroup, or resource | ✅ | ✅ | ✅ |
emergencyReallocate (works while paused) | ✅ | ❌ | ✅ |
forceRemoveResource (FRV and Centrifuge) | ✅ | ❌ | ✅ |
reallocate between YieldGroups / resources | ❌ | ✅ | ❌ |
addHub / removeHub on the HubRegistry | ✅ | ❌ | ❌ |
deposit / mint / withdraw / redeem, accrueFees, views | permissionless | permissionless | permissionless |
Two rows are worth reading carefully, because both depart from a naive loosening-vs-tightening split:
raiseYieldGroupCapandraiseResourceCapare Operator-accessible. They let the Operator open headroom immediately before areallocatewithout waiting for a governance round. The Operator can also unpause individual resources. Raising the per-transaction withdrawal cap and unpausing a Hub or YieldGroup remain governance-only.- Governance does not hold
reallocate. It is Operator-only. Governance’s equivalent lever isemergencyReallocate, which has the same net-zero semantics and additionally works while the Hub is paused.
The Guardian holds pause at all three levels, emergencyReallocate, forceRemoveResource, unpauseResource and updateResourceAdapter. VIP-661 granted resource unpause and adapter replacement to both the Operator and Guardian on the existing Sources; VIP-666 revoked adapter replacement from the Operator. VIP-669 granted the same resource-unpause and adapter-replacement split on the new Spoke Sources. Resource unpause does not clear a Hub-level or YieldGroup-level pause.
Nothing in the contracts hard-codes these role holders. The split is entirely a matter of which addresses governance grants each role to — the asymmetry is a deployment decision, not a code branch.
Invariants and safety
Section titled “Invariants and 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. - Reentrancy-guarded value paths. Every entry point that moves assets is
nonReentrant(ReentrancyGuardUpgradeable) —deposit/mint/withdraw/redeem,reallocate,emergencyReallocate,accrueFees,sweepon the Hub, anddeposit/withdraw/depositResource/withdrawResource/sweepon each YieldGroup — since each makes external calls into Sources, delegatecalls into adapters, and reaches into the underlying Core / Flux / FRV protocols. The ACM-gated admin setters and theonlyHubaccrue()poke are not individually guarded; they rely on ACM gating andonlyHubinstead. - Fault isolation on the quantity gates, fail-closed on price. A Source whose
maxWithdraw()view reverts contributes 0 tomaxWithdraw()and is skipped by the withdraw routing, as is one whosewithdrawreverts; one whosemaxDeposit()view ordepositreverts is skipped by the deposit routing. A Source whosetotalAssets()view reverts is not isolated:Hub.totalAssets()is deliberately fail-closed and reverts with it, halting share pricing and every path that reads it rather than understating NAV. Such a Source can still be removed as an emergency eviction:removeYieldGroupcatches the reverting view and permits removal. - Removal safety.
removeYieldGroupgates on the Source’s balance so a funded Source can’t be silently dropped; YieldGroups apply the same gate on a raw receipt-token balance. - Inflation defense. The ERC-4626 decimals offset is set per asset at
initializeand enforced on-chain in both directions:0and anything aboveMAX_DECIMALS_OFFSET(12) revertInvalidDecimalsOffset, so a Hub can never be deployed with the offset disabled. A non-zero offset is required for every asset regardless of its decimals — offset 0 would let a donation attack zero a first depositor’s shares. - 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. State is isolated in
HubStoragewith a reserved__gap; fields are never reordered or prepended.
Constants
Section titled “Constants”| Constant | Value | Description |
|---|---|---|
BPS_DENOMINATOR | 10_000 | Basis-point denominator (100%) |
EXP_SCALE | 1e18 | Fixed-point mantissa |
MAX_DECIMALS_OFFSET | 12 | Maximum ERC-4626 inflation-defense decimals offset |
MAX_FEE_BPS | 5_000 | Maximum management or performance fee rate (50%) |
MAX_MGMT_FEE_PER_ACCRUAL_BPS | 10_000 | Cap on one management-fee accrual (100% of TVL); remainder defers |
MAX_REDEEM_FEE_BPS | 500 | Maximum redeem (exit) fee rate (5%) |
SECONDS_PER_YEAR | 365 days | Seconds per year, used for management-fee time proration |
State variables
Section titled “State variables”Defined in HubStorage:
| Variable | Type | Description |
|---|---|---|
_hubPaused | bool | True while the Hub is paused |
_decimalsOffsetStored | uint8 | ERC-4626 inflation-defense offset, set once at init |
_maxWithdrawalSize | uint256 | Per-transaction withdraw / redeem cap, in asset units |
_registeredYieldGroups | address[] | Canonical set of registered Sources |
_yieldGroupIndex | mapping(address => uint256) | 1-indexed position in the registry (0 = not registered) |
_yieldGroups | mapping(address => YieldGroupConfig) | Per-Source caps, pause, and registered flag |
_outerDepositQueue | address[] | Deposit cascading order |
_outerWithdrawQueue | address[] | Withdraw pulling order (independent of deposit queue) |
_managementFeeBps | uint16 | Management fee rate in BPS |
_performanceFeeBps | uint16 | Performance fee rate in BPS |
_feeRecipient | address | Recipient of newly-minted fee shares |
_redeemFeeBps | uint16 | Redeem (exit) fee rate in BPS; retained in the vault as a price-per-share uplift, not paid to _feeRecipient |
_highWaterMarkPerShare | uint256 | Performance HWM (totalAssets × 1e18 / totalSupply) |
_lastFeeAccrualTimestamp | uint64 | Timestamp of last fee accrual |
_pauseStart | uint64 | Timestamp the current pause began; management-fee accrual skips the window |
_lastFeeAccrualBlock | uint64 | Block of last fee accrual; makes accrual idempotent per block |
The rows are listed in HubStorage declaration order. _managementFeeBps, _performanceFeeBps, _feeRecipient and _redeemFeeBps share one packed slot.
Structs
Section titled “Structs”YieldGroupConfig
Section titled “YieldGroupConfig”struct YieldGroupConfig { uint256 absoluteCap; // hard cap on the Source's holdings, in asset units uint16 percentageCapBps; // cap as a fraction of totalAssets(); 10_000 disables this component bool paused; // when true, routing skips this Source; balance still counts bool registered; // true iff present in registeredYieldGroups()}ReallocateLeg
Section titled “ReallocateLeg”struct ReallocateLeg { address yieldGroup; // YieldGroup to act on (must be registered) address resource; // specific resource, or address(0) to route through the inner queue uint256 amount; // asset units to move on this leg}The same shape is reused for both the withdraw (pull) and deposit (push) arrays of reallocate. resource == address(0) cascades through the Source’s inner queue (idle-first on a pull, capacity-order on a push); a non-zero resource moves the full amount against that one market / vault and bypasses the queue.
Solidity API
Section titled “Solidity API”ERC-4626 user functions
Section titled “ERC-4626 user functions”Permissionless and atomic-or-revert. Each accrues fees before pricing.
deposit(uint256 assets, address receiver)— depositassets, route through the outer deposit queue, mint shares toreceiver. RevertsHubCapacityExceeded/HubPaused.mint(uint256 shares, address receiver)— mint exactlyshares, depositing the required assets.withdraw(uint256 assets, address receiver, address owner)— burn shares fromowner, pullassets(Hub idle first, then the outer withdraw queue), deliver toreceiver. Size the call againstmaxWithdraw(owner): it is already clamped to the owner’s balance, aggregate liquidity andmaxWithdrawalSize(), so an over-sized request trips the inherited OpenZeppelin guard and reverts with the string error"ERC4626: withdraw more than max"rather thanHubWithdrawCapExceededorHubInsufficientLiquidity. Those two named errors remain reachable on the routing path.redeem(uint256 shares, address receiver, address owner)— burn exactlysharesand deliver the corresponding assets.totalAssets()—Σ Source.totalAssets() + Hub idle balance.
Two consent-gated variants take an extra bytes32 consentHash and emit it in the same transaction as the deposit, so an integrator can prove the supplier acknowledged a specific set of terms. The hash is event-only — no storage is written and there is no on-chain getter, so proving acknowledgement is a log query, not a contract read. They are otherwise identical to their plain counterparts.
depositWithConsent(uint256 assets, address receiver, bytes32 consentHash)— emitsConsentRecordedwhenconsentHashis non-zero, then deposits. Passingbytes32(0)skips the emit and behaves exactly likedeposit.mintWithConsent(uint256 shares, address receiver, bytes32 consentHash)— the same, formint.
Not all the standard ERC-4626 views behave identically to the OpenZeppelin base:
convertToShares/convertToAssets/previewDeposit/previewMintare inherited unmodified. They are pure share math and reflect none of the caps, liquidity, pause state or fees — sizing a deposit offpreviewDepositgives no signal that the deposit will revert.maxDeposit/maxMintare inherited unmodified and returntype(uint256).max, ERC-4626’s “no limit” value. The Hub does not compute a deposit ceiling: OpenZeppelin’sdeposit/mintcall these two views on every call, and solving every Source’s dual cap against the TVL the deposit itself creates does not belong on that path. Caps are still enforced, by the routing cascade. Read the real figure fromHubLens, see Sizing a deposit.maxWithdraw/maxRedeemare overridden and do reflect aggregate liquidity, the per-transaction withdrawal cap and the redeem fee. Both return0while the Hub is paused.previewWithdraw/previewRedeemare overridden to account for the redeem (exit) fee, so they diverge fromconvertToAssets/convertToShareswhenever that fee is non-zero.
Source registry (governance)
Section titled “Source registry (governance)”addYieldGroup(address source, uint256 absoluteCap, uint16 percentageCapBps)— register a Source. Validatessource.asset() == asset()(YieldGroupAssetMismatch) and the cap pair (InvalidCap);YieldGroupAlreadyRegisteredif present. EmitsYieldGroupAdded.removeYieldGroup(address source)— remove a Source.YieldGroupHasBalanceif it still custodies a balance. EmitsYieldGroupRemoved.raiseYieldGroupCap(address source, uint256 absoluteCap, uint16 percentageCapBps)— loosen caps. Both dimensions must be ≥ their current values and at least one must strictly increase; a full no-op revertsNotIncreasing. Raising one dimension while lowering the other is rejected — that needs two calls. Also revertsYieldGroupNotRegisteredandInvalidCap. Operator-accessible. EmitsYieldGroupCapRaised.lowerYieldGroupCap(address source, uint256 absoluteCap, uint16 percentageCapBps)— tighten caps. Both dimensions must be ≤ their current values and at least one must strictly decrease; a full no-op revertsNotDecreasing. Also revertsYieldGroupNotRegisteredandInvalidCap. Operator-accessible. EmitsYieldGroupCapLowered.
Outer queues (Operator)
Section titled “Outer queues (Operator)”setOuterDepositQueue(address[] queue)— replace the deposit routing order; every entry must be a registered Source (YieldGroupNotRegisteredotherwise) with no duplicates (InvalidQueue). EmitsOuterDepositQueueSet.setOuterWithdrawQueue(address[] queue)— replace the withdraw routing order under the same registration / duplicate validation; additionally, dropping a Source that still holds a balance revertsWithdrawQueueOmitsFundedYieldGroup. The funded test is fail-closed: a Source whosetotalAssets()view reverts counts as funded, so a bricked Source can never be dropped from the withdraw queue — the mirror ofremoveYieldGroup, which deliberately permits evicting it from the registry in exactly that case. Note also thattotalAssets()counts a Source’s idle balance, so a 1-wei donation to an otherwise-empty Source is enough to pin it in the queue. EmitsOuterWithdrawQueueSet.
Reallocate (Operator)
Section titled “Reallocate (Operator)”reallocate(ReallocateLeg[] withdraws, ReallocateLeg[] deposits)— atomic net-zero rebalance;ReallocateImbalancedif sums differ. EmitsWithdrawRouted/DepositRouted.emergencyReallocate(ReallocateLeg[] withdraws, ReallocateLeg[] deposits)— same semantics, callable while paused, governance-only role.
Pause (asymmetric)
Section titled “Pause (asymmetric)”pauseHub()/unpauseHub()— pause is Operator-accessible (also Guardian); unpause is governance-only. EmitsHubPauseToggled.pauseYieldGroup(address source)/unpauseYieldGroup(address source)— pause is Operator-accessible; unpause is governance-only. EmitsYieldGroupPauseToggled.
Per-transaction withdrawal cap
Section titled “Per-transaction withdrawal cap”raiseMaxWithdrawalSize(uint256 newSize)— governance-only;NotIncreasingif not strictly higher. EmitsMaxWithdrawalSizeRaised.lowerMaxWithdrawalSize(uint256 newSize)— Operator-accessible;NotDecreasingif not strictly lower, andZeroAmountifnewSize == 0— the cap can never be set to zero, so usepauseHub()to stop withdrawals entirely. EmitsMaxWithdrawalSizeLowered.
setManagementFeeBps(uint16 bps)/setPerformanceFeeBps(uint16 bps)— set fee rates; pending fees accrue at the OLD rate first.InvalidFeeBpsaboveMAX_FEE_BPS. EmitsManagementFeeBpsSet/PerformanceFeeBpsSet.setFeeRecipient(address recipient)— set the recipient; pending fees mint to the OLD recipient first;address(0)disables minting. EmitsFeeRecipientSet.accrueFees()— permissionless poke; idempotent within a block. EmitsFeesAccrued/HighWaterMarkUpdated.setRedeemFeeBps(uint16 bps)— set the exit fee charged onwithdraw/redeem.InvalidFeeBpsaboveMAX_REDEEM_FEE_BPS. EmitsRedeemFeeBpsSet.
sweep(address token, address to)— forward the Hub’s full balance of an arbitrary ERC-20 toto.SweepProtectedAssetiftoken == asset()— the underlying can never be swept. Governance-only. EmitsSwept.
Ownership and access control
Section titled “Ownership and access control”The Hub is Ownable2Step, so a transfer only completes when the incoming owner accepts it. Ownership
is separate from the ACM roles above: the owner replaces the ACM itself, while the ACM decides who may
call everything else. Both sit with governance in v1.
transferOwnership(address newOwner)— nominate a new owner. EmitsOwnershipTransferStarted.acceptOwnership()— the nominee claims it. EmitsOwnershipTransferred. The onboarding proposal calls this on each Hub.pendingOwner()/owner()— the nominee and the current owner.setAccessControlManager(address acm)— point the Hub at a differentAccessControlManagerV8. Owner-only. EmitsNewAccessControlManager.accessControlManager()— the ACM currently in force.
yieldGroupConfig(address source)→YieldGroupConfig— full stored config for a Source.yieldGroupEffectiveCap(address source)→uint256— live effective cap (the stricter of the dual cap).registeredYieldGroups()→address[]— the Source registry.outerDepositQueue()/outerWithdrawQueue()→address[]— the current routing orders.maxWithdrawalSize()→uint256— per-transaction withdraw cap.hubPaused()→bool— Hub-level pause state.feeRecipient()→address— current fee recipient.feeBps()→(uint16 managementBps, uint16 performanceBps)— current dilution fee rates.redeemFeeBps()→uint16— current exit fee rate.highWaterMarkPerShare()→uint256— performance HWM in share-price units.
The Hub exposes no deposit-capacity view. That figure comes from HubLens, see Sizing a deposit.
Events
Section titled “Events”| Event | Parameters | Description |
|---|---|---|
YieldGroupAdded | yieldGroup (indexed), absoluteCap, percentageCapBps | Source registered |
YieldGroupRemoved | yieldGroup (indexed) | Source removed |
YieldGroupCapRaised | yieldGroup (indexed), absoluteCap, percentageCapBps | Source caps loosened |
YieldGroupCapLowered | yieldGroup (indexed), absoluteCap, percentageCapBps | Source caps tightened |
YieldGroupPauseToggled | yieldGroup (indexed), paused | Source pause flag flipped |
OuterDepositQueueSet | queue | Deposit routing order replaced |
OuterWithdrawQueueSet | queue | Withdraw routing order replaced |
HubPauseToggled | paused | Hub pause flag flipped |
MaxWithdrawalSizeRaised | oldSize, newSize | Per-tx withdraw cap raised |
MaxWithdrawalSizeLowered | oldSize, newSize | Per-tx withdraw cap lowered |
DepositRouted | yieldGroup (indexed), amount | Assets deposited into a Source |
WithdrawRouted | yieldGroup (indexed), amount | Assets withdrawn from a Source |
ManagementFeeBpsSet | oldBps, newBps | Management fee rate changed |
PerformanceFeeBpsSet | oldBps, newBps | Performance fee rate changed |
FeeRecipientSet | oldRecipient, newRecipient | Fee recipient changed |
FeesAccrued | managementShares, performanceShares, totalShares | Fee shares minted in an accrual |
HighWaterMarkUpdated | oldHwm, newHwm | Performance HWM changed — ratcheted up on a new PPS high, incremented by the retained exit-fee uplift on withdraw, or re-anchored (possibly downward) to the entry PPS on a refill from an empty vault. Do not assume newHwm >= oldHwm |
RedeemFeeBpsSet | oldBps, newBps | Redeem (exit) fee rate changed |
YieldGroupSkipped | yieldGroup, isDeposit | A YieldGroup’s mutating deposit / withdraw call reverted and routing routed around it. A healthy system emits this zero times — paused, at-cap and dry YieldGroups are skipped silently with no event |
YieldGroupAccrualFailed | yieldGroup (indexed) | An emergencyReallocate could not settle a YieldGroup’s resources because its accrue() reverted, and the call continued without it. A healthy system emits this zero times |
ConsentRecorded | supplier, receiver, consentHash | A consent-gated deposit or mint recorded its hash |
Swept | token, to, amount | Stray-token balance rescued |
OwnershipTransferStarted | previousOwner, newOwner | Ownership transfer nominated |
OwnershipTransferred | previousOwner, newOwner | Ownership transfer accepted |
NewAccessControlManager | oldAccessControlManager, newAccessControlManager | The Hub was pointed at a different ACM |
Errors
Section titled “Errors”| Error | When |
|---|---|
Unauthorized | Caller lacked the ACM role for the called function |
HubCapacityExceeded | Deposit / mint exceeds aggregate spare cap across unpaused Sources, or a reallocate / emergencyReallocate push leg exceeds the destination Source’s own effective cap. Carries (requested, maxAvailable): on a deposit, maxAvailable is the amount the failed attempt routed before reverting, which is not a safe retry size (see Sizing a deposit); on a push leg, it is the destination’s remaining cap room |
HubInsufficientLiquidity | Withdraw / redeem exceeds liquid funds (withdraw queue + Hub idle) |
YieldGroupUnderfilled | A Source delivered an amount not exactly equal to the amount requested — under- or over-delivery, since the check is != — on a user withdraw / redeem cascade step or on a reallocate leg. Not raised by the deposit cascade, which accepts partial fills |
HubWithdrawCapExceeded | Withdraw / redeem exceeds maxWithdrawalSize() |
HubPaused | A user-facing mutating call is attempted while the Hub is paused |
YieldGroupPaused | A reallocate / emergencyReallocate push leg targets a paused Source. User deposits never raise this — the cascade skips paused Sources silently and surfaces HubCapacityExceeded if the amount cannot be placed |
YieldGroupNotRegistered | Operation targets a Source not in the registry |
YieldGroupAlreadyRegistered | addYieldGroup on an already-registered Source |
YieldGroupHasBalance | removeYieldGroup while the Source still holds non-zero resource-backed value; its idle asset() balance is netted out, so a Source holding only a dust donation is removable. If the Source’s totalAssets() reverts, removal is permitted anyway as an emergency eviction |
YieldGroupAssetMismatch | A Source’s asset() does not match the Hub’s underlying |
InvalidCap | Cap pair is invalid (e.g. percentage > 100%, or absoluteCap == uint256.max) |
InvalidQueue | A queue contains a duplicate entry (an unregistered entry reverts YieldGroupNotRegistered instead) |
ReallocateImbalanced | reallocate withdraw and deposit sums do not match |
WithdrawQueueOmitsFundedYieldGroup | A withdraw-queue replacement drops a funded Source. A Source whose totalAssets() reverts is treated as funded, so it cannot be dropped |
SweepProtectedAsset | sweep called with token == asset() |
ZeroAddress / ZeroAmount | A required non-zero address / amount parameter was zero. ZeroAmount also fires on a zero-amount reallocate / emergencyReallocate leg (either side), and on any attempt to set maxWithdrawalSize to 0 |
InvalidDecimalsOffset | decimalsOffset_ at init was 0 or exceeded MAX_DECIMALS_OFFSET (12) |
InvalidFeeBps | A management / performance rate exceeded MAX_FEE_BPS (5000 = 50%), or the redeem rate exceeded MAX_REDEEM_FEE_BPS (500 = 5%) |
NotIncreasing / NotDecreasing | raiseMaxWithdrawalSize / lowerMaxWithdrawalSize was not strictly increasing / decreasing. For raiseYieldGroupCap / lowerYieldGroupCap, both dimensions must move in the requested direction (or stay equal) and at least one must move strictly — mixing a raise of one with a lower of the other reverts |
ACM role strings
Section titled “ACM role strings”The role gating each privileged function is keccak256(hubAddress, roleString) where roleString is the literal signature. The ACM binds a role per contract address, so each per-asset Hub carries its own copy of all 19.
addYieldGroup(address,uint256,uint16), removeYieldGroup(address), raiseYieldGroupCap(address,uint256,uint16), lowerYieldGroupCap(address,uint256,uint16), setOuterDepositQueue(address[]), setOuterWithdrawQueue(address[]), pauseHub(), unpauseHub(), pauseYieldGroup(address), unpauseYieldGroup(address), raiseMaxWithdrawalSize(uint256), lowerMaxWithdrawalSize(uint256), setManagementFeeBps(uint16), setPerformanceFeeBps(uint16), setFeeRecipient(address), setRedeemFeeBps(uint16), sweep(address,address), reallocate((address,address,uint256)[],(address,address,uint256)[]), emergencyReallocate((address,address,uint256)[],(address,address,uint256)[]).
Note the two ReallocateLeg[] signatures expand the struct to its tuple form (address,address,uint256)[] — the ACM role string must use the expanded form, not ReallocateLeg[].
The HubRegistry carries two of its own: addHub(address) and removeHub(address). Per-YieldGroup role strings are listed in Yield Groups.