Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add draft StickyOracle #2

Draft
wants to merge 19 commits into
base: dev
Choose a base branch
from
153 changes: 153 additions & 0 deletions src/StickyOracle.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// SPDX-FileCopyrightText: © 2023 Dai Foundation <www.daifoundation.org>
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

pragma solidity ^0.8.16;

interface PipLike {
function read() external view returns (uint128);
function peek() external view returns (uint128, bool);
}

contract StickyOracle {
mapping (address => uint256) public wards;
mapping (address => uint256) public buds; // Whitelisted feed readers
mapping (uint256 => uint256) accumulators; // daily (eod) sticky oracle price accumulators

PipLike public immutable pip;

uint96 public slope = uint96(RAY); // maximum allowable price growth factor from center of TWAP window to now (in RAY such that slope = (1 + {max growth rate}) * RAY)
uint8 public lo; // how many days ago should the TWAP window start (exclusive)
uint8 public hi; // how many days ago should the TWAP window end (inclusive)

uint128 val; // last poked price
uint32 public age; // time of last poke

event Rely(address indexed usr);
event Deny(address indexed usr);
event Kiss(address indexed usr);
event Diss(address indexed usr);
event File(bytes32 indexed what, uint256 data);

constructor(address _pip) {
pip = PipLike(_pip);

wards[msg.sender] = 1;
emit Rely(msg.sender);
}

modifier auth {
require(wards[msg.sender] == 1, "StickyOracle/not-authorized");
_;
}

modifier toll {
require(buds[msg.sender] == 1, "StickyOracle/not-whitelisted");
_;
}

uint256 internal constant RAY = 10 ** 27;

function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); }
function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); }
function kiss(address usr) external auth { buds[usr] = 1; emit Kiss(usr); }
function diss(address usr) external auth { buds[usr] = 0; emit Diss(usr); }

function file(bytes32 what, uint256 data) external auth {
if (what == "slope") slope = uint96(data);
else if (what == "lo") lo = uint8(data);
else if (what == "hi") hi = uint8(data);
else revert("StickyOracle/file-unrecognized-param");
emit File(what, data);
}

function _min(uint128 a, uint128 b) internal pure returns (uint128 min) {
return a < b ? a : b;
}

function _getCap() internal view returns (uint128 cap) {
uint256 today = block.timestamp / 1 days;
(uint96 slope_, uint8 lo_, uint8 hi_) = (slope, lo, hi);
require(hi_ > 0 && lo_ > hi_, "StickyOracle/invalid-window");

uint256 acc_lo = accumulators[today - lo_];
uint256 acc_hi = accumulators[today - hi_];

if (acc_lo > 0 && acc_hi > 0) {
return uint128((acc_hi - acc_lo) * slope_ / (RAY * (lo_ - hi_) * 1 days));
}

uint256 val_ = val;
require(val_ > 0, "StickyOracle/not-init");
return uint128(val_ * slope_ / RAY); // fallback for missing accumulators
Copy link
Contributor

Choose a reason for hiding this comment

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

Also - maybe it' simpler just to use the last val in this case and not multiply by slope?

}

function init(uint256 days_) external auth {
require(val == 0, "StickyOracle/already-init");
uint128 cur = pip.read();
uint256 prev = block.timestamp / 1 days - days_ - 1; // day before the first initiated day
uint256 day;
for(uint256 i = 1; i <= days_ + 1;) {
unchecked { day = prev + i; }
accumulators[day] = cur * i * 1 days;
unchecked { ++i; }
}
val = cur;
age = uint32(block.timestamp);
}

function fix(uint256 day) external {
uint256 today = block.timestamp / 1 days;
require(day < today, "StickyOracle/too-soon");
require(accumulators[day] == 0, "StickyOracle/nothing-to-fix");

uint256 acc1; uint256 acc2;
uint i; uint j;
for(i = 1; (acc1 = accumulators[day - i]) == 0; ++i) {}
for(j = i + 1; (acc2 = accumulators[day - j]) == 0; ++j) {}

accumulators[day] = acc1 + (acc1 - acc2) * i / (j - i);
}

function poke() external {
Copy link
Contributor

@oldchili oldchili Dec 4, 2023

Choose a reason for hiding this comment

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

Wouldn't it be much simpler just to store the timestamp and value of the last first-of-the-day poke? I feel like we are trying to be too smart here with the bef and aft calculations.

I tried to sketch how it would look like (also with saving cap in storage to avoid calculating it on each read/pip):

    function poke()  {
        uint256 today = block.timestamp / 1 days;
        AccData memory accToday = accumulators[today];
        bool firstPokeToday = accToday.ts == 0;

        // get or recompute cap
        uint128 cap_;
        if (firstPokeToday) {
            cap_ = _getCap();
            cap = cap_;
        } else cap_ = cap;

        // update oracle value
        uint128 cur = _min(pip.read(), cap_);
        val = cur;
        age = uint32(block.timestamp);

        // update accumulator if needed
        if (firstPokeToday) {
            AccData memory accLast_ = accLast; // this is from storage

            accToday.val = _accLast.val + cur * (block.timestamp - _accLast.ts);
            accToday.ts = uint32(block.timestamp);

            accumulators[today] = accToday;
            accLast = accToday;
        }
    }

The above assumes we have in storage accLast and cap, where each accumulator entry is a struct with a value and a timestamp.

uint128 cur = _min(pip.read(), _getCap());
uint256 today = block.timestamp / 1 days;
uint256 acc = accumulators[today];
(uint128 val_, uint32 age_) = (val, age);
uint256 newAcc;
uint256 tmrTs = (today + 1) * 1 days; // timestamp on the first second of tomorrow
if (acc == 0) { // first poke of the day
uint256 prevDay = age_ / 1 days;
uint256 bef = val_ * (block.timestamp - (prevDay + 1) * 1 days); // contribution to the accumulator from the previous value
Copy link
Contributor

Choose a reason for hiding this comment

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

is val_ necessarily the previous day's accumulator value? not sure I get this.

uint256 aft = cur * (tmrTs - block.timestamp); // contribution to the accumulator from the current value, optimistically assuming this will be the last poke of the day
newAcc = accumulators[prevDay] + bef + aft;
} else { // not the first poke of the day
uint256 off = tmrTs - block.timestamp; // period during which the accumulator value needs to be adjusted
Copy link
Contributor

Choose a reason for hiding this comment

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

mmm can we do without the else case? we probably don't want twap related logic on each regular poke.

newAcc = acc + cur * off - val_ * off;
}
accumulators[today] = newAcc;
val = cur;
age = uint32(block.timestamp);
}

function read() external view toll returns (uint128) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

To keep consistency with the peek function we should add a require that cur > 0 in this one, or just return true in peek always.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I also think that peek should probably use pip.peek(), so we make sure it won't revert.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes peek() should be calling pip.peek(), but since pip.read() already reverts when cur is 0 I guess we don't need a require in read().

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe that is assuming too much from the called oracle but yeah not sure, will think about this a bit more.

return _min(pip.read(), _getCap());
Copy link
Contributor

Choose a reason for hiding this comment

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

_getCap is pretty heavy to calculate on each oracle read, maybe we can store the result in storage once a day (I tried to to that with my sketching of poke() above).

}

function peek() external view toll returns (uint128, bool) {
(uint128 cur,) = pip.peek();
return (_min(cur, _getCap()), cur > 0);
}
}
159 changes: 159 additions & 0 deletions test/StickyOracle.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: © 2023 Dai Foundation <www.daifoundation.org>
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

pragma solidity ^0.8.16;

import "forge-std/Test.sol";

import { StickyOracle } from "src/StickyOracle.sol";

interface ChainlogLike {
function getAddress(bytes32) external view returns (address);
}

interface PipLike {
function read() external view returns (uint256);
function kiss(address) external;
}

contract StickyOracleHarness is StickyOracle {
constructor(address _pip) StickyOracle (_pip) {}
function getAccumulator(uint256 day) external view returns (uint256) {
return accumulators[day];
}
function getVal() external view returns (uint128) {
return val;
}
function getCap() external view returns (uint128) {
return _getCap();
}
}

