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

# Oracles

> The IFxOracle interface, the shared freshness rules, and the keeper, Pyth and Chainlink adapters: what each trusts and which one is live.

Every FX rate on Windrose comes through `IFxOracle`: units of a currency per 1 USD in 1e18 fixed point, with a publish time and a freshness flag. The registry, the factory and the router use whichever adapter the registry points at; each vault keeps the adapter it was created with. Today that adapter is `KeeperFxOracle` on every chain.

Source: `contracts/src/interfaces/IFxOracle.sol`, `contracts/src/oracle/FxOracleBase.sol`, `KeeperFxOracle.sol`, `PythFxOracle.sol`, `ChainlinkFxOracle.sol`.

## Interface

```solidity theme={"system"}
interface IFxOracle {
    function rate(bytes32 code) external view returns (uint256 rate_, uint256 publishTime, bool fresh); // reverts StalePrice past the stale limit
    function peekRate(bytes32 code) external view returns (uint256 rate_, uint256 publishTime);        // no age check
    function update(bytes[] calldata priceUpdate) external payable;                                   // push price data; excess value refunded
    function updateFee(bytes[] calldata priceUpdate) external view returns (uint256);                  // native fee `update` needs
    function isSupported(bytes32 code) external view returns (bool);
}
```

`rate` is what vaults use for every mint, redeem, deposit and withdrawal. `peekRate` is only for uses that cannot be manipulated by an old price: the factory sizes a new curve with it, so launches can be created while the FX market is closed. `USD` always returns `1e18`, the current timestamp and `fresh = true`.

## Shared rules: `FxOracleBase`

```solidity theme={"system"}
uint256 public maxAgeFresh;   // age at or below which a price is fresh
uint256 public maxAgeStale;   // age above which rate() reverts StalePrice
function setLimits(uint256 maxAgeFresh_, uint256 maxAgeStale_) external onlyOwner; // f != 0 and s >= f, else BadLimits
```

Three windows apply to every adapter, with `age = block.timestamp - publishTime`:

| Window   | Condition                          | Vault behaviour                                                                                                   |
| -------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Fresh    | `age <= maxAgeFresh`               | normal fees; underwriter deposits and withdrawals allowed                                                         |
| Stale    | `maxAgeFresh < age <= maxAgeStale` | mint and redeem at `staleFeeBps`; deposits and withdrawals revert `NotFresh`                                      |
| Rejected | `age > maxAgeStale`                | `rate()` reverts `StalePrice(code, age)`: mint, redeem, deposit, withdraw, `status()` and the previews all revert |

The stale window exists because FX markets close from Friday evening to Sunday evening (New York time), so a weekend must not stop trading. Deployed limits: keeper oracle 2 hours fresh, 5 days stale; Pyth adapter 60 seconds fresh, 5 days stale.

Errors shared by every adapter: `UnsupportedCurrency(code)`, `StalePrice(code, age)`, `InvalidPrice(code)`, `RefundFailed()`, `BadLimits()`. The base `update` refunds any value and `updateFee` returns 0; push adapters inherit both.

## `KeeperFxOracle` (live on every chain)

```solidity theme={"system"}
struct Entry { uint128 rate; uint64 publishTime; bool enabled; }
mapping(bytes32 => Entry) public entries;
mapping(address => bool) public keepers;
uint256 public maxMoveBps;

function post(bytes32[] calldata codes, uint256[] calldata rates, uint256 publishTime) external; // keepers only
function forcePost(bytes32 code, uint256 rate_) external onlyOwner;
function setKeeper(address keeper, bool allowed) external onlyOwner;
function setCurrency(bytes32 code, bool enabled) external onlyOwner;   // UsdIsFixed for "USD"
function setMaxMoveBps(uint256 bps) external onlyOwner;                 // 0 disables the move check
function isSupported(bytes32 code) external view returns (bool);
```

**`post`** checks, in order: `NotKeeper`; `LengthMismatch`; `FuturePublishTime` when `publishTime > block.timestamp`; then per code: `UnsupportedCurrency` if not enabled, `InvalidPrice` if the rate is zero or above `uint128`, and `MoveTooLarge(code)` if a previous rate exists, `maxMoveBps` is non-zero and the change exceeds `maxMoveBps` of the previous rate. A code whose stored `publishTime` is newer than the posted one is skipped silently (rates never go backwards). Each accepted code emits `RatePosted(code, rate, publishTime)`.

**`forcePost`** lets the owner recover from a bad post: it bypasses the move limit, stamps `block.timestamp` and emits `RatePosted`.

Deployed with `maxAgeFresh = 2 hours`, `maxAgeStale = 5 days`, `maxMoveBps = 1500` (15% per post), `EUR` plus the sixty synthetic codes enabled, and the deployment's `KEEPER` address (the VPS hot key on Robinhood Chain) allow-listed. The keeper bot posts every 10 minutes and re-posts unchanged rates hourly so nothing crosses the fresh window; if it stops, minting, redeeming and underwriting degrade to the stale fee after 2 hours and stop after 5 days, while curve trading in the quote asset continues unaffected.

