CallOracle without a timestamp call reports every read as fresh, so the RFQ's mandatory staleness bound never applies to issuer-set rates
OctarineRFQ treats staleness as mandatory on every route. registerOracle refuses a zero staleness (L210), and _tryOracle documents that a registered feed yields "0 if it is absent, reverting, zero or stale" (L194-L203).
Affected files
src/shared/oracles/OctarineCallOracle.sol#L54-L61 src/octarine-rfq/OctarineRFQ.sol#L194-L203
Description
OctarineRFQ treats staleness as mandatory on every route. registerOracle refuses a zero staleness (L210), and _tryOracle documents that a registered feed yields "0 if it is absent, reverting, zero or stale" (L194-L203). It checks block.timestamp <= updatedAt + oracleMaxStaleness[base][quote] against the lastUpdated the adapter returns.
OctarineCallOracle without a timestamp call returns block.timestamp as lastUpdated (L56):
if (timestampTarget == address(0)) return (_scale(_readSource(), sourceDecimals, inverse), block.timestamp);
The read is therefore reported as current on every call, and the RFQ's staleness bound can never fire for that route, whatever value it was registered with. The constructor documents an empty timestamp call as being "for a live source". But the contract's own list of supported sources (L7-L10) includes rates an issuer sets and leaves until it sets them again, and at least one of them cannot be given a timestamp call at all. BENJI's Ethereum token (0x3ddc84940ab509c11b20b76b466933f40b750dc9, a verified EIP-1967 proxy to MoneyMarketFund_V6) exposes lastKnownPrice() and no update-time getter of any kind.
If an issuer stops maintaining such a rate, for example after migrating to a new contract, suspending the fund, or simply missing an update, fills keep settling on the last value indefinitely. Both counterparties' signed bounds are derived from that same stale price, so neither bound limits the error. The loss to whichever side the stale price disfavours is the full gap between the frozen rate and the asset's current value.
Recommendations
Make "always fresh" an explicit, recorded decision, rather than the default whenever the timestamp call is left empty:
@@ -15,4 +15,7 @@ contract OctarineCallOracle is OctarineOracleBase {uint32 public immutable gasCap;address public immutable timestampTarget;+ /// @notice Set only for a source that computes its price on every read (e.g. ERC-4626+ /// `convertToAssets`). An issuer-set rate is not live, even if it has no update-time getter.+ bool public immutable live;bytes public callData;@@ -33,6 +36,9 @@ contract OctarineCallOracle is OctarineOracleBase {uint32 gasCap_,address timestampTarget_,- bytes memory timestampCallData_+ bytes memory timestampCallData_,+ bool live_) OctarineOracleBase(base_, quote_) {+ if ((timestampCallData_.length == 0) != live_) revert InvalidGuards();+ live = live_;if (target_ == address(0) || callData_.length < 4 || gasCap_ == 0) {revert InvalidGuards();
The flag records the choice, but it cannot stop someone declaring an issuer-set rate "live". For an issuer-set rate with no on-chain update time, such as Pareto's AA price, this adapter has no way to detect staleness. Where a source publishes a related time, such as ULTRA's epoch start, point timestampTarget at it; it bounds an outage of the issuer's process rather than the age of the rate itself. Otherwise price the rate through OctarineThirdPartySetterOracle, with two conditions:
-
Age the price from the issuer's publication, not from the push. The setter oracle measures age from the moment of the push. A keeper that re-pushes an unchanged rate to keep the route alive therefore keeps a frozen issuer rate fresh indefinitely. In our test (not included here), 30 days after the issuer's last publication, the oracle's age was 0 s and a fill still settled; the maker paid 1,004.08 USDC, 56.93 more than at a 0.95 mark. Close this in one of two ways:
- Keeper policy: push only when the issuer publishes a new rate, never on a timer, and set
maxAgeabove the issuer's longest normal gap (about 5 days for ULTRA and OUSG, about 35 days for Pareto's epochs). - On-chain: have the signer attest the publication time. Staleness is then measured from it, and the update interval from the push.
The diff applies on top of the fixes recommended for [M-01] (Option A) and [L-04]. Apply it together with [L-07], keeping
pushedAtper signer: the guarantee in its natspec depends on that. With one interval shared by all signers, a compromised signer that takes every slot with an oldobservedAtgets every honest refresh refused withTooSoon, and the live price ages out aftermaxAge; in our test a fill then paid 1,018.50 instead of 970.00. WithpushedAtper signer, no refresh was refused and the fill paid 970.00. - Keeper policy: push only when the issuer publishes a new rate, never on a timer, and set
--- a/src/shared/oracles/OctarineThirdPartySetterOracle.sol+++ b/src/shared/oracles/OctarineThirdPartySetterOracle.sol@@ -21,12 +21,17 @@uint32 public immutable signerTimelock;uint256 public price;+ /// @notice When the SOURCE published `price` (signer-attested). Staleness is measured from this, not from+ /// the push, so re-pushing an unchanged rate cannot keep a frozen source fresh.uint64 public updatedAt;+ /// @notice When `price` was last pushed. `minUpdateInterval` is measured from this.+ uint64 public pushedAt;/// @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;+ uint64 public pendingObservedAt;mapping(address => bool) public isSigner;mapping(address => uint64) public signerSubmittedAt;@@ -44,6 +49,7 @@error ChangeTooLarge(uint256 previous, uint256 next);error NoPendingSigner();error TimelockNotElapsed(uint64 validAt);+ error BadObservation(uint64 observedAt);constructor(address owner_,@@ -84,26 +90,34 @@}/// @notice Push a new price. The first push, and the first after {invalidate}, is not change-bounded.- function pushPrice(uint256 newPrice) external {+ /// @param observedAt When the source published `newPrice`; never in the future. An older time than the stored+ /// one is accepted but never moves `updatedAt` back, so a rogue signer can neither lock out an honest+ /// correction carrying the issuer's real time nor make a live price read stale.+ function pushPrice(uint256 newPrice, uint64 observedAt) external {if (!isSigner[msg.sender]) revert NotSigner();if (newPrice == 0) revert ZeroPrice();+ if (observedAt > block.timestamp) revert BadObservation(observedAt);uint256 prev = price;if (prev == 0) {if (pendingPrice != newPrice || pendingSigner == address(0) || pendingSigner == msg.sender) {pendingPrice = newPrice;pendingSigner = msg.sender;+ pendingObservedAt = observedAt;emit PriceProposed(msg.sender, newPrice);return;}+ if (pendingObservedAt > observedAt) observedAt = pendingObservedAt;delete pendingPrice;delete pendingSigner;+ delete pendingObservedAt;} else {- uint64 nextAt = updatedAt + minUpdateInterval;+ uint64 nextAt = pushedAt + minUpdateInterval;if (block.timestamp < nextAt) revert TooSoon(nextAt);if (_deviates(newPrice, prev, maxChangeBps)) revert ChangeTooLarge(prev, newPrice);}price = newPrice;- updatedAt = uint64(block.timestamp);+ if (prev == 0 || observedAt > updatedAt) updatedAt = observedAt;+ pushedAt = uint64(block.timestamp);emit PricePushed(msg.sender, newPrice);}@@ -111,6 +125,7 @@delete price;delete pendingPrice;delete pendingSigner;+ delete pendingObservedAt;emit PriceInvalidated();}
Never let an older observedAt move the publication time back, but do not reject it either. Rejecting it would let a compromised signer stamp the current time and lock out an honest correction carrying the issuer's real, older time. Accepting it as-is would let that signer stamp an old time whenever it wins a slot: the route then reads as stale at once and refuses fills, and a direct pair silently reprices on the reference-asset route (in our test, a fill paid 1,018.50 instead of 970.00). The diff keeps the later of the two times, including when a second signer confirms a proposed price. A compromised signer can still keep a frozen price fresh by stamping the current time, which it can already do today.
- Apply that oracle's own fixes first: the reset fix in [M-01], the guard values in [L-04], and, for a source that can default, the guard in [M-06].
Also mark, in the supported-source list at L7-L10, which sources need a timestamp call.
Costs:
- The constructor gains a parameter. Nothing in
src/orscript/constructsOctarineCallOracle; the 6 calls intest/unit/OctarineCallOracle.t.solneed the extra argument. - Moving no-clock sources to the setter oracle adds a keeper dependency for those assets.
- The on-chain publication time changes
pushPrice's signature. Keepers must supply the issuer's publication time, and the 10pushPricecalls in 4 test files need the extra argument.
Remediation
Acknowledged, and regraded to Low by agreement (Medium at delivery). A source without a timestamp call still reports every read as current, by design. Since 3bd8a2a the adapter exposes that choice as live(), readable on-chain.
The launch exposure that set the original severity is gone:
- ULTRA is now bounded through its epoch timestamp. That bounds the issuer's daily process rather than the rate itself.
- A Pareto credit event is caught by the
defaulted()guard from [M-06].
One gap remains: a deployment that passes a timestamp target with empty timestamp calldata deploys as live and silently discards the target. Refusing that combination in the constructor would make the choice explicit.
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.