Crypto Training

AAVE v4: Hub and Spoke, Dual-Stream Interest, and Versioned Risk

A tour through AAVE v4's architecture — what changed from v3, why it changed, and the parts that are genuinely new: the Hub/Spoke split, dual-stream interest accrual, versioned risk configs, target-HF liquidations, and the Dutch-auction bonus. For integrators, auditors, and anyone building a custom Spoke.

Crypto Training2026-03-1025 min read

AAVE v3 is one pool per chain. Every new asset is a global decision. Every risk-parameter change reaches across every open position simultaneously. Adding an RWA? Adding a staking derivative with custom accounting? You're negotiating with the entire market at once.

AAVE v4 breaks this apart. One contract is the custodian of liquidity. Another contract is the rulebook. Liquidity is pooled across markets; rules are per-market. You can stand up a purpose-built market for Lido stVaults without migrating anyone's USDC.

This post walks through the v4 architecture for integrators and auditors: what's new, how it works, and where the sharp edges are.


1. The 60-second mental model#

Two contract roles, split by responsibility:

  • Hub — holds every ERC-20 asset. Tracks supply, debt, accrued interest, and a handful of solvency invariants. Has no notion of "collateral" or "health factor."
  • Spoke — a lending rulebook. Defines what counts as collateral, how the health factor is computed, which oracle to consult, which position manager a user has delegated to. Holds no liquidity of its own.
flowchart LR Users["Users - LPs and Borrowers"] -->|supply, withdraw, borrow, repay, liquidate| Spoke Spoke -->|add, remove, draw, restore, refreshPremium, reportDeficit| Hub Hub -->|ERC-20 transfer| External[External ERC-20 assets] Governance["AAVE Governance via AccessManager"] -.->|configure| Hub Governance -.->|configure| Spoke Oracle[Per-Spoke AaveOracle] --> Spoke IRStrategy["Per-asset InterestRate Strategy"] --> Hub

One Hub, many Spokes. One Spoke can connect to one or many Hubs. Assets are registered on a Hub with a unique assetId. A reserve — the user-facing wrapping of that asset on a specific Spoke — is registered on the Spoke with its own local reserveId and points back to (hub, assetId).

The payoff: governance can stand up an RWA Spoke, a Lido-staking Spoke, a wrapped-BTC Spoke, each with its own collateral rules and oracle, all sharing the underlying Hub liquidity. They compete for it via caps and risk premiums, not via pool migration.


2. Why the split exists#

v3's problem, in one sentence: every risk decision was global. Niche markets (RWAs, illiquid collateral, novel staking primitives) had no path to inclusion without dragging global risk up. Safe-to-add assets had to clear the same bar as dangerous ones. Lowering an asset's LT to reflect new information immediately touched every open position using it.

v4's answer: separate the custodian of liquidity from the definer of lending rules.

  • The Hub is an accountant. It tracks total supplied per asset, total drawn per asset, interest on drawn, and per-Spoke caps. It enforces four solvency invariants and nothing else.
  • A Spoke is a rulebook. It reads prices, computes health factors, decides what's collateral, runs liquidations. When it needs to move money, it calls the Hub.
flowchart TB subgraph AAVEv4["AAVE v4 deployment"] AM["AccessManager - role 0 = ADMIN"] subgraph HubLayer["Hub layer - immutable"] H1[Core Hub] H2["Prime Hub - Lido-focused"] H3["Plus Hub - strategy-heavy stables"] HC[HubConfigurator] IR[AssetInterestRateStrategy] end subgraph SpokeLayer["Spoke layer - upgradeable via ERC1967 proxy"] S1[Main Spoke] S2[Prime Spoke] S3["Custom Spoke - e.g. StVaultSpoke"] SC[SpokeConfigurator] AO1[AaveOracle] end subgraph PMLayer["Position Manager layer - optional"] PM1[NativeTokenGateway] PM2[GiverPositionManager] PM3[TakerPositionManager] PM4[ConfigPositionManager] end AM -.->|authority| H1 AM -.->|authority| H2 AM -.->|authority| S1 AM -.->|authority| S3 HC -->|configure| H1 SC -->|configure| S1 S1 -->|draw, restore| H1 S2 -->|draw, restore| H2 S3 -->|draw, restore| H1 H1 --> IR S1 --> AO1 PM1 --> S1 PM3 --> S1 end Users[Users] --> S1 Users --> S3 Users --> PM1

