[BUG]: Old prices stay in the feed after someone resubmits (decay is not multiplicative)
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- solidity
- Domain
- backend-api-design, blockchain
Research direction
Start with src/Oracle.sol:109-125 and src/DecayLib.sol:36-47, then run the reproduction with forge test --match-path test/DecayRepro.t.sol -vv. The failing assertions show that decay does not compose and old values remain after correction; done means the decay behavior is consistent across submission intervals and the reproduced feed returns 100 after Alice's correction. The issue asks whether to use exponential decay or weights at a fixed reference time, so the design choice needs resolution.
Written by the indexing model from the issue text.
Description
Bug Description
When someone corrects their price part of their old price stays in the feed.
submitValue decays the total weight a little at every submission (src/Oracle.sol:109). Then it takes out the submitter's old weight decayed in one go from their last submit (src/Oracle.sol:116). That only works if small steps give the same result as one big step. DecayLib.decayFactor draws straight lines between powers of 2 (src/DecayLib.sol:36-47) so they don't match. Two half steps give 0.5625 but one full half life gives 0.5. So less weight gets taken out than is really there and the leftover keeps the old price.
The paper's decay does compose (decay_add in OrbOracle-Formalization Oracle.v). Posters submit every 60s by default so this happens all the time. It's also why #17 can't be fixed cleanly yet. I also hit a related underflow at src/Oracle.sol:125 with some stake values.
Steps to Reproduce
- Make an oracle with a 60s half life. Alice stakes 100 and Bob stakes 1.
- Alice submits 10000.
- Bob submits 100 once in between.
- After 60s Alice submits 100.
readValue()should be 100 but it's 678. If Bob submits 59 times in between it's 1035.
Logs and Screenshots
Save the test below as test/DecayRepro.t.sol and run forge test --match-path test/DecayRepro.t.sol -vv. Both tests fail on main.
Exact equality isn't possible in fixed point so the asserts allow 1 wei and 100 wei of rounding.
Reproduction test
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test, console2} from "lib/forge-std/src/Test.sol";
import {ERC20} from "lib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";
import {Oracle} from "../src/Oracle.sol";
import {DecayLib} from "../src/DecayLib.sol";
contract MockToken is ERC20 {
constructor() ERC20("Weight", "WGT") {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
contract DecayReproTest is Test {
address alice = makeAddr("alice"); // stake 100
address bob = makeAddr("bob"); // stake 1
function _stake(Oracle o, MockToken t, address user, uint256 amount) internal {
t.mint(user, amount);
vm.startPrank(user);
t.approve(address(o), amount);
o.depositTokens(amount);
vm.stopPrank();
}
// Alice says 10000, then corrects herself to 100 one half-life later.
// Bob says 100 a few times in between. Everyone now says 100, so the feed should be 100.
function _feedAfterCorrection(uint256 bobSubmissions) internal returns (uint256) {
MockToken t = new MockToken();
// halfLife 60s, q 50000, both locks 60s, rewardBps 100, gamma 0, sampleSize 5
Oracle o = new Oracle(address(this), "test", "test", address(t), 60, 50_000, 60, 60, 100, 0, 5);
_stake(o, t, alice, 100e18);
_stake(o, t, bob, 1e18);
uint256 start = vm.getBlockTimestamp() + 61;
vm.warp(start);
vm.prank(alice);
o.submitValue(10_000e18);
uint256 step = 60 / (bobSubmissions + 1);
for (uint256 i = 1; i <= bobSubmissions; i++) {
vm.warp(start + i * step);
vm.prank(bob);
o.submitValue(100e18);
}
vm.warp(start + 60);
vm.prank(alice);
o.submitValue(100e18);
return o.readValue();
}
function test_decayIsNotMultiplicative() public pure {
uint256 half = DecayLib.decayFactor(30, 60); // 0.5 half-life
console2.log("decay(0.5 HL) * decay(0.5 HL):", half * half / 1e18);
console2.log("decay(1 HL): ", DecayLib.decayFactor(60, 60));
// 1 wei of slack for fixed point rounding
assertApproxEqAbs(half * half / 1e18, DecayLib.decayFactor(60, 60), 1, "decay must compose");
}
function test_oldValueStaysInFeed() public {
console2.log("bob submits 0 times in between -> feed:", _feedAfterCorrection(0));
console2.log("bob submits 1 time in between -> feed:", _feedAfterCorrection(1));
console2.log("bob submits 59 times in between -> feed:", _feedAfterCorrection(59));
// 100 wei of slack for fixed point rounding
assertApproxEqAbs(_feedAfterCorrection(1), 100e18, 100, "old value remains in feed");
}
}
Ran 2 tests for test/DecayRepro.t.sol:DecayReproTest
[FAIL: decay must compose: 562500000000000000 !~= 500000000000000000 (max delta: 1, real delta: 62500000000000000)] test_decayIsNotMultiplicative() (gas: 35082)
Logs:
decay(0.5 HL) * decay(0.5 HL): 562500000000000000
decay(1 HL): 500000000000000000
[FAIL: old value remains in feed: 678271028037383177569 !~= 100000000000000000000 (max delta: 100, real delta: 578271028037383177569)] test_oldValueStaysInFeed() (gas: 23326813)
Logs:
bob submits 0 times in between -> feed: 100000000000000000000
bob submits 1 time in between -> feed: 678271028037383177569
bob submits 59 times in between -> feed: 1035439416692341479361
Suite result: FAILED. 0 passed; 2 failed; 0 skipped; finished in 29.44ms (23.91ms CPU time)
All submitted values are 100 after Alice's correction so the feed should go back to 100. It stays at about 678 with one submission in between and 1035 with 59.
I'd like to work on this. Would you prefer exponential decay or weights stored at a fixed reference time?
Environment Details
macOS with forge 1.8.7 (same as CI) and solc 0.8.33 with via_ir. Upstream main at c2cb5bd. Local test only with no fork. I used Claude Code to help write the test and this text and I checked the numbers myself.
Impact
High - Major feature is broken
Code of Conduct
- I have joined the Discord server and will post updates there
- I have searched existing issues to avoid duplicates
- Dominant language
- Solidity
- Stars
- 3
- Forks
- 3
- Avg merge
- 15h 55m
- Merged PRs (30d)
- 6
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from StabilityNexus/OrbOracle-Solidity
-
[BUG]: Oracle with rewardBps above 1e5 can never accept a funded price submissionPossibly taken @kansalakshat claimed this 1 day ago. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
StabilityNexus/OrbOracle-Solidity#27 · 1 comment ·
-
bug
Difficulty 3/5 1-2 days Newbie friendliness 73/100
-
Difficulty 5/5 Over a week Newbie friendliness 25/100
StabilityNexus/OrbOracle-Solidity#4 · 1 comment ·
-
enhancement
Difficulty 4/5 3-5 days Newbie friendliness 35/100
StabilityNexus/OrbOracle-Solidity#2 · 1 comment ·
All issues in StabilityNexus/OrbOracle-Solidity
Similar issues
-
dropIndexes by key pattern panics on a shorter pattern and drops the wrong index on a longer oneOpen
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mega-yfue/ha-eufy-sdk-bridge#198 ·
Maintainers usually reply within 1 day
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
qte77/agentic-job-offer-to-application-kit#487 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
btclib-org/btclib-wallet#801 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 2 days