Severity: Low (integration hazard) · Target: ARUPriceOracle.sol
stalenessThreshold is written (:74, :141-146) and never read anywhere in the contract. latestRoundData() returns the last price with no freshness check:
solidity// ARUPriceOracle.sol:108-115
function latestRoundData()
external
view
override
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
return (latestRoundId, price, priceTimestamp, priceTimestamp, latestRoundId);
}
This mirrors Chainlink's own pattern — consumers are expected to check block.timestamp - updatedAt against a threshold. But stalenessThreshold being a stored, configurable parameter strongly implies it's enforced. It isn't. That mismatch is the integration hazard.
Keep latestRoundData() Chainlink-compatible, add a strict variant, and make stalenessThreshold load-bearing.
Rejected alternatives:
stalenessThreshold entirely. Leaves the contract even more "you're on your own" than Chainlink. Rejected because operational monitoring wants a single canonical threshold that lives on-chain.latestRoundData() revert on stale. Breaks Chainlink interface contract; downstream aggregators that batch-read many feeds would blow up on a single stale feed.latestRoundDataStrict()solidity/// @notice Reverts if the price is older than stalenessThreshold. Integrators who want
/// hard-fail semantics should call this instead of latestRoundData().
function latestRoundDataStrict()
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
if (block.timestamp > priceTimestamp + stalenessThreshold) {
revert PriceStale(priceTimestamp, block.timestamp, stalenessThreshold);
}
return (latestRoundId, price, priceTimestamp, priceTimestamp, latestRoundId);
}
solidityerror PriceStale(uint256 lastUpdate, uint256 currentTime, uint256 threshold);
latestRoundData() is unchanged — still returns the last value, still doesn't revert. Chainlink-compatible downstream code keeps working.
Not added: isStale() / priceAge() convenience views. The existing public state getters (priceTimestamp(), stalenessThreshold()) already let any consumer compute both in one line:
soliditybool stale = block.timestamp > oracle.priceTimestamp() + oracle.stalenessThreshold();
uint256 age = block.timestamp - oracle.priceTimestamp();
Adding dedicated helpers would save 2 lines of integrator code at the cost of additional surface area to audit. Deferred until a real integrator asks for the sugar.
src/lib/contracts.tsAdd the strict variant to ORACLE_ABI so the AP-facing UI can surface staleness:
ts{ type: "function", name: "latestRoundDataStrict", inputs: [], outputs: [ /* same as latestRoundData */ ], stateMutability: "view" },
priceTimestamp + stalenessThreshold without updating — latestRoundDataStrict() reverts PriceStale; latestRoundData() still returns the stale value (by design).latestRoundData().block.timestamp == priceTimestamp + stalenessThreshold — strict succeeds (condition uses >, not >=).setStalenessThreshold(0) — already disallowed by InvalidParameter (:142), but assert it still reverts.The docs page will include a short integrator guide with this in it:
If you are integrating ARU as a price feed:
- Use
latestRoundDataStrict()unless you have a specific reason to tolerate stale data.- If you use
latestRoundData()(e.g., because your code was written against generic Chainlink feeds), you MUST checkblock.timestamp - updatedAtagainst a threshold of your choice. The ARU oracle's ownstalenessThresholdis published on-chain as a recommended default — read it withoracle.stalenessThreshold().- The oracle emits
PriceUpdated(oldPrice, newPrice, roundId, timestamp)on every update. For push-based freshness tracking, subscribe to that event and alert on gaps.
Once stalenessThreshold is actually enforceable, operations should:
Relayer heartbeat. Configure the Redstone relayer to force a write at least every stalenessThreshold / 2, even when the underlying price hasn't moved. The existing setPrice allows same-or-near-same writes (rate-of-change guard returns 0 bps); only minUpdateInterval throttles how fast heartbeats can fire, which must be configured strictly less than stalenessThreshold / 2.
Current defaults from script/Deploy.s.sol:
minUpdateInterval: 3,600 s (1 h)stalenessThreshold: 90,000 s (25 h)stalenessThreshold / 2: 45,000 s (12.5 h) — heartbeat well above min interval. OK.Monitoring alert. Page oncall if priceAge() > stalenessThreshold * 0.5. Second alert (louder) at > stalenessThreshold * 0.9.
Incident playbook: price feed outage. If the relayer stops and latestRoundDataStrict() starts reverting for integrators, the admin can either (a) bring the relayer back, or (b) push a manual one-off price via the PRICE_UPDATER role as a break-glass measure (rate-of-change guard still applies — a large gap since last update may require temporarily raising maxPriceChangeBps via admin). The playbook should name exactly who can do each step.
setPrice enforce its own freshness?Considered and rejected. setPrice is the producer — it already has minUpdateInterval as its own throttle and can't be "stale" from its own perspective. The check belongs at the consumer edge.
stalenessThreshold is settable by ADMIN_ROLE with no timelock (:141-146). A compromised admin key could set it to type(uint256).max and quietly disable strict mode for everyone. Considered in the Threat Model — currently accepted risk; if it escalates, add a timelock on the setter.