Things the diagram preserves:

  • Hubs are immutable. Deployed via constructor. No proxy. A Hub bug means all funds at risk — making them small, audit-heavy, and unchangeable is the safest design.
  • Spokes are upgradeable. Each is an ERC1967Proxy pointing at a SpokeInstance implementation. A Spoke bug risks only that Spoke's users; governance can ship fixes without touching Hub liquidity.
  • One AccessManager per deployment. Root of all permissioning. Every governance-only function is gated by a restricted modifier that consults it.
  • Position Managers are orthogonal. Standalone contracts that users opt into per-Spoke.

3. Who can do what#

AAVE uses OpenZeppelin's AccessManager pattern: permissions are (target contract, function selector) -> role bindings. Any address can be assigned a role; calling a restricted function checks the caller is in the required role.

RoleTypical holderCan call
0 (ADMIN)AAVE Long ExecutorEverything including grantRole. OZ-locked.
101HubConfigurator contractInternal role for Hub.addSpoke, updateSpokeConfig, etc.
200SpokeConfigurator contractInternal role for Spoke.addReserve, updateReserveConfig, etc.
400Risk Manager multisigUpdate liquidity fees, dynamic configs, caps, premiums
emergencyEmergency AdminhaltAsset, pauseReserve, freezeReserve

Role IDs aren't protocol-fixed — they're set at deployment. The numbers above are conventional; the actual bindings live in AccessManager state and are governance-mutable.

Permissionless surface#

Callable by anyone:

  • Spoke.supply, withdraw, borrow, repay (for their own position, or for anyone if that user opted in via a Position Manager)
  • Spoke.liquidationCall (for any borrower with HF below 1, except you cannot liquidate yourself)
  • Spoke.setUsingAsCollateral (own position)
  • Spoke.updateUserRiskPremium (own position, or anyone — harmless by design)
  • Hub.payFeeShares (anyone can pay fee shares into the Hub on behalf of the fee receiver)

4. Assets on the Hub — lifecycle#

The Hub doesn't know about collateral or borrowers. It knows about assets: fungible ERC-20s listed with a unique assetId inside a specific Hub.

stateDiagram-v2 [*] --> Unlisted Unlisted --> Added : HubConfigurator.addAsset Added --> Active : initial state after add Active --> Halted : HubConfigurator.haltAsset Halted --> Active : updateSpokeHalted false Active --> Deactivated : HubConfigurator.deactivateAsset Deactivated --> [*]

What the Hub physically tracks per asset (from HubStorage.sol):

SOLIDITY
struct Asset {
    address underlying;       // the ERC-20
    address irStrategy;       // rate producer
    address feeReceiver;
    uint32  liquidityFeeBps;
    uint96  drawnIndex;       // grows at drawnRate
    uint96  drawnRate;        // updated on every state change
    uint128 liquidity;        // currently held by this Hub
    uint128 swept;            // reinvested externally
    uint256 deficitRay;       // protocol bad debt
    // ... per-Spoke sub-struct with caps and live shares
}

Four concepts fight for attention in that struct: liquidity (currently in the Hub), drawn (lent out, tracked as shares growing with drawnIndex), swept (deployed to external strategies), deficitRay (protocol bad debt). Together they answer "how much of this asset is here, how much is elsewhere, and who owes what."


5. Reserves on the Spoke — lifecycle#

A reserve is the Spoke's view of a single asset. Each reserve points at an asset on a specific Hub and adds lending rules on top.

stateDiagram-v2 [*] --> Unlisted Unlisted --> Added : SpokeConfigurator.addReserve Added --> ActiveBorrowable : config.borrowable = true Added --> ActiveCollateralOnly : config.borrowable = false ActiveBorrowable --> Frozen : updateFrozen true ActiveBorrowable --> Paused : updatePaused true ActiveCollateralOnly --> Frozen : updateFrozen true ActiveCollateralOnly --> Paused : updatePaused true Paused --> ActiveBorrowable : updatePaused false Frozen --> ActiveBorrowable : updateFrozen false

Static reserve config#

FieldMeaning
borrowableCan borrowers draw this reserve?
paused / frozenEmergency and wind-down flags
receiveSharesEnabledCan liquidators opt to receive shares instead of underlying?
priceSourceAddress of price feed (usually Chainlink or a custom adapter)
assetIdWhich Hub asset backs this reserve
hubWhich Hub holds the liquidity

Dynamic reserve config#

Introduced in v4 as the fix for "global parameter change breaks existing positions." Risk parameters are versioned:

