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:
| Year | DST starts (UTC) | ts | DST ends (UTC) | ts |
|---|---|---|---|---|
| 2026 | 2026-03-08 07:00Z | 1772953200 | 2026-11-01 06:00Z | 1793512800 |
| 2027 | 2027-03-14 07:00Z | 1805007600 | 2027-11-07 06:00Z | 1825567200 |
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 holiday | Date | Day index | 2027 holiday | Date | Day index |
|---|---|---|---|---|---|
| New Year's Day | 2026-01-01 | 20454 | New Year's Day | 2027-01-01 | 20819 |
| MLK Day | 2026-01-19 | 20472 | MLK Day | 2027-01-18 | 20836 |
| Presidents' Day | 2026-02-16 | 20500 | Presidents' Day | 2027-02-15 | 20864 |
| Good Friday | 2026-04-03 | 20546 | Good Friday | 2027-03-26 | 20903 |
| Memorial Day | 2026-05-25 | 20598 | Memorial Day | 2027-05-31 | 20969 |
| Juneteenth | 2026-06-19 | 20623 | Juneteenth (obs.) | 2027-06-18 | 20987 |
| Independence (obs.) | 2026-07-03 | 20637 | Independence (obs.) | 2027-07-05 | 21004 |
| Labor Day | 2026-09-07 | 20703 | Labor Day | 2027-09-06 | 21067 |
| Thanksgiving | 2026-11-26 | 20783 | Thanksgiving | 2027-11-25 | 21147 |
| Christmas | 2026-12-25 | 20812 | Christmas (obs.) | 2027-12-24 | 21176 |
| early close | 2026-11-27 | 20784 | early close | 2027-11-26 | 21148 |
| early close | 2026-12-24 | 20811 |
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:
| Condition | increase | decrease | mark (liquidation) |
|---|---|---|---|
| fresh, converged, open | priced ± spread | priced ± spread | mid |
| market closed (CHAINLINK) | MarketClosed | priced (feed idle => usually "stale" path) | mid / StalePrice |
| stale (feed / keeper / TWAP) | StalePrice | priced ± staleSpreadBps (wider) | StalePrice |
| TWAP vs keeper > divergenceBps | Divergence | priced at min/max of both ± spread | Divergence |
| no price at all | NoPrice | NoPrice | NoPrice |
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 viaStateLibrary.getSlot0and appends(ts, tick, tickCumulative)to a ring buffer (cardinalityslots; 1440 at 60s cadence = 24h).MarketFactory.listwrites the first observation itself. What bounds a caller isMIN_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^meanTickconverted to ETH per token, times Chainlink ETH/USD, in 1e30.fresh == falsewhen the newest record is older thanmaxObservationAgeor ETH/USD is past its staleness;price == 0when history does not reach backwindowseconds.
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.numberon Robinhood Chain is the parent chain's block number (advances ~12s, shared by many L2 blocks).PositionRouternow usesArbSys(0x64).arbBlockNumber()when the precompile exists (it does: code0xfe), sominBlockDelay = 1means one L2 block, and the keeper'sprovider.getBlockNumber()matches.block.timestampis 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.
removeKeeperis 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.
Chainlink feeds on Robinhood Chain (verified 2026-09-14)#
Directory: https://reference-data-directory.vercel.app/feeds-robinhood-mainnet.json. Re-check with
node keeper/fetch-feeds.js (writes keeper/feeds.json).
| Market | Proxy | description() | Observed update gaps |
|---|---|---|---|
| ETH | 0x78F3556b67E17Df817D51Ef5a990cDaF09E8d3A9 | ETH / USD | 30s – 71 min |
| BTC | 0xa2c5184bF03d373Dc9dE4876eb4Bce595B460251 | BTC / USD | 36 min – 3.8h |
| SPY | 0x319724394D3A0e3669269846abE664Cd621f9f6A | RHSPY / USD | 6h – 24h, ~59h over a weekend |
| QQQ | 0x80901d846d5D7B030F26B480776EE3b29374C2ae | Robinhood QQQ / USD | 6h – 24h, ~59h over a weekend |
| NVDA | 0x379EC4f7C378F34a1B47E4F3cbeBCbAC3E8E9F15 | RHNVDA / USD | 2.5 min – 4.5h |
| AAPL | 0x6B22A786bAa607d76728168703a39Ea9C99f2cD0 | Robinhood AAPL / USD | 6 min – 4h |
| USDG | 0x61B7e5650328764B076A108EFF5fa7282a1B9aD2 | USDG / USD | 24h 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)#
| Path | Price |
|---|---|
| Open long / close short | max(TWAP, keeper median, spot) x (1 + spread) |
| Open short / close long | min(TWAP, keeper median, spot) x (1 - spread) |
emergencyDecrease | as a close; stale inputs use staleSpreadBps |
PerpVault.settle | as a close of that side |
| Liquidation mark | mid 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.