Skip to content

Add price oracle spec#14

Draft
jribbink wants to merge 15 commits intomainfrom
jribbink/oracle-spec
Draft

Add price oracle spec#14
jribbink wants to merge 15 commits intomainfrom
jribbink/oracle-spec

Conversation

@jribbink
Copy link
Copy Markdown

No description provided.

Copy link
Copy Markdown
Contributor

@janezpodhostnik janezpodhostnik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Part one. Didn't get far yet.
Looks nice and detailed!

Comment thread docs/price-oracle.md Outdated

### Problem

FCM must value collateral, assess position solvency, and price liquidations. Every such decision depends on a price for each supported token, denominated in the USD Numeraire. Reading a price from a single on-chain source is unsafe — a source can be stale, manipulated, frozen, or compromised, and operating on a bad price produces incorrect valuations, missed liquidations, or wrongful seizures. The protocol therefore needs a *price-of-truth* abstraction that (a) is robust to single-source failure and (b) gives every caller an unambiguous "I can't answer reliably right now" signal.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we limit the spec specifically for the USD Numeraire and not just any specified numeraire?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you're right, intent was to make it clear that USD Numeraire was the convention for FYV/ALP, but spec should be agnostic. Will tighten wording 👍

Comment thread docs/price-oracle.md Outdated
The spec takes the following as given. If any is violated, the conclusions below do not hold.

- **Independent sources exist.** For each supported token in the mature protocol, there exist ≥ 2 independent price sources — uncorrelated in their failure and manipulation modes. Without this, multi-source aggregation buys no safety over a single feed, and the N5 / spread-check layer degenerates.
- **Sources attest `publishTime` truthfully.** A source reports the wall-clock instant at which its value was observed, up to bounded skew. If sources lie about time, staleness checks (N3) are defeated.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random question/thought: could we prescribe that a price report must be signed by a time stamp authority. (on a related note: the flow blockchain can serve as a time stamp authority with a thin wrapper)

Comment thread docs/price-oracle.md Outdated

- **Independent sources exist.** For each supported token in the mature protocol, there exist ≥ 2 independent price sources — uncorrelated in their failure and manipulation modes. Without this, multi-source aggregation buys no safety over a single feed, and the N5 / spread-check layer degenerates.
- **Sources attest `publishTime` truthfully.** A source reports the wall-clock instant at which its value was observed, up to bounded skew. If sources lie about time, staleness checks (N3) are defeated.
- **Majority-honest sources (Byzantine bound).** Across N sources, fewer than ⌈N/2⌉ are simultaneously compromised or stale. Required for median aggregation to be robust and for the spread check to be a useful signal.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two statements:

  • "...there exist ≥ 2 independent..."
  • "fewer than ⌈N/2⌉ are simultaneously compromised"

don't seem to go together... if there is 2 nodes then fewer then 1 nodes must be compromised (so 0), but the first guarantee is given for "manipulation modes". Does that not also cover byzantine nodes?

Copy link
Copy Markdown
Member

@AlexHentschel AlexHentschel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice work. Still reviewing. First batch of comments

Comment thread docs/price-oracle.md Outdated

### Problem

FCM must value collateral, assess position solvency, and price liquidations. Every such decision depends on a price for each supported token, denominated in the USD Numeraire. Reading a price from a single on-chain source is unsafe — a source can be stale, manipulated, frozen, or compromised, and operating on a bad price produces incorrect valuations, missed liquidations, or wrongful seizures. The protocol therefore needs a *price-of-truth* abstraction that (a) is robust to single-source failure and (b) gives every caller an unambiguous "I can't answer reliably right now" signal.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is "price liquidations"? I think maybe this entire sentence is a bit too brief:

FCM must value collateral, assess position solvency, and price liquidations.