FieldMeaning
collateralFactorPercentage of collateral value a user can borrow against (BPS)
maxLiquidationBonusCap on liquidation bonus this collateral can yield
liquidationFeeFraction of the bonus that goes to protocol rather than liquidator
collateralRiskRisk Premium coefficient for this asset

Every edit creates a new dynamicConfigKey. Existing positions keep the key they had at open time. Section 10 has the full model.


6. User flows#

6.1 Supply#

sequenceDiagram autonumber actor LP as Liquidity Provider participant Token as ERC-20 participant Spoke participant Hub participant IR as InterestRateStrategy LP->>Token: approve(Spoke, amount) LP->>Spoke: supply(reserveId, amount, onBehalfOf, useAsCollateral) Spoke->>Spoke: _validateSupply (paused, frozen checks) Spoke->>Token: safeTransferFrom(LP, Hub, amount) Spoke->>Hub: add(assetId, amount) Hub->>Hub: asset.accrue() updates drawnIndex Hub->>IR: getDrawnRate (sets new rate post-state) Hub-->>Spoke: sharesMinted Spoke->>Spoke: userPosition.supplied += shares Note over Spoke: If useAsCollateral and first-time, enable collateral flag Spoke-->>LP: Supply event

Key nuance: the LP transfers tokens to the Hub, not the Spoke. The Spoke never holds user liquidity. It's a rule engine, not a custodian.

6.2 Borrow — the hardest flow#

sequenceDiagram autonumber actor BR as Borrower participant Spoke participant Oracle as AaveOracle participant Hub participant IR as InterestRateStrategy participant Token as ERC-20 BR->>Spoke: borrow(reserveId, amount, onBehalfOf) Spoke->>Spoke: _validateBorrow (borrowable, cap checks) Spoke->>Oracle: getReservesPrices(collateralReserves) Oracle-->>Spoke: prices Spoke->>Spoke: _calculateUserAccountData (collateral, debt, HF) Spoke->>Spoke: require post-borrow HF gte 1.0 Spoke->>Hub: draw(assetId, amount, receiver=BR) Hub->>Hub: asset.accrue() Hub->>IR: getDrawnRate Hub->>Token: safeTransfer(BR, amount) Hub-->>Spoke: drawnShares Spoke->>Spoke: computeRiskPremium - sort collaterals by CR Spoke->>Hub: refreshPremium(assetId, premiumDelta) Hub->>Hub: update premium shares and offset Spoke->>Spoke: userPosition.drawn += shares Spoke-->>BR: Borrow event, tokens in wallet

Three things make borrow hard:

  1. Post-state health factor check. The Spoke simulates the new debt, reads every collateral's price, computes HF with the post-borrow numbers. Reverts if under 1.
  2. Risk Premium refresh. Premiums depend on collateral mix; every debt-increasing action recomputes them. Details in section 8.
  3. Dynamic config rebinding. The user's dynamicConfigKey snapshot updates to the current key — but only if the new key keeps the position solvent. Section 9.

6.3 Liquidation#

sequenceDiagram autonumber actor LIQ as Liquidator participant Spoke participant Oracle participant Hub participant DebtTok as Debt ERC-20 participant CollTok as Collateral ERC-20 LIQ->>DebtTok: approve(Spoke, debtToCover) LIQ->>Spoke: liquidationCall(collReserve, debtReserve, user, debtToCover, receiveShares) Spoke->>Oracle: getReservesPrices Spoke->>Spoke: validate user HF lt 1 Spoke->>Spoke: compute actualDebtToCover (capped at TargetHF) Spoke->>Spoke: compute liquidation bonus (Dutch auction) Spoke->>Spoke: compute collateralToSeize alt receiveShares AND reserve allows Spoke->>Hub: transferShares(user -> LIQ, shares) else underlying Spoke->>Hub: remove(collateralAsset, amount, LIQ) end Spoke->>DebtTok: safeTransferFrom(LIQ, Hub, debtToCover) Spoke->>Hub: restore(debtAsset, debtToCover, premiumDelta) alt user has no collateral left AND debt gt 0 Spoke->>Hub: reportDeficit(debtAsset, residualDebt) Note over Hub: Deficit recorded, cleared later by eliminateDeficit end Spoke-->>LIQ: LiquidationCall event, collateral delivered

