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
122 changes: 64 additions & 58 deletions src/StickyOracle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
oldchili marked this conversation as resolved.
Show resolved Hide resolved
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)
oldchili marked this conversation as resolved.
Show resolved Hide resolved
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;
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 cur);
event Poke(uint256 indexed day, uint128 cap);

constructor(address _pip) {
pip = PipLike(_pip);
Expand Down Expand Up @@ -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);
telome marked this conversation as resolved.
Show resolved Hide resolved
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);
oldchili marked this conversation as resolved.
Show resolved Hide resolved
}

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
}
}
130 changes: 72 additions & 58 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 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();
}
}

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