Skip to content

Conversation

@naddison36
Copy link
Collaborator

@naddison36 naddison36 commented Sep 12, 2023

Contract Changes

  • Added floorPrice to the Vault to get the value (USD or ETH) of the collateral assets received from redeeming 1 Origin Token (OUSD or OETH) from the Vault.
  • Added Chainlink style OETHOracle which is updated by OETHOracleUpdater that aggregates on-chain OETH/ETH prices.
  • Added price_oracle to Curve interface which gets the EMA Oracle price

oethOracles

OETHOracleHierarchy

Tests

Unit tests

yarn test ./test/oracle/oracle-oeth.js 

Fork tests

yarn test:fork ./test/oracle/oracle.fork-test.js

Review

If you made a contract change, make sure to complete the checklist below before merging it in master.

Refer to our documentation for more details about contract security best practices.

Contract change checklist:

  • Code reviewed by 2 reviewers.
  • Copy & paste code review security checklist below this checklist.
  • Unit tests pass
  • Slither tests pass with no warning
  • Echidna tests pass if PR includes changes to OUSD contract (not automated, run manually on local)

@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 12, 2023 12:39 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 12, 2023 12:39 Inactive
@github-actions
Copy link

github-actions bot commented Sep 12, 2023

Warnings
⚠️ 👀 This PR needs at least 2 reviewers

Generated by 🚫 dangerJS against 2b83ec6

@naddison36 naddison36 changed the title OETH Oracle WIP: OETH Oracle Sep 12, 2023
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 13, 2023 07:07 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 13, 2023 07:07 Inactive
@notion-workspace
Copy link

@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 13, 2023 11:31 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 13, 2023 11:31 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 13, 2023 12:29 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 13, 2023 12:29 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 13, 2023 12:52 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 13, 2023 12:52 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 14, 2023 06:59 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 14, 2023 06:59 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-oeth-nicka-oeth-73ahqp September 14, 2023 13:23 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 14, 2023 13:23 Inactive
@rafaelugolini rafaelugolini temporarily deployed to preview-ousd-nicka-oeth-naw3a5 September 14, 2023 22:30 Inactive
@notion-workspace
Copy link

@DanielVF
Copy link
Contributor

DanielVF commented Nov 8, 2023

Second pass review looks good.

@DanielVF
Copy link
Contributor

DanielVF commented Nov 9, 2023

Review part 1: Requirements

We want to provide an oracle for OETH for use on lending platforms, both classic lending platforms and CDP style stablecoins, to allow them to determine the value of OETH as collateral.

Oracles for lending platforms and stablecoin CDP collateral have some core considerations:

There is no safe direction for the oracle to fail to.

If an oracle reports a price that is too low, then the borrower' collateral will report as insuffienct, and will be liquidated. This make angrey borrowers. If the reported price is extra low, then these liquidations could operate agaist the protocol as well, making the protocol itself insolvant. If prices are too high, then attackers will deposit OETH, and borrow out more than its value in collateral, making the protocol insolvant.

Oracles only have to be near the right answer.

Because lending platforms / CDP's usually have collateral ratios on ETH of 110%+, 5-8% off in either direction will not result in protocol level insolvancy. This is a pretty big amount of allowed error, and is a good thing. However, even small errors in the direction of low will result in people who were on the edge of liquidation getting liquidated. They would be mad, but at least it's not everybody.

Transient errors are very bad.

A single extreme bad value can cause a lot of reckage. Inherint to large lending platforms are a lot of liquidation bots in a constant race with each other. If bad values happen, the consequences can be as fast as the same block that the oracle update happened in.

Proposed architecture

We will have a chainlink compatibile oracle contract, feed by an on chain updater contract using on chain data, callable by anyone. A chainlink keeper will backstop the calls.

This seems like a good architecure. Great compatibility, cheap reads. No hard requirement that oracle be updated by the origin team, and a good backstop as long as someone keeps feeding the keeper money.