Behaviors the flow ensures:

  • No self-liquidation. user != msg.sender enforced.
  • Target Health Factor, not close factor. Liquidator repays "just enough to return HF to TargetHealthFactor" — no more — unless dust thresholds activate.
  • Deficit path. If the borrower loses all collateral but still owes debt, the residual is booked as protocol deficit on the Hub and can later be cleared by eliminateDeficit (another Spoke donates supply to cover it).
  • Receive-shares opt-in. If the collateral reserve has receiveSharesEnabled, the liquidator can receive Hub supply shares directly (earning yield) instead of underlying tokens. Useful in low-liquidity environments.

6.4 setUsingAsCollateral#

Toggles whether a specific reserve counts toward the user's collateral.

  • Enabling flips the bit and refreshes this reserve's dynamic config key.
  • Disabling checks the user's position is still solvent without this collateral, then refreshes all dynamic config keys and recomputes risk premium.

7. Dual-stream interest#

This is v4's most conceptually novel piece. Every borrower carries two concurrent interest streams:

  1. Drawn debt — tracks actual capital borrowed from the Hub. Accrues at the Hub's drawnRate (utilization-based).
  2. Premium debt — tracks extra interest owed because the borrower's collateral quality is not pristine. Accrues at drawnRate × riskPremium.
flowchart LR subgraph Debt["Borrower's USDC debt"] Drawn["Drawn debt D_base\n= principal x (1 + drawnRate x t)"] Premium["Premium debt D_prem\n= premiumShares x drawnIndex - premiumOffset"] end subgraph Rates["Accrual rates"] BR["Base rate = drawnRate\nfrom IRStrategy kinked curve"] PR["Premium rate = drawnRate x RP_u\nweighted collateral risk"] end Drawn -.->|accrues at| BR Premium -.->|accrues at| PR Total["Total debt seen by user\n= D_base + D_prem"] --> Drawn Total --> Premium

7.1 Why two streams#

The borrower sees one number. The split is internal bookkeeping so the protocol can:

  • Give liquidity providers their rightful share of utilization-based interest (that is drawn).
  • Apply a quality-weighted surcharge that reflects how dangerous this specific borrower's collateral mix is (that is premium).

7.2 How premium is materialized — shares + offset#

The premium stream is represented as virtual shares in the Hub's accounting. When a borrower opens a position:

  • Premium shares are minted such that premiumShares × drawnIndex == premiumOffset (in asset units).
  • As drawnIndex grows at the base drawn rate, premiumShares × drawnIndex outpaces the stale premiumOffset.
  • The difference (premiumShares × drawnIndex) − premiumOffset is the accrued premium debt.

When a user's risk premium changes (collateral mix changes), refreshPremium is called. Premium shares and offset both get re-anchored so that (a) the accrued-so-far premium debt is preserved, and (b) the new accrual rate reflects the updated premium.

flowchart TB T0["t=0 borrow opens\npremiumShares = N0\npremiumOffset = N0 x drawnIndex(0)\naccrued = 0"] T1["t=1 time passes, no refresh\npremiumShares = N0\npremiumOffset = N0 x drawnIndex(0)\ndrawnIndex(1) gt drawnIndex(0)\naccrued = N0 x (drawnIndex(1) - drawnIndex(0))"] T2["t=2 refreshPremium fires\npremiumShares = N1 recomputed\npremiumOffset = N1 x drawnIndex(1) - accrued\naccrued preserved\nfuture rate = drawnRate x RP_new"] T0 --> T1 --> T2

8. Risk Premium — pricing collateral quality#

Every asset on every Spoke has a collateralRisk parameter (CR_i, in BPS 0 to 100000). CR_i = 0 is risk-free pristine collateral (e.g. wstETH on a conservative Spoke). CR_i = 100000 is maximum risk.

A borrower's User Risk Premium RP_u is a weighted average of the CR_i of their collateral assets — but not all of them. Only enough collateral to cover their debt is included, sorted least-risky first.

8.1 The algorithm#

flowchart TD Start["User has debt D and collaterals\nC = c1, c2, ... cn in USD"] --> Sort["Sort collaterals by CR ascending\ncheapest-risk first"] Sort --> Init["Initialize coveredDebt = 0\nincluded = empty"] Init --> Iter{"For each c_i in sorted order"} Iter --> Check{"coveredDebt + c_i.value gte D"} Check -->|No| Include["Include c_i fully\ncoveredDebt += c_i.value\ncontinue"] Include --> Iter Check -->|Yes| Partial["Include portion p\nso coveredDebt + p = D\nbreak"] Partial --> Compute["RP_u = sum(CR_i x includedValue_i)\ndivided by sum(includedValue_i)"] Compute --> End["RP_u applied to\npremium accrual formula"]

