diff --git a/cadence/tests/fork_oracle_failure_test.cdc b/cadence/tests/fork_oracle_failure_test.cdc index 300a5d0a..240f7987 100644 --- a/cadence/tests/fork_oracle_failure_test.cdc +++ b/cadence/tests/fork_oracle_failure_test.cdc @@ -15,9 +15,13 @@ import "FlowALPMath" import "test_helpers.cdc" access(all) let MAINNET_PROTOCOL_ACCOUNT = Test.getAccount(MAINNET_PROTOCOL_ACCOUNT_ADDRESS) +access(all) let BAND_ORACLE_ACCOUNT = Test.getAccount(MAINNET_BAND_ORACLE_ADDRESS) +access(all) let BAND_ORACLE_CONNECTORS_ACCOUNT = Test.getAccount(MAINNET_BAND_ORACLE_CONNECTORS_ADDRESS) + access(all) let MAINNET_USDF_HOLDER = Test.getAccount(MAINNET_USDF_HOLDER_ADDRESS) access(all) let MAINNET_WETH_HOLDER = Test.getAccount(MAINNET_WETH_HOLDER_ADDRESS) +access(all) let MAINNET_MOCKED_YIELD_TOKEN_ACCOUNT = Test.getAccount(MAINNET_PROTOCOL_ACCOUNT_ADDRESS) access(all) var snapshot: UInt64 = 0 access(all) @@ -32,9 +36,10 @@ access(all) fun setup() { deployContracts() + // create pool with mock oracle createAndStorePool(signer: MAINNET_PROTOCOL_ACCOUNT, defaultTokenIdentifier: MAINNET_MOET_TOKEN_ID, beFailed: false) - // Set initial oracle prices (baseline) + // Set initial oracle prices setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_FLOW_TOKEN_ID, price: 1.0) setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_USDF_TOKEN_ID, price: 1.0) setMockOraclePrice(signer: MAINNET_PROTOCOL_ACCOUNT, forTokenIdentifier: MAINNET_WETH_TOKEN_ID, price: 2000.0) @@ -517,4 +522,202 @@ fun test_flash_pump_increase_doubles_health() { // when information sources disagree. let healthAfterCorrection = getPositionHealth(pid: pid, beFailed: false) Test.assert(healthAfterCorrection < 1.0, message: "Position should be underwater after pump-and-dump scenario") +} + +// ----------------------------------------------------------------------------- +/// test_band_oracle_stale_price tests that the protocol correctly handles stale price data returned by the Band oracle. +/// +/// The test verifies 2 scenarios: +/// 1. A user attempts to open a position when the oracle price is stale, the transaction must fail. +/// 2. The oracle price is updated to a fresh value, the position creation should succeed. +// ----------------------------------------------------------------------------- +access(all) +fun test_band_oracle_stale_price() { + safeReset() + + updateOracleToBandOracle(signer: MAINNET_PROTOCOL_ACCOUNT) + + // setup user with FLOW position + let user = Test.createAccount() + let FLOWAmount = 1000.0 + transferFlowTokens(to: user, amount: FLOWAmount) + grantBetaPoolParticipantAccess(MAINNET_PROTOCOL_ACCOUNT, user) + + // Case 1: try to open position with a stale oracle price, expect error + var openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [FLOWAmount, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beFailed()) + Test.assertError(openRes, errorMessage: "Price data's base timestamp 1771341870 exceeds the staleThreshold 1771345470 at current timestamp") + + // Case 2: update the oracle price and retry opening the position + + // update price for FLOW + let symbolPrices = { + "FLOW": 1.0 + } + setBandOraclePrices(signer: BAND_ORACLE_ACCOUNT, symbolPrices: symbolPrices) + + // user should now be able to open the position + openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [FLOWAmount, FLOW_VAULT_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) +} + +// ----------------------------------------------------------------------------- +/// Tests how the protocol behaves when the Band oracle returns no price (nil) for a token. +/// +/// The test covers three scenarios: +/// 1. The token has no oracle symbol mapping, transaction fails. +/// 2. The symbol exists but the oracle has no price for it, transaction fails. +/// 3. A valid price is provided, the position creation succeeds. +// ----------------------------------------------------------------------------- +access(all) +fun test_band_oracle_nil_price() { + // add Mocked Yield token as supported token (80% CF, 90% BF) + addSupportedTokenZeroRateCurve( + signer: MAINNET_PROTOCOL_ACCOUNT, + tokenTypeIdentifier: MAINNET_MOCKED_YIELD_TOKEN_ID, + collateralFactor: 0.8, + borrowFactor: 0.9, + depositRate: 1_000_000.0, + depositCapacityCap: 1_000_000.0 + ) + + updateOracleToBandOracle(signer: MAINNET_PROTOCOL_ACCOUNT) + + // setup user account with MockYieldToken + let user = Test.createAccount() + let YIELDAmount = 1000.0 + setupMockYieldTokenVault(user, beFailed: false) + mintMockYieldToken(signer: MAINNET_MOCKED_YIELD_TOKEN_ACCOUNT, to: user.address, amount: YIELDAmount, beFailed: false) + + grantBetaPoolParticipantAccess(MAINNET_PROTOCOL_ACCOUNT, user) + + // Case 1: try to open a position without a registered oracle symbol, expect a price error + var openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [YIELDAmount, MAINNET_YIELD_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beFailed()) + Test.assertError(openRes, errorMessage: "Base asset type A.6b00ff876c299c61.MockYieldToken.Vault does not have an assigned symbol") + + // add a new YIELD symbol to BandOracleConnectors + addSymbolToBandOracle( + signer: BAND_ORACLE_CONNECTORS_ACCOUNT, + symbol: "YIELD", + tokenTypeIdentifier: MAINNET_MOCKED_YIELD_TOKEN_ID + ) + + // Case 2: try to open a position when the symbol exists but the oracle returns no price, expect price error + openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [YIELDAmount, MAINNET_YIELD_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beFailed()) + Test.assertError(openRes, errorMessage: "Cannot get a quote for the requested symbol pair.") + + // Case 3: provide a valid oracle price, try to open position + + // update price for YIELD + let symbolPrices = { + "YIELD": 1.0 + } + setBandOraclePrices(signer: BAND_ORACLE_ACCOUNT, symbolPrices: symbolPrices) + + // user should now be able to open the position + openRes = _executeTransaction( + "../transactions/flow-alp/position/create_position.cdc", + [YIELDAmount, MAINNET_YIELD_STORAGE_PATH, false], + user + ) + Test.expect(openRes, Test.beSucceeded()) +} + +// ----------------------------------------------------------------------------- +/// Tests that liquidation is prevented when the DEX price deviates too much +/// from the Band oracle price. +/// +/// The test creates an unhealthy position and configures the DEX price so that +/// the deviation between the DEX price and the Band oracle price exceeds the +/// configured deviation threshold. +// ----------------------------------------------------------------------------- +access(all) +fun test_band_oracle_dex_deviation_threshold() { + safeReset() + + updateOracleToBandOracle(signer: MAINNET_PROTOCOL_ACCOUNT) + + // set initial Band oracle prices for USD (MOET) and FLOW + var symbolPrices = { + "USD": 1.0, + "FLOW": 1.0 + } + setBandOraclePrices(signer: BAND_ORACLE_ACCOUNT, symbolPrices: symbolPrices) + + // setup moetLp account, create position + let moetLp = Test.createAccount() + let MOETAmount = 50000.0 + setupMoetVault(moetLp, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: moetLp.address, amount: MOETAmount, beFailed: false) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: moetLp, amount: MOETAmount, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, pushToDrawDownSink: false) + + // setup user account, create position + let user = Test.createAccount() + setupMoetVault(user, beFailed: false) + let FLOWAmount = 1000.0 + transferFlowTokens(to: user, amount: FLOWAmount) + createPosition(admin: MAINNET_PROTOCOL_ACCOUNT, signer: user, amount: FLOWAmount, vaultStoragePath: FLOW_VAULT_STORAGE_PATH, pushToDrawDownSink: false) + + let openEvents = Test.eventsOfType(Type()) + let pid = (openEvents[openEvents.length - 1] as! FlowALPEvents.Opened).pid + + // borrow MOET against the FLOW collateral + borrowFromPosition(signer: user, positionId: pid, tokenTypeIdentifier: MAINNET_MOET_TOKEN_ID, vaultStoragePath: MAINNET_MOET_STORAGE_PATH, amount: 700.0, beFailed: false) + + // make the position unhealthy by lowering the FLOW oracle price + symbolPrices = { + "FLOW": 0.7 + } + setBandOraclePrices(signer: BAND_ORACLE_ACCOUNT, symbolPrices: symbolPrices) + + // set a DEX price slightly different from the oracle price + // + // Oracle: $0.70 + // DEX: $0.685 + // deviation = |0.685-0.70|/0.685 = 2.19% + setMockDexPriceForPair( + signer: MAINNET_PROTOCOL_ACCOUNT, + inVaultIdentifier: MAINNET_FLOW_TOKEN_ID, + outVaultIdentifier: MAINNET_MOET_TOKEN_ID, + vaultSourceStoragePath: MAINNET_MOET_STORAGE_PATH, + priceRatio: 0.685 + ) + + // tighten the allowed deviation threshold to 100 bps (1%) + setDexLiquidationConfig(signer: MAINNET_PROTOCOL_ACCOUNT, dexOracleDeviationBps: 100) + + // setup liquidator account + let liquidator = Test.createAccount() + setupMoetVault(liquidator, beFailed: false) + mintMoet(signer: MAINNET_PROTOCOL_ACCOUNT, to: liquidator.address, amount: 500.0, beFailed: false) + + // liquidation should now fail (2.19% > 1% threshold) + let liqRes = manualLiquidation( + signer: liquidator, + pid: pid, + debtVaultIdentifier: MAINNET_MOET_TOKEN_ID, + seizeVaultIdentifier: MAINNET_FLOW_TOKEN_ID, + seizeAmount: 140.0, + repayAmount: 100.0, + ) + Test.expect(liqRes, Test.beFailed()) + Test.assertError(liqRes, errorMessage: "DEX/oracle price deviation too large") } \ No newline at end of file diff --git a/cadence/tests/liquidation_phase1_test.cdc b/cadence/tests/liquidation_phase1_test.cdc index da88b3d8..8dff3134 100644 --- a/cadence/tests/liquidation_phase1_test.cdc +++ b/cadence/tests/liquidation_phase1_test.cdc @@ -8,7 +8,6 @@ import "MockYieldToken" import "FlowToken" import "FlowALPMath" -access(all) let MOCK_YIELD_TOKEN_IDENTIFIER = "A.0000000000000007.MockYieldToken.Vault" access(all) var snapshot: UInt64 = 0 access(all) diff --git a/cadence/tests/test_helpers.cdc b/cadence/tests/test_helpers.cdc index 19d43bc0..4b789155 100644 --- a/cadence/tests/test_helpers.cdc +++ b/cadence/tests/test_helpers.cdc @@ -7,6 +7,8 @@ import "MOET" access(all) let MOET_TOKEN_IDENTIFIER = "A.0000000000000007.MOET.Vault" access(all) let FLOW_TOKEN_IDENTIFIER = "A.0000000000000003.FlowToken.Vault" +access(all) let MOCK_YIELD_TOKEN_IDENTIFIER = "A.0000000000000007.MockYieldToken.Vault" + access(all) let FLOW_VAULT_STORAGE_PATH = /storage/flowTokenVault access(all) let PROTOCOL_ACCOUNT = Test.getAccount(0x0000000000000007) @@ -41,18 +43,23 @@ access(all) let MAINNET_WBTC_TOKEN_ID = "A.1e4aa0b87d10b141.EVMVMBridgedToken_71 access(all) let MAINNET_MOET_TOKEN_ID = "A.6b00ff876c299c61.MOET.Vault" access(all) let MAINNET_FLOW_TOKEN_ID = "A.1654653399040a61.FlowToken.Vault" +access(all) let MAINNET_MOCKED_YIELD_TOKEN_ID = "A.6b00ff876c299c61.MockYieldToken.Vault" // Storage paths access(all) let MAINNET_USDF_STORAGE_PATH = /storage/EVMVMBridgedToken_2aabea2058b5ac2d339b163c6ab6f2b6d53aabedVault access(all) let MAINNET_WETH_STORAGE_PATH = /storage/EVMVMBridgedToken_2f6f07cdcf3588944bf4c42ac74ff24bf56e7590Vault access(all) let MAINNET_WBTC_STORAGE_PATH = /storage/EVMVMBridgedToken_717dae2baf7656be9a9b01dee31d571a9d4c9579Vault access(all) let MAINNET_MOET_STORAGE_PATH = /storage/moetTokenVault_0x6b00ff876c299c61 +access(all) let MAINNET_YIELD_STORAGE_PATH = /storage/mockYieldTokenVault_0x6b00ff876c299c61 access(all) let MAINNET_PROTOCOL_ACCOUNT_ADDRESS: Address = 0x6b00ff876c299c61 access(all) let MAINNET_USDF_HOLDER_ADDRESS: Address = 0xf18b50870aed46ad access(all) let MAINNET_WETH_HOLDER_ADDRESS: Address = 0xf62e3381a164f993 access(all) let MAINNET_WBTC_HOLDER_ADDRESS: Address = 0x47f544294e3b7656 +access(all) let MAINNET_BAND_ORACLE_ADDRESS: Address = 0x6801a6222ebf784a +access(all) let MAINNET_BAND_ORACLE_CONNECTORS_ADDRESS: Address = 0xe36ef556b8b5d955 + /* --- Test execution helpers --- */ access(all) @@ -419,6 +426,45 @@ fun setMockOraclePrice(signer: Test.TestAccount, forTokenIdentifier: String, pri Test.expect(setRes, Test.beSucceeded()) } +access(all) +fun updateOracleToBandOracle(signer: Test.TestAccount) { + let setRes = _executeTransaction( + "../transactions/flow-alp/pool-governance/update_oracle.cdc", + [], + signer + ) + Test.expect(setRes, Test.beSucceeded()) +} + +/// Sets multiple BandOracle prices at once +access(all) +fun setBandOraclePrices(signer: Test.TestAccount, symbolPrices: {String: UFix64}) { + let symbolsRates: {String: UInt64} = {} + for symbol in symbolPrices.keys { + // BandOracle uses 1e9 multiplier for prices + // e.g., $1.00 = 1_000_000_000, $0.50 = 500_000_000 + let price = symbolPrices[symbol]! + symbolsRates[symbol] = UInt64(price * 1_000_000_000.0) + } + + let setRes = _executeTransaction( + "./transactions/band-oracle/update_data.cdc", + [symbolsRates], + signer + ) + Test.expect(setRes, Test.beSucceeded()) +} + +access(all) +fun addSymbolToBandOracle(signer: Test.TestAccount, symbol: String, tokenTypeIdentifier: String) { + let setRes = _executeTransaction( + "../../FlowActions/cadence/transactions/band-oracle-connector/add_symbol.cdc", + [symbol, tokenTypeIdentifier], + signer + ) + Test.expect(setRes, Test.beSucceeded()) +} + /// Sets a swapper for the given pair with the given price ratio. /// This overwrites any previously stored swapper for this pair, if any exists. /// This is intended to be used in tests both to set an initial DEX price for a supported token, diff --git a/cadence/tests/transactions/band-oracle/update_data.cdc b/cadence/tests/transactions/band-oracle/update_data.cdc new file mode 100644 index 00000000..46d1ca57 --- /dev/null +++ b/cadence/tests/transactions/band-oracle/update_data.cdc @@ -0,0 +1,24 @@ +import "BandOracle" + +/// TEST TRANSACTION - NOT FOR USE IN PRODUCTION +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// +/// Updates the BandOracle contract with the provided prices +/// +/// @param symbolsRates: Mapping of symbols to prices where prices are USD-rate multiplied by 1e9 +/// +transaction(symbolsRates: {String: UInt64}) { + let updater: &BandOracle.BandOracleAdmin + prepare(signer: auth(BorrowValue) &Account) { + self.updater = signer.storage.borrow<&BandOracle.BandOracleAdmin>(from: BandOracle.OracleAdminStoragePath) + ?? panic("Could not find DataUpdater at \(BandOracle.OracleAdminStoragePath)") + } + execute { + self.updater.updateData( + symbolsRates: symbolsRates, + resolveTime: UInt64(getCurrentBlock().timestamp), + requestID: revertibleRandom(), + relayerID: revertibleRandom() + ) + } +} diff --git a/flow.json b/flow.json index 26e6f86e..1752cc6b 100644 --- a/flow.json +++ b/flow.json @@ -150,6 +150,16 @@ } }, "dependencies": { + "BandOracle": { + "source": "mainnet://6801a6222ebf784a.BandOracle", + "hash": "ababa195ef50b63d71520022aa2468656a9703b924c0f5228cfaa51a71db094d", + "aliases": { + "mainnet": "6801a6222ebf784a", + "mainnet-fork": "6801a6222ebf784a", + "testing": "0000000000000007", + "testnet": "9fb6606c300b5051" + } + }, "Burner": { "source": "mainnet://f233dcee88fe0abe.Burner", "hash": "71af18e227984cd434a3ad00bb2f3618b76482842bae920ee55662c37c8bf331",