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

poke once a day, use struct, more changes #4

Draft
wants to merge 13 commits into
base: sticky
Choose a base branch
from
139 changes: 77 additions & 62 deletions src/StickyOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

The main reason for using a uint128 type was to be able to pack val and age in the same slot (and avoid an explicit cast for a value which under the hood is anyway a uint128 in the pip/osm). But since these variables have been removed, feel free to use a bytes32 (like in the osm) or a uint256 (like in the pip).

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)
Copy link
Contributor

Choose a reason for hiding this comment

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

What I was trying to say here is that, given that the TWAP is an unbiased smoothed estimate of the price at the time in the middle of the TWAP window, the slope can be interpreted as the allowable growth between that particular time and now. But maybe it's simpler to just describe it as a TWAP multiplier.

Suggested change
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)
uint96 public slope = uint96(RAY); // maximum allowable price growth factor in reference to the TWAP (in RAY such that slope = (1 + {max growth rate}) * RAY)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ahh ok got it. Although I think it is easier to describe it as the multiplier of the TWAP.
Your correction looks good to me.

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;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure tracking the exact poke ts is worth the added poke cost (vs just using the eod ts). Generally if we expect the window to be much larger than 1 day (e.g. 30 days) the gain in precision is likely to be negligible (I did some quick approximation math to convince myself of it but might be good to test with actual values).

Copy link
Contributor Author

@oldchili oldchili Dec 5, 2023

Choose a reason for hiding this comment

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

Well the poke is only done once a day, so even if it's a matter of a few storage operations (probably less) I think it's negligible.
I think storing the timestamps is the more simple and standard way (for example Uniswap have this Observation struct - https://github.com/Uniswap/v2-periphery/blob/master/contracts/examples/ExampleSlidingWindowOracle.sol#L19).

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, though note that in the Uniswap example the timestamp is necessary as the accumulators are not indexed by days. That said I don't mind storing the ts here given the frequency of poke calls. I guess we don't need it to be packed as a uint32 though, uint256 would work fine and save a tiny bit of gas.

}

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);
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
function init(uint256 days_) external auth returns(bool) {
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);
}
}
138 changes: 81 additions & 57 deletions test/StickyOracle.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}

Expand All @@ -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));
}
Expand Down Expand Up @@ -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
}
}
Loading