8.2 Worked example#

Borrower has:

  • 10 ETH worth $30k, CR = 0
  • 1 BTC worth $60k, CR = 500 (5%)
  • 50k USDT worth $50k, CR = 1000 (10%)

They borrow $40k of USDC. The greedy walk:

flowchart LR subgraph Coll["Sorted collaterals"] E["ETH - 30k, CR=0"] B["BTC - 60k, CR=500"] U["USDT - 50k, CR=1000"] end subgraph Walk["Greedy walk"] W1["Include ETH entirely\ncoveredDebt = 30k lt 40k"] W2["Include BTC partially\nneed 10k more\ninclude 10k of BTC's 60k"] W3["Done - USDT not touched"] end Coll --> Walk Walk --> Result["RP_u = (0 x 30k + 500 x 10k) / (30k + 10k)\n= 5,000,000 / 40,000\n= 125 BPS = 1.25%"]

The borrower effectively pays an extra 1.25% surcharge on their drawn rate, reflecting that 75% of their coverage is pristine but 25% comes from somewhat-risky BTC. If they had to dip into USDT, the surcharge would be higher.

8.3 When premium gets refreshed#

ActionRefreshes RP_u?
borrowYes
withdraw (if collateral)Yes
setUsingAsCollateral(false)Yes, all reserves
setUsingAsCollateral(true)Yes, only the enabled reserve's config key
liquidationCall (non-deficit)Yes
updateUserRiskPremium (explicit)Yes
supplyNo
repayNo
liquidationCall (deficit path)Resets RP to 0

9. Dynamic Risk Configuration#

v3 had one set of risk parameters per asset. Lowering an asset's LT to reflect new info instantly affected every open position, often creating unexpected liquidations. v4 fixes this with parameter versioning.

flowchart LR subgraph Reserve["Reserve state"] LatestKey["latestDynamicConfigKey = 5"] end subgraph History["dynamicConfigKey dictionary"] K1["key 1 - CF=80, LB=105, LF=10"] K2["key 2 - CF=80, LB=108, LF=10"] K3["key 3 - CF=75, LB=110, LF=10"] K4["key 4 - CF=70, LB=110, LF=12"] K5["key 5 - CF=65, LB=112, LF=12"] end subgraph Positions["User positions"] P1["Alice - positionKey = 2\nopened under old CF=80"] P2["Bob - positionKey = 4\nopened after last tightening"] P3["Carol - positionKey = 5\nbrand new position"] end LatestKey -.-> K5 History --- K1 History --- K2 History --- K3 History --- K4 History --- K5 P1 -.->|bound to| K2 P2 -.->|bound to| K4 P3 -.->|bound to| K5

9.1 Rebinding rules#

When a user performs a risk-increasing action (borrow, withdraw, disabling a collateral reserve):

  1. The engine checks how the user's position looks under the latest dynamicConfigKey.
  2. If HF >= 1 under the latest config, rebind the user's snapshot to the latest key, proceed.
  3. If HF < 1 under the latest config, revert. The user cannot take on more risk while grandfathered under favorable old parameters.

When the user performs a risk-reducing action (supply, repay, liquidationCall), the snapshot stays frozen. They keep their old, more favorable parameters.

9.2 Key constraints#

  • dynamicConfigKey is uint32, giving ~4.29B configurations. Effectively unbounded.
  • Old configurations are not deleted. They keep positions alive.
  • Governance can still update old keys to force-rebind users onto updated parameters (updateDynamicReserveConfig), but this is reserved for exceptional cases.

10. Liquidation engine#

Two innovations over v3:

  • Target health factor replaces fixed close factor.
  • Dutch-auction bonus replaces static liquidation bonus.

10.1 Target health factor vs. close factor#

In v3, a liquidator can repay up to 50% of the borrower's debt — or 100% if HF is below 0.95 or debt is below threshold. In v4, the liquidator repays exactly enough to restore the borrower to the Spoke-configured TargetHealthFactor — no more, no less.

The borrower gets less "over-liquidated." The liquidator gets a well-defined cap. The protocol gets less bad debt pileup from aggressive dumps.

10.2 The bonus curve#