Suggested change
FCM must value collateral, assess position solvency, and price liquidations. Every such decision depends on a price for each supported token, denominated in the USD Numeraire. Reading a price from a single on-chain source is unsafe — a source can be stale, manipulated, frozen, or compromised, and operating on a bad price produces incorrect valuations, missed liquidations, or wrongful seizures. The protocol therefore needs a *price-of-truth* abstraction that (a) is robust to single-source failure and (b) gives every caller an unambiguous "I can't answer reliably right now" signal.
FCM must protect collateral, maintain solvency despite large asset shifts, and execute liquidations. Every decision towards those goals depends on a price for each supported token, denominated in the Numeraire. At the time of writing, we use USD as numeraire [ref Tim's spec once merged]. Relying on a single source for an asset price is generally unsafe — a source can be stale, manipulated, frozen, or compromised, and operating on a bad price produces incorrect valuations, missed liquidations, or wrongful seizures. The protocol therefore needs a *price-of-truth* abstraction that (a) is robust to single-source failure and (b) may return an "I can't answer reliably right now" or otherwise a trustworthy price signal.

Lets make sure we are precise:

  • numeraire is the general concept
  • the choice that USD is our numeraire is a convention that might change

It's always a good practise (also for AIs) to create references between pieces of knowledge

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5d4249d.

Comment thread docs/price-oracle.md Outdated

A minimal `PriceOracle` interface that:

1. Exposes one honest read path: either a reliable price denominated in the oracle's declared unit of account (for FCM, the USD Numeraire), or `nil`.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's already differentiate between a

  • fixed requirement: "We require that an oracle declares is unit of account in which it reports all its prices; this must be constant throughout the lifetime of the oracle"
  • We have the soft convention, that most Oracles should use the FCM system's numeraire as their unit of account. However, it is the caller's responsibility to verify at least once before the oracle is used for the first time that the oracle is returning prices in a compatible unit of account.

Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated

### Lifetime

The interface and the requirements on consumers (Caller Contract) are intended to survive unchanged to the mature protocol. Implementation choices (number of sources, staleness bound, presence of a circuit-breaker wrapper, exact aggregation function) will evolve. Divergences for the initial deployment are in Initial Deployment vs. Mature.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Divergences for the initial deployment are in Initial Deployment vs. Mature.

sorry, have a hard time making sense of this sentence.

Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated
**`PriceReading` is the only way to observe a price.** `value` and `publishTime` are bundled in one atomic return so callers cannot observe one without the other. The interface MUST NOT offer a separate `publishTimeOf(token: Type)` getter — a two-call pattern reintroduces a race between the reads and defeats I7.

- `value` — number of `unitOfAccount()` units per one unit of the requested token, at `publishTime`.
- `publishTime` — source-attested observation instant (I7). For aggregators, the oldest contributing source's publishTime (min-semantics); for breakers, the publishTime of the last accepted observation, unchanged.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `publishTime` — source-attested observation instant (I7). For aggregators, the oldest contributing source's publishTime (min-semantics); for breakers, the publishTime of the last accepted observation, unchanged.
- `publishTime` — source-attested observation time (I7). For aggregators, the oldest contributing source's publishTime (min-semantics); for breakers, the publishTime of the last accepted observation, unchanged.

Comment thread docs/price-oracle.md Outdated
**`PriceReading` is the only way to observe a price.** `value` and `publishTime` are bundled in one atomic return so callers cannot observe one without the other. The interface MUST NOT offer a separate `publishTimeOf(token: Type)` getter — a two-call pattern reintroduces a race between the reads and defeats I7.

- `value` — number of `unitOfAccount()` units per one unit of the requested token, at `publishTime`.
- `publishTime` — source-attested observation instant (I7). For aggregators, the oldest contributing source's publishTime (min-semantics); for breakers, the publishTime of the last accepted observation, unchanged.
Copy link
Copy Markdown
Member

@AlexHentschel AlexHentschel Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution I:

the oldest contributing source's publishTime (min-semantics);

My gut feeling is that most people will be surprised by this convention.

CAUTION II: recent research is pretty consistent that any sort of temporal averaging over prices adds risks to the protocol. (Volatility is different).

`Technical Details (human curated and polished overview)`

Mechanics and Vulnerabilities of Time-Weighted Average Price Oracles (April 15, 2026)

In the decentralized finance (DeFi) ecosystem, smart contracts require price oracles to access external market data. However, relying on instantaneous "spot" prices from Automated Market Makers (AMMs) is highly vulnerable to manipulation [Angeris & Chitra 2020; Adams et al. 2020]. An attacker can use mechanisms like flash loans to artificially inflate an asset's spot price within a single transaction, exploit a lending protocol based on that false price, and immediately reverse the trade [Adams et al. 2020; Mackinga et al. 2022]. To mitigate this, the Time-Weighted Average Price (TWAP) was introduced as a core oracle primitive, notably pioneered by Uniswap V2 [Adams et al. 2020].

Mechanics of TWAP Oracles
A TWAP oracle works by measuring and recording the price of an asset at the beginning of each block, before any trades can occur within that block [Mackinga et al. 2022; Adams et al. 2020]. It maintains a running accumulator that adds the current price multiplied by the number of seconds since the block was last updated [Adams et al. 2020]. By checkpointing this accumulator at a start time ($t_1$) and an end time ($t_2$), external protocols can compute the average price over that specific interval [Adams et al. 2020].

This design introduces a fundamental trade-off in oracle design between security and freshness: calculating a TWAP over a longer time period makes it significantly more expensive for an attacker to manipulate, but it results in a less up-to-date price that lags behind real-time market movements [Mackinga et al. 2022; Adams et al. 2020].

Evolution: Arithmetic vs. Geometric Mean
Oracle design evolved significantly between Uniswap V2 and V3, largely transitioning from arithmetic to geometric means.

  • Arithmetic Mean (Uniswap V2): V2 utilizes an arithmetic mean, which requires tracking two separate accumulators for a trading pair (e.g., A/B and B/A). This is necessary because the arithmetic mean price of asset A in terms of B is not the reciprocal of the arithmetic mean price of B in terms of A [Adams et al. 2020; Adams et al. 2021].
  • Geometric Mean (Uniswap V3): V3 upgraded the oracle to compute a geometric mean by tracking the cumulative sum of the logarithm of prices [Adams et al. 2021]. This provides several benefits. First, it allows for a single accumulator, as the geometric mean of a set of ratios is the reciprocal of the geometric mean of their inverses [Adams et al. 2021]. Second, the geometric mean is theoretically a truer representation of average price behavior (which models geometric Brownian motion), whereas the arithmetic mean tends to overweight higher prices [Adams et al. 2021]. Finally, Uniswap V3 automatically checkpoints these values internally (up to 65,536 times), freeing external users from the gas-intensive burden of manually tracking previous accumulator values [Adams et al. 2021].

Vulnerabilities and Attack Vectors
Despite their robust design, TWAP oracles are susceptible to sophisticated exploits. It was traditionally assumed that manipulating a TWAP required an expensive multi-block attack, where an attacker sustains a manipulated price over an entire TWAP window, with costs scaling linearly with the window's length and the pool's liquidity [Mackinga et al. 2022]. However, research highlights two severe vulnerabilities that bypass these assumptions:

  • The Single-Block Attack: Against an arithmetic mean TWAP, an attacker can manipulate the price to an extreme degree in just one block. If the shift is large enough, executing a massive one-block manipulation costs less than sustaining a smaller manipulation over multiple blocks, meaning the attack cost scales only with the square root of the TWAP length [Mackinga et al. 2022].
  • Multi-Block MEV (MMEV): If an attacker colludes with a miner or uses selfish mining to control two consecutive blocks, they can execute a manipulate-and-exploit transaction in the first block and a de-manipulate transaction in the second [Mackinga et al. 2022]. This eliminates the risk of arbitrageurs front-running the correction, dropping the capital cost of manipulation to merely the AMM trading fees and the cost of renting hash power [Mackinga et al. 2022]. This makes oracle manipulation orders of magnitude cheaper than previously assumed [Mackinga et al. 2022].

Alternative Defenses and Trade-offs
In the broader context of oracle design, developers debate several alternatives and safeguards to address these vulnerabilities:

  • Geometric Mean Defense: The shift to a geometric mean in Uniswap V3 effectively neutralizes the single-block attack. Because the geometric mean dampens extreme outliers, an attacker would have to manipulate the price exponentially higher in a single block to achieve the same average shift, making the single-block attack far more expensive than standard multi-block manipulation [Mackinga et al. 2022].
  • Median Prices: Using a median price instead of an average would completely ignore single-block outliers, neutralizing the single-block attack. However, computing a median on-chain is highly gas-intensive [Mackinga et al. 2022]. Furthermore, a median is actually more vulnerable to multi-block attacks, as an attacker only needs to manipulate half the blocks in a window rather than all of them [Mackinga et al. 2022].
  • Time Delays: Some protocols introduce artificial delays (e.g., using an oracle price from 1 hour ago) to buffer against immediate manipulation. However, empirical analysis shows that during periods of high market volatility, this delay can artificially inject price deviation because it fails to reflect true market conditions, sometimes making the protocol more vulnerable to undercollateralization attacks than if no delay were used [Deng et al. 2024].

References

  • [Angeris & Chitra 2020] Angeris, Guillermo; Chitra, Tarun. Improved Price Oracles: Constant Function Market Makers (2020). arXiv:2003.10001. Public link
  • [Adams et al. 2020] Adams, Hayden; Zinsmeister, Noah; Robinson, Dan. Uniswap v2 Core (2020). Uniswap Labs, March 2020. Public link
  • [Mackinga et al. 2022] Mackinga, Torgin; Nadahalli, Tejaswi; Wattenhofer, Roger. TWAP Oracle Attacks: Easier Done than Said? (2022). IEEE International Conference on Blockchain and Cryptocurrency (ICBC) 2022; IACR ePrint 2022/445. Public link
  • [Adams et al. 2021] Adams, Hayden; Zinsmeister, Noah; Salem, Moody; Keefer, River; Robinson, Dan. Uniswap v3 Core (2021). Uniswap Labs, March 2021. Public link
  • [Deng et al. 2024] Deng, Xun; Beillahi, Sidi Mohamed; Minwalla, Cyrus; Du, Han; Veneris, Andreas; Long, Fan. Safeguarding DeFi Smart Contracts against Oracle Deviations (2024). Proceedings of the IEEE/ACM 46th International Conference on Software Engineering (ICSE 2024); arXiv:2401.06044. Public link

Comment thread docs/price-oracle.md Outdated
Comment on lines +74 to +76
### Unit of account

A non-nil `price(ofToken: T)` is the number of UoA units per one unit of `T`, at the oracle's current time. For FCM, UoA is always the USD Numeraire (`Type<WorldCurrencies.USD>()`). UoA is a type, not a string — the compiler rejects cross-unit mismatches at registration (C3), not at query time.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UoA

Universal guideline: all abbreviations should be introduced on first usage in each document (once). This is simply good style helping the ease of reading and removing ambiguity.

Suggested change
### Unit of account
A non-nil `price(ofToken: T)` is the number of UoA units per one unit of `T`, at the oracle's current time. For FCM, UoA is always the USD Numeraire (`Type<WorldCurrencies.USD>()`). UoA is a type, not a stringthe compiler rejects cross-unit mismatches at registration (C3), not at query time.
### Unit of account [UoA]
A non-nil `price(ofToken: T)` is the value denominated in the UoA of a single token of type `T`, at the oracle's current time. For FCM, UoA is always the Numeraire (currently real-world USD represented by `Type<WorldCurrencies.USD>()`). UoA is a type, not a string, because we want the compiler to reject cross-unit mismatches at registration (see C3 below), not at query time.

Precision

  • Numeraire is the generally persistent concept
  • USD as the choice of numeraire is a convention subject to chane

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5d4249d.

Comment thread docs/price-oracle.md Outdated

A non-nil `price(ofToken: T)` is the number of UoA units per one unit of `T`, at the oracle's current time. For FCM, UoA is always the USD Numeraire (`Type<WorldCurrencies.USD>()`). UoA is a type, not a string — the compiler rejects cross-unit mismatches at registration (C3), not at query time.

If an implementation aggregates multiple underlying oracles, all MUST share the same `unitOfAccount()`. Verified on every `price()` call — a UoA mismatch returns `nil` via N4. Construction-time verification alone is insufficient because a source's storage-path target can be replaced after construction.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Construction-time verification alone is insufficient because a source's storage-path target can be replaced after construction.

Does the same hold true for Oracles? Or only for sources. Dete really desires that we only check once and not on every call. He imagines something like a setter, and only during the setter, we check UoA ... but maybe that's your point here, that that convention is only sufficient for oracles ... just don't quite understand why the difference.

Comment thread docs/price-oracle.md Outdated
- **(II) Query idempotence.** `unitOfAccount()` is `view` (compile-time pure). `price()` is not `view` — side effects are permitted — but MUST NOT alter the value of a subsequent `price()` call beyond what a fresh query against live source data would already produce. Repeated `price(ofToken: T)` within the same block MUST return the same result.
- **(III) Reliability of non-nil.** Every non-nil `price(ofToken: T)` at time `t` satisfies every Nil Contract condition at `t`.
- **(IV) No silent substitution.** Non-nil values are freshly computed from sources whose attested publish time is within the staleness bound. Never a default, a cached-last-known-good past bound, or a zero.
- **(V) Source publish-time propagation.** Source-attested `publishTime` values are propagated through aggregation and history without being relabeled with pull time, insertion time, or `block.timestamp`. Type-enforced by `PriceReading.publishTime` being a `let` field set at construction; downstream layers MUST forward it (single-source) or take the `min` across contributing source publishTimes (aggregator).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this: a very clear and mathematically strict condition.

Unfortunately, I am worried that this will conflict with people's default assumptions: the time stamp being from the latest datum included in the aggregate. But let's see, maybe it's not a problem ... that would be nice. Don't change this just because I am concerned, because if we can make min work that would be really strong.

Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated

Two methods. The interface is part of this spec; implementations MUST NOT add methods returning a price without the full safety contract.

- `unitOfAccount()` — constant over the struct's lifetime (invariant I). `view`. Single source of truth for the oracle's unit of account; used for the registration handshake (C3).
Copy link
Copy Markdown
Member

@turbolent turbolent Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AlexHentschel Some level of Cadence knowledge will need to be assumed when reading the code, I think it's fair to assume what view means and not re-explain it in every spec. See https://cadence-lang.org/docs/language/functions#view-functions

Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated
- **C1 — Treat `nil` as a hard stop.** Every decision that depends on the price MUST abort or defer. No substituting a default, stale cache, or last-known-good.
- **C2 — Respect the unit of account.** Returned `UFix64` is in `unitOfAccount()` units (i.e., USD Numeraire). Different-unit conversions are the caller's responsibility.
- **C3 — Verify UoA at registration.** At wire-up, assert: `assert(oracle.unitOfAccount() == Type<WorldCurrencies.USD>(), message: "UoA mismatch")`. Consumers MAY re-check per query for defense-in-depth; aggregators internally do (Semantics → Unit of account).
- **C4 — Assume non-determinism across blocks.** `price()` may return different values across blocks. No reliance on monotonicity or bounded rate-of-change. Same-block repeats DO return the same value (invariant II), so caching within a single transaction is safe; caching across blocks is not. If cross-block stability is needed, wrap the oracle (Extension: Volatility Circuit Breaker).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **C4 — Assume non-determinism across blocks.** `price()` may return different values across blocks. No reliance on monotonicity or bounded rate-of-change. Same-block repeats DO return the same value (invariant II), so caching within a single transaction is safe; caching across blocks is not. If cross-block stability is needed, wrap the oracle (Extension: Volatility Circuit Breaker).
- **C4 — Assume non-determinism across blocks.** `price()` may return different values across blocks. No reliance on monotonicity or bounded rate-of-change. Same-block repeats DO return the same value (invariant II), so caching within a single transaction is safe; caching across blocks is not. If bounded rate-of-change is needed, wrap the oracle (Extension: Volatility Circuit Breaker).

Context: as mention in my earlier comment, keeping prices constant or delayed across blocks generally exposes the protocols' risk exposure according to latest research. So lets not suggest that something like this would be possible or desirable. We should specifically talk about limiting the rate of change, for which we still report a non-nil value.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5d4249d.

Comment thread docs/price-oracle.md Outdated
- **C2 — Respect the unit of account.** Returned `UFix64` is in `unitOfAccount()` units (i.e., USD Numeraire). Different-unit conversions are the caller's responsibility.
- **C3 — Verify UoA at registration.** At wire-up, assert: `assert(oracle.unitOfAccount() == Type<WorldCurrencies.USD>(), message: "UoA mismatch")`. Consumers MAY re-check per query for defense-in-depth; aggregators internally do (Semantics → Unit of account).
- **C4 — Assume non-determinism across blocks.** `price()` may return different values across blocks. No reliance on monotonicity or bounded rate-of-change. Same-block repeats DO return the same value (invariant II), so caching within a single transaction is safe; caching across blocks is not. If cross-block stability is needed, wrap the oracle (Extension: Volatility Circuit Breaker).
- **C5 — Read once per operation.** "Operation" = one logical decision (a single position's liquidation check, a single NAV snapshot). Read `price()` once at the start of the operation and use the bound value throughout. Don't re-read inside loops. For operations that span multiple tokens (basket NAV), read each token's oracle once and treat any nil as all-nil (C1 hard stop applied to the whole basket).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Please introduce abbreviation NAV

Comment thread docs/price-oracle.md Outdated
- **C2 — Respect the unit of account.** Returned `UFix64` is in `unitOfAccount()` units (i.e., USD Numeraire). Different-unit conversions are the caller's responsibility.
- **C3 — Verify UoA at registration.** At wire-up, assert: `assert(oracle.unitOfAccount() == Type<WorldCurrencies.USD>(), message: "UoA mismatch")`. Consumers MAY re-check per query for defense-in-depth; aggregators internally do (Semantics → Unit of account).
- **C4 — Assume non-determinism across blocks.** `price()` may return different values across blocks. No reliance on monotonicity or bounded rate-of-change. Same-block repeats DO return the same value (invariant II), so caching within a single transaction is safe; caching across blocks is not. If cross-block stability is needed, wrap the oracle (Extension: Volatility Circuit Breaker).
- **C5 — Read once per operation.** "Operation" = one logical decision (a single position's liquidation check, a single NAV snapshot). Read `price()` once at the start of the operation and use the bound value throughout. Don't re-read inside loops. For operations that span multiple tokens (basket NAV), read each token's oracle once and treat any nil as all-nil (C1 hard stop applied to the whole basket).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- **C5 — Read once per operation.** "Operation" = one logical decision (a single position's liquidation check, a single NAV snapshot). Read `price()` once at the start of the operation and use the bound value throughout. Don't re-read inside loops. For operations that span multiple tokens (basket NAV), read each token's oracle once and treat any nil as all-nil (C1 hard stop applied to the whole basket).
- **C5 — Read once per operation.** "Operation" = one logical decision (a single position's liquidation check, a single NAV snapshot). Read `price()` once at the start of the operation and use the value throughout. Don't re-read inside loops. For operations that span multiple tokens (basket NAV), read each token's oracle once and treat any nil as all-nil (C1 hard stop applied to the whole basket).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5d4249d.

Comment thread docs/price-oracle.md
### Aggregation function

Open question (Open Questions). Candidates:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* The **Geometric Mean** would be a viable candidate to consider here, because the geometric mean dampens extreme outliers compared to the arithmetic mean. (For this reason Uniswap V3 uses the geometric mean too, but in a different context).
Summary of recent research result

Mechanics and Vulnerabilities of Time-Weighted Average Price Oracles (April 15, 2026)

In the decentralized finance (DeFi) ecosystem, smart contracts require price oracles to access external market data. However, relying on instantaneous "spot" prices from Automated Market Makers (AMMs) is highly vulnerable to manipulation [Angeris & Chitra 2020; Adams et al. 2020]. An attacker can use mechanisms like flash loans to artificially inflate an asset's spot price within a single transaction, exploit a lending protocol based on that false price, and immediately reverse the trade [Adams et al. 2020; Mackinga et al. 2022]. To mitigate this, the Time-Weighted Average Price (TWAP) was introduced as a core oracle primitive, notably pioneered by Uniswap V2 [Adams et al. 2020].

Mechanics of TWAP Oracles
A TWAP oracle works by measuring and recording the price of an asset at the beginning of each block, before any trades can occur within that block [Mackinga et al. 2022; Adams et al. 2020]. It maintains a running accumulator that adds the current price multiplied by the number of seconds since the block was last updated [Adams et al. 2020]. By checkpointing this accumulator at a start time ($t_1$) and an end time ($t_2$), external protocols can compute the average price over that specific interval [Adams et al. 2020].

This design introduces a fundamental trade-off in oracle design between security and freshness: calculating a TWAP over a longer time period makes it significantly more expensive for an attacker to manipulate, but it results in a less up-to-date price that lags behind real-time market movements [Mackinga et al. 2022; Adams et al. 2020].

Evolution: Arithmetic vs. Geometric Mean
Oracle design evolved significantly between Uniswap V2 and V3, largely transitioning from arithmetic to geometric means.

  • Arithmetic Mean (Uniswap V2): V2 utilizes an arithmetic mean, which requires tracking two separate accumulators for a trading pair (e.g., A/B and B/A). This is necessary because the arithmetic mean price of asset A in terms of B is not the reciprocal of the arithmetic mean price of B in terms of A [Adams et al. 2020; Adams et al. 2021].
  • Geometric Mean (Uniswap V3): V3 upgraded the oracle to compute a geometric mean by tracking the cumulative sum of the logarithm of prices [Adams et al. 2021]. This provides several benefits. First, it allows for a single accumulator, as the geometric mean of a set of ratios is the reciprocal of the geometric mean of their inverses [Adams et al. 2021]. Second, the geometric mean is theoretically a truer representation of average price behavior (which models geometric Brownian motion), whereas the arithmetic mean tends to overweight higher prices [Adams et al. 2021]. Finally, Uniswap V3 automatically checkpoints these values internally (up to 65,536 times), freeing external users from the gas-intensive burden of manually tracking previous accumulator values [Adams et al. 2021].

Vulnerabilities and Attack Vectors
Despite their robust design, TWAP oracles are susceptible to sophisticated exploits. It was traditionally assumed that manipulating a TWAP required an expensive multi-block attack, where an attacker sustains a manipulated price over an entire TWAP window, with costs scaling linearly with the window's length and the pool's liquidity [Mackinga et al. 2022]. However, research highlights two severe vulnerabilities that bypass these assumptions:

  • The Single-Block Attack: Against an arithmetic mean TWAP, an attacker can manipulate the price to an extreme degree in just one block. If the shift is large enough, executing a massive one-block manipulation costs less than sustaining a smaller manipulation over multiple blocks, meaning the attack cost scales only with the square root of the TWAP length [Mackinga et al. 2022].
  • Multi-Block MEV (MMEV): If an attacker colludes with a miner or uses selfish mining to control two consecutive blocks, they can execute a manipulate-and-exploit transaction in the first block and a de-manipulate transaction in the second [Mackinga et al. 2022]. This eliminates the risk of arbitrageurs front-running the correction, dropping the capital cost of manipulation to merely the AMM trading fees and the cost of renting hash power [Mackinga et al. 2022]. This makes oracle manipulation orders of magnitude cheaper than previously assumed [Mackinga et al. 2022].

Alternative Defenses and Trade-offs
In the broader context of oracle design, developers debate several alternatives and safeguards to address these vulnerabilities:

  • Geometric Mean Defense: The shift to a geometric mean in Uniswap V3 effectively neutralizes the single-block attack. Because the geometric mean dampens extreme outliers, an attacker would have to manipulate the price exponentially higher in a single block to achieve the same average shift, making the single-block attack far more expensive than standard multi-block manipulation [Mackinga et al. 2022].
  • Median Prices: Using a median price instead of an average would completely ignore single-block outliers, neutralizing the single-block attack. However, computing a median on-chain is highly gas-intensive [Mackinga et al. 2022]. Furthermore, a median is actually more vulnerable to multi-block attacks, as an attacker only needs to manipulate half the blocks in a window rather than all of them [Mackinga et al. 2022].
  • Time Delays: Some protocols introduce artificial delays (e.g., using an oracle price from 1 hour ago) to buffer against immediate manipulation. However, empirical analysis shows that during periods of high market volatility, this delay can artificially inject price deviation because it fails to reflect true market conditions, sometimes making the protocol more vulnerable to undercollateralization attacks than if no delay were used [Deng et al. 2024].

References

  • [Angeris & Chitra 2020] Angeris, Guillermo; Chitra, Tarun. Improved Price Oracles: Constant Function Market Makers (2020). arXiv:2003.10001. Public link
  • [Adams et al. 2020] Adams, Hayden; Zinsmeister, Noah; Robinson, Dan. Uniswap v2 Core (2020). Uniswap Labs, March 2020. Public link
  • [Mackinga et al. 2022] Mackinga, Torgin; Nadahalli, Tejaswi; Wattenhofer, Roger. TWAP Oracle Attacks: Easier Done than Said? (2022). IEEE International Conference on Blockchain and Cryptocurrency (ICBC) 2022; IACR ePrint 2022/445. Public link
  • [Adams et al. 2021] Adams, Hayden; Zinsmeister, Noah; Salem, Moody; Keefer, River; Robinson, Dan. Uniswap v3 Core (2021). Uniswap Labs, March 2021. Public link
  • [Deng et al. 2024] Deng, Xun; Beillahi, Sidi Mohamed; Minwalla, Cyrus; Du, Han; Veneris, Andreas; Long, Fan. Safeguarding DeFi Smart Contracts against Oracle Deviations (2024). Proceedings of the IEEE/ACM 46th International Conference on Software Engineering (ICSE 2024); arXiv:2401.06044. Public link

Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated
Comment thread docs/price-oracle.md Outdated

The breaker sits between the aggregator and the protocol's consumers. Its **input** is the aggregator's output stream `{(pₖ, τₖ)}`. Its **output** — the trip-gated, cached subset accepted into `history.last` — is the sole price of record that consumers (ALP, FYV) read for liquidation decisions.

`σ̂²` is computed on the raw input stream (every observation, regardless of trip outcome) so the breaker can detect anomalies in what the aggregator is producing before those anomalies become the protocol's price.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please introduce all quantities and symbols your spec uses to reduce ambiguity.

Suggested change
`σ̂²` is computed on the raw input stream (every observation, regardless of trip outcome) so the breaker can detect anomalies in what the aggregator is producing before those anomalies become the protocol's price.
The variance $\hat{\sigma}^2$ is computed on the raw input stream (every observation, regardless of trip outcome) so the breaker can detect anomalies in what the aggregator is producing before those anomalies become the protocol's price.

Comment thread docs/price-oracle.md Outdated

The breaker sits between the aggregator and the protocol's consumers. Its **input** is the aggregator's output stream `{(pₖ, τₖ)}`. Its **output** — the trip-gated, cached subset accepted into `history.last` — is the sole price of record that consumers (ALP, FYV) read for liquidation decisions.

`σ̂²` is computed on the raw input stream (every observation, regardless of trip outcome) so the breaker can detect anomalies in what the aggregator is producing before those anomalies become the protocol's price.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ computing the statistical variance is expensive on-chain if done naively.

  • We should use West's Weighted incremental algorithm for computing the variance.
  • My gut feeling is that we could also use the Exponentially Weighted Moving standard deviation with variable sampling interval. Essentially the Exponentially Weighted Moving Average (EWMA) of the squared deviation from the mean. But that requires an exponential, which we could implement natively in cadence but I think don't have atm.
Exponentially Weighted Moving standard deviation

1. Time-Decay Factor ($\alpha_i$)
The smoothing weight is adjusted for the time elapsed ($\Delta t_i$) and the half-life ($\tau$):
$\alpha_i = 1 - \exp\left( -\frac{\Delta t_i \cdot \ln(2)}{\tau} \right)$

The parameter $\alpha$ relates to the averaging time window. For example, consider a signal $x$ that changes from $x_\textnormal{old}$ to $x_\textnormal{new}$ as a step function. For a fine-grained uniform sampling frequency, i.e. small uniform $\alpha \equiv \alpha_i \ll 1$, the number of samples required to move the EWMA $\bar{x}$ about 2/3 of the way from $x_\textnormal{old}$ to $x_\textnormal{new}$ is approximately $\frac{1}{\alpha}$.

2. Exponentially Weighted Moving Average ($\mu_i$)
The mean must be updated first to be used in the variance calculation:
$\mu_i = (1 - \alpha_i) \mu_{i-1} + \alpha_i x_i$

3. Exponentially Weighted Moving Variance ($\sigma_i^2$)
The recursive formula for variance with irregular intervals is:
$\sigma_i^2 = (1 - \alpha_i) \sigma_{i-1}^2 + \alpha_i (x_i - \mu_i)(x_i - \mu_{i-1})$

Logic: This formula uses the "pre-update" mean ($\mu_{i-1}$) and "post-update" mean ($\mu_i$) to keep the estimator unbiased as the mean shifts over time.

4. Moving Standard Deviation ($\sigma_i$)
Finally, take the square root of the variance:
$\sigma_i = \sqrt{\sigma_i^2}$

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants