Skip to content

SpokeComptroller

SpokeComptroller is the Comptroller of a hub-funded spoke pool. It is a fork of the shared isolated-pools Comptroller and behaves identically except where this page says otherwise: the same hooks, the same account-liquidity model, the same market listing and pause machinery, the same rewards flywheel.

Read Hub-Funded Spoke Pools first for why the fork exists and how a spoke pool is shaped.

AreaShared ComptrollerSpokeComptroller
Supplying to a marketanyone, subject to the supply capoptionally restricted to a per-market allowlist, checked in preMintHook
Seizing collateralanyoneoptionally restricted to a pool-wide liquidation allowlist
Liquidation incentiveone pool-wide valueper collateral market, falling back to the pool-wide value
Risk-parameter safetyno cross-check between threshold and incentivesetCollateralFactor and setMarketLiquidationIncentive both reject a liquidation threshold times incentive that reaches 1
Batch-liquidation routingtotalCollateral vs borrows × incentiveborrows vs maxClearableDebt = Σ Cᵢ / incentiveᵢ
Borrow-power pricingResilientOracle spotDeviationBoundedOracle bounded prices
Entering a marketenterMarkets only, always for msg.senderadds ACM-gated enterMarketForAccount, so a gateway contract can enable collateral for the supplier it minted to
Primeprime + setPrimeToken, seven *Verify hooks update Prime scoresremoved; the seven hooks are no-ops
healAccount pause checksSEIZE (through preSeizeHook) and REPAY (through healBorrow)adds LIQUIDATE in every market the account owes a nonzero balance in
Revert reasonsmixture of custom errors and require stringscustom errors throughout, except inherited checks: setMaxLoopsLimit and initialize still revert with MaxLoopsLimitHelper’s "Comptroller: Invalid maxLoopsLimit", and the onlyOwner setters (setPriceOracle, setDeviationBoundedOracle, addRewardsDistributor, setMaxLoopsLimit) with OpenZeppelin’s Ownable strings, and acceptOwnership with its Ownable2Step string

Exit is never gated, in either implementation. Redeem, repay and transfer are not access-controlled, and an account removed from an allowlist keeps the position it already holds and can still leave. Governance retains the usual market-level pause.

Per market, and off by default, so a newly listed market accepts supply from anyone.

mapping(address vToken => bool) public isSupplyAllowlistEnabled;
mapping(address vToken => mapping(address account => bool)) public isAllowedSupplier;

It is enforced in exactly one place, preMintHook:

if (isSupplyAllowlistEnabled[vToken] && !isAllowedSupplier[vToken][minter]) {
revert SupplyNotAllowed(vToken, minter);
}

The account metered is the one credited with the vTokens, not the one paying for them. VToken.mintBehalf lets a third party fund a mint attributed to someone else, and only the recipient is checked. This is deliberate: metering the recipient is what bounds a market’s supply, and a third party funding a mint credited to the Hub’s YieldGroup is a donation to the Hub, not a way around the restriction.

Only minting is metered. Transfers and seizures do not check the list, so vTokens can still reach an account that is not on it: the allowlist bounds who can create supply, not who can hold it. Redeeming is never restricted. Enabling the allowlist on a market that is already serving supply cuts off every account that is not on the list, including whoever seeded it. That is the intended effect, but it is worth knowing before flipping it on a live market.

On the liquidity side of a hub-funded pool the single allowlisted account is the Liquidity Hub’s Spoke YieldGroup. The listing VIP must call setAllowedSupplier before the Hub registers the market as a resource: AdapterSpokeV1.validateRegistration rejects a market that will not accept the registering YieldGroup.

enterMarkets reads msg.sender, so a contract that supplies through VToken.mintBehalf enters itself rather than the account it credited. On the collateral side of a spoke pool the user supplies and the Hub lends, so without help a first-time supplier needs two transactions: one to supply, one to enable the market as collateral.

enterMarketForAccount takes the account as an argument instead:

function enterMarketForAccount(address account, address vToken) external;

It takes a single market rather than an array, which is what keeps the fork inside the EIP-170 limit; a caller that needs several markets loops over it. enterMarkets is untouched, so entering a market for yourself stays permissionless.

