GithubHelp home page GithubHelp logo

splits-oracle's Introduction

splits-oracle

Docs

What

Oracle provides a generic interface (IOracle) allowing for modular integrations of other onchain oracles (see Swapper for an example integration)

UniV3OracleImpl - provides per-pair customization layer (pool, period) on top of Uniswap v3's TWAP oracle

ChainlinkOracleImpl - provides per-pair customization layer (path, staleAfter) on top of Chainlink's data feed oracle

Why

Many onchain value flows require fair pricing for token pairs

How

UniV3OracleImpl sequence diagram

How does it determine fair pricing?

UniV3OracleImpl uses Uniswap v3's TWAP oracle. The owner must set per-pair reference pools & may set default & per-pair TWAP periods.

ChainlinkOracleImpl - uses Chainlink's data feed oracle. The owner must set per-pair data feed paths.

How is it governed?

Please be aware, an Oracle's owner has SIGNIFICANT CONTROL (depending on the implementation) of the deployment. It may, at any time for any reason, change the quote pair uniswap pools & TWAP periods. In situations where flows ultimately belong to or benefit more than a single person & immutability is a nonstarter, we strongly recommend using a multisig or DAO for governance.

Lint

forge fmt

Setup & test

forge i - install dependencies

forge b - compile the contracts

forge t - compile & test the contracts

forge t -vvv - produces a trace of any failing tests

Natspec

forge doc --serve --port 4000 - serves natspec docs at http://localhost:4000/

splits-oracle's People

Contributors

wminshew avatar r0ohafza avatar abramdawson avatar

Stargazers

 avatar  avatar  avatar Roman Sivakov avatar Kamran avatar Abdul Maajid avatar Michael Demarais avatar 小米 avatar Shun Kakinoki avatar

Watchers

Brandon avatar Michael Demarais avatar  avatar Oisín Kyne avatar Mike avatar  avatar  avatar

Forkers

tsueti blackz42

splits-oracle's Issues

Extra bytes can be included in pairDetails

When $_pairDetails are set, we store a bytestring (which represents a packed version of an array of Feeds) and an inverted boolean flag.

Before storing these values, we use path.getFeeds() to decode the bytestring into an array of Feeds and validate that the parameters passed are valid (ie that decimals equals the decimals on the feed and that staleAfter > 1 hour).

When these values are accessed, we also use the path.getFeeds() function to retrieve the feeds for oracle price calculations.

If we look at the implementation of getFeeds(), we can see that it first gets the number of feeds in the path, and then iterates over each of these feeds, calling getFeed() to decode and return it:

/// @notice get feeds from a path (packed encoded bytes)
function getFeeds(bytes memory path) internal pure returns (ChainlinkOracleImpl.Feed[] memory feeds) {
    uint256 length = len(path);
    feeds = new ChainlinkOracleImpl.Feed[](length);
    for (uint256 i; i < length;) {
        feeds[i] = getFeed(path, i);
        unchecked {
            ++i;
        }
    }
}
/// @notice get the number of feeds in the path
function len(bytes memory path) internal pure returns (uint256) {
    return path.len(PATH_UNIT_SIZE);
}
function len(bytes memory _bytes, uint256 _size) internal pure returns (uint256) {
    return _bytes.length / _size;
}

If the length of the bytearray passed is not evenly divisible by 25, the extra bytes will be ignored by getFeeds(). This will skip validation, store the bytes in storage, and also skip returning them to be used when the oracle is called.

I do not see any harm in these extra bytes existing, but in the event that extra interactions are implemented at a later date or there is a risk I'm not seeing, it would be more precise and safer to require that bytestrings passed do not contain extra bytes.

Recommendation

/// @notice get the number of feeds in the path
function len(bytes memory path) internal pure returns (uint256) {
+   if (path.length % 25 != 0) revert ExtraBytesInPath();
    return path.len(PATH_UNIT_SIZE);
}

Rearrange stale price check formula out of absurd paranoia

