Setter oracle accepts guard values that switch off its bounds, so one signer key can move the price without limit in a single block
OctarineThirdPartySetterOracle bounds a signer in two ways: each push may move the price by at most maxChangeBps, and a push may not land sooner than minUpdateInterval after the last one.
Affected files
src/shared/oracles/OctarineThirdPartySetterOracle.sol#L51 src/shared/oracles/OctarineThirdPartySetterOracle.sol#L79-L83
Description
OctarineThirdPartySetterOracle bounds a signer in two ways: each push may move the price by at most maxChangeBps, and a push may not land sooner than minUpdateInterval after the last one. Its natspec states the purpose: "so a signer key cannot walk the price far in one sitting" (L11-L15). The README relies on it in the same way: "Manual prices are bounded on the oracle (OctarineThirdPartySetterOracle), not here."
The constructor validates maxAge, maxChangeBps and signerTimelock, but not the two values that make those bounds meaningful (L51):
if (maxAge_ == 0 || maxChangeBps_ == 0 || signerTimelock_ == 0) revert InvalidGuards();
minUpdateInterval = 0is accepted.TooSooncan then never fire (L80-L81), so a signer may push any number of times in the same block, each push moving the price by up tomaxChangeBpsfrom the previous one. Ten pushes at 10% compound to 1.1^10 ≈ 2.59x. The bound limits the step but no longer limits the walk. The repository's own RFQ fixtures deploy the oracle this way (test/unit/OctarineRFQPricing.t.sol:181,test/unit/OctarineRFQPushedPriceScale.t.sol:36,test/integration/OctarineRFQIntegration.t.sol:258, allminUpdateInterval = 0,maxChangeBps = 1_000).maxChangeBpsis auint16with no ceiling. At or above10_000,_deviatesadmits any decrease, so one correctly spaced push can take the price to 1 wei.
Both parameters are immutable, so an oracle deployed with either value carries it until it is replaced.
Vulnerable Scenario:
- An oracle is deployed with
minUpdateInterval = 0(as in the fixtures) and registered inOctarineRFQ. A maker has a live open quote on the asset withmaxMakerAmountheadroom above the current price. - A signer key is compromised. In one block it pushes +10%, +10% and then to 1.30x. Each push is within
maxChangeBps, and none is refused. - In the same block the attacker fills the open quote as taker.
quoteRFQOrderprices the fill at the walked price, and the maker pays up to its full cap for RWA worth 1.00x.
Impact
One compromised signer key can set an arbitrary price in a single block. What a fill can then take is capped by the counterparty's own signed bound: maxMakerAmount on any open quote, which any address can fill, and maxMakerAmount or minReceivedAmount on any order the attacker is the counterparty to. So the cost of the missing interval is the part of that headroom beyond one maxChangeBps step, which is all a correctly spaced oracle lets a key take per interval.
- With a large step, such as the fixtures' 10%, one spaced push already covers a typical 1–2% headroom, and the zero interval adds little.
- With a NAV-scale step, it hands over the whole headroom at once. With a 0.5% step and a maker that left 2% over the fair cost, the maker pays 10,169.40 USDC for 10,000 RWA in one block. A correctly spaced oracle limits it to 10,019.85 USDC for that interval.
Any consumer of the same oracle that has no per-order cap is exposed to the full walk.
Recommendations
Refuse the values that switch the bounds off, and require enough room for the bounds to work in practice:
--- a/src/octarine-rfq/OctarineRFQ.sol+++ b/src/octarine-rfq/OctarineRFQ.sol@@ -208,6 +208,11 @@ contract OctarineRFQ is IOctarineRFQ, AccessControl, ReentrancyGuard, Pausable,{if (base == address(0) || quote == address(0)) revert ZeroAddress();if (oracle != address(0) && staleness == 0) revert InvalidStaleness();+ if (oracle != address(0)) {+ // A pushed source must be refreshable well inside the window fills accept it for.+ (bool ok, bytes memory d) = oracle.staticcall(abi.encodeWithSignature("minUpdateInterval()"));+ if (ok && d.length == 32 && uint256(staleness) < 2 * abi.decode(d, (uint256))) revert InvalidStaleness();+ }oracles[base][quote] = IOctarineOracle(oracle);oracleMaxStaleness[base][quote] = staleness;--- a/src/shared/oracles/OctarineThirdPartySetterOracle.sol+++ b/src/shared/oracles/OctarineThirdPartySetterOracle.sol@@ -14,6 +14,7 @@ import {OctarineOracleBase} from "./OctarineOracleBase.sol";/// key cannot walk the price far in one sitting. Adding a signer waits out `signerTimelock`; removing one/// is immediate.contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {+ uint16 public constant MAX_CHANGE_BPS_CEILING = 2_000;uint32 public immutable maxAge;uint16 public immutable maxChangeBps;uint32 public immutable minUpdateInterval;@@ -48,7 +49,12 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {uint32 minUpdateInterval_,uint32 signerTimelock_) OctarineOracleBase(base_, quote_) Ownable(owner_) {- if (maxAge_ == 0 || maxChangeBps_ == 0 || signerTimelock_ == 0) revert InvalidGuards();+ if (+ maxAge_ == 0 || maxChangeBps_ == 0 || maxChangeBps_ > MAX_CHANGE_BPS_CEILING || minUpdateInterval_ == 0+ || uint256(minUpdateInterval_) * 2 > maxAge_ || signerTimelock_ == 0+ ) {+ revert InvalidGuards();+ }maxAge = maxAge_;maxChangeBps = maxChangeBps_;minUpdateInterval = minUpdateInterval_;
- A ceiling on
maxChangeBps, not only below 10,000. RejectingmaxChangeBps >= 10_000closes the collapse to 1 wei, but 9,999 would still let one push take the price down to 0.01% of its previous value. A pair that divides by this price (an inverse leg, or the quote leg of a cross) would then move ×10,000 in a single push. Choose a ceiling a real NAV never needs in one step; the diff uses 20%. - A non-zero interval with headroom:
2 × minUpdateInterval <= maxAge. With the interval equal tomaxAge, a keeper one block late leaves the route without a price for a few seconds every cycle. - An RFQ staleness of at least twice the interval.
registerOraclenow refuses a staleness below twice the oracle'sminUpdateInterval. Otherwise the route is down for most of every interval: in our test (not included here), with a 1-hour interval and 30-minute staleness, it was down for 1,799 s of every hour.
These values are chosen per asset by whoever deploys and operates each oracle, so the constructor and registerOracle are the only places the protocol can hold every deployment to them.
We checked this, with the ceiling at 20%:
- 9,999 and 2,001 are refused.
- An interval above half of
maxAgeis refused. - A configuration at the limits deploys, prices, and accepts a full 20% step.
registerOraclerefuses a staleness below twice the interval and accepts exactly twice.- The full test suite passes once the three fixtures above use a 1-second interval. Their
maxAgeand staleness are 1 hour. - No test depends on several pushes in one block.
A non-zero floor is necessary, not sufficient. At minUpdateInterval = 1 a signer can still walk maxChangeBps per second, and over time t the bound is (1 + maxChangeBps / 10_000)^(t / minUpdateInterval). Choose minUpdateInterval against how fast a bad push can be detected and the signer removed; a 24-hour interval then needs a maxAge of at least 48 hours. Alternatively, bound the total move per time window rather than per push. That costs one stored anchor price and timestamp.
Remediation
Fixed in ce92212, documented in 00407fe. The constructor refuses:
- a zero
minUpdateInterval; - a
maxChangeBpsabove 2,500 (25%); - an interval above half of
maxAge.
The README adds the two deployment rules we asked for:
- an RFQ staleness of at least twice the oracle's interval;
maxChangeBpssized for the number of signers and for inverse legs: at 25%, one push moves an inverse leg ×1.25.
The staleness rule is documented, not enforced in registerOracle. A registration below it leaves the route down for most of every interval.
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.