Skip to content
Protocol

Oracle

A composite price recomputed every hour from three independent groups of sources, eight validation checks, three distinct prices, and no governance override anywhere.

Each branch's price is a composite, recomputed every hour and never read from a calendar. It is the median of three groups of sources, each addressed as an immutable set at construction (Rule R-3.2.2, Rule R-12.2.2):

  1. the time-weighted averages of qualified on-chain pools;
  2. signed perpetual and index feeds;
  3. twin-token markets, where the same exposure trades under another ticker.

No group may weigh more than half the total, so no single kind of source sets the price on its own, and losing a whole group narrows the protocol's view rather than handing it to whatever is left. There is no governance override and no manual price. A branch that has lost too many independent sources for seven days shuts down automatically, urgent redemptions open, and everyone exits at the last accepted price.

Alongside the composite sits a witness: one to six Uniswap pools with a native accumulator, fixed at construction. The witness narrows the uncertainty around the composite and reveals a fall; it never raises a valuation and never on its own unblocks anything. If every pool goes quiet, the branch keeps working at a lower confidence.

Three prices, never confused

A single generic getPrice() is the original sin of most real-world-asset integrations. The adapter returns three prices with three different jobs (Rule R-3.2, glossary):

FieldUsed forValue
pRefValuation, ICR, TCR, display, borrowing limitmin(pComposite, pDex clamped down by the band), then reduced by the haircut
pLiqLiquidation executionpRef when the fall is confirmed, pComposite otherwise. Nothing in between
pRedeemRedemption executionmax(pComposite, pDex clamped up by the band)

A fourth, lastGood, is frozen at shutdown and is the only price a shut branch ever uses. priceRef24, the previous day's accepted composite, survives only as the unit in which d2 depth is converted to dollars.

Guarantee

pComposite × (1 − B) ≤ pRef ≤ pComposite ≤ pRedeem ≤ pComposite × (1 + B) in every regime, with pLiq taking one of two values and equal to pRef exactly when the fall is confirmed, so pRef ≤ pLiq ≤ pComposite. Fuzzed as invariant I-2. The asymmetry is the whole design: an attacker who pushes the on-chain price up gets nothing, and one who pushes it down can only obstruct, at the cost of the manipulation, positions already within 4% of their threshold.

The band B is a constant, equal to 4% on tier 1 and 6% on tier 2. It does not widen with the duration of a fall, and it has no separate ceiling. The widening an earlier design carried was deleted (decision D78) for two reasons. A wide band had no value in a real crash: at a true −20%, a buyer paying 0.88 × 1.02 pays more than the market, so nobody buys, and the only party the extra room served was whoever had fabricated the fall. And the cost of manipulating a pool is paid per pool while the gain was collected per branch, which made the attack profitable from the second branch onward and more so with each branch added.

Never wrap, never scale

Guarantee

The protocol holds the raw stock token. It never wraps it, never deposits it in a yield-bearing vault, and never reads a rate from a contract a third party can move. Valuation is raw collateral × feed price, full stop (principle P4, Rule R-2.4.2).

Accounting never uses balanceOf. totalColl is the sum of deposits minus withdrawals and seizures, so a donation of tokens to the branch changes nothing (Rule R-2.4.1). This closes the class of attacks in which someone inflates a vault's apparent collateral, or manipulates an exchange rate that the protocol reads as a price.

The uiMultiplier() of the stock token — the ERC-8056 field that expresses a split or a reinvested dividend — never enters a valuation. The feed already publishes the price per raw token with the multiplier applied. The multiplier is used only for a consistency check and for display: "20 raw SPY, shown as 200 SPY after the split" (Rule R-2.4.2).

The validation chain

Checks run in order, first match wins. Any failure produces a state, never a revert on the view, and never a clamped price (Rule R-3.2.1 to R-3.2.10):

  1. Sequencer. If a sequencer uptime feed address is configured, a non-zero status or a start time under 30 minutes old gives SEQ_DOWN. If the address is zero, this check does not exist and neither does the cause: there is no ETH/USD substitute (Rule R-3.2.10, decision D81).
  2. Delisted. A shut branch reports DELISTED and lastGood forever.
  3. Issuer pause. oraclePaused() is read through a staticcall capped at 30 000 gas, fail-closed: a failed call counts as paused. True gives PAUSED and the pre-pause snapshot.
  4. Round sanity. answer <= 0, updatedAt == 0, answeredInRound < roundId, a regressing roundId, a zero aggregator(), or a reverting call gives CIRCUIT.
  5. Relative price window. An answer outside [lastAccepted.price / 20, lastAccepted.price × 20] gives CIRCUIT.
  6. Heartbeat. 26 hours without a round from a source group gives FROZEN, once too few independent groups remain. A shorter silence gives DEGRADED, not a fault.
  7. Deviation guard. A move larger than a time-based budget gives CIRCUIT.
  8. Multiplier consistency. An implied share price outside its bounds gives CIRCUIT.