Proposed pricing strategy

Our current plan for getting new prices to use in updates is:

  1. Use the curve price oracle if it is above the redeem fee.
  2. If below the redeem fee, take the higher of the curve price and the redeem price.
  3. Cap the result at one ETH.

How does this work in practice:

Protocol health Curve price Behavior
Healthy Too high 🟢 Correctly capped at 1ETH
Healthy Too low 🟢 Will do approx 0.995ETH, which should be good.
Small Depegged Backing Too high 🟠 Will use curve price. Okay for small depegs.
Small Depegged Backing Too low 🟢 Will use vault floor. Accurate price.
Missing vault collateral Too high 🔴 Will be too high if curve is manipulated up.
Missing vault collateral Too low 🔴 Will assume that the vault has assets to redeem at the target price.
Vault reverts floorPrice() Pegged 🟠 Oracle will use curve price. If we would be reverting, things prob bad though
Vault reverts floorPrice() Below threshold 🔴 Oracle will fail to update.
Vault reverts redeems Above threshold 🟠 Oracle will use curve price. If we would be reverting redeems, things prob bad though
Vault reverts redeems Below threshold 🔴 We could be reverting becuase the protcool has lost funds or the price oracles are weird.

Assuming our vault, strategies, and backing coins are healthy, this pricing strategy should work great for the intended use case. The cap prevents curve pool manipulation up, and any manipulation down is quickly limited by the vault's floor price. The 0.5% margin for error here is well within what lending platforms would tolerate with no problems.

However, the pricing strategy could be broken if something were wrong.

  1. If something went wrong, manipulating the curve price up would would bypass all vault level and collateral price checks. Also in case of either collateral level problems or OETH vault problems, the pool size is likely to shrink, making manipulation less costly. How easily and at what price could curve prices be manipulated back to peg?

  2. If a backing collateral fell below to 0.70 ETH, then our vault oracles would revert. This both blocks redeems (good), but also reverts our floorPrice() method, causing our oracle to be unable to update at all, and effectively freezing at the last value for the duration of the problem.

  3. floorPrice() always assumes the vault can redeem at the current quoted mix of coins. If the vault or an underlying backing strategy lost big funds, then floorPrice() would continue to report at current asset prices, while redeems would be unavailable (since we revert redeems if more than 3%? undercapitalized). For example, in the case that all balancer pools were hacked, and we were down 35% assets, this oracle would still report the price as near peg, even with the curve pool trading at low, accurate levels.

@DanielVF
Copy link
Contributor

DanielVF commented Nov 9, 2023

Review Part 2: Easy Checks

Authentication

  • Never use tx.origin
  • Every external/public function is supposed to be externally accessible
  • Every external/public function has the correct authentication

Ethereum

  • Contract does not send or receive Ethereum.
  • Contract has no payable methods.

Cryptographic code

No crypto

Gas problems

  • Contracts with for loops must have either:
    • A way to remove items
    • Can be upgraded to get unstuck
    • Size can only controlled by admins
  • Contracts with for loops must not allow end users to add unlimited items to a loop that is used by others or admins.

Black magic

  • Does not contain selfdestruct
  • Does not use delegatecall outside of proxying. If an implementation contract were to call delegatecall under attacker control, it could call selfdestruct the implementation contract, leading to calls through the proxy silently succeeding, even though they were failing.
  • Address.isContract should be treated as if could return anything at any time, because that's reality.

Overflow

  • Code is solidity version >= 0.8.0
  • All for loops use uint256

Proxy

  • No storage variable initialized at definition when contract used as a proxy implementation.

Events

  • All state changing functions emit events

This behavior is okay. We emit events on the updater contract for updates, but not on the oracle contract itself. However, this matches chainlinks behavior, and all updates can be tracked down by getting round info.

marketPrice = _getMarketPrice();

