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 →
M-01·

After invalidate(), the first push from any signer sets an unbounded price that honest signers cannot correct

Fixedrwarfqoracles
TL;DR

pushPrice bounds a push only when a price is already stored (L79-L83):

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

Affected files

src/shared/oracles/OctarineThirdPartySetterOracle.sol#L74-L92

03Section · Description

Description

pushPrice bounds a push only when a price is already stored (L79-L83):

solidity
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);
}

invalidate() deletes price (L89-L92). So after a reset, and before the very first push, any signer may set any price with no step or interval limit, as the L74 natspec says. Every later push is then bounded against whatever that first push set.

That inverts the purpose of the bounds ("so a signer key cannot walk the price far in one sitting", L11-L15) at the one moment the owner uses them. invalidate() is the reset path for a genuine move larger than maxChangeBps, such as a NAV mark-down or a distribution, or for a bad price. Whichever signer pushes first after it chooses the new anchor without limit. The honest correction that follows is held to the same bounds that rogue push skipped, so it is refused until it has been walked back maxChangeBps at a time, one minUpdateInterval apart, or until the owner resets again and the race repeats.

Vulnerable Scenario:

  1. The oracle runs a conservative configuration: 5% per push, one push per hour, two signers.
  2. The issuer marks NAV down from 1.00 to 0.92. That exceeds 5%, so the owner calls invalidate() to let the keeper publish 0.92.
  3. A second signer key, compromised, pushes 1.03 first. It is accepted, unbounded.
  4. The keeper's 0.92 reverts ChangeTooLarge, in that block and an hour later. The oracle keeps serving 1.03.
  5. The attacker fills a maker's open quote at 1.03 for RWA the issuer values at 0.92.
04Section · Impact

Impact

A single signer key that pushes first after a reset fixes the price the RFQ settles at. The price it chooses is not bounded by the oracle, and it cannot be corrected by the other signers within the oracle's own bounds. Live orders and open quotes are exposed up to their signed caps until the owner identifies and removes the key and resets again. In the PoC the maker pays 10,269.10 USDC for 10,000 RWA that the issuer's NAV values at 9,172.40 USDC, +12%, and the keeper's correct price is refused twice. Most of that gap is the 8% markdown the rogue key keeps from being published; the maker's own 3% headroom over the pre-markdown price is the rest. The loss therefore grows with the size of the NAV move the reset was meant to publish.

This is the path your runbook takes for very large NAV moves: the owner invalidates, and a signer pushes the new price. The first push after invalidate() sets the anchor, so on every such move the honest keeper is racing every other signer key for that push.

05Section · Recommendations

Recommendations

Do not let a single signer set the price that the bounds are then measured from. There are two ways to do that, and they differ in who holds price power.

Option A: a second signer confirms the reset price. Owner keeps no price power.

With no live price, which means the first price after deployment or after invalidate(), a push only proposes the value. It goes live when a different signer pushes the same value. Signers keep sole price authority, and the owner keeps only signer management behind signerTimelock, as today.