flowchart LR subgraph Curve["Bonus as a function of HF at liquidation time"] A["HF lte hfForMaxBonus\nbonus = maxLiquidationBonus"] B["hfForMaxBonus lt HF lte 1\nbonus interpolates linearly\nfrom minLB at HF=1\nto maxLB at HF=hfForMaxBonus"] C["HF gt 1\nnot liquidatable"] end subgraph Formula["Formulas"] F1["minLB = (maxLB - 100) x liquidationBonusFactor + 100"] F2["bonus(HF) = maxLB if HF lte hfForMaxBonus\notherwise minLB + (maxLB - minLB) x (1 - HF) / (1 - hfForMaxBonus)"] end

Numeric example. maxLiquidationBonus = 110%, liquidationBonusFactor = 50%, hfForMaxBonus = 0.5, HF at liquidation = 0.8:

CODE
minLB = (110% − 100%) × 50% + 100% = 105%
bonus(0.8) = 105% + (110% − 105%) × (1 − 0.8) / (1 − 0.5)
           = 105% + 5% × 0.4
           = 107%

The liquidator seizes 7% extra collateral on top of the debt repaid. A portion (via liquidationFee, say 10% of bonus) goes to the protocol; the rest goes to the liquidator.

The Dutch-auction framing: the later you liquidate (lower HF), the bigger the bonus. This is a built-in incentive alignment — liquidators compete on speed, and the protocol pays more for riskier liquidations.

10.3 Dust handling#

Residual fragments below DUST_LIQUIDATION_THRESHOLD (hard-coded $1,000 USD) cannot remain on either side:

  • Intended liquidation would leave under $1k of debt AND liquidator wants full close: allow full repay, overriding the Target HF cap.
  • Intended liquidation would leave under $1k of debt AND liquidator does not want to fully close: revert.
  • Dust on collateral side: only allowed if the collateral reserve is fully exhausted.

10.4 Deficit path#

If after liquidation the borrower has debt above zero across any reserve AND no collateral across any reserve, the residual debt is booked to the Hub as deficit and later cleared by any authorized Spoke calling Hub.eliminateDeficit (donating supply to cover it).


11. Hub accounting invariants#

Four rules the Hub must satisfy at all times:

flowchart TB subgraph Invariants["Hub invariants"] I1["I1 - Total borrowed shares = sum of Spoke drawn shares"] I2["I2 - Hub liquidity assets gte sum of Spoke supply assets"] I3["I3 - Hub added shares = sum of Spoke added shares"] I4["I4 - Supply share price and drawnIndex monotonically non-decreasing"] end subgraph Defense["What each protects"] D1["I1 prevents debt creation outside of Hub.draw"] D2["I2 ensures suppliers can always be repaid, modulo deficit"] D3["I3 ensures no share inflation / dilution attacks"] D4["I4 ensures interest accrues forward only"] end I1 --> D1 I2 --> D2 I3 --> D3 I4 --> D4

Implication for custom Spokes: any Spoke that calls Hub functions (add, remove, draw, restore, reportDeficit, refreshPremium) inherits these invariants automatically. But if the Spoke tries to bypass these calls (e.g., to move funds around through a custom path), it will likely break I1 or I3.


12. Oracle architecture#

Each Spoke has its own AaveOracle. The oracle is a registry mapping reserveId -> priceSource, where priceSource is any contract implementing IPriceSource (8-decimal price in USD).

flowchart LR Spoke -->|getReservePrice| AaveOracle AaveOracle -->|reserve 0| PS1[priceSource] AaveOracle -->|reserve 1| PS2[priceSource] AaveOracle -->|reserve N| PS3[priceSource] PS1 --> Chainlink["Chainlink aggregator\nETH/USD etc."] PS2 --> Adapter["Custom adapter\ne.g. wstETH PriceCapAdapter"] PS3 --> Custom["Custom oracle\ne.g. CapacityOracle for stVaults"] Adapter --> Chainlink Adapter --> Chainlink2[Chainlink stETH/ETH] Custom --> Chainlink Custom --> External["External on-chain state\ne.g. Lido Dashboard"]

Per-Spoke isolation matters. Main Spoke's oracle is completely independent from a custom Spoke's oracle. Breaking one does not affect the other.

Price cap adapters are a popular v3 and v4 pattern: wrap Chainlink with a floor and ceiling computed from slower-moving state (e.g., stETH share rate), so a transient market depeg does not translate into an instant HF collapse. The CAPO wrapper effectively imposes a max rate-of-change constraint on reported prices.


13. Position Managers#

Position Managers are standalone contracts users opt into per-Spoke. Once opted in, the PM can call user-action functions on behalf of the user.

