Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
L-04·

Setter oracle accepts guard values that switch off its bounds, so one signer key can move the price without limit in a single block

Fixedrwarfqoracles
TL;DR

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.

Severity
LOW
Method
MManual review
CAT.
02Section · Affected files

Affected files

src/shared/oracles/OctarineThirdPartySetterOracle.sol#L51 src/shared/oracles/OctarineThirdPartySetterOracle.sol#L79-L83

03Section · Description

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):

solidity
if (maxAge_ == 0 || maxChangeBps_ == 0 || signerTimelock_ == 0) revert InvalidGuards();
  • minUpdateInterval = 0 is accepted. TooSoon can 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 to maxChangeBps from 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, all minUpdateInterval = 0, maxChangeBps = 1_000).
  • maxChangeBps is a uint16 with no ceiling. At or above 10_000, _deviates admits 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:

  1. An oracle is deployed with minUpdateInterval = 0 (as in the fixtures) and registered in OctarineRFQ. A maker has a live open quote on the asset with maxMakerAmount headroom above the current price.
  2. 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.
  3. In the same block the attacker fills the open quote as taker. quoteRFQOrder prices the fill at the walked price, and the maker pays up to its full cap for RWA worth 1.00x.
04Section · Impact

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.

05Section · Recommendations

Recommendations

Refuse the values that switch the bounds off, and require enough room for the bounds to work in practice:

diff
--- 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. Rejecting maxChangeBps >= 10_000 closes 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 to maxAge, 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. registerOracle now refuses a staleness below twice the oracle's minUpdateInterval. 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 maxAge is refused.
  • A configuration at the limits deploys, prices, and accepts a full 20% step.
  • registerOracle refuses 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 maxAge and 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.

06Section · Remediation

Remediation

Fixed in ce92212, documented in 00407fe. The constructor refuses:

  • a zero minUpdateInterval;
  • a maxChangeBps above 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;
  • maxChangeBps sized 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.

07Section · Report provenance

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.

Status
Fixed
L-04