diff --git a/FlowActions b/FlowActions index 6769d4c9..9788d6ee 160000 --- a/FlowActions +++ b/FlowActions @@ -1 +1 @@ -Subproject commit 6769d4c9f9ded4a5b4404d8c982300e84ccef532 +Subproject commit 9788d6ee9f71e29d19643960ccb86738751065c4 diff --git a/PriceOracleArchitecture.md b/PriceOracleArchitecture.md new file mode 100644 index 00000000..07415732 --- /dev/null +++ b/PriceOracleArchitecture.md @@ -0,0 +1,105 @@ +# 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 | +|----------|------| +| **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. + +### 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. + +## PriceOracleAggregatorv1 + +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 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 (average) + +The aggregator uses the **arithmetic mean** of all oracle prices: + +- **Average:** `sum(prices) / count`. Same for any number of oracles (1, 2, 3+). + +## 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 stability (history tolerance) + +The aggregator keeps an array of the last **n** aggregated prices (with timestamps), respecting `priceHistoryInterval` and `maxPriceHistoryAge`. + +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{allowedRelativeDiff}_{i} = \text{baseTolerance} + \text{driftExpansionRate} \times \Delta t_{\text{minutes}} +$$ + +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})} +$$ + +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{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 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. + +## 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. + +- **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/PriceOracleAggregatorv1.cdc b/cadence/contracts/PriceOracleAggregatorv1.cdc new file mode 100644 index 00000000..d7036843 --- /dev/null +++ b/cadence/contracts/PriceOracleAggregatorv1.cdc @@ -0,0 +1,411 @@ +import "FlowToken" +import "DeFiActions" +import "FlowTransactionScheduler" + +/// 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`, +/// - 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. +/// Config is immutable at creation to avoid accidental changes in production. +access(all) contract PriceOracleAggregatorv1 { + + /// 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(oracleType: Type) + /// Spread between min and max oracle prices exceeded the configured + /// tolerance. + 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} + + init() { + self.storage <- {} + } + + /// Storage resource for one aggregated oracle (single market): a fixed + /// 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 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 + /// 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 + /// 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 + /// 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 + + init( + ofToken: Type, + oracles: [{DeFiActions.PriceOracle}], + maxSpread: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + 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" + minimumPriceHistory <= maxPriceHistorySize: + "minimumPriceHistory must be <= maxPriceHistorySize" + maxPriceHistoryAge >= priceHistoryInterval * UFix64(minimumPriceHistory + 1): + "maxPriceHistoryAge must be >= priceHistoryInterval * (minimumPriceHistory + 1)" + } + if maxPriceHistorySize == 0 && priceHistoryInterval != 0.0 { + panic("if maxPriceHistorySize == 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 + self.priceHistory = [] + self.maxSpread = maxSpread + self.baseTolerance = baseTolerance + self.driftExpansionRate = driftExpansionRate + self.maxPriceHistorySize = maxPriceHistorySize + self.priceHistoryInterval = priceHistoryInterval + self.maxPriceHistoryAge = maxPriceHistoryAge + self.minimumPriceHistory = minimumPriceHistory + 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, + /// - history is not stable. + access(all) fun price(ofToken: Type): UFix64? { + pre { + self.ofToken == ofToken: "ofToken type mismatch" + } + let now = getCurrentBlock().timestamp + let price = self.getPriceUncheckedHistory(now: now) + if price == nil { + return nil + } + let validPrice = price! + if !self.isHistoryStable(currentPrice: validPrice, now: now) { + return nil + } + 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 _ = self.getPriceUncheckedHistory( + now: getCurrentBlock().timestamp + ) + } + + /// 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 validPrices = prices! + if !self.isWithinSpreadTolerance(prices: validPrices) { + return nil + } + let price = self.averagePrice(prices: validPrices) + self.tryAddPriceToHistoryInternal(price: price, now: now) + return price + } + + access(self) fun getPrices(): [UFix64]? { + let prices: [UFix64] = [] + for oracle in self.oracles { + let price = oracle.price(ofToken: self.ofToken) + if price == nil { + emit PriceNotAvailable(oracleType: oracle.getType()) + return nil + } + prices.append(price!) + } + return prices + } + + 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 { + if price < minPrice { + minPrice = price + } + if price > maxPrice { + maxPrice = price + } + } + if minPrice == 0.0 { + return false + } + let spread = (maxPrice - minPrice) / minPrice + if spread > self.maxSpread { + emit PriceNotWithinSpreadTolerance( + spread: spread, + maxAllowedSpread: self.maxSpread + ) + return false + } + return true + } + + /// 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 + for price in prices { + totalSum = totalSum + price + } + return totalSum / UFix64(prices.length) + } + + 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) + + // 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 + if currentPrice > entry.price { + let priceDiff = currentPrice - entry.price + relativeDiff = priceDiff / entry.price + } else { + let priceDiff = entry.price - currentPrice + relativeDiff = priceDiff / currentPrice + } + + // The "n" component: baseTolerance + // The "mx" component: driftExpansionRate * deltaT + let deltaTMinutes = deltaT / 60.0 + let totalAllowedTolerance = self.baseTolerance + (self.driftExpansionRate * deltaTMinutes) + + if relativeDiff > totalAllowedTolerance { + emit PriceNotWithinHistoryTolerance( + relativeDiff: relativeDiff, + deltaTMinutes: deltaTMinutes, + maxAllowedRelativeDiff: totalAllowedTolerance + ) + return false + } + } + return validEntryCount >= self.minimumPriceHistory + } + + 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] + let nextEntryTimestamp = lastEntry.timestamp + self.priceHistoryInterval + if now < nextEntryTimestamp { + return + } + } + let newEntry = PriceHistoryEntry(price: price, timestamp: now) + self.priceHistory.append(newEntry) + if self.priceHistory.length > Int(self.maxPriceHistorySize) { + let _ = self.priceHistory.removeFirst() + } + } + } + + /// Struct over a `PriceOracleAggregatorStorage` + /// See `DeFiActions.PriceOracle` for interface documentation. + /// + /// Additionally implements `priceHistory()` to return the price history + /// array. + access(all) struct PriceOracleAggregator: DeFiActions.PriceOracle { + access(all) let storageID: UInt64 + access(contract) var uniqueID: DeFiActions.UniqueIdentifier? + + init(storageID: UInt64) { + self.storageID = storageID + self.uniqueID = DeFiActions.createUniqueIdentifier() + if PriceOracleAggregatorv1.storage.containsKey(self.storageID) == false { + panic("Storage not found for storageID: \(self.storageID)") + } + } + + access(all) fun price(ofToken: Type): UFix64? { + return self.borrowPriceOracleAggregator().price(ofToken: ofToken) + } + + access(all) view fun unitOfAccount(): Type { + return self.borrowPriceOracleAggregator().unitOfAccountType + } + + access(all) fun priceHistory(): &[PriceHistoryEntry] { + return self.borrowPriceOracleAggregator().priceHistory + } + + 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 (&PriceOracleAggregatorv1.storage[self.storageID])! + } + } + + /// 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(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 (&PriceOracleAggregatorv1.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, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + unitOfAccount: Type, + ): UInt64 { + let priceOracleAggregator <- create PriceOracleAggregatorStorage( + ofToken: ofToken, + oracles: oracles, + maxSpread: maxSpread, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, + maxPriceHistorySize: maxPriceHistorySize, + priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, + unitOfAccount: unitOfAccount + ) + let id = priceOracleAggregator.uuid + self.storage[id] <-! priceOracleAggregator + emit StorageCreated(storageID: id) + return id + } + + /// Returns a `PriceOracleAggregator` which implements + /// `DeFiActions.PriceOracle` for the given storage. + access(all) fun createPriceOracleAggregator(storageID: UInt64): PriceOracleAggregator { + return PriceOracleAggregator(storageID: storageID) + } + + /// 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) + } + + /// Struct to store one entry in the aggregator's price history array for + /// history stability checks. + 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 + } + } + + +} \ No newline at end of file diff --git a/cadence/contracts/PriceOracleRouterv1.cdc b/cadence/contracts/PriceOracleRouterv1.cdc new file mode 100644 index 00000000..bb3d9980 --- /dev/null +++ b/cadence/contracts/PriceOracleRouterv1.cdc @@ -0,0 +1,82 @@ +import "DeFiActions" + +/// 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 PriceOracleRouterv1 { + + /// 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? + + init(unitOfAccount: Type, oracles: {Type: {DeFiActions.PriceOracle}}) { + self.unitOfAccountType = unitOfAccount + self.uniqueID = DeFiActions.createUniqueIdentifier() + for oracle in oracles.values { + if oracle.unitOfAccount() != unitOfAccount { + panic("Oracle unit of account does not match router unit of account") + } + } + self.oracles = oracles + } + + access(all) fun price(ofToken: Type): UFix64? { + return self.oracles[ofToken]?.price(ofToken: ofToken) ?? nil + } + + access(all) view fun unitOfAccount(): Type { + 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 + } + + 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 + } + } + + /// 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}}, + ): PriceOracleRouter { + return PriceOracleRouter(unitOfAccount: unitOfAccount, oracles: oracles) + } +} \ No newline at end of file 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/contracts/ExampleToken1.cdc b/cadence/tests/contracts/ExampleToken1.cdc new file mode 100644 index 00000000..37bc445a --- /dev/null +++ b/cadence/tests/contracts/ExampleToken1.cdc @@ -0,0 +1,227 @@ +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 + 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..e87821ac --- /dev/null +++ b/cadence/tests/contracts/ExampleToken2.cdc @@ -0,0 +1,227 @@ +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 + 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 new file mode 100644 index 00000000..4b175c98 --- /dev/null +++ b/cadence/tests/contracts/MultiMockOracle.cdc @@ -0,0 +1,98 @@ +import "FungibleToken" + +import "DeFiActions" + +/// 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(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?} + + access(all) fun setPrice(forToken: Type, price: UFix64?) { + self.prices[forToken] = price + } + + init(unitOfAccountType: Type) { + self.unitOfAccountType = unitOfAccountType + self.prices = {} + } + } + + /// 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 storageID: 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] ?? 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(), + 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.storageID])! + } + + 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(storageID: id) + let oracle = PriceOracle(storageID: id) + return oracle + } + + access(all) view fun borrowPriceOracleStorage(storageID: UInt64): &PriceOracleStorage? { + return &self.priceOracleStorages[storageID] + } + + /// 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) + } + + init() { + self.priceOracleStorages <- {} + } +} diff --git a/cadence/tests/contracts/OracleStorage.cdc b/cadence/tests/contracts/OracleStorage.cdc new file mode 100644 index 00000000..85bc303f --- /dev/null +++ b/cadence/tests/contracts/OracleStorage.cdc @@ -0,0 +1,17 @@ +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}? + + init() { + 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 new file mode 100644 index 00000000..29e06a2e --- /dev/null +++ b/cadence/tests/price_oracle_aggregator_test.cdc @@ -0,0 +1,718 @@ +import Test +import BlockchainHelpers + +import "PriceOracleAggregatorv1" +import "FlowToken" +import "MOET" +import "MultiMockOracle" +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() +} + +access(all) fun beforeEach() { + Test.commitBlock() + Test.reset(to: snapshot) +} + +access(all) fun test_single_oracle() { + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + maxPriceHistorySize: 0, + priceHistoryInterval: 0.0, + maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@MOET.Vault>(), + ) + let prices: [UFix64?] = [1.0, 0.0001, 1337.0] + for p in prices { + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: p, + ) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + Test.assertEqual(p, price) + } +} + +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( + ofToken: Type<@MOET.Vault>(), + oracleCount: oracleCount, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + maxPriceHistorySize: 0, + priceHistoryInterval: 0.0, + maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@FlowToken.Vault>(), + ) + let prices: [UFix64?] = [1.0, 0.0001, 1337.0] + for p in prices { + 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<@MOET.Vault>() + ) + Test.assertEqual(p, price) + } + } +} + +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: 4.33333333, + ), TestRunAveragePrice( + prices: [1.0, 9.0, 10.0], + expectedPrice: 6.66666666, + ), TestRunAveragePrice( + prices: [1.0, 1.0, 2.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.75, + ), 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, + ), 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, + )] + 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( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: testRun.prices.length, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + maxPriceHistorySize: 0, + priceHistoryInterval: 0.0, + maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@MOET.Vault>(), + ) + set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + if price != testRun.expectedPrice { + log(testRun) + logFailEvents() + 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() { + // 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, + ), + // 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, + ), + // Same spread [1.0, 2.0, 1.0]: reject + TestRunSpread( + maxSpread: 0.9, + prices: [1.0, 2.0, 1.0], + expectedPrice: nil, + ), + // 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, + ), + // maxSpread 0 = no tolerance; tiny diff 1.0001 vs 1.0: reject + TestRunSpread( + maxSpread: 0.0, + prices: [1.0, 1.0001], + expectedPrice: nil, + ), + // Same with three oracles: reject + TestRunSpread( + maxSpread: 0.0, + prices: [1.0, 1.0001, 1.0], + expectedPrice: nil, + ), + // Very loose maxSpread: accept average 1.5 + TestRunSpread( + maxSpread: 10000.0, + prices: [1.0, 2.0], + expectedPrice: 1.5, + ), + // Loose spread, three oracles: accept average 1.5 + TestRunSpread( + maxSpread: 10000.0, + prices: [1.0, 1.5, 2.0], + expectedPrice: 1.5, + )] + 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( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: testRun.prices.length, + maxSpread: testRun.maxSpread, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + maxPriceHistorySize: 0, + priceHistoryInterval: 0.0, + maxPriceHistoryAge: 0.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@MOET.Vault>(), + ) + set_prices(info: info, prices: testRun.prices, forToken: Type<@FlowToken.Vault>()) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + if price != testRun.expectedPrice { + log(testRun) + logFailEvents() + log(price) + Test.assertEqual(testRun.expectedPrice, price) + } + } +} + +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 isHistoryStable: Bool + + init( + baseTolerance: UFix64, + driftExpansionRate: UFix64, + priceHistory: [UFix64], + priceHistoryDelay: Fix64, + isHistoryStable: Bool, + ) { + self.baseTolerance = baseTolerance + self.driftExpansionRate = driftExpansionRate + self.priceHistory = priceHistory + self.priceHistoryDelay = priceHistoryDelay + self.isHistoryStable = isHistoryStable + } +} + +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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 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, + ), + // 1.31 exceeds baseTolerance 0.2 + drift from 1.0: unstable + TestRunHistory( + baseTolerance: 0.2, + driftExpansionRate: 0.1, + priceHistory: [1.0, 1.31], + priceHistoryDelay: 60.0, + isHistoryStable: false, + )] + let reversedRuns: [TestRunHistory] = [] + for testRun in testRuns { + reversedRuns.append(TestRunHistory( + baseTolerance: testRun.baseTolerance, + driftExpansionRate: testRun.driftExpansionRate, + priceHistory: testRun.priceHistory.reverse(), + priceHistoryDelay: testRun.priceHistoryDelay, + isHistoryStable: testRun.isHistoryStable, + )) + } + testRuns.appendAll(reversedRuns) + for testRun in testRuns { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: testRun.baseTolerance, + driftExpansionRate: testRun.driftExpansionRate, + maxPriceHistorySize: 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 { + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: testRun.priceHistoryDelay) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + } + // make sure prices are correctly recorded + let priceHistory = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) + 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) + i = i + 1 + } + + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + if priceIsStable != testRun.isHistoryStable { + log(testRun) + log(price) + logFailEvents() + Test.fail(message: "invalid price") + } + } +} + +access(self) fun test_incomplete_price_history() { + let priceHistory = [1.0, nil, nil, 4.0] + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: 0.0, + driftExpansionRate: 1.0, + maxPriceHistorySize: 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) + for price in priceHistory { + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: 60.0) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + } + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + Test.assert(priceIsStable, message: "price should be stable") +} + +access(self) fun test_old_price_history() { + let priceHistory = [1.0, nil, nil, 40.0] + let info = createAggregator( + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: 0.0, + driftExpansionRate: 1.0, + maxPriceHistorySize: UInt8(priceHistory.length), + priceHistoryInterval: 59.0, // allow some jitter + maxPriceHistoryAge: 150.0, + minimumPriceHistory: 0, + unitOfAccount: Type<@MOET.Vault>(), + ) + for price in priceHistory { + setMultiMockOraclePrice( + storageID: info.mockOracleStorageIDs[0], + forToken: Type<@FlowToken.Vault>(), + price: price, + ) + Test.moveTime(by: 60.0) + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + } + var price = oracleAggregatorPrice( + storageID: info.aggregatorStorageID, + ofToken: Type<@FlowToken.Vault>() + ) + let priceIsStable = price != nil + 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, + maxPriceHistorySize: 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, + maxPriceHistorySize: 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! PriceOracleAggregatorv1.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! 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") + + // 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! 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)") + 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() { + let info = createAggregatorWithCron( + signer: signer, + ofToken: Type<@FlowToken.Vault>(), + oracleCount: 1, + maxSpread: 10000.0, + baseTolerance: 10000.0, + driftExpansionRate: 10000.0, + maxPriceHistorySize: 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")!, + 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: 30.0) + while i < 5 { + let history = oracleAggregatorPriceHistory(storageID: info.aggregatorStorageID) + 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 + Test.moveTime(by: 60.0) + } +} + +access(self) fun set_prices(info: CreateAggregatorInfo, prices: [UFix64?], forToken: Type) { + var i = 0 + let txs: [Test.Transaction] = [] + for p in prices { + let tx = setMultiMockOraclePriceTx( + storageID: info.mockOracleStorageIDs[i], + 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 logFailEvents() { + 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/price_oracle_router_test.cdc b/cadence/tests/price_oracle_router_test.cdc new file mode 100644 index 00000000..80085267 --- /dev/null +++ b/cadence/tests/price_oracle_router_test.cdc @@ -0,0 +1,143 @@ +import Test +import BlockchainHelpers + +import "PriceOracleRouterv1" +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() { + if snapshot != getCurrentBlockHeight() { + Test.reset(to: snapshot) + } +} + +access(all) fun test_router_add_oracle() { + let info = [ + createPriceOracleRouterInfo( + unitOfAccount: Type<@MOET.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ) + ] + createPriceOracleRouter( + unitOfAccount: Type<@MOET.Vault>(), + createRouterInfo: info, + expectSucceeded: true + ) + var price = 0.0 as UFix64? + price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) + Test.assertEqual(1.0 as UFix64?, price) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) + Test.assertEqual(nil as UFix64?, price) +} + +access(all) fun test_router_add_multiple_oracles() { + 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 + ) + var price = 0.0 as UFix64? + price = priceOracleRouterPrice(ofToken: Type<@FlowToken.Vault>()) + Test.assertEqual(1.0 as UFix64?, price) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken1.Vault>()) + Test.assertEqual(2.0 as UFix64?, price) + price = priceOracleRouterPrice(ofToken: Type<@ExampleToken2.Vault>()) + Test.assertEqual(3.0 as UFix64?, price) +} + +access(all) fun test_router_add_wrong_unit_of_account() { + let info = [ + createPriceOracleRouterInfo( + unitOfAccount: Type<@ExampleToken1.Vault>(), + oracleOfToken: Type<@FlowToken.Vault>(), + prices: 1.0 + ) + ] + createPriceOracleRouter( + unitOfAccount: Type<@MOET.Vault>(), + 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-aggregator/history.cdc b/cadence/tests/scripts/price-oracle-aggregator/history.cdc new file mode 100644 index 00000000..a21f0506 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-aggregator/history.cdc @@ -0,0 +1,6 @@ +import "PriceOracleAggregatorv1" + +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 new file mode 100644 index 00000000..10f4205f --- /dev/null +++ b/cadence/tests/scripts/price-oracle-aggregator/price.cdc @@ -0,0 +1,6 @@ +import "PriceOracleAggregatorv1" + +access(all) fun main(storageID: UInt64, ofToken: Type): UFix64? { + 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 new file mode 100644 index 00000000..29d2ee27 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-router/get_supported_tokens.cdc @@ -0,0 +1,8 @@ +import "OracleStorage" +import "PriceOracleRouterv1" + +access(all) fun main(): [Type] { + let oracle = OracleStorage.oracle! + 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 new file mode 100644 index 00000000..6ce43e62 --- /dev/null +++ b/cadence/tests/scripts/price-oracle-router/is_token_supported.cdc @@ -0,0 +1,8 @@ +import "OracleStorage" +import "PriceOracleRouterv1" + +access(all) fun main(tokenType: Type): Bool { + let oracle = OracleStorage.oracle! + let router = oracle as! &PriceOracleRouterv1.PriceOracleRouter + return router.isTokenSupported(tokenType: tokenType) +} 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 8cf54f5b..844cd8a6 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -171,6 +171,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: "PriceOracleAggregatorv1", + path: "../contracts/PriceOracleAggregatorv1.cdc", + arguments: [] + ) + Test.expect(err, Test.beNil()) + let initialYieldTokenSupply = 0.0 err = Test.deployContract( name: "MockYieldToken", @@ -249,6 +263,34 @@ fun deployContracts() { arguments: [] ) Test.expect(err, Test.beNil()) + + err = Test.deployContract( + name: "PriceOracleRouterv1", + path: "../contracts/PriceOracleRouterv1.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 --- */ @@ -771,7 +813,7 @@ fun collectStability( [ tokenTypeIdentifier ], signer ) - + return res } @@ -788,7 +830,7 @@ fun withdrawStabilityFund( [tokenTypeIdentifier, amount, recipient, recipientPath], signer ) - + return res } diff --git a/cadence/tests/test_helpers_price_oracle_aggregator.cdc b/cadence/tests/test_helpers_price_oracle_aggregator.cdc new file mode 100644 index 00000000..575cf258 --- /dev/null +++ b/cadence/tests/test_helpers_price_oracle_aggregator.cdc @@ -0,0 +1,201 @@ +import Test +import "DeFiActions" +import "PriceOracleAggregatorv1" +import "MultiMockOracle" + +access(all) struct CreateAggregatorInfo { + access(all) let aggregatorStorageID: UInt64 + access(all) let mockOracleStorageIDs: [UInt64] + + init(aggregatorStorageID: UInt64, mockOracleStorageIDs: [UInt64]) { + self.aggregatorStorageID = aggregatorStorageID + self.mockOracleStorageIDs = mockOracleStorageIDs + } +} + +access(all) fun createAggregator( + ofToken: Type, + oracleCount: Int, + maxSpread: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + unitOfAccount: Type, +): CreateAggregatorInfo { + let res = _executeTransaction( + "./transactions/price-oracle-aggregator/create.cdc", + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, maxPriceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount], + [] + ) + Test.expect(res, Test.beSucceeded()) + 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 + 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, + maxSpread: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + unitOfAccount: Type, + cronExpression: String, + cronHandlerStoragePath: StoragePath, + keeperExecutionEffort: UInt64, + executorExecutionEffort: UInt64, + aggregatorCronHandlerStoragePath: StoragePath +): CreateAggregatorInfo { + let res = _executeTransaction( + "./transactions/price-oracle-aggregator/create_with_cron.cdc", + [ofToken, oracleCount, maxSpread, baseTolerance, driftExpansionRate, maxPriceHistorySize, priceHistoryInterval, maxPriceHistoryAge, minimumPriceHistory, unitOfAccount, cronExpression, cronHandlerStoragePath, keeperExecutionEffort, executorExecutionEffort, aggregatorCronHandlerStoragePath], + [signer] + ) + Test.expect(res, Test.beSucceeded()) + 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 + 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 + ) +} + +// 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, + price: UFix64?, +) { + let res = _executeTransaction( + "./transactions/multi-mock-oracle/set_price.cdc", + [storageID, forToken, price], + [] + ) + Test.expect(res, Test.beSucceeded()) +} + +access(all) fun oracleAggregatorPrice( + storageID: UInt64, + ofToken: Type, +): UFix64? { + // execute transaction to emit events + let res = _executeTransaction( + "./transactions/price-oracle-aggregator/price.cdc", + [storageID, ofToken], + [] + ) + Test.expect(res, Test.beSucceeded()) + // execute script to get price + let res2 = _executeScript( + "./scripts/price-oracle-aggregator/price.cdc", + [storageID, ofToken] + ) + Test.expect(res2, Test.beSucceeded()) + 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, +): [PriceOracleAggregatorv1.PriceHistoryEntry] { + let res = _executeScript( + "./scripts/price-oracle-aggregator/history.cdc", + [storageID] + ) + Test.expect(res, Test.beSucceeded()) + return res.returnValue as! [PriceOracleAggregatorv1.PriceHistoryEntry] +} + +// --- 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 { + 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..3c544c9e --- /dev/null +++ b/cadence/tests/test_helpers_price_oracle_router.cdc @@ -0,0 +1,74 @@ +import Test + +access(all) fun createPriceOracleRouter( + 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 createPriceOracleRouterInfo( + unitOfAccount: Type, + oracleOfToken: Type, + prices: UFix64? +): {String: AnyStruct} { + return { + "unitOfAccount": unitOfAccount, + "oracleOfToken": oracleOfToken, + "price": prices + } +} + +access(all) fun priceOracleRouterPrice(ofToken: Type): UFix64? { + let res = _executeScript( + "./scripts/price-oracle-router/price.cdc", + [ofToken], + ) + Test.expect(res, Test.beSucceeded()) + 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 { + 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/multi-mock-oracle/set_price.cdc b/cadence/tests/transactions/multi-mock-oracle/set_price.cdc new file mode 100644 index 00000000..e838f9a2 --- /dev/null +++ b/cadence/tests/transactions/multi-mock-oracle/set_price.cdc @@ -0,0 +1,15 @@ +import "MultiMockOracle" + +transaction( + storageID: UInt64, + forToken: Type, + price: UFix64?, +) { + execute { + MultiMockOracle.setPrice( + storageID: storageID, + forToken: forToken, + price: price, + ) + } +} \ No newline at end of file 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..c4f90fd9 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/assert_price.cdc @@ -0,0 +1,18 @@ +import "PriceOracleAggregatorv1" + +transaction( + storageID: UInt64, + forToken: Type, + price: UFix64?, +) { + execute { + let realPrice = PriceOracleAggregatorv1.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 new file mode 100644 index 00000000..5577c593 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/create.cdc @@ -0,0 +1,49 @@ +import "DeFiActions" +import "PriceOracleAggregatorv1" +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FungibleToken" +import "FlowToken" +import "FlowFees" +import "FlowCron" +import "MultiMockOracle" +import "MOET" + +transaction( + ofToken: Type, + oracleCount: Int, + maxSpread: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + unitOfAccount: Type, +) { + let oracles: [{DeFiActions.PriceOracle}] + + prepare() { + self.oracles = [] + var i = 0 + while i < oracleCount { + self.oracles.append(MultiMockOracle.createPriceOracle(unitOfAccountType: unitOfAccount)) + i = i + 1 + } + } + + execute { + let _ = PriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + ofToken: ofToken, + oracles: self.oracles, + maxSpread: maxSpread, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, + maxPriceHistorySize: maxPriceHistorySize, + priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, + unitOfAccount: unitOfAccount + ) + } +} \ 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..ca5eff46 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/create_with_cron.cdc @@ -0,0 +1,146 @@ +import "DeFiActions" +import "PriceOracleAggregatorv1" +import "FlowTransactionScheduler" +import "FlowTransactionSchedulerUtils" +import "FungibleToken" +import "FlowToken" +import "FlowFees" +import "FlowCron" +import "MultiMockOracle" +import "MOET" + +transaction( + ofToken: Type, + oracleCount: Int, + maxSpread: UFix64, + baseTolerance: UFix64, + driftExpansionRate: UFix64, + maxPriceHistorySize: UInt8, + priceHistoryInterval: UFix64, + maxPriceHistoryAge: UFix64, + minimumPriceHistory: UInt8, + 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 = PriceOracleAggregatorv1.createPriceOracleAggregatorStorage( + ofToken: ofToken, + oracles: self.oracles, + maxSpread: maxSpread, + baseTolerance: baseTolerance, + driftExpansionRate: driftExpansionRate, + maxPriceHistorySize: maxPriceHistorySize, + priceHistoryInterval: priceHistoryInterval, + maxPriceHistoryAge: maxPriceHistoryAge, + minimumPriceHistory: minimumPriceHistory, + unitOfAccount: unitOfAccount + ) + + // Create cron handler for the aggregator, look at flow-cron for reference + + 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") + + 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/price.cdc b/cadence/tests/transactions/price-oracle-aggregator/price.cdc new file mode 100644 index 00000000..ca8cdf30 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-aggregator/price.cdc @@ -0,0 +1,12 @@ +import "PriceOracleAggregatorv1" + +transaction( + storageID: UInt64, + forToken: Type, +) { + execute { + let _ = PriceOracleAggregatorv1.createPriceOracleAggregator(storageID: storageID).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..a8a354b2 --- /dev/null +++ b/cadence/tests/transactions/price-oracle-router/create.cdc @@ -0,0 +1,27 @@ +import "PriceOracleRouterv1" +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 = PriceOracleRouterv1.createPriceOracleRouter( + unitOfAccount: unitOfAccount, + oracles: oracles, + ) + OracleStorage.saveOracle(oracle: router) + } +} \ No newline at end of file diff --git a/flow.json b/flow.json index bd329189..154d3e71 100644 --- a/flow.json +++ b/flow.json @@ -49,6 +49,22 @@ "mainnet-fork": "6d888f175c158410" } }, + "ExampleToken1": { + "source": "./cadence/tests/contracts/ExampleToken1.cdc", + "aliases": { + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } + }, + "ExampleToken2": { + "source": "./cadence/tests/contracts/ExampleToken2.cdc", + "aliases": { + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } + }, "FlowALPEvents": { "source": "./cadence/contracts/FlowALPEvents.cdc", "aliases": { @@ -123,11 +139,9 @@ "FungibleTokenConnectors": { "source": "./FlowActions/cadence/contracts/connectors/FungibleTokenConnectors.cdc", "aliases": { - "emulator": "045a1763c93006ca", - "mainnet": "0c237e1265caa7a3", - "mainnet-fork": "0c237e1265caa7a3", "testing": "0000000000000006", - "testnet": "4cd02f8de4122c84" + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" } }, "MOET": { @@ -161,6 +175,38 @@ "testing": "0000000000000007", "mainnet-fork": "6b00ff876c299c61" } + }, + "MultiMockOracle": { + "source": "./cadence/tests/contracts/MultiMockOracle.cdc", + "aliases": { + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } + }, + "OracleStorage": { + "source": "./cadence/tests/contracts/OracleStorage.cdc", + "aliases": { + "testing": "0000000000000008", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } + }, + "PriceOracleAggregatorv1": { + "source": "./cadence/contracts/PriceOracleAggregatorv1.cdc", + "aliases": { + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } + }, + "PriceOracleRouterv1": { + "source": "./cadence/contracts/PriceOracleRouterv1.cdc", + "aliases": { + "testing": "0000000000000007", + "mainnet-fork": "6b00ff876c299c61", + "mainnet": "6b00ff876c299c61" + } } }, "dependencies": {