Typical uses:

  • Wrap native ETH around supply, withdraw, borrow, repay (NativeTokenGateway). The user never sees WETH.
  • Sponsor gas via meta-transactions (EIP-712 signed user authorization).
  • Multi-action flows — supply + borrow + swap in one tx.
  • Delegated risk management — custodian-managed strategies.
flowchart LR User -->|1 setUserPositionManager pm true| Spoke User -->|2 signs intent| SigMsg[EIP-712 message] SigMsg --> PM[Position Manager] PM -->|3 supplyOnBehalfOf or borrowOnBehalfOf| Spoke Spoke -->|checks user enabled this PM| Spoke Spoke --> Hub

The v4 stock distribution ships:

Position ManagerPurpose
GiverPositionManagerSupply / repay on behalf of arbitrary users
TakerPositionManagerWithdraw / borrow on behalf (requires explicit per-action approval)
ConfigPositionManagersetUsingAsCollateral on behalf
NativeTokenGatewayWrap and unwrap ETH around user actions
SignatureGatewayShared EIP-712 plumbing

Security model#

  • PositionManagerBase.registerSpoke is owner-only (per-PM governance).
  • Users revoke via setUserPositionManager(pm, false).
  • Users must explicitly authorize each PM. No default "all users allow all PMs" state.

14. Reinvestment#

Idle Hub liquidity can be deployed to external strategies (e.g., short-term T-bill RWAs) to earn yield without affecting the drawn rate. Critical invariant: swept assets still count in the utilization denominator, so sweeping does not change interest rates for borrowers.

flowchart LR Hub -->|sweep| Strategy["External strategy\ne.g. sDAI, T-bill RWA"] Strategy -->|yield| Governor["Governance-controlled treasury"] Strategy -.->|reclaim on demand| Hub Governor -.->|loss absorption if strategy underperforms| Hub subgraph Invariant U["utilization = drawn / (liquidity + swept + drawn)"] end

Who bears what:

  • LPs keep their normal supply APY (utilization-driven).
  • Governance keeps excess yield (pays LPs the normal rate out of strategy returns).
  • Governance absorbs the loss if the strategy underperforms.

This is opt-in per asset. Most assets run with reinvestment disabled.


15. Emergency controls — four flags#

flowchart TD subgraph HubLevel["Hub-level"] ACT[asset.active] HAL[asset.halted] end subgraph SpokeLevel["Spoke-level"] PAU[reserve.paused] FRO[reserve.frozen] end ACT -.->|false means asset retired| Out["Asset cannot be added, drawn, removed.\nDeficit reporting still allowed.\nTerminal state."] HAL -.->|true means emergency| Stop["add and draw blocked.\nExisting positions accrue and can repay.\nReversible."] PAU -.->|true means all ops blocked| AllOff["supply, withdraw, borrow, repay, liquidate all revert.\nFor this reserve only.\nReversible."] FRO -.->|true means wind-down| Wind["New supply and borrow blocked.\nWithdraw and repay allowed.\nLiquidation allowed.\nReversible."]

When to use which:

SituationUse
Asset contract has a bug, stop everythingpaused on reserve(s) using it
Oracle feeding garbage, stop all ops on reserves reading itpaused on those reserves
Winding down a reserve (replaced by a newer version)frozen
Compromised Hub asset strategy — stop new activity but preserve existinghalted on asset
Protocol deprecation / end-of-lifedeactivated on asset

16. Upgrade and immutability model#

flowchart LR subgraph Immutable["Hub side - immutable"] HubDeploy["Hub deployed via constructor\nHubInstance"] Note1["No proxy\ncannot upgrade\nhas to be bug-free"] end subgraph Upgradeable["Spoke side - upgradeable"] Impl[SpokeInstance implementation] Proxy["ERC1967Proxy\npoints at impl"] Impl2[SpokeInstance v2 deployed] Upgrade["proxy upgraded to impl v2\nvia governance + AccessManager"] end HubDeploy --> Note1 Impl --> Proxy Impl2 --> Upgrade Upgrade --> Proxy

Why the asymmetry:

  • Hubs are the custodian. A bug means all funds at risk. Make them immutable, small, audit-heavy.
  • Spokes are the rule engine. A bug risks only that Spoke's users. Upgradeability lets governance ship fixes without disrupting Hub liquidity.

Consequences for integrators:

  • A custom Spoke inherits upgradeability — you can ship a v2 and migrate the proxy.
  • A custom Hub does not exist in the current deployment. If you need Hub-level custom logic, you fork and redeploy.
  • Hub.initialize(authority) is called once during construction-like deployment. No re-initialization path.

17. What changed from v3 to v4#

flowchart LR subgraph v3["AAVE v3"] v3_single[Single pool per chain] v3_global[Global risk params] v3_fixed["Fixed close factor - 50 or 100"] v3_static[Static liquidation bonus] v3_notwo[Single-stream interest] end subgraph v4["AAVE v4"] v4_hubspoke[Hub + N Spokes] v4_dynamic["Dynamic risk configs\nversioned, per-position snapshots"] v4_targethf[TargetHealthFactor] v4_dutch[Dutch-auction bonus] v4_dual[Drawn + Premium dual stream] end v3_single -.-> v4_hubspoke v3_global -.-> v4_dynamic v3_fixed -.-> v4_targethf v3_static -.-> v4_dutch v3_notwo -.-> v4_dual

The core bet: capital efficiency and risk pricing are both functions of collateral quality, and the architecture should reward precise pricing of that quality per-market rather than imposing a single pessimistic answer everywhere.


18. Sharp edges for integrators#

If you are writing a custom Spoke or a Position Manager, the places where you are most likely to cut yourself:

Not every Spoke function is virtual. Neither liquidationCall nor _processUserAccountData is marked virtual on the stock Spoke. If you need to change their behavior, you must fork Spoke.sol — copy the contract, apply minimal-diff changes, and audit the whole thing. Inheritance alone does not give you override points.

VaultHub and Dashboard interface stability is a trust assumption. Your custom Spoke's immutables bind to specific Lido or external contract addresses. If those contracts break interface compatibility, your Spoke reverts. There is no fallback.

Hub invariants are enforced by the Hub. You cannot bypass add, remove, draw, restore and still satisfy I1 and I3. Any custom funds movement must route through these four entry points.

Position Manager opt-in is per-user per-Spoke. A PM that works on Main Spoke does not automatically work on a custom Spoke. The user must explicitly opt in each time, and the PM must be registered on each Spoke.

Oracle staleness is the Spoke's problem. AaveOracle.getReservePrice does not enforce Chainlink freshness — your price source must. If you use a price cap adapter, verify the adapter has its own freshness check. A stale price that still returns a value will silently let borrowers over-borrow or under-liquidate.

Risk premium is not something you opt out of. Every Spoke inherits the dual-stream interest model. If you add a new collateral asset and forget to set its collateralRisk, it defaults to whatever the Configurator leaves it at, which may be zero. Zero-CR means "pristine collateral" — fine for wstETH, wrong for most things.

Dynamic config keys accumulate. Every parameter change mints a new key. Historical keys stay live because old positions reference them. Governance can update old keys in exceptional cases, but the expectation is that old keys stay frozen.

The restricted modifier hides a lot. "Restricted" on a function means "gated by AccessManager role X." X depends on deployment. Before you trust a function to only-be-callable-by-governance, resolve which role is bound to it in the production AccessManager state.


19. Takeaways#

  • The Hub is a custodian-accountant. The Spoke is a rulebook. Learn which one you are calling, and why.
  • v4's big wins over v3 are all about per-position time-invariance: dynamic configs grandfather old positions, target-HF limits how much a liquidator can take, Dutch-auction pricing rewards timely liquidation.
  • Dual-stream interest is an accounting device, not a user-visible feature. Borrowers see one number; the Hub internally splits it into utilization-driven and quality-driven components.
  • Risk Premium (greedy-walk weighted average of collateral risk) is the mechanism that makes heterogeneous collateral portfolios priceable.
  • Spokes are upgradeable; Hubs are not. A Hub bug means "all funds at risk." Hubs are kept small and audit-dense.
  • Custom Spokes inherit Hub invariants for free, as long as they route all fund movement through add, remove, draw, restore. Try to take a shortcut and you break I1 or I3.
  • The hardest flow to reason about is borrow: post-state HF check, risk premium refresh, dynamic config rebinding, oracle reads, rate update. Everything else is easier once you understand borrow.
  • The oracle is per-Spoke. Per-Spoke isolation is a feature, not an accident — you do not want a broken Main Spoke oracle to cascade into every custom Spoke.

The underlying pattern — small immutable custodian + many upgradeable rulebooks + per-position versioned risk — generalizes well beyond lending. Any protocol where many markets share underlying liquidity but have different policy requirements faces the same shape of problem. v4 is worth reading even if you never write a Spoke.