The function is gated by the AccessControlManager under the role string enterMarketForAccount(address,address). It takes the same arguments in the same order as the Core pool’s enterMarketForAccount, but returns nothing where the Core version returns a uint256 error code. A caller written for the Core version that decodes that return value reverts here. Grant it only to a contract that passes its own caller as account. A holder of this permission can enable a market as collateral for any address, and entering a market is what makes a balance eligible to be seized in a liquidation. The Collateral Gateway is the intended holder. Its spoke functions need this role on the pool; on BNB Chain it is granted for the bStock index pool.

Pool-wide, and off by default.

bool public isLiquidationAllowlistEnabled;
mapping(address account => bool) public isAllowedLiquidator;

It is pool-wide rather than per market because healAccount seizes across every market the borrower is in and cannot attribute a seizure to a single one of them.

Two call sites enforce it:

  • preSeizeHook: every seizure reaches this hook carrying the account that receives the collateral, whichever entry point it came from, so this is the check that actually enforces the list.
  • healAccount, at the entry: a borrower holding no vTokens at all takes a branch that only calls healBorrow and never reaches a hook carrying the caller. Without this check anyone could move the whole remaining principal into bad debt at no cost.

liquidateAccount has no entry check of its own. Every one of its orders ends in a seizure, so preSeizeHook rejects a caller that is not on the list while the first order is being processed.

Enabling the list also restricts healAccount, so any keeper relied on to record bad debt has to be allowlisted too.

Note that when the collateral is a tokenized stock liquidated through BStockLiquidator, it is that contract’s address that has to be on the list, not the operator’s. The liquidator contract is the account that receives the seized collateral.

mapping(address vTokenCollateral => uint256) public liquidationIncentives;

Keyed on the collateral market, because the incentive is the discount on the collateral being seized. 0 is the “unset” sentinel, in which case the pool-wide value applies:

effectiveLiquidationIncentive(vToken) = liquidationIncentives[vToken] != 0
? liquidationIncentives[vToken]
: poolLiquidationIncentive

The motivation is risk steering: a higher incentive on high-volatility collateral pushes liquidators to seize the riskiest collateral first, which de-risks an account faster. A borrow-only market (collateral factor 0) is never seized in normal operation, so its incentive is not used to price a liquidation. It is still read by setCollateralFactor, and vTokens transferred into the market by an entered account can be seized, so it should still hold a valid value.

Two getters, deliberately different.

  • effectiveLiquidationIncentive(address vToken): takes the market as an argument. This is what a lens, a keeper or a liquidation bot should read.
  • liquidationIncentiveMantissa(): takes no argument and answers for msg.sender. This is the getter ComptrollerViewInterface declares and the one a VToken calls on its Comptroller: VToken._seize divides the protocol seize share by it and VToken.setProtocolSeizeShare bounds that share against it, and both need the discount that prices the calling market’s own collateral. Any caller with no per-market value stored reads the pool-wide value from it, including any caller that is not a market of this pool. A market that was unlisted after setting its own value still reads that value.

Floors. An incentive is a multiplier on the debt repaid, and VToken._seize hands the protocol protocolSeizeShareMantissa of that debt out of the seized collateral. An incentive below 1e18 + protocolSeizeShareMantissa therefore pays the liquidator less collateral than it repaid, and nobody liquidates that market.

  • setMarketLiquidationIncentive enforces ≥ 1e18 + vToken.protocolSeizeShareMantissa() against the market’s live share.
  • setLiquidationIncentive (the pool-wide value) enforces ≥ MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA = 1.05e18, which is 1e18 plus VToken’s default seize share. That covers a newly listed market carrying the default share and no incentive of its own. This is stricter than the shared Comptroller, which stops at 1e18 and would let a pool be registered with a value that pays every default-share market’s liquidator less than it repaid.
  • VToken.setProtocolSeizeShare holds the same bound from the other side, reading the incentive back through liquidationIncentiveMantissa().

A market whose seize share is later raised above the default still needs an incentive of its own; the pool-wide floor is a backstop for the newly listed case, not a full guarantee.

There is no way back to “unset” once a per-market value is stored: setMarketLiquidationIncentive rejects 0, so a mistaken zero cannot silently move a market back onto the pool-wide value. Passing the current pool-wide value is not the same as unsetting either: the market is pinned at that number and stops following later setLiquidationIncentive changes, so a pool-wide change has to update those markets too.

