Skip to main content
The controller is the integrator-facing lending contract. The pool is owned by the controller and is not called directly by users. Types are defined in Data types. Error codes are listed in Errors. Events are listed in Events.
In production, governance owns the controller. Owner-gated configuration is reached through governance proposal, timelock, and execution.

Reading the tables

Every state-changing entrypoint takes an explicit caller: Address and calls require_auth() on it. The Auth column describes the additional authority required beyond that signature: Pausegated means the entrypoint carries #[when_not_paused] and reverts while the controller is paused. open means it stays callable, so a paused protocol never traps a solvent user’s exit or blocks a liquidation. Flash-loan reentrancy — every monetary verb routes through require_authorized_caller, which combines caller.require_auth() with the flash-loan guard. A call made while a flash loan is in flight reverts with #400 FlashLoanOngoing.

Core lending

  • account_id = 0 on supply creates the account, mints its position NFT to caller, binds it to spoke_id in PositionMode::Normal, and returns the new id. There is no separate account-creation entrypoint.
  • Third-party supply is narrowed. A caller who is neither owner nor delegate may only add to hub assets the account already holds a supply position in. New accounts skip the check because the caller becomes the owner.
  • to separates borrower from recipient on borrow and withdraw; it defaults to caller. Debt is always booked to account_id.
  • On withdraw, a zero amount withdraws the entire position for that asset. The return value is the resolved figure — the only way a caller learns what a withdraw-all actually paid.
  • repay is fully permissionless. Anyone may repay anyone’s debt; the caller’s signature is needed only to pull the tokens.
  • Amounts credited are measured, not requested. A token that delivers less than it is sent credits the delivered amount.

Liquidation

seize_mode is SeizeMode::Transfer (pool pays underlying, withholding the protocol fee) or SeizeMode::Credit(account_id) (seized supply shares move to a controller account). Credit(0) creates the receiving account and liquidate returns its id; Transfer returns 0. See Liquidations.

Leverage and strategies

All are pause-gated and end in strategy_finalize: restamp listed collateral risk parameters, apply post-pool risk gates, persist, emit the position batch.
multiply and flash_position accept account_id = 0 to create the account, and otherwise assert the passed mode equals the account’s stored mode (#25 AccountModeMismatch). See Strategies and Flash loans.

Account and delegation

  • add_delegate requires the delegate to be an active, governance-approved position manager at the moment of the grant. Delegates are capped at 16 (#45 RegistryCapReached).
  • remove_delegate is deliberately not pause-gated — revoking authority must never be blocked.
  • A delegate grant is bound to the owner who made it. Transferring the position NFT deactivates the previous owner’s grants immediately.
  • renew_account extends both the account’s storage TTL and the NFT Owner entry.

Permissionless maintenance

Caller-signed; none carries a privileged role.

Owner-gated configuration

Every mutating entrypoint in ControllerAdmin carries #[only_owner], and the owner is the governance contract. Reaching them requires a governance proposal, the applicable timelock tier, and execution.
  • Wiring: set_swap_aggregator, set_price_aggregator, set_accumulator
  • Limits: set_position_limits, set_min_borrow_collateral_usd
  • Delegation: set_position_manager(manager, is_active)
  • Blend: approve_blend_pool, revoke_blend_pool
  • Topology: create_hub, add_spoke, remove_spoke, set_spoke_liquidation_curve
  • Listings: add_asset_to_spoke, edit_asset_in_spoke, set_spoke_asset_flags, remove_asset_from_spoke
  • Deployment: deploy_pool, deploy_position_nft, upgrade_pool, upgrade_position_nft
  • Markets: create_liquidity_pool, upgrade_liquidity_pool_params
  • Emergency: pause, unpause, force_socialize_bad_debt
  • Lifecycle: upgrade, migrate, transfer_ownership, accept_ownership
Two entries are ungated: get_app_version, a permissionless read of the stored migration version, and accept_ownership, callable only by the pending owner.
Emergency asymmetry: the guardian can pause and tighten listing flags immediately, but reopening requires timelocked governance (#317 SpokeAssetFlagRelaxation).

Views

Read-only; none mutates state. Views that accept a list are bounded by MAX_VIEW_INPUTS = 256. Account risk Account state Simulation get_liquidation_estimate(account_id, debt_payments, seize_mode) -> LiquidationEstimate returns seized_collaterals, protocol_fees, refunds, max_payment_wad, and bonus_rate_bps.
Two traps: seized_collaterals is gross of protocol_fees (the liquidator ends up with the difference), and the units follow the mode — asset units for Transfer, RAY-scaled supply shares for Credit.
Market and configuration
There is no max_withdraw, max_borrow, or max_supply view on the controller. Size an action against get_collateral_amount, get_ltv_collateral_usd, get_spoke_usage, and the pool’s get_reserves, or simulate the call.

Cross-cutting semantics

  1. account_id = 0 means “create”. It applies to supply, multiply, flash_position, migrate_from_blend, and as SeizeMode::Credit(0) in liquidate. Each returns the resolved account id.
  2. Spoke binding is permanent. Every later call rechecks it (#310 SpokeMismatch).
  3. Position mode is fixed at creation.
  4. Zero is overloaded by direction. On entry legs (supply, borrow, repay) zero is rejected. On withdraw it means “the entire position”.
  5. Batching is uniform. Multi-asset calls sum duplicate legs per asset and preserve first-appearance ordering. Overflow reverts #33 MathOverflow.
  6. Pause protects exits. withdraw, repay, liquidate, clean_bad_debt, recapitalize, renew_account, and remove_delegate stay callable while the protocol is paused.

Notes

  • HubAssetKey { hub_id, asset } is the market coordinate used by user flows, pool rows, keeper config, and most views.
  • Account creation uses spoke_id, not an account category.
  • The account_id is the position NFT token_id. The controller stores no owner address; it calls owner_of(account_id) on every authority check, so transferring the token transfers the whole position. Its own ABI is on Position NFT.
  • Prices are resolved by the price aggregator, keyed by PriceKey::Token(asset).