// If market price is above the vault price with the withdraw fee
if (marketPrice > MAX_VAULT_PRICE) {
Copy link
Contributor

Choose a reason for hiding this comment

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

>= would be more efficient here, assuming MAX_VAULT_PRICE is truly the max the vault can report. If the vault actually did report MAX_VAULT_PRICE and the marketPrice was also MAX_VAULT_PRICE, then we would get the same answer checking the vault as not checking the vault.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

good point. I'll update


import { IOracleReceiver } from "./IOracleReceiver.sol";
import { IVault } from "../interfaces/IVault.sol";
import { AggregatorV3Interface } from "../interfaces/chainlink/AggregatorV3Interface.sol";
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think AggregatorV3Interface is used?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

AggregatorV3Interface is not used. I'll remove it.

Apparently Slither can detect unused imports. I'll see why it's not detecting ours.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I was wondering how this got through the automated tools. I should have chased this down more.

@DanielVF
Copy link
Contributor

DanielVF commented Nov 9, 2023

Review Part 3: Medium Checks

Rounding

  • Contract rounds in the protocols favor
  • Contract does not have bugs from loosing rounding precision
  • Code correctly multiplies before division
  • Contract does not have bugs from zero or near zero amounts

Dependencies

  • Review any new contract dependencies thoroughly (e.g. OpenZeppelin imports) when new dependencies are added or version of dependencies changes.
  • If OpenZeppelin ACL roles are use review & enumerate all of them.
  • Check OpenZeppelin security vulnerabilities and see if any apply to current PR considering the version of OpenZeppelin contract used.

External calls

  • Contract addresses passed in are validated 🟫 OracleUpdader#addPrice makes a call to an arbitrary address. This should be okay from a system security point of view, since this is the last thing the code does, and nothing in our system will take any action or depend on any side effects. 🔴 However the event fires regardless of if the correct oracle was updated. This could confuse our analytics. We should probably include the updated oracle in the event. Added comment in code.
  • No unsafe external calls
  • Reentrancy guards on all state changing functions. 🟫 OracleUpdader#addPrice only performs external reads before the final write. There should be no room for reentrancy. 🟫 OracleBase#addPrice performs no external calls.
    • Still doesn't protect against external contracts changing the state of the world if they are called.
  • No Malicious behaviors
  • Low level call() must require success.
  • No slippage attacks (we need to validate expected tokens received)
  • Oracles, one of: 🟫 See dicussion in requirements. We do use an oracle, and it can't hurt us in normal operations. What happens if something is wrong, and someome is trying to manipulate it requires more analysis.
    • No oracles
    • Oracles can't be bent
    • If oracle can be bent, it won't hurt us.
  • Do not call balanceOf for external contracts to determine what they will do when they use internal accounting

Deploy

  • Deployer permissions are removed after deploy

@DanielVF
Copy link
Contributor

@naddison36, got any thoughts on how we should handle bad vault situations?

Only thing I've thought of is that we could factor in the difference between the vault total supply and the OUSD supply, if it's less than one. Would add a ton of gas to the oracle update, but in theory we only need to actually call the update if there's been a big price move, and we only the pay the cost if we are off peg.

@sparrowDom
Copy link
Member

There is some good discussion here. Regarding the bad Vault State:

  • in case the backing collateral's falls below 0.70 ETH/USDT it would make sense to me to still allow our floorPrice function to update and not revert (the behaviour for redeems stays the same). This would at least require change in the OracleRouter, to return the price even in cases where there is more than 30% deviation.
  • Thinking what to do in the other case where underlying strategies are being hacked and not able to report the correct value in checkBalance. If we can't really rely on the Vault to report the correct value and also not on the Curve pool (easy to manipulate) what is really a trusted source in that case? Can we assume Curve pool being manipulated if latest price and e.g. price X minutes ago diverge too much? We could also take an average of the last X minute price of the Curve pool and consider that as the "closest to truth" with some resistance being manipulated by tilting the pool.

****************************************/

/// @notice The number of decimals in the Oracle price.
function decimals()
Copy link
Member

Choose a reason for hiding this comment

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

This could also be expressed as:

uint8 public constant override decimals = 18;

expect(await oethOracleUpdater.vault()).to.eq(oethVault.address);

expect(await oethOracle.decimals()).to.eq(18);
expect(await oethOracle.version()).to.eq(1);
Copy link
Member

Choose a reason for hiding this comment

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

would be cool to have a test that maps deployed implementation addresses to versions. And when we attempt to update the Oracle and forget to also bump the version that test would fail.

Copy link
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

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

@naddison36 do you mind pinging when you implement the changes we have discussed today and I'll finish my full review?

/// Can not be run twice in the same block.
/// @param _price is the Oracle price with 18 decimals
function addPrice(uint128 _price) external override {
if (msg.sender != oracleUpdater) revert OnlyOracleUpdater();
Copy link
Member

Choose a reason for hiding this comment

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

👍 on using custom errors

// price is to 18 decimals, so we do not need to scale them up to 18 decimals
price +=
redeemAssets[i] *
IOracle(priceProvider).price(allAssets[i]);
Copy link
Member

Choose a reason for hiding this comment

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

Leaving a note here so it is not forgotten: we query oracle price per asset in this line above and also in _toUnitPrice function withing the _calculateRedeemOutputs

Copy link
Member

@sparrowDom sparrowDom left a comment

Choose a reason for hiding this comment

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

Eyeballed the PR again and left some extra comments

/// @notice Contract or account that can call addRoundData() to update the Oracle prices
address public oracleUpdater;

/// @notice Last round ID where isBadData is false and price is within maximum deviation
Copy link
Member

Choose a reason for hiding this comment

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

I can't find any reference to isBadData does this comment need updating?

addresses.mainnet.Timelock
);
expect(await oethOracleUpdater.MAX_VAULT_PRICE()).to.eq(
parseUnits("0.995")
Copy link
Member

Choose a reason for hiding this comment

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

to add extra security we should add a fork test that subtracts the VaultCore's redeemFeeBps from 1 and see if it matches "0.995". This way the fork test will fail in case we update the redeemFeeBps and forget to re-deploy the OETH oracle updater with its MAX_VAULT_PRICE constant

answer = marketPrice;
// Avoid getting the vault price as this is gas intensive.
// its not going to be higher than 0.995 with a 0.5% withdraw fee
vaultPrice = MAX_VAULT_PRICE;
Copy link
Member

Choose a reason for hiding this comment

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

mental note: I think we've discussed this to change to:

vaultPrice = vault.floorPrice();
answer = Math.min(vaultPrice + vault.redeemFeeBps().scaleBy(18, 4), marketPrice);


if (marketPrice > vaultPrice) {
// Return the market price with the Vault price as the floor price
answer = marketPrice;
Copy link
Member

Choose a reason for hiding this comment

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

mental note: and this one similarly:

answer = Math.min(vaultPrice + vault.redeemFeeBps().scaleBy(18, 4), marketPrice);

probably the if condition can be reworked

answer = marketPrice;
} else {
// Return the vault price
answer = vaultPrice;
Copy link
Member

Choose a reason for hiding this comment

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

And I think we've said that if market price is lower than the vaultPrice, then we might go with the market price.

Thinking more about this, I am not so sure this is a good idea, since the market price can be manipulated downwards even though there is a smaller financial incentive to do it. The way it is in the codebase right now seems safer to me.

probably something we should discuss more about.

@DanielVF
Copy link
Contributor

DanielVF commented May 8, 2024

After some analysis, we decided to not use this oracle approach. We did end up discovering a bug in Curve's oracle implementation though.

@DanielVF DanielVF closed this May 8, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contracts Works related to contracts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants