Skip to content
Bias
DocsORACLE.md8 min read
On this page · 6

Oracle notes (Session 2)

Market hours and DST#

OracleRouter.isOpen() computes the ET offset on-chain from the US DST rule (2nd Sunday of March 02:00 EST to 1st Sunday of November 02:00 EDT). No timelock action is needed at DST switches. The rule is verified in test_dstBounds2026And2027:

YearDST starts (UTC)tsDST ends (UTC)ts
20262026-03-08 07:00Z17729532002026-11-01 06:00Z1793512800
20272027-03-14 07:00Z18050076002027-11-07 06:00Z1825567200

If Congress changes the rule (Sunshine Protection Act etc.), the owner calls setEtOffset(secs), which disables autoDst and pins a manual offset; setAutoDst(true) re-enables the rule.

Hours: 09:30–16:00 ET, or 09:30–13:00 ET on early-close days. Weekends closed. Holidays and early closes are per-day flags keyed by ET day index (unix days of the local date). script/SetMarketHours.s.sol sets 2026 and 2027 from the NYSE calendar; verify against nyse.com before running, and re-run each December for the next year.

2026 holidayDateDay index2027 holidayDateDay index
New Year's Day2026-01-0120454New Year's Day2027-01-0120819
MLK Day2026-01-1920472MLK Day2027-01-1820836
Presidents' Day2026-02-1620500Presidents' Day2027-02-1520864
Good Friday2026-04-0320546Good Friday2027-03-2620903
Memorial Day2026-05-2520598Memorial Day2027-05-3120969
Juneteenth2026-06-1920623Juneteenth (obs.)2027-06-1820987
Independence (obs.)2026-07-0320637Independence (obs.)2027-07-0521004
Labor Day2026-09-0720703Labor Day2027-09-0621067
Thanksgiving2026-11-2620783Thanksgiving2027-11-2521147
Christmas2026-12-2520812Christmas (obs.)2027-12-2421176
early close2026-11-2720784early close2027-11-2621148
early close2026-12-2420811

Market hours gate increases only. Decreases are always priced (see policy below).

Staleness / divergence policy#

BUILD.md Session 7 requires: stale feed must revert increases and allow decreases; keeper-offline must leave emergencyDecrease usable. Implemented in OracleRouter:

Conditionincreasedecreasemark (liquidation)
fresh, converged, openpriced ± spreadpriced ± spreadmid
market closed (CHAINLINK)MarketClosedpriced (feed idle => usually "stale" path)mid / StalePrice
stale (feed / keeper / TWAP)StalePricepriced ± staleSpreadBps (wider)StalePrice
TWAP vs keeper > divergenceBpsDivergencepriced at min/max of both ± spreadDivergence
no price at allNoPriceNoPriceNoPrice

Known risk, decide before mainnet: allowing decreases at a stale (last-close) equity price lets a trader close on overnight information the pool does not have yet (gap risk). Gains and Ostium disallow closes outside hours for this reason. staleSpreadBps is the only mitigation here; the alternative is "close only at next open", which the spec rejects ("never pause decreases").

Chainlink stock feeds on Robinhood Chain: addresses not yet known. Keep feed empty until found. Note that equity feeds only update during market hours, so staleness for them is effectively "max age during the session" (90s in the deploy example) and every after-hours decrease takes the stale path by design.

Chainlink ETH/USD on Robinhood Chain (0x78F3556b67E17Df817D51Ef5a990cDaF09E8d3A9, 8 dec) has a 24h heartbeat. UniV4TwapSource takes an ethUsdStaleness (use ≥ 26h); do not put this feed behind a 90s staleness.

TWAP-class markets: UniV4TwapSource#

