Docs/Engineering/Oracle Design
accepted

Oracle Design

ARUPriceOracle design — Chainlink-compatible AggregatorV3 interface, rate-of-change guard, strict variant for downstream integrators that want hard-fail on stale price.

Oracle Staleness — Strict Variant + Dead-State Cleanup

Severity: Low (integration hazard) · Target: ARUPriceOracle.sol

Problem

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.

Decision

Keep latestRoundData() Chainlink-compatible, add a strict variant, and make stalenessThreshold load-bearing.

Rejected alternatives:

  • Delete 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.
  • Make latestRoundData() revert on stale. Breaks Chainlink interface contract; downstream aggregators that batch-read many feeds would blow up on a single stale feed.

Fix

1. Add 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);
}

2. New error + no changes to existing functions

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.

3. Extend ABI in src/lib/contracts.ts

Add 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" },

Tests to add

  1. Advance past priceTimestamp + stalenessThreshold without updating — latestRoundDataStrict() reverts PriceStale; latestRoundData() still returns the stale value (by design).
  2. Set a fresh price, call both — strict returns identical tuple to latestRoundData().
  3. Edge case: block.timestamp == priceTimestamp + stalenessThreshold — strict succeeds (condition uses >, not >=).
  4. setStalenessThreshold(0) — already disallowed by InvalidParameter (:142), but assert it still reverts.

Integrator guidance (publish alongside the fix)

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 check block.timestamp - updatedAt against a threshold of your choice. The ARU oracle's own stalenessThreshold is published on-chain as a recommended default — read it with oracle.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.

Operational process

Once stalenessThreshold is actually enforceable, operations should:

  1. 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.
  2. Monitoring alert. Page oncall if priceAge() > stalenessThreshold * 0.5. Second alert (louder) at > stalenessThreshold * 0.9.

  3. 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.

Why not make 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.

Known limit

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.