diff --git a/src/StickyOracle.sol b/src/StickyOracle.sol index 2c41a394..4ef3faf3 100644 --- a/src/StickyOracle.sol +++ b/src/StickyOracle.sol @@ -17,29 +17,39 @@ pragma solidity ^0.8.16; interface PipLike { - function read() external view returns (uint128); - function peek() external view returns (uint128, bool); + function read() external view returns (uint128); // TODO: shouldn't this (and our function) return bytes32? https://github.com/makerdao/osm/blob/e36c874b4e14fba860e48c0cf99cd600c0c59efa/src/osm.sol#L150C49-L150C56 + function peek() external view returns (uint128, bool); // TODO: shouldn't this (and our function) return (bytes32, bool)? https://github.com/makerdao/osm/blob/e36c874b4e14fba860e48c0cf99cd600c0c59efa/src/osm.sol#L142 } +// TODO: should we implement peep as well? (even if a dummy implementation) Scribe does - https://github.com/chronicleprotocol/scribe/blob/41f25a8a40f1a1d2ef62d6a073f98a3c57d23579/src/Scribe.sol#L276. 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 + mapping (address => uint256) public buds; // whitelisted feed readers - PipLike public immutable pip; + mapping (uint256 => Accumulator) accumulators; // daily sticky oracle price accumulators + uint128 cap; // max allowed price + uint128 pokePrice; // last price at which poke() was called + uint256 pokeDay; // last day at which poke() was called - 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) + uint96 public slope = uint96(RAY); // maximum allowable price growth factor from the average value of a TWAP window (in RAY such that slope = (1 + {max growth rate}) * RAY) + uint8 public lo; // how many days ago should the TWAP window start (exclusive), should be more than hi + uint8 public hi; // how many days ago should the TWAP window end (inclusive), should be less than lo and more than 0 - uint128 val; // last poked price - uint32 public age; // time of last poke + PipLike public immutable pip; + + struct Accumulator { + uint256 val; + uint32 ts; + } 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); + event Init(uint256 days_, uint128 pokePrice_); + event Void(); + event Poke(uint256 indexed day, uint128 cap, uint128 pokePrice_); constructor(address _pip) { pip = PipLike(_pip); @@ -77,77 +87,82 @@ contract StickyOracle { return a < b ? a : b; } - function _getCap() internal view returns (uint128 cap) { + function _calcCap() 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_]; + Accumulator memory acc_lo = accumulators[today - lo_]; + Accumulator memory 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 + return (acc_lo.val > 0 && acc_hi.val > 0) ? + uint128((acc_hi.val - acc_lo.val) * slope_ / (RAY * (acc_hi.ts - acc_lo.ts))) : + 0; } - 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; } + // days_ is the number of daily samples to initialize on top of the current one + // days_ == N will fill up a window corresponding to [lo == N, hi == 1] along with the current day + // days_ should be selected carefully as too many iterations can cause the transaction to run out of gas + // if the initiated timespan is shorter than the [lo, hi] window the initial cap will just be used for longer + function init(uint256 days_) external auth returns(bool) { + require(cap == 0, "StickyOracle/already-init"); + + (uint128 pokePrice_, bool has) = pip.peek(); // non-reverting to support calling from a spell + if (!has) return false; + + pokePrice = cap = pokePrice_; + uint256 pokeDay_ = pokeDay = block.timestamp / 1 days; + uint256 accumulatedVal = 0; + uint32 accumulatedTs = uint32(block.timestamp - days_ * 1 days); + + for (uint256 day = pokeDay_ - days_; day <= pokeDay_;) { + accumulators[day].val = accumulatedVal; + accumulators[day].ts = accumulatedTs; + + accumulatedVal += pokePrice_ * 1 days; + accumulatedTs += 1 days; + unchecked { ++day; } } - val = cur; - age = uint32(block.timestamp); + + emit Init(days_, pokePrice_); + return true; } - 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 void() external auth { + cap = 0; + emit Void(); } function poke() external { - 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 - 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 - newAcc = acc + cur * off - val_ * off; - } - accumulators[today] = newAcc; - val = cur; - age = uint32(block.timestamp); + require(accumulators[today].val == 0, "StickyOracle/already-poked-today"); + + // calculate new cap if possible, otherwise use the current one + uint128 cap_ = cap; + require(cap_ > 0, "StickyOracle/cap-not-set"); + uint128 newCap = _calcCap(); + if (newCap > 0) cap = cap_ = newCap; + + // update accumulator + accumulators[today].val = accumulators[pokeDay].val + pokePrice * (block.timestamp - accumulators[pokeDay].ts); + accumulators[today].ts = uint32(block.timestamp); + + // store for next accumulator calc + uint128 pokePrice_ = pokePrice = _min(pip.read(), cap_); + pokeDay = today; + + emit Poke(today, cap, pokePrice_); } function read() external view toll returns (uint128) { - return _min(pip.read(), _getCap()); + uint128 cap_ = cap; + require(cap_ > 0, "StickyOracle/cap-not-set"); + return _min(pip.read(), cap_); } function peek() external view toll returns (uint128, bool) { - (uint128 cur,) = pip.peek(); - return (_min(cur, _getCap()), cur > 0); + uint128 cap_ = cap; + (uint128 cur, bool has) = pip.peek(); + return (_min(cur, cap_), has && cap_ > 0); } } diff --git a/test/StickyOracle.t.sol b/test/StickyOracle.t.sol index d46ec86c..42d89b3e 100644 --- a/test/StickyOracle.t.sol +++ b/test/StickyOracle.t.sol @@ -31,14 +31,29 @@ interface PipLike { contract StickyOracleHarness is StickyOracle { constructor(address _pip) StickyOracle (_pip) {} - function getAccumulator(uint256 day) external view returns (uint256) { - return accumulators[day]; + + function getAccumulatorVal(uint256 day) external view returns (uint256) { + return accumulators[day].val; + } + + function getAccumulatorTs(uint256 day) external view returns (uint32) { + return accumulators[day].ts; } - function getVal() external view returns (uint128) { - return val; + + function getPokePrice() external view returns (uint256) { + return pokePrice; } + + function getPokeDay() external view returns (uint256) { + return pokeDay; + } + function getCap() external view returns (uint128) { - return _getCap(); + return cap; + } + + function calcCap() external view returns (uint128) { + return _calcCap(); } } @@ -54,6 +69,9 @@ contract StickyOracleTest is Test { address PAUSE_PROXY; address PIP_MKR; + event Init(uint256 days_, uint128 pokePrice_); + event Poke(uint256 indexed day, uint128 cap, uint128 pokePrice_); + function setMedianizerPrice(uint256 newPrice) internal { vm.store(address(medianizer), bytes32(uint256(1)), bytes32(block.timestamp << 128 | newPrice)); } @@ -85,75 +103,81 @@ contract StickyOracleTest is Test { } function testInit() public { - vm.expectRevert("StickyOracle/not-init"); + vm.expectRevert("StickyOracle/cap-not-set"); oracle.read(); + vm.expectEmit(true, true, true, true); + emit Init(3, uint128(initialMedianizerPrice)); 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); + assertEq(oracle.getCap(), medianizer.read()); + + assertEq(oracle.getAccumulatorVal(block.timestamp / 1 days - 3), 0); + assertEq(oracle.getAccumulatorVal(block.timestamp / 1 days - 2), initialMedianizerPrice * 1 days); + assertEq(oracle.getAccumulatorVal(block.timestamp / 1 days - 1), initialMedianizerPrice * 2 days); + assertEq(oracle.getAccumulatorVal(block.timestamp / 1 days ), initialMedianizerPrice * 3 days); + + assertEq(oracle.getAccumulatorTs(block.timestamp / 1 days - 3), block.timestamp - 3 days); + assertEq(oracle.getAccumulatorTs(block.timestamp / 1 days - 2), block.timestamp - 2 days); + assertEq(oracle.getAccumulatorTs(block.timestamp / 1 days - 1), block.timestamp - 1 days); + assertEq(oracle.getAccumulatorTs(block.timestamp / 1 days ), block.timestamp); + + assertEq(oracle.getPokePrice(), initialMedianizerPrice); + assertEq(oracle.getPokeDay(), block.timestamp / 1 days); } - function testFix() external { + function testPoke() public { 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); + setMedianizerPrice(initialMedianizerPrice * 110 / 100); + + vm.expectRevert("StickyOracle/already-poked-today"); + oracle.poke(); vm.warp(block.timestamp + 1 days); + vm.expectEmit(true, true, true, true); + emit Poke(block.timestamp / 1 days, uint128(initialMedianizerPrice * 105 / 100), uint128(initialMedianizerPrice * 105 / 100)); + oracle.poke(); // before: [100,100,100] + assertEq(oracle.getCap(), initialMedianizerPrice * 105 / 100); // (100 + 100) / 2 * 1.05 = 105 + assertEq(oracle.read(), initialMedianizerPrice * 105 / 100); - vm.expectRevert("StickyOracle/too-soon"); - oracle.fix(block.timestamp / 1 days); + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: // [100,100,105] + assertEq(oracle.getCap(), initialMedianizerPrice * 105 / 100 ); // (100 + 100) / 2 * 1.05 = 105 + assertEq(oracle.read(), initialMedianizerPrice * 105 / 100); vm.warp(block.timestamp + 1 days); - assertEq(oracle.getAccumulator(block.timestamp / 1 days - 1), 0); + oracle.poke(); // before: [100,105,105] + assertEq(oracle.getCap(), initialMedianizerPrice * 107625 / 100000); // (100 + 105) /2 * 1.05 = 107.625 + assertEq(oracle.read(), initialMedianizerPrice * 107625 / 100000); - oracle.fix(block.timestamp / 1 days - 1); + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: [105,105,107.625] + assertEq(oracle.getCap(), initialMedianizerPrice * 11025 / 10000); // (105 + 105) / 2 * 1.05 = 110.25 + assertEq(oracle.read(), initialMedianizerPrice * 110 / 100); // blocked by current price of 110 - 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)); - } + vm.warp(block.timestamp + 2 days); // missing a poke + oracle.poke(); // before: [107.625,110,Miss] + assertEq(oracle.getCap(), initialMedianizerPrice * 11025 / 10000); // cannot calc twap, cap will stay the same + assertEq(oracle.read(), initialMedianizerPrice * 110 / 100); // still blocked by current price of 110 - function testPoke() public { - vm.prank(PAUSE_PROXY); oracle.init(3); - assertEq(oracle.read(), medianizer.read()); + setMedianizerPrice(initialMedianizerPrice * 111 / 100); // price goes up a bit + + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: [110,Miss,110] + assertEq(oracle.getCap(), initialMedianizerPrice * 1155 / 1000); // (110 * 2) / 2 * 1.05 = 115.5 + assertEq(oracle.read(), initialMedianizerPrice * 111 / 100); // blocked by current price of 111 - 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); + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: [Miss,110,111]; + assertEq(oracle.getCap(), initialMedianizerPrice * 1155 / 1000); // cannot calc twap, cap will stay the same + assertEq(oracle.read(), initialMedianizerPrice * 111 / 100); // still blocked by current price of 111 + + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: [110,111,111]; + assertEq(oracle.getCap(), initialMedianizerPrice * 116025 / 100000); // (110 + 111)/2 * 1.05 = 116.025 + assertEq(oracle.read(), initialMedianizerPrice * 111 / 100); // still blocked by current price of 111 } }