Skip to content

[VPD-893]: Add DeviationBoundedOracle Contracts#306

Open
GitGuru7 wants to merge 9 commits intodevelopfrom
feat/vpd-893-bounded-price-oracle
Open

[VPD-893]: Add DeviationBoundedOracle Contracts#306
GitGuru7 wants to merge 9 commits intodevelopfrom
feat/vpd-893-bounded-price-oracle

Conversation

@GitGuru7
Copy link
Copy Markdown
Contributor

Description

Adds DeviationBoundedOracle contract that wraps the Resilient Oracle and provides manipulation-resistant pricing for lending operations
Maintains a per-market rolling min/max price window and automatically activates protection mode when the spot price deviates significantly from the window bounds
In protection mode, collateral is valued at min(spot, windowMin) and debt at max(spot, windowMax), guarding against short-duration price manipulation on low-liquidity tokens
Supports configurable entry/exit thresholds with hysteresis and per-market cooldown periods
Adds IDeviationBoundedOracle interface

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 31, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 034ec313-44e7-4e98-860b-da7d1d24f54d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/vpd-893-bounded-price-oracle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

*/
constructor(ResilientOracleInterface _resilientOracle, address nativeMarketAddress, address vaiAddress) {
ensureNonzeroAddress(address(_resilientOracle));
ensureNonzeroAddress(nativeMarketAddress);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ensureNonzeroAddress(nativeMarketAddress);
ensureNonzeroAddress(nativeMarketAddress);
ensureNonzeroAddress(vaiAddress);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
_expandPriceWindow(state, spot, asset);
_checkAndTriggerProtection(state, spot, asset);
(minPrice, maxPrice) = _resolveBoundedPrices(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state.minPrice, state.maxPrice, and state.isProtectedPriceActive are all re-read from storage here after _expandPriceWindow and _checkAndTriggerProtection have already loaded (and potentially written) them. That's up to 3 avoidable warm SLOADs on every call. Consider having _expandPriceWindow return (uint128 windowMin, uint128 windowMax) and _checkAndTriggerProtection return bool nowActive, then pass those locals in directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

address asset = _getUnderlyingAsset(vToken);
MarketProtectionState storage state = assetProtectionConfig[asset];
uint256 spot = _fetchSpotPrice(asset);
if (!state.isBoundedPricingEnabled) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If setTokenConfig was never called for the asset, state is zero-initialized so isBoundedPricingEnabled == false and this silently returns (spot, spot) with no protection — every other helper here uses _ensureInitialized and reverts. We should add _ensureInitialized(asset) at the top here as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ensureEnabled is only called by helpers that operate on assets enabled for bounded pricing. Returning the spot price here is intentional, it prevents non-enabled assets from falling back to the resilient oracle, avoiding additional branching in the Comptroller.

Comment on lines +25 to +26
/// @notice Timestamp of the last protection trigger — reset on every trigger
uint64 lastProtectionTriggeredAt;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_checkAndTriggerProtection early-returns when isProtectedPriceActive == true, so this timestamp is only ever set on the initial false→true transition — it's never refreshed for subsequent anomalies during the same protection cycle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, as per the design this should be updated on each call, but I was thinking the threshold breach itself might be sufficient.

I think it should be fine to rely on a fixed cooldown from when it was triggered, since additionally we also have a threshold based check. This avoids repeated updates on every call, keeping it open here.

* @custom:error PriceRangeNotConverged if window range is still above exit threshold
* @custom:event ProtectedPriceDisabled
*/
function disableActiveProtection(address asset) external {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For address(0) or any uninitialized asset, state is the zero default with isProtectedPriceActive == false, so this reverts with ProtectedPriceInactive — a misleading error suggesting the asset is configured but inactive. Add ensureNonzeroAddress(asset) and use _ensureInitialized(asset) like the other governance functions, so callers get MarketNotInitialized instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert ProtectedPriceActive(asset);
}

state.isBoundedPricingEnabled = enabled;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling with enabled equal to the current value still pays an SSTORE and emits a misleading BoundedPricingWhitelistUpdated event. Add if (state.isBoundedPricingEnabled == enabled) return; before the write.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revert ProtectedPriceActive(asset);
}

state.isBoundedPricingEnabled = enabled;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-enabling a previously disabled asset leaves the window state stale. If asset was disabled at spot=$15 with window ($13, $17) and re-enabled later at spot=$80, the next public call expands maxPrice to $80, fires the trigger ($80 > $13 * 1.1667), and returns collateral=$13 for an asset worth $80 — mass liquidations follow.

when flipping enabled false → true, we should re-seed minPrice = maxPrice = _fetchSpotPrice(asset).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GitGuru7
Copy link
Copy Markdown
Contributor Author

GitGuru7 commented Apr 8, 2026

@coderabbitai review

@GitGuru7 GitGuru7 marked this pull request as ready for review April 9, 2026 06:14
minPrice: spotU128,
maxPrice: spotU128,
isProtectedPriceActive: false,
isBoundedPricingEnabled: true,
Copy link
Copy Markdown
Contributor

@fred-venus fred-venus Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about we have isBoundedPricingEnabled pass in as a param

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes would be more flexible adde here: 58ce852

function disableActiveProtection(address asset) external {
_checkAccessAllowed("disableActiveProtection(address)");
ensureNonzeroAddress(asset);
_ensureInitialized(asset);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    MarketProtectionState storage state = _ensureInitialized(asset); ? 

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


MarketProtectionState storage state = _ensureInitialized(asset);

if (!enabled && state.isProtectedPriceActive) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about rename isProtectedPriceActive to currentlyUsingProtectedPrice etc..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* @custom:event ProtectionInitialized
* @custom:event BoundedPricingWhitelistUpdated
*/
function setTokenConfig(
Copy link
Copy Markdown
Contributor

@fred-venus fred-venus Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally we should have batch version for the following setters as we might want to enable this for mostly all the assets

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (rangeRatio >= state.resetThreshold) {
revert PriceRangeNotConverged(asset, rangeRatio, state.resetThreshold);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastProtectionTriggeredAt = 0 ?

uint256 spot,
address asset
) internal returns (bool triggered) {
if (state.isProtectedPriceActive) return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try not to early return here, as we need to update the lastProtectionTriggerAt, imagine that protectedPriceActive has already been set to true, and then the market is volatile and constantly exceeds the deviation threshold. In this case, we need to keep lastProtectionTriggerAt up to date as well. In the current implementation, this is always going to be the stale value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

constructor(ResilientOracleInterface _resilientOracle, address nativeMarketAddress, address vaiAddress) {
ensureNonzeroAddress(address(_resilientOracle));
ensureNonzeroAddress(nativeMarketAddress);
ensureNonzeroAddress(vaiAddress);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have vai on on bsc chain ?

Copy link
Copy Markdown
Contributor Author

@GitGuru7 GitGuru7 Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we do have it, there isn’t any market for it, just the token address. Still, I think it’s good to keep the bounded oracle consistent across all assets for overall oracle behavior.

@github-actions
Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
contracts 99% 89%
contracts.interfaces 100% 100%
contracts.lib 100% 100%
contracts.oracles 83% 76%
contracts.oracles.common 98% 93%
Summary 93% (540 / 580) 85% (261 / 306)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants