The rate-provider stack answers one question for an UltraVault: how much is an amount of some supported asset worth in the vault's base asset? It splits that into two layers so valuation policy and raw pricing stay independent.
- Registry layer —
UltraVaultRateProvider, one per vault. An owner-curated map from each supported asset to how it is valued, plus theAdjustment-aware entrypoints the vault calls. - Conversion layer — standalone pricing primitives, each implementing the minimal
IRateProviderinterface. The registry delegates to these for any asset that is not pegged 1:1 to the base asset.
The seam between the layers is IRateProvider: when an asset is not pegged, the registry calls IRateProvider(rateProvider).convertToUnderlying(...) / .convertFromUnderlying(...).
flowchart TD
Vault -->|"convertToUnderlying(asset, amt, Adjustment)"| Reg[UltraVaultRateProvider]
Reg -->|pegged| Dec[decimal rescale only]
Reg -->|"not pegged: IRateProvider"| RP[external rate provider]
Reg --> Adj[_applyAdjustment]
The two-function interface every conversion primitive implements:
convertToUnderlying(address asset, uint256 assets)— convert an amount ofassetinto the provider's underlying units.convertFromUnderlying(address asset, uint256 baseAssets)— the inverse.
The interface NatSpec mentions 18 decimals, but concrete providers return amounts in their own underlying token's native decimals; treat outputs as "in the provider's underlying-asset units."
A non-upgradeable Ownable2Step contract. Its constructor (address _owner, address _baseAsset) records baseAsset and baseDecimals (read from IERC20Metadata), then self-registers the base asset as pegged with no provider. The base asset is therefore always supported, always pegged 1:1, and can never be removed or updated (CannotUpdateBaseAsset).
Each asset is stored in a packed AssetSlot { decimals, adjustmentRate, rateProvider, __padding }. A slot with rateProvider == address(0) is pegged (value 1:1, only decimals differ); a non-zero rateProvider points at an IRateProvider. Support is keyed on non-zero decimals, which is why decimals must land in [MIN_DECIMALS, MAX_DECIMALS] = [6, 18].
Reads. getAssetInfo(asset) returns (isPegged, decimals, rateProvider, adjustmentRate); getSupportedAssets() lists every registered asset; isSupported(asset), baseAsset(), and baseDecimals() round out the view surface.
Owner writes (all onlyOwner):
addAsset(asset, isPegged, rateProvider, adjustmentRate)— validates the asset is new (AssetAlreadySupported), that peg and provider agree (pegged ⇒ zero provider, else non-zero,InvalidRateProvider), that decimals fit[6, 18](InvalidDecimals), andadjustmentRate <= MAX_ADJUSTMENT_RATE(AdjustmentRateTooHigh). EmitsAssetAdded.updateRateProvider(asset, isPegged, newRateProvider)— re-points an asset, also flipping it between pegged and externally-priced under the same consistency rule. EmitsRateProviderUpdated. Decimals are immutable once set.updateAdjustmentRate(asset, newAdjustmentRate)— capped atMAX_ADJUSTMENT_RATE. EmitsAdjustmentRateUpdated.removeAsset(asset)— swap-pops the asset, emitsAssetRemoved.
convertToUnderlying / convertFromUnderlying come in an Adjustment-aware three-arg form and a two-arg form (no adjustment) kept for a not-yet-upgraded V1 vault. For pegged assets, conversion is a pure decimal rescale via _convertDecimals — no price is read. Otherwise the call is forwarded to the external provider.
The result then passes through _applyAdjustment. Adjustment is { DOWN, UP } (SCALE = 1e18 = 100%, MAX_ADJUSTMENT_RATE = 1e16 = 1%):
DOWN→amount * (SCALE - adjustmentRate) / SCALE, rounded down.UP→amount * SCALE / (SCALE - adjustmentRate), rounded up — the exact inverse ofDOWN.
A zero adjustmentRate is a passthrough. The haircut is a conservative buffer (max 1%) applied to provider output so the vault always values an asset slightly in its own favor.
Each is a single-responsibility IRateProvider with no notion of the vault or adjustments. Pick by what you are pricing:
AnyERC4626VaultRateProvider— wraps any ERC-4626 vault, converting between its shares and its own underlying viaconvertToAssets/convertToShares(gated onInvalidAsset). Because its output is the wrapped vault's underlying, not the UltraVault base asset, do not register it directly unless that underlying equals the base asset — otherwise compose it.ChainlinkRateProvider— converts an asset againstunderlyingAssetusing two Chainlink USD feeds. The constructor requires non-zero addresses and staleness, decimals in[6, 18], and matching feed decimals (FeedDecimalsMismatch). Every read validates the answer (InvalidFeedAnswer,StaleFeed,IncompleteRound). A virtual_validateRatehook is a no-op here.StableChainlinkRateProvider— aChainlinkRateProviderfor 1:1 pairs that overrides_validateRateto reject prices deviating from parity by more thanmaxDeviationBps, hard-capped atMAX_DEVIATION_BPS = 300(3%) and owner-tunable viasetMaxDeviationBps(RateDeviationTooHigh,MaxDeviationBpsUpdated). Use it whenever the pair is supposed to hold a peg.ComposableRateProvider— chains two providers through oneintermediateAsset, presenting the pair as a singleIRateProvider. It is a pure router: no decimal math, each leg handles its own units. Since it is itself anIRateProvider, it nests for deeper chains.
To value sUSDe in a USDC-based vault, no single primitive spans the path. Compose two:
flowchart LR
sUSDe -->|"AnyERC4626VaultRateProvider(sUSDe)"| USDe
USDe -->|"StableChainlinkRateProvider(USDe, USDC, peg guard)"| USDC
assetProvider:AnyERC4626VaultRateProvider(sUSDe)prices sUSDe shares in USDe.baseProvider:StableChainlinkRateProvider(USDe, USDC, ...)converts USDe to USDC with a peg guard.- Wrap both in
ComposableRateProvider(intermediateAsset = USDe, assetProvider, baseProvider), thenaddAsset(sUSDe, isPegged = false, rateProvider = composite, adjustmentRate).
This is exactly the case the AnyERC4626VaultRateProvider NatSpec warns must be composed rather than registered directly.
See also: Oracle and Rate Provider System, Architecture, Permissions.