From 6571b444c04a94694fcf33488aa1c52ecd8cf602 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 6 Feb 2026 18:08:31 +0100 Subject: [PATCH] oracle aggregator design --- FlowActions | 2 +- OracleAggregatorArchitecture.md | 59 +++++++++ cadence/contracts/FlowOracleAggregatorV1.cdc | 131 +++++++++++++++++++ cadence/tests/oracle_aggregator_test.cdc | 42 ++++++ flow.json | 6 + 5 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 OracleAggregatorArchitecture.md create mode 100644 cadence/contracts/FlowOracleAggregatorV1.cdc create mode 100644 cadence/tests/oracle_aggregator_test.cdc diff --git a/FlowActions b/FlowActions index 1254f6e9..d41ef3b6 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 1254f6e94fe23e27490d9df042de186b29e5e4cc +Subproject commit d41ef3b6eb8bb42e9cf4e35e740bbca6f122aefb diff --git a/OracleAggregatorArchitecture.md b/OracleAggregatorArchitecture.md new file mode 100644 index 00000000..61e9b048 --- /dev/null +++ b/OracleAggregatorArchitecture.md @@ -0,0 +1,59 @@ +# OracleAggregator + +## Requirements + +- The lending protocol (ALP / FCM) depends on a single trusted oracle interface that returns either a valid price or nil if the price should not be trusted. +- The lending protocol does not contain any logic for validating prices and simply consumes the output of the trusted oracle. +- The oracle aggregator combines multiple price sources such as on-chain DEX prices and off-chain price feeds. +- A price is considered usable only if the sources are reasonably aligned within a configurable tolerance and recent price changes are not anomalous. +- If sources diverge beyond tolerance or show suspicious short-term volatility, the aggregator returns nil and the protocol skips actions like liquidation or rebalancing. +- Governance is responsible for configuring which sources are used and what tolerances apply, not the lending protocol itself. +- This separation is intentional so the lending protocol remains reusable and does not encode assumptions about specific oracle implementations. + +--- +# Design draft: The following sections outline ideas that are still being designed. + +## Aggregate price + +To avoid the complexity of calculating a median, we instead use a trimmed mean: removing the maximum and minimum values to protect against "oracle jitter." + +## Oracle spread + +A **Pessimistic Relative Spread** calculation is used. This measures the distance between the most extreme values in the oracles ($Price_{max}$ and $Price_{min}$) relative to the lowest value. + +$$ +\text{Spread} = \frac{Price_{max} - Price_{min}}{Price_{min}} +$$ + +A price set is considered **Coherent** only if the calculated spread is within the configured tolerance ($\tau$): + +$$ +\text{isCoherent} = +\begin{cases} +\text{true} & \text{if } \left( \frac{Price_{max} - Price_{min}}{Price_{min}} \right) \le \tau \\ +\text{false} & \text{otherwise} +\end{cases} +$$ +## Short-term volatility + +The oracle maintains a ring buffer of the last `n` aggregated prices with timestamps, +respecting `minTimeDelta` and `maxTimeDelta`. +Prices are collected on calls to `price()`. +If multiple updates occur within the same `minTimeDelta`, only the most recent price is retained. + +The pessimistic relative price move is: + +$$ +\text{Move} = \frac{Price_{max} - Price_{min}}{Price_{min}} +$$ + +The price history is considered **Stable** only if the move is below the configured +maximum allowed move. + +$$ +\text{isStable} = +\begin{cases} +\text{true} & \text{if } \text{Move} \le \text{maxMove} \\ +\text{false} & \text{otherwise} +\end{cases} +$$ diff --git a/cadence/contracts/FlowOracleAggregatorV1.cdc b/cadence/contracts/FlowOracleAggregatorV1.cdc new file mode 100644 index 00000000..0bcd21f7 --- /dev/null +++ b/cadence/contracts/FlowOracleAggregatorV1.cdc @@ -0,0 +1,131 @@ +import "FlowToken" +import "DeFiActions" + +access(all) contract FlowOracleAggregatorV1 { + + access(all) entitlement Governance + + access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + access(self) let unit: Type + access(all) let oracles: [{DeFiActions.PriceOracle}] + + access(all) var maxSpread: UFix64 + + init(uniqueID: DeFiActions.UniqueIdentifier?, unitOfAccount: Type, maxSpread: UFix64) { + self.uniqueID = uniqueID + self.unit = unitOfAccount + self.oracles = [] + self.maxSpread = maxSpread + } + + access(all) view fun unitOfAccount(): Type { + return self.unit + } + + access(all) view fun id(): UInt64? { + return self.uniqueID?.id + } + + access(all) fun price(ofToken: Type): UFix64? { + let prices = self.getPrices() + if prices.length == 0 { + return nil + } + let minAndMaxPrices = self.getMinAndMaxPrices(prices: prices) + if !self.isWithinSpreadTolerance(minPrice: minAndMaxPrices.minPrice, maxPrice: minAndMaxPrices.maxPrice) { + return nil + } + return self.trimmedMeanPrice(prices: prices, minPrice: minAndMaxPrices.minPrice, maxPrice: minAndMaxPrices.maxPrice) + } + + access(all) fun getID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) + } + + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + + access(Governance) fun setMaxSpread(_ maxSpread: UFix64) { + self.maxSpread = maxSpread + } + + access(self) fun getMaxSpread(): UFix64 { + return self.maxSpread + } + + access(self) fun getPrices(): [UFix64] { + let prices: [UFix64] = [] + for oracle in self.oracles { + let price = oracle.price(ofToken: self.unit) + prices.append(price!) + } + return prices + } + + access(self) fun getMinAndMaxPrices(prices: [UFix64]): MinAndMaxPrices { + var minPrice = UFix64.max + var maxPrice = UFix64.min + for price in prices { + if price < minPrice { + minPrice = price + } + if price > maxPrice { + maxPrice = price + } + } + return MinAndMaxPrices(minPrice: minPrice, maxPrice: maxPrice) + } + + access(self) view fun trimmedMeanPrice(prices: [UFix64], minPrice: UFix64, maxPrice: UFix64): UFix64? { + switch prices.length { + case 0: + return nil + case 1: + return prices[0] + case 2: + return (prices[0] + prices[1]) / 2.0 + } + var sum = 0.0 + for price in prices { + if price != minPrice && price != maxPrice { + sum = sum + price + } + } + sum = sum - (minPrice + maxPrice) + return sum / UFix64(prices.length - 2) + } + + access(self) view fun isWithinSpreadTolerance(minPrice: UFix64, maxPrice: UFix64): Bool { + let spread = (maxPrice - minPrice) / minPrice + return spread <= self.maxSpread + } + } + + access(all) fun createPriceOracleAggregator(uniqueID: DeFiActions.UniqueIdentifier?, unitOfAccount: Type, maxSpread: UFix64): PriceOracleAggregator { + return PriceOracleAggregator(uniqueID: uniqueID, unitOfAccount: unitOfAccount, maxSpread: maxSpread) + } + + access(all) struct MinAndMaxPrices { + access(all) let minPrice: UFix64 + access(all) let maxPrice: UFix64 + + init(minPrice: UFix64, maxPrice: UFix64) { + self.minPrice = minPrice + self.maxPrice = maxPrice + } + } +} \ No newline at end of file diff --git a/cadence/tests/oracle_aggregator_test.cdc b/cadence/tests/oracle_aggregator_test.cdc new file mode 100644 index 00000000..eda33ffc --- /dev/null +++ b/cadence/tests/oracle_aggregator_test.cdc @@ -0,0 +1,42 @@ +import Test +import BlockchainHelpers + +import "FlowOracleAggregatorV1" +import "DeFiActions" +import "FlowToken" +import "test_helpers.cdc" + +access(all) var snapshot: UInt64 = 0 + +access(all) fun setup() { + deployContracts() + var err = Test.deployContract( + name: "FlowOracleAggregatorV1", + path: "../contracts/FlowOracleAggregatorV1.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + snapshot = getCurrentBlockHeight() + Test.commitBlock() +} + +access(all) fun beforeEach() { + Test.reset(to: snapshot) +} + +access(all) fun test_create_aggregator() { + let aggregator = FlowOracleAggregatorV1.createPriceOracleAggregator( + uniqueID: DeFiActions.createUniqueIdentifier(), + unitOfAccount: Type<@FlowToken.Vault>(), + maxSpread: 0.05 + ) +} + +access(all) fun test_create_aggregator() { + let aggregator = FlowOracleAggregatorV1.createPriceOracleAggregator( + uniqueID: DeFiActions.createUniqueIdentifier(), + unitOfAccount: Type<@FlowToken.Vault>(), + maxSpread: 0.05 + ) +} \ No newline at end of file diff --git a/flow.json b/flow.json index a72b42db..c58c7d6c 100644 --- a/flow.json +++ b/flow.json @@ -46,6 +46,12 @@ "testing": "0000000000000007" } }, + "FlowOracleAggregatorV1": { + "source": "./cadence/contracts/FlowOracleAggregatorV1.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, "MockDexSwapper": { "source": "./cadence/contracts/mocks/MockDexSwapper.cdc", "aliases": {