decimals() is read once at construction and cached; normalisation to WAD happens once. If the feed's decimals were ever to change, the price would immediately leave the relative window, the branch would go to CIRCUIT, and after seven days it would shut down (Rule R-3.2.2).

Price bounds, never a clamp

The relative window is deliberately wide: a factor of twenty in either direction from the last accepted price. It is not there to constrain the market — a stock can legitimately double — but to catch an absurd value from a broken aggregator: zero, type(int256).max, a price in the wrong unit.

Guarantee

A price outside the sanity window produces CIRCUIT, never a clamped value. The pattern of clamping to a minimum or maximum answer — the mechanism behind the Venus and Compound losses on LUNA — is not implemented anywhere in Fyber (Rule R-3.2.3).

The window is initialised at construction by the first valid reading, which must itself lie within a factor of two of an expectedPrice passed to the constructor; otherwise the deployment reverts. The window is never revised afterwards. A trigger is an incident, not a market event.

Deviation guard

The guard allows a price move proportional to the time since the last accepted price, up to an absolute cap (Rule R-3.2.5):

How it is calculated

budget = min(maxJumpPerMin × (dt / 60 s) + 0.5%, maxJumpAbs) — a move exceeding budget relative to the last accepted price gives CIRCUIT.

Tier 1 uses 0.6% per minute with a 12% absolute cap; tier 2 uses 1.0% per minute with an 18% cap. When a composite arrives after a long silence, the absolute cap is doubled and the quietEdge flag is set, which stops the Stability Pool from executing for 10 minutes. The protocol accepts the price for valuation at a quiet edge and declines to liquidate on it.

Exit from CIRCUIT is automatic and only automatic, by either of two routes (Rule R-4.6.2): 3 consecutive rounds mutually consistent within the budget and at least 10 minutes elapsed, or 2 such rounds and at least 60 minutes since the first fresh one. The last consistent round becomes the new accepted price, and a quiet edge follows. There is no manual exit, because a manual exit would make the operator the oracle.

The slower route exists because the faster one had a bad failure mode. A source that only publishes on a 0.5% deviation may produce two rounds quickly after a gap and then nothing for hours. Waiting for a third round held the branch frozen exactly when redemptions and liquidations were most needed, while interest ran on toward its 24-hour stop (decision D92).

Age, and what it costs

Age never switches a branch on and off. It drops a source out of the qualified set, which can move the branch from LIVE24 to DEGRADED, and it raises the uncertainty; nothing else. Two thresholds matter (Rule R-4.5.1), both immutable and calibrated on 28 days of production source logs before deployment:

ThresholdValueEffect
τ_live, the age past which a source stops counting4 hoursfewer independent groups, so LIVE24 becomes DEGRADED
Missed heartbeat26 hoursthat source is broken rather than merely quiet

A deviation-triggered source that says nothing is asserting that the price has not moved. Treating that as a broken source would stop a branch for hours in a calm market.

So silence costs 0.5% of certainty at most from the deviation term, plus the square-root-of-time term. It never blocks anything on its own. Only the missed heartbeat does, and only once too few independent groups remain.

The on-chain witness

A pool qualifies as a price source for one poke() only if all of these hold (Rule R-3.2, report 07 §2.3):

  • its one-hour time-weighted average is readable, with a 30-minute fallback;
  • its depth to a 2% sale is at least $500,000 on tier 1;
  • it sits within 3% of the median of the other qualified pools;
  • if it quotes against WETH, the ETH/USD feed is less than 60 minutes old.

pDex is the depth-weighted median of the qualified averages. Full credit, which halves the uncertainty and is the most a witness can ever do, needs $3,000,000 of qualified depth on tier 1.

Converting a pool quoted in WETH

A token/WETH average is in ETH, and turning it into dollars used to mean multiplying by the latest ETH/USD round. The adapter multiplies instead by the time-weighted average of the ETH/USD rounds covering the same window, walking back with getRoundData while updatedAt is inside the window, at most 12 rounds over 60 minutes (Rule R-3.2.12, decision D86).

The reason is arithmetic. A one-hour average of the token in ETH multiplied by a spot ETH price mixes two different clocks. A 5% move in ETH over thirty minutes shifted the dollar value by 2 to 2.5% while the token itself had not moved at all, which started the persistence clock, pulled pRef down, and could disqualify the only qualified SPY pool for dispersion, immunising a position that was genuinely underwater. If the round history is unreadable, the conversion falls back to the last round alone: exact whenever ETH has not moved 0.5% (there is then no round inside the window), and wrong only when the history is missing and ETH moves, a case the dispersion figure records.

The basket confirms nothing

Each equity branch carries a list of the other equity branches. Their pDex values, weighted by depth, produce a composite that feeds dispBps and nothing else. It does not confirm a fall, it does not widen anything, and it does not enter pRef, pLiq or pRedeem (invariant I-18).

Confirmation by the basket was removed. The branches share the same pools: SPY/WETH and QQQ/USDG are read by both SPY and QQQ, so the basket was the same market read twice and not an independent source. Worse, it let an attacker pay the cost of manipulation per pool and collect confirmation for every equity branch at once. Only a dormant branch is excluded outright; a branch in FROZEN or shut down drops out too.

Guarantee

Confidence is monotone in the witness. Removing a pool, draining one, letting ETH/USD go stale, or putting a basket branch into FROZEN can only lower confidence and raise pLiq toward the composite. Losing a source is never a liquidation event.

Risk

The instantaneous price of a Uniswap v4 pool is manipulable inside a block and there is no oracle hook on this chain, so v4 pools count for depth and coherence and never for price. On SPY, most of the on-chain liquidity is exactly there — which means SPY carries a thinner witness, a lower confidence and a larger haircut than assets that are objectively more volatile. The protocol degrades honestly rather than pretending otherwise.

Multiplier consistency check

This is the only protection against a desynchronisation between the feed and the token's multiplier — the failure that would let a branch value collateral at ten times its worth after a split, or a tenth of it after a reverse split (Rule R-3.2.7, Rule R-11.1).

How it is calculated

impliedShare = price × 1e18 / uiMultiplier() — outside [minShare, maxShare], the state is CIRCUIT.

The bounds start at one third and three times the implied share at construction, and are automatically recalibrated by the ratio of the old to the new multiplier at each observed transition after a pause. The behaviour on a multiplier change:

  • A change of 1% or less updates the stored multiplier silently. This is a reinvested dividend.
  • A change above 1% observed after a pause is a normal split transition: the stored multiplier is updated and the bounds are recalibrated.
  • A change above 1% with no pause observed in the preceding 24 hours gives CIRCUIT and emits MultiplierAnomaly. Exit is the ordinary automatic exit — three consistent rounds and ten minutes — provided the implied share is back inside its bounds.

There is no whitelist of expected corporate actions, and no function to acknowledge one. The relative bounds absorb the event or they do not, and if they do not the branch stops acting until they do.

poke

function poke() external;   // permissionless

poke() refreshes the adapter's snapshots: the last accepted composite, the pre-pause snapshot, the last seen multiplier, the frozen-episode counters degradedSince and longestDegraded, the whole witness (each pool's average, the ETH/USD average that converts the WETH-quoted ones, the qualified depth, the dispersion against the basket, the persistence clock dropSince), and edgeUntil when the accepted composite followed a long silence or an exit from FROZEN (Rule R-3.2.9).

It is called at the head of every branch operation, of LiquidityOracle.poke(), and of CollateralSale.buy(). A third party may call it every fifteen minutes if it wishes, but nothing depends on it: the next user operation refreshes the same state, and the view functions recompute uncertainty, confidence, pRef and pLiq from the current timestamp, so confidence decays between two pokes without anybody being called. This is the general pattern — every maintenance function is permissionless and implicit in the operations that need it, so the protocol works with zero keepers.

One poke per branch per hour is enough. It costs roughly 150 000 to 250 000 gas, paid by whoever happens to operate next. The depth poke() on LiquidityOracle is a separate call and costs 150 000 to 300 000 of its own; the older figure of 250 000 to 400 000 counted the two together.

Invariants

The adapter is fuzzed against the following properties (Rule R-3.2.11, Rule R-16.3 item 12):

  • pComposite × (1 − B) ≤ pRef ≤ pComposite ≤ pRedeem ≤ pComposite × (1 + B), in every regime, with B the constant 4% on tier 1.
  • pLiq ∈ {pRef, pComposite}, and pLiq == pRef exactly when the fall is confirmed, so pRef ≤ pLiq ≤ pComposite.
  • bandBps == BAND_BASE under every call sequence: the band is never written.
  • regime == LIVE24 implies all three prices equal to pComposite, quiet edge included, and conf == 1.
  • A zero sequencerFeed implies the cause is never SEQ_DOWN, for any sequence, including 72 hours with no ETH/USD round (invariant I-15).
  • The basket composite moves dispBps and therefore confidence, and moves none of pRef, pLiq, pRedeem or the confirmation flag (invariant I-18).
  • Confidence is monotone: removing a pool, expiring ETH/USD, draining a pool or freezing a basket branch can only lower it and raise pLiq.
  • degradedSince is non-zero exactly when the regime is FROZEN.
  • pRef is unchanged by any sequence of deposits, borrows, liquidations, token donations, swaps or pokes — a swap followed by a poke in one transaction moves pLiq by at most what a 60-minute average allows, which at this chain's block time is nothing measurable.
  • No adapter function writes a constant.

Last reviewed: 2026-09-07 · Spec v0.4