In _getFeedAnswer(), we check if the Chainlink oracle has returned a stale price:

if (updatedAt < block.timestamp - feed_.staleAfter) {
    revert StalePrice(feed_.feed, updatedAt);
}

feed_.staleAfter is a uint24, so for any chain that uses standard Unix timestamps, it should be impossible for block.timestamp - feed_.staleAfter to underflow (because the current Unix time is greater than type(uint24).max).

However, out of an abundance of paranoia, it is worth rearranging the formula to accomplish the same thing without risk of reverting.

Recommendation

- if (updatedAt < block.timestamp - feed_.staleAfter) {
+ if (updatedAt + feed_.staleAfter < block.timestamp) {
      revert StalePrice(feed_.feed, updatedAt);
  }

UniV3 Oracle unsafe on L2s in event of Sequencer downtime

The UniV3 oracle uses the built in consult() function provided by Uniswap's Oracle Library to query the pool and determine the time weighted price. This takes in a secondsAgo and observes the price at secondsAgo and block.timestamp, returning the time weighted average between these two points.

In the event that we haven't had any observation since secondsAgo, we assume the latest observation still holds:

    function getSurroundingObservations(
        Observation[65535] storage self,
        uint32 time,
        uint32 target,
        int24 tick,
        uint16 index,
        uint128 liquidity,
        uint16 cardinality
    ) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) {
        // optimistically set before to the newest observation
        beforeOrAt = self[index];

        // if the target is chronologically at or after the newest observation, we can early return
        if (lte(time, beforeOrAt.blockTimestamp, target)) {
            if (beforeOrAt.blockTimestamp == target) {
                // if newest observation equals target, we're in the same block, so we can ignore atOrAfter
                return (beforeOrAt, atOrAfter);
            } else {
                // otherwise, we need to transform
                return (beforeOrAt, transform(beforeOrAt, target, tick, liquidity));
            }
        }
        ...
}

In the event that an L2's sequencer goes down, the time weighted price when it comes back online will be the extrapolated previous price. This will create an opportunity to push through transactions at the old price before it is updated. Even when the new price is observed, it will be assumed by the sequencer that the previous price held up until the moment it came back online, which will result in a slow, time weighted adjustment back to the current price.

Note that, in the case of Arbitrum, there is the ability to force transactions through the delayed inbox. If other users are forcing transactions into the given pool, this could solve the problem, but if not it could also make the problem worse by allowing an attacker to force a transaction that abuses the outdated price while the sequencer is down, guaranteeing inclusion.

Recommendation

Use the Chainlink oracle for all L2s.

Tokens with extreme price differences along the path could round price to zero

In the unlikely event that the price ratio between two tokens is more than 1e18:1, the oracle will round the price down zero.

While the largest price ratio I can currently find is BTC/SHIB (which can be calculated using SHIB/ETH and ETH/BTC to be approximately 1e11:1), that puts us close enough to the range that this is something we should be prepared for.

Based on the use for this oracle with swappers, a 0 price would allow the bot executing the swap to steal all of a user's tokens for free.

Note that this 0 price could occur as an intermediate step along a longer path, and the 0 value would carry through to the final price, leading to the theft of tokens with closer values as well.

Recommendation

While rounding is inevitable with such a large price discrepancy and 18 decimals of precision, it is worth including an explicit check that price != 0 to ensure that tokens cannot be stolen.

function _getQuoteAmount(QuoteParams calldata quoteParams_) internal view returns (uint256) {
    ...
    if (pd.inverted) price = WAD.divWadDown(price);
+   if (price == 0) revert ZeroPrice();
    return _convertPriceToQuoteAmount(price, quoteParams_);
}

High decimal tokens will lead to loss of precision in oracle results

When the Chainlink Oracle has calculated a relative price between two assets, it results in a price, which is always represented in 18 decimals. This price is used to convert the passed baseAmount into a final result:

function _convertPriceToQuoteAmount(uint256 price_, QuoteParams calldata quoteParams_)
    internal
    view
    returns (uint256 finalAmount)
{
    uint8 baseDecimals = quoteParams_.quotePair.base._decimals();
    uint8 quoteDecimals = quoteParams_.quotePair.quote._decimals();

    finalAmount = price_ * quoteParams_.baseAmount / 10 ** baseDecimals;
    if (18 > quoteDecimals) {
        finalAmount = finalAmount / (10 ** (18 - quoteDecimals));
    } else if (18 < quoteDecimals) {
        finalAmount = finalAmount * (10 ** (quoteDecimals - 18));
    }
}

In the case of high decimal tokens, this function performs a large division before multiplying the amount back up by (10 ** (quoteDecimals - 18)). In that division and subsequent multiplication, there is a loss of precision that can lead to incorrect oracle results.

Proof of Concept

The following proof of concept pulls out the _convertPriceToQuoteAmount() function to display its behavior more clearly. We create two tokens with 24 decimals (highest value I know of that exists in the wild) and presume they have equal value (price = 1e18). We input amount = 1e6 - 1 for tokenA, which should return an equal number of tokenB, but instead returns 0.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import { Test, console2 } from "forge-std/Test.sol";
import { ERC20 } from "solmate/tokens/ERC20.sol";
import {QuotePair, QuoteParams} from "splits-utils/LibQuotes.sol";
import {TokenUtils} from "splits-utils/TokenUtils.sol";

contract MockERC20 is ERC20 {
    constructor(uint8 decimals_) ERC20("Token", "TKN", decimals_) {}
}

contract HighDecimalsTest is Test {
    using TokenUtils for address;

    function _convertPriceToQuoteAmount(uint256 price_, QuoteParams memory quoteParams_)
        internal
        view
        returns (uint256 finalAmount)
    {
        uint8 baseDecimals = quoteParams_.quotePair.base._decimals();
        uint8 quoteDecimals = quoteParams_.quotePair.quote._decimals();

        finalAmount = price_ * quoteParams_.baseAmount / 10 ** baseDecimals;
        if (18 > quoteDecimals) {
            finalAmount = finalAmount / (10 ** (18 - quoteDecimals));
        } else if (18 < quoteDecimals) {
            finalAmount = finalAmount * (10 ** (quoteDecimals - 18));
        }
    }


    function testZach_getQuoteAmtsHighDecimals() public {
        // deploy two high decimal ERC20s
        MockERC20 tokenA = new MockERC20(24);
        MockERC20 tokenB = new MockERC20(24);

        // let's assume these two tokens have equal value
        // oracle always returns 1e18 prices, so:
        uint price = 1e18;

        // if we try to convert tokenA to tokenB,
        // division by baseDecimals will round us down
        // for small amounts, this will round down to 0
        uint128 amount = 1e6 - 1;
        QuotePair memory quotePair = QuotePair({base: address(tokenA), quote: address(tokenB)});
        QuoteParams memory quoteParams = QuoteParams({quotePair: quotePair, baseAmount: amount, data: ""});

        assertEq(_convertPriceToQuoteAmount(price, quoteParams), 0);
    }
}

Recommendation

Change the order of operations in the relevant function so that multiplication comes before division:

function _convertPriceToQuoteAmount(uint256 price_, QuoteParams memory quoteParams_)
    internal
    view
    returns (uint256 finalAmount)
{
    uint8 baseDecimals = quoteParams_.quotePair.base._decimals();
    uint8 quoteDecimals = quoteParams_.quotePair.quote._decimals();

-   finalAmount = price_ * quoteParams_.baseAmount / 10 ** baseDecimals;
+   finalAmount = price_ * quoteParams_.baseAmount;
    if (18 > quoteDecimals) {
        finalAmount = finalAmount / (10 ** (18 - quoteDecimals));
    } else if (18 < quoteDecimals) {
        finalAmount = finalAmount * (10 ** (quoteDecimals - 18));
    }
+   finalAmount = finalAmount  / 10 ** baseDecimals;
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.