setLiquidationIncentive does not recheck the markets that fall back to it, so give every market with a nonzero liquidation threshold an incentive of its own and leave the pool-wide value applying to none.

The shared Comptroller routes an account that is in shortfall and whose collateral is at or below minLiquidatableCollateral between liquidateAccount and healAccount using one pool-wide incentive. With per-market incentives that no longer works, so the account snapshot carries a new field:

maxClearableDebt = Σ over the account's collateral markets of ( Cᵢ / incentiveᵢ )

where Cᵢ is the account’s collateral value in market i and incentiveᵢ is that market’s own effective incentive. It is the largest borrow value the account’s collateral can clear without leaving bad debt behind.

The two batch operations are the two sides of that one benchmark, so every such account routes to exactly one of them, with no gap and no overlap:

Applies whenOtherwise reverts with
liquidateAccountborrows < maxClearableDebtDebtExceedsClearableAmount(borrows, maxClearableDebt)
healAccountmaxClearableDebt ≤ borrowsCollateralCoversDebt(borrows, maxClearableDebt)

healAccount’s repayment share becomes:

percentage = maxClearableDebt / borrows

One blended share applies to every borrow, so what the caller pays in total is the sum over the collateral markets of each market’s value at its own incentive. The discount is exact in aggregate; it is not attributed per piece of collateral.

Each market’s repayment is rounded up, so the rounding falls on the caller rather than on the market’s suppliers. It can never exceed the borrow balance, because the routing gate keeps maxClearableDebt at or below borrows.

liquidateAccount keeps its order loop unchanged: each order is a (debt, collateral) pair, and liquidateCalculateSeizeTokens prices it at the collateral market’s own incentive. What changed is the gate above and the market refresh described below.

maxClearableDebt is only accumulated on liquidation-threshold snapshots. A collateral-factor snapshot leaves it at zero, and reading it there would put healAccount on a repayment percentage of zero, forgiving the whole position as bad debt.

Both batch operations still require the account’s total collateral to be at or below minLiquidatableCollateral; above that threshold they revert CollateralExceedsThreshold and the position must go through the regular VToken.liquidateBorrow path.

Both also accrue interest and push an oracle update for every market the borrower is in before taking the routing snapshot. The orders inside liquidateAccount run with the per-order liquidity check skipped, so that snapshot is the only thing separating a solvent account from a full liquidation, and it has to be taken on the same state the orders will execute against.

A spoke pool values borrowing capacity through the DeviationBoundedOracle rather than at spot. This is the same mechanism the Core pool uses; see the DeviationBoundedOracle article for the window, trigger and exit logic, and Protection Mode for the user-facing behavior.

Which oracle is read depends on which risk parameter weights the snapshot:

WeightingUsed byCollateral priceDebt price
USE_COLLATERAL_FACTORpreBorrowHook, preRedeemHook, preTransferHook, exitMarket, getBorrowingPower, getHypotheticalAccountLiquiditydeviationBoundedOracledeviationBoundedOracle
USE_LIQUIDATION_THRESHOLDpreLiquidateHook, healAccount, liquidateAccount, getAccountLiquidityoracle (spot)oracle (spot)

While protection is active for an asset the bounded oracle values collateral at the low end of its recent price window and debt at the high end; otherwise it returns spot on both legs, including for an asset it holds no configuration for. A print that triggers protection can therefore only ever shrink an account’s borrowing capacity, never inflate it. While protection is off, a move too small to trigger it is priced at spot, exactly as the shared Comptroller prices every check, so capacity follows it up or down.

The liquidation paths stay on spot because they route liquidations and set how much of a position healing repays, which has to track the live price. liquidateCalculateSeizeTokens is likewise priced at spot.

Both preBorrowHook and _checkRedeemAllowed (which serves redeem, transfer and exit) call deviationBoundedOracle.updateProtectionState(vToken) for every market the account is in before pricing, so a deviating print latches protection and starts its cooldown instead of evaporating once the price returns to the window.

deviationBoundedOracle must be set before the pool serves any borrow, redeem, transfer or market exit. Nothing falls back to spot when it is unset: the updateProtectionState and getBoundedPricesView calls go to the zero address and revert, so all four fail closed rather than running unbounded. Minting and repaying are unaffected because neither calls the bounded oracle. So is any redeem, transfer or exit that moves zero tokens or is made by an account that is not a member of the market, since the check returns before pricing in both cases. A supply-only account such as the Liquidity Hub’s YieldGroup never becomes a market member, so it can still exit a market whose pool has no bounded oracle set.

Only the surface this fork adds or changes is listed here. Everything else matches the shared Comptroller. Three changed functions are covered by sections above rather than entries of their own: setCollateralFactor (also throws UnsafeLiquidationParams, see Floors), preSeizeHook (also throws LiquidationNotAllowed, see Liquidation allowlist) and liquidateCalculateSeizeTokens (prices at the collateral market’s own incentive, see Batch-liquidation routing).

Sets the discount a liquidator receives on the collateral it seizes from a single market.

function setMarketLiquidationIncentive(address vToken, uint256 newLiquidationIncentiveMantissa) external
NameTypeDescription
vTokenaddressThe collateral market to set the incentive for
newLiquidationIncentiveMantissauint256New incentive for this market, scaled by 1e18, at least 1e18 + protocolSeizeShareMantissa of the market
  • Emits NewMarketLiquidationIncentive on success
  • Controlled by AccessControlManager
  • MarketNotListed is thrown if the market is not listed
  • InvalidLiquidationIncentive is thrown if the new incentive would leave the liquidator with less collateral than the debt it repaid, 0 included
  • UnsafeLiquidationParams is thrown if the market’s liquidation threshold times the new incentive would reach 1

Restricts supplying to a market to the accounts on its supply allowlist, or lifts the restriction.

function setSupplyAllowlistEnabled(address vToken, bool enabled) external
NameTypeDescription
vTokenaddressThe market to change the setting for
enabledboolWhether the market should accept supply only from allowlisted accounts
  • Emits SupplyAllowlistEnabledUpdated on success
  • Controlled by AccessControlManager
  • MarketNotListed is thrown if the market is not listed

Adds an account to a market’s supply allowlist or removes it. Takes effect only while that market’s allowlist is enabled. Setting an account to the value it already holds is not an error, so a governance action that overlaps an earlier one still executes.

function setAllowedSupplier(address vToken, address supplier, bool allowed) external
NameTypeDescription
vTokenaddressThe market whose allowlist to update
supplieraddressThe account to add or remove
allowedboolWhether the account should be allowed to supply
  • Emits AllowedSupplierUpdated on success
  • Controlled by AccessControlManager
  • MarketNotListed is thrown if the market is not listed
  • ZeroAddressNotAllowed is thrown if the account is the zero address

Restricts seizing collateral in this pool to the accounts on the liquidation allowlist, or lifts the restriction. Also restricts healAccount.

function setLiquidationAllowlistEnabled(bool enabled) external
NameTypeDescription
enabledboolWhether seizing collateral should be restricted to allowlisted accounts
  • Emits LiquidationAllowlistEnabledUpdated on success
  • Controlled by AccessControlManager

Adds an account to the pool’s liquidation allowlist or removes it. Takes effect only while the allowlist is enabled.

function setAllowedLiquidator(address liquidator, bool allowed) external
NameTypeDescription
liquidatoraddressThe account to add or remove
allowedboolWhether the account should be allowed to seize collateral
  • Emits AllowedLiquidatorUpdated on success
  • Controlled by AccessControlManager
  • ZeroAddressNotAllowed is thrown if the account is the zero address

Adds a market to another account’s liquidity calculation, enabling it as collateral for that account.

function enterMarketForAccount(address account, address vToken) external
NameTypeDescription
accountaddressThe account to enable it for
vTokenaddressThe market to enable
  • Emits MarketEntered when the account was not already a member; nothing is emitted otherwise
  • Controlled by AccessControlManager
  • ActionPaused is thrown if entering the market is paused
  • MarketNotListed is thrown if the market is not listed
  • ZeroAddressNotAllowed is thrown when the account address is zero

Sets the deviation-bounded oracle the pool prices borrowing capacity through.

function setDeviationBoundedOracle(IDeviationBoundedOracle newBoundedOracle) external
NameTypeDescription
newBoundedOracleIDeviationBoundedOracleAddress of the new deviation-bounded oracle
  • Emits NewDeviationBoundedOracle on success
  • Only the owner (governance)
  • ZeroAddressNotAllowed is thrown when the new oracle address is zero

Sets the liquidation incentive applied to any market that has no incentive of its own. Unchanged from the shared Comptroller except for the floor and the revert type.

function setLiquidationIncentive(uint256 newLiquidationIncentiveMantissa) external
NameTypeDescription
newLiquidationIncentiveMantissauint256New pool-wide incentive, scaled by 1e18, at least 1.05e18
  • Emits NewLiquidationIncentive on success
  • Controlled by AccessControlManager
  • InvalidLiquidationIncentive is thrown if the new incentive is below MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA

The discount that applies to a market: its own if it has one, otherwise the pool-wide value.

function effectiveLiquidationIncentive(address vToken) external view returns (uint256)
NameTypeDescription
vTokenaddressThe collateral market to read the discount of
NameTypeDescription
[0]uint256The discount that prices this market’s collateral, scaled by 1e18

The discount a liquidator receives on the collateral it seizes from the calling market. Answers for msg.sender rather than taking the market as an argument. See Per-market liquidation incentive.

function liquidationIncentiveMantissa() external view returns (uint256)
NameTypeDescription
[0]uint256The discount that applies to the caller, scaled by 1e18

Seizes all of a given account’s collateral, requiring the caller to repay maxClearableDebt / borrows of its debt in every market. The unpaid debt is recorded as badDebt on each market. The Shortfall auction contract cannot auction it: it only finds pools in the pool registry it is configured with, and that registry does not hold spoke pools. Recovery is a governance action instead. badDebtRecovered can only be called by the address stored in the market’s shortfall variable, and on BNB Chain that address is the Normal Timelock, so a proposal sends the underlying to the market and calls it to write the recovered amount off badDebt.

The heal is all or nothing: every seizure and repayment runs in this one call, so a pause that blocks any of them reverts the whole heal.

function healAccount(address user) external
NameTypeDescription
useraddressThe account to heal
  • Not restricted while the liquidation allowlist is disabled, otherwise the caller has to be on it
  • LiquidationNotAllowed is thrown if the liquidation allowlist is enabled and the caller is not on it
  • CollateralExceedsThreshold is thrown when the account’s collateral is above minLiquidatableCollateral
  • CollateralCoversDebt is thrown when the collateral can clear the whole debt at each market’s own incentive, which leaves nothing to heal; use liquidateAccount
  • InsufficientShortfall is thrown when the account is not liquidatable
  • ActionPaused is thrown if SEIZE is paused in a market the account holds collateral in, or LIQUIDATE or REPAY is paused in a market it owes a nonzero balance in (REPAY only when the repay amount is not zero)
  • SnapshotError is thrown if a market fails to return the account’s snapshot
  • PriceError is thrown if the oracle returns a zero price for a market the account is in

Liquidates all of a borrower’s positions in one call, executing the supplied orders. Skips the close-factor check.

function liquidateAccount(address borrower, struct LiquidationOrder[] orders) external
NameTypeDescription
borroweraddressThe account to liquidate
ordersLiquidationOrder[]The (vTokenCollateral, vTokenBorrowed, repayAmount) orders to execute
  • Not restricted while the liquidation allowlist is disabled, otherwise the caller has to be on it
  • LiquidationNotAllowed is thrown if the liquidation allowlist is enabled and the caller is not on it
  • CollateralExceedsThreshold is thrown when the account’s collateral is above minLiquidatableCollateral
  • DebtExceedsClearableAmount is thrown when the debt is too large for the collateral to clear; use healAccount
  • MarketNotListed is thrown if either market in any order is not listed
  • NonzeroBorrowBalanceAfterLiquidation is thrown if a debt remains in any of the borrower’s markets once every order has executed
  • InsufficientShortfall is thrown when the account is not liquidatable
  • MaxLoopsLimitExceeded is thrown if half the number of orders exceeds the loop limit (the check is _ensureMaxLoops(ordersCount / 2))
  • ActionPaused is thrown if LIQUIDATE or REPAY is paused in a borrowed market, or SEIZE in a collateral market, of any order
  • TooMuchRepay is thrown if an order repays more than the borrow balance
  • MarketNotCollateral is thrown if the borrower has not entered the collateral market of an order

Unchanged apart from the allowlist check.

function preMintHook(address vToken, address minter, uint256 mintAmount) external
  • SupplyNotAllowed is thrown if the market’s supply allowlist is enabled and the minter (the account credited with the vTokens, not necessarily the payer) is not on it
  • plus every error the shared Comptroller throws here

mintVerify, redeemVerify, borrowVerify, repayBorrowVerify, liquidateBorrowVerify, seizeVerify and transferVerify are all no-ops. They cannot be removed: ComptrollerInterface declares all seven, and the VToken calls each of them with a plain external call against a Comptroller that has no fallback, so a missing function would revert the operation it belongs to.

Everything the shared Comptroller emits, plus:

EventEmitted when
NewMarketLiquidationIncentive(address indexed vToken, uint256 oldLiquidationIncentiveMantissa, uint256 newLiquidationIncentiveMantissa)a single market’s liquidation incentive changes
NewDeviationBoundedOracle(IDeviationBoundedOracle oldBoundedOracle, IDeviationBoundedOracle newBoundedOracle)the deviation-bounded oracle changes
SupplyAllowlistEnabledUpdated(address indexed vToken, bool enabled)a market’s supply allowlist is enabled or disabled
AllowedSupplierUpdated(address indexed vToken, address indexed supplier, bool allowed)an account is added to or removed from a market’s supply allowlist
LiquidationAllowlistEnabledUpdated(bool enabled)the pool’s liquidation allowlist is enabled or disabled
AllowedLiquidatorUpdated(address indexed liquidator, bool allowed)an account is added to or removed from the liquidation allowlist

NewPrimeToken is not emitted. Prime is not part of a spoke pool.

ErrorMeaning
SupplyNotAllowed(address market, address supplier)the market’s supply allowlist is enabled and the account being credited is not on it
LiquidationNotAllowed(address liquidator)the liquidation allowlist is enabled and the account seizing is not on it
InvalidLiquidationIncentive()an incentive below 1e18 + protocolSeizeShareMantissa for a market, or below MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA for the pool-wide value
CollateralCoversDebt(uint256 borrows, uint256 maxClearableDebt)healAccount on an account whose collateral can clear the whole debt
DebtExceedsClearableAmount(uint256 borrows, uint256 maxClearableDebt)liquidateAccount on an account whose debt the collateral cannot clear
NonzeroBorrowBalanceAfterLiquidation()the orders passed to liquidateAccount did not cover the whole position
UnsafeLiquidationParams(uint256 liquidationThresholdMantissa, uint256 liquidationIncentiveMantissa)a market’s liquidation threshold times its liquidation incentive would reach 1, the point from which a liquidation stops reducing the account’s shortfall

The fork converts every revert string of its own to a custom error. The inherited MaxLoopsLimitHelper and OpenZeppelin Ownable / Ownable2Step strings are left as they are.

ErrorReplaces
InvalidCloseFactor()"Close factor greater than maximum close factor" / "Close factor smaller than minimum close factor"
InvalidVToken()"Comptroller: Invalid vToken"
InvalidArrayLength()"invalid input" / "invalid number of markets"
RewardsDistributorAlreadyExists()"already exists"
MarketNotListed(address)"cannot pause a market that is not listed"
NonzeroBorrowBalanceAfterLiquidation()"Nonzero borrow balance after liquidation"
InvalidLiquidationIncentive()"liquidation incentive should be greater than 1e18"
  • CollateralExceedsThreshold(uint256 expectedLessThanOrEqualTo, uint256 actual) keeps the shared Comptroller’s name, arguments and selector, and is thrown by both batch operations when the account’s collateral is above minLiquidatableCollateral. In the shared Comptroller healAccount also uses it for the “collateral covers the debt” case; here that case is CollateralCoversDebt.
  • InsufficientCollateral does not exist in this fork. liquidateAccount throws DebtExceedsClearableAmount instead. Both of its arguments are debt values rather than collateral values, and reusing the old name would leave two incompatible versions sharing one selector.