The DEX on Robinhood Chain is Uniswap v4 (PoolManager 0x8366a39CC670B4001A1121B8F6A443A643e40951). Pons graduation pools have key (currency0 = native ETH, currency1 = token, fee 0, tickSpacing 200, hooks = MemeHook 0xE5e702641Ea86F4ae6cC3cDaeD2B886f976Be044). There is no v3 on this chain, and v4 pools have no oracle unless their hook records observations. MemeHook is Pons's, not ours. So UniV4TwapSource keeps its own accumulator:

  • record() (permissionless since 2026-09-20, so whoever lists a coin can warm their own market up) reads the pool tick via StateLibrary.getSlot0 and appends (ts, tick, tickCumulative) to a ring buffer (cardinality slots; 1440 at 60s cadence = 24h). MarketFactory.list writes the first observation itself. What bounds a caller is MIN_RECORD_SPACING (15s) and the fact that the tick comes from the pool, never from the caller; see RISK.md 8.6a for the manipulation model this changed.
  • twap(window) returns the time-weighted mean tick over [now - window, now] from recorded ticks only; the live spot never enters the average (test_liveSpotNeverEntersTwap). Price = 1.0001^meanTick converted to ETH per token, times Chainlink ETH/USD, in 1e30.
  • fresh == false when the newest record is older than maxObservationAge or ETH/USD is past its staleness; price == 0 when history does not reach back window seconds.

Manipulation bound: one manipulated observation weighs cadence / window. test_manipulationWeightBoundedByCadence shows a 10,000-tick spike (price ÷ 2.7) held for one 60s record inside a 30-minute window moves the TWAP 3.3%. With divergenceBps = 300 against the keeper-signed median, that already trips the breaker for increases. Superseded 2026-09-20: record() is permissionless, so an attacker can pump, record and unwind in one transaction and no longer has to hold the price across an unpredictable record time. What is left is the 15s spacing (240 observations per hour, each one a paid pool round trip), the fact that the observation comes from the pool rather than the caller, and spotGuardBps, which stops the market entirely once a walked TWAP is more than 5% from live spot. RISK.md 8.6a has the full model. Keeper cadence jitter (±20%) no longer buys anything against a deliberate attacker, only against accidental synchronisation.

Suggested launch config for a TWAP market: twapWindow 1800, staleness 60 (keeper posts every 5s), spreadBps 30, staleSpreadBps 100, divergenceBps 300, source maxObservationAge 300, cardinality 1440.

Verified on a fork of mainnet (test/ForkOracle.t.sol, token "DeFi" 0xca322Ed94B3d44B9d25F26BE21cec4AdABe255e6): spot ≈ $0.0000026, TWAP after two records within 1bp of spot, router prices it end-to-end.

forge test --match-contract ForkOracle --fork-url https://rpc.mainnet.chain.robinhood.com -vv

Arbitrum-stack notes#

  • block.number on Robinhood Chain is the parent chain's block number (advances ~12s, shared by many L2 blocks). PositionRouter now uses ArbSys(0x64).arbBlockNumber() when the precompile exists (it does: code 0xfe), so minBlockDelay = 1 means one L2 block, and the keeper's provider.getBlockNumber() matches.
  • block.timestamp is sequencer-set; small drift is fine for funding and cooldowns.

Session 5 additions (signers, spot guard, sequencer, feeds)#

Keeper price signers#

  • Each signer owns one of the three slots per market. A signer posting again overwrites only its own slot, so one key can never fill the median by itself (test_singleSignerOwnsOneSlot).
  • Timestamps must strictly increase per signer: an older or identical signed price cannot be replayed (test_signatureReplayRejected). Before this, anyone could re-post any signature from the last 120s.
  • minSigners (launch: 2) distinct signers must be fresh for the price to count as fresh. With fewer, the median of the remaining current signers is returned as stale: decreases only, at the stale spread.
  • A removed signer's slot stops counting immediately, and a new signer takes it over. removeKeeper is available to the guardian (instant); adding signers is owner-only (timelocked).

Spot guard (TWAP class)#

setSpotGuard(id, bps) (launch: 500). If the live pool spot (ISpotSource.spot(), implemented by UniV4TwapSource) is more than bps from the TWAP, the market is flagged diverged: increases and liquidation marks revert. Spot is also folded into the decrease price range, which defeats "pump, let go, close on the lagging TWAP": the close is priced at the collapsed spot. Spot never enters the mark value.

