Platform: Code4rena
Start Date: 25/08/2022
Pot Size: $75,000 USDC
Total HM: 35
Participants: 147
Period: 7 days
Judge: 0xean
Total Solo HM: 15
Id: 156
League: ETH
Rank: 31/147
Findings: 4
Award: $607.78
🌟 Selected for report: 1
🚀 Solo Findings: 0
11.0311 DAI - $11.03
Currently no price validity check is performed in getCurrentPrice(). This way zero _ohmEthPriceFeed.latestRoundData
produced prices will yield zero getCurrentPrice() which will be passed over to the logic. Also, negative OHM price or zero / negative reserve _reserveEthPriceFeed.latestRoundData
produced price will yield low level error from uint256 currentPrice = (ohmEthPrice * _scaleFactor) / reserveEthPrice
logic.
Passing zero price to the protocol logic can lead to understated moving average values that will break up the downstream logic as it heavily relies on the correct market prices being aggregated to the moving average.
Now Oracle read price isn't controlled to be positive:
/// @notice Get the current price of OHM in the Reserve asset from the price feeds function getCurrentPrice() public view returns (uint256) { if (!initialized) revert Price_NotInitialized(); // Get prices from feeds uint256 ohmEthPrice; uint256 reserveEthPrice; { (, int256 ohmEthPriceInt, , uint256 updatedAt, ) = _ohmEthPriceFeed.latestRoundData(); // Use a multiple of observation frequency to determine what is too old to use. // Price feeds will not provide an updated answer if the data doesn't change much. // This would be similar to if the feed just stopped updating; therefore, we need a cutoff. if (updatedAt < block.timestamp - 3 * uint256(observationFrequency)) revert Price_BadFeed(address(_ohmEthPriceFeed)); ohmEthPrice = uint256(ohmEthPriceInt); int256 reserveEthPriceInt; (, reserveEthPriceInt, , updatedAt, ) = _reserveEthPriceFeed.latestRoundData(); if (updatedAt < block.timestamp - uint256(observationFrequency)) revert Price_BadFeed(address(_reserveEthPriceFeed)); reserveEthPrice = uint256(reserveEthPriceInt); } // Convert to OHM/RESERVE price uint256 currentPrice = (ohmEthPrice * _scaleFactor) / reserveEthPrice; return currentPrice; }
getCurrentPrice() produced prices are aggregated into moving average:
function updateMovingAverage() external permissioned { // Revert if not initialized if (!initialized) revert Price_NotInitialized(); // Cache numbe of observations to save gas. uint32 numObs = numObservations; // Get earliest observation in window uint256 earliestPrice = observations[nextObsIndex]; uint256 currentPrice = getCurrentPrice();
updateMovingAverage() is regularly called via heartbeats:
/// @inheritdoc IHeart function beat() external nonReentrant { if (!active) revert Heart_BeatStopped(); if (block.timestamp < lastBeat + frequency()) revert Heart_OutOfCycle(); // Update the moving average on the Price module PRICE.updateMovingAverage();
And then it's used downstream in the business logic:
/// @notice Update the prices on the RANGE module function _updateRangePrices() internal { /// Get latest moving average from the price module uint256 movingAverage = PRICE.getMovingAverage(); /// Update the prices on the range module RANGE.updatePrices(movingAverage); }
function _addObservation() internal { /// Get latest moving average from the price module uint256 movingAverage = PRICE.getMovingAverage();
Consider adding a non-zero Oracle price check, i.e. requiring that ohmEthPriceInt > 0
and reserveEthPriceInt > 0
as otherwise the current Oracle reading is incorrect, being the result of a malfunction on Oracle side, and the moving average shouldn't be updated.
#0 - Oighty
2022-09-06T18:51:43Z
Duplicate. See comment on #441.
🌟 Selected for report: hyh
Also found by: CertoraInc, d3e4, rbserver
347.2615 DAI - $347.26
Now the precision is lost in moving average calculations as the difference is calculated separately and added each time, while it typically can be small enough to lose precision in the division involved.
For example, 10000
moves of 990
size, numObservations = 1000
. This will yield 0
on each update, while must yield 9900
increase in the moving average.
Moving average is calculated with the addition of the difference:
// Calculate new moving average if (currentPrice > earliestPrice) { _movingAverage += (currentPrice - earliestPrice) / numObs; } else { _movingAverage -= (earliestPrice - currentPrice) / numObs; }
/ numObs
can lose precision as currentPrice - earliestPrice
is usually small.
It is returned on request as is:
/// @notice Get the moving average of OHM in the Reserve asset over the defined window (see movingAverageDuration and observationFrequency). function getMovingAverage() external view returns (uint256) { if (!initialized) revert Price_NotInitialized(); return _movingAverage; }
Consider storing the cumulative sum
, while returning sum / numObs
on request:
/// @notice Get the moving average of OHM in the Reserve asset over the defined window (see movingAverageDuration and observationFrequency). function getMovingAverage() external view returns (uint256) { if (!initialized) revert Price_NotInitialized(); - return _movingAverage; + return _movingAverage / numObservations; }
#0 - Oighty
2022-09-06T17:06:57Z
Keeping track of the observations as a sum and then dividing is a good suggestion. The price values have 18 decimals and the max discrepancy introduced is very small (10**-15) with expected parameter ranges. The potential risk to the protocol seems low though.
#1 - dmitriia
2022-09-08T17:13:38Z
Please notice that discrepancy here is unbounded, i.e. the logic itself do not have any max discrepancy, the divergence between fact and recorded value can pile up over time without a limit.
If you do imply that markets should behave in some way that minuses be matched with pluses, then I must say that they really shouldn't.
#2 - 0xean
2022-09-16T23:16:21Z
Debating between QA and Med on this one. I am going to award it as medium because there is a potential to leak some value do this imprecision compounding over time.
🌟 Selected for report: zzzitron
Also found by: 0x040, 0x1f8b, 0x52, 0x85102, 0xDjango, 0xNazgul, 0xNineDec, 0xSky, 0xSmartContract, 0xkatana, 8olidity, Aymen0909, Bahurum, BipinSah, Bnke0x0, CRYP70, CertoraInc, Ch_301, Chandr, Chom, CodingNameKiki, Deivitto, DimSon, Diraco, ElKu, EthLedger, Funen, GalloDaSballo, Guardian, IllIllI, JansenC, Jeiwan, Lambda, LeoS, Margaret, MasterCookie, PPrieditis, PaludoX0, Picodes, PwnPatrol, RaymondFam, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, StevenL, The_GUILD, TomJ, Tomo, Trust, Waze, __141345__, ajtra, ak1, apostle0x01, aviggiano, bin2chen, bobirichman, brgltd, c3phas, cRat1st0s, carlitox477, cccz, ch13fd357r0y3r, cloudjunky, cryptphi, csanuragjain, d3e4, datapunk, delfin454000, devtooligan, dipp, djxploit, durianSausage, eierina, enckrish, erictee, fatherOfBlocks, gogo, grGred, hansfriese, hyh, ignacio, indijanc, itsmeSTYJ, ladboy233, lukris02, martin, medikko, mics, natzuu, ne0n, nxrblsrpr, okkothejawa, oyc_109, p_crypt0, pfapostol, prasantgupta52, rajatbeladiya, rbserver, reassor, ret2basic, robee, rokinot, rvierdiiev, shenwilly, sikorico, sorrynotsorry, tnevler, tonisives, w0Lfrum, yixxas
216.9067 DAI - $216.91
// Cache numbe of observations to save gas. uint32 numObs = numObservations;
- // Cache numbe of observations to save gas. + // Cache number of observations to save gas. uint32 numObs = numObservations;
} else if (action_ == Actions.ChangeExecutor) { executor = target_; } else if (action_ == Actions.ChangeAdmin) { admin = target_;
Consider utilizing two-step ownership transferring process (proposition and acceptance in the separate actions) with a noticeable delay between the steps to enforce the transparency and stability of the system.
pragma solidity 0.8.15;
Update to 0.8.16 as a bug was fixed in 0.8.15
(, int256 ohmEthPriceInt, , uint256 updatedAt, ) = _ohmEthPriceFeed.latestRoundData(); // Use a multiple of observation frequency to determine what is too old to use. // Price feeds will not provide an updated answer if the data doesn't change much. // This would be similar to if the feed just stopped updating; therefore, we need a cutoff. if (updatedAt < block.timestamp - 3 * uint256(observationFrequency)) revert Price_BadFeed(address(_ohmEthPriceFeed));
/// @notice Set the wall and cushion spreads. /// @notice Access restricted to activated policies. /// @param cushionSpread_ - Percent spread to set the cushions at above/below the moving average, assumes 2 decimals (i.e. 1000 = 10%). /// @param wallSpread_ - Percent spread to set the walls at above/below the moving average, assumes 2 decimals (i.e. 1000 = 10%). /// @dev The new spreads will not go into effect until the next time updatePrices() is called. function setSpreads(uint256 cushionSpread_, uint256 wallSpread_) external permissioned { // Confirm spreads are within allowed values if ( wallSpread_ > 10000 || wallSpread_ < 100 || cushionSpread_ > 10000 || cushionSpread_ < 100 || cushionSpread_ > wallSpread_ ) revert RANGE_InvalidParams();
Amount calculations can become incorrect with a range of impacts up to fund loss and insolvency if the values hard coded across the implementation mix up on future development.
Numerical bases differ and are hardcoded across the logic:
/// @notice Submit an on chain governance proposal. /// @param instructions_ - an array of Instruction objects each containing a Kernel Action and a target Contract address. /// @param title_ - a human-readable title of the proposal — i.e. "OIP XX - My Proposal Title". /// @param proposalURI_ - an arbitrary url linking to a human-readable description of the proposal - i.e. Snapshot, Discourse, Google Doc. function submitProposal( Instruction[] calldata instructions_, bytes32 title_, string memory proposalURI_ ) external { if (VOTES.balanceOf(msg.sender) * 10000 < VOTES.totalSupply() * SUBMISSION_REQUIREMENT) revert NotEnoughVotesToPropose();
if ( (totalEndorsementsForProposal[proposalId_] * 100) < VOTES.totalSupply() * ENDORSEMENT_THRESHOLD ) { revert NotEnoughEndorsementsToActivateProposal(); }
/// @notice Execute the currently active proposal. function executeProposal() external { uint256 netVotes = yesVotesForProposal[activeProposal.proposalId] - noVotesForProposal[activeProposal.proposalId]; if (netVotes * 100 < VOTES.totalSupply() * EXECUTION_THRESHOLD) { revert NotEnoughVotesToExecute(); }
Consider unifying the bases and introducing the bp constant, for example:
/// @notice The amount of votes a proposer needs in order to submit a proposal as a percentage of total supply (in basis points). /// @dev This is set to 1% of the total supply. uint256 public constant SUBMISSION_REQUIREMENT = 100; /// @notice Amount of time a submitted proposal has to activate before it expires. uint256 public constant ACTIVATION_DEADLINE = 2 weeks; /// @notice Amount of time an activated proposal must stay up before it can be replaced by a new activated proposal. uint256 public constant GRACE_PERIOD = 1 weeks; - /// @notice Endorsements required to activate a proposal as percentage of total supply. + /// @notice Endorsements required to activate a proposal as percentage of total supply, in basis points. - uint256 public constant ENDORSEMENT_THRESHOLD = 20; + uint256 public constant ENDORSEMENT_THRESHOLD = 2000; - /// @notice Net votes required to execute a proposal on chain as a percentage of total supply. + /// @notice Net votes required to execute a proposal on chain as a percentage of total supply, in basis points. - uint256 public constant EXECUTION_THRESHOLD = 33; + uint256 public constant EXECUTION_THRESHOLD = 3300; + /// @notice Basis point base. + uint256 public constant BP_BASE = 10000;
/// @notice Submit an on chain governance proposal. /// @param instructions_ - an array of Instruction objects each containing a Kernel Action and a target Contract address. /// @param title_ - a human-readable title of the proposal — i.e. "OIP XX - My Proposal Title". /// @param proposalURI_ - an arbitrary url linking to a human-readable description of the proposal - i.e. Snapshot, Discourse, Google Doc. function submitProposal( Instruction[] calldata instructions_, bytes32 title_, string memory proposalURI_ ) external { - if (VOTES.balanceOf(msg.sender) * 10000 < VOTES.totalSupply() * SUBMISSION_REQUIREMENT) + if (VOTES.balanceOf(msg.sender) * BP_BASE < VOTES.totalSupply() * SUBMISSION_REQUIREMENT) revert NotEnoughVotesToPropose();
if ( - (totalEndorsementsForProposal[proposalId_] * 100) < + (totalEndorsementsForProposal[proposalId_] * BP_BASE) < VOTES.totalSupply() * ENDORSEMENT_THRESHOLD ) { revert NotEnoughEndorsementsToActivateProposal(); }
/// @notice Execute the currently active proposal. function executeProposal() external { uint256 netVotes = yesVotesForProposal[activeProposal.proposalId] - noVotesForProposal[activeProposal.proposalId]; - if (netVotes * 100 < VOTES.totalSupply() * EXECUTION_THRESHOLD) { + if (netVotes * BP_BASE < VOTES.totalSupply() * EXECUTION_THRESHOLD) { revert NotEnoughVotesToExecute(); }
_addObservation() low and high cases comments state more restrictive logic:
/// Observation is positive if the current price is greater than the MA uint32 observe = _config.regenObserve; Regen memory regen = _status.low; if (currentPrice >= movingAverage) {
/// Observation is positive if the current price is less than the MA regen = _status.high; if (currentPrice <= movingAverage) {
- /// Observation is positive if the current price is greater than the MA + /// Observation is positive if the current price not less than the MA uint32 observe = _config.regenObserve; Regen memory regen = _status.low; if (currentPrice >= movingAverage) {
- /// Observation is positive if the current price is less than the MA + /// Observation is positive if the current price is not greater than the MA regen = _status.high; if (currentPrice <= movingAverage) {
regenerate() makes a thresholdFactor
effective after the change, but this isn't broadcasted in WallUp, i.e. the change is in fact performed silently.
regenerate() omits new threshold in the broadcast, while it's both new capacity_
and new thresholdFactor
can be made effective at that moment:
/// @notice Regenerate a side of the range to a specific capacity. /// @notice Access restricted to activated policies. /// @param high_ - Specifies the side of the range to regenerate (true = high side, false = low side). /// @param capacity_ - Amount to set the capacity to (OHM tokens for high side, Reserve tokens for low side). function regenerate(bool high_, uint256 capacity_) external permissioned { uint256 threshold = (capacity_ * thresholdFactor) / FACTOR_SCALE;
emit WallUp(high_, block.timestamp, capacity_); }
As it's only regenerate() makes current thresholdFactor
effective after the change:
/// @dev The new threshold factor will not go into effect until the next time regenerate() is called for each side of the wall. function setThresholdFactor(uint256 thresholdFactor_) external permissioned {
WallDown depends on the capacity_
vs threshold
situation, but also does not emit the current threshold
:
// If the new capacity is below the threshold, deactivate the wall if they are currently active if (capacity_ < _range.high.threshold && _range.high.active) { // Set wall to inactive _range.high.active = false; _range.high.lastActive = uint48(block.timestamp); emit WallDown(true, block.timestamp, capacity_); }
// If the new capacity is below the threshold, deactivate the wall if they are currently active if (capacity_ < _range.low.threshold && _range.low.active) { // Set wall to inactive _range.low.active = false; _range.low.lastActive = uint48(block.timestamp); emit WallDown(false, block.timestamp, capacity_); }
Consider adding the thresholds to enhance transparency:
- emit WallUp(high_, block.timestamp, capacity_); + emit WallUp(high_, block.timestamp, capacity_, threshold); }
// If the new capacity is below the threshold, deactivate the wall if they are currently active if (capacity_ < _range.high.threshold && _range.high.active) { // Set wall to inactive _range.high.active = false; _range.high.lastActive = uint48(block.timestamp); - emit WallDown(true, block.timestamp, capacity_); + emit WallDown(true, block.timestamp, capacity_, _range.high.threshold); }
// If the new capacity is below the threshold, deactivate the wall if they are currently active if (capacity_ < _range.low.threshold && _range.low.active) { // Set wall to inactive _range.low.active = false; _range.low.lastActive = uint48(block.timestamp); - emit WallDown(false, block.timestamp, capacity_); + emit WallDown(false, block.timestamp, capacity_, _range.low.threshold); }
- event WallUp(bool high_, uint256 timestamp_, uint256 capacity_); - event WallDown(bool high_, uint256 timestamp_, uint256 capacity_); + event WallUp(bool high_, uint256 timestamp_, uint256 capacity_, uint256 threshold_); + event WallDown(bool high_, uint256 timestamp_, uint256 capacity_, uint256 threshold_);
🌟 Selected for report: pfapostol
Also found by: 0x040, 0x1f8b, 0x85102, 0xDjango, 0xNazgul, 0xNineDec, 0xSmartContract, 0xkatana, Amithuddar, Aymen0909, Bnke0x0, CertoraInc, Chandr, CodingNameKiki, Deivitto, Dionysus, Diraco, ElKu, Fitraldys, Funen, GalloDaSballo, Guardian, IllIllI, JC, JansenC, Jeiwan, LeoS, Metatron, Noah3o6, RaymondFam, ReyAdmirado, Rohan16, RoiEvenHaim, Rolezn, Ruhum, Saintcode_, Shishigami, Sm4rty, SooYa, StevenL, Tagir2003, The_GUILD, TomJ, Tomo, Waze, __141345__, ajtra, apostle0x01, aviggiano, bobirichman, brgltd, c3phas, cRat1st0s, carlitox477, cccz, ch0bu, chrisdior4, d3e4, delfin454000, djxploit, durianSausage, erictee, exolorkistis, fatherOfBlocks, gogo, grGred, hyh, ignacio, jag, karanctf, kris, ladboy233, lukris02, m_Rassska, martin, medikko, natzuu, ne0n, newfork01, oyc_109, peiw, rbserver, ret2basic, robee, rokinot, rvierdiiev, sikorico, simon135, tnevler, zishansami
32.5835 DAI - $32.58
The general case is always run, while there will be many one element operations, when it's excessive:
uint256 origIndex = getDependentIndex[keycode][policy_]; Policy lastPolicy = dependents[dependents.length - 1]; // Swap with last and pop dependents[origIndex] = lastPolicy; dependents.pop(); // Record new index and delete deactivated policy index getDependentIndex[keycode][lastPolicy] = origIndex; delete getDependentIndex[keycode][policy_];
Consider optimizing for the one element case:
uint256 origIndex = getDependentIndex[keycode][policy_]; + uint256 dependentsLength = dependents.length; + if (dependentsLength > 1) { - Policy lastPolicy = dependents[dependents.length - 1]; + Policy lastPolicy = dependents[dependentsLength - 1]; // Swap with last and pop dependents[origIndex] = lastPolicy; dependents.pop(); // Record new index and delete deactivated policy index getDependentIndex[keycode][lastPolicy] = origIndex; + } else { + delete dependents; + } delete getDependentIndex[keycode][policy_];