From bdefba4ca86dafabd583b840624f807a01ab7015 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Fri, 6 Feb 2026 18:08:31 +0100 Subject: [PATCH 01/18] oracle aggregator design --- OracleAggregatorArchitecture.md | 59 +++++++++ cadence/contracts/FlowOracleAggregatorV1.cdc | 131 +++++++++++++++++++ cadence/tests/oracle_aggregator_test.cdc | 42 ++++++ flow.json | 6 + 4 files changed, 238 insertions(+) create mode 100644 OracleAggregatorArchitecture.md create mode 100644 cadence/contracts/FlowOracleAggregatorV1.cdc create mode 100644 cadence/tests/oracle_aggregator_test.cdc 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 9feaf04b..15faa9e1 100644 --- a/flow.json +++ b/flow.json @@ -92,6 +92,12 @@ "testing": "0000000000000007" } }, + "FlowOracleAggregatorV1": { + "source": "./cadence/contracts/FlowOracleAggregatorV1.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, "MockDexSwapper": { "source": "./cadence/contracts/mocks/MockDexSwapper.cdc", "aliases": { From e370e0ec9b906442abe96242eb96e79607de568f Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Tue, 17 Feb 2026 03:05:30 +0100 Subject: [PATCH 02/18] update oracleAggregator - average price - spread - gradient missing historical price collection --- OracleAggregatorArchitecture.md | 27 +- cadence/contracts/FlowOracleAggregatorV1.cdc | 266 +++++++++++++------ cadence/tests/contracts/MultiMockOracle.cdc | 91 +++++++ cadence/tests/test_helpers.cdc | 18 +- flow.json | 10 +- 5 files changed, 318 insertions(+), 94 deletions(-) create mode 100644 cadence/tests/contracts/MultiMockOracle.cdc diff --git a/OracleAggregatorArchitecture.md b/OracleAggregatorArchitecture.md index 61e9b048..35ff020f 100644 --- a/OracleAggregatorArchitecture.md +++ b/OracleAggregatorArchitecture.md @@ -6,12 +6,12 @@ - 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. +- If sources diverge beyond tolerance or the short-term gradient exceeds the configured threshold, 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. +# Design draft: The following sections outline ideas that are still being designed. ## Aggregate price @@ -28,32 +28,29 @@ $$ A price set is considered **Coherent** only if the calculated spread is within the configured tolerance ($\tau$): $$ -\text{isCoherent} = -\begin{cases} +\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. +## Short-term gradient + +The oracle maintains a ring buffer of the last **n** aggregated prices with timestamps, respecting `minTimeDelta` and `maxTimeDelta`. -The pessimistic relative price move is: +For each historical point $i$, the **gradient to the current price** is the relative change per unit time: $$ -\text{Move} = \frac{Price_{max} - Price_{min}}{Price_{min}} +\text{Gradient}_{i} = \frac{Price_{current} - Price_{i}}{Price_{i} \cdot (t_{current} - t_{i})} $$ -The price history is considered **Stable** only if the move is below the configured -maximum allowed move. +The current price is considered **Stable** only if **every** such gradient (from each of the n historical points to the current price) is at or below the configured threshold. If **any** gradient is above the threshold, the current price is **invalid** and the aggregator returns nil. $$ \text{isStable} = \begin{cases} -\text{true} & \text{if } \text{Move} \le \text{maxMove} \\ -\text{false} & \text{otherwise} +\text{true} & \text{if } \text{Gradient}_{i} \le \text{gradientThreshold} \text{ for all } i \\ +\text{false} & \text{otherwise (price invalid)} \end{cases} $$ diff --git a/cadence/contracts/FlowOracleAggregatorV1.cdc b/cadence/contracts/FlowOracleAggregatorV1.cdc index 0bcd21f7..b1be4175 100644 --- a/cadence/contracts/FlowOracleAggregatorV1.cdc +++ b/cadence/contracts/FlowOracleAggregatorV1.cdc @@ -1,76 +1,82 @@ import "FlowToken" import "DeFiActions" +import "FlowTransactionScheduler" -access(all) contract FlowOracleAggregatorV1 { +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) event AggregatorCreated(uuid: UInt64) + access(all) event PriceNotAvailable() + access(all) event PriceNotWithinSpreadTolerance(spread: UFix64) + access(all) event PriceNotStable(gradient: UFix64) - access(all) var maxSpread: UFix64 + access(self) var oracleAggregators: @{UInt64: PriceOracleAggregatorStorage} - init(uniqueID: DeFiActions.UniqueIdentifier?, unitOfAccount: Type, maxSpread: UFix64) { - self.uniqueID = uniqueID - self.unit = unitOfAccount - self.oracles = [] - self.maxSpread = maxSpread - } + access(all) resource PriceOracleAggregatorStorage: FlowTransactionScheduler.TransactionHandler{ + access(all) let priceHistory: [PriceHistoryEntry] - access(all) view fun unitOfAccount(): Type { - return self.unit - } - - access(all) view fun id(): UInt64? { - return self.uniqueID?.id + // constants intentional to avoid stupid bugs + access(all) let oracles: [{DeFiActions.PriceOracle}] + access(all) let maxSpread: UFix64 + access(all) let maxGradient: UFix64 + access(all) let priceHistorySize: Int + access(all) let priceHistoryInterval: UFix64 + + access(all) let unit: Type + + init( + oracles: [{DeFiActions.PriceOracle}], + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + unitOfAccount: Type, + ) { + self.oracles = oracles + self.priceHistory = [] + self.maxSpread = maxSpread + self.maxGradient = maxGradient + self.priceHistorySize = priceHistorySize + self.priceHistoryInterval = priceHistoryInterval + self.unit = unitOfAccount } access(all) fun price(ofToken: Type): UFix64? { - let prices = self.getPrices() - if prices.length == 0 { + let price = self.getPriceUncheckedGradient(ofToken: ofToken) + if price == nil { return nil } - let minAndMaxPrices = self.getMinAndMaxPrices(prices: prices) - if !self.isWithinSpreadTolerance(minPrice: minAndMaxPrices.minPrice, maxPrice: minAndMaxPrices.maxPrice) { + if !self.isGradientStable(currentPrice: price!) { return nil } - return self.trimmedMeanPrice(prices: prices, minPrice: minAndMaxPrices.minPrice, maxPrice: minAndMaxPrices.maxPrice) + return price } - 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(self) fun getPriceUncheckedGradient(ofToken: Type): UFix64? { + let prices = self.getPrices(ofToken: ofToken) + if prices == nil || prices!.length == 0 { + return nil + } + let minAndMaxPrices = self.getMinAndMaxPrices(prices: prices!) + if !self.isWithinSpreadTolerance(minPrice: minAndMaxPrices.min, maxPrice: minAndMaxPrices.max) { + return nil + } + return self.trimmedMeanPrice( + prices: prices!, + minPrice: minAndMaxPrices.min, + maxPrice: minAndMaxPrices.max, ) } - 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] { + access(self) fun getPrices(ofToken: Type): [UFix64]? { let prices: [UFix64] = [] for oracle in self.oracles { - let price = oracle.price(ofToken: self.unit) + let price = oracle.price(ofToken: ofToken) + if price == nil { + emit PriceNotAvailable() + return nil + } prices.append(price!) } return prices @@ -81,51 +87,161 @@ access(all) contract FlowOracleAggregatorV1 { var maxPrice = UFix64.min for price in prices { if price < minPrice { - minPrice = price + minPrice = price } if price > maxPrice { maxPrice = price } } - return MinAndMaxPrices(minPrice: minPrice, maxPrice: maxPrice) + return MinAndMaxPrices(min: minPrice, max: 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 + access(self) view fun isWithinSpreadTolerance(minPrice: UFix64, maxPrice: UFix64): Bool { + let spread = (maxPrice - minPrice) / minPrice + if spread > self.maxSpread { + emit PriceNotWithinSpreadTolerance(spread: spread) + return false } - var sum = 0.0 + return true + } + + access(self) view fun trimmedMeanPrice(prices: [UFix64], minPrice: UFix64, maxPrice: UFix64): UFix64? { + let count = prices.length + + // Handle edge cases where trimming isn't possible + if count == 0 { return nil } + if count == 1 { return prices[0] } + if count == 2 { return (prices[0] + prices[1]) / 2.0 } + + var totalSum = 0.0 for price in prices { - if price != minPrice && price != maxPrice { - sum = sum + price + totalSum = totalSum + price + } + let trimmedSum = totalSum - minPrice - maxPrice + return trimmedSum / UFix64(count - 2) + } + + access(self) fun isGradientStable(currentPrice: UFix64): Bool { + let now = getCurrentBlock().timestamp + for entry in self.priceHistory { + var deltaT = now - UFix64(entry.timestamp) + if deltaT == 0.0 { + // if price got measured in the same block allow for some price jitter + deltaT = 1.0 + } + let gradient = (currentPrice - entry.price) / (entry.price * deltaT) + if gradient > self.maxGradient { + return false } } - sum = sum - (minPrice + maxPrice) - return sum / UFix64(prices.length - 2) + return true } - access(self) view fun isWithinSpreadTolerance(minPrice: UFix64, maxPrice: UFix64): Bool { - let spread = (maxPrice - minPrice) / minPrice - return spread <= self.maxSpread + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + let price = self.getPriceUncheckedGradient(ofToken: self.unit) + if price == nil { + return + } + self.priceHistory.append(PriceHistoryEntry(price: price!, timestamp: getCurrentBlock().timestamp)) + if self.priceHistory.length > self.priceHistorySize { + self.priceHistory.removeFirst() + } + } + } + + access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { + access(all) let PriceOracleID: UInt64 + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + + init(PriceOracleID: UInt64) { + self.PriceOracleID = PriceOracleID + self.uniqueID = nil + } + + access(all) fun price(ofToken: Type): UFix64? { + return self.borrowPriceOracleAggregator().price(ofToken: ofToken) + } + + access(all) view fun unitOfAccount(): Type { + return self.borrowPriceOracleAggregator().unit + } + + access(all) view fun id(): UInt64 { + return self.uniqueID!.id + } + + access(all) fun getID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) } + + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + + access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { + return (&FlowOracleAggregatorv1.oracleAggregators[self.PriceOracleID])! + } + } + + access(all) fun createPriceOracleAggregatorStorage( + oracles: [{DeFiActions.PriceOracle}], + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + unitOfAccount: Type, + ): UInt64 { + let priceOracleAggregator <- create PriceOracleAggregatorStorage( + oracles: oracles, + maxSpread: maxSpread, + maxGradient: maxGradient, + priceHistorySize: priceHistorySize, + priceHistoryInterval: priceHistoryInterval, + unitOfAccount: unitOfAccount + ) + let id = priceOracleAggregator.uuid + self.oracleAggregators[id] <-! priceOracleAggregator + emit AggregatorCreated(uuid: id) + return id } - access(all) fun createPriceOracleAggregator(uniqueID: DeFiActions.UniqueIdentifier?, unitOfAccount: Type, maxSpread: UFix64): PriceOracleAggregator { - return PriceOracleAggregator(uniqueID: uniqueID, unitOfAccount: unitOfAccount, maxSpread: maxSpread) + access(all) fun createPriceOracleAggregator(id: UInt64): PriceOracleAggregator { + return PriceOracleAggregator(PriceOracleID: id) } access(all) struct MinAndMaxPrices { - access(all) let minPrice: UFix64 - access(all) let maxPrice: UFix64 + access(all) let min: UFix64 + access(all) let max: UFix64 - init(minPrice: UFix64, maxPrice: UFix64) { - self.minPrice = minPrice - self.maxPrice = maxPrice + init(min: UFix64, max: UFix64) { + self.min = min + self.max = max } } + + access(all) struct PriceHistoryEntry { + access(all) let price: UFix64 + access(all) let timestamp: UFix64 + + init(price: UFix64, timestamp: UFix64) { + self.price = price + self.timestamp = timestamp + } + } + + init() { + self.oracleAggregators <- {} + } } \ No newline at end of file diff --git a/cadence/tests/contracts/MultiMockOracle.cdc b/cadence/tests/contracts/MultiMockOracle.cdc new file mode 100644 index 00000000..22ccc95b --- /dev/null +++ b/cadence/tests/contracts/MultiMockOracle.cdc @@ -0,0 +1,91 @@ +import "FungibleToken" + +import "DeFiActions" + +/// +/// THIS CONTRACT IS A MOCK AND IS NOT INTENDED FOR USE IN PRODUCTION +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// +access(all) contract MultiMockOracle { + + access(all) event OracleCreated(uuid: UInt64) + + access(all) var priceOracleStorages: @{UInt64: PriceOracleStorage} + + access(all) resource PriceOracleStorage { + access(contract) var unitOfAccountType: Type + access(contract) var prices: {Type: UFix64} + + access(all) fun setPrice(forToken: Type, price: UFix64) { + self.prices[forToken] = price + } + + init(unitOfAccountType: Type) { + self.unitOfAccountType = unitOfAccountType + self.prices = {} + } + } + + access(all) struct PriceOracle : DeFiActions.PriceOracle { + access(contract) var priceOracleStorageID: UInt64 + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + + access(all) view fun unitOfAccount(): Type { + return self.borrowPriceOracleStorage().unitOfAccountType + } + + access(all) fun price(ofToken: Type): UFix64? { + if ofToken == self.borrowPriceOracleStorage().unitOfAccountType { + return 1.0 + } + return self.borrowPriceOracleStorage().prices[ofToken] + } + + 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(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + + access(all) view fun borrowPriceOracleStorage(): &PriceOracleStorage { + return (&MultiMockOracle.priceOracleStorages[self.priceOracleStorageID])! + } + + init(priceOracleStorageID: UInt64) { + self.priceOracleStorageID = priceOracleStorageID + self.uniqueID = DeFiActions.createUniqueIdentifier() + } + } + + access(all) fun createPriceOracle(unitOfAccountType: Type): PriceOracle { + let oracleStorage <- create PriceOracleStorage(unitOfAccountType: unitOfAccountType) + let id = oracleStorage.uuid + self.priceOracleStorages[id] <-! oracleStorage + emit OracleCreated(uuid: id) + let oracle = PriceOracle(priceOracleStorageID: id) + return oracle + } + + access(all) view fun borrowPriceOracleStorage(priceOracleStorageID: UInt64): &PriceOracleStorage? { + return &self.priceOracleStorages[priceOracleStorageID] + } + + access(all) fun setPrice(priceOracleStorageID: UInt64, forToken: Type, price: UFix64) { + let oracleStorage = self.borrowPriceOracleStorage(priceOracleStorageID: priceOracleStorageID)! + oracleStorage.setPrice(forToken: forToken, price: price) + } + + init() { + self.priceOracleStorages <- {} + } +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 9548c24e..b3ea6b57 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -111,6 +111,20 @@ fun deployContracts() { ) Test.expect(err, Test.beNil()) + err = Test.deployContract( + name: "MultiMockOracle", + path: "./contracts/MultiMockOracle.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "FlowOracleAggregatorv1", + path: "../contracts/FlowOracleAggregatorv1.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + let initialYieldTokenSupply = 0.0 err = Test.deployContract( name: "MockYieldToken", @@ -675,7 +689,7 @@ fun collectStability( [ tokenTypeIdentifier ], signer ) - + return res } @@ -692,7 +706,7 @@ fun withdrawStabilityFund( [tokenTypeIdentifier, amount, recipient, recipientPath], signer ) - + return res } diff --git a/flow.json b/flow.json index 15faa9e1..facd6ec2 100644 --- a/flow.json +++ b/flow.json @@ -92,8 +92,8 @@ "testing": "0000000000000007" } }, - "FlowOracleAggregatorV1": { - "source": "./cadence/contracts/FlowOracleAggregatorV1.cdc", + "FlowOracleAggregatorv1": { + "source": "./cadence/contracts/FlowOracleAggregatorv1.cdc", "aliases": { "testing": "0000000000000007" } @@ -112,6 +112,12 @@ "testnet": "d2580caf2ef07c2f" } }, + "MultiMockOracle": { + "source": "./cadence/tests/contracts/MultiMockOracle.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, "MockYieldToken": { "source": "./cadence/contracts/mocks/MockYieldToken.cdc", "aliases": { From caf7ab48b4f7fffec2b657c784e90112cc54fba1 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Tue, 17 Feb 2026 03:15:33 +0100 Subject: [PATCH 03/18] add oracle aggregator tests --- cadence/tests/oracle_aggregator_test.cdc | 244 ++++++++++++++++-- .../tests/scripts/oracle_aggregator_price.cdc | 6 + .../tests/test_helpers_oracle_aggregator.cdc | 95 +++++++ .../oracle-aggregator/create_aggregator.cdc | 36 +++ .../oracle-aggregator/get_price.cdc | 12 + .../oracle-aggregator/set_price.cdc | 15 ++ 6 files changed, 386 insertions(+), 22 deletions(-) create mode 100644 cadence/tests/scripts/oracle_aggregator_price.cdc create mode 100644 cadence/tests/test_helpers_oracle_aggregator.cdc create mode 100644 cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc create mode 100644 cadence/tests/transactions/oracle-aggregator/get_price.cdc create mode 100644 cadence/tests/transactions/oracle-aggregator/set_price.cdc diff --git a/cadence/tests/oracle_aggregator_test.cdc b/cadence/tests/oracle_aggregator_test.cdc index eda33ffc..75109024 100644 --- a/cadence/tests/oracle_aggregator_test.cdc +++ b/cadence/tests/oracle_aggregator_test.cdc @@ -1,42 +1,242 @@ import Test import BlockchainHelpers -import "FlowOracleAggregatorV1" +import "FlowOracleAggregatorv1" import "DeFiActions" import "FlowToken" +import "MOET" +import "MultiMockOracle" import "test_helpers.cdc" +import "test_helpers_oracle_aggregator.cdc" access(all) var snapshot: UInt64 = 0 +// access(all) var aggregatorID: UInt64 = 0 +// access(all) var aggregatorStruct: FlowOracleAggregatorV1.PriceOracleAggregatorStruct? = nil 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) + if snapshot != getCurrentBlockHeight() { + 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_single_oracle() { + let info = createAggregator( + oracleCount: 1, + maxSpread: 0.0, + maxGradient: 0.0, + priceHistorySize: 0, + priceHistoryInterval: 0.0, + unitOfAccount: Type<@MOET.Vault>() ) + let prices = [1.0, 0.0001, 1337.0] + for p in prices { + setPrice( + priceOracleStorageID: info.oracleIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: p, + ) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + Test.assert(price != nil, message: "Price should not be nil") + Test.assertEqual(price!, p) + } } -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 +access(all) fun test_multiple_oracles() { + let oracleCounts = [1, 2, 3, 4, 5, 6] + for oracleCount in oracleCounts { + let info = createAggregator( + oracleCount: oracleCount, + maxSpread: 0.0, + maxGradient: 0.0, + priceHistorySize: 0, + priceHistoryInterval: 0.0, + unitOfAccount: Type<@MOET.Vault>() + ) + let prices = [1.0, 0.0001, 1337.0] + for p in prices { + for oracleID in info.oracleIDs { + setPrice( + priceOracleStorageID: oracleID, + forToken: Type<@FlowToken.Vault>(), + price: p, + ) + } + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + Test.assert(price != nil, message: "Price should not be nil") + Test.assertEqual(price!, p) + } + } +} + +access(all) struct TestRunAveragePrice { + access(all) let prices: [UFix64] + access(all) let expectedPrice: UFix64? + + init(prices: [UFix64], expectedPrice: UFix64?) { + self.prices = prices + self.expectedPrice = expectedPrice + } +} + +access(all) fun test_average_price() { + let testRuns = [TestRunAveragePrice( + prices: [1.0, 2.0], + expectedPrice: 1.5, + ), TestRunAveragePrice( + prices: [1.0, 2.0, 3.0], + expectedPrice: 2.0, + ), TestRunAveragePrice( + prices: [1.0, 2.0, 10.0], + expectedPrice: 2.0, + ), TestRunAveragePrice( + prices: [1.0, 9.0, 10.0], + expectedPrice: 9.0, + ), TestRunAveragePrice( + prices: [1.0, 1.0, 2.0], + expectedPrice: 1.0, + ), TestRunAveragePrice( + prices: [1.0, 1.0, 1.0], + expectedPrice: 1.0, + ), TestRunAveragePrice( + prices: [1.0, 1.0, 2.0, 3.0], + expectedPrice: 1.5, + ), TestRunAveragePrice( + prices: [1.0, 2.0, 3.0, 4.0, 5.0], + expectedPrice: 3.0, + ), TestRunAveragePrice( + prices: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + expectedPrice: 3.5, + )] + testRuns.appendAll(testRuns.reverse()) + for testRun in testRuns { + let info = createAggregator( + oracleCount: testRun.prices.length, + maxSpread: UFix64.max, + maxGradient: UFix64.max, + priceHistorySize: 0, + priceHistoryInterval: 0.0, + unitOfAccount: Type<@MOET.Vault>() + ) + set_prices(info: info, prices: testRun.prices) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + if price != testRun.expectedPrice { + log(testRun) + log_fail_events() + Test.fail(message: "invalid price") + } + } +} + +access(all) struct TestRunSpread { + access(all) let maxSpread: UFix64 + access(all) let prices: [UFix64] + access(all) let expectedPrice: UFix64? + + init(maxSpread: UFix64, prices: [UFix64], expectedPrice: UFix64?) { + self.maxSpread = maxSpread + self.prices = prices + self.expectedPrice = expectedPrice + } +} + +access(all) fun test_spread() { + let testRuns = [TestRunSpread( + maxSpread: 0.9, + prices: [1.0, 2.0], + expectedPrice: nil, + ), TestRunSpread( + maxSpread: 0.9, + prices: [1.0, 1.5, 2.0], + expectedPrice: nil, + ), TestRunSpread( + maxSpread: 0.9, + prices: [1.0, 2.0, 1.0], + expectedPrice: nil, + ), TestRunSpread( + maxSpread: 0.9, + prices: [1.0, 1.0, 1.0, 1.0], + expectedPrice: 1.0, + ), TestRunSpread( + maxSpread: 0.0, + prices: [1.0, 1.0001], + expectedPrice: nil, + ), TestRunSpread( + maxSpread: 0.0, + prices: [1.0, 1.0001, 1.0], + expectedPrice: nil, + ), TestRunSpread( + maxSpread: 1.0, + prices: [1.0, 2.0], + expectedPrice: 1.5, + ), TestRunSpread( + maxSpread: 1.0, + prices: [1.0, 1.5, 2.0], + expectedPrice: 1.5, + )] + testRuns.appendAll(testRuns.reverse()) + for testRun in testRuns { + let info = createAggregator( + oracleCount: testRun.prices.length, + maxSpread: testRun.maxSpread, + maxGradient: UFix64.max, + priceHistorySize: 0, + priceHistoryInterval: 0.0, + unitOfAccount: Type<@MOET.Vault>() + ) + set_prices(info: info, prices: testRun.prices) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + if price != testRun.expectedPrice { + log(testRun) + log_fail_events() + Test.fail(message: "invalid price") + } + } +} + +access(all) fun test_gradient() { + Test.assert(false, message: "not implemented") +} + +access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64]) { + var i = 0 + for p in prices { + setPrice( + priceOracleStorageID: info.oracleIDs[i], + forToken: Type<@FlowToken.Vault>(), + price: p, + ) + i = i + 1 + } +} + +access(self) fun log_fail_events() { + let failureEvents = [ + Type(), + Type(), + Type() + ] + for eventType in failureEvents { + let events = Test.eventsOfType(eventType) + if events.length > 0 { + log(eventType) + log(events) + } + } +} diff --git a/cadence/tests/scripts/oracle_aggregator_price.cdc b/cadence/tests/scripts/oracle_aggregator_price.cdc new file mode 100644 index 00000000..088448a5 --- /dev/null +++ b/cadence/tests/scripts/oracle_aggregator_price.cdc @@ -0,0 +1,6 @@ +import "FlowOracleAggregatorv1" + +access(all) fun main(uuid: UInt64, ofToken: Type): UFix64? { + let priceOracle = FlowOracleAggregatorv1.createPriceOracleAggregator(id: uuid) + return priceOracle.price(ofToken: ofToken) +} diff --git a/cadence/tests/test_helpers_oracle_aggregator.cdc b/cadence/tests/test_helpers_oracle_aggregator.cdc new file mode 100644 index 00000000..e739e909 --- /dev/null +++ b/cadence/tests/test_helpers_oracle_aggregator.cdc @@ -0,0 +1,95 @@ +import Test +import "DeFiActions" +import "FlowOracleAggregatorv1" +import "MultiMockOracle" + +access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { + let authorizers: [Address] = [] + for signer in signers { + authorizers.append(signer.address) + } + let txn = Test.Transaction( + code: Test.readFile(path), + authorizers: authorizers, + signers: signers, + arguments: args, + ) + return Test.executeTransaction(txn) +} + +access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) +} + +access(all) struct CreateAggregatorInfo { + access(all) let aggregatorID: UInt64 + access(all) let oracleIDs: [UInt64] + + init(aggregatorID: UInt64, oracleIDs: [UInt64]) { + self.aggregatorID = aggregatorID + self.oracleIDs = oracleIDs + } +} + +access(all) fun createAggregator( + oracleCount: Int, + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + unitOfAccount: Type, +): CreateAggregatorInfo { + let res = _executeTransaction( + "./transactions/oracle-aggregator/create_aggregator.cdc", + [oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, unitOfAccount], + [] + ) + Test.expect(res, Test.beSucceeded()) + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowOracleAggregatorv1.AggregatorCreated + let oracleCreatedEvents = Test.eventsOfType(Type()) + let oracleIDs: [UInt64] = [] + var i = oracleCreatedEvents.length - oracleCount + while i < oracleCreatedEvents.length { + let oracleCreatedData = oracleCreatedEvents[i] as! MultiMockOracle.OracleCreated + oracleIDs.append(oracleCreatedData.uuid) + i = i + 1 + } + return CreateAggregatorInfo( + aggregatorID: aggregatorCreatedData.uuid, + oracleIDs: oracleIDs + ) +} + +access(all) fun setPrice( + priceOracleStorageID: UInt64, + forToken: Type, + price: UFix64, +) { + let res = _executeTransaction( + "./transactions/oracle-aggregator/set_price.cdc", + [priceOracleStorageID, forToken, price], + [] + ) + Test.expect(res, Test.beSucceeded()) +} + +access(all) fun getPrice( + uuid: UInt64, + ofToken: Type, +): UFix64? { + // execute transaction to emit events + let res = _executeTransaction( + "./transactions/oracle-aggregator/get_price.cdc", + [uuid, ofToken], + [] + ) + Test.expect(res, Test.beSucceeded()) + // execute script to get price + let res2 = _executeScript( + "./scripts/oracle_aggregator_price.cdc", + [uuid, ofToken] + ) + Test.expect(res2, Test.beSucceeded()) + return res2.returnValue as! UFix64? +} \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc b/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc new file mode 100644 index 00000000..b4206312 --- /dev/null +++ b/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc @@ -0,0 +1,36 @@ +import "DeFiActions" +import "FlowOracleAggregatorv1" +import "FlowToken" +import "MultiMockOracle" +import "MOET" + +transaction( + oracleCount: Int, + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + unitOfAccount: Type +) { + let oracles: [{DeFiActions.PriceOracle}] + + prepare() { + self.oracles = [] + var i = 0 + while i < oracleCount { + self.oracles.append(MultiMockOracle.createPriceOracle(unitOfAccountType: Type<@MOET.Vault>())) + i = i + 1 + } + } + + execute { + let uuid = FlowOracleAggregatorv1.createPriceOracleAggregatorStorage( + oracles: self.oracles, + maxSpread: maxSpread, + maxGradient: maxGradient, + priceHistorySize: priceHistorySize, + priceHistoryInterval: priceHistoryInterval, + unitOfAccount: unitOfAccount + ) + } +} \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/get_price.cdc b/cadence/tests/transactions/oracle-aggregator/get_price.cdc new file mode 100644 index 00000000..3df4f084 --- /dev/null +++ b/cadence/tests/transactions/oracle-aggregator/get_price.cdc @@ -0,0 +1,12 @@ +import "FlowOracleAggregatorv1" + +transaction( + oracleStorageID: UInt64, + forToken: Type, +) { + execute { + FlowOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( + ofToken: forToken, + ) + } +} \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/set_price.cdc b/cadence/tests/transactions/oracle-aggregator/set_price.cdc new file mode 100644 index 00000000..78497743 --- /dev/null +++ b/cadence/tests/transactions/oracle-aggregator/set_price.cdc @@ -0,0 +1,15 @@ +import "MultiMockOracle" + +transaction( + oracleStorageID: UInt64, + forToken: Type, + price: UFix64, +) { + execute { + MultiMockOracle.setPrice( + priceOracleStorageID: oracleStorageID, + forToken: forToken, + price: price, + ) + } +} \ No newline at end of file From 247d64d246435150a5bec76646e0005b0ee05ccd Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Tue, 17 Feb 2026 18:30:49 +0100 Subject: [PATCH 04/18] update oracleAggregator add historical price collection add gradient calculation including tests --- FlowActions | 2 +- cadence/contracts/FlowOracleAggregatorV1.cdc | 72 ++++- cadence/tests/contracts/MultiMockOracle.cdc | 8 +- cadence/tests/oracle_aggregator_test.cdc | 278 ++++++++++++++++-- .../scripts/oracle_aggregator_history.cdc | 6 + .../tests/test_helpers_oracle_aggregator.cdc | 24 +- .../oracle-aggregator/create_aggregator.cdc | 108 ++++++- .../oracle-aggregator/set_price.cdc | 2 +- 8 files changed, 461 insertions(+), 39 deletions(-) create mode 100644 cadence/tests/scripts/oracle_aggregator_history.cdc diff --git a/FlowActions b/FlowActions index 527b2e5b..0cd0fcc9 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 527b2e5b5aac4093ee3dc71ab47ff62bf3283733 +Subproject commit 0cd0fcc94e63fcfc954ea98b5b8b30d3535011da diff --git a/cadence/contracts/FlowOracleAggregatorV1.cdc b/cadence/contracts/FlowOracleAggregatorV1.cdc index b1be4175..9527e8ee 100644 --- a/cadence/contracts/FlowOracleAggregatorV1.cdc +++ b/cadence/contracts/FlowOracleAggregatorV1.cdc @@ -13,15 +13,17 @@ access(all) contract FlowOracleAggregatorv1 { access(self) var oracleAggregators: @{UInt64: PriceOracleAggregatorStorage} - access(all) resource PriceOracleAggregatorStorage: FlowTransactionScheduler.TransactionHandler{ + access(all) resource PriceOracleAggregatorStorage { access(all) let priceHistory: [PriceHistoryEntry] // constants intentional to avoid stupid bugs access(all) let oracles: [{DeFiActions.PriceOracle}] access(all) let maxSpread: UFix64 + // % change per minute access(all) let maxGradient: UFix64 access(all) let priceHistorySize: Int access(all) let priceHistoryInterval: UFix64 + access(all) let maxPriceHistoryAge: UFix64 access(all) let unit: Type @@ -31,6 +33,7 @@ access(all) contract FlowOracleAggregatorv1 { maxGradient: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, unitOfAccount: Type, ) { self.oracles = oracles @@ -39,6 +42,7 @@ access(all) contract FlowOracleAggregatorv1 { self.maxGradient = maxGradient self.priceHistorySize = priceHistorySize self.priceHistoryInterval = priceHistoryInterval + self.maxPriceHistoryAge = maxPriceHistoryAge self.unit = unitOfAccount } @@ -47,6 +51,7 @@ access(all) contract FlowOracleAggregatorv1 { if price == nil { return nil } + self.tryAddPriceToHistoryInternal(price: price!) if !self.isGradientStable(currentPrice: price!) { return nil } @@ -129,20 +134,42 @@ access(all) contract FlowOracleAggregatorv1 { // if price got measured in the same block allow for some price jitter deltaT = 1.0 } - let gradient = (currentPrice - entry.price) / (entry.price * deltaT) + if deltaT > self.maxPriceHistoryAge { + continue + } + var gradient = 0.0 + if currentPrice > entry.price { + gradient = (currentPrice - entry.price) / (entry.price * deltaT) * 6000.0 + } else { + gradient = (entry.price - currentPrice) / (currentPrice * deltaT) * 6000.0 + } if gradient > self.maxGradient { + emit PriceNotStable(gradient: gradient) return false } } return true } - access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + // Permissionless can be called by anyone, idempotent + access(all) fun tryAddPriceToHistory() { let price = self.getPriceUncheckedGradient(ofToken: self.unit) if price == nil { return } - self.priceHistory.append(PriceHistoryEntry(price: price!, timestamp: getCurrentBlock().timestamp)) + self.tryAddPriceToHistoryInternal(price: price!) + } + + access(self) fun tryAddPriceToHistoryInternal(price: UFix64) { + // Check if enough time has passed since the last entry + if self.priceHistory.length > 0 { + let lastEntry = self.priceHistory[self.priceHistory.length - 1] + let timeSinceLastEntry = getCurrentBlock().timestamp - lastEntry.timestamp + if timeSinceLastEntry < self.priceHistoryInterval { + return + } + } + self.priceHistory.append(PriceHistoryEntry(price: price, timestamp: getCurrentBlock().timestamp)) if self.priceHistory.length > self.priceHistorySize { self.priceHistory.removeFirst() } @@ -150,11 +177,11 @@ access(all) contract FlowOracleAggregatorv1 { } access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { - access(all) let PriceOracleID: UInt64 + access(all) let priceOracleID: UInt64 access(contract) var uniqueID: DeFiActions.UniqueIdentifier? - init(PriceOracleID: UInt64) { - self.PriceOracleID = PriceOracleID + init(priceOracleID: UInt64) { + self.priceOracleID = priceOracleID self.uniqueID = nil } @@ -166,6 +193,10 @@ access(all) contract FlowOracleAggregatorv1 { return self.borrowPriceOracleAggregator().unit } + access(all) fun priceHistory(): &[PriceHistoryEntry] { + return self.borrowPriceOracleAggregator().priceHistory + } + access(all) view fun id(): UInt64 { return self.uniqueID!.id } @@ -191,7 +222,24 @@ access(all) contract FlowOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowOracleAggregatorv1.oracleAggregators[self.PriceOracleID])! + return (&FlowOracleAggregatorv1.oracleAggregators[self.priceOracleID])! + } + } + + access(all) resource PriceOracleCronHandler: FlowTransactionScheduler.TransactionHandler{ + access(all) let priceOracleID: UInt64 + + init(priceOracleID: UInt64) { + self.priceOracleID = priceOracleID + } + + access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { + let priceOracleAggregator = self.borrowPriceOracleAggregator() + priceOracleAggregator.tryAddPriceToHistory() + } + + access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { + return (&FlowOracleAggregatorv1.oracleAggregators[self.priceOracleID])! } } @@ -201,6 +249,7 @@ access(all) contract FlowOracleAggregatorv1 { maxGradient: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, unitOfAccount: Type, ): UInt64 { let priceOracleAggregator <- create PriceOracleAggregatorStorage( @@ -209,6 +258,7 @@ access(all) contract FlowOracleAggregatorv1 { maxGradient: maxGradient, priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, unitOfAccount: unitOfAccount ) let id = priceOracleAggregator.uuid @@ -218,7 +268,11 @@ access(all) contract FlowOracleAggregatorv1 { } access(all) fun createPriceOracleAggregator(id: UInt64): PriceOracleAggregator { - return PriceOracleAggregator(PriceOracleID: id) + return PriceOracleAggregator(priceOracleID: id) + } + + access(all) fun createPriceOracleCronHandler(id: UInt64): @PriceOracleCronHandler { + return <- create PriceOracleCronHandler(priceOracleID: id) } access(all) struct MinAndMaxPrices { diff --git a/cadence/tests/contracts/MultiMockOracle.cdc b/cadence/tests/contracts/MultiMockOracle.cdc index 22ccc95b..0632a85c 100644 --- a/cadence/tests/contracts/MultiMockOracle.cdc +++ b/cadence/tests/contracts/MultiMockOracle.cdc @@ -14,9 +14,9 @@ access(all) contract MultiMockOracle { access(all) resource PriceOracleStorage { access(contract) var unitOfAccountType: Type - access(contract) var prices: {Type: UFix64} + access(contract) var prices: {Type: UFix64?} - access(all) fun setPrice(forToken: Type, price: UFix64) { + access(all) fun setPrice(forToken: Type, price: UFix64?) { self.prices[forToken] = price } @@ -38,7 +38,7 @@ access(all) contract MultiMockOracle { if ofToken == self.borrowPriceOracleStorage().unitOfAccountType { return 1.0 } - return self.borrowPriceOracleStorage().prices[ofToken] + return self.borrowPriceOracleStorage().prices[ofToken] ?? nil } access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { @@ -80,7 +80,7 @@ access(all) contract MultiMockOracle { return &self.priceOracleStorages[priceOracleStorageID] } - access(all) fun setPrice(priceOracleStorageID: UInt64, forToken: Type, price: UFix64) { + access(all) fun setPrice(priceOracleStorageID: UInt64, forToken: Type, price: UFix64?) { let oracleStorage = self.borrowPriceOracleStorage(priceOracleStorageID: priceOracleStorageID)! oracleStorage.setPrice(forToken: forToken, price: price) } diff --git a/cadence/tests/oracle_aggregator_test.cdc b/cadence/tests/oracle_aggregator_test.cdc index 75109024..3b72d52d 100644 --- a/cadence/tests/oracle_aggregator_test.cdc +++ b/cadence/tests/oracle_aggregator_test.cdc @@ -2,7 +2,6 @@ import Test import BlockchainHelpers import "FlowOracleAggregatorv1" -import "DeFiActions" import "FlowToken" import "MOET" import "MultiMockOracle" @@ -10,12 +9,13 @@ import "test_helpers.cdc" import "test_helpers_oracle_aggregator.cdc" access(all) var snapshot: UInt64 = 0 -// access(all) var aggregatorID: UInt64 = 0 -// access(all) var aggregatorStruct: FlowOracleAggregatorV1.PriceOracleAggregatorStruct? = nil +access(all) var signer: Test.TestAccount = Test.getAccount(0x0000000000000001) access(all) fun setup() { deployContracts() + mintFlow(to: signer, amount: 100.0) snapshot = getCurrentBlockHeight() + Test.commitBlock() } access(all) fun beforeEach() { @@ -26,14 +26,21 @@ access(all) fun beforeEach() { access(all) fun test_single_oracle() { let info = createAggregator( + signer: signer, oracleCount: 1, maxSpread: 0.0, maxGradient: 0.0, priceHistorySize: 0, priceHistoryInterval: 0.0, - unitOfAccount: Type<@MOET.Vault>() + maxPriceHistoryAge: 0.0, + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "0 0 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - let prices = [1.0, 0.0001, 1337.0] + let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { setPrice( priceOracleStorageID: info.oracleIDs[0], @@ -44,23 +51,32 @@ access(all) fun test_single_oracle() { uuid: info.aggregatorID, ofToken: Type<@FlowToken.Vault>() ) - Test.assert(price != nil, message: "Price should not be nil") - Test.assertEqual(price!, p) + Test.assertEqual(price, p) } } access(all) fun test_multiple_oracles() { let oracleCounts = [1, 2, 3, 4, 5, 6] for oracleCount in oracleCounts { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } let info = createAggregator( + signer: signer, oracleCount: oracleCount, maxSpread: 0.0, maxGradient: 0.0, priceHistorySize: 0, priceHistoryInterval: 0.0, - unitOfAccount: Type<@MOET.Vault>() + maxPriceHistoryAge: 0.0, + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "0 0 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - let prices = [1.0, 0.0001, 1337.0] + let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { for oracleID in info.oracleIDs { setPrice( @@ -73,17 +89,16 @@ access(all) fun test_multiple_oracles() { uuid: info.aggregatorID, ofToken: Type<@FlowToken.Vault>() ) - Test.assert(price != nil, message: "Price should not be nil") - Test.assertEqual(price!, p) + Test.assertEqual(price, p) } } } access(all) struct TestRunAveragePrice { - access(all) let prices: [UFix64] + access(all) let prices: [UFix64?] access(all) let expectedPrice: UFix64? - init(prices: [UFix64], expectedPrice: UFix64?) { + init(prices: [UFix64?], expectedPrice: UFix64?) { self.prices = prices self.expectedPrice = expectedPrice } @@ -117,16 +132,39 @@ access(all) fun test_average_price() { ), TestRunAveragePrice( prices: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], expectedPrice: 3.5, + ), TestRunAveragePrice( + prices: [1.0, nil, 3.0, 4.0, 5.0, 6.0], + expectedPrice: nil, + ), TestRunAveragePrice( + prices: [1.0, 2.0, 3.0, 4.0, 5.0, nil], + expectedPrice: nil, )] - testRuns.appendAll(testRuns.reverse()) + let reversedRuns: [TestRunAveragePrice] = [] + for testRun in testRuns { + reversedRuns.append(TestRunAveragePrice( + prices: testRun.prices.reverse(), + expectedPrice: testRun.expectedPrice + )) + } + testRuns.appendAll(reversedRuns) for testRun in testRuns { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } let info = createAggregator( + signer: signer, oracleCount: testRun.prices.length, maxSpread: UFix64.max, maxGradient: UFix64.max, priceHistorySize: 0, priceHistoryInterval: 0.0, - unitOfAccount: Type<@MOET.Vault>() + maxPriceHistoryAge: 0.0, + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "0 0 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) set_prices(info: info, prices: testRun.prices) var price = getPrice( @@ -187,15 +225,33 @@ access(all) fun test_spread() { prices: [1.0, 1.5, 2.0], expectedPrice: 1.5, )] - testRuns.appendAll(testRuns.reverse()) + let reversedRuns: [TestRunSpread] = [] for testRun in testRuns { + reversedRuns.append(TestRunSpread( + maxSpread: testRun.maxSpread, + prices: testRun.prices.reverse(), + expectedPrice: testRun.expectedPrice, + )) + } + testRuns.appendAll(reversedRuns) + for testRun in testRuns { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } let info = createAggregator( + signer: signer, oracleCount: testRun.prices.length, maxSpread: testRun.maxSpread, maxGradient: UFix64.max, priceHistorySize: 0, priceHistoryInterval: 0.0, - unitOfAccount: Type<@MOET.Vault>() + maxPriceHistoryAge: 0.0, + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "0 0 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) set_prices(info: info, prices: testRun.prices) var price = getPrice( @@ -210,11 +266,195 @@ access(all) fun test_spread() { } } +access(all) struct TestRunGradient { + access(all) let maxGradient: UFix64 + access(all) let priceHistory: [UFix64] + access(all) let priceHistoryDelay: Fix64 + access(all) let isGradientStable: Bool + + init(maxGradient: UFix64, priceHistory: [UFix64], priceHistoryDelay: Fix64, isGradientStable: Bool) { + self.maxGradient = maxGradient + self.priceHistory = priceHistory + self.priceHistoryDelay = priceHistoryDelay + self.isGradientStable = isGradientStable + } +} + access(all) fun test_gradient() { - Test.assert(false, message: "not implemented") + let testRuns = [ + TestRunGradient( + maxGradient: 0.0, + priceHistory: [1.0], + priceHistoryDelay: 60.0, + isGradientStable: true, + ),TestRunGradient( + maxGradient: 100.0, + priceHistory: [1.0, 2.0], + priceHistoryDelay: 60.0, + isGradientStable: true, + ),TestRunGradient( + maxGradient: 95.0, + priceHistory: [1.0, 2.0], + priceHistoryDelay: 60.0, + isGradientStable: false, + ),TestRunGradient( + maxGradient: 100.0, + priceHistory: [1.0, 2.0, 3.1], + priceHistoryDelay: 60.0, + isGradientStable: false, + ),TestRunGradient( + maxGradient: 100.0, + priceHistory: [2.0, 1.0, 3.0, 2.0], + priceHistoryDelay: 60.0, + isGradientStable: true, + ),TestRunGradient( + maxGradient: 0.1, + priceHistory: [100.0, 100.1, 100.1, 100.1, 100.1, 100.2], + priceHistoryDelay: 60.0, + isGradientStable: true, + )] + let reversedRuns: [TestRunGradient] = [] + for testRun in testRuns { + reversedRuns.append(TestRunGradient( + maxGradient: testRun.maxGradient, + priceHistory: testRun.priceHistory.reverse(), + priceHistoryDelay: testRun.priceHistoryDelay, + isGradientStable: testRun.isGradientStable, + )) + } + testRuns.appendAll(reversedRuns) + for testRun in testRuns { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } + let info = createAggregator( + signer: signer, + oracleCount: 1, + maxSpread: UFix64.max, + maxGradient: testRun.maxGradient, + priceHistorySize: testRun.priceHistory.length, + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 600.0, // 10 minutes + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "* * 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler + ) + // need to move time to avoid race condition of the cron job + Test.moveTime(by: 10.0) + for price in testRun.priceHistory { + setPrice( + priceOracleStorageID: info.oracleIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: testRun.priceHistoryDelay) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + } + // make sure prices are correctly recorded + let priceHistory = getPriceHistory(uuid: info.aggregatorID,) + Test.assertEqual(testRun.priceHistory.length, priceHistory.length) + var i = 0 + for price in testRun.priceHistory { + Test.assertEqual(price, priceHistory[i].price) + i = i + 1 + } + + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + if priceIsStable != testRun.isGradientStable { + log(testRun) + log_fail_events() + Test.fail(message: "invalid price") + } + } +} + +access(self) fun test_gradient_incomplete_price_history() { + let priceHistory = [1.0, nil, nil, 4.0] + let info = createAggregator( + signer: signer, + oracleCount: 1, + maxSpread: UFix64.max, + maxGradient: 100.0, + priceHistorySize: priceHistory.length, + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 600.0, // 10 minutes + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "* * 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler + ) + Test.moveTime(by: 10.0) + for price in priceHistory { + setPrice( + priceOracleStorageID: info.oracleIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: 60.0) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + } + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + Test.assertEqual(priceIsStable, true) +} + +access(self) fun test_gradient_old_price_history() { + let priceHistory = [1.0, nil, nil, 40.0] + let info = createAggregator( + signer: signer, + oracleCount: 1, + maxSpread: UFix64.max, + maxGradient: 1.0, + priceHistorySize: priceHistory.length, + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 150.0, + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "* * 1 1 *", + cronHandlerStoragePath: /storage/cronHandler, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler + ) + Test.moveTime(by: 10.0) + for price in priceHistory { + setPrice( + priceOracleStorageID: info.oracleIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: 60.0) + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + } + var price = getPrice( + uuid: info.aggregatorID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + Test.assertEqual(priceIsStable, true) } -access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64]) { +access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?]) { var i = 0 for p in prices { setPrice( diff --git a/cadence/tests/scripts/oracle_aggregator_history.cdc b/cadence/tests/scripts/oracle_aggregator_history.cdc new file mode 100644 index 00000000..33c3bca3 --- /dev/null +++ b/cadence/tests/scripts/oracle_aggregator_history.cdc @@ -0,0 +1,6 @@ +import "FlowOracleAggregatorv1" + +access(all) fun main(uuid: UInt64): &[FlowOracleAggregatorv1.PriceHistoryEntry] { + let priceOracle = FlowOracleAggregatorv1.createPriceOracleAggregator(id: uuid) + return priceOracle.priceHistory() +} diff --git a/cadence/tests/test_helpers_oracle_aggregator.cdc b/cadence/tests/test_helpers_oracle_aggregator.cdc index e739e909..d0c4da46 100644 --- a/cadence/tests/test_helpers_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_oracle_aggregator.cdc @@ -32,17 +32,24 @@ access(all) struct CreateAggregatorInfo { } access(all) fun createAggregator( + signer: Test.TestAccount, oracleCount: Int, maxSpread: UFix64, maxGradient: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, unitOfAccount: Type, + cronExpression: String, + cronHandlerStoragePath: StoragePath, + keeperExecutionEffort: UInt64, + executorExecutionEffort: UInt64, + aggregatorCronHandlerStoragePath: StoragePath ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/oracle-aggregator/create_aggregator.cdc", - [oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, unitOfAccount], - [] + [oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [signer] ) Test.expect(res, Test.beSucceeded()) let aggregatorCreatedEvents = Test.eventsOfType(Type()) @@ -64,7 +71,7 @@ access(all) fun createAggregator( access(all) fun setPrice( priceOracleStorageID: UInt64, forToken: Type, - price: UFix64, + price: UFix64?, ) { let res = _executeTransaction( "./transactions/oracle-aggregator/set_price.cdc", @@ -92,4 +99,15 @@ access(all) fun getPrice( ) Test.expect(res2, Test.beSucceeded()) return res2.returnValue as! UFix64? +} + +access(all) fun getPriceHistory( + uuid: UInt64, +): [FlowOracleAggregatorv1.PriceHistoryEntry] { + let res = _executeScript( + "./scripts/oracle_aggregator_history.cdc", + [uuid] + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! [FlowOracleAggregatorv1.PriceHistoryEntry] } \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc b/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc index b4206312..a7c45d0f 100644 --- a/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc +++ b/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc @@ -1,6 +1,11 @@ import "DeFiActions" import "FlowOracleAggregatorv1" +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FungibleToken" import "FlowToken" +import "FlowFees" +import "FlowCron" import "MultiMockOracle" import "MOET" @@ -10,11 +15,21 @@ transaction( maxGradient: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, - unitOfAccount: Type + maxPriceHistoryAge: UFix64, + unitOfAccount: Type, + cronExpression: String, + cronHandlerStoragePath: StoragePath, + keeperExecutionEffort: UInt64, + executorExecutionEffort: UInt64, + aggregatorCronHandlerStoragePath: StoragePath ) { let oracles: [{DeFiActions.PriceOracle}] + let signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account + let feeProviderCap: Capability - prepare() { + prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) { + self.feeProviderCap = signer.capabilities.storage.issue(/storage/flowTokenVault) + self.signer = signer self.oracles = [] var i = 0 while i < oracleCount { @@ -30,7 +45,96 @@ transaction( maxGradient: maxGradient, priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, unitOfAccount: unitOfAccount ) + + // Create cron handler for the aggregator, look at flow-cron for reference + + let aggregatorCronHandler <- FlowOracleAggregatorv1.createPriceOracleCronHandler(id: uuid) + self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) + let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) + assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") + + self.signer.storage.save(<-FlowTransactionSchedulerUtils.createManager(), to: FlowTransactionSchedulerUtils.managerStoragePath) + let schedulerManagerCap = self.signer.capabilities.storage.issue( + FlowTransactionSchedulerUtils.managerStoragePath + ) + assert(schedulerManagerCap.check(), message: "Invalid scheduler manager capability") + let manager = self.signer.storage.borrow( + from: FlowTransactionSchedulerUtils.managerStoragePath + ) ?? panic("Cannot borrow manager") + + let cronHandler <- FlowCron.createCronHandler( + cronExpression: cronExpression, + wrappedHandlerCap: wrappedHandlerCap, + feeProviderCap: self.feeProviderCap, + schedulerManagerCap: schedulerManagerCap + ) + self.signer.storage.save(<-cronHandler, to: cronHandlerStoragePath) + let cronHandlerCap = self.signer.capabilities.storage.issue(cronHandlerStoragePath) + assert(cronHandlerCap.check(), message: "Invalid cron handler capability") + + // Use the official FlowFees calculation + let executorBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) + // Scale the execution fee by the multiplier for the priority + let executorScaledExecutionFee = executorBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowTransactionScheduler.Priority.Low]! + // Add inclusion Flow fee for scheduled transactions + let inclusionFee = 0.00001 + + let executorFlowFee = executorScaledExecutionFee + inclusionFee + + let keeperBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) + let keeperScaledExecutionFee = keeperBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowCron.keeperPriority]! + let keeperFlowFee = keeperScaledExecutionFee + inclusionFee + + let totalFee = executorFlowFee + keeperFlowFee + + // Borrow fee vault and check balance + let feeVault = self.signer.storage.borrow(from: /storage/flowTokenVault) + ?? panic("Flow token vault not found") + + if feeVault.balance < totalFee { + panic("Insufficient funds: required ".concat(totalFee.toString()).concat(" FLOW (executor: ").concat(executorFlowFee.toString()).concat(", keeper: ").concat(keeperFlowFee.toString()).concat("), available ").concat(feeVault.balance.toString())) + } + + // Withdraw fees for BOTH transactions + let executorFees <- feeVault.withdraw(amount: executorFlowFee) as! @FlowToken.Vault + let keeperFees <- feeVault.withdraw(amount: keeperFlowFee) as! @FlowToken.Vault + + let executorContext = FlowCron.CronContext( + executionMode: FlowCron.ExecutionMode.Executor, + executorPriority: FlowTransactionScheduler.Priority.Low, + executorExecutionEffort: executorExecutionEffort, + keeperExecutionEffort: keeperExecutionEffort, + wrappedData: nil + ) + + let executorTxID = manager.schedule( + handlerCap: cronHandlerCap, + data: executorContext, + timestamp: UFix64(getCurrentBlock().timestamp + 1.0), + priority: FlowTransactionScheduler.Priority.Low, + executionEffort: executorExecutionEffort, + fees: <-executorFees + ) + + let keeperContext = FlowCron.CronContext( + executionMode: FlowCron.ExecutionMode.Keeper, + executorPriority: FlowTransactionScheduler.Priority.Low, + executorExecutionEffort: executorExecutionEffort, + keeperExecutionEffort: keeperExecutionEffort, + wrappedData: nil + ) + + // Schedule KEEPER transaction (1 second after executor to prevent race conditions) + let keeperTxID = manager.schedule( + handlerCap: cronHandlerCap, + data: keeperContext, + timestamp: UFix64(getCurrentBlock().timestamp + 2.0), + priority: FlowCron.keeperPriority, + executionEffort: keeperExecutionEffort, + fees: <-keeperFees + ) } } \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/set_price.cdc b/cadence/tests/transactions/oracle-aggregator/set_price.cdc index 78497743..d375296f 100644 --- a/cadence/tests/transactions/oracle-aggregator/set_price.cdc +++ b/cadence/tests/transactions/oracle-aggregator/set_price.cdc @@ -3,7 +3,7 @@ import "MultiMockOracle" transaction( oracleStorageID: UInt64, forToken: Type, - price: UFix64, + price: UFix64?, ) { execute { MultiMockOracle.setPrice( From e6fff3e6ddf30b163b099ba8f6e0d69451f24e2b Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 18 Feb 2026 10:01:07 +0100 Subject: [PATCH 05/18] renaming --- ...V1.cdc => FlowPriceOracleAggregatorv1.cdc} | 6 +++--- ...t.cdc => price_oracle_aggregator_test.cdc} | 10 +++++----- .../scripts/oracle_aggregator_history.cdc | 6 ------ .../tests/scripts/oracle_aggregator_price.cdc | 6 ------ .../price-oracle-aggregator/history.cdc | 6 ++++++ .../scripts/price-oracle-aggregator/price.cdc | 6 ++++++ cadence/tests/test_helpers.cdc | 4 ++-- ... test_helpers_price_oracle_aggregator.cdc} | 20 +++++++++---------- .../create.cdc} | 6 +++--- .../get_price.cdc | 4 ++-- .../set_price.cdc | 0 flow.json | 4 ++-- 12 files changed, 39 insertions(+), 39 deletions(-) rename cadence/contracts/{FlowOracleAggregatorV1.cdc => FlowPriceOracleAggregatorv1.cdc} (97%) rename cadence/tests/{oracle_aggregator_test.cdc => price_oracle_aggregator_test.cdc} (98%) delete mode 100644 cadence/tests/scripts/oracle_aggregator_history.cdc delete mode 100644 cadence/tests/scripts/oracle_aggregator_price.cdc create mode 100644 cadence/tests/scripts/price-oracle-aggregator/history.cdc create mode 100644 cadence/tests/scripts/price-oracle-aggregator/price.cdc rename cadence/tests/{test_helpers_oracle_aggregator.cdc => test_helpers_price_oracle_aggregator.cdc} (82%) rename cadence/tests/transactions/{oracle-aggregator/create_aggregator.cdc => price-oracle-aggregator/create.cdc} (96%) rename cadence/tests/transactions/{oracle-aggregator => price-oracle-aggregator}/get_price.cdc (50%) rename cadence/tests/transactions/{oracle-aggregator => price-oracle-aggregator}/set_price.cdc (100%) diff --git a/cadence/contracts/FlowOracleAggregatorV1.cdc b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc similarity index 97% rename from cadence/contracts/FlowOracleAggregatorV1.cdc rename to cadence/contracts/FlowPriceOracleAggregatorv1.cdc index 9527e8ee..1048a648 100644 --- a/cadence/contracts/FlowOracleAggregatorV1.cdc +++ b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc @@ -2,7 +2,7 @@ import "FlowToken" import "DeFiActions" import "FlowTransactionScheduler" -access(all) contract FlowOracleAggregatorv1 { +access(all) contract FlowPriceOracleAggregatorv1 { access(all) entitlement Governance @@ -222,7 +222,7 @@ access(all) contract FlowOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowOracleAggregatorv1.oracleAggregators[self.priceOracleID])! + return (&FlowPriceOracleAggregatorv1.oracleAggregators[self.priceOracleID])! } } @@ -239,7 +239,7 @@ access(all) contract FlowOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowOracleAggregatorv1.oracleAggregators[self.priceOracleID])! + return (&FlowPriceOracleAggregatorv1.oracleAggregators[self.priceOracleID])! } } diff --git a/cadence/tests/oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc similarity index 98% rename from cadence/tests/oracle_aggregator_test.cdc rename to cadence/tests/price_oracle_aggregator_test.cdc index 3b72d52d..586a07b5 100644 --- a/cadence/tests/oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -1,12 +1,12 @@ import Test import BlockchainHelpers -import "FlowOracleAggregatorv1" +import "FlowPriceOracleAggregatorv1" import "FlowToken" import "MOET" import "MultiMockOracle" import "test_helpers.cdc" -import "test_helpers_oracle_aggregator.cdc" +import "test_helpers_price_oracle_aggregator.cdc" access(all) var snapshot: UInt64 = 0 access(all) var signer: Test.TestAccount = Test.getAccount(0x0000000000000001) @@ -468,9 +468,9 @@ access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?]) { access(self) fun log_fail_events() { let failureEvents = [ - Type(), - Type(), - Type() + Type(), + Type(), + Type() ] for eventType in failureEvents { let events = Test.eventsOfType(eventType) diff --git a/cadence/tests/scripts/oracle_aggregator_history.cdc b/cadence/tests/scripts/oracle_aggregator_history.cdc deleted file mode 100644 index 33c3bca3..00000000 --- a/cadence/tests/scripts/oracle_aggregator_history.cdc +++ /dev/null @@ -1,6 +0,0 @@ -import "FlowOracleAggregatorv1" - -access(all) fun main(uuid: UInt64): &[FlowOracleAggregatorv1.PriceHistoryEntry] { - let priceOracle = FlowOracleAggregatorv1.createPriceOracleAggregator(id: uuid) - return priceOracle.priceHistory() -} diff --git a/cadence/tests/scripts/oracle_aggregator_price.cdc b/cadence/tests/scripts/oracle_aggregator_price.cdc deleted file mode 100644 index 088448a5..00000000 --- a/cadence/tests/scripts/oracle_aggregator_price.cdc +++ /dev/null @@ -1,6 +0,0 @@ -import "FlowOracleAggregatorv1" - -access(all) fun main(uuid: UInt64, ofToken: Type): UFix64? { - let priceOracle = FlowOracleAggregatorv1.createPriceOracleAggregator(id: uuid) - return priceOracle.price(ofToken: ofToken) -} diff --git a/cadence/tests/scripts/price-oracle-aggregator/history.cdc b/cadence/tests/scripts/price-oracle-aggregator/history.cdc new file mode 100644 index 00000000..4273c7ba --- /dev/null +++ b/cadence/tests/scripts/price-oracle-aggregator/history.cdc @@ -0,0 +1,6 @@ +import "FlowPriceOracleAggregatorv1" + +access(all) fun main(uuid: UInt64): &[FlowPriceOracleAggregatorv1.PriceHistoryEntry] { + let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: uuid) + return priceOracle.priceHistory() +} diff --git a/cadence/tests/scripts/price-oracle-aggregator/price.cdc b/cadence/tests/scripts/price-oracle-aggregator/price.cdc new file mode 100644 index 00000000..a2dda781 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-aggregator/price.cdc @@ -0,0 +1,6 @@ +import "FlowPriceOracleAggregatorv1" + +access(all) fun main(uuid: UInt64, ofToken: Type): UFix64? { + let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: uuid) + return priceOracle.price(ofToken: ofToken) +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index b3ea6b57..88d5220a 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -119,8 +119,8 @@ fun deployContracts() { Test.expect(err, Test.beNil()) err = Test.deployContract( - name: "FlowOracleAggregatorv1", - path: "../contracts/FlowOracleAggregatorv1.cdc", + name: "FlowPriceOracleAggregatorv1", + path: "../contracts/FlowPriceOracleAggregatorV1.cdc", arguments: [] ) Test.expect(err, Test.beNil()) diff --git a/cadence/tests/test_helpers_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc similarity index 82% rename from cadence/tests/test_helpers_oracle_aggregator.cdc rename to cadence/tests/test_helpers_price_oracle_aggregator.cdc index d0c4da46..aa6b758b 100644 --- a/cadence/tests/test_helpers_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -1,6 +1,6 @@ import Test import "DeFiActions" -import "FlowOracleAggregatorv1" +import "FlowPriceOracleAggregatorv1" import "MultiMockOracle" access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { @@ -47,13 +47,13 @@ access(all) fun createAggregator( aggregatorCronHandlerStoragePath: StoragePath ): CreateAggregatorInfo { let res = _executeTransaction( - "./transactions/oracle-aggregator/create_aggregator.cdc", + "./transactions/price-oracle-aggregator/create.cdc", [oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) Test.expect(res, Test.beSucceeded()) - let aggregatorCreatedEvents = Test.eventsOfType(Type()) - let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowOracleAggregatorv1.AggregatorCreated + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.AggregatorCreated let oracleCreatedEvents = Test.eventsOfType(Type()) let oracleIDs: [UInt64] = [] var i = oracleCreatedEvents.length - oracleCount @@ -74,7 +74,7 @@ access(all) fun setPrice( price: UFix64?, ) { let res = _executeTransaction( - "./transactions/oracle-aggregator/set_price.cdc", + "./transactions/price-oracle-aggregator/set_price.cdc", [priceOracleStorageID, forToken, price], [] ) @@ -87,14 +87,14 @@ access(all) fun getPrice( ): UFix64? { // execute transaction to emit events let res = _executeTransaction( - "./transactions/oracle-aggregator/get_price.cdc", + "./transactions/price-oracle-aggregator/get_price.cdc", [uuid, ofToken], [] ) Test.expect(res, Test.beSucceeded()) // execute script to get price let res2 = _executeScript( - "./scripts/oracle_aggregator_price.cdc", + "./scripts/price-oracle-aggregator/price.cdc", [uuid, ofToken] ) Test.expect(res2, Test.beSucceeded()) @@ -103,11 +103,11 @@ access(all) fun getPrice( access(all) fun getPriceHistory( uuid: UInt64, -): [FlowOracleAggregatorv1.PriceHistoryEntry] { +): [FlowPriceOracleAggregatorv1.PriceHistoryEntry] { let res = _executeScript( - "./scripts/oracle_aggregator_history.cdc", + "./scripts/price-oracle-aggregator/history.cdc", [uuid] ) Test.expect(res, Test.beSucceeded()) - return res.returnValue as! [FlowOracleAggregatorv1.PriceHistoryEntry] + return res.returnValue as! [FlowPriceOracleAggregatorv1.PriceHistoryEntry] } \ No newline at end of file diff --git a/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc similarity index 96% rename from cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc rename to cadence/tests/transactions/price-oracle-aggregator/create.cdc index a7c45d0f..f70b2bdc 100644 --- a/cadence/tests/transactions/oracle-aggregator/create_aggregator.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -1,5 +1,5 @@ import "DeFiActions" -import "FlowOracleAggregatorv1" +import "FlowPriceOracleAggregatorv1" import "FlowTransactionScheduler" import "FlowTransactionSchedulerUtils" import "FungibleToken" @@ -39,7 +39,7 @@ transaction( } execute { - let uuid = FlowOracleAggregatorv1.createPriceOracleAggregatorStorage( + let uuid = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( oracles: self.oracles, maxSpread: maxSpread, maxGradient: maxGradient, @@ -51,7 +51,7 @@ transaction( // Create cron handler for the aggregator, look at flow-cron for reference - let aggregatorCronHandler <- FlowOracleAggregatorv1.createPriceOracleCronHandler(id: uuid) + let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(id: uuid) self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") diff --git a/cadence/tests/transactions/oracle-aggregator/get_price.cdc b/cadence/tests/transactions/price-oracle-aggregator/get_price.cdc similarity index 50% rename from cadence/tests/transactions/oracle-aggregator/get_price.cdc rename to cadence/tests/transactions/price-oracle-aggregator/get_price.cdc index 3df4f084..0f7d4804 100644 --- a/cadence/tests/transactions/oracle-aggregator/get_price.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/get_price.cdc @@ -1,11 +1,11 @@ -import "FlowOracleAggregatorv1" +import "FlowPriceOracleAggregatorv1" transaction( oracleStorageID: UInt64, forToken: Type, ) { execute { - FlowOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( + FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( ofToken: forToken, ) } diff --git a/cadence/tests/transactions/oracle-aggregator/set_price.cdc b/cadence/tests/transactions/price-oracle-aggregator/set_price.cdc similarity index 100% rename from cadence/tests/transactions/oracle-aggregator/set_price.cdc rename to cadence/tests/transactions/price-oracle-aggregator/set_price.cdc diff --git a/flow.json b/flow.json index facd6ec2..7509a0af 100644 --- a/flow.json +++ b/flow.json @@ -92,8 +92,8 @@ "testing": "0000000000000007" } }, - "FlowOracleAggregatorv1": { - "source": "./cadence/contracts/FlowOracleAggregatorv1.cdc", + "FlowPriceOracleAggregatorv1": { + "source": "./cadence/contracts/FlowPriceOracleAggregatorV1.cdc", "aliases": { "testing": "0000000000000007" } From e99076b202fe62e26a2e57eeb3e7c892cb7204c3 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 18 Feb 2026 10:57:11 +0100 Subject: [PATCH 06/18] add FlowPriceOracleRouterv1 --- .../contracts/FlowPriceOracleAggregatorv1.cdc | 2 +- cadence/contracts/FlowPriceOracleRouterv1.cdc | 78 +++++++++++++++++++ flow.json | 8 +- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 cadence/contracts/FlowPriceOracleRouterv1.cdc diff --git a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc index 1048a648..ff8c5cb3 100644 --- a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc +++ b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc @@ -182,7 +182,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { init(priceOracleID: UInt64) { self.priceOracleID = priceOracleID - self.uniqueID = nil + self.uniqueID = DeFiActions.createUniqueIdentifier() } access(all) fun price(ofToken: Type): UFix64? { diff --git a/cadence/contracts/FlowPriceOracleRouterv1.cdc b/cadence/contracts/FlowPriceOracleRouterv1.cdc new file mode 100644 index 00000000..55200653 --- /dev/null +++ b/cadence/contracts/FlowPriceOracleRouterv1.cdc @@ -0,0 +1,78 @@ +import "DeFiActions" + +access(all) contract FlowPriceOracleRouterv1 { + + access(all) entitlement Governance + + access(all) struct PriceOracleRouter: DeFiActions.PriceOracle { + access(self) let oracles: {Type: {DeFiActions.PriceOracle}} + access(self) let unitOfAccountType: Type + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + + init(unitOfAccount: Type) { + self.unitOfAccountType = unitOfAccount + self.uniqueID = DeFiActions.createUniqueIdentifier() + self.oracles = {} + } + + access(all) fun price(ofToken: Type): UFix64? { + return nil + } + + access(all) fun addOracle(oracle: {DeFiActions.PriceOracle}, ofToken: Type) { + pre { + oracle.unitOfAccount() == self.unitOfAccountType: + "Oracle unit of account does not match router unit of account" + self.oracles[ofToken] == nil: + "Oracle already added" + } + self.oracles[ofToken] = oracle + } + + access(all) fun replaceOracle(oracle: {DeFiActions.PriceOracle}, ofToken: Type) { + pre { + oracle.unitOfAccount() == self.unitOfAccountType: + "Oracle unit of account does not match router unit of account" + self.oracles[ofToken] != nil: + "Oracle not added" + } + self.oracles[ofToken] = oracle + } + + access(all) fun removeOracle(ofToken: Type) { + self.oracles.remove(key: ofToken) + } + + access(all) view fun unitOfAccount(): Type { + return self.unitOfAccountType + } + + access(all) view fun id(): UInt64 { + return self.uniqueID!.id + } + + access(all) fun getID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { + return DeFiActions.ComponentInfo( + type: self.getType(), + id: self.id(), + innerComponents: [] + ) + } + + access(contract) fun setID(_ id: DeFiActions.UniqueIdentifier?) { + self.uniqueID = id + } + + access(contract) view fun copyID(): DeFiActions.UniqueIdentifier? { + return self.uniqueID + } + } + + access(all) fun createPriceOracleRouter(unitOfAccount: Type): PriceOracleRouter { + return PriceOracleRouter(unitOfAccount: unitOfAccount) + } +} \ No newline at end of file diff --git a/flow.json b/flow.json index 7509a0af..e46c6e79 100644 --- a/flow.json +++ b/flow.json @@ -93,7 +93,13 @@ } }, "FlowPriceOracleAggregatorv1": { - "source": "./cadence/contracts/FlowPriceOracleAggregatorV1.cdc", + "source": "./cadence/contracts/FlowPriceOracleAggregatorv1.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, + "FlowPriceOracleRouterv1": { + "source": "./cadence/contracts/FlowPriceOracleRouterv1.cdc", "aliases": { "testing": "0000000000000007" } From 31deb9caf7d16f89400ec3786cdd067124886ab6 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 18 Feb 2026 14:11:18 +0100 Subject: [PATCH 07/18] add flowPriceOracleRouterv1 tests --- OracleAggregatorArchitecture.md | 3 + cadence/contracts/FlowPriceOracleRouterv1.cdc | 44 +--- cadence/tests/contracts/ExampleToken1.cdc | 226 ++++++++++++++++++ cadence/tests/contracts/ExampleToken2.cdc | 226 ++++++++++++++++++ cadence/tests/contracts/MultiMockOracle.cdc | 6 +- cadence/tests/contracts/OracleStorage.cdc | 14 ++ .../tests/price_oracle_aggregator_test.cdc | 6 +- cadence/tests/price_oracle_router_test.cdc | 82 +++++++ .../scripts/price-oracle-router/price.cdc | 6 + cadence/tests/test_helpers.cdc | 28 +++ .../test_helpers_price_oracle_aggregator.cdc | 42 ++-- .../test_helpers_price_oracle_router.cdc | 56 +++++ .../set_price.cdc | 0 .../{get_price.cdc => add_oracle.cdc} | 0 .../price-oracle-aggregator/price.cdc | 12 + .../price-oracle-router/create.cdc | 27 +++ flow.json | 50 ++-- 17 files changed, 754 insertions(+), 74 deletions(-) create mode 100644 cadence/tests/contracts/ExampleToken1.cdc create mode 100644 cadence/tests/contracts/ExampleToken2.cdc create mode 100644 cadence/tests/contracts/OracleStorage.cdc create mode 100644 cadence/tests/price_oracle_router_test.cdc create mode 100644 cadence/tests/scripts/price-oracle-router/price.cdc create mode 100644 cadence/tests/test_helpers_price_oracle_router.cdc rename cadence/tests/transactions/{price-oracle-aggregator => multi-mock-oracle}/set_price.cdc (100%) rename cadence/tests/transactions/price-oracle-aggregator/{get_price.cdc => add_oracle.cdc} (100%) create mode 100644 cadence/tests/transactions/price-oracle-aggregator/price.cdc create mode 100644 cadence/tests/transactions/price-oracle-router/create.cdc diff --git a/OracleAggregatorArchitecture.md b/OracleAggregatorArchitecture.md index 35ff020f..0e6c2ea3 100644 --- a/OracleAggregatorArchitecture.md +++ b/OracleAggregatorArchitecture.md @@ -13,6 +13,9 @@ --- # Design draft: The following sections outline ideas that are still being designed. +Intentionally immutable to avoid bugs, through changing configs in production without testing. +If oracles change there should be an OracelChange event emitted from the ALP contract. + ## 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." diff --git a/cadence/contracts/FlowPriceOracleRouterv1.cdc b/cadence/contracts/FlowPriceOracleRouterv1.cdc index 55200653..bf052d10 100644 --- a/cadence/contracts/FlowPriceOracleRouterv1.cdc +++ b/cadence/contracts/FlowPriceOracleRouterv1.cdc @@ -2,45 +2,24 @@ import "DeFiActions" access(all) contract FlowPriceOracleRouterv1 { - access(all) entitlement Governance - access(all) struct PriceOracleRouter: DeFiActions.PriceOracle { access(self) let oracles: {Type: {DeFiActions.PriceOracle}} access(self) let unitOfAccountType: Type access(contract) var uniqueID: DeFiActions.UniqueIdentifier? - init(unitOfAccount: Type) { + init(unitOfAccount: Type, oracles: {Type: {DeFiActions.PriceOracle}}) { self.unitOfAccountType = unitOfAccount self.uniqueID = DeFiActions.createUniqueIdentifier() - self.oracles = {} - } - - access(all) fun price(ofToken: Type): UFix64? { - return nil - } - - access(all) fun addOracle(oracle: {DeFiActions.PriceOracle}, ofToken: Type) { - pre { - oracle.unitOfAccount() == self.unitOfAccountType: - "Oracle unit of account does not match router unit of account" - self.oracles[ofToken] == nil: - "Oracle already added" + for oracle in oracles.values { + if oracle.unitOfAccount() != unitOfAccount { + panic("Oracle unit of account does not match router unit of account") + } } - self.oracles[ofToken] = oracle + self.oracles = oracles } - access(all) fun replaceOracle(oracle: {DeFiActions.PriceOracle}, ofToken: Type) { - pre { - oracle.unitOfAccount() == self.unitOfAccountType: - "Oracle unit of account does not match router unit of account" - self.oracles[ofToken] != nil: - "Oracle not added" - } - self.oracles[ofToken] = oracle - } - - access(all) fun removeOracle(ofToken: Type) { - self.oracles.remove(key: ofToken) + access(all) fun price(ofToken: Type): UFix64? { + return self.oracles[ofToken]?.price(ofToken: ofToken) ?? nil } access(all) view fun unitOfAccount(): Type { @@ -72,7 +51,10 @@ access(all) contract FlowPriceOracleRouterv1 { } } - access(all) fun createPriceOracleRouter(unitOfAccount: Type): PriceOracleRouter { - return PriceOracleRouter(unitOfAccount: unitOfAccount) + access(all) fun createPriceOracleRouter( + unitOfAccount: Type, + oracles: {Type: {DeFiActions.PriceOracle}}, + ): PriceOracleRouter { + return PriceOracleRouter(unitOfAccount: unitOfAccount, oracles: oracles) } } \ No newline at end of file diff --git a/cadence/tests/contracts/ExampleToken1.cdc b/cadence/tests/contracts/ExampleToken1.cdc new file mode 100644 index 00000000..5e583636 --- /dev/null +++ b/cadence/tests/contracts/ExampleToken1.cdc @@ -0,0 +1,226 @@ +import "FungibleToken" +import "MetadataViews" +import "FungibleTokenMetadataViews" + +access(all) contract ExampleToken1: FungibleToken { + + /// The event that is emitted when new tokens are minted + access(all) event TokensMinted(amount: UFix64, type: String) + + /// Total supply of ExampleToken1s in existence + access(all) var totalSupply: UFix64 + + /// Storage and Public Paths + access(all) let VaultStoragePath: StoragePath + access(all) let VaultPublicPath: PublicPath + access(all) let ReceiverPublicPath: PublicPath + access(all) let AdminStoragePath: StoragePath + + access(all) view fun getContractViews(resourceType: Type?): [Type] { + return [ + Type(), + Type(), + Type(), + Type() + ] + } + + access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? { + switch viewType { + case Type(): + return FungibleTokenMetadataViews.FTView( + ftDisplay: self.resolveContractView(resourceType: nil, viewType: Type()) as! FungibleTokenMetadataViews.FTDisplay?, + ftVaultData: self.resolveContractView(resourceType: nil, viewType: Type()) as! FungibleTokenMetadataViews.FTVaultData? + ) + case Type(): + let media = MetadataViews.Media( + file: MetadataViews.HTTPFile( + url: "https://assets.website-files.com/5f6294c0c7a8cdd643b1c820/5f6294c0c7a8cda55cb1c936_Flow_Wordmark.svg" + ), + mediaType: "image/svg+xml" + ) + let medias = MetadataViews.Medias([media]) + return FungibleTokenMetadataViews.FTDisplay( + name: "Example Fungible Token", + symbol: "EFT", + description: "This fungible token is used as an example to help you develop your next FT #onFlow.", + externalURL: MetadataViews.ExternalURL("https://example-ft.onflow.org"), + logos: medias, + socials: { + "twitter": MetadataViews.ExternalURL("https://twitter.com/flow_blockchain") + } + ) + case Type(): + return FungibleTokenMetadataViews.FTVaultData( + storagePath: self.VaultStoragePath, + receiverPath: self.ReceiverPublicPath, + metadataPath: self.VaultPublicPath, + receiverLinkedType: Type<&ExampleToken1.Vault>(), + metadataLinkedType: Type<&ExampleToken1.Vault>(), + createEmptyVaultFunction: (fun(): @{FungibleToken.Vault} { + return <-ExampleToken1.createEmptyVault(vaultType: Type<@ExampleToken1.Vault>()) + }) + ) + case Type(): + return FungibleTokenMetadataViews.TotalSupply( + totalSupply: ExampleToken1.totalSupply + ) + } + return nil + } + + /// Vault + /// + /// Each user stores an instance of only the Vault in their storage + /// The functions in the Vault and governed by the pre and post conditions + /// in FungibleToken when they are called. + /// The checks happen at runtime whenever a function is called. + /// + /// Resources can only be created in the context of the contract that they + /// are defined in, so there is no way for a malicious user to create Vaults + /// out of thin air. A special Minter resource needs to be defined to mint + /// new tokens. + /// + access(all) resource Vault: FungibleToken.Vault { + + /// The total balance of this vault + access(all) var balance: UFix64 + + // initialize the balance at resource creation time + init(balance: UFix64) { + self.balance = balance + } + + /// Called when a fungible token is burned via the `Burner.burn()` method + access(contract) fun burnCallback() { + if self.balance > 0.0 { + ExampleToken1.totalSupply = ExampleToken1.totalSupply - self.balance + } + self.balance = 0.0 + } + + /// In fungible tokens, there are no specific views for specific vaults, + /// So we can route calls to view functions to the contract views functions + access(all) view fun getViews(): [Type] { + return ExampleToken1.getContractViews(resourceType: nil) + } + + access(all) fun resolveView(_ view: Type): AnyStruct? { + return ExampleToken1.resolveContractView(resourceType: nil, viewType: view) + } + + /// getSupportedVaultTypes optionally returns a list of vault types that this receiver accepts + access(all) view fun getSupportedVaultTypes(): {Type: Bool} { + let supportedTypes: {Type: Bool} = {} + supportedTypes[self.getType()] = true + return supportedTypes + } + + access(all) view fun isSupportedVaultType(type: Type): Bool { + return self.getSupportedVaultTypes()[type] ?? false + } + + /// Asks if the amount can be withdrawn from this vault + access(all) view fun isAvailableToWithdraw(amount: UFix64): Bool { + return amount <= self.balance + } + + /// withdraw + /// + /// Function that takes an amount as an argument + /// and withdraws that amount from the Vault. + /// + /// It creates a new temporary Vault that is used to hold + /// the tokens that are being transferred. It returns the newly + /// created Vault to the context that called so it can be deposited + /// elsewhere. + /// + access(FungibleToken.Withdraw) fun withdraw(amount: UFix64): @ExampleToken1.Vault { + self.balance = self.balance - amount + return <-create Vault(balance: amount) + } + + /// deposit + /// + /// Function that takes a Vault object as an argument and adds + /// its balance to the balance of the owners Vault. + /// + /// It is allowed to destroy the sent Vault because the Vault + /// was a temporary holder of the tokens. The Vault's balance has + /// been consumed and therefore can be destroyed. + /// + access(all) fun deposit(from: @{FungibleToken.Vault}) { + let vault <- from as! @ExampleToken1.Vault + self.balance = self.balance + vault.balance + destroy vault + } + + /// createEmptyVault + /// + /// Function that creates a new Vault with a balance of zero + /// and returns it to the calling context. A user must call this function + /// and store the returned Vault in their storage in order to allow their + /// account to be able to receive deposits of this token type. + /// + access(all) fun createEmptyVault(): @ExampleToken1.Vault { + return <-create Vault(balance: 0.0) + } + } + + /// Minter + /// + /// Resource object that token admin accounts can hold to mint new tokens. + /// + access(all) resource Minter { + /// mintTokens + /// + /// Function that mints new tokens, adds them to the total supply, + /// and returns them to the calling context. + /// + access(all) fun mintTokens(amount: UFix64): @ExampleToken1.Vault { + ExampleToken1.totalSupply = ExampleToken1.totalSupply + amount + let vault <-create Vault(balance: amount) + emit TokensMinted(amount: amount, type: vault.getType().identifier) + return <-vault + } + } + + /// createEmptyVault + /// + /// Function that creates a new Vault with a balance of zero + /// and returns it to the calling context. A user must call this function + /// and store the returned Vault in their storage in order to allow their + /// account to be able to receive deposits of this token type. + /// + access(all) fun createEmptyVault(vaultType: Type): @ExampleToken1.Vault { + return <- create Vault(balance: 0.0) + } + + init() { + self.totalSupply = 0.0 + + self.VaultStoragePath = /storage/ExampleToken1Vault + self.VaultPublicPath = /public/ExampleToken1Vault + self.ReceiverPublicPath = /public/ExampleToken1Receiver + self.AdminStoragePath = /storage/ExampleToken1Admin + + let admin <- create Minter() + + // Create the Vault with the total supply of tokens and save it in storage + // + let vault <- admin.mintTokens(amount: 1000.0) + + self.account.storage.save(<-vault, to: self.VaultStoragePath) + + self.account.storage.save(<-admin, to: self.AdminStoragePath) + + // Create a public capability to the stored Vault that exposes + // the `deposit` method and getAcceptedTypes method through the `Receiver` interface + // and the `balance` method through the `Balance` interface + // + let ExampleToken1Cap = self.account.capabilities.storage.issue<&ExampleToken1.Vault>(self.VaultStoragePath) + self.account.capabilities.publish(ExampleToken1Cap, at: self.VaultPublicPath) + let receiverCap = self.account.capabilities.storage.issue<&ExampleToken1.Vault>(self.VaultStoragePath) + self.account.capabilities.publish(receiverCap, at: self.ReceiverPublicPath) + } +} \ No newline at end of file diff --git a/cadence/tests/contracts/ExampleToken2.cdc b/cadence/tests/contracts/ExampleToken2.cdc new file mode 100644 index 00000000..0c6c698c --- /dev/null +++ b/cadence/tests/contracts/ExampleToken2.cdc @@ -0,0 +1,226 @@ +import "FungibleToken" +import "MetadataViews" +import "FungibleTokenMetadataViews" + +access(all) contract ExampleToken2: FungibleToken { + + /// The event that is emitted when new tokens are minted + access(all) event TokensMinted(amount: UFix64, type: String) + + /// Total supply of ExampleToken2s in existence + access(all) var totalSupply: UFix64 + + /// Storage and Public Paths + access(all) let VaultStoragePath: StoragePath + access(all) let VaultPublicPath: PublicPath + access(all) let ReceiverPublicPath: PublicPath + access(all) let AdminStoragePath: StoragePath + + access(all) view fun getContractViews(resourceType: Type?): [Type] { + return [ + Type(), + Type(), + Type(), + Type() + ] + } + + access(all) fun resolveContractView(resourceType: Type?, viewType: Type): AnyStruct? { + switch viewType { + case Type(): + return FungibleTokenMetadataViews.FTView( + ftDisplay: self.resolveContractView(resourceType: nil, viewType: Type()) as! FungibleTokenMetadataViews.FTDisplay?, + ftVaultData: self.resolveContractView(resourceType: nil, viewType: Type()) as! FungibleTokenMetadataViews.FTVaultData? + ) + case Type(): + let media = MetadataViews.Media( + file: MetadataViews.HTTPFile( + url: "https://assets.website-files.com/5f6294c0c7a8cdd643b1c820/5f6294c0c7a8cda55cb1c936_Flow_Wordmark.svg" + ), + mediaType: "image/svg+xml" + ) + let medias = MetadataViews.Medias([media]) + return FungibleTokenMetadataViews.FTDisplay( + name: "Example Fungible Token", + symbol: "EFT", + description: "This fungible token is used as an example to help you develop your next FT #onFlow.", + externalURL: MetadataViews.ExternalURL("https://example-ft.onflow.org"), + logos: medias, + socials: { + "twitter": MetadataViews.ExternalURL("https://twitter.com/flow_blockchain") + } + ) + case Type(): + return FungibleTokenMetadataViews.FTVaultData( + storagePath: self.VaultStoragePath, + receiverPath: self.ReceiverPublicPath, + metadataPath: self.VaultPublicPath, + receiverLinkedType: Type<&ExampleToken2.Vault>(), + metadataLinkedType: Type<&ExampleToken2.Vault>(), + createEmptyVaultFunction: (fun(): @{FungibleToken.Vault} { + return <-ExampleToken2.createEmptyVault(vaultType: Type<@ExampleToken2.Vault>()) + }) + ) + case Type(): + return FungibleTokenMetadataViews.TotalSupply( + totalSupply: ExampleToken2.totalSupply + ) + } + return nil + } + + /// Vault + /// + /// Each user stores an instance of only the Vault in their storage + /// The functions in the Vault and governed by the pre and post conditions + /// in FungibleToken when they are called. + /// The checks happen at runtime whenever a function is called. + /// + /// Resources can only be created in the context of the contract that they + /// are defined in, so there is no way for a malicious user to create Vaults + /// out of thin air. A special Minter resource needs to be defined to mint + /// new tokens. + /// + access(all) resource Vault: FungibleToken.Vault { + + /// The total balance of this vault + access(all) var balance: UFix64 + + // initialize the balance at resource creation time + init(balance: UFix64) { + self.balance = balance + } + + /// Called when a fungible token is burned via the `Burner.burn()` method + access(contract) fun burnCallback() { + if self.balance > 0.0 { + ExampleToken2.totalSupply = ExampleToken2.totalSupply - self.balance + } + self.balance = 0.0 + } + + /// In fungible tokens, there are no specific views for specific vaults, + /// So we can route calls to view functions to the contract views functions + access(all) view fun getViews(): [Type] { + return ExampleToken2.getContractViews(resourceType: nil) + } + + access(all) fun resolveView(_ view: Type): AnyStruct? { + return ExampleToken2.resolveContractView(resourceType: nil, viewType: view) + } + + /// getSupportedVaultTypes optionally returns a list of vault types that this receiver accepts + access(all) view fun getSupportedVaultTypes(): {Type: Bool} { + let supportedTypes: {Type: Bool} = {} + supportedTypes[self.getType()] = true + return supportedTypes + } + + access(all) view fun isSupportedVaultType(type: Type): Bool { + return self.getSupportedVaultTypes()[type] ?? false + } + + /// Asks if the amount can be withdrawn from this vault + access(all) view fun isAvailableToWithdraw(amount: UFix64): Bool { + return amount <= self.balance + } + + /// withdraw + /// + /// Function that takes an amount as an argument + /// and withdraws that amount from the Vault. + /// + /// It creates a new temporary Vault that is used to hold + /// the tokens that are being transferred. It returns the newly + /// created Vault to the context that called so it can be deposited + /// elsewhere. + /// + access(FungibleToken.Withdraw) fun withdraw(amount: UFix64): @ExampleToken2.Vault { + self.balance = self.balance - amount + return <-create Vault(balance: amount) + } + + /// deposit + /// + /// Function that takes a Vault object as an argument and adds + /// its balance to the balance of the owners Vault. + /// + /// It is allowed to destroy the sent Vault because the Vault + /// was a temporary holder of the tokens. The Vault's balance has + /// been consumed and therefore can be destroyed. + /// + access(all) fun deposit(from: @{FungibleToken.Vault}) { + let vault <- from as! @ExampleToken2.Vault + self.balance = self.balance + vault.balance + destroy vault + } + + /// createEmptyVault + /// + /// Function that creates a new Vault with a balance of zero + /// and returns it to the calling context. A user must call this function + /// and store the returned Vault in their storage in order to allow their + /// account to be able to receive deposits of this token type. + /// + access(all) fun createEmptyVault(): @ExampleToken2.Vault { + return <-create Vault(balance: 0.0) + } + } + + /// Minter + /// + /// Resource object that token admin accounts can hold to mint new tokens. + /// + access(all) resource Minter { + /// mintTokens + /// + /// Function that mints new tokens, adds them to the total supply, + /// and returns them to the calling context. + /// + access(all) fun mintTokens(amount: UFix64): @ExampleToken2.Vault { + ExampleToken2.totalSupply = ExampleToken2.totalSupply + amount + let vault <-create Vault(balance: amount) + emit TokensMinted(amount: amount, type: vault.getType().identifier) + return <-vault + } + } + + /// createEmptyVault + /// + /// Function that creates a new Vault with a balance of zero + /// and returns it to the calling context. A user must call this function + /// and store the returned Vault in their storage in order to allow their + /// account to be able to receive deposits of this token type. + /// + access(all) fun createEmptyVault(vaultType: Type): @ExampleToken2.Vault { + return <- create Vault(balance: 0.0) + } + + init() { + self.totalSupply = 0.0 + + self.VaultStoragePath = /storage/ExampleToken2Vault + self.VaultPublicPath = /public/ExampleToken2Vault + self.ReceiverPublicPath = /public/ExampleToken2Receiver + self.AdminStoragePath = /storage/ExampleToken2Admin + + let admin <- create Minter() + + // Create the Vault with the total supply of tokens and save it in storage + // + let vault <- admin.mintTokens(amount: 1000.0) + + self.account.storage.save(<-vault, to: self.VaultStoragePath) + + self.account.storage.save(<-admin, to: self.AdminStoragePath) + + // Create a public capability to the stored Vault that exposes + // the `deposit` method and getAcceptedTypes method through the `Receiver` interface + // and the `balance` method through the `Balance` interface + // + let ExampleToken2Cap = self.account.capabilities.storage.issue<&ExampleToken2.Vault>(self.VaultStoragePath) + self.account.capabilities.publish(ExampleToken2Cap, at: self.VaultPublicPath) + let receiverCap = self.account.capabilities.storage.issue<&ExampleToken2.Vault>(self.VaultStoragePath) + self.account.capabilities.publish(receiverCap, at: self.ReceiverPublicPath) + } +} \ No newline at end of file diff --git a/cadence/tests/contracts/MultiMockOracle.cdc b/cadence/tests/contracts/MultiMockOracle.cdc index 0632a85c..3d144a2f 100644 --- a/cadence/tests/contracts/MultiMockOracle.cdc +++ b/cadence/tests/contracts/MultiMockOracle.cdc @@ -27,7 +27,7 @@ access(all) contract MultiMockOracle { } access(all) struct PriceOracle : DeFiActions.PriceOracle { - access(contract) var priceOracleStorageID: UInt64 + access(all) var priceOracleStorageID: UInt64 access(contract) var uniqueID: DeFiActions.UniqueIdentifier? access(all) view fun unitOfAccount(): Type { @@ -41,6 +41,10 @@ access(all) contract MultiMockOracle { return self.borrowPriceOracleStorage().prices[ofToken] ?? nil } + access(all) fun setPrice(forToken: Type, price: UFix64?) { + self.borrowPriceOracleStorage().setPrice(forToken: forToken, price: price) + } + access(all) fun getComponentInfo(): DeFiActions.ComponentInfo { return DeFiActions.ComponentInfo( type: self.getType(), diff --git a/cadence/tests/contracts/OracleStorage.cdc b/cadence/tests/contracts/OracleStorage.cdc new file mode 100644 index 00000000..eb6183c4 --- /dev/null +++ b/cadence/tests/contracts/OracleStorage.cdc @@ -0,0 +1,14 @@ +import "DeFiActions" + +access(all) contract OracleStorage { + + access(all) var oracle: {DeFiActions.PriceOracle}? + + init() { + self.oracle = nil + } + + access(all) fun saveOracle(oracle: {DeFiActions.PriceOracle}) { + self.oracle = oracle + } +} diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 586a07b5..9fd586bb 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -15,13 +15,11 @@ access(all) fun setup() { deployContracts() mintFlow(to: signer, amount: 100.0) snapshot = getCurrentBlockHeight() - Test.commitBlock() } access(all) fun beforeEach() { - if snapshot != getCurrentBlockHeight() { - Test.reset(to: snapshot) - } + Test.commitBlock() + Test.reset(to: snapshot) } access(all) fun test_single_oracle() { diff --git a/cadence/tests/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc new file mode 100644 index 00000000..cd1959a7 --- /dev/null +++ b/cadence/tests/price_oracle_router_test.cdc @@ -0,0 +1,82 @@ +import Test +import BlockchainHelpers + +import "FlowPriceOracleRouterv1" +import "FlowToken" +import "MOET" +import "ExampleToken1" +import "ExampleToken2" +import "test_helpers.cdc" +import "test_helpers_price_oracle_router.cdc" + +access(all) var snapshot: UInt64 = 0 + +access(all) fun setup() { + deployContracts() + snapshot = getCurrentBlockHeight() +} + +access(all) fun beforeEach() { + Test.commitBlock() + Test.reset(to: snapshot) +} + +access(all) fun test_router_add_oracle() { + let info = [ + createRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ) + ] + createRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: info, + expectSucceeded: true + ) + Test.assertEqual(price(ofToken: Type<@FlowToken.Vault>()), 1.0 as UFix64?) + Test.assertEqual(price(ofToken: Type<@ExampleToken1.Vault>()), nil as UFix64?) +} + +access(all) fun test_router_add_multiple_oracles() { + let info = [ + createRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ), + createRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@ExampleToken1.Vault>(), + prices: 2.0 + ), + createRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@ExampleToken2.Vault>(), + prices: 3.0 + ) + ] + createRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: info, + expectSucceeded: true + ) + Test.assertEqual(price(ofToken: Type<@FlowToken.Vault>()), 1.0 as UFix64?) + Test.assertEqual(price(ofToken: Type<@ExampleToken1.Vault>()), 2.0 as UFix64?) + Test.assertEqual(price(ofToken: Type<@ExampleToken2.Vault>()), 3.0 as UFix64?) +} + +access(all) fun test_router_add_wrong_unit_of_account() { + let createRouterInfo = [ + createRouterInfo( + unitOfAccount: Type<@ExampleToken1.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ) + ] + createRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: createRouterInfo, + expectSucceeded: false + ) +} \ No newline at end of file diff --git a/cadence/tests/scripts/price-oracle-router/price.cdc b/cadence/tests/scripts/price-oracle-router/price.cdc new file mode 100644 index 00000000..91978be5 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-router/price.cdc @@ -0,0 +1,6 @@ +import "OracleStorage" + +access(all) fun main(ofToken: Type): UFix64? { + let oracle = OracleStorage.oracle! + return oracle.price(ofToken: ofToken) +} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 88d5220a..c1b93f0d 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -204,6 +204,34 @@ fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "FlowPriceOracleRouterv1", + path: "../contracts/FlowPriceOracleRouterv1.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "OracleStorage", + path: "./contracts/OracleStorage.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "ExampleToken1", + path: "./contracts/ExampleToken1.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "ExampleToken2", + path: "./contracts/ExampleToken2.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) } /* --- Script Helpers --- */ diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index aa6b758b..98891fa8 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -3,24 +3,6 @@ import "DeFiActions" import "FlowPriceOracleAggregatorv1" import "MultiMockOracle" -access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { - let authorizers: [Address] = [] - for signer in signers { - authorizers.append(signer.address) - } - let txn = Test.Transaction( - code: Test.readFile(path), - authorizers: authorizers, - signers: signers, - arguments: args, - ) - return Test.executeTransaction(txn) -} - -access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { - return Test.executeScript(Test.readFile(path), args) -} - access(all) struct CreateAggregatorInfo { access(all) let aggregatorID: UInt64 access(all) let oracleIDs: [UInt64] @@ -74,7 +56,7 @@ access(all) fun setPrice( price: UFix64?, ) { let res = _executeTransaction( - "./transactions/price-oracle-aggregator/set_price.cdc", + "./transactions/multi-mock-oracle/set_price.cdc", [priceOracleStorageID, forToken, price], [] ) @@ -87,7 +69,7 @@ access(all) fun getPrice( ): UFix64? { // execute transaction to emit events let res = _executeTransaction( - "./transactions/price-oracle-aggregator/get_price.cdc", + "./transactions/price-oracle-aggregator/price.cdc", [uuid, ofToken], [] ) @@ -110,4 +92,24 @@ access(all) fun getPriceHistory( ) Test.expect(res, Test.beSucceeded()) return res.returnValue as! [FlowPriceOracleAggregatorv1.PriceHistoryEntry] +} + +// --- Helper Functions --- + +access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { + let authorizers: [Address] = [] + for signer in signers { + authorizers.append(signer.address) + } + let txn = Test.Transaction( + code: Test.readFile(path), + authorizers: authorizers, + signers: signers, + arguments: args, + ) + return Test.executeTransaction(txn) +} + +access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) } \ No newline at end of file diff --git a/cadence/tests/test_helpers_price_oracle_router.cdc b/cadence/tests/test_helpers_price_oracle_router.cdc new file mode 100644 index 00000000..60f8e1cd --- /dev/null +++ b/cadence/tests/test_helpers_price_oracle_router.cdc @@ -0,0 +1,56 @@ +import Test + +access(all) fun createRouter( + unitOfAccount: Type, + createRouterInfo: [{String: AnyStruct}], + expectSucceeded: Bool +) { + let res = _executeTransaction( + "./transactions/price-oracle-router/create.cdc", + [unitOfAccount, createRouterInfo], + [] + ) + Test.expect(res, expectSucceeded ? Test.beSucceeded() : Test.beFailed()) +} + +// need this because can't define struct here to pass to transaction +access(all) fun createRouterInfo( + unitOfAccount: Type, + oracleOfToken: Type, + prices: UFix64? +): {String: AnyStruct} { + return { + "unitOfAccount": unitOfAccount, + "oracleOfToken": oracleOfToken, + "price": prices + } +} + +access(all) fun price(ofToken: Type): UFix64? { + let res = _executeScript( + "./scripts/price-oracle-router/price.cdc", + [ofToken], + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! UFix64? +} + +// --- Helper Functions --- + +access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { + let authorizers: [Address] = [] + for signer in signers { + authorizers.append(signer.address) + } + let txn = Test.Transaction( + code: Test.readFile(path), + authorizers: authorizers, + signers: signers, + arguments: args, + ) + return Test.executeTransaction(txn) +} + +access(self) fun _executeScript(_ path: String, _ args: [AnyStruct]): Test.ScriptResult { + return Test.executeScript(Test.readFile(path), args) +} \ No newline at end of file diff --git a/cadence/tests/transactions/price-oracle-aggregator/set_price.cdc b/cadence/tests/transactions/multi-mock-oracle/set_price.cdc similarity index 100% rename from cadence/tests/transactions/price-oracle-aggregator/set_price.cdc rename to cadence/tests/transactions/multi-mock-oracle/set_price.cdc diff --git a/cadence/tests/transactions/price-oracle-aggregator/get_price.cdc b/cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc similarity index 100% rename from cadence/tests/transactions/price-oracle-aggregator/get_price.cdc rename to cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc diff --git a/cadence/tests/transactions/price-oracle-aggregator/price.cdc b/cadence/tests/transactions/price-oracle-aggregator/price.cdc new file mode 100644 index 00000000..0f7d4804 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/price.cdc @@ -0,0 +1,12 @@ +import "FlowPriceOracleAggregatorv1" + +transaction( + oracleStorageID: UInt64, + forToken: Type, +) { + execute { + FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( + ofToken: forToken, + ) + } +} \ No newline at end of file diff --git a/cadence/tests/transactions/price-oracle-router/create.cdc b/cadence/tests/transactions/price-oracle-router/create.cdc new file mode 100644 index 00000000..00899d80 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-router/create.cdc @@ -0,0 +1,27 @@ +import "FlowPriceOracleRouterv1" +import "OracleStorage" +import "DeFiActions" +import "MultiMockOracle" + +transaction( + unitOfAccount: Type, + createRouterInfo: [{String: AnyStruct}], +) { + execute { + let oracles: {Type: {DeFiActions.PriceOracle}} = {} + for info in createRouterInfo { + // have to do this because transactions can't define structs? + let unitOfAccount = info["unitOfAccount"] as! Type + let oracleOfToken = info["oracleOfToken"] as! Type + let price = info["price"] as! UFix64? + let oracle = MultiMockOracle.createPriceOracle(unitOfAccountType: unitOfAccount) + oracle.setPrice(forToken: oracleOfToken, price: price) + oracles[oracleOfToken] = oracle + } + let router = FlowPriceOracleRouterv1.createPriceOracleRouter( + unitOfAccount: unitOfAccount, + oracles: oracles, + ) + OracleStorage.saveOracle(oracle: router) + } +} \ No newline at end of file diff --git a/flow.json b/flow.json index e46c6e79..eb23ea1c 100644 --- a/flow.json +++ b/flow.json @@ -76,30 +76,26 @@ "testing": "0000000000000007" } }, - "FungibleTokenConnectors": { - "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", + "FlowPriceOracleAggregatorv1": { + "source": "./cadence/contracts/FlowPriceOracleAggregatorv1.cdc", "aliases": { - "emulator": "045a1763c93006ca", - "mainnet": "0c237e1265caa7a3", - "mainnet-fork": "0c237e1265caa7a3", - "testing": "0000000000000006", - "testnet": "4cd02f8de4122c84" + "testing": "0000000000000007" } }, - "MOET": { - "source": "./cadence/contracts/MOET.cdc", + "FlowPriceOracleRouterv1": { + "source": "./cadence/contracts/FlowPriceOracleRouterv1.cdc", "aliases": { "testing": "0000000000000007" } }, - "FlowPriceOracleAggregatorv1": { - "source": "./cadence/contracts/FlowPriceOracleAggregatorv1.cdc", + "FungibleTokenConnectors": { + "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", "aliases": { - "testing": "0000000000000007" + "testing": "0000000000000006" } }, - "FlowPriceOracleRouterv1": { - "source": "./cadence/contracts/FlowPriceOracleRouterv1.cdc", + "MOET": { + "source": "./cadence/contracts/MOET.cdc", "aliases": { "testing": "0000000000000007" } @@ -118,16 +114,34 @@ "testnet": "d2580caf2ef07c2f" } }, + "MockYieldToken": { + "source": "./cadence/contracts/mocks/MockYieldToken.cdc", + "aliases": { + "testing": "0000000000000007" + } + }, "MultiMockOracle": { "source": "./cadence/tests/contracts/MultiMockOracle.cdc", "aliases": { "testing": "0000000000000007" } }, - "MockYieldToken": { - "source": "./cadence/contracts/mocks/MockYieldToken.cdc", + "OracleStorage": { + "source": "./cadence/tests/contracts/OracleStorage.cdc", "aliases": { - "testing": "0000000000000007" + "testing": "0000000000000008" + } + }, + "ExampleToken1": { + "source": "./cadence/tests/contracts/ExampleToken1.cdc", + "aliases": { + "testing": "0000000000000008" + } + }, + "ExampleToken2": { + "source": "./cadence/tests/contracts/ExampleToken2.cdc", + "aliases": { + "testing": "0000000000000008" } } }, @@ -430,4 +444,4 @@ ] } } -} +} \ No newline at end of file From 8ff93002487b747bb0b42405dccf6bc8700bad72 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 18 Feb 2026 20:31:50 +0100 Subject: [PATCH 08/18] improve documentation --- OracleAggregatorArchitecture.md | 59 ------ PriceOracleArchitecture.md | 96 +++++++++ .../contracts/FlowPriceOracleAggregatorv1.cdc | 183 ++++++++++++------ cadence/contracts/FlowPriceOracleRouterv1.cdc | 14 ++ cadence/tests/contracts/ExampleToken1.cdc | 1 + cadence/tests/contracts/ExampleToken2.cdc | 1 + cadence/tests/contracts/MultiMockOracle.cdc | 33 ++-- cadence/tests/contracts/OracleStorage.cdc | 3 + .../tests/price_oracle_aggregator_test.cdc | 81 ++++---- cadence/tests/price_oracle_router_test.cdc | 37 ++-- .../price-oracle-aggregator/history.cdc | 4 +- .../scripts/price-oracle-aggregator/price.cdc | 4 +- .../test_helpers_price_oracle_aggregator.cdc | 44 +++-- .../test_helpers_price_oracle_router.cdc | 6 +- .../multi-mock-oracle/set_price.cdc | 4 +- .../price-oracle-aggregator/add_oracle.cdc | 12 -- .../price-oracle-aggregator/create.cdc | 6 +- .../price-oracle-aggregator/price.cdc | 4 +- 18 files changed, 366 insertions(+), 226 deletions(-) delete mode 100644 OracleAggregatorArchitecture.md create mode 100644 PriceOracleArchitecture.md delete mode 100644 cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc diff --git a/OracleAggregatorArchitecture.md b/OracleAggregatorArchitecture.md deleted file mode 100644 index 0e6c2ea3..00000000 --- a/OracleAggregatorArchitecture.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 the short-term gradient exceeds the configured threshold, 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. - -Intentionally immutable to avoid bugs, through changing configs in production without testing. -If oracles change there should be an OracelChange event emitted from the ALP contract. - -## 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 gradient - -The oracle maintains a ring buffer of the last **n** aggregated prices with timestamps, respecting `minTimeDelta` and `maxTimeDelta`. - -For each historical point $i$, the **gradient to the current price** is the relative change per unit time: - -$$ -\text{Gradient}_{i} = \frac{Price_{current} - Price_{i}}{Price_{i} \cdot (t_{current} - t_{i})} -$$ - -The current price is considered **Stable** only if **every** such gradient (from each of the n historical points to the current price) is at or below the configured threshold. If **any** gradient is above the threshold, the current price is **invalid** and the aggregator returns nil. - -$$ -\text{isStable} = -\begin{cases} -\text{true} & \text{if } \text{Gradient}_{i} \le \text{gradientThreshold} \text{ for all } i \\ -\text{false} & \text{otherwise (price invalid)} -\end{cases} -$$ diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md new file mode 100644 index 00000000..4c6521a8 --- /dev/null +++ b/PriceOracleArchitecture.md @@ -0,0 +1,96 @@ +# Price Oracle Architecture + +This document describes the price oracle design for the ALP. +How multiple sources are combined into a single trusted oracle interface, and how routing and aggregation are split across two contracts. + +## Overview + +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. + +Two contracts implement this design: + +| Contract | Role | +|----------|------| +| **FlowPriceOracleAggregatorv1** | 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 gradient is stable. | +| **FlowPriceOracleRouterv1** | Exposes **one** `DeFiActions.PriceOracle` that routes by token type. Each token has its own oracle; typically each oracle is an aggregator. | + +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. + +That makes total sense. Direct mutations in production are essentially "testing in prod," which is a recipe for disaster. Forcing a full replacement ensures a clean audit trail and clear governance. + +Here is a refined version that incorporates those specific points: + +### Immutable Configuration + +The **Aggregator** and **Router** are immutable by design to eliminate the risks associated with live production changes. + +* **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. +* **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. +* **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. +* **Transparent Auditing:** Every change is recorded on-chain via the `PriceOracleUpdated` event, ensuring all shifts in logic or parameters are visible and expected. + +## FlowPriceOracleAggregatorv1 + +One aggregated oracle per “market” (e.g. FLOW in USDC). Multiple underlying oracles, single unit of account, fixed tolerances. +- **Price flow:** + 1. Collect prices from all oracles for the requested token. + 2. If any oracle returns nil → emit `PriceNotAvailable`, return nil. + 3. Compute min/max; if spread > `maxSpread` → emit `PriceNotWithinSpreadTolerance`, return nil. + 4. Compute aggregated price (trimmed mean: drop min and max, average the rest). + 5. Check short-term stability: compare current price to recent history; if any gradient > `maxGradient` → emit `PriceNotStable`, return nil. + 6. Otherwise return the aggregated price. +- **History:** An array of `(price, timestamp)` is maintained. Updates are permissionless via `tryAddPriceToHistory()` (idempotent); A FlowCron job should be created to call this regularly. +Additionally every call to price() will also attempt to store the price in the history. + +## Aggregate price (trimmed mean) + +To avoid the complexity of a full median, the aggregator uses a **trimmed mean**: remove the single maximum and single minimum, then average the rest. This reduces the impact of a single outlier or “oracle jitter.” + +- With <2 prices: mean +- With 3+ prices: `(sum - min - max) / (count - 2)`. + +## Oracle spread (coherence) + +A **pessimistic relative spread** is used: the distance between the most extreme oracle prices relative to the **minimum** price. + +$$ +\text{Spread} = \frac{Price_{\max} - Price_{\min}}{Price_{\min}} +$$ + +The price set is **coherent** only if: + +$$ +\text{isCoherent} = +\begin{cases} +\text{true} & \text{if } \frac{Price_{\max} - Price_{\min}}{Price_{\min}} \le maxSpread \\ +\text{false} & \text{otherwise} +\end{cases} +$$ + +## Short-term gradient (stability) + +The aggregator keeps an array of the last **n** aggregated prices (with timestamps), respecting `priceHistoryInterval` and `maxPriceHistoryAge`. + +For each historical point (i), the **gradient to the current price** is the relative change per unit time (scaled for “per minute”): + +$$ +\text{Gradient}_{i} = \frac{|Price_{\text{current}} - Price_{i}|}{\min(Price_{\text{current}}, Price_{i}) \cdot (t_{\text{current}} - t_{i})} \times \text{6000} +$$ + +The current price is **stable** only if **every** such gradient (from each valid history entry to the current price) is at or below the configured `maxGradient`. If **any** gradient is above the threshold, the aggregator emits `PriceNotStable(gradient)` and returns nil. + +$$ +\text{isStable} = +\begin{cases} +\text{true} & \text{if } \text{Gradient}_{i} \le \text{maxGradient} \text{ for all } i \\ +\text{false} & \text{otherwise (price invalid)} +\end{cases} +$$ + +Implementationally, entries older than `maxPriceHistoryAge` are ignored; same-block timestamps are treated with a minimum time delta of 1 to allow small jitter within the same block. + +--- + +## FlowPriceOracleRouterv1 + +Single oracle interface that routes by **token type**. Each token type maps to an oracle. This makes it easy to combine different aggrigators without the need to supply different kinds of thresholds for individual token types. diff --git a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc index ff8c5cb3..631f1af1 100644 --- a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc +++ b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc @@ -2,32 +2,62 @@ import "FlowToken" import "DeFiActions" import "FlowTransactionScheduler" +/// FlowPriceOracleAggregatorv1 combines multiple `DeFiActions.PriceOracle` +/// sources into a single trusted oracle. A price is returned only when: +/// - All oracles return a value (no missing data), +/// - The spread between min and max oracle prices is within `maxSpread`, +/// - Short-term price gradient vs. recent history is within `maxGradient`. +/// +/// One aggregator instance = one market (one token type). For multiple +/// markets, create one storage per market and use a router to expose them. +/// Config is immutable at creation to avoid accidental changes in production. access(all) contract FlowPriceOracleAggregatorv1 { - access(all) entitlement Governance - - access(all) event AggregatorCreated(uuid: UInt64) + /// Emitted when a new aggregator storage is created. + access(all) event StorageCreated(storageID: UInt64) + /// At least one underlying oracle did not return a price for the requested + /// token. access(all) event PriceNotAvailable() + /// Spread between min and max oracle prices exceeded the configured + /// tolerance. access(all) event PriceNotWithinSpreadTolerance(spread: UFix64) - access(all) event PriceNotStable(gradient: UFix64) + /// Short-term price change (gradient) exceeded the configured tolerance. + access(all) event PriceNotWithinGradientTolerance(gradient: UFix64) + /// storageID -> PriceOracleAggregatorStorage + access(self) var storage: @{UInt64: PriceOracleAggregatorStorage} - access(self) var oracleAggregators: @{UInt64: PriceOracleAggregatorStorage} + init() { + self.storage <- {} + } + /// Storage resource for one aggregated oracle (single market): a fixed + /// set of oracles, tolerances, and an array of recent prices for gradient + /// (stability) checks. Immutable: no post-creation config change to avoid + /// accidental misconfiguration in production. access(all) resource PriceOracleAggregatorStorage { + /// Token type for this oracle. + access(all) let ofToken: Type + /// Recent prices for gradient (stability) checks. access(all) let priceHistory: [PriceHistoryEntry] - - // constants intentional to avoid stupid bugs + /// Fixed set of oracles. access(all) let oracles: [{DeFiActions.PriceOracle}] + /// Max allowed relative spread (max-min)/min between oracle prices. access(all) let maxSpread: UFix64 - // % change per minute + /// Max allowed short-term gradient (effective % change per minute). access(all) let maxGradient: UFix64 + /// Length of the price history array for gradient stability checks. access(all) let priceHistorySize: Int + /// Min time between two consecutive history entries. access(all) let priceHistoryInterval: UFix64 + /// Maximum age of a price history entry. + /// History entries older than this are ignored when computing gradient + /// stability. access(all) let maxPriceHistoryAge: UFix64 - - access(all) let unit: Type + /// Unit of account type for this oracle. + access(all) let unitOfAccountType: Type init( + ofToken: Type, oracles: [{DeFiActions.PriceOracle}], maxSpread: UFix64, maxGradient: UFix64, @@ -36,6 +66,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { maxPriceHistoryAge: UFix64, unitOfAccount: Type, ) { + self.ofToken = ofToken self.oracles = oracles self.priceHistory = [] self.maxSpread = maxSpread @@ -43,11 +74,19 @@ access(all) contract FlowPriceOracleAggregatorv1 { self.priceHistorySize = priceHistorySize self.priceHistoryInterval = priceHistoryInterval self.maxPriceHistoryAge = maxPriceHistoryAge - self.unit = unitOfAccount + self.unitOfAccountType = unitOfAccount } + /// Returns aggregated price for `ofToken` or nil if + /// - no oracle defined for `ofToken`, + /// - oracle returned nil, + /// - spread between min and max prices is too high, + /// - gradient is too high. access(all) fun price(ofToken: Type): UFix64? { - let price = self.getPriceUncheckedGradient(ofToken: ofToken) + pre { + self.ofToken == ofToken: "ofToken type mismatch" + } + let price = self.getPriceUncheckedGradient() if price == nil { return nil } @@ -58,13 +97,27 @@ access(all) contract FlowPriceOracleAggregatorv1 { return price } - access(self) fun getPriceUncheckedGradient(ofToken: Type): UFix64? { - let prices = self.getPrices(ofToken: ofToken) + /// Permissionless: anyone may call. Appends current aggregated price + /// to history if available and interval has elapsed. + /// Idempotent; safe to call from a cron/scheduler. + access(all) fun tryAddPriceToHistory() { + let price = self.getPriceUncheckedGradient() + if price == nil { + return + } + self.tryAddPriceToHistoryInternal(price: price!) + } + + access(self) fun getPriceUncheckedGradient(): UFix64? { + let prices = self.getPrices() if prices == nil || prices!.length == 0 { return nil } let minAndMaxPrices = self.getMinAndMaxPrices(prices: prices!) - if !self.isWithinSpreadTolerance(minPrice: minAndMaxPrices.min, maxPrice: minAndMaxPrices.max) { + if !self.isWithinSpreadTolerance( + minPrice: minAndMaxPrices.min, + maxPrice: minAndMaxPrices.max, + ) { return nil } return self.trimmedMeanPrice( @@ -74,10 +127,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { ) } - access(self) fun getPrices(ofToken: Type): [UFix64]? { + access(self) fun getPrices(): [UFix64]? { let prices: [UFix64] = [] for oracle in self.oracles { - let price = oracle.price(ofToken: ofToken) + let price = oracle.price(ofToken: self.ofToken) if price == nil { emit PriceNotAvailable() return nil @@ -101,7 +154,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { return MinAndMaxPrices(min: minPrice, max: maxPrice) } - access(self) view fun isWithinSpreadTolerance(minPrice: UFix64, maxPrice: UFix64): Bool { + access(self) view fun isWithinSpreadTolerance( + minPrice: UFix64, + maxPrice: UFix64, + ): Bool { let spread = (maxPrice - minPrice) / minPrice if spread > self.maxSpread { emit PriceNotWithinSpreadTolerance(spread: spread) @@ -110,7 +166,11 @@ access(all) contract FlowPriceOracleAggregatorv1 { return true } - access(self) view fun trimmedMeanPrice(prices: [UFix64], minPrice: UFix64, maxPrice: UFix64): UFix64? { + access(self) view fun trimmedMeanPrice( + prices: [UFix64], + minPrice: UFix64, + maxPrice: UFix64, + ): UFix64? { let count = prices.length // Handle edge cases where trimming isn't possible @@ -131,7 +191,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { for entry in self.priceHistory { var deltaT = now - UFix64(entry.timestamp) if deltaT == 0.0 { - // if price got measured in the same block allow for some price jitter + // Same block: allow some jitter (avoid div by zero). deltaT = 1.0 } if deltaT > self.maxPriceHistoryAge { @@ -139,49 +199,49 @@ access(all) contract FlowPriceOracleAggregatorv1 { } var gradient = 0.0 if currentPrice > entry.price { - gradient = (currentPrice - entry.price) / (entry.price * deltaT) * 6000.0 + gradient = ((currentPrice - entry.price) * 6000.0) + / (entry.price * deltaT) } else { - gradient = (entry.price - currentPrice) / (currentPrice * deltaT) * 6000.0 + gradient = ((entry.price - currentPrice) * 6000.0) + / (currentPrice * deltaT) } if gradient > self.maxGradient { - emit PriceNotStable(gradient: gradient) + emit PriceNotWithinGradientTolerance(gradient: gradient) return false } } return true } - // Permissionless can be called by anyone, idempotent - access(all) fun tryAddPriceToHistory() { - let price = self.getPriceUncheckedGradient(ofToken: self.unit) - if price == nil { - return - } - self.tryAddPriceToHistoryInternal(price: price!) - } - access(self) fun tryAddPriceToHistoryInternal(price: UFix64) { - // Check if enough time has passed since the last entry + let now = getCurrentBlock().timestamp + // Only append if enough time has passed since the last entry. if self.priceHistory.length > 0 { let lastEntry = self.priceHistory[self.priceHistory.length - 1] - let timeSinceLastEntry = getCurrentBlock().timestamp - lastEntry.timestamp + let timeSinceLastEntry = now - lastEntry.timestamp if timeSinceLastEntry < self.priceHistoryInterval { return } } - self.priceHistory.append(PriceHistoryEntry(price: price, timestamp: getCurrentBlock().timestamp)) + let newEntry = PriceHistoryEntry(price: price, timestamp: now) + self.priceHistory.append(newEntry) if self.priceHistory.length > self.priceHistorySize { self.priceHistory.removeFirst() } } } + /// Struct over a `PriceOracleAggregatorStorage` + /// See `DeFiActions.PriceOracle` for interface documentation. + /// + /// Additionaly implements `priceHistory()` to return the price history + /// array. access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { - access(all) let priceOracleID: UInt64 + access(all) let storageID: UInt64 access(contract) var uniqueID: DeFiActions.UniqueIdentifier? - init(priceOracleID: UInt64) { - self.priceOracleID = priceOracleID + init(storageID: UInt64) { + self.storageID = storageID self.uniqueID = DeFiActions.createUniqueIdentifier() } @@ -190,7 +250,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { } access(all) view fun unitOfAccount(): Type { - return self.borrowPriceOracleAggregator().unit + return self.borrowPriceOracleAggregator().unitOfAccountType } access(all) fun priceHistory(): &[PriceHistoryEntry] { @@ -222,28 +282,37 @@ access(all) contract FlowPriceOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowPriceOracleAggregatorv1.oracleAggregators[self.priceOracleID])! + return (&FlowPriceOracleAggregatorv1.storage[self.storageID])! } } - access(all) resource PriceOracleCronHandler: FlowTransactionScheduler.TransactionHandler{ - access(all) let priceOracleID: UInt64 + /// Scheduler handler that calls `tryAddPriceToHistory()` on the given + /// aggregator storage each tick. Use FlowCron for scheduling of this + /// handler. + access(all) resource PriceOracleCronHandler: FlowTransactionScheduler.TransactionHandler { + /// Storage ID of the aggregator to update. + access(all) let storageID: UInt64 - init(priceOracleID: UInt64) { - self.priceOracleID = priceOracleID + init(storageID: UInt64) { + self.storageID = storageID } + /// Function called by the scheduler to update the price history. access(FlowTransactionScheduler.Execute) fun executeTransaction(id: UInt64, data: AnyStruct?) { let priceOracleAggregator = self.borrowPriceOracleAggregator() priceOracleAggregator.tryAddPriceToHistory() } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowPriceOracleAggregatorv1.oracleAggregators[self.priceOracleID])! + return (&FlowPriceOracleAggregatorv1.storage[self.storageID])! } } + /// Creates a new aggregator storage with the given oracles and tolerances. + /// Returns the storage ID (resource UUID) for `createPriceOracleAggregator` + /// and `createPriceOracleCronHandler`. Config is immutable after creation. access(all) fun createPriceOracleAggregatorStorage( + ofToken: Type, oracles: [{DeFiActions.PriceOracle}], maxSpread: UFix64, maxGradient: UFix64, @@ -253,6 +322,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { unitOfAccount: Type, ): UInt64 { let priceOracleAggregator <- create PriceOracleAggregatorStorage( + ofToken: ofToken, oracles: oracles, maxSpread: maxSpread, maxGradient: maxGradient, @@ -262,19 +332,24 @@ access(all) contract FlowPriceOracleAggregatorv1 { unitOfAccount: unitOfAccount ) let id = priceOracleAggregator.uuid - self.oracleAggregators[id] <-! priceOracleAggregator - emit AggregatorCreated(uuid: id) + self.storage[id] <-! priceOracleAggregator + emit StorageCreated(storageID: id) return id } - access(all) fun createPriceOracleAggregator(id: UInt64): PriceOracleAggregator { - return PriceOracleAggregator(priceOracleID: id) + /// Returns a `PriceOracleAggregator` which implements + /// `DeFiActions.PriceOracle` for the given storage. + access(all) fun createPriceOracleAggregator(storageID: UInt64): PriceOracleAggregator { + return PriceOracleAggregator(storageID: storageID) } - access(all) fun createPriceOracleCronHandler(id: UInt64): @PriceOracleCronHandler { - return <- create PriceOracleCronHandler(priceOracleID: id) + /// Creates a cron handler that can be used to update the price history + /// for the given storage. Must be stored and registered with FlowCron. + access(all) fun createPriceOracleCronHandler(storageID: UInt64): @PriceOracleCronHandler { + return <- create PriceOracleCronHandler(storageID: storageID) } + /// Helper struct to store the min and max of a set of prices. access(all) struct MinAndMaxPrices { access(all) let min: UFix64 access(all) let max: UFix64 @@ -285,6 +360,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { } } + /// Struct to store one entry in the aggregator's price history array for + /// gradient stability checks. access(all) struct PriceHistoryEntry { access(all) let price: UFix64 access(all) let timestamp: UFix64 @@ -295,7 +372,5 @@ access(all) contract FlowPriceOracleAggregatorv1 { } } - init() { - self.oracleAggregators <- {} - } + } \ No newline at end of file diff --git a/cadence/contracts/FlowPriceOracleRouterv1.cdc b/cadence/contracts/FlowPriceOracleRouterv1.cdc index bf052d10..e0793113 100644 --- a/cadence/contracts/FlowPriceOracleRouterv1.cdc +++ b/cadence/contracts/FlowPriceOracleRouterv1.cdc @@ -1,8 +1,20 @@ import "DeFiActions" +/// FlowPriceOracleRouterv1 exposes a single `DeFiActions.PriceOracle` that +/// routes by token type: one oracle per token. All oracles must share the +/// same unit of account. Config (oracles, unit of account) is immutable at +/// creation to avoid accidental changes in production. +/// Use this when the protocol needs one oracle reference but prices come +/// from different sources per token. access(all) contract FlowPriceOracleRouterv1 { + /// Router implementing `DeFiActions.PriceOracle`: dispatches + /// `price(ofToken)` to the oracle for that token type. All oracles must + /// have the same `unitOfAccount` (enforced at creation). Immutable. + /// + /// See `DeFiActions.PriceOracle` for interface documentation. access(all) struct PriceOracleRouter: DeFiActions.PriceOracle { + /// Token type -> oracle for that token type. access(self) let oracles: {Type: {DeFiActions.PriceOracle}} access(self) let unitOfAccountType: Type access(contract) var uniqueID: DeFiActions.UniqueIdentifier? @@ -51,6 +63,8 @@ access(all) contract FlowPriceOracleRouterv1 { } } + /// Creates a router with the given unit of account and token-type -> oracle + /// map. All oracles must report in `unitOfAccount`. access(all) fun createPriceOracleRouter( unitOfAccount: Type, oracles: {Type: {DeFiActions.PriceOracle}}, diff --git a/cadence/tests/contracts/ExampleToken1.cdc b/cadence/tests/contracts/ExampleToken1.cdc index 5e583636..37bc445a 100644 --- a/cadence/tests/contracts/ExampleToken1.cdc +++ b/cadence/tests/contracts/ExampleToken1.cdc @@ -2,6 +2,7 @@ import "FungibleToken" import "MetadataViews" import "FungibleTokenMetadataViews" +/// Test-only: example FungibleToken contract for testing. access(all) contract ExampleToken1: FungibleToken { /// The event that is emitted when new tokens are minted diff --git a/cadence/tests/contracts/ExampleToken2.cdc b/cadence/tests/contracts/ExampleToken2.cdc index 0c6c698c..e87821ac 100644 --- a/cadence/tests/contracts/ExampleToken2.cdc +++ b/cadence/tests/contracts/ExampleToken2.cdc @@ -2,6 +2,7 @@ import "FungibleToken" import "MetadataViews" import "FungibleTokenMetadataViews" +/// Test-only: example FungibleToken contract for testing. access(all) contract ExampleToken2: FungibleToken { /// The event that is emitted when new tokens are minted diff --git a/cadence/tests/contracts/MultiMockOracle.cdc b/cadence/tests/contracts/MultiMockOracle.cdc index 3d144a2f..4b175c98 100644 --- a/cadence/tests/contracts/MultiMockOracle.cdc +++ b/cadence/tests/contracts/MultiMockOracle.cdc @@ -2,16 +2,15 @@ import "FungibleToken" import "DeFiActions" -/// -/// THIS CONTRACT IS A MOCK AND IS NOT INTENDED FOR USE IN PRODUCTION -/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -/// +/// Test-only mock: implements `DeFiActions.PriceOracle` with settable prices +/// per token. Use to feed the aggregator or router in tests. access(all) contract MultiMockOracle { - access(all) event OracleCreated(uuid: UInt64) + access(all) event OracleCreated(storageID: UInt64) access(all) var priceOracleStorages: @{UInt64: PriceOracleStorage} + /// Holds unit-of-account type and a mutable map of token type -> price. access(all) resource PriceOracleStorage { access(contract) var unitOfAccountType: Type access(contract) var prices: {Type: UFix64?} @@ -26,8 +25,10 @@ access(all) contract MultiMockOracle { } } + /// Mock oracle view over storage; implements DeFiActions.PriceOracle. + /// Unit-of-account always returns 1.0; other tokens use set prices. access(all) struct PriceOracle : DeFiActions.PriceOracle { - access(all) var priceOracleStorageID: UInt64 + access(all) var storageID: UInt64 access(contract) var uniqueID: DeFiActions.UniqueIdentifier? access(all) view fun unitOfAccount(): Type { @@ -62,30 +63,32 @@ access(all) contract MultiMockOracle { } access(all) view fun borrowPriceOracleStorage(): &PriceOracleStorage { - return (&MultiMockOracle.priceOracleStorages[self.priceOracleStorageID])! + return (&MultiMockOracle.priceOracleStorages[self.storageID])! } - init(priceOracleStorageID: UInt64) { - self.priceOracleStorageID = priceOracleStorageID + init(storageID: UInt64) { + self.storageID = storageID self.uniqueID = DeFiActions.createUniqueIdentifier() } } + /// Creates a new mock oracle storage and returns a PriceOracle view. access(all) fun createPriceOracle(unitOfAccountType: Type): PriceOracle { let oracleStorage <- create PriceOracleStorage(unitOfAccountType: unitOfAccountType) let id = oracleStorage.uuid self.priceOracleStorages[id] <-! oracleStorage - emit OracleCreated(uuid: id) - let oracle = PriceOracle(priceOracleStorageID: id) + emit OracleCreated(storageID: id) + let oracle = PriceOracle(storageID: id) return oracle } - access(all) view fun borrowPriceOracleStorage(priceOracleStorageID: UInt64): &PriceOracleStorage? { - return &self.priceOracleStorages[priceOracleStorageID] + access(all) view fun borrowPriceOracleStorage(storageID: UInt64): &PriceOracleStorage? { + return &self.priceOracleStorages[storageID] } - access(all) fun setPrice(priceOracleStorageID: UInt64, forToken: Type, price: UFix64?) { - let oracleStorage = self.borrowPriceOracleStorage(priceOracleStorageID: priceOracleStorageID)! + /// Sets the price for a token on the given storage (for tests). + access(all) fun setPrice(storageID: UInt64, forToken: Type, price: UFix64?) { + let oracleStorage = self.borrowPriceOracleStorage(storageID: storageID)! oracleStorage.setPrice(forToken: forToken, price: price) } diff --git a/cadence/tests/contracts/OracleStorage.cdc b/cadence/tests/contracts/OracleStorage.cdc index eb6183c4..85bc303f 100644 --- a/cadence/tests/contracts/OracleStorage.cdc +++ b/cadence/tests/contracts/OracleStorage.cdc @@ -1,5 +1,7 @@ import "DeFiActions" +/// Test-only: holds an optional `DeFiActions.PriceOracle` (e.g. a router or +/// aggregator view) so tests can save and later use it. access(all) contract OracleStorage { access(all) var oracle: {DeFiActions.PriceOracle}? @@ -8,6 +10,7 @@ access(all) contract OracleStorage { self.oracle = nil } + /// Stores the given oracle for the test account. access(all) fun saveOracle(oracle: {DeFiActions.PriceOracle}) { self.oracle = oracle } diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 9fd586bb..b32d704f 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -9,11 +9,11 @@ import "test_helpers.cdc" import "test_helpers_price_oracle_aggregator.cdc" access(all) var snapshot: UInt64 = 0 -access(all) var signer: Test.TestAccount = Test.getAccount(0x0000000000000001) +access(all) var signer = Test.getAccount(0x0000000000000001) access(all) fun setup() { deployContracts() - mintFlow(to: signer, amount: 100.0) + let _ = mintFlow(to: signer, amount: 100.0) snapshot = getCurrentBlockHeight() } @@ -25,6 +25,7 @@ access(all) fun beforeEach() { access(all) fun test_single_oracle() { let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: 0.0, maxGradient: 0.0, @@ -40,13 +41,13 @@ access(all) fun test_single_oracle() { ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { - setPrice( - priceOracleStorageID: info.oracleIDs[0], + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], forToken: Type<@FlowToken.Vault>(), price: p, ) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) Test.assertEqual(price, p) @@ -61,6 +62,7 @@ access(all) fun test_multiple_oracles() { } let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: oracleCount, maxSpread: 0.0, maxGradient: 0.0, @@ -76,15 +78,15 @@ access(all) fun test_multiple_oracles() { ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { - for oracleID in info.oracleIDs { - setPrice( - priceOracleStorageID: oracleID, + for oracleID in info.mockOracleStorageIDs { + setMultiMockOraclePrice( + storageID: oracleID, forToken: Type<@FlowToken.Vault>(), price: p, ) } - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) Test.assertEqual(price, p) @@ -151,6 +153,7 @@ access(all) fun test_average_price() { } let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, maxSpread: UFix64.max, maxGradient: UFix64.max, @@ -165,8 +168,8 @@ access(all) fun test_average_price() { aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) set_prices(info: info, prices: testRun.prices) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) if price != testRun.expectedPrice { @@ -238,6 +241,7 @@ access(all) fun test_spread() { } let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, maxSpread: testRun.maxSpread, maxGradient: UFix64.max, @@ -252,8 +256,8 @@ access(all) fun test_spread() { aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) set_prices(info: info, prices: testRun.prices) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) if price != testRun.expectedPrice { @@ -327,6 +331,7 @@ access(all) fun test_gradient() { } let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, maxGradient: testRun.maxGradient, @@ -343,19 +348,19 @@ access(all) fun test_gradient() { // need to move time to avoid race condition of the cron job Test.moveTime(by: 10.0) for price in testRun.priceHistory { - setPrice( - priceOracleStorageID: info.oracleIDs[0], + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], forToken: Type<@FlowToken.Vault>(), price: price, ) Test.moveTime(by: testRun.priceHistoryDelay) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) } // make sure prices are correctly recorded - let priceHistory = getPriceHistory(uuid: info.aggregatorID,) + let priceHistory = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) Test.assertEqual(testRun.priceHistory.length, priceHistory.length) var i = 0 for price in testRun.priceHistory { @@ -363,8 +368,8 @@ access(all) fun test_gradient() { i = i + 1 } - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil @@ -380,6 +385,7 @@ access(self) fun test_gradient_incomplete_price_history() { let priceHistory = [1.0, nil, nil, 4.0] let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, maxGradient: 100.0, @@ -395,19 +401,19 @@ access(self) fun test_gradient_incomplete_price_history() { ) Test.moveTime(by: 10.0) for price in priceHistory { - setPrice( - priceOracleStorageID: info.oracleIDs[0], + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], forToken: Type<@FlowToken.Vault>(), price: price, ) Test.moveTime(by: 60.0) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) } - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil @@ -418,6 +424,7 @@ access(self) fun test_gradient_old_price_history() { let priceHistory = [1.0, nil, nil, 40.0] let info = createAggregator( signer: signer, + ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, maxGradient: 1.0, @@ -433,19 +440,19 @@ access(self) fun test_gradient_old_price_history() { ) Test.moveTime(by: 10.0) for price in priceHistory { - setPrice( - priceOracleStorageID: info.oracleIDs[0], + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], forToken: Type<@FlowToken.Vault>(), price: price, ) Test.moveTime(by: 60.0) - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) } - var price = getPrice( - uuid: info.aggregatorID, + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil @@ -455,8 +462,8 @@ access(self) fun test_gradient_old_price_history() { access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?]) { var i = 0 for p in prices { - setPrice( - priceOracleStorageID: info.oracleIDs[i], + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[i], forToken: Type<@FlowToken.Vault>(), price: p, ) @@ -468,7 +475,7 @@ access(self) fun log_fail_events() { let failureEvents = [ Type(), Type(), - Type() + Type() ] for eventType in failureEvents { let events = Test.eventsOfType(eventType) diff --git a/cadence/tests/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc index cd1959a7..da09acd5 100644 --- a/cadence/tests/price_oracle_router_test.cdc +++ b/cadence/tests/price_oracle_router_test.cdc @@ -23,60 +23,67 @@ access(all) fun beforeEach() { access(all) fun test_router_add_oracle() { let info = [ - createRouterInfo( + createPriceOracleRouterInfo( unitOfAccount: Type<@MOET.Vault>(), oracleOfToken: Type<@FlowToken.Vault>(), prices: 1.0 ) ] - createRouter( + createPriceOracleRouter( unitOfAccount: Type<@MOET.Vault>(), createRouterInfo: info, expectSucceeded: true ) - Test.assertEqual(price(ofToken: Type<@FlowToken.Vault>()), 1.0 as UFix64?) - Test.assertEqual(price(ofToken: Type<@ExampleToken1.Vault>()), nil as UFix64?) + var price = 0.0 as UFix64? + price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) + Test.assertEqual(price, 1.0 as UFix64?) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) + Test.assertEqual(price, nil as UFix64?) } access(all) fun test_router_add_multiple_oracles() { let info = [ - createRouterInfo( + createPriceOracleRouterInfo( unitOfAccount: Type<@MOET.Vault>(), oracleOfToken: Type<@FlowToken.Vault>(), prices: 1.0 ), - createRouterInfo( + createPriceOracleRouterInfo( unitOfAccount: Type<@MOET.Vault>(), oracleOfToken: Type<@ExampleToken1.Vault>(), prices: 2.0 ), - createRouterInfo( + createPriceOracleRouterInfo( unitOfAccount: Type<@MOET.Vault>(), oracleOfToken: Type<@ExampleToken2.Vault>(), prices: 3.0 ) ] - createRouter( + createPriceOracleRouter( unitOfAccount: Type<@MOET.Vault>(), createRouterInfo: info, expectSucceeded: true ) - Test.assertEqual(price(ofToken: Type<@FlowToken.Vault>()), 1.0 as UFix64?) - Test.assertEqual(price(ofToken: Type<@ExampleToken1.Vault>()), 2.0 as UFix64?) - Test.assertEqual(price(ofToken: Type<@ExampleToken2.Vault>()), 3.0 as UFix64?) + var price = 0.0 as UFix64? + price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) + Test.assertEqual(price, 1.0 as UFix64?) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) + Test.assertEqual(price, 2.0 as UFix64?) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken2.Vault>()) + Test.assertEqual(price, 3.0 as UFix64?) } access(all) fun test_router_add_wrong_unit_of_account() { - let createRouterInfo = [ - createRouterInfo( + let info = [ + createPriceOracleRouterInfo( unitOfAccount: Type<@ExampleToken1.Vault>(), oracleOfToken: Type<@FlowToken.Vault>(), prices: 1.0 ) ] - createRouter( + createPriceOracleRouter( unitOfAccount: Type<@MOET.Vault>(), - createRouterInfo: createRouterInfo, + createRouterInfo: info, expectSucceeded: false ) } \ No newline at end of file diff --git a/cadence/tests/scripts/price-oracle-aggregator/history.cdc b/cadence/tests/scripts/price-oracle-aggregator/history.cdc index 4273c7ba..2d0b7042 100644 --- a/cadence/tests/scripts/price-oracle-aggregator/history.cdc +++ b/cadence/tests/scripts/price-oracle-aggregator/history.cdc @@ -1,6 +1,6 @@ import "FlowPriceOracleAggregatorv1" -access(all) fun main(uuid: UInt64): &[FlowPriceOracleAggregatorv1.PriceHistoryEntry] { - let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: uuid) +access(all) fun main(storageID: UInt64): &[FlowPriceOracleAggregatorv1.PriceHistoryEntry] { + let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) return priceOracle.priceHistory() } diff --git a/cadence/tests/scripts/price-oracle-aggregator/price.cdc b/cadence/tests/scripts/price-oracle-aggregator/price.cdc index a2dda781..2b31db41 100644 --- a/cadence/tests/scripts/price-oracle-aggregator/price.cdc +++ b/cadence/tests/scripts/price-oracle-aggregator/price.cdc @@ -1,6 +1,6 @@ import "FlowPriceOracleAggregatorv1" -access(all) fun main(uuid: UInt64, ofToken: Type): UFix64? { - let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: uuid) +access(all) fun main(storageID: UInt64, ofToken: Type): UFix64? { + let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) return priceOracle.price(ofToken: ofToken) } diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index 98891fa8..9dc538a0 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -2,19 +2,21 @@ import Test import "DeFiActions" import "FlowPriceOracleAggregatorv1" import "MultiMockOracle" +// import "test_helpers.cdc" access(all) struct CreateAggregatorInfo { - access(all) let aggregatorID: UInt64 - access(all) let oracleIDs: [UInt64] + access(all) let aggregatorStorageID: UInt64 + access(all) let mockOracleStorageIDs: [UInt64] - init(aggregatorID: UInt64, oracleIDs: [UInt64]) { - self.aggregatorID = aggregatorID - self.oracleIDs = oracleIDs + init(aggregatorStorageID: UInt64, mockOracleStorageIDs: [UInt64]) { + self.aggregatorStorageID = aggregatorStorageID + self.mockOracleStorageIDs = mockOracleStorageIDs } } access(all) fun createAggregator( signer: Test.TestAccount, + ofToken: Type, oracleCount: Int, maxSpread: UFix64, maxGradient: UFix64, @@ -30,65 +32,65 @@ access(all) fun createAggregator( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create.cdc", - [oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [ofToken, oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) Test.expect(res, Test.beSucceeded()) - let aggregatorCreatedEvents = Test.eventsOfType(Type()) - let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.AggregatorCreated + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.StorageCreated let oracleCreatedEvents = Test.eventsOfType(Type()) let oracleIDs: [UInt64] = [] var i = oracleCreatedEvents.length - oracleCount while i < oracleCreatedEvents.length { let oracleCreatedData = oracleCreatedEvents[i] as! MultiMockOracle.OracleCreated - oracleIDs.append(oracleCreatedData.uuid) + oracleIDs.append(oracleCreatedData.storageID) i = i + 1 } return CreateAggregatorInfo( - aggregatorID: aggregatorCreatedData.uuid, - oracleIDs: oracleIDs + aggregatorStorageID: aggregatorCreatedData.storageID, + mockOracleStorageIDs: oracleIDs ) } -access(all) fun setPrice( - priceOracleStorageID: UInt64, +access(all) fun setMultiMockOraclePrice( + storageID: UInt64, forToken: Type, price: UFix64?, ) { let res = _executeTransaction( "./transactions/multi-mock-oracle/set_price.cdc", - [priceOracleStorageID, forToken, price], + [storageID, forToken, price], [] ) Test.expect(res, Test.beSucceeded()) } -access(all) fun getPrice( - uuid: UInt64, +access(all) fun oracleAggregatorPrice( + storageID: UInt64, ofToken: Type, ): UFix64? { // execute transaction to emit events let res = _executeTransaction( "./transactions/price-oracle-aggregator/price.cdc", - [uuid, ofToken], + [storageID, ofToken], [] ) Test.expect(res, Test.beSucceeded()) // execute script to get price let res2 = _executeScript( "./scripts/price-oracle-aggregator/price.cdc", - [uuid, ofToken] + [storageID, ofToken] ) Test.expect(res2, Test.beSucceeded()) return res2.returnValue as! UFix64? } -access(all) fun getPriceHistory( - uuid: UInt64, +access(all) fun oracleAggregatorPriceHistory( + storageID: UInt64, ): [FlowPriceOracleAggregatorv1.PriceHistoryEntry] { let res = _executeScript( "./scripts/price-oracle-aggregator/history.cdc", - [uuid] + [storageID] ) Test.expect(res, Test.beSucceeded()) return res.returnValue as! [FlowPriceOracleAggregatorv1.PriceHistoryEntry] diff --git a/cadence/tests/test_helpers_price_oracle_router.cdc b/cadence/tests/test_helpers_price_oracle_router.cdc index 60f8e1cd..b5547018 100644 --- a/cadence/tests/test_helpers_price_oracle_router.cdc +++ b/cadence/tests/test_helpers_price_oracle_router.cdc @@ -1,6 +1,6 @@ import Test -access(all) fun createRouter( +access(all) fun createPriceOracleRouter( unitOfAccount: Type, createRouterInfo: [{String: AnyStruct}], expectSucceeded: Bool @@ -14,7 +14,7 @@ access(all) fun createRouter( } // need this because can't define struct here to pass to transaction -access(all) fun createRouterInfo( +access(all) fun createPriceOracleRouterInfo( unitOfAccount: Type, oracleOfToken: Type, prices: UFix64? @@ -26,7 +26,7 @@ access(all) fun createRouterInfo( } } -access(all) fun price(ofToken: Type): UFix64? { +access(all) fun priceOracleRouterPrice(ofToken: Type): UFix64? { let res = _executeScript( "./scripts/price-oracle-router/price.cdc", [ofToken], diff --git a/cadence/tests/transactions/multi-mock-oracle/set_price.cdc b/cadence/tests/transactions/multi-mock-oracle/set_price.cdc index d375296f..e838f9a2 100644 --- a/cadence/tests/transactions/multi-mock-oracle/set_price.cdc +++ b/cadence/tests/transactions/multi-mock-oracle/set_price.cdc @@ -1,13 +1,13 @@ import "MultiMockOracle" transaction( - oracleStorageID: UInt64, + storageID: UInt64, forToken: Type, price: UFix64?, ) { execute { MultiMockOracle.setPrice( - priceOracleStorageID: oracleStorageID, + storageID: storageID, forToken: forToken, price: price, ) diff --git a/cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc b/cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc deleted file mode 100644 index 0f7d4804..00000000 --- a/cadence/tests/transactions/price-oracle-aggregator/add_oracle.cdc +++ /dev/null @@ -1,12 +0,0 @@ -import "FlowPriceOracleAggregatorv1" - -transaction( - oracleStorageID: UInt64, - forToken: Type, -) { - execute { - FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( - ofToken: forToken, - ) - } -} \ No newline at end of file diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index f70b2bdc..d0e8c61e 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -10,6 +10,7 @@ import "MultiMockOracle" import "MOET" transaction( + ofToken: Type, oracleCount: Int, maxSpread: UFix64, maxGradient: UFix64, @@ -40,6 +41,7 @@ transaction( execute { let uuid = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, maxGradient: maxGradient, @@ -51,7 +53,7 @@ transaction( // Create cron handler for the aggregator, look at flow-cron for reference - let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(id: uuid) + let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(storageID: uuid) self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") @@ -95,7 +97,7 @@ transaction( ?? panic("Flow token vault not found") if feeVault.balance < totalFee { - panic("Insufficient funds: required ".concat(totalFee.toString()).concat(" FLOW (executor: ").concat(executorFlowFee.toString()).concat(", keeper: ").concat(keeperFlowFee.toString()).concat("), available ").concat(feeVault.balance.toString())) + panic("Insufficient funds: required \(totalFee.toString()) FLOW (executor: \(executorFlowFee.toString()), keeper: \(keeperFlowFee.toString())), available \(feeVault.balance.toString())") } // Withdraw fees for BOTH transactions diff --git a/cadence/tests/transactions/price-oracle-aggregator/price.cdc b/cadence/tests/transactions/price-oracle-aggregator/price.cdc index 0f7d4804..0974834c 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/price.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/price.cdc @@ -1,11 +1,11 @@ import "FlowPriceOracleAggregatorv1" transaction( - oracleStorageID: UInt64, + storageID: UInt64, forToken: Type, ) { execute { - FlowPriceOracleAggregatorv1.createPriceOracleAggregator(id: oracleStorageID).price( + let _ = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( ofToken: forToken, ) } From ed2d75543aaea9d6502238bf8aa5cbbee8d3c44f Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 18 Feb 2026 22:47:44 +0100 Subject: [PATCH 09/18] add jitter test --- .../tests/price_oracle_aggregator_test.cdc | 129 ++++++++-------- .../test_helpers_price_oracle_aggregator.cdc | 83 +++++++++- .../price-oracle-aggregator/assert_price.cdc | 18 +++ .../price-oracle-aggregator/create.cdc | 103 +------------ .../create_with_cron.cdc | 142 ++++++++++++++++++ 5 files changed, 313 insertions(+), 162 deletions(-) create mode 100644 cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc create mode 100644 cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index b32d704f..43187574 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -5,14 +5,15 @@ import "FlowPriceOracleAggregatorv1" import "FlowToken" import "MOET" import "MultiMockOracle" -import "test_helpers.cdc" import "test_helpers_price_oracle_aggregator.cdc" +import "test_helpers.cdc" access(all) var snapshot: UInt64 = 0 access(all) var signer = Test.getAccount(0x0000000000000001) access(all) fun setup() { deployContracts() + let _ = mintFlow(to: signer, amount: 100.0) snapshot = getCurrentBlockHeight() } @@ -24,7 +25,6 @@ access(all) fun beforeEach() { access(all) fun test_single_oracle() { let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: 0.0, @@ -33,11 +33,6 @@ access(all) fun test_single_oracle() { priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "0 0 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { @@ -61,33 +56,27 @@ access(all) fun test_multiple_oracles() { Test.reset(to: snapshot) } let info = createAggregator( - signer: signer, - ofToken: Type<@FlowToken.Vault>(), + ofToken: Type<@MOET.Vault>(), oracleCount: oracleCount, maxSpread: 0.0, maxGradient: 0.0, priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, - unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "0 0 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler + unitOfAccount: Type<@FlowToken.Vault>(), ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] for p in prices { - for oracleID in info.mockOracleStorageIDs { - setMultiMockOraclePrice( - storageID: oracleID, - forToken: Type<@FlowToken.Vault>(), - price: p, - ) + let samePrices: [UFix64?] = [] + var i = 0 + while i < oracleCount { + samePrices.append(p) + i = i + 1 } + set_prices(info: info, prices: samePrices, forToken: Type<@MOET.Vault>()) var price = oracleAggregatorPrice( storageID: info.aggregatorStorageID, - ofToken: Type<@FlowToken.Vault>() + ofToken: Type<@MOET.Vault>() ) Test.assertEqual(price, p) } @@ -152,7 +141,6 @@ access(all) fun test_average_price() { Test.reset(to: snapshot) } let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, maxSpread: UFix64.max, @@ -161,13 +149,8 @@ access(all) fun test_average_price() { priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "0 0 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - set_prices(info: info, prices: testRun.prices) + set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) var price = oracleAggregatorPrice( storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() @@ -240,7 +223,6 @@ access(all) fun test_spread() { Test.reset(to: snapshot) } let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, maxSpread: testRun.maxSpread, @@ -249,13 +231,8 @@ access(all) fun test_spread() { priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "0 0 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - set_prices(info: info, prices: testRun.prices) + set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) var price = oracleAggregatorPrice( storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() @@ -263,7 +240,8 @@ access(all) fun test_spread() { if price != testRun.expectedPrice { log(testRun) log_fail_events() - Test.fail(message: "invalid price") + log(price) + Test.assertEqual(testRun.expectedPrice, price) } } } @@ -330,7 +308,6 @@ access(all) fun test_gradient() { Test.reset(to: snapshot) } let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, @@ -339,14 +316,7 @@ access(all) fun test_gradient() { priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "* * 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - // need to move time to avoid race condition of the cron job - Test.moveTime(by: 10.0) for price in testRun.priceHistory { setMultiMockOraclePrice( storageID: info.mockOracleStorageIDs[0], @@ -384,7 +354,6 @@ access(all) fun test_gradient() { access(self) fun test_gradient_incomplete_price_history() { let priceHistory = [1.0, nil, nil, 4.0] let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, @@ -393,11 +362,6 @@ access(self) fun test_gradient_incomplete_price_history() { priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "* * 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) Test.moveTime(by: 10.0) for price in priceHistory { @@ -423,7 +387,6 @@ access(self) fun test_gradient_incomplete_price_history() { access(self) fun test_gradient_old_price_history() { let priceHistory = [1.0, nil, nil, 40.0] let info = createAggregator( - signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, maxSpread: UFix64.max, @@ -432,13 +395,7 @@ access(self) fun test_gradient_old_price_history() { priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 150.0, unitOfAccount: Type<@MOET.Vault>(), - cronExpression: "* * 1 1 *", - cronHandlerStoragePath: /storage/cronHandler, - keeperExecutionEffort: 7500, - executorExecutionEffort: 2500, - aggregatorCronHandlerStoragePath: /storage/aggregatorCronHandler ) - Test.moveTime(by: 10.0) for price in priceHistory { setMultiMockOraclePrice( storageID: info.mockOracleStorageIDs[0], @@ -459,16 +416,66 @@ access(self) fun test_gradient_old_price_history() { Test.assertEqual(priceIsStable, true) } -access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?]) { +access(all) fun test_allow_jitter() { + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: UFix64.max, + maxGradient: 1.0, + priceHistorySize: 1, + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 150.0, + unitOfAccount: Type<@MOET.Vault>(), + ) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 1.0, + ) + // now everything needs to be in the same block + var txs: [Test.Transaction] = [] + let originalHeight = getCurrentBlockHeight() + let originalTimestamp = getCurrentBlock().timestamp + txs.append(oracleAggregatorPriceTx( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + )) + txs.append(setMultiMockOraclePriceTx( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 1.00000001, + )) + txs.append(oracleAggregatorAssertPriceTx( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>(), + price: 1.00000001, + )) + let res = Test.executeTransactions(txs) + for r in res { + Test.expect(r, Test.beSucceeded()) + } +} + +access(all) fun test_cron_job() { + Test.assert(false, message: "not implemented") +} + +access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?], forToken: Type) { var i = 0 + let txs: [Test.Transaction] = [] for p in prices { - setMultiMockOraclePrice( + let tx = setMultiMockOraclePriceTx( storageID: info.mockOracleStorageIDs[i], - forToken: Type<@FlowToken.Vault>(), + forToken: forToken, price: p, ) + txs.append(tx) i = i + 1 } + let res = Test.executeTransactions(txs) + for r in res { + Test.expect(r, Test.beSucceeded()) + } } access(self) fun log_fail_events() { diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index 9dc538a0..51acdf5b 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -15,6 +15,38 @@ access(all) struct CreateAggregatorInfo { } access(all) fun createAggregator( + ofToken: Type, + oracleCount: Int, + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + unitOfAccount: Type, +): CreateAggregatorInfo { + let res = _executeTransaction( + "./transactions/price-oracle-aggregator/create.cdc", + [ofToken, oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount], + [] + ) + Test.expect(res, Test.beSucceeded()) + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.StorageCreated + let oracleCreatedEvents = Test.eventsOfType(Type()) + let oracleIDs: [UInt64] = [] + var i = oracleCreatedEvents.length - oracleCount + while i < oracleCreatedEvents.length { + let oracleCreatedData = oracleCreatedEvents[i] as! MultiMockOracle.OracleCreated + oracleIDs.append(oracleCreatedData.storageID) + i = i + 1 + } + return CreateAggregatorInfo( + aggregatorStorageID: aggregatorCreatedData.storageID, + mockOracleStorageIDs: oracleIDs + ) +} + +access(all) fun createAggregatorWithCron( signer: Test.TestAccount, ofToken: Type, oracleCount: Int, @@ -31,7 +63,7 @@ access(all) fun createAggregator( aggregatorCronHandlerStoragePath: StoragePath ): CreateAggregatorInfo { let res = _executeTransaction( - "./transactions/price-oracle-aggregator/create.cdc", + "./transactions/price-oracle-aggregator/create_with_cron.cdc", [ofToken, oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) @@ -52,6 +84,19 @@ access(all) fun createAggregator( ) } +// speed improvement by not executing individual transactions +access(all) fun setMultiMockOraclePriceTx( + storageID: UInt64, + forToken: Type, + price: UFix64?, +): Test.Transaction { + return getTransaction( + "./transactions/multi-mock-oracle/set_price.cdc", + [storageID, forToken, price], + [] + ) +} + access(all) fun setMultiMockOraclePrice( storageID: UInt64, forToken: Type, @@ -85,6 +130,29 @@ access(all) fun oracleAggregatorPrice( return res2.returnValue as! UFix64? } +access(all) fun oracleAggregatorPriceTx( + storageID: UInt64, + ofToken: Type, +): Test.Transaction { + return getTransaction( + "./transactions/price-oracle-aggregator/price.cdc", + [storageID, ofToken], + [] + ) +} + +access(all) fun oracleAggregatorAssertPriceTx( + storageID: UInt64, + ofToken: Type, + price: UFix64?, +): Test.Transaction { + return getTransaction( + "./transactions/price-oracle-aggregator/assert_price.cdc", + [storageID, ofToken, price], + [] + ) +} + access(all) fun oracleAggregatorPriceHistory( storageID: UInt64, ): [FlowPriceOracleAggregatorv1.PriceHistoryEntry] { @@ -98,6 +166,19 @@ access(all) fun oracleAggregatorPriceHistory( // --- Helper Functions --- +access(self) fun getTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.Transaction { + let authorizers: [Address] = [] + for signer in signers { + authorizers.append(signer.address) + } + return Test.Transaction( + code: Test.readFile(path), + authorizers: authorizers, + signers: signers, + arguments: args, + ) +} + access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { let authorizers: [Address] = [] for signer in signers { diff --git a/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc b/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc new file mode 100644 index 00000000..15f3faa6 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc @@ -0,0 +1,18 @@ +import "FlowPriceOracleAggregatorv1" + +transaction( + storageID: UInt64, + forToken: Type, + price: UFix64?, +) { + execute { + let realPrice = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( + ofToken: forToken, + ) + if price != realPrice { + log(price) + log(realPrice) + panic("invalid price") + } + } +} \ No newline at end of file diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index d0e8c61e..818e4a45 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -18,29 +18,20 @@ transaction( priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, unitOfAccount: Type, - cronExpression: String, - cronHandlerStoragePath: StoragePath, - keeperExecutionEffort: UInt64, - executorExecutionEffort: UInt64, - aggregatorCronHandlerStoragePath: StoragePath ) { let oracles: [{DeFiActions.PriceOracle}] - let signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account - let feeProviderCap: Capability - prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) { - self.feeProviderCap = signer.capabilities.storage.issue(/storage/flowTokenVault) - self.signer = signer + prepare() { self.oracles = [] var i = 0 while i < oracleCount { - self.oracles.append(MultiMockOracle.createPriceOracle(unitOfAccountType: Type<@MOET.Vault>())) + self.oracles.append(MultiMockOracle.createPriceOracle(unitOfAccountType: unitOfAccount)) i = i + 1 } } execute { - let uuid = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + let _ = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, @@ -50,93 +41,5 @@ transaction( maxPriceHistoryAge: maxPriceHistoryAge, unitOfAccount: unitOfAccount ) - - // Create cron handler for the aggregator, look at flow-cron for reference - - let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(storageID: uuid) - self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) - let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) - assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") - - self.signer.storage.save(<-FlowTransactionSchedulerUtils.createManager(), to: FlowTransactionSchedulerUtils.managerStoragePath) - let schedulerManagerCap = self.signer.capabilities.storage.issue( - FlowTransactionSchedulerUtils.managerStoragePath - ) - assert(schedulerManagerCap.check(), message: "Invalid scheduler manager capability") - let manager = self.signer.storage.borrow( - from: FlowTransactionSchedulerUtils.managerStoragePath - ) ?? panic("Cannot borrow manager") - - let cronHandler <- FlowCron.createCronHandler( - cronExpression: cronExpression, - wrappedHandlerCap: wrappedHandlerCap, - feeProviderCap: self.feeProviderCap, - schedulerManagerCap: schedulerManagerCap - ) - self.signer.storage.save(<-cronHandler, to: cronHandlerStoragePath) - let cronHandlerCap = self.signer.capabilities.storage.issue(cronHandlerStoragePath) - assert(cronHandlerCap.check(), message: "Invalid cron handler capability") - - // Use the official FlowFees calculation - let executorBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) - // Scale the execution fee by the multiplier for the priority - let executorScaledExecutionFee = executorBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowTransactionScheduler.Priority.Low]! - // Add inclusion Flow fee for scheduled transactions - let inclusionFee = 0.00001 - - let executorFlowFee = executorScaledExecutionFee + inclusionFee - - let keeperBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) - let keeperScaledExecutionFee = keeperBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowCron.keeperPriority]! - let keeperFlowFee = keeperScaledExecutionFee + inclusionFee - - let totalFee = executorFlowFee + keeperFlowFee - - // Borrow fee vault and check balance - let feeVault = self.signer.storage.borrow(from: /storage/flowTokenVault) - ?? panic("Flow token vault not found") - - if feeVault.balance < totalFee { - panic("Insufficient funds: required \(totalFee.toString()) FLOW (executor: \(executorFlowFee.toString()), keeper: \(keeperFlowFee.toString())), available \(feeVault.balance.toString())") - } - - // Withdraw fees for BOTH transactions - let executorFees <- feeVault.withdraw(amount: executorFlowFee) as! @FlowToken.Vault - let keeperFees <- feeVault.withdraw(amount: keeperFlowFee) as! @FlowToken.Vault - - let executorContext = FlowCron.CronContext( - executionMode: FlowCron.ExecutionMode.Executor, - executorPriority: FlowTransactionScheduler.Priority.Low, - executorExecutionEffort: executorExecutionEffort, - keeperExecutionEffort: keeperExecutionEffort, - wrappedData: nil - ) - - let executorTxID = manager.schedule( - handlerCap: cronHandlerCap, - data: executorContext, - timestamp: UFix64(getCurrentBlock().timestamp + 1.0), - priority: FlowTransactionScheduler.Priority.Low, - executionEffort: executorExecutionEffort, - fees: <-executorFees - ) - - let keeperContext = FlowCron.CronContext( - executionMode: FlowCron.ExecutionMode.Keeper, - executorPriority: FlowTransactionScheduler.Priority.Low, - executorExecutionEffort: executorExecutionEffort, - keeperExecutionEffort: keeperExecutionEffort, - wrappedData: nil - ) - - // Schedule KEEPER transaction (1 second after executor to prevent race conditions) - let keeperTxID = manager.schedule( - handlerCap: cronHandlerCap, - data: keeperContext, - timestamp: UFix64(getCurrentBlock().timestamp + 2.0), - priority: FlowCron.keeperPriority, - executionEffort: keeperExecutionEffort, - fees: <-keeperFees - ) } } \ No newline at end of file diff --git a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc new file mode 100644 index 00000000..133c39ba --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -0,0 +1,142 @@ +import "DeFiActions" +import "FlowPriceOracleAggregatorv1" +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FungibleToken" +import "FlowToken" +import "FlowFees" +import "FlowCron" +import "MultiMockOracle" +import "MOET" + +transaction( + ofToken: Type, + oracleCount: Int, + maxSpread: UFix64, + maxGradient: UFix64, + priceHistorySize: Int, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + unitOfAccount: Type, + cronExpression: String, + cronHandlerStoragePath: StoragePath, + keeperExecutionEffort: UInt64, + executorExecutionEffort: UInt64, + aggregatorCronHandlerStoragePath: StoragePath +) { + let oracles: [{DeFiActions.PriceOracle}] + let signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account + let feeProviderCap: Capability + + prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) { + self.feeProviderCap = signer.capabilities.storage.issue(/storage/flowTokenVault) + self.signer = signer + self.oracles = [] + var i = 0 + while i < oracleCount { + self.oracles.append(MultiMockOracle.createPriceOracle(unitOfAccountType: unitOfAccount)) + i = i + 1 + } + } + + execute { + let uuid = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + ofToken: ofToken, + oracles: self.oracles, + maxSpread: maxSpread, + maxGradient: maxGradient, + priceHistorySize: priceHistorySize, + priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, + unitOfAccount: unitOfAccount + ) + + // Create cron handler for the aggregator, look at flow-cron for reference + + let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(storageID: uuid) + self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) + let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) + assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") + + self.signer.storage.save(<-FlowTransactionSchedulerUtils.createManager(), to: FlowTransactionSchedulerUtils.managerStoragePath) + let schedulerManagerCap = self.signer.capabilities.storage.issue( + FlowTransactionSchedulerUtils.managerStoragePath + ) + assert(schedulerManagerCap.check(), message: "Invalid scheduler manager capability") + let manager = self.signer.storage.borrow( + from: FlowTransactionSchedulerUtils.managerStoragePath + ) ?? panic("Cannot borrow manager") + + let cronHandler <- FlowCron.createCronHandler( + cronExpression: cronExpression, + wrappedHandlerCap: wrappedHandlerCap, + feeProviderCap: self.feeProviderCap, + schedulerManagerCap: schedulerManagerCap + ) + self.signer.storage.save(<-cronHandler, to: cronHandlerStoragePath) + let cronHandlerCap = self.signer.capabilities.storage.issue(cronHandlerStoragePath) + assert(cronHandlerCap.check(), message: "Invalid cron handler capability") + + // Use the official FlowFees calculation + let executorBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) + // Scale the execution fee by the multiplier for the priority + let executorScaledExecutionFee = executorBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowTransactionScheduler.Priority.Low]! + // Add inclusion Flow fee for scheduled transactions + let inclusionFee = 0.00001 + + let executorFlowFee = executorScaledExecutionFee + inclusionFee + + let keeperBaseFee = FlowFees.computeFees(inclusionEffort: 1.0, executionEffort: 0.0) + let keeperScaledExecutionFee = keeperBaseFee * FlowTransactionScheduler.getConfig().priorityFeeMultipliers[FlowCron.keeperPriority]! + let keeperFlowFee = keeperScaledExecutionFee + inclusionFee + + let totalFee = executorFlowFee + keeperFlowFee + + // Borrow fee vault and check balance + let feeVault = self.signer.storage.borrow(from: /storage/flowTokenVault) + ?? panic("Flow token vault not found") + + if feeVault.balance < totalFee { + panic("Insufficient funds: required \(totalFee.toString()) FLOW (executor: \(executorFlowFee.toString()), keeper: \(keeperFlowFee.toString())), available \(feeVault.balance.toString())") + } + + // Withdraw fees for BOTH transactions + let executorFees <- feeVault.withdraw(amount: executorFlowFee) as! @FlowToken.Vault + let keeperFees <- feeVault.withdraw(amount: keeperFlowFee) as! @FlowToken.Vault + + let executorContext = FlowCron.CronContext( + executionMode: FlowCron.ExecutionMode.Executor, + executorPriority: FlowTransactionScheduler.Priority.Low, + executorExecutionEffort: executorExecutionEffort, + keeperExecutionEffort: keeperExecutionEffort, + wrappedData: nil + ) + + let executorTxID = manager.schedule( + handlerCap: cronHandlerCap, + data: executorContext, + timestamp: UFix64(getCurrentBlock().timestamp + 1.0), + priority: FlowTransactionScheduler.Priority.Low, + executionEffort: executorExecutionEffort, + fees: <-executorFees + ) + + let keeperContext = FlowCron.CronContext( + executionMode: FlowCron.ExecutionMode.Keeper, + executorPriority: FlowTransactionScheduler.Priority.Low, + executorExecutionEffort: executorExecutionEffort, + keeperExecutionEffort: keeperExecutionEffort, + wrappedData: nil + ) + + // Schedule KEEPER transaction (1 second after executor to prevent race conditions) + let keeperTxID = manager.schedule( + handlerCap: cronHandlerCap, + data: keeperContext, + timestamp: UFix64(getCurrentBlock().timestamp + 2.0), + priority: FlowCron.keeperPriority, + executionEffort: keeperExecutionEffort, + fees: <-keeperFees + ) + } +} \ No newline at end of file From 66e24f669608cf0c3c508b1f86e10a1776a993e0 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Thu, 19 Feb 2026 11:56:02 +0100 Subject: [PATCH 10/18] add cron test --- .../tests/price_oracle_aggregator_test.cdc | 32 ++++++++++++++++++- cadence/tests/price_oracle_router_test.cdc | 5 +-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 43187574..670d9741 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -457,7 +457,37 @@ access(all) fun test_allow_jitter() { } access(all) fun test_cron_job() { - Test.assert(false, message: "not implemented") + let info = createAggregatorWithCron( + signer: signer, + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: UFix64.max, + maxGradient: 1.0, + priceHistorySize: 5, + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 600.0, // 10 minutes + unitOfAccount: Type<@MOET.Vault>(), + cronExpression: "* * * * *", // every minute + cronHandlerStoragePath: StoragePath(identifier: "cronHandler")!, + keeperExecutionEffort: 7500, + executorExecutionEffort: 2500, + aggregatorCronHandlerStoragePath: StoragePath(identifier: "aggregatorCronHandler")!, + ) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 1.0, + ) + var i = 0; + Test.moveTime(by: 10.0) + while i < 5 { + let history = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) + Test.assertEqual(history.length, i + 1) + let timeDelta = Fix64(getCurrentBlock().timestamp) - Fix64(history[i].timestamp) + Test.assert(timeDelta < 30.0, message: "timestamp mismatch") + i = i + 1 + Test.moveTime(by: 60.0) + } } access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?], forToken: Type) { diff --git a/cadence/tests/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc index da09acd5..28278947 100644 --- a/cadence/tests/price_oracle_router_test.cdc +++ b/cadence/tests/price_oracle_router_test.cdc @@ -17,8 +17,9 @@ access(all) fun setup() { } access(all) fun beforeEach() { - Test.commitBlock() - Test.reset(to: snapshot) + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } } access(all) fun test_router_add_oracle() { From 997059532a195a04c5f2fd84bc5b4b0b56747db0 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Thu, 19 Feb 2026 13:44:05 +0100 Subject: [PATCH 11/18] add baseTolerance to oracle aggregator --- PriceOracleArchitecture.md | 45 ++-- .../contracts/FlowPriceOracleAggregatorv1.cdc | 110 ++++++--- .../tests/price_oracle_aggregator_test.cdc | 210 ++++++++++-------- .../test_helpers_price_oracle_aggregator.cdc | 11 +- .../price-oracle-aggregator/create.cdc | 6 +- .../create_with_cron.cdc | 6 +- 6 files changed, 230 insertions(+), 158 deletions(-) diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md index 4c6521a8..5d30b50f 100644 --- a/PriceOracleArchitecture.md +++ b/PriceOracleArchitecture.md @@ -11,15 +11,11 @@ Two contracts implement this design: | Contract | Role | |----------|------| -| **FlowPriceOracleAggregatorv1** | 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 gradient is stable. | +| **FlowPriceOracleAggregatorv1** | 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). | | **FlowPriceOracleRouterv1** | Exposes **one** `DeFiActions.PriceOracle` that routes by token type. Each token has its own oracle; typically each oracle is an aggregator. | 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. -That makes total sense. Direct mutations in production are essentially "testing in prod," which is a recipe for disaster. Forcing a full replacement ensures a clean audit trail and clear governance. - -Here is a refined version that incorporates those specific points: - ### Immutable Configuration The **Aggregator** and **Router** are immutable by design to eliminate the risks associated with live production changes. @@ -37,17 +33,18 @@ One aggregated oracle per “market” (e.g. FLOW in USDC). Multiple underlying 2. If any oracle returns nil → emit `PriceNotAvailable`, return nil. 3. Compute min/max; if spread > `maxSpread` → emit `PriceNotWithinSpreadTolerance`, return nil. 4. Compute aggregated price (trimmed mean: drop min and max, average the rest). - 5. Check short-term stability: compare current price to recent history; if any gradient > `maxGradient` → emit `PriceNotStable`, return nil. + 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. 6. Otherwise return the aggregated price. - **History:** An array of `(price, timestamp)` is maintained. Updates are permissionless via `tryAddPriceToHistory()` (idempotent); A FlowCron job should be created to call this regularly. Additionally every call to price() will also attempt to store the price in the history. ## Aggregate price (trimmed mean) -To avoid the complexity of a full median, the aggregator uses a **trimmed mean**: remove the single maximum and single minimum, then average the rest. This reduces the impact of a single outlier or “oracle jitter.” +To avoid the complexity of a full median, the aggregator uses a **trimmed mean**: remove the single maximum and single minimum, then average the rest. This reduces the impact of a single outlier. -- With <2 prices: mean -- With 3+ prices: `(sum - min - max) / (count - 2)`. +- With 1 oracle: that price. +- With 2 oracles: arithmetic mean. +- With 3+ oracles: trimmed mean `(sum - min - max) / (count - 2)`. ## Oracle spread (coherence) @@ -67,30 +64,44 @@ $$ \end{cases} $$ -## Short-term gradient (stability) +## Short-term stability (history tolerance) The aggregator keeps an array of the last **n** aggregated prices (with timestamps), respecting `priceHistoryInterval` and `maxPriceHistoryAge`. -For each historical point (i), the **gradient to the current price** is the relative change per unit time (scaled for “per minute”): +Stability is defined by two parameters: + +- **baseTolerance** (n): fixed buffer to account for immediate market noise. +- **driftExpansionRate** (m): additional allowance per minute to account for natural price drift. + +For each historical point (i), the **allowed relative difference** between the current price and the history price grows with time: $$ -\text{Gradient}_{i} = \frac{|Price_{\text{current}} - Price_{i}|}{\min(Price_{\text{current}}, Price_{i}) \cdot (t_{\text{current}} - t_{i})} \times \text{6000} +\text{allowedRelativeDiff}_{i} = \text{baseTolerance} + \text{driftExpansionRate} \times \Delta t_{\text{minutes}} $$ -The current price is **stable** only if **every** such gradient (from each valid history entry to the current price) is at or below the configured `maxGradient`. If **any** gradient is above the threshold, the aggregator emits `PriceNotStable(gradient)` and returns nil. +where \(\Delta t_{\text{minutes}}\) is the time in minutes from the history entry to now. The **actual relative difference** is: + +$$ +\text{relativeDiff}_{i} = \frac{|Price_{\text{current}} - Price_{i}|}{\min(Price_{\text{current}}, Price_{i})} +$$ + +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. $$ \text{isStable} = \begin{cases} -\text{true} & \text{if } \text{Gradient}_{i} \le \text{maxGradient} \text{ for all } i \\ +\text{true} & \text{if } \text{relativeDiff}_{i} \le \text{allowedRelativeDiff}_{i} \text{ for all } i \\ \text{false} & \text{otherwise (price invalid)} \end{cases} $$ -Implementationally, entries older than `maxPriceHistoryAge` are ignored; same-block timestamps are treated with a minimum time delta of 1 to allow small jitter within the same block. +Implementationally, entries older than `maxPriceHistoryAge` are ignored when evaluating stability. ---- +**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. ## FlowPriceOracleRouterv1 -Single oracle interface that routes by **token type**. Each token type maps to an oracle. This makes it easy to combine different aggrigators without the need to supply different kinds of thresholds for individual token types. +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. + +- **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). +- **Empty router:** If the oracle map is empty or a token type is not registered, `price(ofToken)` returns `nil`. diff --git a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc index 631f1af1..386089e5 100644 --- a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc +++ b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc @@ -6,7 +6,7 @@ import "FlowTransactionScheduler" /// sources into a single trusted oracle. A price is returned only when: /// - All oracles return a value (no missing data), /// - The spread between min and max oracle prices is within `maxSpread`, -/// - Short-term price gradient vs. recent history is within `maxGradient`. +/// - Short-term price history is stable. /// /// One aggregator instance = one market (one token type). For multiple /// markets, create one storage per market and use a router to expose them. @@ -20,9 +20,16 @@ access(all) contract FlowPriceOracleAggregatorv1 { access(all) event PriceNotAvailable() /// Spread between min and max oracle prices exceeded the configured /// tolerance. - access(all) event PriceNotWithinSpreadTolerance(spread: UFix64) - /// Short-term price change (gradient) exceeded the configured tolerance. - access(all) event PriceNotWithinGradientTolerance(gradient: UFix64) + access(all) event PriceNotWithinSpreadTolerance( + spread: UFix64, + maxAllowedSpread: UFix64 + ) + /// Short-term price change exceeded the configured tolerance. + access(all) event PriceNotWithinHistoryTolerance( + relativeDiff: UFix64, + deltaTMinutes: UFix64, + maxAllowedRelativeDiff: UFix64 + ) /// storageID -> PriceOracleAggregatorStorage access(self) var storage: @{UInt64: PriceOracleAggregatorStorage} @@ -31,27 +38,28 @@ access(all) contract FlowPriceOracleAggregatorv1 { } /// Storage resource for one aggregated oracle (single market): a fixed - /// set of oracles, tolerances, and an array of recent prices for gradient + /// set of oracles, tolerances, and an array of recent prices for history /// (stability) checks. Immutable: no post-creation config change to avoid /// accidental misconfiguration in production. access(all) resource PriceOracleAggregatorStorage { /// Token type for this oracle. access(all) let ofToken: Type - /// Recent prices for gradient (stability) checks. + /// Recent prices for history stability checks. access(all) let priceHistory: [PriceHistoryEntry] /// Fixed set of oracles. access(all) let oracles: [{DeFiActions.PriceOracle}] /// Max allowed relative spread (max-min)/min between oracle prices. access(all) let maxSpread: UFix64 - /// Max allowed short-term gradient (effective % change per minute). - access(all) let maxGradient: UFix64 - /// Length of the price history array for gradient stability checks. + /// Fixed relative buffer to account for immediate market noise. + access(all) let baseTolerance: UFix64 + /// Additional allowance per minute to account for natural price drift. + access(all) let driftExpansionRate: UFix64 + /// Size of the price history array. access(all) let priceHistorySize: Int /// Min time between two consecutive history entries. access(all) let priceHistoryInterval: UFix64 - /// Maximum age of a price history entry. - /// History entries older than this are ignored when computing gradient - /// stability. + /// Maximum age of a price history entry. History entries older than + /// this are ignored when computing history stability. access(all) let maxPriceHistoryAge: UFix64 /// Unit of account type for this oracle. access(all) let unitOfAccountType: Type @@ -60,17 +68,29 @@ access(all) contract FlowPriceOracleAggregatorv1 { ofToken: Type, oracles: [{DeFiActions.PriceOracle}], maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, unitOfAccount: Type, ) { + pre { + oracles.length > 0: + "at least one oracle must be provided" + maxSpread <= 10000.0: + "maxSpread must be <= 10000.0" + baseTolerance <= 10000.0: + "baseTolerance must be <= 10000.0" + driftExpansionRate <= 10000.0: + "driftExpansionRate must be <= 10000.0" + } self.ofToken = ofToken self.oracles = oracles self.priceHistory = [] self.maxSpread = maxSpread - self.maxGradient = maxGradient + self.baseTolerance = baseTolerance + self.driftExpansionRate = driftExpansionRate self.priceHistorySize = priceHistorySize self.priceHistoryInterval = priceHistoryInterval self.maxPriceHistoryAge = maxPriceHistoryAge @@ -81,17 +101,17 @@ access(all) contract FlowPriceOracleAggregatorv1 { /// - no oracle defined for `ofToken`, /// - oracle returned nil, /// - spread between min and max prices is too high, - /// - gradient is too high. + /// - history is not stable. access(all) fun price(ofToken: Type): UFix64? { pre { self.ofToken == ofToken: "ofToken type mismatch" } - let price = self.getPriceUncheckedGradient() + let price = self.getPriceUncheckedHistory() if price == nil { return nil } self.tryAddPriceToHistoryInternal(price: price!) - if !self.isGradientStable(currentPrice: price!) { + if !self.isHistoryStable(currentPrice: price!) { return nil } return price @@ -101,14 +121,14 @@ access(all) contract FlowPriceOracleAggregatorv1 { /// to history if available and interval has elapsed. /// Idempotent; safe to call from a cron/scheduler. access(all) fun tryAddPriceToHistory() { - let price = self.getPriceUncheckedGradient() + let price = self.getPriceUncheckedHistory() if price == nil { return } self.tryAddPriceToHistoryInternal(price: price!) } - access(self) fun getPriceUncheckedGradient(): UFix64? { + access(self) fun getPriceUncheckedHistory(): UFix64? { let prices = self.getPrices() if prices == nil || prices!.length == 0 { return nil @@ -160,7 +180,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { ): Bool { let spread = (maxPrice - minPrice) / minPrice if spread > self.maxSpread { - emit PriceNotWithinSpreadTolerance(spread: spread) + emit PriceNotWithinSpreadTolerance( + spread: spread, + maxAllowedSpread: self.maxSpread + ) return false } return true @@ -186,27 +209,38 @@ access(all) contract FlowPriceOracleAggregatorv1 { return trimmedSum / UFix64(count - 2) } - access(self) fun isGradientStable(currentPrice: UFix64): Bool { + access(self) fun isHistoryStable(currentPrice: UFix64): Bool { let now = getCurrentBlock().timestamp + for entry in self.priceHistory { - var deltaT = now - UFix64(entry.timestamp) - if deltaT == 0.0 { - // Same block: allow some jitter (avoid div by zero). - deltaT = 1.0 - } + let deltaT = now - UFix64(entry.timestamp) + let deltaTMinutes = deltaT / 60.0 + // Skip entries that are too old to be relevant for the + // stability check if deltaT > self.maxPriceHistoryAge { continue } - var gradient = 0.0 + + // Calculate the absolute relative difference (delta P / P) + var relativeDiff = 0.0 if currentPrice > entry.price { - gradient = ((currentPrice - entry.price) * 6000.0) - / (entry.price * deltaT) + let priceDiff = currentPrice - entry.price + relativeDiff = priceDiff / entry.price } else { - gradient = ((entry.price - currentPrice) * 6000.0) - / (currentPrice * deltaT) + let priceDiff = entry.price - currentPrice + relativeDiff = priceDiff / currentPrice } - if gradient > self.maxGradient { - emit PriceNotWithinGradientTolerance(gradient: gradient) + + // The "n" component: baseTolerance + // The "mx" component: driftExpansionRate * deltaT + let totalAllowedTolerance = self.baseTolerance + (self.driftExpansionRate * deltaTMinutes) + + if relativeDiff > totalAllowedTolerance { + emit PriceNotWithinHistoryTolerance( + relativeDiff: relativeDiff, + deltaTMinutes: deltaTMinutes, + maxAllowedRelativeDiff: totalAllowedTolerance + ) return false } } @@ -234,7 +268,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { /// Struct over a `PriceOracleAggregatorStorage` /// See `DeFiActions.PriceOracle` for interface documentation. /// - /// Additionaly implements `priceHistory()` to return the price history + /// Additionally implements `priceHistory()` to return the price history /// array. access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { access(all) let storageID: UInt64 @@ -315,7 +349,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { ofToken: Type, oracles: [{DeFiActions.PriceOracle}], maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, @@ -325,7 +360,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { ofToken: ofToken, oracles: oracles, maxSpread: maxSpread, - maxGradient: maxGradient, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, @@ -361,7 +397,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { } /// Struct to store one entry in the aggregator's price history array for - /// gradient stability checks. + /// history stability checks. access(all) struct PriceHistoryEntry { access(all) let price: UFix64 access(all) let timestamp: UFix64 diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 670d9741..e6d81415 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -27,8 +27,9 @@ access(all) fun test_single_oracle() { let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, - maxSpread: 0.0, - maxGradient: 0.0, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, @@ -58,8 +59,9 @@ access(all) fun test_multiple_oracles() { let info = createAggregator( ofToken: Type<@MOET.Vault>(), oracleCount: oracleCount, - maxSpread: 0.0, - maxGradient: 0.0, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, @@ -143,8 +145,9 @@ access(all) fun test_average_price() { let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, - maxSpread: UFix64.max, - maxGradient: UFix64.max, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, @@ -157,7 +160,7 @@ access(all) fun test_average_price() { ) if price != testRun.expectedPrice { log(testRun) - log_fail_events() + logFailEvents() Test.fail(message: "invalid price") } } @@ -201,11 +204,11 @@ access(all) fun test_spread() { prices: [1.0, 1.0001, 1.0], expectedPrice: nil, ), TestRunSpread( - maxSpread: 1.0, + maxSpread: 10000.0, prices: [1.0, 2.0], expectedPrice: 1.5, ), TestRunSpread( - maxSpread: 1.0, + maxSpread: 10000.0, prices: [1.0, 1.5, 2.0], expectedPrice: 1.5, )] @@ -226,7 +229,8 @@ access(all) fun test_spread() { ofToken: Type<@FlowToken.Vault>(), oracleCount: testRun.prices.length, maxSpread: testRun.maxSpread, - maxGradient: UFix64.max, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, @@ -239,67 +243,118 @@ access(all) fun test_spread() { ) if price != testRun.expectedPrice { log(testRun) - log_fail_events() + logFailEvents() log(price) Test.assertEqual(testRun.expectedPrice, price) } } } -access(all) struct TestRunGradient { - access(all) let maxGradient: UFix64 +access(all) struct TestRunHistory { + access(all) let baseTolerance: UFix64 + access(all) let driftExpansionRate: UFix64 access(all) let priceHistory: [UFix64] access(all) let priceHistoryDelay: Fix64 - access(all) let isGradientStable: Bool + access(all) let isHistoryStable: Bool - init(maxGradient: UFix64, priceHistory: [UFix64], priceHistoryDelay: Fix64, isGradientStable: Bool) { - self.maxGradient = maxGradient + init( + baseTolerance: UFix64, + driftExpansionRate: UFix64, + priceHistory: [UFix64], + priceHistoryDelay: Fix64, + isHistoryStable: Bool, + ) { + self.baseTolerance = baseTolerance + self.driftExpansionRate = driftExpansionRate self.priceHistory = priceHistory self.priceHistoryDelay = priceHistoryDelay - self.isGradientStable = isGradientStable + self.isHistoryStable = isHistoryStable } } -access(all) fun test_gradient() { +access(all) fun test_history() { let testRuns = [ - TestRunGradient( - maxGradient: 0.0, + TestRunHistory( + baseTolerance: 0.0, + driftExpansionRate: 0.0, priceHistory: [1.0], priceHistoryDelay: 60.0, - isGradientStable: true, - ),TestRunGradient( - maxGradient: 100.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 1.0, + driftExpansionRate: 0.0, priceHistory: [1.0, 2.0], priceHistoryDelay: 60.0, - isGradientStable: true, - ),TestRunGradient( - maxGradient: 95.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.95, + driftExpansionRate: 0.0, priceHistory: [1.0, 2.0], priceHistoryDelay: 60.0, - isGradientStable: false, - ),TestRunGradient( - maxGradient: 100.0, - priceHistory: [1.0, 2.0, 3.1], + isHistoryStable: false, + ),TestRunHistory( + baseTolerance: 1.0, + driftExpansionRate: 0.0, + priceHistory: [1.0, 2.0, 2.1], priceHistoryDelay: 60.0, - isGradientStable: false, - ),TestRunGradient( - maxGradient: 100.0, + isHistoryStable: false, + ),TestRunHistory( + baseTolerance: 1.0, + driftExpansionRate: 0.0, priceHistory: [2.0, 1.0, 3.0, 2.0], priceHistoryDelay: 60.0, - isGradientStable: true, - ),TestRunGradient( - maxGradient: 0.1, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.1 / 100.0, + driftExpansionRate: 0.0, priceHistory: [100.0, 100.1, 100.1, 100.1, 100.1, 100.2], priceHistoryDelay: 60.0, - isGradientStable: true, + isHistoryStable: false, + ),TestRunHistory( + baseTolerance: 0.0, + driftExpansionRate: 1.0, + priceHistory: [1.0, 2.0, 3.0], + priceHistoryDelay: 60.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.0, + driftExpansionRate: 1.0, + priceHistory: [1.0, 2.0, 3.1], + priceHistoryDelay: 60.0, + isHistoryStable: false, + ),TestRunHistory( + baseTolerance: 0.0, + driftExpansionRate: 1.0, + priceHistory: [2.0 , 1.0, 3.0, 2.0], + priceHistoryDelay: 60.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.0, + driftExpansionRate: 0.1, + priceHistory: [1.0, 1.1, 1.2, 1.3], + priceHistoryDelay: 60.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.2, + driftExpansionRate: 0.1, + priceHistory: [1.0, 1.3], + priceHistoryDelay: 60.0, + isHistoryStable: true, + ),TestRunHistory( + baseTolerance: 0.2, + driftExpansionRate: 0.1, + priceHistory: [1.0, 1.31], + priceHistoryDelay: 60.0, + isHistoryStable: false, )] - let reversedRuns: [TestRunGradient] = [] + let reversedRuns: [TestRunHistory] = [] for testRun in testRuns { - reversedRuns.append(TestRunGradient( - maxGradient: testRun.maxGradient, + reversedRuns.append(TestRunHistory( + baseTolerance: testRun.baseTolerance, + driftExpansionRate: testRun.driftExpansionRate, priceHistory: testRun.priceHistory.reverse(), priceHistoryDelay: testRun.priceHistoryDelay, - isGradientStable: testRun.isGradientStable, + isHistoryStable: testRun.isHistoryStable, )) } testRuns.appendAll(reversedRuns) @@ -310,10 +365,11 @@ access(all) fun test_gradient() { let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, - maxSpread: UFix64.max, - maxGradient: testRun.maxGradient, + maxSpread: 10000.0, + baseTolerance: testRun.baseTolerance, + driftExpansionRate: testRun.driftExpansionRate, priceHistorySize: testRun.priceHistory.length, - priceHistoryInterval: 59.0, // allow some jitter + priceHistoryInterval: UFix64(testRun.priceHistoryDelay - 1.0), // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes unitOfAccount: Type<@MOET.Vault>(), ) @@ -343,9 +399,10 @@ access(all) fun test_gradient() { ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil - if priceIsStable != testRun.isGradientStable { + if priceIsStable != testRun.isHistoryStable { log(testRun) - log_fail_events() + log(price) + logFailEvents() Test.fail(message: "invalid price") } } @@ -356,8 +413,9 @@ access(self) fun test_gradient_incomplete_price_history() { let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, - maxSpread: UFix64.max, - maxGradient: 100.0, + maxSpread: 10000.0, + baseTolerance: 0.0, + driftExpansionRate: 1.0, priceHistorySize: priceHistory.length, priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes @@ -389,8 +447,9 @@ access(self) fun test_gradient_old_price_history() { let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, - maxSpread: UFix64.max, - maxGradient: 1.0, + maxSpread: 10000.0, + baseTolerance: 0.0, + driftExpansionRate: 1.0, priceHistorySize: priceHistory.length, priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 150.0, @@ -416,53 +475,14 @@ access(self) fun test_gradient_old_price_history() { Test.assertEqual(priceIsStable, true) } -access(all) fun test_allow_jitter() { - let info = createAggregator( - ofToken: Type<@FlowToken.Vault>(), - oracleCount: 1, - maxSpread: UFix64.max, - maxGradient: 1.0, - priceHistorySize: 1, - priceHistoryInterval: 59.0, // allow some jitter - maxPriceHistoryAge: 150.0, - unitOfAccount: Type<@MOET.Vault>(), - ) - setMultiMockOraclePrice( - storageID: info.mockOracleStorageIDs[0], - forToken: Type<@FlowToken.Vault>(), - price: 1.0, - ) - // now everything needs to be in the same block - var txs: [Test.Transaction] = [] - let originalHeight = getCurrentBlockHeight() - let originalTimestamp = getCurrentBlock().timestamp - txs.append(oracleAggregatorPriceTx( - storageID: info.aggregatorStorageID, - ofToken: Type<@FlowToken.Vault>() - )) - txs.append(setMultiMockOraclePriceTx( - storageID: info.mockOracleStorageIDs[0], - forToken: Type<@FlowToken.Vault>(), - price: 1.00000001, - )) - txs.append(oracleAggregatorAssertPriceTx( - storageID: info.aggregatorStorageID, - ofToken: Type<@FlowToken.Vault>(), - price: 1.00000001, - )) - let res = Test.executeTransactions(txs) - for r in res { - Test.expect(r, Test.beSucceeded()) - } -} - access(all) fun test_cron_job() { let info = createAggregatorWithCron( signer: signer, ofToken: Type<@FlowToken.Vault>(), oracleCount: 1, - maxSpread: UFix64.max, - maxGradient: 1.0, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, priceHistorySize: 5, priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes @@ -479,7 +499,7 @@ access(all) fun test_cron_job() { price: 1.0, ) var i = 0; - Test.moveTime(by: 10.0) + Test.moveTime(by: 30.0) while i < 5 { let history = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) Test.assertEqual(history.length, i + 1) @@ -508,11 +528,11 @@ access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?], forTo } } -access(self) fun log_fail_events() { +access(self) fun logFailEvents() { let failureEvents = [ Type(), Type(), - Type() + Type() ] for eventType in failureEvents { let events = Test.eventsOfType(eventType) diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index 51acdf5b..d7cdaa27 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -2,7 +2,6 @@ import Test import "DeFiActions" import "FlowPriceOracleAggregatorv1" import "MultiMockOracle" -// import "test_helpers.cdc" access(all) struct CreateAggregatorInfo { access(all) let aggregatorStorageID: UInt64 @@ -18,7 +17,8 @@ access(all) fun createAggregator( ofToken: Type, oracleCount: Int, maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, @@ -26,7 +26,7 @@ access(all) fun createAggregator( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create.cdc", - [ofToken, oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount], [] ) Test.expect(res, Test.beSucceeded()) @@ -51,7 +51,8 @@ access(all) fun createAggregatorWithCron( ofToken: Type, oracleCount: Int, maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, @@ -64,7 +65,7 @@ access(all) fun createAggregatorWithCron( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create_with_cron.cdc", - [ofToken, oracleCount, maxSpread, maxGradient, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) Test.expect(res, Test.beSucceeded()) diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index 818e4a45..ba0e3159 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -13,7 +13,8 @@ transaction( ofToken: Type, oracleCount: Int, maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, @@ -35,7 +36,8 @@ transaction( ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, - maxGradient: maxGradient, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, diff --git a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc index 133c39ba..03276f0a 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -13,7 +13,8 @@ transaction( ofToken: Type, oracleCount: Int, maxSpread: UFix64, - maxGradient: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, priceHistorySize: Int, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, @@ -44,7 +45,8 @@ transaction( ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, - maxGradient: maxGradient, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, From 3404b3610d4d587821fb3d7f7334206804cb259c Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Thu, 19 Feb 2026 14:29:28 +0100 Subject: [PATCH 12/18] fix case-sensitive V1 --- PriceOracleArchitecture.md | 2 +- cadence/tests/test_helpers.cdc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md index 5d30b50f..7a09d125 100644 --- a/PriceOracleArchitecture.md +++ b/PriceOracleArchitecture.md @@ -79,7 +79,7 @@ $$ \text{allowedRelativeDiff}_{i} = \text{baseTolerance} + \text{driftExpansionRate} \times \Delta t_{\text{minutes}} $$ -where \(\Delta t_{\text{minutes}}\) is the time in minutes from the history entry to now. The **actual relative difference** is: +where Delta t_minutes is the time in minutes from the history entry to now. The **actual relative difference** is: $$ \text{relativeDiff}_{i} = \frac{|Price_{\text{current}} - Price_{i}|}{\min(Price_{\text{current}}, Price_{i})} diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index c1b93f0d..5550cefe 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -120,7 +120,7 @@ fun deployContracts() { err = Test.deployContract( name: "FlowPriceOracleAggregatorv1", - path: "../contracts/FlowPriceOracleAggregatorV1.cdc", + path: "../contracts/FlowPriceOracleAggregatorv1.cdc", arguments: [] ) Test.expect(err, Test.beNil()) From 2e04bfcec9e48e507aa22deecbc87ff63b844a4a Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Sat, 21 Feb 2026 22:28:55 +0100 Subject: [PATCH 13/18] address pr comments --- .../contracts/FlowPriceOracleAggregatorv1.cdc | 117 ++++----- cadence/contracts/FlowPriceOracleRouterv1.cdc | 10 +- .../tests/price_oracle_aggregator_test.cdc | 234 +++++++++++++++--- cadence/tests/price_oracle_router_test.cdc | 63 ++++- .../get_supported_tokens.cdc | 8 + .../is_token_supported.cdc | 8 + .../test_helpers_price_oracle_aggregator.cdc | 10 +- .../test_helpers_price_oracle_router.cdc | 18 ++ .../price-oracle-aggregator/create.cdc | 4 +- .../create_with_cron.cdc | 4 +- 10 files changed, 376 insertions(+), 100 deletions(-) create mode 100644 cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc create mode 100644 cadence/tests/scripts/price-oracle-router/is_token_supported.cdc diff --git a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc index 386089e5..0788a069 100644 --- a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc +++ b/cadence/contracts/FlowPriceOracleAggregatorv1.cdc @@ -17,7 +17,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { access(all) event StorageCreated(storageID: UInt64) /// At least one underlying oracle did not return a price for the requested /// token. - access(all) event PriceNotAvailable() + access(all) event PriceNotAvailable(oracleType: Type) /// Spread between min and max oracle prices exceeded the configured /// tolerance. access(all) event PriceNotWithinSpreadTolerance( @@ -55,12 +55,16 @@ access(all) contract FlowPriceOracleAggregatorv1 { /// Additional allowance per minute to account for natural price drift. access(all) let driftExpansionRate: UFix64 /// Size of the price history array. - access(all) let priceHistorySize: Int + access(all) let priceHistorySize: UInt8 /// Min time between two consecutive history entries. access(all) let priceHistoryInterval: UFix64 /// Maximum age of a price history entry. History entries older than /// this are ignored when computing history stability. access(all) let maxPriceHistoryAge: UFix64 + /// Minimum number of (non-expired) history entries required for the + /// history to be considered stable. If fewer entries exist, price() + /// returns nil. + access(all) let minimumPriceHistory: UInt8 /// Unit of account type for this oracle. access(all) let unitOfAccountType: Type @@ -70,9 +74,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, ) { pre { @@ -84,6 +89,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { "baseTolerance must be <= 10000.0" driftExpansionRate <= 10000.0: "driftExpansionRate must be <= 10000.0" + minimumPriceHistory <= priceHistorySize: + "minimumPriceHistory must be <= priceHistorySize" } self.ofToken = ofToken self.oracles = oracles @@ -94,6 +101,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { self.priceHistorySize = priceHistorySize self.priceHistoryInterval = priceHistoryInterval self.maxPriceHistoryAge = maxPriceHistoryAge + self.minimumPriceHistory = minimumPriceHistory self.unitOfAccountType = unitOfAccount } @@ -106,45 +114,41 @@ access(all) contract FlowPriceOracleAggregatorv1 { pre { self.ofToken == ofToken: "ofToken type mismatch" } - let price = self.getPriceUncheckedHistory() + let now = getCurrentBlock().timestamp + let price = self.getPriceUncheckedHistory(now: now) if price == nil { return nil } - self.tryAddPriceToHistoryInternal(price: price!) - if !self.isHistoryStable(currentPrice: price!) { + let validPrice = price! + if !self.isHistoryStable(currentPrice: validPrice, now: now) { return nil } - return price + return validPrice } /// Permissionless: anyone may call. Appends current aggregated price /// to history if available and interval has elapsed. /// Idempotent; safe to call from a cron/scheduler. access(all) fun tryAddPriceToHistory() { - let price = self.getPriceUncheckedHistory() - if price == nil { - return - } - self.tryAddPriceToHistoryInternal(price: price!) + let _ = self.getPriceUncheckedHistory( + now: getCurrentBlock().timestamp + ) } - access(self) fun getPriceUncheckedHistory(): UFix64? { + /// Returns the current aggregated price, checks if it is within spread + /// tolerance and adds it to the history. + /// **Does not validate that the history is stable.** + access(self) fun getPriceUncheckedHistory(now: UFix64): UFix64? { let prices = self.getPrices() if prices == nil || prices!.length == 0 { return nil } - let minAndMaxPrices = self.getMinAndMaxPrices(prices: prices!) - if !self.isWithinSpreadTolerance( - minPrice: minAndMaxPrices.min, - maxPrice: minAndMaxPrices.max, - ) { + if !self.isWithinSpreadTolerance(prices: prices!) { return nil } - return self.trimmedMeanPrice( - prices: prices!, - minPrice: minAndMaxPrices.min, - maxPrice: minAndMaxPrices.max, - ) + let price = self.trimmedMeanPrice(prices: prices!) + self.tryAddPriceToHistoryInternal(price: price!, now: now) + return price } access(self) fun getPrices(): [UFix64]? { @@ -152,7 +156,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { for oracle in self.oracles { let price = oracle.price(ofToken: self.ofToken) if price == nil { - emit PriceNotAvailable() + emit PriceNotAvailable(oracleType: oracle.getType()) return nil } prices.append(price!) @@ -160,7 +164,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { return prices } - access(self) fun getMinAndMaxPrices(prices: [UFix64]): MinAndMaxPrices { + access(self) view fun isWithinSpreadTolerance(prices: [UFix64]): Bool { + if prices.length == 0 { + return false + } var minPrice = UFix64.max var maxPrice = UFix64.min for price in prices { @@ -171,13 +178,9 @@ access(all) contract FlowPriceOracleAggregatorv1 { maxPrice = price } } - return MinAndMaxPrices(min: minPrice, max: maxPrice) - } - - access(self) view fun isWithinSpreadTolerance( - minPrice: UFix64, - maxPrice: UFix64, - ): Bool { + if minPrice == 0.0 { + return false + } let spread = (maxPrice - minPrice) / minPrice if spread > self.maxSpread { emit PriceNotWithinSpreadTolerance( @@ -189,11 +192,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { return true } - access(self) view fun trimmedMeanPrice( - prices: [UFix64], - minPrice: UFix64, - maxPrice: UFix64, - ): UFix64? { + access(self) view fun trimmedMeanPrice(prices: [UFix64]): UFix64? { let count = prices.length // Handle edge cases where trimming isn't possible @@ -202,24 +201,32 @@ access(all) contract FlowPriceOracleAggregatorv1 { if count == 2 { return (prices[0] + prices[1]) / 2.0 } var totalSum = 0.0 + var minPrice = UFix64.max + var maxPrice = UFix64.min for price in prices { + if price < minPrice { + minPrice = price + } + if price > maxPrice { + maxPrice = price + } totalSum = totalSum + price } let trimmedSum = totalSum - minPrice - maxPrice return trimmedSum / UFix64(count - 2) } - access(self) fun isHistoryStable(currentPrice: UFix64): Bool { - let now = getCurrentBlock().timestamp - + access(self) fun isHistoryStable(currentPrice: UFix64, now: UFix64): Bool { + var validEntryCount = 0 as UInt8 for entry in self.priceHistory { let deltaT = now - UFix64(entry.timestamp) - let deltaTMinutes = deltaT / 60.0 + // Skip entries that are too old to be relevant for the // stability check if deltaT > self.maxPriceHistoryAge { continue } + validEntryCount = validEntryCount + 1 // Calculate the absolute relative difference (delta P / P) var relativeDiff = 0.0 @@ -233,6 +240,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { // The "n" component: baseTolerance // The "mx" component: driftExpansionRate * deltaT + let deltaTMinutes = deltaT / 60.0 let totalAllowedTolerance = self.baseTolerance + (self.driftExpansionRate * deltaTMinutes) if relativeDiff > totalAllowedTolerance { @@ -244,11 +252,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { return false } } - return true + return validEntryCount >= self.minimumPriceHistory } - access(self) fun tryAddPriceToHistoryInternal(price: UFix64) { - let now = getCurrentBlock().timestamp + access(self) fun tryAddPriceToHistoryInternal(price: UFix64, now: UFix64) { // Only append if enough time has passed since the last entry. if self.priceHistory.length > 0 { let lastEntry = self.priceHistory[self.priceHistory.length - 1] @@ -259,8 +266,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { } let newEntry = PriceHistoryEntry(price: price, timestamp: now) self.priceHistory.append(newEntry) - if self.priceHistory.length > self.priceHistorySize { - self.priceHistory.removeFirst() + if self.priceHistory.length > Int(self.priceHistorySize) { + let _ = self.priceHistory.removeFirst() } } } @@ -277,6 +284,9 @@ access(all) contract FlowPriceOracleAggregatorv1 { init(storageID: UInt64) { self.storageID = storageID self.uniqueID = DeFiActions.createUniqueIdentifier() + if FlowPriceOracleAggregatorv1.storage.containsKey(self.storageID) == false { + panic("Storage not found for storageID: \(self.storageID)") + } } access(all) fun price(ofToken: Type): UFix64? { @@ -351,9 +361,10 @@ access(all) contract FlowPriceOracleAggregatorv1 { maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, ): UInt64 { let priceOracleAggregator <- create PriceOracleAggregatorStorage( @@ -365,6 +376,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, unitOfAccount: unitOfAccount ) let id = priceOracleAggregator.uuid @@ -385,17 +397,6 @@ access(all) contract FlowPriceOracleAggregatorv1 { return <- create PriceOracleCronHandler(storageID: storageID) } - /// Helper struct to store the min and max of a set of prices. - access(all) struct MinAndMaxPrices { - access(all) let min: UFix64 - access(all) let max: UFix64 - - init(min: UFix64, max: UFix64) { - self.min = min - self.max = max - } - } - /// Struct to store one entry in the aggregator's price history array for /// history stability checks. access(all) struct PriceHistoryEntry { diff --git a/cadence/contracts/FlowPriceOracleRouterv1.cdc b/cadence/contracts/FlowPriceOracleRouterv1.cdc index e0793113..730a2f1c 100644 --- a/cadence/contracts/FlowPriceOracleRouterv1.cdc +++ b/cadence/contracts/FlowPriceOracleRouterv1.cdc @@ -4,7 +4,7 @@ import "DeFiActions" /// routes by token type: one oracle per token. All oracles must share the /// same unit of account. Config (oracles, unit of account) is immutable at /// creation to avoid accidental changes in production. -/// Use this when the protocol needs one oracle reference but prices come +/// Use this when the protocol needs one oracle reference but prices come /// from different sources per token. access(all) contract FlowPriceOracleRouterv1 { @@ -38,6 +38,14 @@ access(all) contract FlowPriceOracleRouterv1 { return self.unitOfAccountType } + access(all) view fun getSupportedTokens(): [Type] { + return self.oracles.keys + } + + access(all) view fun isTokenSupported(tokenType: Type): Bool { + return self.oracles[tokenType] != nil + } + access(all) view fun id(): UInt64 { return self.uniqueID!.id } diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index e6d81415..98732a63 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -33,6 +33,7 @@ access(all) fun test_single_oracle() { priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] @@ -46,7 +47,7 @@ access(all) fun test_single_oracle() { storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) - Test.assertEqual(price, p) + Test.assertEqual(p, price) } } @@ -65,6 +66,7 @@ access(all) fun test_multiple_oracles() { priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, unitOfAccount: Type<@FlowToken.Vault>(), ) let prices: [UFix64?] = [1.0, 0.0001, 1337.0] @@ -80,7 +82,7 @@ access(all) fun test_multiple_oracles() { storageID: info.aggregatorStorageID, ofToken: Type<@MOET.Vault>() ) - Test.assertEqual(price, p) + Test.assertEqual(p, price) } } } @@ -151,6 +153,7 @@ access(all) fun test_average_price() { priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) @@ -179,35 +182,52 @@ access(all) struct TestRunSpread { } access(all) fun test_spread() { - let testRuns = [TestRunSpread( + // testRuns: maxSpread rejects when (max - min) / min > maxSpread + let testRuns = [ + // Spread 1.0 (100%) > maxSpread 0.9: reject + TestRunSpread( maxSpread: 0.9, prices: [1.0, 2.0], expectedPrice: nil, - ), TestRunSpread( + ), + // Spread from 1.0 to 2.0 still exceeds 0.9: reject + TestRunSpread( maxSpread: 0.9, prices: [1.0, 1.5, 2.0], expectedPrice: nil, - ), TestRunSpread( + ), + // Same spread [1.0, 2.0, 1.0]: reject + TestRunSpread( maxSpread: 0.9, prices: [1.0, 2.0, 1.0], expectedPrice: nil, - ), TestRunSpread( + ), + // All oracles agree: within any spread, accept 1.0 + TestRunSpread( maxSpread: 0.9, prices: [1.0, 1.0, 1.0, 1.0], expectedPrice: 1.0, - ), TestRunSpread( + ), + // maxSpread 0 = no tolerance; tiny diff 1.0001 vs 1.0: reject + TestRunSpread( maxSpread: 0.0, prices: [1.0, 1.0001], expectedPrice: nil, - ), TestRunSpread( + ), + // Same with three oracles: reject + TestRunSpread( maxSpread: 0.0, prices: [1.0, 1.0001, 1.0], expectedPrice: nil, - ), TestRunSpread( + ), + // Very loose maxSpread: accept average 1.5 + TestRunSpread( maxSpread: 10000.0, prices: [1.0, 2.0], expectedPrice: 1.5, - ), TestRunSpread( + ), + // Loose spread, three oracles: accept median/average 1.5 + TestRunSpread( maxSpread: 10000.0, prices: [1.0, 1.5, 2.0], expectedPrice: 1.5, @@ -234,6 +254,7 @@ access(all) fun test_spread() { priceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) @@ -273,74 +294,98 @@ access(all) struct TestRunHistory { } access(all) fun test_history() { + // testRuns: price history stability (baseTolerance + driftExpansionRate over time) let testRuns = [ + // Single price point: always stable TestRunHistory( baseTolerance: 0.0, driftExpansionRate: 0.0, priceHistory: [1.0], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // baseTolerance 1.0 allows 2x jump (1.0 -> 2.0): stable + TestRunHistory( baseTolerance: 1.0, driftExpansionRate: 0.0, priceHistory: [1.0, 2.0], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // baseTolerance 0.95 too tight for 1.0 -> 2.0 (100% move): unstable + TestRunHistory( baseTolerance: 0.95, driftExpansionRate: 0.0, priceHistory: [1.0, 2.0], priceHistoryDelay: 60.0, isHistoryStable: false, - ),TestRunHistory( + ), + // Third point 2.1 deviates from history [1.0, 2.0] beyond tolerance: unstable + TestRunHistory( baseTolerance: 1.0, driftExpansionRate: 0.0, priceHistory: [1.0, 2.0, 2.1], priceHistoryDelay: 60.0, isHistoryStable: false, - ),TestRunHistory( + ), + // History [2, 1, 3, 2]: within tolerance band from previous: stable + TestRunHistory( baseTolerance: 1.0, driftExpansionRate: 0.0, priceHistory: [2.0, 1.0, 3.0, 2.0], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // Small drift 100 -> 100.2 over 6 steps; very tight baseTolerance (0.1%): unstable + TestRunHistory( baseTolerance: 0.1 / 100.0, driftExpansionRate: 0.0, priceHistory: [100.0, 100.1, 100.1, 100.1, 100.1, 100.2], priceHistoryDelay: 60.0, isHistoryStable: false, - ),TestRunHistory( + ), + // driftExpansionRate 1.0 allows linear drift 1 -> 2 -> 3: stable + TestRunHistory( baseTolerance: 0.0, driftExpansionRate: 1.0, priceHistory: [1.0, 2.0, 3.0], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // 3.1 exceeds allowed drift from 3.0: unstable + TestRunHistory( baseTolerance: 0.0, driftExpansionRate: 1.0, priceHistory: [1.0, 2.0, 3.1], priceHistoryDelay: 60.0, isHistoryStable: false, - ),TestRunHistory( + ), + // History [2, 1, 3, 2] with drift allowed: stable + TestRunHistory( baseTolerance: 0.0, driftExpansionRate: 1.0, priceHistory: [2.0 , 1.0, 3.0, 2.0], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // driftExpansionRate 0.1 allows 0.1 steps 1.0 -> 1.1 -> 1.2 -> 1.3: stable + TestRunHistory( baseTolerance: 0.0, driftExpansionRate: 0.1, priceHistory: [1.0, 1.1, 1.2, 1.3], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // baseTolerance 0.2 + drift 0.1: 1.0 -> 1.3 within band: stable + TestRunHistory( baseTolerance: 0.2, driftExpansionRate: 0.1, priceHistory: [1.0, 1.3], priceHistoryDelay: 60.0, isHistoryStable: true, - ),TestRunHistory( + ), + // 1.31 exceeds baseTolerance 0.2 + drift from 1.0: unstable + TestRunHistory( baseTolerance: 0.2, driftExpansionRate: 0.1, priceHistory: [1.0, 1.31], @@ -368,9 +413,10 @@ access(all) fun test_history() { maxSpread: 10000.0, baseTolerance: testRun.baseTolerance, driftExpansionRate: testRun.driftExpansionRate, - priceHistorySize: testRun.priceHistory.length, + priceHistorySize: UInt8(testRun.priceHistory.length), priceHistoryInterval: UFix64(testRun.priceHistoryDelay - 1.0), // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) for price in testRun.priceHistory { @@ -387,7 +433,7 @@ access(all) fun test_history() { } // make sure prices are correctly recorded let priceHistory = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) - Test.assertEqual(testRun.priceHistory.length, priceHistory.length) + Test.assert(testRun.priceHistory.length == priceHistory.length, message: "price history length should be \(testRun.priceHistory.length), got \(priceHistory.length)") var i = 0 for price in testRun.priceHistory { Test.assertEqual(price, priceHistory[i].price) @@ -408,7 +454,7 @@ access(all) fun test_history() { } } -access(self) fun test_gradient_incomplete_price_history() { +access(self) fun test_incomplete_price_history() { let priceHistory = [1.0, nil, nil, 4.0] let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), @@ -416,9 +462,10 @@ access(self) fun test_gradient_incomplete_price_history() { maxSpread: 10000.0, baseTolerance: 0.0, driftExpansionRate: 1.0, - priceHistorySize: priceHistory.length, + priceHistorySize: UInt8(priceHistory.length), priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) Test.moveTime(by: 10.0) @@ -439,10 +486,10 @@ access(self) fun test_gradient_incomplete_price_history() { ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil - Test.assertEqual(priceIsStable, true) + Test.assert(priceIsStable, message: "price should be stable") } -access(self) fun test_gradient_old_price_history() { +access(self) fun test_old_price_history() { let priceHistory = [1.0, nil, nil, 40.0] let info = createAggregator( ofToken: Type<@FlowToken.Vault>(), @@ -450,9 +497,10 @@ access(self) fun test_gradient_old_price_history() { maxSpread: 10000.0, baseTolerance: 0.0, driftExpansionRate: 1.0, - priceHistorySize: priceHistory.length, + priceHistorySize: UInt8(priceHistory.length), priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 150.0, + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), ) for price in priceHistory { @@ -472,7 +520,132 @@ access(self) fun test_gradient_old_price_history() { ofToken: Type<@FlowToken.Vault>() ) let priceIsStable = price != nil - Test.assertEqual(priceIsStable, true) + Test.assert(priceIsStable, message: "price should be stable") +} + +access(all) fun test_minimum_price_history() { + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + priceHistorySize: 10, + priceHistoryInterval: 59.0, + maxPriceHistoryAge: 600.0, // 20 minutes + minimumPriceHistory: 5, + unitOfAccount: Type<@MOET.Vault>(), + ) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 1.0, + ) + var _ = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + var i = 0 + while i < 15 { + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + Test.moveTime(by: 60.0) + // 0, 1, 2, 3 are not enough, 4 is enough + if i <= 3 { + Test.assert(price == nil, message: "price history should not have enough entries") + } else { + Test.assert(price == 1.0, message: "price history should have enough entries") + } + i = i + 1 + } +} + +access(all) fun test_events() { + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 2, + maxSpread: 1.0, + baseTolerance: 1.0, + driftExpansionRate: 1.0, + priceHistorySize: 1, + priceHistoryInterval: 60.0, + maxPriceHistoryAge: 600.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@MOET.Vault>(), + ) + // 1. PriceNotAvailable: one oracle returns nil + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 1.0, + ) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[1], + forToken: Type<@FlowToken.Vault>(), + price: nil, + ) + var _ = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let notAvailEvents = Test.eventsOfType(Type()) + Test.assert(notAvailEvents.length == 1, message: "expected exactly one PriceNotAvailable event") + let notAvailData = notAvailEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotAvailable + Test.assert(notAvailData.oracleType == Type(), message: "oracleType should be MultiMockOracle.PriceOracle") + + // 2. PriceNotWithinSpreadTolerance: spread between oracles exceeds maxSpread + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[1], + forToken: Type<@FlowToken.Vault>(), + price: 3.0, + ) + _ = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let spreadEvents = Test.eventsOfType(Type()) + Test.assert(spreadEvents.length == 1, message: "expected exactly one PriceNotWithinSpreadTolerance event") + let spreadData = spreadEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotWithinSpreadTolerance + Test.assert(spreadData.spread >= 2.0, message: "spread should be greater than 2.0") + Test.assert(spreadData.maxAllowedSpread >= 1.0, message: "maxAllowedSpread should be greater than 1.0") + + // 3. PriceNotWithinHistoryTolerance: current price deviates too much from history + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[1], + forToken: Type<@FlowToken.Vault>(), + price: 1.0, + ) + _ = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + // now history is 1.0 + Test.moveTime(by: 50.0) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: 3.0, + ) + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[1], + forToken: Type<@FlowToken.Vault>(), + price: 3.0, + ) + _ = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let historyEvents = Test.eventsOfType(Type()) + Test.assert(historyEvents.length == 1, message: "expected exactly one PriceNotWithinHistoryTolerance event got \(historyEvents.length)") + let historyData = historyEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotWithinHistoryTolerance + Test.assert(historyData.relativeDiff >= 2.0, message: "relativeDiff should be greater than 2.0 got \(historyData.relativeDiff)") + let deltaTInRange = historyData.deltaTMinutes >= 50.0 / 60.0 && historyData.deltaTMinutes <= 1.0 + Test.assert(deltaTInRange, message: "deltaTMinutes should be between \(50.0 / 60.0) and 1.0 got \(historyData.deltaTMinutes)") + let minMaxAllowedRelativeDiff = 1.0 + 1.0 * (50.0 / 60.0) + let relativeDiffInRange = historyData.relativeDiff >= minMaxAllowedRelativeDiff && historyData.relativeDiff <= 2.0 + Test.assert(relativeDiffInRange, message: "relativeDiff should be between \(minMaxAllowedRelativeDiff) and 2.0 got \(historyData.relativeDiff)") } access(all) fun test_cron_job() { @@ -486,6 +659,7 @@ access(all) fun test_cron_job() { priceHistorySize: 5, priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes + minimumPriceHistory: 0, unitOfAccount: Type<@MOET.Vault>(), cronExpression: "* * * * *", // every minute cronHandlerStoragePath: StoragePath(identifier: "cronHandler")!, @@ -502,7 +676,7 @@ access(all) fun test_cron_job() { Test.moveTime(by: 30.0) while i < 5 { let history = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) - Test.assertEqual(history.length, i + 1) + Test.assert(history.length == i + 1, message: "history length should be \(i + 1), got \(history.length)") let timeDelta = Fix64(getCurrentBlock().timestamp) - Fix64(history[i].timestamp) Test.assert(timeDelta < 30.0, message: "timestamp mismatch") i = i + 1 diff --git a/cadence/tests/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc index 28278947..b1413ea2 100644 --- a/cadence/tests/price_oracle_router_test.cdc +++ b/cadence/tests/price_oracle_router_test.cdc @@ -37,9 +37,9 @@ access(all) fun test_router_add_oracle() { ) var price = 0.0 as UFix64? price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) - Test.assertEqual(price, 1.0 as UFix64?) + Test.assertEqual(1.0 as UFix64?, price) price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) - Test.assertEqual(price, nil as UFix64?) + Test.assertEqual(nil as UFix64?, price) } access(all) fun test_router_add_multiple_oracles() { @@ -67,11 +67,11 @@ access(all) fun test_router_add_multiple_oracles() { ) var price = 0.0 as UFix64? price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) - Test.assertEqual(price, 1.0 as UFix64?) + Test.assertEqual(1.0 as UFix64?, price) price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) - Test.assertEqual(price, 2.0 as UFix64?) + Test.assertEqual(2.0 as UFix64?, price) price = priceOracleRouterPrice(ofToken: Type<@ExampleToken2.Vault>()) - Test.assertEqual(price, 3.0 as UFix64?) + Test.assertEqual(3.0 as UFix64?, price) } access(all) fun test_router_add_wrong_unit_of_account() { @@ -87,4 +87,57 @@ access(all) fun test_router_add_wrong_unit_of_account() { createRouterInfo: info, expectSucceeded: false ) +} + +access(all) fun test_router_get_supported_tokens() { + let info = [ + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ), + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@ExampleToken1.Vault>(), + prices: 2.0 + ), + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@ExampleToken2.Vault>(), + prices: 3.0 + ) + ] + createPriceOracleRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: info, + expectSucceeded: true + ) + let supported = priceOracleRouterGetSupportedTokens() + Test.assert(supported.length == 3, message: "supported tokens length should be 3, got \(supported.length)") + Test.assert(supported.contains(Type<@FlowToken.Vault>())) + Test.assert(supported.contains(Type<@ExampleToken1.Vault>())) + Test.assert(supported.contains(Type<@ExampleToken2.Vault>())) +} + +access(all) fun test_router_is_token_supported() { + let info = [ + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ), + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@ExampleToken1.Vault>(), + prices: 2.0 + ) + ] + createPriceOracleRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: info, + expectSucceeded: true + ) + Test.assert(priceOracleRouterIsTokenSupported(tokenType: Type<@FlowToken.Vault>())) + Test.assert(priceOracleRouterIsTokenSupported(tokenType: Type<@ExampleToken1.Vault>())) + Test.assert(!priceOracleRouterIsTokenSupported(tokenType: Type<@ExampleToken2.Vault>())) } \ No newline at end of file diff --git a/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc b/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc new file mode 100644 index 00000000..33dc7d6a --- /dev/null +++ b/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc @@ -0,0 +1,8 @@ +import "OracleStorage" +import "FlowPriceOracleRouterv1" + +access(all) fun main(): [Type] { + let oracle = OracleStorage.oracle! + let router = oracle as! &FlowPriceOracleRouterv1.PriceOracleRouter + return router.getSupportedTokens() +} diff --git a/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc b/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc new file mode 100644 index 00000000..27690f82 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc @@ -0,0 +1,8 @@ +import "OracleStorage" +import "FlowPriceOracleRouterv1" + +access(all) fun main(tokenType: Type): Bool { + let oracle = OracleStorage.oracle! + let router = oracle as! &FlowPriceOracleRouterv1.PriceOracleRouter + return router.isTokenSupported(tokenType: tokenType) +} diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index d7cdaa27..139102d2 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -19,14 +19,15 @@ access(all) fun createAggregator( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create.cdc", - [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount], [] ) Test.expect(res, Test.beSucceeded()) @@ -53,9 +54,10 @@ access(all) fun createAggregatorWithCron( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, cronExpression: String, cronHandlerStoragePath: StoragePath, @@ -65,7 +67,7 @@ access(all) fun createAggregatorWithCron( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create_with_cron.cdc", - [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) Test.expect(res, Test.beSucceeded()) diff --git a/cadence/tests/test_helpers_price_oracle_router.cdc b/cadence/tests/test_helpers_price_oracle_router.cdc index b5547018..3c544c9e 100644 --- a/cadence/tests/test_helpers_price_oracle_router.cdc +++ b/cadence/tests/test_helpers_price_oracle_router.cdc @@ -35,6 +35,24 @@ access(all) fun priceOracleRouterPrice(ofToken: Type): UFix64? { return res.returnValue as! UFix64? } +access(all) fun priceOracleRouterGetSupportedTokens(): [Type] { + let res = _executeScript( + "./scripts/price-oracle-router/get_supported_tokens.cdc", + [], + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! [Type] +} + +access(all) fun priceOracleRouterIsTokenSupported(tokenType: Type): Bool { + let res = _executeScript( + "./scripts/price-oracle-router/is_token_supported.cdc", + [tokenType], + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! Bool +} + // --- Helper Functions --- access(self) fun _executeTransaction(_ path: String, _ args: [AnyStruct], _ signers: [Test.TestAccount]): Test.TransactionResult { diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index ba0e3159..ce7a20f8 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -15,9 +15,10 @@ transaction( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, ) { let oracles: [{DeFiActions.PriceOracle}] @@ -41,6 +42,7 @@ transaction( priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, unitOfAccount: unitOfAccount ) } diff --git a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc index 03276f0a..9f858838 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -15,9 +15,10 @@ transaction( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: Int, + priceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, unitOfAccount: Type, cronExpression: String, cronHandlerStoragePath: StoragePath, @@ -50,6 +51,7 @@ transaction( priceHistorySize: priceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, unitOfAccount: unitOfAccount ) From cde1d883fa8dd52fa093fbd75ad3dc5d095d62ae Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Tue, 24 Feb 2026 17:01:34 +0100 Subject: [PATCH 14/18] address pr comments --- PriceOracleArchitecture.md | 8 +++--- ...atorv1.cdc => PriceOracleAggregatorv1.cdc} | 26 ++++++++++--------- ...leRouterv1.cdc => PriceOracleRouterv1.cdc} | 4 +-- .../tests/price_oracle_aggregator_test.cdc | 20 +++++++------- cadence/tests/price_oracle_router_test.cdc | 2 +- .../price-oracle-aggregator/history.cdc | 6 ++--- .../scripts/price-oracle-aggregator/price.cdc | 4 +-- .../get_supported_tokens.cdc | 4 +-- .../is_token_supported.cdc | 4 +-- cadence/tests/test_helpers.cdc | 8 +++--- .../test_helpers_price_oracle_aggregator.cdc | 14 +++++----- .../price-oracle-aggregator/assert_price.cdc | 4 +-- .../price-oracle-aggregator/create.cdc | 4 +-- .../create_with_cron.cdc | 6 ++--- .../price-oracle-aggregator/price.cdc | 4 +-- .../price-oracle-router/create.cdc | 4 +-- flow.json | 8 +++--- 17 files changed, 66 insertions(+), 64 deletions(-) rename cadence/contracts/{FlowPriceOracleAggregatorv1.cdc => PriceOracleAggregatorv1.cdc} (94%) rename cadence/contracts/{FlowPriceOracleRouterv1.cdc => PriceOracleRouterv1.cdc} (96%) diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md index 7a09d125..c0ceaa1c 100644 --- a/PriceOracleArchitecture.md +++ b/PriceOracleArchitecture.md @@ -11,8 +11,8 @@ Two contracts implement this design: | Contract | Role | |----------|------| -| **FlowPriceOracleAggregatorv1** | 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). | -| **FlowPriceOracleRouterv1** | Exposes **one** `DeFiActions.PriceOracle` that routes by token type. Each token has its own oracle; typically each oracle is an aggregator. | +| **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). | +| **PriceOracleRouterv1** | Exposes **one** `DeFiActions.PriceOracle` that routes by token type. Each token has its own oracle; typically each oracle is an aggregator. | 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. @@ -25,7 +25,7 @@ The **Aggregator** and **Router** are immutable by design to eliminate the risks * **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. * **Transparent Auditing:** Every change is recorded on-chain via the `PriceOracleUpdated` event, ensuring all shifts in logic or parameters are visible and expected. -## FlowPriceOracleAggregatorv1 +## PriceOracleAggregatorv1 One aggregated oracle per “market” (e.g. FLOW in USDC). Multiple underlying oracles, single unit of account, fixed tolerances. - **Price flow:** @@ -99,7 +99,7 @@ Implementationally, entries older than `maxPriceHistoryAge` are ignored when eva **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. -## FlowPriceOracleRouterv1 +## PriceOracleRouterv1 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. diff --git a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc b/cadence/contracts/PriceOracleAggregatorv1.cdc similarity index 94% rename from cadence/contracts/FlowPriceOracleAggregatorv1.cdc rename to cadence/contracts/PriceOracleAggregatorv1.cdc index 0788a069..438a8768 100644 --- a/cadence/contracts/FlowPriceOracleAggregatorv1.cdc +++ b/cadence/contracts/PriceOracleAggregatorv1.cdc @@ -2,7 +2,7 @@ import "FlowToken" import "DeFiActions" import "FlowTransactionScheduler" -/// FlowPriceOracleAggregatorv1 combines multiple `DeFiActions.PriceOracle` +/// PriceOracleAggregatorv1 combines multiple `DeFiActions.PriceOracle` /// sources into a single trusted oracle. A price is returned only when: /// - All oracles return a value (no missing data), /// - The spread between min and max oracle prices is within `maxSpread`, @@ -11,7 +11,7 @@ import "FlowTransactionScheduler" /// One aggregator instance = one market (one token type). For multiple /// markets, create one storage per market and use a router to expose them. /// Config is immutable at creation to avoid accidental changes in production. -access(all) contract FlowPriceOracleAggregatorv1 { +access(all) contract PriceOracleAggregatorv1 { /// Emitted when a new aggregator storage is created. access(all) event StorageCreated(storageID: UInt64) @@ -143,11 +143,12 @@ access(all) contract FlowPriceOracleAggregatorv1 { if prices == nil || prices!.length == 0 { return nil } - if !self.isWithinSpreadTolerance(prices: prices!) { + let validPrices = prices! + if !self.isWithinSpreadTolerance(prices: validPrices) { return nil } - let price = self.trimmedMeanPrice(prices: prices!) - self.tryAddPriceToHistoryInternal(price: price!, now: now) + let price = self.trimmedMeanPrice(prices: validPrices) + self.tryAddPriceToHistoryInternal(price: price, now: now) return price } @@ -192,11 +193,12 @@ access(all) contract FlowPriceOracleAggregatorv1 { return true } - access(self) view fun trimmedMeanPrice(prices: [UFix64]): UFix64? { + /// will panic if prices is empty + access(self) view fun trimmedMeanPrice(prices: [UFix64]): UFix64 { let count = prices.length // Handle edge cases where trimming isn't possible - if count == 0 { return nil } + if count == 0 { panic("No prices to compute trimmed mean") } if count == 1 { return prices[0] } if count == 2 { return (prices[0] + prices[1]) / 2.0 } @@ -259,8 +261,8 @@ access(all) contract FlowPriceOracleAggregatorv1 { // Only append if enough time has passed since the last entry. if self.priceHistory.length > 0 { let lastEntry = self.priceHistory[self.priceHistory.length - 1] - let timeSinceLastEntry = now - lastEntry.timestamp - if timeSinceLastEntry < self.priceHistoryInterval { + let nextEntryTimestamp = lastEntry.timestamp + self.priceHistoryInterval + if now < nextEntryTimestamp { return } } @@ -284,7 +286,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { init(storageID: UInt64) { self.storageID = storageID self.uniqueID = DeFiActions.createUniqueIdentifier() - if FlowPriceOracleAggregatorv1.storage.containsKey(self.storageID) == false { + if PriceOracleAggregatorv1.storage.containsKey(self.storageID) == false { panic("Storage not found for storageID: \(self.storageID)") } } @@ -326,7 +328,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowPriceOracleAggregatorv1.storage[self.storageID])! + return (&PriceOracleAggregatorv1.storage[self.storageID])! } } @@ -348,7 +350,7 @@ access(all) contract FlowPriceOracleAggregatorv1 { } access(self) view fun borrowPriceOracleAggregator(): &PriceOracleAggregatorStorage { - return (&FlowPriceOracleAggregatorv1.storage[self.storageID])! + return (&PriceOracleAggregatorv1.storage[self.storageID])! } } diff --git a/cadence/contracts/FlowPriceOracleRouterv1.cdc b/cadence/contracts/PriceOracleRouterv1.cdc similarity index 96% rename from cadence/contracts/FlowPriceOracleRouterv1.cdc rename to cadence/contracts/PriceOracleRouterv1.cdc index 730a2f1c..bb3d9980 100644 --- a/cadence/contracts/FlowPriceOracleRouterv1.cdc +++ b/cadence/contracts/PriceOracleRouterv1.cdc @@ -1,12 +1,12 @@ import "DeFiActions" -/// FlowPriceOracleRouterv1 exposes a single `DeFiActions.PriceOracle` that +/// PriceOracleRouterv1 exposes a single `DeFiActions.PriceOracle` that /// routes by token type: one oracle per token. All oracles must share the /// same unit of account. Config (oracles, unit of account) is immutable at /// creation to avoid accidental changes in production. /// Use this when the protocol needs one oracle reference but prices come /// from different sources per token. -access(all) contract FlowPriceOracleRouterv1 { +access(all) contract PriceOracleRouterv1 { /// Router implementing `DeFiActions.PriceOracle`: dispatches /// `price(ofToken)` to the oracle for that token type. All oracles must diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 98732a63..90fe8bee 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" import "FlowToken" import "MOET" import "MultiMockOracle" @@ -590,9 +590,9 @@ access(all) fun test_events() { storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) - let notAvailEvents = Test.eventsOfType(Type()) + let notAvailEvents = Test.eventsOfType(Type()) Test.assert(notAvailEvents.length == 1, message: "expected exactly one PriceNotAvailable event") - let notAvailData = notAvailEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotAvailable + let notAvailData = notAvailEvents[0] as! PriceOracleAggregatorv1.PriceNotAvailable Test.assert(notAvailData.oracleType == Type(), message: "oracleType should be MultiMockOracle.PriceOracle") // 2. PriceNotWithinSpreadTolerance: spread between oracles exceeds maxSpread @@ -605,9 +605,9 @@ access(all) fun test_events() { storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) - let spreadEvents = Test.eventsOfType(Type()) + let spreadEvents = Test.eventsOfType(Type()) Test.assert(spreadEvents.length == 1, message: "expected exactly one PriceNotWithinSpreadTolerance event") - let spreadData = spreadEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotWithinSpreadTolerance + let spreadData = spreadEvents[0] as! PriceOracleAggregatorv1.PriceNotWithinSpreadTolerance Test.assert(spreadData.spread >= 2.0, message: "spread should be greater than 2.0") Test.assert(spreadData.maxAllowedSpread >= 1.0, message: "maxAllowedSpread should be greater than 1.0") @@ -637,9 +637,9 @@ access(all) fun test_events() { storageID: info.aggregatorStorageID, ofToken: Type<@FlowToken.Vault>() ) - let historyEvents = Test.eventsOfType(Type()) + let historyEvents = Test.eventsOfType(Type()) Test.assert(historyEvents.length == 1, message: "expected exactly one PriceNotWithinHistoryTolerance event got \(historyEvents.length)") - let historyData = historyEvents[0] as! FlowPriceOracleAggregatorv1.PriceNotWithinHistoryTolerance + let historyData = historyEvents[0] as! PriceOracleAggregatorv1.PriceNotWithinHistoryTolerance Test.assert(historyData.relativeDiff >= 2.0, message: "relativeDiff should be greater than 2.0 got \(historyData.relativeDiff)") let deltaTInRange = historyData.deltaTMinutes >= 50.0 / 60.0 && historyData.deltaTMinutes <= 1.0 Test.assert(deltaTInRange, message: "deltaTMinutes should be between \(50.0 / 60.0) and 1.0 got \(historyData.deltaTMinutes)") @@ -704,9 +704,9 @@ access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?], forTo access(self) fun logFailEvents() { let failureEvents = [ - Type(), - Type(), - Type() + Type(), + Type(), + Type() ] for eventType in failureEvents { let events = Test.eventsOfType(eventType) diff --git a/cadence/tests/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc index b1413ea2..80085267 100644 --- a/cadence/tests/price_oracle_router_test.cdc +++ b/cadence/tests/price_oracle_router_test.cdc @@ -1,7 +1,7 @@ import Test import BlockchainHelpers -import "FlowPriceOracleRouterv1" +import "PriceOracleRouterv1" import "FlowToken" import "MOET" import "ExampleToken1" diff --git a/cadence/tests/scripts/price-oracle-aggregator/history.cdc b/cadence/tests/scripts/price-oracle-aggregator/history.cdc index 2d0b7042..a21f0506 100644 --- a/cadence/tests/scripts/price-oracle-aggregator/history.cdc +++ b/cadence/tests/scripts/price-oracle-aggregator/history.cdc @@ -1,6 +1,6 @@ -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" -access(all) fun main(storageID: UInt64): &[FlowPriceOracleAggregatorv1.PriceHistoryEntry] { - let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) +access(all) fun main(storageID: UInt64): &[PriceOracleAggregatorv1.PriceHistoryEntry] { + let priceOracle = PriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) return priceOracle.priceHistory() } diff --git a/cadence/tests/scripts/price-oracle-aggregator/price.cdc b/cadence/tests/scripts/price-oracle-aggregator/price.cdc index 2b31db41..10f4205f 100644 --- a/cadence/tests/scripts/price-oracle-aggregator/price.cdc +++ b/cadence/tests/scripts/price-oracle-aggregator/price.cdc @@ -1,6 +1,6 @@ -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" access(all) fun main(storageID: UInt64, ofToken: Type): UFix64? { - let priceOracle = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) + let priceOracle = PriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID) return priceOracle.price(ofToken: ofToken) } diff --git a/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc b/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc index 33dc7d6a..29d2ee27 100644 --- a/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc +++ b/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc @@ -1,8 +1,8 @@ import "OracleStorage" -import "FlowPriceOracleRouterv1" +import "PriceOracleRouterv1" access(all) fun main(): [Type] { let oracle = OracleStorage.oracle! - let router = oracle as! &FlowPriceOracleRouterv1.PriceOracleRouter + let router = oracle as! &PriceOracleRouterv1.PriceOracleRouter return router.getSupportedTokens() } diff --git a/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc b/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc index 27690f82..6ce43e62 100644 --- a/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc +++ b/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc @@ -1,8 +1,8 @@ import "OracleStorage" -import "FlowPriceOracleRouterv1" +import "PriceOracleRouterv1" access(all) fun main(tokenType: Type): Bool { let oracle = OracleStorage.oracle! - let router = oracle as! &FlowPriceOracleRouterv1.PriceOracleRouter + let router = oracle as! &PriceOracleRouterv1.PriceOracleRouter return router.isTokenSupported(tokenType: tokenType) } diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 5550cefe..ca7b8ffd 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -119,8 +119,8 @@ fun deployContracts() { Test.expect(err, Test.beNil()) err = Test.deployContract( - name: "FlowPriceOracleAggregatorv1", - path: "../contracts/FlowPriceOracleAggregatorv1.cdc", + name: "PriceOracleAggregatorv1", + path: "../contracts/PriceOracleAggregatorv1.cdc", arguments: [] ) Test.expect(err, Test.beNil()) @@ -206,8 +206,8 @@ fun deployContracts() { Test.expect(err, Test.beNil()) err = Test.deployContract( - name: "FlowPriceOracleRouterv1", - path: "../contracts/FlowPriceOracleRouterv1.cdc", + name: "PriceOracleRouterv1", + path: "../contracts/PriceOracleRouterv1.cdc", arguments: [] ) Test.expect(err, Test.beNil()) diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index 139102d2..2d478f67 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -1,6 +1,6 @@ import Test import "DeFiActions" -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" import "MultiMockOracle" access(all) struct CreateAggregatorInfo { @@ -31,8 +31,8 @@ access(all) fun createAggregator( [] ) Test.expect(res, Test.beSucceeded()) - let aggregatorCreatedEvents = Test.eventsOfType(Type()) - let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.StorageCreated + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! PriceOracleAggregatorv1.StorageCreated let oracleCreatedEvents = Test.eventsOfType(Type()) let oracleIDs: [UInt64] = [] var i = oracleCreatedEvents.length - oracleCount @@ -71,8 +71,8 @@ access(all) fun createAggregatorWithCron( [signer] ) Test.expect(res, Test.beSucceeded()) - let aggregatorCreatedEvents = Test.eventsOfType(Type()) - let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! FlowPriceOracleAggregatorv1.StorageCreated + let aggregatorCreatedEvents = Test.eventsOfType(Type()) + let aggregatorCreatedData = aggregatorCreatedEvents[aggregatorCreatedEvents.length - 1] as! PriceOracleAggregatorv1.StorageCreated let oracleCreatedEvents = Test.eventsOfType(Type()) let oracleIDs: [UInt64] = [] var i = oracleCreatedEvents.length - oracleCount @@ -158,13 +158,13 @@ access(all) fun oracleAggregatorAssertPriceTx( access(all) fun oracleAggregatorPriceHistory( storageID: UInt64, -): [FlowPriceOracleAggregatorv1.PriceHistoryEntry] { +): [PriceOracleAggregatorv1.PriceHistoryEntry] { let res = _executeScript( "./scripts/price-oracle-aggregator/history.cdc", [storageID] ) Test.expect(res, Test.beSucceeded()) - return res.returnValue as! [FlowPriceOracleAggregatorv1.PriceHistoryEntry] + return res.returnValue as! [PriceOracleAggregatorv1.PriceHistoryEntry] } // --- Helper Functions --- diff --git a/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc b/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc index 15f3faa6..c4f90fd9 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc @@ -1,4 +1,4 @@ -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" transaction( storageID: UInt64, @@ -6,7 +6,7 @@ transaction( price: UFix64?, ) { execute { - let realPrice = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( + let realPrice = PriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( ofToken: forToken, ) if price != realPrice { diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index ce7a20f8..028f2f10 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -1,5 +1,5 @@ import "DeFiActions" -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" import "FlowTransactionScheduler" import "FlowTransactionSchedulerUtils" import "FungibleToken" @@ -33,7 +33,7 @@ transaction( } execute { - let _ = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + let _ = PriceOracleAggregatorv1.createPriceOracleAggregatorStorage( ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, diff --git a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc index 9f858838..ce203735 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -1,5 +1,5 @@ import "DeFiActions" -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" import "FlowTransactionScheduler" import "FlowTransactionSchedulerUtils" import "FungibleToken" @@ -42,7 +42,7 @@ transaction( } execute { - let uuid = FlowPriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + let uuid = PriceOracleAggregatorv1.createPriceOracleAggregatorStorage( ofToken: ofToken, oracles: self.oracles, maxSpread: maxSpread, @@ -57,7 +57,7 @@ transaction( // Create cron handler for the aggregator, look at flow-cron for reference - let aggregatorCronHandler <- FlowPriceOracleAggregatorv1.createPriceOracleCronHandler(storageID: uuid) + let aggregatorCronHandler <- PriceOracleAggregatorv1.createPriceOracleCronHandler(storageID: uuid) self.signer.storage.save(<-aggregatorCronHandler, to: aggregatorCronHandlerStoragePath) let wrappedHandlerCap = self.signer.capabilities.storage.issue(aggregatorCronHandlerStoragePath) assert(wrappedHandlerCap.check(), message: "Invalid wrapped handler capability") diff --git a/cadence/tests/transactions/price-oracle-aggregator/price.cdc b/cadence/tests/transactions/price-oracle-aggregator/price.cdc index 0974834c..ca8cdf30 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/price.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/price.cdc @@ -1,11 +1,11 @@ -import "FlowPriceOracleAggregatorv1" +import "PriceOracleAggregatorv1" transaction( storageID: UInt64, forToken: Type, ) { execute { - let _ = FlowPriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( + let _ = PriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).price( ofToken: forToken, ) } diff --git a/cadence/tests/transactions/price-oracle-router/create.cdc b/cadence/tests/transactions/price-oracle-router/create.cdc index 00899d80..a8a354b2 100644 --- a/cadence/tests/transactions/price-oracle-router/create.cdc +++ b/cadence/tests/transactions/price-oracle-router/create.cdc @@ -1,4 +1,4 @@ -import "FlowPriceOracleRouterv1" +import "PriceOracleRouterv1" import "OracleStorage" import "DeFiActions" import "MultiMockOracle" @@ -18,7 +18,7 @@ transaction( oracle.setPrice(forToken: oracleOfToken, price: price) oracles[oracleOfToken] = oracle } - let router = FlowPriceOracleRouterv1.createPriceOracleRouter( + let router = PriceOracleRouterv1.createPriceOracleRouter( unitOfAccount: unitOfAccount, oracles: oracles, ) diff --git a/flow.json b/flow.json index eb23ea1c..6af4da88 100644 --- a/flow.json +++ b/flow.json @@ -76,14 +76,14 @@ "testing": "0000000000000007" } }, - "FlowPriceOracleAggregatorv1": { - "source": "./cadence/contracts/FlowPriceOracleAggregatorv1.cdc", + "PriceOracleAggregatorv1": { + "source": "./cadence/contracts/PriceOracleAggregatorv1.cdc", "aliases": { "testing": "0000000000000007" } }, - "FlowPriceOracleRouterv1": { - "source": "./cadence/contracts/FlowPriceOracleRouterv1.cdc", + "PriceOracleRouterv1": { + "source": "./cadence/contracts/PriceOracleRouterv1.cdc", "aliases": { "testing": "0000000000000007" } From 9189692fde0e4e6aef99586050a770d58b04898e Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 25 Feb 2026 12:27:07 +0100 Subject: [PATCH 15/18] simplify aggregator using arithmetic mean --- PriceOracleArchitecture.md | 10 +++--- cadence/contracts/PriceOracleAggregatorv1.cdc | 36 +++++++++---------- cadence/tests/TEST_COVERAGE.md | 6 ++-- .../tests/price_oracle_aggregator_test.cdc | 10 +++--- 4 files changed, 28 insertions(+), 34 deletions(-) diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md index c0ceaa1c..07415732 100644 --- a/PriceOracleArchitecture.md +++ b/PriceOracleArchitecture.md @@ -32,19 +32,17 @@ One aggregated oracle per “market” (e.g. FLOW in USDC). Multiple underlying 1. Collect prices from all oracles for the requested token. 2. If any oracle returns nil → emit `PriceNotAvailable`, return nil. 3. Compute min/max; if spread > `maxSpread` → emit `PriceNotWithinSpreadTolerance`, return nil. - 4. Compute aggregated price (trimmed mean: drop min and max, average the rest). + 4. Compute aggregated price as the arithmetic mean of all oracle prices. 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. 6. Otherwise return the aggregated price. - **History:** An array of `(price, timestamp)` is maintained. Updates are permissionless via `tryAddPriceToHistory()` (idempotent); A FlowCron job should be created to call this regularly. Additionally every call to price() will also attempt to store the price in the history. -## Aggregate price (trimmed mean) +## Aggregate price (average) -To avoid the complexity of a full median, the aggregator uses a **trimmed mean**: remove the single maximum and single minimum, then average the rest. This reduces the impact of a single outlier. +The aggregator uses the **arithmetic mean** of all oracle prices: -- With 1 oracle: that price. -- With 2 oracles: arithmetic mean. -- With 3+ oracles: trimmed mean `(sum - min - max) / (count - 2)`. +- **Average:** `sum(prices) / count`. Same for any number of oracles (1, 2, 3+). ## Oracle spread (coherence) diff --git a/cadence/contracts/PriceOracleAggregatorv1.cdc b/cadence/contracts/PriceOracleAggregatorv1.cdc index 438a8768..d2b7e7fa 100644 --- a/cadence/contracts/PriceOracleAggregatorv1.cdc +++ b/cadence/contracts/PriceOracleAggregatorv1.cdc @@ -91,6 +91,14 @@ access(all) contract PriceOracleAggregatorv1 { "driftExpansionRate must be <= 10000.0" minimumPriceHistory <= priceHistorySize: "minimumPriceHistory must be <= priceHistorySize" + maxPriceHistoryAge >= priceHistoryInterval * UFix64(minimumPriceHistory + 1): + "maxPriceHistoryAge must be >= priceHistoryInterval * (minimumPriceHistory + 1)" + } + if priceHistorySize == 0 && priceHistoryInterval != 0.0 { + panic("if priceHistorySize == 0, priceHistoryInterval must be 0.0") + } + if priceHistorySize != 0 && priceHistoryInterval == 0.0 { + panic("if priceHistorySize != 0, priceHistoryInterval must be > 0.0") } self.ofToken = ofToken self.oracles = oracles @@ -147,7 +155,7 @@ access(all) contract PriceOracleAggregatorv1 { if !self.isWithinSpreadTolerance(prices: validPrices) { return nil } - let price = self.trimmedMeanPrice(prices: validPrices) + let price = self.averagePrice(prices: validPrices) self.tryAddPriceToHistoryInternal(price: price, now: now) return price } @@ -193,29 +201,17 @@ access(all) contract PriceOracleAggregatorv1 { return true } - /// will panic if prices is empty - access(self) view fun trimmedMeanPrice(prices: [UFix64]): UFix64 { - let count = prices.length - - // Handle edge cases where trimming isn't possible - if count == 0 { panic("No prices to compute trimmed mean") } - if count == 1 { return prices[0] } - if count == 2 { return (prices[0] + prices[1]) / 2.0 } - + /// Returns the arithmetic mean of all oracle prices. + /// Panics if prices is empty. + access(self) view fun averagePrice(prices: [UFix64]): UFix64 { + if prices.length == 0 { + panic("No prices to compute average") + } var totalSum = 0.0 - var minPrice = UFix64.max - var maxPrice = UFix64.min for price in prices { - if price < minPrice { - minPrice = price - } - if price > maxPrice { - maxPrice = price - } totalSum = totalSum + price } - let trimmedSum = totalSum - minPrice - maxPrice - return trimmedSum / UFix64(count - 2) + return totalSum / UFix64(prices.length) } access(self) fun isHistoryStable(currentPrice: UFix64, now: UFix64): Bool { diff --git a/cadence/tests/TEST_COVERAGE.md b/cadence/tests/TEST_COVERAGE.md index 6c81ec3c..f25e9c5f 100644 --- a/cadence/tests/TEST_COVERAGE.md +++ b/cadence/tests/TEST_COVERAGE.md @@ -1,8 +1,8 @@ # Test Inventory and Coverage Notes (FlowALP; `FlowALPv0` Implementation) -**Last Reviewed:** 2026-02-14 -**Core Contract:** `cadence/contracts/FlowALPv0.cdc` -**Test Files:** Git-tracked tests under `cadence/tests/*_test.cdc` +**Last Reviewed:** 2026-02-14 +**Core Contract:** `cadence/contracts/FlowALPv0.cdc` +**Test Files:** Git-tracked tests under `cadence/tests/*_test.cdc` **Coverage:** Coverage artifacts are generated locally (e.g. `coverage.json`, `coverage.lcov`) and are ignored by git. **Naming:** FlowALP refers to the protocol; the Cadence implementation under test in this repo is deployed as the `FlowALPv0` contract. diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 90fe8bee..5a031c95 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -106,19 +106,19 @@ access(all) fun test_average_price() { expectedPrice: 2.0, ), TestRunAveragePrice( prices: [1.0, 2.0, 10.0], - expectedPrice: 2.0, + expectedPrice: 4.33333333, ), TestRunAveragePrice( prices: [1.0, 9.0, 10.0], - expectedPrice: 9.0, + expectedPrice: 6.66666666, ), TestRunAveragePrice( prices: [1.0, 1.0, 2.0], - expectedPrice: 1.0, + expectedPrice: 1.33333333, ), TestRunAveragePrice( prices: [1.0, 1.0, 1.0], expectedPrice: 1.0, ), TestRunAveragePrice( prices: [1.0, 1.0, 2.0, 3.0], - expectedPrice: 1.5, + expectedPrice: 1.75, ), TestRunAveragePrice( prices: [1.0, 2.0, 3.0, 4.0, 5.0], expectedPrice: 3.0, @@ -226,7 +226,7 @@ access(all) fun test_spread() { prices: [1.0, 2.0], expectedPrice: 1.5, ), - // Loose spread, three oracles: accept median/average 1.5 + // Loose spread, three oracles: accept average 1.5 TestRunSpread( maxSpread: 10000.0, prices: [1.0, 1.5, 2.0], From fd6fd496b725e3c4ba834151e0176609a3657e5e Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Wed, 25 Feb 2026 12:31:37 +0100 Subject: [PATCH 16/18] update to recent block_height --- flow.json | 82 +++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/flow.json b/flow.json index 6af4da88..734ebc63 100644 --- a/flow.json +++ b/flow.json @@ -46,6 +46,18 @@ "testing": "0000000000000007" } }, + "ExampleToken1": { + "source": "./cadence/tests/contracts/ExampleToken1.cdc", + "aliases": { + "testing": "0000000000000008" + } + }, + "ExampleToken2": { + "source": "./cadence/tests/contracts/ExampleToken2.cdc", + "aliases": { + "testing": "0000000000000008" + } + }, "FlowALPMath": { "source": "./cadence/lib/FlowALPMath.cdc", "aliases": { @@ -76,18 +88,6 @@ "testing": "0000000000000007" } }, - "PriceOracleAggregatorv1": { - "source": "./cadence/contracts/PriceOracleAggregatorv1.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, - "PriceOracleRouterv1": { - "source": "./cadence/contracts/PriceOracleRouterv1.cdc", - "aliases": { - "testing": "0000000000000007" - } - }, "FungibleTokenConnectors": { "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", "aliases": { @@ -132,16 +132,16 @@ "testing": "0000000000000008" } }, - "ExampleToken1": { - "source": "./cadence/tests/contracts/ExampleToken1.cdc", + "PriceOracleAggregatorv1": { + "source": "./cadence/contracts/PriceOracleAggregatorv1.cdc", "aliases": { - "testing": "0000000000000008" + "testing": "0000000000000007" } }, - "ExampleToken2": { - "source": "./cadence/tests/contracts/ExampleToken2.cdc", + "PriceOracleRouterv1": { + "source": "./cadence/contracts/PriceOracleRouterv1.cdc", "aliases": { - "testing": "0000000000000008" + "testing": "0000000000000007" } } }, @@ -149,7 +149,7 @@ "Burner": { "source": "mainnet://f233dcee88fe0abe.Burner", "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "f233dcee88fe0abe", @@ -160,7 +160,7 @@ "EVM": { "source": "mainnet://e467b9dd11fa00df.EVM", "hash": "960b0c7df7ee536956af196fba8c8d5dd4f7a89a4ecc61467e31287c4617b0dd", - "block_height": 141019535, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -171,7 +171,7 @@ "FlowCron": { "source": "mainnet://6dec6e64a13b881e.FlowCron", "hash": "ab570aabfb4d3ee01537ad85ad789ed13ac193ba447bc037365d51d748272cd5", - "block_height": 141024643, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -183,7 +183,7 @@ "FlowCronUtils": { "source": "mainnet://6dec6e64a13b881e.FlowCronUtils", "hash": "498c32c1345b9b1db951a18e4ea94325b8c9c05ea691f2d9b6af75b886ab51a2", - "block_height": 141024643, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "6dec6e64a13b881e", @@ -195,7 +195,7 @@ "FlowFees": { "source": "mainnet://f919ee77447b7497.FlowFees", "hash": "341cc0f3cc847d6b787c390133f6a5e6c867c111784f09c5c0083c47f2f1df64", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "e5a8b7f23e8b548f", "mainnet": "f919ee77447b7497", @@ -206,7 +206,7 @@ "FlowStorageFees": { "source": "mainnet://e467b9dd11fa00df.FlowStorageFees", "hash": "a92c26fb2ea59725441fa703aa4cd811e0fc56ac73d649a8e12c1e72b67a8473", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -217,7 +217,7 @@ "FlowToken": { "source": "mainnet://1654653399040a61.FlowToken", "hash": "f82389e2412624ffa439836b00b42e6605b0c00802a4e485bc95b8930a7eac38", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "0ae53cb6e3f42a79", "mainnet": "1654653399040a61", @@ -228,7 +228,7 @@ "FlowTransactionScheduler": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionScheduler", "hash": "23157cf7d70534e45b0ab729133232d0ffb3cdae52661df1744747cb1f8c0495", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "e467b9dd11fa00df", @@ -239,7 +239,7 @@ "FlowTransactionSchedulerUtils": { "source": "mainnet://e467b9dd11fa00df.FlowTransactionSchedulerUtils", "hash": "71a1febab6b9ba76abec36dab1e61b1c377e44fbe627e5fac649deb71b727877", - "block_height": 141019535, + "block_height": 143365043, "aliases": { "mainnet": "e467b9dd11fa00df", "mainnet-fork": "e467b9dd11fa00df" @@ -248,7 +248,7 @@ "FungibleToken": { "source": "mainnet://f233dcee88fe0abe.FungibleToken", "hash": "4b74edfe7d7ddfa70b703c14aa731a0b2e7ce016ce54d998bfd861ada4d240f6", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -259,7 +259,7 @@ "FungibleTokenMetadataViews": { "source": "mainnet://f233dcee88fe0abe.FungibleTokenMetadataViews", "hash": "70477f80fd7678466c224507e9689f68f72a9e697128d5ea54d19961ec856b3c", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "ee82856bf20e2aa6", "mainnet": "f233dcee88fe0abe", @@ -270,7 +270,7 @@ "MetadataViews": { "source": "mainnet://1d7e57aa55817448.MetadataViews", "hash": "b290b7906d901882b4b62e596225fb2f10defb5eaaab4a09368f3aee0e9c18b1", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -281,7 +281,7 @@ "NonFungibleToken": { "source": "mainnet://1d7e57aa55817448.NonFungibleToken", "hash": "a258de1abddcdb50afc929e74aca87161d0083588f6abf2b369672e64cf4a403", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -292,7 +292,7 @@ "ViewResolver": { "source": "mainnet://1d7e57aa55817448.ViewResolver", "hash": "374a1994046bac9f6228b4843cb32393ef40554df9bd9907a702d098a2987bde", - "block_height": 139085361, + "block_height": 143365043, "aliases": { "emulator": "f8d6e0586b0a20c7", "mainnet": "1d7e57aa55817448", @@ -327,14 +327,6 @@ "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" } }, - "mainnet-fyv-deployer": { - "address": "b1d63873c3cc9f79", - "key": { - "type": "google-kms", - "hashAlgorithm": "SHA2_256", - "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" - } - }, "mainnet-fork-deployer": { "address": "6b00ff876c299c61", "key": { @@ -349,6 +341,14 @@ "location": "emulator-account.pkey" } }, + "mainnet-fyv-deployer": { + "address": "b1d63873c3cc9f79", + "key": { + "type": "google-kms", + "hashAlgorithm": "SHA2_256", + "resourceID": "projects/dl-flow-devex-production/locations/us-central1/keyRings/tidal-keyring/cryptoKeys/tidal_admin_pk/cryptoKeyVersions/1" + } + }, "testnet-deployer": { "address": "426f0458ced60037", "key": { @@ -402,7 +402,6 @@ "MockDexSwapper", "MockOracle" ] - }, "mainnet-fork": { "mainnet-fork-deployer": [ @@ -422,7 +421,6 @@ "MockDexSwapper", "MockOracle" ] - }, "testnet": { "testnet-deployer": [ From 8843ad7d7800dad407b4151f73d49d1f283b8c89 Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Thu, 26 Feb 2026 17:43:10 +0100 Subject: [PATCH 17/18] rename maxPriceHistorySize --- cadence/contracts/PriceOracleAggregatorv1.cdc | 26 +++++++++---------- .../tests/price_oracle_aggregator_test.cdc | 20 +++++++------- .../test_helpers_price_oracle_aggregator.cdc | 8 +++--- .../price-oracle-aggregator/create.cdc | 4 +-- .../create_with_cron.cdc | 4 +-- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/cadence/contracts/PriceOracleAggregatorv1.cdc b/cadence/contracts/PriceOracleAggregatorv1.cdc index d2b7e7fa..d7036843 100644 --- a/cadence/contracts/PriceOracleAggregatorv1.cdc +++ b/cadence/contracts/PriceOracleAggregatorv1.cdc @@ -54,8 +54,8 @@ access(all) contract PriceOracleAggregatorv1 { access(all) let baseTolerance: UFix64 /// Additional allowance per minute to account for natural price drift. access(all) let driftExpansionRate: UFix64 - /// Size of the price history array. - access(all) let priceHistorySize: UInt8 + /// Max size of the price history array. + access(all) let maxPriceHistorySize: UInt8 /// Min time between two consecutive history entries. access(all) let priceHistoryInterval: UFix64 /// Maximum age of a price history entry. History entries older than @@ -74,7 +74,7 @@ access(all) contract PriceOracleAggregatorv1 { maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -89,16 +89,16 @@ access(all) contract PriceOracleAggregatorv1 { "baseTolerance must be <= 10000.0" driftExpansionRate <= 10000.0: "driftExpansionRate must be <= 10000.0" - minimumPriceHistory <= priceHistorySize: - "minimumPriceHistory must be <= priceHistorySize" + minimumPriceHistory <= maxPriceHistorySize: + "minimumPriceHistory must be <= maxPriceHistorySize" maxPriceHistoryAge >= priceHistoryInterval * UFix64(minimumPriceHistory + 1): "maxPriceHistoryAge must be >= priceHistoryInterval * (minimumPriceHistory + 1)" } - if priceHistorySize == 0 && priceHistoryInterval != 0.0 { - panic("if priceHistorySize == 0, priceHistoryInterval must be 0.0") + if maxPriceHistorySize == 0 && priceHistoryInterval != 0.0 { + panic("if maxPriceHistorySize == 0, priceHistoryInterval must be 0.0") } - if priceHistorySize != 0 && priceHistoryInterval == 0.0 { - panic("if priceHistorySize != 0, priceHistoryInterval must be > 0.0") + if maxPriceHistorySize != 0 && priceHistoryInterval == 0.0 { + panic("if maxPriceHistorySize != 0, priceHistoryInterval must be > 0.0") } self.ofToken = ofToken self.oracles = oracles @@ -106,7 +106,7 @@ access(all) contract PriceOracleAggregatorv1 { self.maxSpread = maxSpread self.baseTolerance = baseTolerance self.driftExpansionRate = driftExpansionRate - self.priceHistorySize = priceHistorySize + self.maxPriceHistorySize = maxPriceHistorySize self.priceHistoryInterval = priceHistoryInterval self.maxPriceHistoryAge = maxPriceHistoryAge self.minimumPriceHistory = minimumPriceHistory @@ -264,7 +264,7 @@ access(all) contract PriceOracleAggregatorv1 { } let newEntry = PriceHistoryEntry(price: price, timestamp: now) self.priceHistory.append(newEntry) - if self.priceHistory.length > Int(self.priceHistorySize) { + if self.priceHistory.length > Int(self.maxPriceHistorySize) { let _ = self.priceHistory.removeFirst() } } @@ -359,7 +359,7 @@ access(all) contract PriceOracleAggregatorv1 { maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -371,7 +371,7 @@ access(all) contract PriceOracleAggregatorv1 { maxSpread: maxSpread, baseTolerance: baseTolerance, driftExpansionRate: driftExpansionRate, - priceHistorySize: priceHistorySize, + maxPriceHistorySize: maxPriceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, minimumPriceHistory: minimumPriceHistory, diff --git a/cadence/tests/price_oracle_aggregator_test.cdc b/cadence/tests/price_oracle_aggregator_test.cdc index 5a031c95..29e06a2e 100644 --- a/cadence/tests/price_oracle_aggregator_test.cdc +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -30,7 +30,7 @@ access(all) fun test_single_oracle() { maxSpread: 10000.0, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 0, + maxPriceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, minimumPriceHistory: 0, @@ -63,7 +63,7 @@ access(all) fun test_multiple_oracles() { maxSpread: 10000.0, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 0, + maxPriceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, minimumPriceHistory: 0, @@ -150,7 +150,7 @@ access(all) fun test_average_price() { maxSpread: 10000.0, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 0, + maxPriceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, minimumPriceHistory: 0, @@ -251,7 +251,7 @@ access(all) fun test_spread() { maxSpread: testRun.maxSpread, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 0, + maxPriceHistorySize: 0, priceHistoryInterval: 0.0, maxPriceHistoryAge: 0.0, minimumPriceHistory: 0, @@ -413,7 +413,7 @@ access(all) fun test_history() { maxSpread: 10000.0, baseTolerance: testRun.baseTolerance, driftExpansionRate: testRun.driftExpansionRate, - priceHistorySize: UInt8(testRun.priceHistory.length), + maxPriceHistorySize: UInt8(testRun.priceHistory.length), priceHistoryInterval: UFix64(testRun.priceHistoryDelay - 1.0), // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes minimumPriceHistory: 0, @@ -462,7 +462,7 @@ access(self) fun test_incomplete_price_history() { maxSpread: 10000.0, baseTolerance: 0.0, driftExpansionRate: 1.0, - priceHistorySize: UInt8(priceHistory.length), + maxPriceHistorySize: UInt8(priceHistory.length), priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes minimumPriceHistory: 0, @@ -497,7 +497,7 @@ access(self) fun test_old_price_history() { maxSpread: 10000.0, baseTolerance: 0.0, driftExpansionRate: 1.0, - priceHistorySize: UInt8(priceHistory.length), + maxPriceHistorySize: UInt8(priceHistory.length), priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 150.0, minimumPriceHistory: 0, @@ -530,7 +530,7 @@ access(all) fun test_minimum_price_history() { maxSpread: 10000.0, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 10, + maxPriceHistorySize: 10, priceHistoryInterval: 59.0, maxPriceHistoryAge: 600.0, // 20 minutes minimumPriceHistory: 5, @@ -569,7 +569,7 @@ access(all) fun test_events() { maxSpread: 1.0, baseTolerance: 1.0, driftExpansionRate: 1.0, - priceHistorySize: 1, + maxPriceHistorySize: 1, priceHistoryInterval: 60.0, maxPriceHistoryAge: 600.0, minimumPriceHistory: 0, @@ -656,7 +656,7 @@ access(all) fun test_cron_job() { maxSpread: 10000.0, baseTolerance: 10000.0, driftExpansionRate: 10000.0, - priceHistorySize: 5, + maxPriceHistorySize: 5, priceHistoryInterval: 59.0, // allow some jitter maxPriceHistoryAge: 600.0, // 10 minutes minimumPriceHistory: 0, diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc index 2d478f67..575cf258 100644 --- a/cadence/tests/test_helpers_price_oracle_aggregator.cdc +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -19,7 +19,7 @@ access(all) fun createAggregator( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -27,7 +27,7 @@ access(all) fun createAggregator( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create.cdc", - [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, maxPriceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount], [] ) Test.expect(res, Test.beSucceeded()) @@ -54,7 +54,7 @@ access(all) fun createAggregatorWithCron( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -67,7 +67,7 @@ access(all) fun createAggregatorWithCron( ): CreateAggregatorInfo { let res = _executeTransaction( "./transactions/price-oracle-aggregator/create_with_cron.cdc", - [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, priceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, maxPriceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], [signer] ) Test.expect(res, Test.beSucceeded()) diff --git a/cadence/tests/transactions/price-oracle-aggregator/create.cdc b/cadence/tests/transactions/price-oracle-aggregator/create.cdc index 028f2f10..5577c593 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -15,7 +15,7 @@ transaction( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -39,7 +39,7 @@ transaction( maxSpread: maxSpread, baseTolerance: baseTolerance, driftExpansionRate: driftExpansionRate, - priceHistorySize: priceHistorySize, + maxPriceHistorySize: maxPriceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, minimumPriceHistory: minimumPriceHistory, diff --git a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc index ce203735..ca5eff46 100644 --- a/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -15,7 +15,7 @@ transaction( maxSpread: UFix64, baseTolerance: UFix64, driftExpansionRate: UFix64, - priceHistorySize: UInt8, + maxPriceHistorySize: UInt8, priceHistoryInterval: UFix64, maxPriceHistoryAge: UFix64, minimumPriceHistory: UInt8, @@ -48,7 +48,7 @@ transaction( maxSpread: maxSpread, baseTolerance: baseTolerance, driftExpansionRate: driftExpansionRate, - priceHistorySize: priceHistorySize, + maxPriceHistorySize: maxPriceHistorySize, priceHistoryInterval: priceHistoryInterval, maxPriceHistoryAge: maxPriceHistoryAge, minimumPriceHistory: minimumPriceHistory, From 23928700133f5e9de574934bd70cfb65a7616c2c Mon Sep 17 00:00:00 2001 From: Patrick Fuchs Date: Tue, 10 Mar 2026 17:12:09 +0100 Subject: [PATCH 18/18] add fork contract addresses --- flow.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/flow.json b/flow.json index ad8bd23c..b7635563 100644 --- a/flow.json +++ b/flow.json @@ -52,13 +52,17 @@ "ExampleToken1": { "source": "./cadence/tests/contracts/ExampleToken1.cdc", "aliases": { - "testing": "0000000000000008" + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "ExampleToken2": { "source": "./cadence/tests/contracts/ExampleToken2.cdc", "aliases": { - "testing": "0000000000000008" + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "FlowALPEvents": { @@ -121,7 +125,9 @@ "FungibleTokenConnectors": { "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", "aliases": { - "testing": "0000000000000006" + "testing": "0000000000000006", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "MOET": { @@ -159,25 +165,33 @@ "MultiMockOracle": { "source": "./cadence/tests/contracts/MultiMockOracle.cdc", "aliases": { - "testing": "0000000000000007" + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "OracleStorage": { "source": "./cadence/tests/contracts/OracleStorage.cdc", "aliases": { - "testing": "0000000000000008" + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "PriceOracleAggregatorv1": { "source": "./cadence/contracts/PriceOracleAggregatorv1.cdc", "aliases": { - "testing": "0000000000000007" + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "PriceOracleRouterv1": { "source": "./cadence/contracts/PriceOracleRouterv1.cdc", "aliases": { - "testing": "0000000000000007" + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } } },