|
| 1 | +# Price Oracle Architecture |
| 2 | + |
| 3 | +This document describes the price oracle design for the ALP. |
| 4 | +How multiple sources are combined into a single trusted oracle interface, and how routing and aggregation are split across two contracts. |
| 5 | + |
| 6 | +## Overview |
| 7 | + |
| 8 | +The protocol depends on a **single trusted oracle** that returns either a valid price or `nil` when the price should not be used (e.g. liquidation or rebalancing should be skipped). The protocol does **not** validate prices; it only consumes the oracle’s result. |
| 9 | + |
| 10 | +Two contracts implement this design: |
| 11 | + |
| 12 | +| Contract | Role | |
| 13 | +|----------|------| |
| 14 | +| **PriceOracleAggregatorv1** | Combines multiple price sources for **one** market (e.g. several FLOW/USDC oracles). Returns a price only when sources agree within spread tolerance and short-term history is within `baseTolerance` + `driftExpansionRate` (stability). | |
| 15 | +| **PriceOracleRouterv1** | Exposes **one** `DeFiActions.PriceOracle` that routes by token type. Each token has its own oracle; typically each oracle is an aggregator. | |
| 16 | + |
| 17 | +Typical usage: create one **aggregator** per market (same token pair, multiple sources), then register each aggregator in a **router** under the corresponding token type. The protocol then uses the router as its single oracle. |
| 18 | + |
| 19 | +### Immutable Configuration |
| 20 | + |
| 21 | +The **Aggregator** and **Router** are immutable by design to eliminate the risks associated with live production changes. |
| 22 | + |
| 23 | +* **Eliminates "Testing in Prod":** Because parameters cannot be modified in place, you avoid the risk of breaking a live oracle. Instead, new configurations can be fully tested as a separate instance before deployment. |
| 24 | +* **Centralized Governance:** Changes can only be made by updating the oracle reference on the **ALP**. This makes it explicitly clear who holds governance authority over the system. |
| 25 | +* **Timelock Compatibility:** Since updates require a fresh deployment, it is easy to implement an "Escape Period" (Timelock). This introduces a mandatory delay before a new oracle address takes effect, giving users time to react or exit before the change goes live. |
| 26 | +* **Transparent Auditing:** Every change is recorded on-chain via the `PriceOracleUpdated` event, ensuring all shifts in logic or parameters are visible and expected. |
| 27 | + |
| 28 | +## PriceOracleAggregatorv1 |
| 29 | + |
| 30 | +One aggregated oracle per “market” (e.g. FLOW in USDC). Multiple underlying oracles, single unit of account, fixed tolerances. |
| 31 | +- **Price flow:** |
| 32 | + 1. Collect prices from all oracles for the requested token. |
| 33 | + 2. If any oracle returns nil → emit `PriceNotAvailable`, return nil. |
| 34 | + 3. Compute min/max; if spread > `maxSpread` → emit `PriceNotWithinSpreadTolerance`, return nil. |
| 35 | + 4. Compute aggregated price as the arithmetic mean of all oracle prices. |
| 36 | + 5. Check short-term stability: compare current price to recent history; for each history entry the allowed relative difference is `baseTolerance + driftExpansionRate * deltaTMinutes`; if any relative difference exceeds that → emit `PriceNotWithinHistoryTolerance`, return nil. |
| 37 | + 6. Otherwise return the aggregated price. |
| 38 | +- **History:** An array of `(price, timestamp)` is maintained. Updates are permissionless via `tryAddPriceToHistory()` (idempotent); A FlowCron job should be created to call this regularly. |
| 39 | +Additionally every call to price() will also attempt to store the price in the history. |
| 40 | + |
| 41 | +## Aggregate price (average) |
| 42 | + |
| 43 | +The aggregator uses the **arithmetic mean** of all oracle prices: |
| 44 | + |
| 45 | +- **Average:** `sum(prices) / count`. Same for any number of oracles (1, 2, 3+). |
| 46 | + |
| 47 | +## Oracle spread (coherence) |
| 48 | + |
| 49 | +A **pessimistic relative spread** is used: the distance between the most extreme oracle prices relative to the **minimum** price. |
| 50 | + |
| 51 | +$$ |
| 52 | +\text{Spread} = \frac{Price_{\max} - Price_{\min}}{Price_{\min}} |
| 53 | +$$ |
| 54 | + |
| 55 | +The price set is **coherent** only if: |
| 56 | + |
| 57 | +$$ |
| 58 | +\text{isCoherent} = |
| 59 | +\begin{cases} |
| 60 | +\text{true} & \text{if } \frac{Price_{\max} - Price_{\min}}{Price_{\min}} \le maxSpread \\ |
| 61 | +\text{false} & \text{otherwise} |
| 62 | +\end{cases} |
| 63 | +$$ |
| 64 | + |
| 65 | +## Short-term stability (history tolerance) |
| 66 | + |
| 67 | +The aggregator keeps an array of the last **n** aggregated prices (with timestamps), respecting `priceHistoryInterval` and `maxPriceHistoryAge`. |
| 68 | + |
| 69 | +Stability is defined by two parameters: |
| 70 | + |
| 71 | +- **baseTolerance** (n): fixed buffer to account for immediate market noise. |
| 72 | +- **driftExpansionRate** (m): additional allowance per minute to account for natural price drift. |
| 73 | + |
| 74 | +For each historical point (i), the **allowed relative difference** between the current price and the history price grows with time: |
| 75 | + |
| 76 | +$$ |
| 77 | +\text{allowedRelativeDiff}_{i} = \text{baseTolerance} + \text{driftExpansionRate} \times \Delta t_{\text{minutes}} |
| 78 | +$$ |
| 79 | + |
| 80 | +where Delta t_minutes is the time in minutes from the history entry to now. The **actual relative difference** is: |
| 81 | + |
| 82 | +$$ |
| 83 | +\text{relativeDiff}_{i} = \frac{|Price_{\text{current}} - Price_{i}|}{\min(Price_{\text{current}}, Price_{i})} |
| 84 | +$$ |
| 85 | + |
| 86 | +The current price is **stable** only if **every** such relative difference (from each valid history entry to the current price) is at or below the allowed tolerance for that entry. If **any** exceeds it, the aggregator emits `PriceNotWithinHistoryTolerance(relativeDiff, deltaTMinutes, maxAllowedRelativeDiff)` and returns nil. |
| 87 | + |
| 88 | +$$ |
| 89 | +\text{isStable} = |
| 90 | +\begin{cases} |
| 91 | +\text{true} & \text{if } \text{relativeDiff}_{i} \le \text{allowedRelativeDiff}_{i} \text{ for all } i \\ |
| 92 | +\text{false} & \text{otherwise (price invalid)} |
| 93 | +\end{cases} |
| 94 | +$$ |
| 95 | + |
| 96 | +Implementationally, entries older than `maxPriceHistoryAge` are ignored when evaluating stability. |
| 97 | + |
| 98 | +**Parameter units:** `maxSpread`, `baseTolerance`, and `driftExpansionRate` are dimensionless relative values (e.g. `0.01` = 1%, `1.0` = 100%). All are bounded by the contract to ≤ 10000.0. |
| 99 | + |
| 100 | +## PriceOracleRouterv1 |
| 101 | + |
| 102 | +Single oracle interface that routes by **token type**. Each token type maps to an oracle. This makes it easy to combine different aggregators without the need to supply different kinds of thresholds for individual token types. |
| 103 | + |
| 104 | +- **Price flow:** `price(ofToken)` looks up the oracle for that token type; if none is registered, returns `nil`. All oracles must share the same `unitOfAccount` (enforced at router creation). |
| 105 | +- **Empty router:** If the oracle map is empty or a token type is not registered, `price(ofToken)` returns `nil`. |
0 commit comments