Events: `KeeperSet`, `CurrencySet(code, enabled)`, `RatePosted`, `MaxMoveSet`, `LimitsSet`. Errors: `NotKeeper`, `MoveTooLarge`, `UsdIsFixed`, `LengthMismatch`, `FuturePublishTime` plus the shared ones.

<Warning>
  The trust assumption is the keeper key and the reference-rate API behind it (`open.er-api.com`, with `frankfurter.app` as fallback: free daily reference rates, not tradeable quotes). Within the 15% band a compromised keeper can post wrong prices, and the owner can post any price with `forcePost`. The contract's own comment says it: never use this as the sole oracle for a mainnet vault holding real money. Today it is the sole oracle everywhere, because no decentralised FX feed covers most of the sixty currencies on the chains Windrose runs on. See [Security model](/protocol/integrate/security-model).
</Warning>

## `PythFxOracle` (deployed on Arc testnet, not in use)

```solidity theme={"system"}
struct Feed { bytes32 id; bool invert; bool enabled; }
IPyth public immutable pyth;
mapping(bytes32 => Feed) public feeds;
uint256 public maxConfBps;

function setFeed(bytes32 code, bytes32 id, bool invert, bool enabled) external onlyOwner;
function setMaxConfBps(uint256 bps) external onlyOwner;
function update(bytes[] calldata priceUpdate) external payable;               // forwards pyth.getUpdateFee to updatePriceFeeds; empty payload is a no-op
function updateFee(bytes[] calldata priceUpdate) external view returns (uint256); // 0 for an empty payload
```

A pull adapter: callers fetch Hermes update data for the feed id and attach it as `priceUpdate`; the fee is paid in native value (18-decimal USDC on Arc) and any excess is refunded. `_read` uses `getPriceUnsafe`, rejects non-positive prices, zero publish times and a confidence interval wider than `maxConfBps` of the price (`InvalidPrice`), normalises `price * 10^expo` to 1e18 (`ExponentOutOfRange` beyond 40 digits) and inverts (`1e36 / v`) for feeds quoted as USD per XXX. Deployed on Arc testnet at the address in [Addresses](/protocol/addresses) with 60 s fresh, 5 days stale, `maxConfBps = 100`, and the 30 feeds in `contracts/script/PythFeeds.sol` (AUD, EUR, GBP and NZD inverted). Pyth is not on Arc mainnet or Robinhood Chain, and Hermes FX data needs a paid key since August 2026.

Errors specific to it: `InsufficientFee(required, provided)`, `UsdIsFixed`, `ExponentOutOfRange`.

## `ChainlinkFxOracle` (implemented, not deployed)

```solidity theme={"system"}
struct Feed { AggregatorV3Interface aggregator; uint8 decimals; bool invert; bool enabled; }
function setFeed(bytes32 code, AggregatorV3Interface aggregator, bool invert, bool enabled) external onlyOwner;
```

A push adapter reading `latestRoundData` (free): rejects non-positive answers and zero `updatedAt`, scales by the feed's decimals and inverts when the feed is XXX/USD (the usual case). The deploy script does not deploy it. Arc mainnet has seven free FX feeds (EUR, JPY, CAD, AUD, MXN, BRL, KRW); Robinhood Chain has none.

## How the rest of the protocol uses the oracle

| Reader                           | Call                                          | Purpose                                                                       |
| -------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------- |
| `LaunchFactory.createLaunch`     | `registry.oracle().update` + `peekRate(code)` | size `virtualQuote` at creation; no age check                                 |
| `Router` dollar paths            | `registry.oracle().updateFee(priceUpdate)`    | how much value to forward to the vault                                        |
| `FxVault` (own immutable oracle) | `update` + `rate(code)`                       | every mint, redeem, deposit, withdrawal, preview and `status()`               |
| Keeper bot                       | `entries`, `keepers`, `post`                  | posts and heartbeats                                                          |
| Indexer                          | `RatePosted`                                  | `rate` history and `currency.lastRate`, which price every launch's `priceUsd` |

Switching the registry to another adapter changes the first two rows only; vaults keep reading the keeper oracle. The router computes the fee from the registry's oracle but the vault forwards it to its own, so a mismatch between the two would make `buyWithUSDC` / `sellForUSDC` revert with the vault's `InsufficientFee` or leave the fee unrefunded. Keep the registry oracle and the vault oracle the same contract, or leave the registry on the keeper oracle.

`contracts/test/Oracles.t.sol` covers Pyth normalisation and inversion, the fresh-to-stale transitions, the confidence check, fee refunds and rejection, the empty-update no-op, the fixed USD rate, Chainlink inversion and the keeper's rules (`test_keeper_rules`).