diff
@@ -23,4 +23,9 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
uint64 public updatedAt;
+ /// @notice With no live price (first push, or after {invalidate}), a push is only proposed; it goes live
+ /// when a different signer pushes the same value.
+ uint256 public pendingPrice;
+ address public pendingSigner;
+
mapping(address => bool) public isSigner;
mapping(address => uint64) public signerSubmittedAt;
@@ -28,4 +33,5 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
event PricePushed(address indexed signer, uint256 price);
event PriceInvalidated();
+ event PriceProposed(address indexed signer, uint256 price);
event SignerSubmitted(address indexed signer, uint64 validAt);
event SignerRevoked(address indexed signer);
@@ -77,5 +83,14 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
if (newPrice == 0) revert ZeroPrice();
uint256 prev = price;
- if (prev != 0) {
+ if (prev == 0) {
+ if (pendingPrice != newPrice || pendingSigner == address(0) || pendingSigner == msg.sender) {
+ pendingPrice = newPrice;
+ pendingSigner = msg.sender;
+ emit PriceProposed(msg.sender, newPrice);
+ return;
+ }
+ delete pendingPrice;
+ delete pendingSigner;
+ } else {
uint64 nextAt = updatedAt + minUpdateInterval;
if (block.timestamp < nextAt) revert TooSoon(nextAt);
@@ -89,4 +104,6 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
function invalidate() external onlyOwner {
delete price;
+ delete pendingPrice;
+ delete pendingSigner;
emit PriceInvalidated();
}
@@ -115,4 +132,8 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
function removeSigner(address signer) external onlyOwner {
isSigner[signer] = false;
+ if (pendingSigner == signer) {
+ delete pendingPrice;
+ delete pendingSigner;
+ }
delete signerSubmittedAt[signer];
emit SignerSet(signer, false);

Costs:

  • Deploy every setter oracle with at least three signers. The honest keepers must publish identical values. They read the same issuer NAV, so that is natural, but it must be exact.
  • A compromised signer can stall a reset, but no longer set the price. By overwriting the proposal it keeps fills on the asset reverting until the owner removes it. Setting the price would take two compromised keys.
  • Recovery needs a third signer. With only two signers, removing the compromised one leaves a single signer, and one signer can never confirm its own proposal. The asset then stays unpriced until a replacement signer clears signerTimelock. In our test (not included here), 24 hourly honest pushes after the removal all stayed proposals, and pricing returned only when a new signer cleared the timelock, 259,200 s later. With three signers, the two honest keys confirm in the next block.
  • In the in-scope suites, 15 tests fail. Every one bootstraps with a single signer push, or asserts the single-push behaviour this removes, and needs its fixture updated.

Option B: the owner publishes the reset price. Simpler, but the owner gains price power.

An owner-only seedPrice sets the first price and the price after invalidate(), and pushPrice refuses when no price is live:

diff
@@ -38,4 +38,5 @@ contract OctarineThirdPartySetterOracle is OctarineOracleBase, Ownable2Step {
error NoPendingSigner();
error TimelockNotElapsed(uint64 validAt);
+ error NotSeeded();
constructor(
@@ -77,9 +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);
- }
+ if (prev == 0) revert NotSeeded();
+ uint64 nextAt = updatedAt + minUpdateInterval;
+ if (block.timestamp < nextAt) revert TooSoon(nextAt);
+ if (_deviates(newPrice, prev, maxChangeBps)) revert ChangeTooLarge(prev, newPrice);
+ price = newPrice;
+ updatedAt = uint64(block.timestamp);
+ emit PricePushed(msg.sender, newPrice);
+ }
+
+ /// @notice Set the price outright: the first price, or the anchor after {invalidate}. Signers resume
+ /// bounded pushes from it.
+ function seedPrice(uint256 newPrice) external onlyOwner {
+ if (newPrice == 0) revert ZeroPrice();
price = newPrice;
updatedAt = uint64(block.timestamp);

Costs:

  • Today the owner cannot set a price at all. To get a price in, it must add a signer and wait out signerTimelock, and that delay is what limits a stolen owner key. With seedPrice, a stolen owner key can call invalidate() and seedPrice() in one block and set any price instantly. If you choose this option, restrict seedPrice to when no price is live, and consider putting it behind the same signerTimelock. The asset is then unpriced for that delay.
  • A large NAV move waits on the owner rather than a keeper, which is slower if the owner is a multisig.
  • In the in-scope suites, 17 tests fail. Every one fails with NotSeeded() at a signer's bootstrap push, and two of them assert the exemption this removes.

Either option also closes the same exemption at deployment, where the first push is otherwise unbounded for every signer. With either applied, testFuzz_resetHandsAnchorToFirstSigner passes 5,000 runs and test_firstPushAfterResetIsStickyAgainstHonestCorrection fails, as described above.

06Section · Remediation

Remediation

Fixed in beefe0b (Option B). After invalidate(), and at deployment, every push reverts NotSeeded until the owner calls seedPrice, so no signer can set the price the bounds are measured from. Replaying the scenario, the rogue key's 1.03 is refused and the maker pays the fair 9,172.40 USDC instead of 10,269.10.

The cost of Option B remains. seedPrice is not behind signerTimelock, so a stolen owner key can call invalidate() and seedPrice() in one block and set any price. The setter oracle's owner is therefore trusted with the price; the README does not yet say so. A reset also leaves each signer's interval running, so a signer whose interval is open can move a fresh seed by one step.

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
M-01