contract StickyOracleTest is Test {

PipLike public medianizer;
StickyOracleHarness public oracle;
uint256 public initialMedianizerPrice;

uint256 constant RAY = 10 ** 27;
address constant LOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F;

address PAUSE_PROXY;
address PIP_MKR;

function setMedianizerPrice(uint256 newPrice) internal {
vm.store(address(medianizer), bytes32(uint256(1)), bytes32(block.timestamp << 128 | newPrice));
}

function setUp() public {
vm.createSelectFork(vm.envString("ETH_RPC_URL"));

PAUSE_PROXY = ChainlogLike(LOG).getAddress("MCD_PAUSE_PROXY");
PIP_MKR = ChainlogLike(LOG).getAddress("PIP_MKR");

medianizer = PipLike(PIP_MKR);

vm.startPrank(PAUSE_PROXY);

oracle = new StickyOracleHarness(PIP_MKR);
oracle.kiss(address(this));
medianizer.kiss(address(oracle));
medianizer.kiss(address(this));

oracle.file("hi", 1);
oracle.file("lo", 3);
oracle.file("slope", RAY * 105 / 100);

vm.stopPrank();

initialMedianizerPrice = 1000 * 10**18;
setMedianizerPrice(initialMedianizerPrice);
assertEq(medianizer.read(), initialMedianizerPrice);
}

function testInit() public {
vm.expectRevert("StickyOracle/not-init");
oracle.read();

vm.prank(PAUSE_PROXY); oracle.init(3);
assertEq(oracle.read(), medianizer.read());
assertEq(oracle.getVal(), medianizer.read());
assertEq(oracle.age(), block.timestamp);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 4), 0);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 3), initialMedianizerPrice * 1 days);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 2), initialMedianizerPrice * 2 days);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 1), initialMedianizerPrice * 3 days);
assertEq(oracle.getAccumulator(block.timestamp / 1 days ), initialMedianizerPrice * 4 days);
}

function testFix() external {
vm.prank(PAUSE_PROXY); oracle.init(3);
assertEq(oracle.read(), medianizer.read());

vm.expectRevert("StickyOracle/nothing-to-fix");
oracle.fix(block.timestamp / 1 days - 1);

vm.warp(block.timestamp + 1 days);

vm.expectRevert("StickyOracle/too-soon");
oracle.fix(block.timestamp / 1 days);

vm.warp(block.timestamp + 1 days);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 1), 0);

oracle.fix(block.timestamp / 1 days - 1);

uint256 acc1 = oracle.getAccumulator(block.timestamp / 1 days - 2);
uint256 acc2 = oracle.getAccumulator(block.timestamp / 1 days - 3);
assertGt(oracle.getAccumulator(block.timestamp / 1 days - 1), 0);
assertEq(oracle.getAccumulator(block.timestamp / 1 days - 1), acc1 + (acc1 - acc2));
}

function testPoke() public {
vm.prank(PAUSE_PROXY); oracle.init(3);
assertEq(oracle.read(), medianizer.read());

uint256 medianizerPrice1 = initialMedianizerPrice * 110 / 100;
setMedianizerPrice(medianizerPrice1);
vm.warp((block.timestamp / 1 days) * 1 days + 1 days + 8 hours); // warping to 8am on the next day
uint256 prevVal = oracle.getVal();

oracle.poke(); // first poke of the day

uint256 oraclePrice1 = 105 * initialMedianizerPrice / 100;
assertEq(oracle.getCap(), oraclePrice1);
assertEq(oracle.getVal(), oraclePrice1);
assertEq(oracle.age(), block.timestamp);
assertEq(oracle.read(), oraclePrice1);
uint256 bef = prevVal * 8 hours;
uint256 aft = oraclePrice1 * 16 hours;
assertEq(oracle.getAccumulator(block.timestamp / 1 days), oracle.getAccumulator(block.timestamp / 1 days - 1) + bef + aft);

uint256 prevAcc = oracle.getAccumulator(block.timestamp / 1 days);
vm.warp(block.timestamp + 8 hours); // warping to 4pm on the same day
uint256 medianizerPrice2 = initialMedianizerPrice * 104 / 100;
setMedianizerPrice(medianizerPrice2);

oracle.poke(); // second poke of the day

uint256 oraclePrice2 = 104 * initialMedianizerPrice / 100;
assertEq(oracle.getCap(), 105 * initialMedianizerPrice / 100);
assertEq(oracle.getVal(), oraclePrice2);
assertEq(oracle.age(), block.timestamp);
assertEq(oracle.read(), oraclePrice2);
assertEq(oracle.getAccumulator(block.timestamp / 1 days), prevAcc + 8 hours * oraclePrice2 - 8 hours * oraclePrice1);
}
}
Loading