> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nilemarkets.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Oracle Safeguards

> Onchain safeguards on every publishRound: 10s minimum spacing, 2% max single-update move, 0.5% deviation vs prior, 1.5% anchor-deviation versus lastPublishedSpot × IRP carry, cache-mode refresh during FX closure (bounded by MAX_SPOT_CACHE_AGE = 96h), and bootstrap-window relaxation on first publish per pair

Oracle prices are the most critical external input to the Open Nile Protocol. Every position opening, PnL calculation, liquidation, and settlement depends on accurate forward prices. A manipulated or erroneous price could enable unfair trades, trigger improper liquidations, or drain the liquidity pool. The protocol enforces multiple layers of onchain safeguards to validate every price update before it is accepted.

<Warning>
  Oracle safeguards protect against **publisher errors and manipulation**, not against fundamental market moves. If the underlying EUR/USD rate genuinely moves by a large amount, the publisher may need the oracle admin to temporarily adjust safeguard parameters to accommodate the real price movement.
</Warning>

## Safeguard Checks

Every call to `publishRound()` first parses the Pyth update bytes onchain via `IPyth.parsePriceFeedUpdates`, stores the verified spot as `lastPublishedSpot[pairId]`, then runs four sequential validation checks against each per-tenor forward in the same transaction. If any check fails, the entire update is rejected and the previous prices remain in effect.

### 1. Spacing Check

```
block.timestamp - lastPublishTime >= minForwardUpdateSpacing
```

Ensures a minimum time interval between consecutive forward price updates. This prevents rapid-fire updates that could overwhelm the system or be used in a sandwich attack.

| Parameter                 | Default    | Role                |
| ------------------------- | ---------- | ------------------- |
| `minForwardUpdateSpacing` | 10 seconds | `ORACLE_ADMIN_ROLE` |

<Info>
  The spacing check uses `block.timestamp`, which means the actual minimum interval depends on block times. On Sepolia (12-second blocks), the 10-second default effectively allows updates on every block.
</Info>

### 2. Move Limit Check

```
|newPrice - lastPrice| * 10,000 <= lastPrice * maxOracleMovePerUpdateBps
```

Caps the maximum price change permitted in a single update. This catches erroneous prices from publisher bugs, API failures, or deliberate manipulation attempts.

| Parameter                   | Default  | Role                |
| --------------------------- | -------- | ------------------- |
| `maxOracleMovePerUpdateBps` | 200 (2%) | `ORACLE_ADMIN_ROLE` |

<Accordion title="Why 2% per update?">
  EUR/USD is one of the most liquid currency pairs in the world. Under normal market conditions, it rarely moves more than 0.5% in a single day. A 2% per-update cap provides ample headroom for legitimate intraday volatility while catching grossly erroneous prices. During extreme events (central bank surprises, geopolitical shocks), the oracle admin may need to temporarily raise this limit.
</Accordion>

### 3. Deviation vs Prior Check

```
|newPrice - lastAcceptedPrice| * 10,000 <= lastAcceptedPrice * maxDeviationVsPriorBps
```

Limits the total drift from the last accepted price. Unlike the move limit (which checks per-update jumps), this check measures cumulative drift, preventing a series of small updates from gradually walking the price to an unreasonable level.

| Parameter                | Default   | Role                |
| ------------------------ | --------- | ------------------- |
| `maxDeviationVsPriorBps` | 50 (0.5%) | `ORACLE_ADMIN_ROLE` |

<Note>
  The deviation vs prior check uses the **last accepted price** as its reference, not the last published price. If a price update is rejected by safeguards, the reference does not change. This means the deviation window is anchored to the most recent successfully published price.
</Note>

### 4. Anchor Deviation Check

```
expectedForward = lastPublishedSpot × (1 + pairForwardRateBps × t / SECONDS_PER_YEAR / 10_000)
|newPrice - expectedForward| × 10_000 <= expectedForward × maxAnchorDeviationBps
```

Compares the new forward price against the IRP-expected forward derived from the **publisher-pushed, Pyth-verified spot anchor** and the per-pair carry rate. The bound is sized for spot volatility (1.5% by default) and applies uniformly across every tenor — carry is absorbed by `pairForwardRateBps`, not by widening the bound.