Sequencer uptime#

setSequencerFeed(feed, grace). While the feed reports down, or within grace seconds of recovery, all prices are stale (increases and marks revert, decreases use the stale spread). Robinhood Chain has no Chainlink sequencer uptime feed as of 2026-09-14, so this stays unset.

Directory: https://reference-data-directory.vercel.app/feeds-robinhood-mainnet.json. Re-check with node keeper/fetch-feeds.js (writes keeper/feeds.json).

MarketProxydescription()Observed update gaps
ETH0x78F3556b67E17Df817D51Ef5a990cDaF09E8d3A9ETH / USD30s – 71 min
BTC0xa2c5184bF03d373Dc9dE4876eb4Bce595B460251BTC / USD36 min – 3.8h
SPY0x319724394D3A0e3669269846abE664Cd621f9f6ARHSPY / USD6h – 24h, ~59h over a weekend
QQQ0x80901d846d5D7B030F26B480776EE3b29374C2aeRobinhood QQQ / USD6h – 24h, ~59h over a weekend
NVDA0x379EC4f7C378F34a1B47E4F3cbeBCbAC3E8E9F15RHNVDA / USD2.5 min – 4.5h
AAPL0x6B22A786bAa607d76728168703a39Ea9C99f2cD0Robinhood AAPL / USD6 min – 4h
USDG0x61B7e5650328764B076A108EFF5fa7282a1B9aD2USDG / USD24h heartbeat

No feed for gold (GLD/XAU) or AMC; SLV and GME exist. The "RH" names suggest these price Robinhood's tokenized stocks. The directory lists all of them at 0.5% deviation, so spreads are 30 bps each way (PARAMS.md). The public RPC occasionally answers call bursts with a Cloudflare HTML page; keepers treat that as retryable.

Session 6 additions (2026-09-16): coin markets#

Quotes#

UniV4TwapSource takes the quote's decimals and USD feed: native ETH and WETH (18 decimals, Chainlink ETH/USD with 26h staleness) and USDG (6 decimals, no feed: exactly $1, the vault's own unit, so a USDG pool's ratio already is the settlement price and no feed staleness is added). Prices keep 36 extra digits, so sub-cent tokens in 6-decimal pools do not truncate. Both orderings (token as currency0 or currency1) are tested; on the mainnet fork PONS/USDG (PONS = currency0) and FRONG/ETH price within 1e-12 of an independent slot0 computation (test/ForkListing.t.sol).

Window#

Coin markets use a 1h TWAP (twapWindow = 3600). The observation at the window start is found by binary search over the ring, so a 1h window costs the same as a short one (fuzzed against the old linear walk). One manipulated 60s record weighs 1/60 of the average; a leaked recorder key at the 15s spacing floor gets 1/240 per record.

Price used by each path (coin markets)#

PathPrice
Open long / close shortmax(TWAP, keeper median, spot) x (1 + spread)
Open short / close longmin(TWAP, keeper median, spot) x (1 - spread)
emergencyDecreaseas a close; stale inputs use staleSpreadBps
PerpVault.settleas a close of that side
Liquidation markmid of TWAP and keeper median, no spot, no spread; reverts when stale or diverged

Verified in test/PricePaths.t.sol.

Keeper-optional markets#

oracle.keeperOptional(id) (set by MarketFactory for auto-listed coins, owner-settable): without a fresh signer median the market prices from the TWAP (plus spot for opens and closes) and counts as fresh; a stale or missing median is ignored. A fresh median is used exactly as on other TWAP markets. RiskManager caps such a market at the Lite tier while its median is not fresh (RISK.md 8.6). Pricers can cover auto-listed markets: their id is the pool id.

Source: hoodperps-contracts/ORACLE.md, synced at build. Title in repo: Oracle notes (Session 2).