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):
- the time-weighted averages of qualified on-chain pools;
- signed perpetual and index feeds;
- 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):
| Field | Used for | Value |
|---|---|---|
pRef | Valuation, ICR, TCR, display, borrowing limit | min(pComposite, pDex clamped down by the band), then reduced by the haircut |
pLiq | Liquidation execution | pRef when the fall is confirmed, pComposite otherwise. Nothing in between |
pRedeem | Redemption execution | max(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):
- 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). - Delisted. A shut branch reports
DELISTEDandlastGoodforever. - Issuer pause.
oraclePaused()is read through astaticcallcapped at 30 000 gas, fail-closed: a failed call counts as paused. True givesPAUSEDand the pre-pause snapshot. - Round sanity.
answer <= 0,updatedAt == 0,answeredInRound < roundId, a regressingroundId, a zeroaggregator(), or a reverting call givesCIRCUIT. - Relative price window. An answer outside
[lastAccepted.price / 20, lastAccepted.price × 20]givesCIRCUIT. - Heartbeat. 26 hours without a round from a source group gives
FROZEN, once too few independent groups remain. A shorter silence givesDEGRADED, not a fault. - Deviation guard. A move larger than a time-based budget gives
CIRCUIT. - 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:
| Threshold | Value | Effect |
|---|---|---|
τ_live, the age past which a source stops counting | 4 hours | fewer independent groups, so LIVE24 becomes DEGRADED |
| Missed heartbeat | 26 hours | that 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
CIRCUITand emitsMultiplierAnomaly. 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; // permissionlesspoke() 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, withBthe constant 4% on tier 1.pLiq ∈ {pRef, pComposite}, andpLiq == pRefexactly when the fall is confirmed, sopRef ≤ pLiq ≤ pComposite.bandBps == BAND_BASEunder every call sequence: the band is never written.regime == LIVE24implies all three prices equal topComposite, quiet edge included, andconf == 1.- A zero
sequencerFeedimplies the cause is neverSEQ_DOWN, for any sequence, including 72 hours with no ETH/USD round (invariant I-15). - The basket composite moves
dispBpsand therefore confidence, and moves none ofpRef,pLiq,pRedeemor 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. degradedSinceis non-zero exactly when the regime isFROZEN.pRefis unchanged by any sequence of deposits, borrows, liquidations, token donations, swaps or pokes — a swap followed by a poke in one transaction movespLiqby 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
Price regimes
Three regimes describe how well the protocol sees the price. How confidence is computed, what every rule does with it, and what still counts as having no price at all.
Liquidations
Partial by default, confirmed before execution, throttled by measured liquidity, and paid for by a bonus that moves with conditions.