| Parameter               | Default                      | Role                |
| ----------------------- | ---------------------------- | ------------------- |
| `maxAnchorDeviationBps` | 150 (1.5%)                   | `ORACLE_ADMIN_ROLE` |
| `pairForwardRateBps`    | 250 (EUR/USD), 400 (USD/JPY) | `ORACLE_ADMIN_ROLE` |

<Info>
  The spot anchor is refreshed **before** the per-tenor safeguard runs inside `publishRound`, so the very first publish on a pair is also gated by this check (no bootstrap bypass). The anchor is exclusively maintained by `publishRound` from Pyth-signed bytes — no setter can write it directly.
</Info>

### 5. Cache-Mode Refreshes (Weekend Trading)

```
publishRound(pairId, [], fixingTimestamps, prices, roundIds)  // empty pythUpdateData, msg.value == 0
```

When `publishRound` is called with an empty `pythUpdateData` array (and `msg.value == 0`), the contract reuses the previously-stored `lastPublishedSpot` as the anchor instead of parsing fresh Pyth bytes. Each forward's `publishTimestamp` is refreshed to the current block, keeping `getForward.isValid` true through FX market closure (Fri 5pm ET → Sun 5pm ET, plus holidays) when Pyth upstream attestations are paused. The per-tenor safeguards (`maxAnchorDeviationBps`, move-limit, spacing) still run against the cached anchor.

Cache mode is bounded by `MAX_SPOT_CACHE_AGE` (96 hours). Calls past that bound revert with `SpotCacheStale` — multi-day Pyth outages still pause trading rather than allowing trades against a multi-day-old anchor.

| Constant             | Value    | Role                  |
| -------------------- | -------- | --------------------- |
| `MAX_SPOT_CACHE_AGE` | 96 hours | hard-coded, immutable |

<Info>
  Cache mode emits `SpotCacheFallbackUsed(pairId, cachedSpot, cachedSpotTimestamp, blockTimestamp)` on every call. The subgraph indexes this event as `SpotCacheFallbackEvent`; off-chain monitors should alert when this fires outside expected FX closure windows (signals a transient Pyth outage worth investigating).
</Info>

### 6. Bootstrap-Window Relaxation

```
if lastPublishedSpotTimestamp[pairId] == 0:
    minPublishTime = max(0, now - MAX_SPOT_CACHE_AGE)  // 96h instead of maxOracleAge (30s)
```

`_verifyAndStoreSpot` widens its accepted Pyth publish-time window to `MAX_SPOT_CACHE_AGE` (96h) on the first publish per pair (`lastPublishedSpotTimestamp == 0`). This lets a fresh deploy seed the spot anchor from the most recent historical Pyth attestation even when upstream attestations are paused (e.g. deploying on a Saturday). Subsequent calls re-engage the strict `maxOracleAge` window (30s default).

The same relaxation applies after `clearAllForwards` or `unregisterPair` clears the cached spot — the next publish bootstraps fresh.

## Staleness Checks

Beyond the safeguards on price updates, the protocol also enforces **staleness checks** on price reads. A price that has not been refreshed within its allowed age is considered stale and invalid.

<Columns cols={2}>
  <Card title="Forward Staleness" icon="clock">
    ```
    block.timestamp - publishTimestamp > maxForwardAge
    ```

    **Default**: 60 seconds

    If the forward price has not been updated within `maxForwardAge`, `isValid` returns `false`. Operations requiring a valid forward price (position opens, PnL calculations, early terminations) will revert with `OracleInvalid`.
  </Card>

  <Card title="Spot Staleness" icon="clock-rotate-left">
    ```
    block.timestamp - cachedPublishTime > maxOracleAge
    ```

    **Default**: 30 seconds

    If the cached spot price has not been refreshed within `maxOracleAge`, spot-dependent operations will revert. Spot is pull-based from Pyth, so it is typically refreshed on demand.
  </Card>
</Columns>

<Warning>
  Stale prices directly impact protocol availability. If the publisher service goes down and stops refreshing forward prices, the protocol will automatically detect staleness and operations will halt. The `checkOracleAndPause()` function can automatically escalate the protocol to PAUSED mode when staleness is detected.
</Warning>

## Oracle Validity

The `isOracleValid(pairId)` function provides a comprehensive validity check used by the ModeController for automatic mode escalation:

