From 5d88731babd62cb57bf60db9f12d4d3436150735 Mon Sep 17 00:00:00 2001 From: oldchili <130549691+oldchili@users.noreply.github.com> Date: Tue, 5 Dec 2023 18:09:03 +0200 Subject: [PATCH] poke once a day, use struct, more changes --- src/StickyOracle.sol | 122 +++++++++++++++++++------------------ test/StickyOracle.t.sol | 130 ++++++++++++++++++++++------------------ 2 files changed, 136 insertions(+), 116 deletions(-) diff --git a/src/StickyOracle.sol b/src/StickyOracle.sol index 2c41a394..dbce85ee 100644 --- a/src/StickyOracle.sol +++ b/src/StickyOracle.sol @@ -23,23 +23,30 @@ interface PipLike { 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 + Accumulator accLast; // last set accumulator + uint128 cap; // max allowed price 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) + 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 cur); + event Poke(uint256 indexed day, uint128 cap); constructor(address _pip) { pip = PipLike(_pip); @@ -77,77 +84,76 @@ 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; } + // days_ is the number of daily samples to initialize on top of the current one + // days_ == X will fill up a window corresponding to [lo == X, 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 { - 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; } + require(cap == 0, "StickyOracle/already-init"); + uint128 cur = cap = pip.read(); + + uint256 currentDay = block.timestamp / 1 days; + uint256 firstDay = currentDay - days_; + + uint256 accumulatedVal = 0; + uint32 accumulatedTs = uint32(block.timestamp - days_ * 1 days); + + for (uint256 day = firstDay; day <= currentDay;) { + accumulators[day].val = accumulatedVal; + accumulators[day].ts = accumulatedTs; + + accumulatedVal += cur * 1 days; + accumulatedTs += 1 days; + unchecked { ++day; } } - 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); + accLast = accumulators[currentDay]; + + emit Init(days_, cur); } 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); + Accumulator memory accToday = accumulators[today]; + require(accToday.val == 0, "StickyOracle/already-poked-today"); + + // calculate new cap if possible, otherwise use the current one + uint128 cap_ = _calcCap(); + if (cap_ > 0) cap = cap_; + else cap_ = cap; + + // update accumulator + uint128 cur = _min(pip.read(), cap_); + + accToday.val = accLast.val + cur * (block.timestamp - accLast.ts); + accToday.ts = uint32(block.timestamp); + accumulators[today] = accLast = accToday; + + emit Poke(today, cap); } function read() external view toll returns (uint128) { - return _min(pip.read(), _getCap()); + uint128 cap_ = cap; + require(cap_ > 0, "StickyOracle/cap-not-set"); // TODO: decide if we need the cap_ require + return _min(pip.read(), cap); } function peek() external view toll returns (uint128, bool) { + uint128 cap_ = cap; (uint128 cur,) = pip.peek(); - return (_min(cur, _getCap()), cur > 0); + return (_min(cur, cap_), cur > 0 && cap_ > 0); // TODO: decide if we need the cap_ condition } } diff --git a/test/StickyOracle.t.sol b/test/StickyOracle.t.sol index d46ec86c..5db1baeb 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 getAccLastVal() external view returns (uint256) { + return accLast.val; + } + + function getAccLastTs() external view returns (uint32) { + return accLast.ts; } + function getCap() external view returns (uint128) { - return _getCap(); + return cap; + } + + function calcCap() external view returns (uint128) { + return _calcCap(); } } @@ -54,6 +69,8 @@ contract StickyOracleTest is Test { address PAUSE_PROXY; address PIP_MKR; + event Init(uint256 days_, uint128 cur); + function setMedianizerPrice(uint256 newPrice) internal { vm.store(address(medianizer), bytes32(uint256(1)), bytes32(block.timestamp << 128 | newPrice)); } @@ -85,75 +102,72 @@ contract StickyOracleTest is Test { } function testInit() public { - vm.expectRevert("StickyOracle/not-init"); - oracle.read(); + assertEq(oracle.read(), 0); + 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.getAccLastVal(), 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.getAccLastTs(), block.timestamp); } - 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); + oracle.poke(); // before: [-,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,105] + assertEq(oracle.getCap(), initialMedianizerPrice * 107625 / 100000); // (100+105)/2 * 1.05 = 107.625 + assertEq(oracle.read(), initialMedianizerPrice * 107625 / 100000); vm.warp(block.timestamp + 1 days); - assertEq(oracle.getAccumulator(block.timestamp / 1 days - 1), 0); + oracle.poke(); // before: [-,105,107.625] + assertEq(oracle.getCap(), initialMedianizerPrice * 111628125 / 100000000); // (105+107.625)/2 * 1.05 = 111.628125 + assertEq(oracle.read(), initialMedianizerPrice * 110 / 100); // blocked by current price of 110 - oracle.fix(block.timestamp / 1 days - 1); + vm.warp(block.timestamp + 2 days); // missing poke for 1 day + oracle.poke(); // before: [-,110,Miss] + assertEq(oracle.getCap(), initialMedianizerPrice * 111628125 / 100000000); // cannot calc twap, cap will stay the same + assertEq(oracle.read(), initialMedianizerPrice * 110 / 100); // still 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)); - } + setMedianizerPrice(initialMedianizerPrice * 111 / 100); // price goes up a bit - function testPoke() public { - vm.prank(PAUSE_PROXY); oracle.init(3); - assertEq(oracle.read(), medianizer.read()); + vm.warp(block.timestamp + 1 days); + oracle.poke(); // before: [-,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: [-,111,111]; + assertEq(oracle.getCap(), initialMedianizerPrice * 11655 / 10000); // (111 + 111)/2 * 1.05 = 116.55 + assertEq(oracle.read(), initialMedianizerPrice * 111 / 100); // still blocked by current price of 111 } }