Skip to main content
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

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

Three windows apply to every adapter, with age = block.timestamp - publishTime: 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)

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

PythFxOracle (deployed on Arc testnet, not in use)

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 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)

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

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