<Steps>
  <Step title="Pair Enabled Check">
    Verifies the currency pair is registered and enabled in the oracle module. Disabled pairs are considered invalid.
  </Step>

  <Step title="Forward Published Check">
    Verifies that at least one forward price has been published (`lastForwardPublishTime > 0`). Before the first publish, the oracle is invalid.
  </Step>

  <Step title="Freshness Check">
    Verifies the forward price is not stale: `block.timestamp - lastForwardPublishTime <= maxForwardAge`. This is the most common failure mode -- if the publisher stops updating, staleness is detected within 60 seconds.
  </Step>
</Steps>

## Safeguard Parameters Summary

| Parameter                   | Default  | Unit       | Description                                     |
| --------------------------- | -------- | ---------- | ----------------------------------------------- |
| `minForwardUpdateSpacing`   | 10       | seconds    | Min time between forward updates                |
| `maxOracleMovePerUpdateBps` | 200      | bps (2%)   | Max price change per single update              |
| `maxDeviationVsPriorBps`    | 50       | bps (0.5%) | Max drift from last accepted price              |
| `maxAnchorDeviationBps`     | 150      | bps (1.5%) | Max drift from `lastPublishedSpot × IRP carry`  |
| `pairForwardRateBps`        | per pair | bps        | Per-pair carry rate used to scale spot to tenor |
| `maxForwardAge`             | 60       | seconds    | Forward price staleness threshold               |
| `maxOracleAge`              | 30       | seconds    | Spot price staleness threshold                  |

All parameters are configured by the `ORACLE_ADMIN_ROLE` (except `maxOracleAge`, which is set by the contract owner) and take effect immediately upon update.

## Baseline Reset

When matured forward rounds are cleared via `clearMaturedForwards(pairId)`, the safeguard baselines are **reset**. This means:

* The spacing timer resets, allowing an immediate publish after clearing
* The move limit reference price resets to the latest accepted price
* The deviation tracking restarts from a clean state

<Tip>
  Baseline resets are important for operational continuity. After a batch of forwards mature and are cleared, the publisher starts a fresh publish cycle without being constrained by stale references from the previous cycle.
</Tip>

## Defense in Depth

The oracle safeguards work in concert with other protocol protections to provide defense in depth:

<AccordionGroup>
  <Accordion title="Safeguards + Mode Escalation">
    If all safeguard checks pass but the published price is still suspicious, an operator with `DEFAULT_ADMIN_ROLE` can manually escalate to DEGRADED mode via `ModeController.setDegraded(reasonCode)`. The protocol then blocks new positions; if it remains in DEGRADED beyond `degradedDurationSeconds`, `checkDegradedTimeout()` auto-escalates to PAUSED. See [Mode Escalation](/protocol/mode-escalation) for details.
  </Accordion>

  <Accordion title="Safeguards + Margin Requirements">
    Even if a slightly inaccurate price passes the safeguard checks, the margin system provides a buffer. Positions require 2% initial margin and are liquidated at 1% maintenance margin, so a small price error would need to exceed the margin buffer to cause harm.
  </Accordion>

  <Accordion title="Safeguards + Exposure Caps">
    Exposure caps limit the total notional at risk. Even if a manipulated price were accepted, the maximum loss to the pool is bounded by the stress-adjusted net exposure cap. See [Pool Exposure Caps](/protocol/pool-exposure-caps) for the formula.
  </Accordion>

  <Accordion title="Safeguards + Snapshotted Entry Prices">
    Each position's entry price is immutably set at open time. A manipulated current price cannot retroactively change the entry price of existing positions. It can only affect new position openings and current PnL valuations.
  </Accordion>
</AccordionGroup>

## Publisher Architecture

The publisher service that submits forward prices operates as an authorized off-chain agent:

| Property         | Value                                                               |
| ---------------- | ------------------------------------------------------------------- |
| Access           | `PUBLISHER_ROLE` (access-controlled)                                |
| Price source     | Pyth Hermes API for spot, interest-rate parity formula for forwards |
| Update frequency | Every `maxForwardAge / 2` (default: 30 seconds)                     |
| Retry strategy   | 3 attempts with exponential backoff                                 |
| Batch support    | `publishRound()` for all enabled tenors in a single transaction     |

<Note>
  In the M2 deployment, the publisher is operated by the protocol team. It is the only entity with `PUBLISHER_ROLE`. The safeguard checks provide protection even against bugs in the publisher software itself -- an erroneous price calculation will be rejected by the onchain checks before it can affect any positions.
</Note>
