One compromised setter signer can take every update slot, so honest signers cannot correct a walked price and extra signers add no protection
The setter oracle's update interval is one slot shared by every signer. pushPrice measures it from the last push by anyone (L80-L82):
Affected files
src/shared/oracles/OctarineThirdPartySetterOracle.sol#L75-L87
Description
The setter oracle's update interval is one slot shared by every signer. pushPrice measures it from the last push by anyone (L80-L82):
uint64 nextAt = updatedAt + minUpdateInterval;if (block.timestamp < nextAt) revert TooSoon(nextAt);if (_deviates(newPrice, prev, maxChangeBps)) revert ChangeTooLarge(prev, newPrice);
A compromised signer that pushes as soon as each slot opens therefore does two things at once:
- It walks the price. It moves the price by up to
maxChangeBpsper interval, so afterkintervals the price is(1 − maxChangeBps/10_000)^kof where it started. - It blocks every correction. Once its push lands, every other push in that interval reverts
TooSoon. A rogue that submits as each slot opens, with a higher priority fee where there is a public mempool, can take every slot.
So an oracle with several signers drifts exactly as fast as one with a single rogue signer. Extra signers add no protection until the owner notices and calls removeSigner.
Which side loses depends on the direction of the walk:
- On a walk up, makers are protected: you told us makers set
maxMakerAmountfrom their own independent NAV. - On a walk down, nothing protects the taker. You told us the UI sets
minReceivedAmountto the quote at signing minus the slippage tolerance (1% by default), and that quote is computed from the walked price. Every signed bound is satisfied.
The natspec's claim that "a signer key cannot walk the price far in one sitting" holds per interval. What breaks is the containment between intervals: honest signers cannot pull the price back.
Impact
Setup: two signers (an honest bot and a compromised one), a 1-hour interval and a 5% step. The true NAV stays at 1.00 throughout.
- The rogue pushes −5% as each slot opens, and the honest bot's correction a minute later is refused 12 times out of 12.
- After 12 hours the oracle serves 0.5404.
- A taker then asks for a quote on 1,000 RWA, signs a floor of the quote minus 1%, and is filled by a maker key controlled by the same attacker. The taker receives 524.15 USDC against 970.00 at NAV: a loss of 445.85 USDC (46%).
The loss grows with the time until the compromise is noticed. At a 24-hour interval it is 5% per day, about 14% over three days. A taker who checks the issuer's published NAV would refuse a large discount, so the realistic exposure is the first hours, while the walk is still a few percent. Honest pushes reverting TooSoon are the only on-chain signal.
Recommendations
- Measure the update interval per signer, and bound each step against the lower of the two prices. The per-signer interval stops one key from occupying every slot. The second change is needed too. The current bound is a percentage of the previous price, so a 5% step down followed by a 5% step back up leaves the price 0.25% lower (0.95 × 1.05 = 0.9975). With only the per-signer interval, every honest correction lands, but with one honest signer the price still walks down by 0.25% per interval: 0.9704 after 12 hours, 0.6567 after a week. Measured against the lower price, a step down and the step back up cancel exactly.
diff --git a/src/shared/oracles/OctarineThirdPartySetterOracle.sol b/src/shared/oracles/OctarineThirdPartySetterOracle.sol--- a/src/shared/oracles/OctarineThirdPartySetterOracle.sol+++ b/src/shared/oracles/OctarineThirdPartySetterOracle.sol@@ -24,6 +24,7 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {mapping(address => bool) public isSigner;mapping(address => uint64) public signerSubmittedAt;+ mapping(address => uint64) public lastPushAt;event PricePushed(address indexed signer, uint256 price);event PriceInvalidated();@@ -77,12 +78,17 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {if (newPrice == 0) revert ZeroPrice();uint256 prev = price;if (prev != 0) {- uint64 nextAt = updatedAt + minUpdateInterval;- if (block.timestamp < nextAt) revert TooSoon(nextAt);- if (_deviates(newPrice, prev, maxChangeBps)) revert ChangeTooLarge(prev, newPrice);+ // The interval is per signer, so one key cannot occupy every slot.+ uint64 last = lastPushAt[msg.sender];+ uint64 nextAt = last + minUpdateInterval;+ if (last != 0 && block.timestamp < nextAt) revert TooSoon(nextAt);+ // Bound the step as a ratio of the lower price, so one honest step always undoes one rogue step.+ (uint256 lo, uint256 hi) = newPrice < prev ? (newPrice, prev) : (prev, newPrice);+ if ((hi - lo) * BPS > lo * maxChangeBps) revert ChangeTooLarge(prev, newPrice);}price = newPrice;updatedAt = uint64(block.timestamp);+ lastPushAt[msg.sender] = uint64(block.timestamp);emit PricePushed(msg.sender, newPrice);}
With only the per-signer interval, the taker receives 941.30 USDC in the same test. With this change the test fails at the rogue's first push with ChangeTooLarge, because the largest step down is now 4.76% at a 5% setting. With the rogue stepping down 4.76% instead, every honest correction lands and restores 1.00, and the taker receives 970.00 USDC. Between corrections the rogue can hold the price at most one step (4.76%) below the honest one. Your full test suite passes with this change (706 passed, 1 skipped).
This relies on an honest signer that corrects at the rogue's pace: one push every minUpdateInterval, one step toward NAV, for as long as the oracle is off it. A keeper that pushes only when the issuer publishes gets one correction per publication against one rogue step per interval; in our test the oracle served 0.33 after 24 hours. Correcting every interval fits [M-04]'s on-chain option, where each correction carries the issuer's real publication time and so does not make a stale rate look fresh. It does not fit [M-04]'s keeper-policy option.
If you adopt the change to pushPrice recommended in [M-04], keep its pushedAt per signer in the same way, and use this step bound in place of its _deviates check.
This is a mitigation, not a cure. Each signer can now push once per interval, so n signers can move the price n steps in one interval. If that matters for an oracle, size maxChangeBps for it.
- For any setter oracle that carries significant value, use a quorum or a median of signers rather than signers with equal, independent power.
- Monitor
PricePushed. Raise an alarm when a signer pushes a price more than a small tolerance away from your keeper's own NAV, or when a keeper push revertsChangeTooLarge. ThenremoveSignerand pause or unroute the asset. Once the interval is per signer, honest pushes no longer revertTooSoon, so that revert stops being a signal.
Remediation
Fixed in b12c068, as recommended, with the residual risk documented in 9076b6c. The update interval is now per signer, and each step is bounded against the lower of the two prices. One key can no longer walk the price: the taker receives the full 970.00 USDC.
The README documents what remains, together with monitoring of PricePushed:
- One compromised key can hold the price one step off NAV between honest pushes (923.81 instead of 970.00 in our test).
- Two compromised keys can walk the price again.
A quorum or median of signers is the full fix.
A runnable Foundry proof of concept for this finding is in the corresponding issue on the project tracker.
Report provenance
Source: the final report dated 28 October 2026. Original finding identifiers are retained, including M-03 and M-04, which were regraded Low. Final dispositions and residual conditions are preserved in this write-up. Read the full report.