diff --git a/Cargo.lock b/Cargo.lock index b290794d3d..98681c3842 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10097,7 +10097,7 @@ dependencies = [ [[package]] name = "pallet-dca" -version = "1.18.0" +version = "1.18.1" dependencies = [ "cumulus-pallet-parachain-system", "cumulus-primitives-core", @@ -10431,7 +10431,7 @@ dependencies = [ [[package]] name = "pallet-ema-oracle" -version = "1.11.0" +version = "1.12.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -11283,7 +11283,7 @@ dependencies = [ [[package]] name = "pallet-omnipool" -version = "7.3.0" +version = "7.3.1" dependencies = [ "bitflags 1.3.2", "frame-benchmarking", @@ -11312,7 +11312,7 @@ dependencies = [ [[package]] name = "pallet-omnipool-liquidity-mining" -version = "3.3.1" +version = "3.4.0" dependencies = [ "frame-benchmarking", "frame-support", @@ -15646,7 +15646,7 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "runtime-integration-tests" -version = "1.78.0" +version = "1.79.0" dependencies = [ "cumulus-pallet-aura-ext", "cumulus-pallet-parachain-system", diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 3a144e9d7b..f1846d05a2 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runtime-integration-tests" -version = "1.78.0" +version = "1.79.0" description = "Integration tests" authors = ["GalacticCouncil"] edition = "2021" diff --git a/integration-tests/src/driver/mod.rs b/integration-tests/src/driver/mod.rs index edf435fe6f..aaba010ad6 100644 --- a/integration-tests/src/driver/mod.rs +++ b/integration-tests/src/driver/mod.rs @@ -4,7 +4,6 @@ use crate::polkadot_test_net::*; use frame_support::assert_ok; use frame_support::traits::fungible::Mutate; use frame_support::BoundedVec; -use hydradx_runtime::bifrost_account; use hydradx_runtime::AssetLocation; use hydradx_runtime::*; use hydradx_traits::stableswap::AssetAmount; @@ -13,6 +12,7 @@ use pallet_asset_registry::AssetType; use pallet_stableswap::MAX_ASSETS_IN_POOL; use primitives::constants::chain::{OMNIPOOL_SOURCE, STABLESWAP_SOURCE}; use primitives::{AccountId, AssetId}; +use sp_runtime::traits::Convert; use sp_runtime::{FixedU128, Permill}; use sp_std::cell::RefCell; use xcm_emulator::TestExt; @@ -125,6 +125,7 @@ impl HydrationTestDriver { self } + #[allow(deprecated)] pub fn update_bifrost_oracle( &self, asset_a: Box, @@ -132,6 +133,24 @@ impl HydrationTestDriver { price: (Balance, Balance), ) -> &Self { self.execute(|| { + let asset_a_id = + ::LocationToAssetIdConversion::convert( + (*asset_a).clone(), + ) + .expect("driver: could not resolve asset_a location to asset id"); + let asset_b_id = + ::LocationToAssetIdConversion::convert( + (*asset_b).clone(), + ) + .expect("driver: could not resolve asset_b location to asset id"); + + let _ = EmaOracle::register_external_source(RuntimeOrigin::root(), pallet_ema_oracle::BIFROST_SOURCE); + let _ = EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + pallet_ema_oracle::BIFROST_SOURCE, + (asset_a_id, asset_b_id), + bifrost_account(), + ); assert_ok!(EmaOracle::update_bifrost_oracle( RuntimeOrigin::signed(bifrost_account()), asset_a, diff --git a/integration-tests/src/exchange_asset.rs b/integration-tests/src/exchange_asset.rs index 8c9df88e72..3d043d8a48 100644 --- a/integration-tests/src/exchange_asset.rs +++ b/integration-tests/src/exchange_asset.rs @@ -1206,7 +1206,7 @@ mod circuit_breaker { Hydra::execute_with(|| { let trapped_event = &last_hydra_events(10)[3].clone(); - assert_trapped_acala_token(trapped_event, 90054588142157u128); + assert_trapped_acala_token(trapped_event, 90350628868136u128); let fee = hydradx_runtime::Tokens::free_balance(ACA, &hydradx_runtime::Treasury::account_id()); assert!(fee > 0, "treasury should have received fees"); diff --git a/integration-tests/src/oracle.rs b/integration-tests/src/oracle.rs index 3d971cd6aa..81c248b5e6 100644 --- a/integration-tests/src/oracle.rs +++ b/integration-tests/src/oracle.rs @@ -5,6 +5,7 @@ use crate::polkadot_test_net::*; use frame_support::assert_noop; use frame_support::dispatch::GetDispatchInfo; use frame_support::storage::with_transaction; +use frame_support::traits::Get; use frame_support::traits::OnFinalize; use frame_support::traits::OnInitialize; use frame_support::{ @@ -13,7 +14,6 @@ use frame_support::{ traits::tokens::fungibles::Mutate, }; use hydra_dx_math::ema::smoothing_from_period; -use hydradx_runtime::bifrost_account; use hydradx_runtime::AssetLocation; use hydradx_runtime::AssetRegistry; use hydradx_runtime::{EmaOracle, RuntimeOrigin}; @@ -30,7 +30,6 @@ use pallet_ema_oracle::BIFROST_SOURCE; use pallet_transaction_payment::ChargeTransactionPayment; use primitives::constants::chain::{OMNIPOOL_SOURCE, XYK_SOURCE}; use sp_runtime::traits::{DispatchTransaction, TransactionExtension}; -use sp_runtime::DispatchError::BadOrigin; use sp_runtime::DispatchResult; use sp_runtime::TransactionOutcome; use sp_std::collections::btree_map::BTreeMap; @@ -441,12 +440,24 @@ fn arrange_bifrost_assets() -> ( } #[test] +#[allow(deprecated)] fn bifrost_oracle_should_be_updated() { TestNet::reset(); let (asset_a_id, asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); Hydra::execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (asset_a_id, asset_b_id), + bifrost_account(), + )); + assert_ok!(EmaOracle::add_oracle( RuntimeOrigin::root(), BIFROST_SOURCE, @@ -477,20 +488,33 @@ fn bifrost_oracle_should_be_updated() { } #[test] +#[allow(deprecated)] fn bifrost_oracle_should_be_added_when_pair_not_whitelisted() { TestNet::reset(); let (asset_a_id, asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); Hydra::execute_with(|| { - // act + // Arrange + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (asset_a_id, asset_b_id), + bifrost_account(), + )); + + // act - no whitelist setup, external sources bypass whitelist assert_ok!(EmaOracle::update_bifrost_oracle( RuntimeOrigin::signed(bifrost_account()), asset_a, asset_b, (50, 100) )); - // will store the data received in the sell as oracle values + hydradx_run_to_next_block(); // assert @@ -507,12 +531,25 @@ fn bifrost_oracle_should_be_added_when_pair_not_whitelisted() { } #[test] +#[allow(deprecated)] fn bifrost_oracle_update_should_return_fee() { // arrange TestNet::reset(); - let (_asset_a_id, _asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); + let (asset_a_id, asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); let balance = 10 * UNITS; Hydra::execute_with(|| { + // Arrange + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (asset_a_id, asset_b_id), + bifrost_account(), + )); + assert_ok!(hydradx_runtime::Currencies::update_balance( hydradx_runtime::RuntimeOrigin::root(), bifrost_account(), @@ -559,11 +596,18 @@ fn bifrost_oracle_update_should_return_fee() { } #[test] +#[allow(deprecated)] fn bifrost_oracle_update_fail_should_charge_fee() { // arrange TestNet::reset(); let (_asset_a_id, _asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); Hydra::execute_with(|| { + // Register BIFROST_SOURCE but do NOT authorize ALICE + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + let balance = hydradx_runtime::Currencies::free_balance(0, &ALICE.into()); let oracle_call = hydradx_runtime::RuntimeCall::EmaOracle( pallet_ema_oracle::Call::::update_bifrost_oracle { @@ -586,7 +630,10 @@ fn bifrost_oracle_update_fail_should_charge_fee() { "fee should be withdrawn" ); let exec = EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(ALICE.into()), asset_a, asset_b, (50, 100)); - assert_noop!(exec.clone(), BadOrigin); + assert_noop!( + exec.clone(), + pallet_ema_oracle::Error::::NotAuthorized + ); let mut exec_err_post_info = exec.err().unwrap().post_info; assert_ok!(ChargeTransactionPayment::::post_dispatch( pre_data, @@ -602,3 +649,166 @@ fn bifrost_oracle_update_fail_should_charge_fee() { ); }); } + +#[test] +fn many_same_pair_external_updates_do_not_block_router_sell_through_omnipool() { + TestNet::reset(); + + let (asset_a_id, asset_b_id, asset_a, asset_b) = arrange_bifrost_assets(); + + Hydra::execute_with(|| { + hydradx_run_to_next_block(); + init_omnipool(); + // Drain accumulator entries produced by init_omnipool's add_token hooks. + hydradx_run_to_next_block(); + + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (asset_a_id, asset_b_id), + bifrost_account(), + )); + + // 50 > MaxUniqueEntries (40) — if same-pair updates didn't merge, Router::sell below + // would be rejected with TooManyUniqueEntries. + let spam_count: u32 = 50; + for i in 0..spam_count { + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(bifrost_account()), + BIFROST_SOURCE, + asset_a.clone(), + asset_b.clone(), + (100 + i as u128, 99), + )); + } + + let acc = pallet_ema_oracle::Accumulator::::get(); + let bifrost_slots: usize = acc.keys().filter(|(src, _)| *src == BIFROST_SOURCE).count(); + assert_eq!(bifrost_slots, 1); + + let amount_in = 10 * UNITS; + let bob_dai_before = hydradx_runtime::Currencies::free_balance(DAI, &AccountId::from(BOB)); + assert_ok!(hydradx_runtime::Router::sell( + RuntimeOrigin::signed(BOB.into()), + HDX, + DAI, + amount_in, + 0, + vec![].try_into().unwrap() + )); + let bob_dai_after = hydradx_runtime::Currencies::free_balance(DAI, &AccountId::from(BOB)); + assert!(bob_dai_after > bob_dai_before); + + let acc = pallet_ema_oracle::Accumulator::::get(); + let bifrost_slots: usize = acc.keys().filter(|(src, _)| *src == BIFROST_SOURCE).count(); + let omnipool_slots: usize = acc.keys().filter(|(src, _)| *src == OMNIPOOL_SOURCE).count(); + assert_eq!(bifrost_slots, 1); + assert!(omnipool_slots >= 1); + + hydradx_run_to_next_block(); + + assert!(EmaOracle::get_price(asset_a_id, asset_b_id, LastBlock, BIFROST_SOURCE).is_ok()); + assert!(EmaOracle::get_price(HDX, LRNA, LastBlock, OMNIPOOL_SOURCE).is_ok()); + }); +} + +#[test] +fn router_sell_must_succeed_even_when_external_source_fills_accumulator() { + TestNet::reset(); + + // Register `pair_count + 1` assets, each with a unique XCM location, so the pallet's + // location→asset converter resolves them. Pairs are built as (base, base + i). + let base: AssetId = 200; + let pair_count: u32 = 45; // > MaxUniqueEntries (40) with margin + let para: u32 = 3000; + let ext_location = |asset_id: AssetId| -> polkadot_xcm::v5::Location { + polkadot_xcm::v5::Location::new( + 1, + [ + polkadot_xcm::v5::Junction::Parachain(para), + polkadot_xcm::v5::Junction::GeneralIndex(asset_id as u128), + ], + ) + }; + let ext_boxed = |asset_id: AssetId| -> Box { + Box::new(ext_location(asset_id).into_versioned()) + }; + + Hydra::execute_with(|| { + assert_ok!(with_transaction(|| { + hydradx_run_to_next_block(); + for i in 0..=pair_count { + let asset_id = base + i; + let loc = ext_location(asset_id); + // 3-char symbol derived from i so each registration is unique. + let sym: Vec = format!("E{i:02}").into_bytes(); + assert_ok!(AssetRegistry::register_sufficient_asset( + Some(asset_id), + Some(sym.try_into().unwrap()), + AssetKind::Token, + 1_000_000, + None, + None, + Some(AssetLocation::try_from(loc).unwrap()), + None, + )); + } + TransactionOutcome::Commit(DispatchResult::Ok(())) + })); + }); + + Hydra::execute_with(|| { + hydradx_run_to_next_block(); + init_omnipool(); + // Drain accumulator entries produced by init_omnipool's add_token hooks. + hydradx_run_to_next_block(); + + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE, + )); + for i in 1..=pair_count { + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (base, base + i), + bifrost_account(), + )); + } + + // Fill the accumulator with `pair_count` DISTINCT external pair entries. + for i in 1..=pair_count { + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(bifrost_account()), + BIFROST_SOURCE, + ext_boxed(base), + ext_boxed(base + i), + (100, 99), + )); + } + + let acc = pallet_ema_oracle::Accumulator::::get(); + let external_slots: usize = acc.keys().filter(|(src, _)| *src == BIFROST_SOURCE).count(); + let max_entries: u32 = ::MaxUniqueEntries::get(); + assert!( + external_slots >= max_entries as usize, + "expected >= {max_entries} external accumulator slots to trigger the cap, got {external_slots}" + ); + + let bob_dai_before = hydradx_runtime::Currencies::free_balance(DAI, &AccountId::from(BOB)); + assert_ok!(hydradx_runtime::Router::sell( + RuntimeOrigin::signed(BOB.into()), + HDX, + DAI, + 10 * UNITS, + 0, + vec![].try_into().unwrap(), + )); + let bob_dai_after = hydradx_runtime::Currencies::free_balance(DAI, &AccountId::from(BOB)); + assert!(bob_dai_after > bob_dai_before); + }); +} diff --git a/integration-tests/src/polkadot_test_net.rs b/integration-tests/src/polkadot_test_net.rs index 53b5a241f2..3e85641804 100644 --- a/integration-tests/src/polkadot_test_net.rs +++ b/integration-tests/src/polkadot_test_net.rs @@ -121,6 +121,10 @@ pub const BOB_INITIAL_DAI_BALANCE: Balance = 1_000_000_000 * UNITS; pub const CHARLIE_INITIAL_NATIVE_BALANCE: Balance = 1_000 * UNITS; pub const CHARLIE_INITIAL_LRNA_BALANCE: Balance = 1_000 * UNITS; +pub fn bifrost_account() -> AccountId { + hydradx_runtime::BifrostAccount::get() +} + pub fn parachain_reserve_account() -> AccountId { polkadot_parachain::primitives::Sibling::from(ACALA_PARA_ID).into_account_truncating() } diff --git a/integration-tests/src/stableswap.rs b/integration-tests/src/stableswap.rs index 9dfaa0de75..05a7e8c517 100644 --- a/integration-tests/src/stableswap.rs +++ b/integration-tests/src/stableswap.rs @@ -254,6 +254,7 @@ mod circuit_breaker { } #[test] +#[allow(deprecated)] fn pool_with_pegs_should_update_pegs_only_once_per_block() { let dot_location: polkadot_xcm::v5::Location = polkadot_xcm::v5::Location::new( 1, diff --git a/pallets/dca/Cargo.toml b/pallets/dca/Cargo.toml index 49c33c2c60..b65f45a524 100644 --- a/pallets/dca/Cargo.toml +++ b/pallets/dca/Cargo.toml @@ -1,6 +1,6 @@ [package] name = 'pallet-dca' -version = "1.18.0" +version = "1.18.1" description = 'A pallet to manage DCA scheduling' authors = ['GalacticCouncil'] edition = '2021' diff --git a/pallets/dca/src/tests/mock.rs b/pallets/dca/src/tests/mock.rs index 2a5d01f052..c9ad9dbafd 100644 --- a/pallets/dca/src/tests/mock.rs +++ b/pallets/dca/src/tests/mock.rs @@ -18,7 +18,7 @@ use crate as dca; use crate::{Config, Error, RandomnessProvider, RelayChainBlockHashProvider}; use cumulus_primitives_core::relay_chain::Hash; -use frame_support::traits::{Everything, Nothing, SortedMembers}; +use frame_support::traits::{Everything, Nothing}; use frame_support::weights::constants::ExtrinsicBaseWeight; use frame_support::weights::WeightToFeeCoefficient; use frame_support::weights::{IdentityFee, Weight}; @@ -139,26 +139,18 @@ parameter_types! { pub static MockBlockNumberProvider: u64 = 0; pub SupportedPeriods: BoundedVec> = BoundedVec::truncate_from(vec![ OraclePeriod::LastBlock, OraclePeriod::Short, OraclePeriod::TenMinutes]); - pub PriceDifference: Permill = Permill::from_percent(10); } -pub struct BifrostAcc; -impl SortedMembers for BifrostAcc { - fn sorted_members() -> Vec { - vec![ALICE] - } -} impl pallet_ema_oracle::Config for Test { type AuthorityOrigin = EnsureRoot; type BlockNumberProvider = MockBlockNumberProvider; type SupportedPeriods = SupportedPeriods; type OracleWhitelist = Everything; + type InternalSources = Everything; type MaxUniqueEntries = ConstU32<20>; type LocationToAssetIdConversion = (); #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); - type BifrostOrigin = frame_system::EnsureSignedBy; - type MaxAllowedPriceDifference = PriceDifference; type WeightInfo = (); } diff --git a/pallets/ema-oracle/Cargo.toml b/pallets/ema-oracle/Cargo.toml index 3c0b3077bd..10d44afe5a 100644 --- a/pallets/ema-oracle/Cargo.toml +++ b/pallets/ema-oracle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-ema-oracle" -version = "1.11.0" +version = "1.12.0" description = "Exponential moving average oracle for AMM pools" authors = ["GalacticCouncil"] edition = "2021" diff --git a/pallets/ema-oracle/src/lib.rs b/pallets/ema-oracle/src/lib.rs index a8d2a2ca99..08a5abe05f 100644 --- a/pallets/ema-oracle/src/lib.rs +++ b/pallets/ema-oracle/src/lib.rs @@ -78,8 +78,6 @@ use hydradx_traits::{ OnLiquidityChangedHandler, OnTradeHandler, RawEntry, RawOracle, Volume, }; use sp_arithmetic::traits::Saturating; -use sp_arithmetic::FixedU128; -use sp_arithmetic::Permill; use sp_runtime::traits::Convert; use sp_std::collections::btree_map::BTreeMap; use sp_std::marker::PhantomData; @@ -100,6 +98,18 @@ pub const MAX_PERIODS: u32 = OraclePeriod::all_periods().len() as u32; pub const BIFROST_SOURCE: [u8; 8] = *b"bifrosto"; +/// Denominator used by `fractional_on_finalize_weight` to split the worst-case +/// `on_finalize` cost across contributing calls. Not a hard cap — the `on_finalize` +/// weight function is linear in entry count, so aggregate accounting stays correct +/// even if more than this actually land in a block. Benchmarks measure a smaller +/// range (see benchmarking file) and the linear formula extrapolates up to this value. +pub const MAX_EXTERNAL_ENTRIES_PER_BLOCK: u32 = 100; + +/// Upper bound on the number of authorized (pair, account) entries per external oracle source. +/// Used for worst-case weight estimation when removing a source, as `clear_prefix` +/// must delete all associated authorization entries. +pub const MAX_AUTHORIZED_ENTRIES_PER_SOURCE: u32 = 40; + const LOG_TARGET: &str = "runtime::ema-oracle"; // Re-export pallet items so that they can be accessed from the crate namespace. @@ -120,7 +130,8 @@ impl BenchmarkHelper for () { #[frame_support::pallet] pub mod pallet { use super::*; - use frame_support::{BoundedBTreeMap, BoundedBTreeSet}; + use frame_support::BoundedBTreeSet; + use frame_system::ensure_signed; use frame_system::pallet_prelude::{BlockNumberFor, OriginFor}; #[pallet::pallet] @@ -132,12 +143,10 @@ pub mod pallet { /// Weight information for the extrinsics. type WeightInfo: WeightInfo; - /// Origin that can enable oracle for assets that would be rejected by `OracleWhitelist` otherwise. + /// Origin that can enable oracle for assets that would be rejected by `OracleWhitelist` otherwise + /// and manage external oracle sources and authorized accounts. type AuthorityOrigin: EnsureOrigin; - /// Origin that can update bifrost oracle via `update_bifrost_oracle` extrinsic. - type BifrostOrigin: EnsureOrigin; - /// Provider for the current block number. type BlockNumberProvider: BlockNumberProvider>; @@ -147,13 +156,14 @@ pub mod pallet { /// Whitelist determining what oracles are tracked by the pallet. type OracleWhitelist: Contains<(Source, AssetId, AssetId)>; + /// Identifies internal (AMM) oracle sources. + /// Implemented with hardcoded source constants in the runtime (0 storage reads). + /// Used to count non-external entries in the accumulator without costly storage lookups. + type InternalSources: Contains; + /// Location to Asset Id converter type LocationToAssetIdConversion: sp_runtime::traits::Convert>; - /// Maximum allowed percentage difference for bifrost oracle price update - #[pallet::constant] - type MaxAllowedPriceDifference: Get; - /// Maximum number of unique oracle entries expected in one block. #[pallet::constant] type MaxUniqueEntries: Get; @@ -169,8 +179,14 @@ pub mod pallet { OracleNotFound, /// Asset not found AssetNotFound, - ///The new price is outside the max allowed range - PriceOutsideAllowedRange, + /// The external source is already registered. + SourceAlreadyRegistered, + /// The external source was not found. + SourceNotFound, + /// The caller is not authorized for the given (source, pair). + NotAuthorized, + /// Price must not be zero. + PriceIsZero, } #[pallet::event] @@ -186,16 +202,31 @@ pub mod pallet { assets: (AssetId, AssetId), updates: BTreeMap, }, + /// An external oracle source was registered. + ExternalSourceRegistered { source: Source }, + /// An external oracle source was removed. + ExternalSourceRemoved { source: Source }, + /// An account was authorized to update the given (source, pair). + AuthorizedAccountAdded { + source: Source, + pair: (AssetId, AssetId), + account: T::AccountId, + }, + /// An authorization was removed for the given (source, pair, account). + AuthorizedAccountRemoved { + source: Source, + pair: (AssetId, AssetId), + account: T::AccountId, + }, } /// Accumulator for oracle data in current block that will be recorded at the end of the block. #[pallet::storage] + #[pallet::unbounded] #[pallet::getter(fn accumulator)] - pub type Accumulator = StorageValue< - _, - BoundedBTreeMap<(Source, (AssetId, AssetId)), OracleEntry>, T::MaxUniqueEntries>, - ValueQuery, - >; + #[pallet::whitelist_storage] + pub type Accumulator = + StorageValue<_, BTreeMap<(Source, (AssetId, AssetId)), OracleEntry>>, ValueQuery>; /// Oracle storage keyed by data source, involved asset ids and the period length of the oracle. /// @@ -219,6 +250,27 @@ pub mod pallet { pub type WhitelistedAssets = StorageValue<_, BoundedBTreeSet<(Source, (AssetId, AssetId)), T::MaxUniqueEntries>, ValueQuery>; + /// Registered external oracle sources. + #[pallet::storage] + pub type ExternalSources = StorageMap<_, Twox64Concat, Source, (), OptionQuery>; + + /// Authorized accounts per (external oracle source, asset pair). + /// + /// Authorization is scoped per-pair so that a compromised external oracle account can + /// only update the specific pairs it was authorized for, limiting DDoS blast radius. + /// The asset pair is stored in `ordered_pair` form. + #[pallet::storage] + pub type AuthorizedAccounts = StorageNMap< + _, + ( + NMapKey, + NMapKey, + NMapKey, + ), + (), + OptionQuery, + >; + #[pallet::genesis_config] #[derive(frame_support::DefaultNoBound)] pub struct GenesisConfig { @@ -274,6 +326,14 @@ pub mod pallet { #[pallet::call] impl Pallet { + /// Add an oracle to the whitelist so it is tracked by the pallet. + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: data source identifier + /// - `assets`: the asset pair to track + /// + /// Emits `AddedToWhitelist` event when successful. #[pallet::call_index(0)] #[pallet::weight(::WeightInfo::add_oracle())] pub fn add_oracle(origin: OriginFor, source: Source, assets: (AssetId, AssetId)) -> DispatchResult { @@ -291,6 +351,14 @@ pub mod pallet { Ok(()) } + /// Remove an oracle from the whitelist and delete all its stored entries. + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: data source identifier + /// - `assets`: the asset pair to stop tracking + /// + /// Emits `RemovedFromWhitelist` event when successful. #[pallet::call_index(1)] #[pallet::weight(::WeightInfo::remove_oracle())] pub fn remove_oracle(origin: OriginFor, source: Source, assets: (AssetId, AssetId)) -> DispatchResult { @@ -318,8 +386,22 @@ pub mod pallet { Ok(()) } + /// Update an oracle entry for BIFROST_SOURCE. Thin wrapper around `set_external_oracle`. + /// + /// Parameters: + /// - `origin`: signed origin — must be authorized for the specific `(BIFROST_SOURCE, pair)` + /// - `asset_a`: XCM location of the first asset + /// - `asset_b`: XCM location of the second asset + /// - `price`: price as `(numerator, denominator)` + /// + /// Emits `OracleUpdated` event on the next `on_finalize`. + #[deprecated( + note = "Use `set_external_oracle` instead. Kept only for backward compatibility with bifrost and will be removed in the future" + )] + #[allow(deprecated)] #[pallet::call_index(2)] - #[pallet::weight(::WeightInfo::update_bifrost_oracle())] + #[pallet::weight(::WeightInfo::update_bifrost_oracle() + .saturating_add(fractional_on_finalize_weight::(MAX_EXTERNAL_ENTRIES_PER_BLOCK)))] pub fn update_bifrost_oracle( origin: OriginFor, //NOTE: these must be boxed becasue of https://github.com/paritytech/polkadot-sdk/blob/6875d36b2dba537f3254aad3db76ac7aa656b7ab/substrate/frame/utility/src/lib.rs#L150 @@ -327,45 +409,215 @@ pub mod pallet { asset_b: Box, price: (Balance, Balance), ) -> DispatchResultWithPostInfo { - T::BifrostOrigin::ensure_origin(origin)?; - - let asset_a = T::LocationToAssetIdConversion::convert(*asset_a).ok_or(Error::::AssetNotFound)?; - let asset_b = T::LocationToAssetIdConversion::convert(*asset_b).ok_or(Error::::AssetNotFound)?; - - let ordered_pair = ordered_pair(asset_a, asset_b); - let entry: OracleEntry> = { - let e = OracleEntry::new( - EmaPrice::new(price.0, price.1), - Volume::default(), - Liquidity::default(), - None, - T::BlockNumberProvider::current_block_number(), - ); - if ordered_pair == (asset_a, asset_b) { - e - } else { - e.inverted() - } - }; + let who = ensure_signed(origin)?; + Self::do_set_oracle(who, BIFROST_SOURCE, asset_a, asset_b, price) + } - if let Some(reference_entry) = Self::oracle((BIFROST_SOURCE, ordered_pair, OraclePeriod::TenMinutes)) { - if !Self::is_within_range(reference_entry.0.price.into(), price) { - log::error!( - target: LOG_TARGET, - "Updating bifrost oracle failed as the price is outside the allowed range" - ); - return Err(Error::::PriceOutsideAllowedRange.into()); - } - } + /// Submit an oracle price update for an external source. + /// + /// The call is feeless on success (`Pays::No`). + /// + /// Parameters: + /// - `origin`: signed origin — must be authorized for the specific `(source, pair)` via + /// `add_authorized_account` + /// - `source`: external source identifier (must be registered via `register_external_source`) + /// - `asset_a`: XCM location of the first asset + /// - `asset_b`: XCM location of the second asset + /// - `price`: price as `(numerator, denominator)` — both must be non-zero + /// + /// Emits `OracleUpdated` event on the next `on_finalize`. + #[pallet::call_index(6)] + #[pallet::weight(::WeightInfo::set_external_oracle() + .saturating_add(fractional_on_finalize_weight::(MAX_EXTERNAL_ENTRIES_PER_BLOCK)))] + pub fn set_external_oracle( + origin: OriginFor, + source: Source, + asset_a: Box, + asset_b: Box, + price: (Balance, Balance), + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + Self::do_set_oracle(who, source, asset_a, asset_b, price) + } - Self::on_entry(BIFROST_SOURCE, ordered_pair, entry).map_err(|_| Error::::TooManyUniqueEntries)?; + /// Update an external oracle entry using local `AssetId`s directly. + /// + /// Cheaper variant of `set_external_oracle` for callers that already know the local + /// AssetIds — skips the `VersionedLocation` → `AssetId` conversion and the + /// `AssetRegistry::LocationAssets` storage read. Authorization shares the same + /// `AuthorizedAccounts` storage as the location variant. + /// + /// Parameters: + /// - `origin`: signed origin — must be authorized for the specific `(source, pair)` via + /// `add_authorized_account` + /// - `source`: external source identifier (must be registered via `register_external_source`) + /// - `asset_a`: local AssetId of the first asset + /// - `asset_b`: local AssetId of the second asset + /// - `price`: price as `(numerator, denominator)` — both must be non-zero + /// + /// The call is feeless on success (`Pays::No`). + /// + /// Emits `OracleUpdated` event on the next `on_finalize`. + #[pallet::call_index(8)] + #[pallet::weight(::WeightInfo::set_external_oracle_by_ids() + .saturating_add(fractional_on_finalize_weight::(MAX_EXTERNAL_ENTRIES_PER_BLOCK)))] + pub fn set_external_oracle_by_ids( + origin: OriginFor, + source: Source, + asset_a: AssetId, + asset_b: AssetId, + price: (Balance, Balance), + ) -> DispatchResultWithPostInfo { + let who = ensure_signed(origin)?; + Self::do_set_oracle_inner(who, source, asset_a, asset_b, price) + } - Ok(Pays::No.into()) + /// Register a new external oracle source. + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: 8-byte source identifier to register + /// + /// Emits `ExternalSourceRegistered` event when successful. + #[pallet::call_index(3)] + #[pallet::weight(::WeightInfo::register_external_source())] + pub fn register_external_source(origin: OriginFor, source: Source) -> DispatchResult { + T::AuthorityOrigin::ensure_origin(origin)?; + ensure!( + !ExternalSources::::contains_key(source), + Error::::SourceAlreadyRegistered + ); + ExternalSources::::insert(source, ()); + Self::deposit_event(Event::ExternalSourceRegistered { source }); + Ok(()) + } + + /// Remove an external oracle source, its per-pair authorizations, and ALL oracle data it + /// ever wrote (both committed `Oracles` rows and any in-flight `Accumulator` entries). + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: source identifier to remove + /// + /// Emits `ExternalSourceRemoved` event when successful. + #[pallet::call_index(5)] + #[pallet::weight(::WeightInfo::remove_external_source(MAX_AUTHORIZED_ENTRIES_PER_SOURCE))] + pub fn remove_external_source(origin: OriginFor, source: Source) -> DispatchResult { + T::AuthorityOrigin::ensure_origin(origin)?; + ensure!(ExternalSources::::contains_key(source), Error::::SourceNotFound); + ExternalSources::::remove(source); + let _ = AuthorizedAccounts::::clear_prefix((source,), u32::MAX, None); + let _ = Oracles::::clear_prefix((source,), u32::MAX, None); + Accumulator::::mutate(|acc| acc.retain(|(s, _), _| *s != source)); + Self::deposit_event(Event::ExternalSourceRemoved { source }); + Ok(()) + } + + /// Authorize `account` to submit oracle updates for a specific `(source, pair)`. + /// + /// Authorization is scoped per-pair so a compromised account can only update the + /// pairs it was explicitly granted, limiting DDoS blast radius. + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: external source identifier (must already be registered) + /// - `assets`: the asset pair to authorize — stored in ordered form + /// - `account`: the account to authorize + /// + /// Emits `AuthorizedAccountAdded` event when successful. + #[pallet::call_index(4)] + #[pallet::weight(::WeightInfo::add_authorized_account())] + pub fn add_authorized_account( + origin: OriginFor, + source: Source, + assets: (AssetId, AssetId), + account: T::AccountId, + ) -> DispatchResult { + T::AuthorityOrigin::ensure_origin(origin)?; + ensure!(ExternalSources::::contains_key(source), Error::::SourceNotFound); + let pair = ordered_pair(assets.0, assets.1); + AuthorizedAccounts::::insert((source, pair, &account), ()); + Self::deposit_event(Event::AuthorizedAccountAdded { source, pair, account }); + Ok(()) + } + + /// Revoke oracle-update authorization for `account` on a specific `(source, pair)`. + /// + /// Parameters: + /// - `origin`: `AuthorityOrigin` + /// - `source`: external source identifier (must already be registered) + /// - `assets`: the asset pair to revoke — matched in ordered form + /// - `account`: the account to revoke + /// + /// Emits `AuthorizedAccountRemoved` event when successful. + #[pallet::call_index(7)] + #[pallet::weight(::WeightInfo::remove_authorized_account())] + pub fn remove_authorized_account( + origin: OriginFor, + source: Source, + assets: (AssetId, AssetId), + account: T::AccountId, + ) -> DispatchResult { + T::AuthorityOrigin::ensure_origin(origin)?; + ensure!(ExternalSources::::contains_key(source), Error::::SourceNotFound); + let pair = ordered_pair(assets.0, assets.1); + AuthorizedAccounts::::remove((source, pair, &account)); + Self::deposit_event(Event::AuthorizedAccountRemoved { source, pair, account }); + Ok(()) } } } impl Pallet { + fn do_set_oracle( + who: T::AccountId, + source: Source, + asset_a: Box, + asset_b: Box, + price: (Balance, Balance), + ) -> DispatchResultWithPostInfo { + let asset_a = T::LocationToAssetIdConversion::convert(*asset_a).ok_or(Error::::AssetNotFound)?; + let asset_b = T::LocationToAssetIdConversion::convert(*asset_b).ok_or(Error::::AssetNotFound)?; + + Self::do_set_oracle_inner(who, source, asset_a, asset_b, price) + } + + fn do_set_oracle_inner( + who: T::AccountId, + source: Source, + asset_a: AssetId, + asset_b: AssetId, + price: (Balance, Balance), + ) -> DispatchResultWithPostInfo { + ensure!(ExternalSources::::contains_key(source), Error::::SourceNotFound); + ensure!(price.0 != 0 && price.1 != 0, Error::::PriceIsZero); + + let ordered = ordered_pair(asset_a, asset_b); + + ensure!( + AuthorizedAccounts::::contains_key((source, ordered, &who)), + Error::::NotAuthorized + ); + let entry: OracleEntry> = { + let e = OracleEntry::new( + EmaPrice::new(price.0, price.1), + Volume::default(), + Liquidity::default(), + None, + T::BlockNumberProvider::current_block_number(), + ); + if ordered == (asset_a, asset_b) { + e + } else { + e.inverted() + } + }; + + Self::on_entry(source, ordered, entry).map_err(|_| Error::::TooManyUniqueEntries)?; + + Ok(Pays::No.into()) + } + /// Insert or update data in the accumulator from received entry. Aggregates volume and /// takes the most recent data for the rest. pub(crate) fn on_entry( @@ -373,7 +625,8 @@ impl Pallet { assets: (AssetId, AssetId), oracle_entry: OracleEntry>, ) -> Result<(), ()> { - if !T::OracleWhitelist::contains(&(src, assets.0, assets.1)) && src.ne(&BIFROST_SOURCE) { + let is_internal_source = T::InternalSources::contains(&src); + if !T::OracleWhitelist::contains(&(src, assets.0, assets.1)) && is_internal_source { // if we don't track oracle for given asset pair, don't throw error return Ok(()); } @@ -383,10 +636,21 @@ impl Pallet { entry.accumulate_volume_and_update_from(&oracle_entry); Ok(()) } else { - accumulator - .try_insert((src, assets), oracle_entry) - .map(|_| ()) - .map_err(|_| ()) + // The `MaxUniqueEntries` soft cap applies ONLY to non-external (AMM) + // entries. An authorized external caller must not be able to push + // legitimate AMM new-pair trades out of the accumulator by filling it + // with distinct external pairs. + if is_internal_source { + let non_external_len = accumulator + .keys() + .filter(|(s, _)| T::InternalSources::contains(s)) + .count(); + if non_external_len >= T::MaxUniqueEntries::get() as usize { + return Err(()); + } + } + accumulator.insert((src, assets), oracle_entry); + Ok(()) } }) } @@ -549,17 +813,6 @@ impl Pallet { (entry, init) }) } - - fn is_within_range(reference_price: (u128, u128), new_price: (u128, u128)) -> bool { - let reference = FixedU128::from_rational(reference_price.0, reference_price.1); - let new_value = FixedU128::from_rational(new_price.0, new_price.1); - - let percentage_difference = T::MaxAllowedPriceDifference::get(); - let lower_bound = reference.saturating_mul(FixedU128::one().saturating_sub(percentage_difference.into())); - let upper_bound = reference.saturating_mul(FixedU128::one().saturating_add(percentage_difference.into())); - - new_value >= lower_bound && new_value <= upper_bound - } } /// A callback handler for trading and liquidity activity that schedules oracle updates. @@ -618,9 +871,8 @@ impl OnTradeHandler for OnActivityHandler fn on_trade_weight() -> Weight { let max_entries = T::MaxUniqueEntries::get(); - // on_trade + on_finalize / max_entries T::WeightInfo::on_trade_multiple_tokens(max_entries) - .saturating_add(fractional_on_finalize_weight::(max_entries)) + .saturating_add(fractional_on_finalize_weight::(MAX_EXTERNAL_ENTRIES_PER_BLOCK)) } } @@ -659,9 +911,8 @@ impl OnLiquidityChangedHandler for OnActivit fn on_liquidity_changed_weight() -> Weight { let max_entries = T::MaxUniqueEntries::get(); - // on_liquidity + on_finalize / max_entries T::WeightInfo::on_liquidity_changed_multiple_tokens(max_entries) - .saturating_add(fractional_on_finalize_weight::(max_entries)) + .saturating_add(fractional_on_finalize_weight::(MAX_EXTERNAL_ENTRIES_PER_BLOCK)) } } diff --git a/pallets/ema-oracle/src/migrations/mod.rs b/pallets/ema-oracle/src/migrations/mod.rs index ad59d2de4a..379e1d8b82 100644 --- a/pallets/ema-oracle/src/migrations/mod.rs +++ b/pallets/ema-oracle/src/migrations/mod.rs @@ -1,6 +1,7 @@ use frame_support::traits::StorageVersion; pub mod v1; +pub mod v2; /// The in-code storage version. -pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); +pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); diff --git a/pallets/ema-oracle/src/migrations/v2.rs b/pallets/ema-oracle/src/migrations/v2.rs new file mode 100644 index 0000000000..517dcce87a --- /dev/null +++ b/pallets/ema-oracle/src/migrations/v2.rs @@ -0,0 +1,50 @@ +use crate::*; +use frame_support::traits::Get; +use frame_support::weights::Weight; +use frame_support::{migrations::VersionedMigration, traits::UncheckedOnRuntimeUpgrade}; + +mod unversioned { + use super::*; + + pub struct InnerMigrateV1ToV2>( + core::marker::PhantomData<(T, BifrostAccount)>, + ); +} + +impl> UncheckedOnRuntimeUpgrade + for unversioned::InnerMigrateV1ToV2 +{ + fn on_runtime_upgrade() -> Weight { + log::info!(target: "runtime::ema-oracle", "v1->v2 migration started"); + + ExternalSources::::insert(BIFROST_SOURCE, ()); + + let bifrost_account = BifrostAccount::get(); + let dot_vdot = ordered_pair(5, 15); + AuthorizedAccounts::::insert((BIFROST_SOURCE, dot_vdot, &bifrost_account), ()); + + log::info!(target: "runtime::ema-oracle", "v1->v2 migration finished"); + + T::DbWeight::get().reads_writes(0, 2) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(_: Vec) -> Result<(), sp_runtime::DispatchError> { + let bifrost_account = BifrostAccount::get(); + assert!(ExternalSources::::contains_key(BIFROST_SOURCE)); + assert!(AuthorizedAccounts::::contains_key(( + BIFROST_SOURCE, + ordered_pair(5, 15), + &bifrost_account + ))); + Ok(()) + } +} + +pub type MigrateV1ToV2 = VersionedMigration< + 1, + 2, + unversioned::InnerMigrateV1ToV2, + Pallet, + ::DbWeight, +>; diff --git a/pallets/ema-oracle/src/tests/external_oracle.rs b/pallets/ema-oracle/src/tests/external_oracle.rs new file mode 100644 index 0000000000..33c04bbb4a --- /dev/null +++ b/pallets/ema-oracle/src/tests/external_oracle.rs @@ -0,0 +1,1441 @@ +// This file is part of pallet-ema-oracle. + +// Copyright (C) 2022-2023 Intergalactic, Limited (GIB). +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::mock::{expect_events, EmaOracle, ExtBuilder, RuntimeOrigin, System, Test, ACA, ALICE, BOB, DOT, HDX}; +use super::SOURCE; +use crate::pallet::{AuthorizedAccounts, ExternalSources, Oracles}; +use crate::*; + +use frame_support::{assert_noop, assert_ok}; +use pretty_assertions::assert_eq; + +const EXTERNAL_SOURCE: Source = *b"external"; +const ANOTHER_SOURCE: Source = *b"another_"; + +const HDX_DOT_PAIR: (AssetId, AssetId) = (0, 5); + +pub fn new_test_ext() -> sp_io::TestExternalities { + ExtBuilder::default().build() +} + +fn hdx_location() -> polkadot_xcm::VersionedLocation { + polkadot_xcm::v5::Location::new( + 0, + polkadot_xcm::v5::Junctions::X1([polkadot_xcm::v5::Junction::GeneralIndex(0)].into()), + ) + .into_versioned() +} + +fn dot_location() -> polkadot_xcm::VersionedLocation { + polkadot_xcm::v5::Location::parent().into_versioned() +} + +#[test] +fn register_external_source_works() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert!(ExternalSources::::contains_key(EXTERNAL_SOURCE)); + }); +} + +#[test] +fn register_duplicate_source_fails() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_noop!( + EmaOracle::register_external_source(RuntimeOrigin::root(), EXTERNAL_SOURCE), + Error::::SourceAlreadyRegistered + ); + }); +} + +#[test] +fn register_external_source_requires_authority() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::register_external_source(RuntimeOrigin::signed(ALICE), EXTERNAL_SOURCE), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn remove_external_source_clears_all_pair_authorizations() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // Authorize ALICE for two different pairs under the same source. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (0, 5), + ALICE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (1, 2), + ALICE + )); + // And BOB for another pair. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (0, 1), + BOB + )); + + assert_ok!(EmaOracle::remove_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + assert!(!ExternalSources::::contains_key(EXTERNAL_SOURCE)); + // All pair authorizations under the removed source must be gone. + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (0, 5), + ALICE + ))); + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (1, 2), + ALICE + ))); + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (0, 1), + BOB + ))); + }); +} + +#[test] +fn remove_external_source_clears_oracle_data_and_accumulator() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + System::set_block_number(3); + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + )); + + // In-flight accumulator entry exists pre-finalize. + let ordered = ordered_pair(HDX_DOT_PAIR.0, HDX_DOT_PAIR.1); + assert!(Accumulator::::get().contains_key(&(EXTERNAL_SOURCE, ordered))); + + // Flush to persistent Oracles via on_finalize. + EmaOracle::on_finalize(3); + System::set_block_number(4); + let stored_before: Vec<_> = Oracles::::iter_prefix((EXTERNAL_SOURCE,)).collect(); + assert!(!stored_before.is_empty(), "Oracles row must exist pre-removal"); + + // Also seed an in-flight accumulator entry for a different pair in the new block so we + // exercise the Accumulator::retain path too. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (1, 2), + ALICE + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + 1, + 2, + (123, 456), + )); + assert!(Accumulator::::get().contains_key(&(EXTERNAL_SOURCE, ordered_pair(1, 2)))); + + assert_ok!(EmaOracle::remove_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + // 1. Source registration gone. + assert!(!ExternalSources::::contains_key(EXTERNAL_SOURCE)); + // 2. Authorizations gone (existing coverage, re-asserted here for integration). + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + ordered, + ALICE + ))); + // 3. Committed Oracles rows gone — this is the new guarantee. + assert_eq!( + Oracles::::iter_prefix((EXTERNAL_SOURCE,)).count(), + 0, + "committed Oracles rows must be cleared across every supported period" + ); + // 4. In-flight accumulator entries for this source also dropped. + let acc = Accumulator::::get(); + assert!( + !acc.keys().any(|(s, _)| *s == EXTERNAL_SOURCE), + "no accumulator entries for the removed source may survive" + ); + }); +} + +#[test] +fn remove_external_source_only_touches_the_targeted_source() { + new_test_ext().execute_with(|| { + const THIRD_SOURCE: Source = *b"third___"; + const NEIGHBOR_SOURCE: Source = *b"externaL"; // byte-adjacent to EXTERNAL_SOURCE + + let pair_hd = ordered_pair(HDX, DOT); + let pair_ha = ordered_pair(HDX, ACA); + let pair_da = ordered_pair(DOT, ACA); + let pair_auth_only = ordered_pair(4, 5); + + // ─── AMM SOURCE: 2 whitelisted pairs ─── + assert_ok!(EmaOracle::add_oracle(RuntimeOrigin::root(), SOURCE, (HDX, DOT))); + assert_ok!(EmaOracle::add_oracle(RuntimeOrigin::root(), SOURCE, (HDX, ACA))); + + // ─── EXTERNAL_SOURCE: 3 pairs with data + 1 pair auth-only ─── + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (HDX, DOT), + ALICE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (HDX, ACA), + ALICE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (DOT, ACA), + BOB + )); + // Auth-only — never submitted to; verifies clearing works for pairs without Oracles rows. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (4, 5), + ALICE + )); + + // ─── ANOTHER_SOURCE: 2 pairs overlapping with EXTERNAL_SOURCE pairs ─── + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + ANOTHER_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + ANOTHER_SOURCE, + (HDX, DOT), + BOB + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + ANOTHER_SOURCE, + (DOT, ACA), + BOB + )); + + // ─── THIRD_SOURCE: empty (registered but no auths, no data) ─── + assert_ok!(EmaOracle::register_external_source(RuntimeOrigin::root(), THIRD_SOURCE)); + + // ─── NEIGHBOR_SOURCE: byte-adjacent to EXTERNAL_SOURCE, on shared pair ─── + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + NEIGHBOR_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + NEIGHBOR_SOURCE, + (HDX, DOT), + ALICE + )); + + // ─── Block 3: write across every (source, pair) with data ─── + System::set_block_number(3); + + // AMM writes (two pairs). + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + HDX, + DOT, + 1_000, + 500, + 2_000, + 1_000, + Price::new(2_000, 1_000), + Some(2_000_u128), + )); + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + HDX, + ACA, + 800, + 400, + 2_000, + 1_000, + Price::new(2_000, 1_000), + Some(2_000_u128), + )); + // EXTERNAL_SOURCE writes on 3 pairs (auth-only pair deliberately not written). + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX, + DOT, + (3_000, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX, + ACA, + (3_500, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(BOB), + EXTERNAL_SOURCE, + DOT, + ACA, + (4_000, 1_000), + )); + // ANOTHER_SOURCE writes on 2 pairs. + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(BOB), + ANOTHER_SOURCE, + HDX, + DOT, + (5_000, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(BOB), + ANOTHER_SOURCE, + DOT, + ACA, + (5_500, 1_000), + )); + // NEIGHBOR_SOURCE write on shared pair. + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + NEIGHBOR_SOURCE, + HDX, + DOT, + (7_000, 1_000), + )); + + // Commit to Oracles for every supported period. + EmaOracle::on_finalize(3); + System::set_block_number(4); + + // Snapshot the per-source row counts so post-removal assertions can compare exactly. + let rows = |s: Source| Oracles::::iter_prefix((s,)).count(); + let source_rows_pre = rows(SOURCE); + let another_rows_pre = rows(ANOTHER_SOURCE); + let neighbor_rows_pre = rows(NEIGHBOR_SOURCE); + assert!(source_rows_pre >= 2, "AMM: expect ≥2 pairs × N periods rows"); + assert!(rows(EXTERNAL_SOURCE) >= 3, "EXTERNAL_SOURCE: 3 data pairs"); + assert!(another_rows_pre >= 2, "ANOTHER_SOURCE: 2 pairs"); + assert!(neighbor_rows_pre >= 1, "NEIGHBOR_SOURCE: 1 pair"); + + // ─── Seed in-flight Accumulator entries across all sources in the new block ─── + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + HDX, + DOT, + 2_000, + 1_000, + 4_000, + 2_000, + Price::new(2_000, 1_000), + Some(2_000_u128), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX, + DOT, + (9_000, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(BOB), + EXTERNAL_SOURCE, + DOT, + ACA, + (9_100, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(BOB), + ANOTHER_SOURCE, + HDX, + DOT, + (11_000, 1_000), + )); + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + NEIGHBOR_SOURCE, + HDX, + DOT, + (13_000, 1_000), + )); + let acc_before = Accumulator::::get(); + assert!(acc_before.contains_key(&(SOURCE, pair_hd))); + assert!(acc_before.contains_key(&(EXTERNAL_SOURCE, pair_hd))); + assert!(acc_before.contains_key(&(EXTERNAL_SOURCE, pair_da))); + assert!(acc_before.contains_key(&(ANOTHER_SOURCE, pair_hd))); + assert!(acc_before.contains_key(&(NEIGHBOR_SOURCE, pair_hd))); + + // ─── ACT ─── + assert_ok!(EmaOracle::remove_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + // ─── ASSERT: EXTERNAL_SOURCE fully wiped across every pair ─── + assert!(!ExternalSources::::contains_key(EXTERNAL_SOURCE)); + assert_eq!( + AuthorizedAccounts::::iter_prefix((EXTERNAL_SOURCE,)).count(), + 0, + "all 4 authorizations (incl. the data-less (4,5)) must be cleared" + ); + assert_eq!( + Oracles::::iter_prefix((EXTERNAL_SOURCE,)).count(), + 0, + "all 3 data pairs' Oracles rows must be cleared across every period" + ); + let acc_after = Accumulator::::get(); + assert!(!acc_after.contains_key(&(EXTERNAL_SOURCE, pair_hd))); + assert!(!acc_after.contains_key(&(EXTERNAL_SOURCE, pair_ha))); + assert!(!acc_after.contains_key(&(EXTERNAL_SOURCE, pair_da))); + assert!(!acc_after.contains_key(&(EXTERNAL_SOURCE, pair_auth_only))); + + // ─── ASSERT: AMM SOURCE untouched (both pairs) ─── + assert!(WhitelistedAssets::::get().contains(&(SOURCE, (HDX, DOT)))); + assert!(WhitelistedAssets::::get().contains(&(SOURCE, (HDX, ACA)))); + assert_eq!(rows(SOURCE), source_rows_pre, "AMM Oracles row count must be unchanged"); + assert!(acc_after.contains_key(&(SOURCE, pair_hd))); + + // ─── ASSERT: ANOTHER_SOURCE untouched (both overlapping pairs) ─── + assert!(ExternalSources::::contains_key(ANOTHER_SOURCE)); + assert!(AuthorizedAccounts::::contains_key((ANOTHER_SOURCE, pair_hd, BOB))); + assert!(AuthorizedAccounts::::contains_key((ANOTHER_SOURCE, pair_da, BOB))); + assert_eq!( + rows(ANOTHER_SOURCE), + another_rows_pre, + "ANOTHER_SOURCE Oracles row count must be unchanged" + ); + assert!(acc_after.contains_key(&(ANOTHER_SOURCE, pair_hd))); + + // ─── ASSERT: NEIGHBOR_SOURCE (byte-adjacent) untouched ─── + assert!(ExternalSources::::contains_key(NEIGHBOR_SOURCE)); + assert!(AuthorizedAccounts::::contains_key(( + NEIGHBOR_SOURCE, + pair_hd, + ALICE + ))); + assert_eq!( + rows(NEIGHBOR_SOURCE), + neighbor_rows_pre, + "byte-adjacent NEIGHBOR_SOURCE must be untouched" + ); + assert!(acc_after.contains_key(&(NEIGHBOR_SOURCE, pair_hd))); + + // ─── ASSERT: THIRD_SOURCE (empty registration) untouched ─── + assert!(ExternalSources::::contains_key(THIRD_SOURCE)); + }); +} + +#[test] +fn remove_nonexistent_source_fails() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::remove_external_source(RuntimeOrigin::root(), EXTERNAL_SOURCE), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn add_authorized_account_works() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + assert!(AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + ))); + }); +} + +#[test] +fn add_authorized_account_stores_in_ordered_pair_form() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // Pass the pair in reverse order; storage must be keyed by the ordered form. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (5, 0), + ALICE + )); + assert!(AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + ordered_pair(0, 5), + ALICE + ))); + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (5, 0), + ALICE + ))); + }); +} + +#[test] +fn add_account_for_nonexistent_source_fails() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::add_authorized_account(RuntimeOrigin::root(), EXTERNAL_SOURCE, HDX_DOT_PAIR, ALICE), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn remove_authorized_account_works() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + assert_ok!(EmaOracle::remove_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + ))); + }); +} + +#[test] +fn remove_authorized_account_only_affects_the_given_pair() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // ALICE is authorized for two pairs under the same source. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (0, 5), + ALICE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (1, 2), + ALICE + )); + + // Revoking one pair must leave the other intact. + assert_ok!(EmaOracle::remove_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (0, 5), + ALICE + )); + assert!(!AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (0, 5), + ALICE + ))); + assert!(AuthorizedAccounts::::contains_key(( + EXTERNAL_SOURCE, + (1, 2), + ALICE + ))); + }); +} + +#[test] +fn set_external_oracle_happy_path() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + System::set_block_number(3); + + let res = EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + ); + assert_eq!(res, Ok(Pays::No.into())); + + // Verify the entry is in the accumulator + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 5)))); + }); +} + +#[test] +fn set_external_oracle_unauthorized_rejected() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + ), + Error::::NotAuthorized + ); + }); +} + +// Core DDoS protection invariant: an account authorized for pair A must NOT be able to push +// updates for pair B under the same source. This is the test that prevents the regression +// the refactor was introduced to fix. +#[test] +fn authorized_account_cannot_update_unauthorized_pair() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // ALICE is authorized ONLY for (0, 1), not for (0, 5). + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (0, 1), + ALICE + )); + + System::set_block_number(3); + + // Attempting to update (hdx, dot) = (0, 5) must fail. + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + ), + Error::::NotAuthorized + ); + }); +} + +#[test] +fn set_external_oracle_zero_price_rejected() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (0, 100), + ), + Error::::PriceIsZero + ); + + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 0), + ), + Error::::PriceIsZero + ); + }); +} + +#[test] +fn set_external_oracle_unregistered_source_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + ), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn external_sources_bypass_whitelist() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + // Use INSUFFICIENT_ASSET which is normally excluded by the whitelist + System::set_block_number(3); + + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + )); + + // Verify the entry is in the accumulator (bypasses whitelist) + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 5)))); + }); +} + +#[test] +fn multiple_sources_in_same_block() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + ANOTHER_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + ANOTHER_SOURCE, + HDX_DOT_PAIR, + BOB + )); + + System::set_block_number(3); + + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + )); + + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(BOB), + ANOTHER_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (200, 99), + )); + + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 5)))); + assert!(acc.contains_key(&(ANOTHER_SOURCE, ordered_pair(0, 5)))); + }); +} + +#[test] +fn amm_trades_are_limited_to_max_unique_entries() { + new_test_ext().execute_with(|| { + //Arrange + let max_entries = <::MaxUniqueEntries as Get>::get(); + + //Act - fill the accumulator to max + for i in 0..max_entries { + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + i, + i + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + } + + //Assert - accumulator is full, next AMM trade fails + assert_eq!(Accumulator::::get().len(), max_entries as usize); + assert_noop!( + OnActivityHandler::::on_trade( + SOURCE, + 2 * max_entries, + 2 * max_entries + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + ) + .map_err(|(_w, e)| e), + Error::::TooManyUniqueEntries + ); + }); +} + +#[test] +fn soft_limit_only_applies_to_non_external_sources() { + new_test_ext().execute_with(|| { + let max_entries = <::MaxUniqueEntries as Get>::get(); + + // Fill the accumulator to max with AMM trades + for i in 0..max_entries { + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + i, + i + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + } + + // Non-external source should fail when accumulator is full + assert_noop!( + OnActivityHandler::::on_trade( + SOURCE, + 2 * max_entries, + 2 * max_entries + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + ) + .map_err(|(_w, e)| e), + Error::::TooManyUniqueEntries + ); + + // But external sources should still be able to insert beyond the limit + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + )); + + // Accumulator has more entries than MaxUniqueEntries + assert_eq!(Accumulator::::get().len(), (max_entries + 1) as usize); + }); +} + +#[test] +fn external_entries_do_not_block_amm_new_pair_trades() { + new_test_ext().execute_with(|| { + let max_entries = <::MaxUniqueEntries as Get>::get(); + + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + 100, + 101, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + assert_eq!(Accumulator::::get().len(), 1); + + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + for i in 0..(max_entries - 1) { + assert_ok!(OnActivityHandler::::on_trade( + EXTERNAL_SOURCE, + i, + i + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + } + assert_eq!(Accumulator::::get().len(), max_entries as usize); + + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + 100, + 101, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + 0, + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + + assert_ok!(OnActivityHandler::::on_trade( + SOURCE, + 2 * max_entries, + 2 * max_entries + 1, + 1_000, + 1_000, + 2_000, + 2_000, + Price::new(2_000, 2_000), + Some(1_000_u128), + )); + + assert_eq!(Accumulator::::get().len(), (max_entries + 2) as usize); + }); +} + +#[test] +fn account_can_update_only_explicitly_authorized_pairs_in_one_block() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + // Build several distinct locations that the mock converter resolves to distinct asset IDs. + let loc_0 = polkadot_xcm::v5::Location::new( + 0, + polkadot_xcm::v5::Junctions::X1([polkadot_xcm::v5::Junction::GeneralIndex(0)].into()), + ) + .into_versioned(); // → asset 0 + let loc_1 = polkadot_xcm::v5::Location::new( + 0, + polkadot_xcm::v5::Junctions::X1([polkadot_xcm::v5::Junction::GeneralIndex(1)].into()), + ) + .into_versioned(); // → asset 1 + let loc_2 = polkadot_xcm::v5::Location::new( + 0, + polkadot_xcm::v5::Junctions::X1([polkadot_xcm::v5::Junction::GeneralIndex(2)].into()), + ) + .into_versioned(); // → asset 2 + let loc_dot = polkadot_xcm::v5::Location::parent().into_versioned(); // → asset 5 + + // ALICE is authorized for exactly three pairs: (0, 1), (0, 2), (2, 5). + // She is NOT authorized for (0, 5), so that update must fail. + for pair in &[(0_u32, 1_u32), (0, 2), (2, 5)] { + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + *pair, + ALICE + )); + } + + System::set_block_number(3); + + // Three authorized pairs land in the accumulator in the same block. + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(loc_0.clone()), + Box::new(loc_1.clone()), + (100, 99), + )); + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(loc_0.clone()), + Box::new(loc_2.clone()), + (200, 99), + )); + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(loc_2.clone()), + Box::new(loc_dot.clone()), + (300, 99), + )); + + // The unauthorized pair (0, 5) is rejected — this is the DDoS mitigation. + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(loc_0), + Box::new(loc_dot), + (400, 99), + ), + Error::::NotAuthorized + ); + + let acc = Accumulator::::get(); + assert_eq!(acc.len(), 3); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 1)))); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 2)))); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(2, 5)))); + // The rejected pair did NOT land in the accumulator. + assert!(!acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 5)))); + }); +} + +#[test] +fn set_external_oracle_accepts_reversed_location_order() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, // canonical (0, 5) + ALICE + )); + + System::set_block_number(3); + + // Call with (dot, hdx) instead of (hdx, dot). + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(dot_location()), + Box::new(hdx_location()), + (100, 99), + )); + + // Accumulator stores in ordered_pair form regardless of call-site order. + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(0, 5)))); + }); +} + +#[test] +fn add_authorized_account_requires_external_oracle_origin() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_noop!( + EmaOracle::add_authorized_account(RuntimeOrigin::signed(ALICE), EXTERNAL_SOURCE, HDX_DOT_PAIR, BOB), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn remove_authorized_account_requires_external_oracle_origin() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + assert_noop!( + EmaOracle::remove_authorized_account(RuntimeOrigin::signed(BOB), EXTERNAL_SOURCE, HDX_DOT_PAIR, ALICE), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn remove_external_source_requires_external_oracle_origin() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_noop!( + EmaOracle::remove_external_source(RuntimeOrigin::signed(ALICE), EXTERNAL_SOURCE), + sp_runtime::DispatchError::BadOrigin + ); + }); +} + +#[test] +fn remove_account_for_nonexistent_source_fails() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::remove_authorized_account(RuntimeOrigin::root(), EXTERNAL_SOURCE, HDX_DOT_PAIR, ALICE), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn authorized_account_events_carry_pair_in_ordered_form() { + new_test_ext().execute_with(|| { + System::set_block_number(1); // events are only recorded on blocks >= 1 + + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + // Intentionally pass the pair reversed so we prove ordering normalization happens + // before the event is emitted. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (5, 0), + ALICE + )); + expect_events(vec![crate::Event::AuthorizedAccountAdded { + source: EXTERNAL_SOURCE, + pair: ordered_pair(0, 5), + account: ALICE, + } + .into()]); + + assert_ok!(EmaOracle::remove_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + (5, 0), + ALICE + )); + expect_events(vec![crate::Event::AuthorizedAccountRemoved { + source: EXTERNAL_SOURCE, + pair: ordered_pair(0, 5), + account: ALICE, + } + .into()]); + }); +} + +#[test] +fn set_external_oracle_rejected_after_source_removed() { + new_test_ext().execute_with(|| { + // Arrange: source + authorization, and a baseline successful update. + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + System::set_block_number(3); + + assert_ok!(EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + )); + + // Act: governance removes the entire source. + assert_ok!(EmaOracle::remove_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + // Assert: the same caller, pair, and price now hits the source gate first. + assert_noop!( + EmaOracle::set_external_oracle( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + Box::new(hdx_location()), + Box::new(dot_location()), + (100, 99), + ), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn set_external_oracle_by_ids_happy_path() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + System::set_block_number(3); + + let res = EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + HDX_DOT_PAIR.1, + (100, 99), + ); + assert_eq!(res, Ok(Pays::No.into())); + + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(HDX_DOT_PAIR.0, HDX_DOT_PAIR.1)))); + }); +} + +#[test] +fn set_external_oracle_by_ids_unauthorized_rejected() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + + assert_noop!( + EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + HDX_DOT_PAIR.1, + (100, 99), + ), + Error::::NotAuthorized + ); + }); +} + +#[test] +fn set_external_oracle_by_ids_unknown_asset_id_returns_not_authorized() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // Authorize ALICE only for the real pair. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + const BOGUS_ASSET_ID: AssetId = 99_999; + assert_noop!( + EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + BOGUS_ASSET_ID, + (100, 99), + ), + Error::::NotAuthorized + ); + }); +} + +#[test] +fn set_external_oracle_by_ids_zero_price_rejected() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + assert_noop!( + EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + HDX_DOT_PAIR.1, + (0, 100), + ), + Error::::PriceIsZero + ); + + assert_noop!( + EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + HDX_DOT_PAIR.1, + (100, 0), + ), + Error::::PriceIsZero + ); + }); +} + +#[test] +fn set_external_oracle_by_ids_unregistered_source_rejected() { + new_test_ext().execute_with(|| { + assert_noop!( + EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.0, + HDX_DOT_PAIR.1, + (100, 99), + ), + Error::::SourceNotFound + ); + }); +} + +#[test] +fn set_external_oracle_by_ids_accepts_reversed_id_order() { + new_test_ext().execute_with(|| { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + EXTERNAL_SOURCE + )); + // Authorize the ordered pair (HDX_DOT_PAIR.0, HDX_DOT_PAIR.1) where .0 < .1. + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + EXTERNAL_SOURCE, + HDX_DOT_PAIR, + ALICE + )); + + System::set_block_number(3); + + // Caller passes the pair in reversed order; ordered_pair() normalizes it so auth still matches. + assert_ok!(EmaOracle::set_external_oracle_by_ids( + RuntimeOrigin::signed(ALICE), + EXTERNAL_SOURCE, + HDX_DOT_PAIR.1, + HDX_DOT_PAIR.0, + (100, 99), + )); + + let acc = Accumulator::::get(); + assert!(acc.contains_key(&(EXTERNAL_SOURCE, ordered_pair(HDX_DOT_PAIR.0, HDX_DOT_PAIR.1)))); + }); +} diff --git a/pallets/ema-oracle/src/tests/mock.rs b/pallets/ema-oracle/src/tests/mock.rs index 4ce879726c..175b77bf2a 100644 --- a/pallets/ema-oracle/src/tests/mock.rs +++ b/pallets/ema-oracle/src/tests/mock.rs @@ -25,7 +25,7 @@ use frame_support::sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, BuildStorage, }; -use frame_support::traits::{Contains, Everything, SortedMembers}; +use frame_support::traits::{Contains, Everything}; use frame_support::BoundedVec; use frame_system::EnsureRoot; use hydradx_traits::OraclePeriod::{self, *}; @@ -33,7 +33,6 @@ use hydradx_traits::Source; use hydradx_traits::{Liquidity, Volume}; use polkadot_xcm::latest::{Junctions, Location}; use polkadot_xcm::prelude::GeneralIndex; -use sp_arithmetic::Permill; use sp_core::H256; use sp_runtime::traits::Convert; @@ -127,7 +126,6 @@ impl frame_system::Config for Test { parameter_types! { pub SupportedPeriods: BoundedVec> = bounded_vec![LastBlock, TenMinutes, Day, Week]; - pub PriceDifference: Permill = Permill::from_percent(10); } pub struct OracleWhitelist; @@ -137,10 +135,11 @@ impl Contains<(Source, AssetId, AssetId)> for OracleWhitelist { } } -pub struct BifrostAcc; -impl SortedMembers for BifrostAcc { - fn sorted_members() -> Vec { - vec![ALICE] +/// Identifies internal (AMM) sources by checking they are not registered as external. +pub struct InternalSources; +impl Contains for InternalSources { + fn contains(s: &Source) -> bool { + !ema_oracle::pallet::ExternalSources::::contains_key(s) } } @@ -149,13 +148,12 @@ impl Config for Test { type BlockNumberProvider = System; type SupportedPeriods = SupportedPeriods; type OracleWhitelist = OracleWhitelist; + type InternalSources = InternalSources; type MaxUniqueEntries = ConstU32<45>; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); - type BifrostOrigin = frame_system::EnsureSignedBy; type WeightInfo = (); type LocationToAssetIdConversion = CurrencyIdConvertMock; - type MaxAllowedPriceDifference = PriceDifference; } pub struct CurrencyIdConvertMock; diff --git a/pallets/ema-oracle/src/tests/mod.rs b/pallets/ema-oracle/src/tests/mod.rs index a166e41078..34fc8c220d 100644 --- a/pallets/ema-oracle/src/tests/mod.rs +++ b/pallets/ema-oracle/src/tests/mod.rs @@ -16,6 +16,7 @@ // limitations under the License. mod add_and_remove_oracle; +mod external_oracle; mod invariants; mod mock; mod oracle_updated_event; diff --git a/pallets/ema-oracle/src/tests/update_bifrost_oracle.rs b/pallets/ema-oracle/src/tests/update_bifrost_oracle.rs index 2031278ce2..a6294ca74e 100644 --- a/pallets/ema-oracle/src/tests/update_bifrost_oracle.rs +++ b/pallets/ema-oracle/src/tests/update_bifrost_oracle.rs @@ -15,6 +15,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(deprecated)] + use super::*; pub use mock::{EmaOracle, RuntimeOrigin, Test}; @@ -29,12 +31,28 @@ pub fn new_test_ext() -> sp_io::TestExternalities { use crate::tests::mock::ALICE; use polkadot_xcm::v5::prelude::*; -use sp_runtime::DispatchError::BadOrigin; + +const BIFROST_HDX_DOT_PAIR: (crate::AssetId, crate::AssetId) = (0, 5); + +fn setup_bifrost_auth() { + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE + )); + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + BIFROST_HDX_DOT_PAIR, + ALICE + )); +} #[test] fn add_oracle_should_add_entry_to_storage() { new_test_ext().execute_with(|| { //Arrange + setup_bifrost_auth(); + let hdx = polkadot_xcm::v5::Location::new(0, polkadot_xcm::v5::Junctions::X1([GeneralIndex(0)].into())) .into_versioned(); @@ -70,6 +88,8 @@ fn add_oracle_should_add_entry_to_storage() { fn successful_oracle_update_shouldnt_pay_fee() { new_test_ext().execute_with(|| { //Arrange + setup_bifrost_auth(); + let hdx = polkadot_xcm::v5::Location::new(0, polkadot_xcm::v5::Junctions::X1([GeneralIndex(0)].into())) .into_versioned(); let dot = polkadot_xcm::v5::Location::parent().into_versioned(); @@ -78,7 +98,7 @@ fn successful_oracle_update_shouldnt_pay_fee() { let res = EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(ALICE), Box::new(hdx), Box::new(dot), (100, 99)); - // Assert + //Assert assert_eq!(res, Ok(Pays::No.into())); }); } @@ -87,6 +107,8 @@ fn successful_oracle_update_shouldnt_pay_fee() { fn add_oracle_should_add_entry_to_storage_with_inversed_pair() { new_test_ext().execute_with(|| { //Arrange + setup_bifrost_auth(); + let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); let dot = polkadot_xcm::v5::Location::parent().into_versioned(); @@ -118,9 +140,11 @@ fn add_oracle_should_add_entry_to_storage_with_inversed_pair() { } #[test] -fn bitfrost_oracle_should_not_be_updated_by_nonpriviliged_account() { +fn bifrost_oracle_should_not_be_updated_by_nonprivileged_account() { new_test_ext().execute_with(|| { //Arrange + setup_bifrost_auth(); + let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); let dot = polkadot_xcm::v5::Location::parent().into_versioned(); @@ -128,92 +152,69 @@ fn bitfrost_oracle_should_not_be_updated_by_nonpriviliged_account() { let asset_a = Box::new(hdx); let asset_b = Box::new(dot); - //Act System::set_block_number(3); + //Act & Assert assert_noop!( EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(BOB), asset_a, asset_b, (100, 99)), - BadOrigin + Error::::NotAuthorized ); }); } #[test] -fn should_fail_when_new_price_is_bigger_than_allowed() { +fn should_fail_when_price_is_zero() { new_test_ext().execute_with(|| { //Arrange - let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); + setup_bifrost_auth(); + let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); let dot = polkadot_xcm::v5::Location::parent().into_versioned(); - let asset_a = Box::new(hdx); - let asset_b = Box::new(dot); - System::set_block_number(3); - assert_ok!(EmaOracle::update_bifrost_oracle( - RuntimeOrigin::signed(ALICE), - asset_a.clone(), - asset_b.clone(), - (100, 100) - )); - - update_aggregated_oracles(); - - //Act + //Act & Assert assert_noop!( EmaOracle::update_bifrost_oracle( RuntimeOrigin::signed(ALICE), - asset_a.clone(), - asset_b.clone(), - (111, 100) + Box::new(hdx.clone()), + Box::new(dot.clone()), + (0, 100) ), - Error::::PriceOutsideAllowedRange + Error::::PriceIsZero ); assert_noop!( - EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(ALICE), asset_a, asset_b, (89, 100)), - Error::::PriceOutsideAllowedRange + EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(ALICE), Box::new(hdx), Box::new(dot), (100, 0)), + Error::::PriceIsZero ); }); } #[test] -fn should_pass_when_new_price_is_still_within_range() { +fn bifrost_oracle_rejects_unauthorized_pair() { new_test_ext().execute_with(|| { - //Arrange - let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); + assert_ok!(EmaOracle::register_external_source( + RuntimeOrigin::root(), + BIFROST_SOURCE + )); + // Alice is authorized only for (0, 1), not for (0, 5). + assert_ok!(EmaOracle::add_authorized_account( + RuntimeOrigin::root(), + BIFROST_SOURCE, + (0, 1), + ALICE + )); + let hdx = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]).into_versioned(); let dot = polkadot_xcm::v5::Location::parent().into_versioned(); - let asset_a = Box::new(hdx); - let asset_b = Box::new(dot); - System::set_block_number(3); - assert_ok!(EmaOracle::update_bifrost_oracle( - RuntimeOrigin::signed(ALICE), - asset_a.clone(), - asset_b.clone(), - (100, 100) - )); - - update_aggregated_oracles(); - - //Act - assert_ok!(EmaOracle::update_bifrost_oracle( - RuntimeOrigin::signed(ALICE), - asset_a.clone(), - asset_b.clone(), - (110, 100) - ),); - - assert_ok!(EmaOracle::update_bifrost_oracle( - RuntimeOrigin::signed(ALICE), - asset_a, - asset_b, - (90, 100) - ),); + assert_noop!( + EmaOracle::update_bifrost_oracle(RuntimeOrigin::signed(ALICE), Box::new(hdx), Box::new(dot), (100, 99)), + Error::::NotAuthorized + ); }); } @@ -222,5 +223,3 @@ pub fn update_aggregated_oracles() { System::set_block_number(7); EmaOracle::on_initialize(7); } - -//TODO: add negative test when it is not called by bitfrost origni diff --git a/pallets/ema-oracle/src/weights.rs b/pallets/ema-oracle/src/weights.rs index c85fb7dd6d..8742c2e6c1 100644 --- a/pallets/ema-oracle/src/weights.rs +++ b/pallets/ema-oracle/src/weights.rs @@ -19,6 +19,12 @@ pub trait WeightInfo { fn on_trade_multiple_tokens(b: u32) -> Weight; fn on_liquidity_changed_multiple_tokens(b: u32) -> Weight; fn get_entry() -> Weight; + fn set_external_oracle() -> Weight; + fn set_external_oracle_by_ids() -> Weight; + fn register_external_source() -> Weight; + fn remove_external_source(n: u32) -> Weight; + fn add_authorized_account() -> Weight; + fn remove_authorized_account() -> Weight; } /// Weights for `pallet_ema_oracle` using the HydraDX node and recommended hardware. @@ -54,6 +60,30 @@ impl WeightInfo for () { Weight::zero() } + fn set_external_oracle() -> Weight { + Weight::zero() + } + + fn set_external_oracle_by_ids() -> Weight { + Weight::zero() + } + + fn register_external_source() -> Weight { + Weight::zero() + } + + fn remove_external_source(_n: u32) -> Weight { + Weight::zero() + } + + fn add_authorized_account() -> Weight { + Weight::zero() + } + + fn remove_authorized_account() -> Weight { + Weight::zero() + } + /// Storage: `EmaOracle::Accumulator` (r:1 w:0) /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(5921), added: 6416, mode: `MaxEncodedLen`) fn on_finalize_no_entry() -> Weight { diff --git a/pallets/omnipool-liquidity-mining/Cargo.toml b/pallets/omnipool-liquidity-mining/Cargo.toml index b6cb75120b..77e0d67a7c 100644 --- a/pallets/omnipool-liquidity-mining/Cargo.toml +++ b/pallets/omnipool-liquidity-mining/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-omnipool-liquidity-mining" -version = "3.3.1" +version = "3.4.0" authors = ['GalacticCouncil'] edition = "2021" license = "Apache-2.0" diff --git a/pallets/omnipool-liquidity-mining/src/tests/mock.rs b/pallets/omnipool-liquidity-mining/src/tests/mock.rs index f29b6bcfe5..8c6f695d40 100644 --- a/pallets/omnipool-liquidity-mining/src/tests/mock.rs +++ b/pallets/omnipool-liquidity-mining/src/tests/mock.rs @@ -26,7 +26,7 @@ use frame_support::BoundedVec; use hydradx_traits::liquidity_mining::PriceAdjustment; use pallet_omnipool; -use frame_support::traits::{ConstU128, Contains, Everything, SortedMembers}; +use frame_support::traits::{ConstU128, Contains, Everything}; use frame_support::{ assert_ok, construct_runtime, parameter_types, traits::{ConstU32, ConstU64}, @@ -293,26 +293,17 @@ parameter_types! { pub SupportedPeriods: BoundedVec> = BoundedVec::truncate_from(vec![ OraclePeriod::LastBlock, OraclePeriod::Short, OraclePeriod::TenMinutes]); - pub PriceDifference: Permill = Permill::from_percent(10); } -pub struct BifrostAcc; -impl SortedMembers for BifrostAcc { - fn sorted_members() -> Vec { - vec![ALICE] - } -} - impl pallet_ema_oracle::Config for Test { type AuthorityOrigin = EnsureRoot; type BlockNumberProvider = MockBlockNumberProvider; type SupportedPeriods = SupportedPeriods; type OracleWhitelist = Everything; + type InternalSources = Everything; type MaxUniqueEntries = ConstU32<20>; - type BifrostOrigin = frame_system::EnsureSignedBy; type LocationToAssetIdConversion = (); - type MaxAllowedPriceDifference = PriceDifference; #[cfg(feature = "runtime-benchmarks")] type BenchmarkHelper = (); type WeightInfo = (); diff --git a/pallets/omnipool/Cargo.toml b/pallets/omnipool/Cargo.toml index e15ffe10bf..063996a7ec 100644 --- a/pallets/omnipool/Cargo.toml +++ b/pallets/omnipool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-omnipool" -version = "7.3.0" +version = "7.3.1" authors = ['GalacticCouncil'] edition = "2021" license = "Apache-2.0" diff --git a/runtime/hydradx/src/assets.rs b/runtime/hydradx/src/assets.rs index b98813e30c..e28cb16bed 100644 --- a/runtime/hydradx/src/assets.rs +++ b/runtime/hydradx/src/assets.rs @@ -32,7 +32,7 @@ use frame_support::{ sp_runtime::{FixedU128, Perbill, Permill}, traits::{ AsEnsureOriginWithArg, ConstU32, Contains, Currency, Defensive, EitherOf, EnsureOrigin, ExistenceRequirement, - Imbalance, LockIdentifier, NeverEnsureOrigin, OnUnbalanced, SortedMembers, + Imbalance, LockIdentifier, NeverEnsureOrigin, OnUnbalanced, }, BoundedVec, PalletId, }; @@ -77,7 +77,7 @@ use pallet_staking::{ use pallet_transaction_multi_payment::{AddTxAssetOnAccount, AssetIdOf, RemoveTxAssetOnKilled}; use pallet_xyk::weights::WeightInfo as XykWeights; use primitives::constants::{ - chain::{CORE_ASSET_ID, OMNIPOOL_SOURCE, XYK_SOURCE}, + chain::{CORE_ASSET_ID, OMNIPOOL_SOURCE, STABLESWAP_SOURCE, XYK_SOURCE}, currency::{NATIVE_EXISTENTIAL_DEPOSIT, UNITS}, time::DAYS, }; @@ -634,8 +634,16 @@ impl pallet_circuit_breaker::Config for Runtime { parameter_types! { pub SupportedPeriods: BoundedVec> = BoundedVec::truncate_from(vec![ OraclePeriod::LastBlock, OraclePeriod::Short, OraclePeriod::TenMinutes]); + // sibling:2030 = 7LCt6dFs6sraSg31uKfbRH7soQ66GRb3LAkGZJ1ie3369crq + pub BifrostAccount: AccountId = hex!["7369626cee070000000000000000000000000000000000000000000000000000"].into(); +} - pub MaxAllowedPriceDifferenceForBifrostOracleUpdate: Permill = Permill::from_percent(10); +/// Identifies internal (AMM) oracle sources via hardcoded constants. Zero storage reads. +pub struct InternalOracleSources; +impl Contains for InternalOracleSources { + fn contains(s: &Source) -> bool { + matches!(s, &OMNIPOOL_SOURCE | &STABLESWAP_SOURCE | &XYK_SOURCE) + } } pub struct OracleWhitelist(PhantomData); @@ -650,26 +658,15 @@ where } } -// sibling:2030 = 7LCt6dFs6sraSg31uKfbRH7soQ66GRb3LAkGZJ1ie3369crq -pub fn bifrost_account() -> AccountId { - hex!["7369626cee070000000000000000000000000000000000000000000000000000"].into() -} -pub struct BifrostAcc; -impl SortedMembers for BifrostAcc { - fn sorted_members() -> Vec { - vec![bifrost_account()] - } -} - impl pallet_ema_oracle::Config for Runtime { - type AuthorityOrigin = EitherOf, GeneralAdmin>; - type BifrostOrigin = frame_system::EnsureSignedBy; + type AuthorityOrigin = EitherOf, EconomicParameters>; /// The definition of the oracle time periods currently assumes a 6 second block time. /// We use the parachain blocks anyway, because we want certain guarantees over how many blocks correspond /// to which smoothing factor. type BlockNumberProvider = System; type SupportedPeriods = SupportedPeriods; type OracleWhitelist = OracleWhitelist; + type InternalSources = InternalOracleSources; /// With every asset trading against LRNA we will only have as many pairs as there will be assets, so /// 40 seems a decent upper bound for the foreseeable future. type MaxUniqueEntries = ConstU32<40>; @@ -678,7 +675,6 @@ impl pallet_ema_oracle::Config for Runtime { /// Should take care of the overhead introduced by `OracleWhitelist`. type BenchmarkHelper = RegisterAsset; type LocationToAssetIdConversion = CurrencyIdConvert; - type MaxAllowedPriceDifference = MaxAllowedPriceDifferenceForBifrostOracleUpdate; } pub struct ExtendedDustRemovalWhitelist; diff --git a/runtime/hydradx/src/benchmarking/ema_oracle.rs b/runtime/hydradx/src/benchmarking/ema_oracle.rs index 5a567e7fe3..64586e0d7b 100644 --- a/runtime/hydradx/src/benchmarking/ema_oracle.rs +++ b/runtime/hydradx/src/benchmarking/ema_oracle.rs @@ -16,9 +16,10 @@ // limitations under the License. #![cfg(feature = "runtime-benchmarks")] +#![allow(deprecated)] use super::*; -use crate::bifrost_account; +use crate::assets::BifrostAccount; use hydradx_traits::oracle::OraclePeriod; use hydradx_traits::AggregatedEntry; use pallet_ema_oracle::ordered_pair; @@ -44,6 +45,16 @@ use sp_core::{ConstU32, Get}; /// Default oracle source. const SOURCE: Source = *b"dummysrc"; +/// Benchmark range / worst-case pre-fill for external-oracle entries. +/// Decoupled from `MAX_EXTERNAL_ENTRIES_PER_BLOCK`: the `on_finalize` weight formula +/// is linear in entry count, so fitting it from a small range is both faster and +/// more stable, and the formula extrapolates up to the runtime accounting value. +const BENCH_EXTERNAL_ENTRIES: u32 = 10; + +fn bifrost_account() -> AccountId { + BifrostAccount::get() +} + fn fill_whitelist_storage(n: u32) { for i in 0..n { assert_ok!(EmaOracle::add_oracle(RawOrigin::Root.into(), SOURCE, (HDX, i))); @@ -116,7 +127,7 @@ runtime_benchmarks! { shares_issuance: Some(shares_issuance), }; - assert_eq!(Accumulator::::get().into_inner(), [((SOURCE, pallet_ema_oracle::ordered_pair(HDX, DOT)), entry.clone())].into_iter().collect()); + assert_eq!(Accumulator::::get(), [((SOURCE, pallet_ema_oracle::ordered_pair(HDX, DOT)), entry.clone())].into_iter().collect()); }: { as frame_support::traits::OnFinalize>>::on_finalize(block_num); } verify { @@ -161,7 +172,7 @@ runtime_benchmarks! { shares_issuance: Some(shares_issuance), }; - assert_eq!(Accumulator::::get().into_inner(), [((SOURCE, ordered_pair(HDX, DOT)), entry.clone())].into_iter().collect()); + assert_eq!(Accumulator::::get(), [((SOURCE, ordered_pair(HDX, DOT)), entry.clone())].into_iter().collect()); }: { as frame_support::traits::OnFinalize>>::on_finalize(block_num); } verify { @@ -170,10 +181,11 @@ runtime_benchmarks! { } on_finalize_multiple_tokens { - let b in 1 .. (<::MaxUniqueEntries as Get>::get() - 1); + let b in 1 .. BENCH_EXTERNAL_ENTRIES; - let max_entries = <::MaxUniqueEntries as Get>::get(); - fill_whitelist_storage(max_entries); + // Register an external source so on_trade bypasses the whitelist and soft limit + let external_source: Source = *b"benchext"; + EmaOracle::register_external_source(RawOrigin::Root.into(), external_source).expect("error when registering external source"); let initial_data_block: BlockNumberFor = 5u32; let block_num = initial_data_block.saturating_add(1_000_000u32); @@ -182,7 +194,7 @@ runtime_benchmarks! { as frame_support::traits::OnInitialize>>::on_initialize(initial_data_block); let (amount_in, amount_out) = (1_000_000_000_000, 2_000_000_000_000); let (liquidity_asset_in, liquidity_asset_out) = (1_000_000_000_000_000, 2_000_000_000_000_000); - let shares_issuance =1_000_000_000_000; + let shares_issuance = 1_000_000_000_000; for i in 0 .. b { let asset_a = (i + 1) * 1_000; let asset_b = asset_a + 500; @@ -190,7 +202,7 @@ runtime_benchmarks! { register_asset_with_id([b"AS2", asset_b.to_string().as_bytes()].concat(), asset_b).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; assert_ok!(OnActivityHandler::::on_trade( - SOURCE, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, + external_source, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); } as frame_support::traits::OnFinalize>>::on_finalize(initial_data_block); @@ -201,7 +213,7 @@ runtime_benchmarks! { let asset_a = (i + 1) * 1_000; let asset_b = asset_a + 500; assert_ok!(OnActivityHandler::::on_trade( - SOURCE, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, + external_source, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); } }: { as frame_support::traits::OnFinalize>>::on_finalize(block_num); } @@ -217,7 +229,7 @@ runtime_benchmarks! { for i in 0 .. b { let asset_a = (i + 1) * 1_000; let asset_b = asset_a + 500; - assert_eq!(pallet_ema_oracle::Pallet::::oracle((SOURCE, ordered_pair(asset_a, asset_b), OraclePeriod::LastBlock)).unwrap(), (entry.clone(), initial_data_block)); + assert_eq!(pallet_ema_oracle::Pallet::::oracle((external_source, ordered_pair(asset_a, asset_b), OraclePeriod::LastBlock)).unwrap(), (entry.clone(), initial_data_block)); } } @@ -225,8 +237,13 @@ runtime_benchmarks! { let b in 1 .. (<::MaxUniqueEntries as Get>::get() - 1); let max_entries = <::MaxUniqueEntries as Get>::get(); + let max_external = BENCH_EXTERNAL_ENTRIES; fill_whitelist_storage(max_entries); + let ext_source: Source = *b"benchex1"; + EmaOracle::register_external_source(RawOrigin::Root.into(), ext_source) + .expect("error when registering external source"); + let initial_data_block: BlockNumberFor = 5u32; let block_num = initial_data_block.saturating_add(1_000_000u32); @@ -237,6 +254,9 @@ runtime_benchmarks! { let (amount_in, amount_out) = (1_000_000_000_000, 2_000_000_000_000); let (liquidity_asset_in, liquidity_asset_out) = (1_000_000_000_000_000, 2_000_000_000_000_000); let shares_issuance = 1_000_000_000_000; + + // Pre-seed AMM pairs in the previous block so their `Oracles` rows exist and + // the measured call exercises the "already-tracked" path in update_oracle. for i in 0 .. b { let asset_a = (i + 1) * 1_000; let asset_b = asset_a + 500; @@ -248,6 +268,19 @@ runtime_benchmarks! { SOURCE, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); } + // Pre-seed the worst-case number of external pairs. Asset-id range is disjoint + // from the AMM one above (+100_000 offset) so no key collision in the registry. + for i in 0 .. max_external { + let asset_a = 100_000 + (i + 1) * 1_000; + let asset_b = asset_a + 500; + + register_asset_with_id([b"EX1", asset_a.to_string().as_bytes()].concat(), asset_a).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + register_asset_with_id([b"EX2", asset_b.to_string().as_bytes()].concat(), asset_b).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + + assert_ok!(OnActivityHandler::::on_trade( + ext_source, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, + EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); + } as frame_support::traits::OnFinalize>>::on_finalize(initial_data_block); frame_system::Pallet::::set_block_number(block_num); @@ -259,6 +292,7 @@ runtime_benchmarks! { updated_at: block_num, shares_issuance: Some(shares_issuance), }; + // Refill the current block's accumulator with `b` AMM entries. for i in 0 .. b { let asset_a = (i + 1) * 1_000; let asset_b = asset_a + 500; @@ -267,6 +301,15 @@ runtime_benchmarks! { EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); entries.push(((SOURCE, ordered_pair(asset_a, asset_b)), entry.clone())); } + // Refill the worst-case external state. + for i in 0 .. max_external { + let asset_a = 100_000 + (i + 1) * 1_000; + let asset_b = asset_a + 500; + assert_ok!(OnActivityHandler::::on_trade( + ext_source, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, + EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance))); + entries.push(((ext_source, ordered_pair(asset_a, asset_b)), entry.clone())); + } let asset_a = (b + 1) * 1_000; let asset_b = asset_a + 500; register_asset_with_id([b"AS1", asset_a.to_string().as_bytes()].concat(), asset_a).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; @@ -278,21 +321,28 @@ runtime_benchmarks! { OnActivityHandler::::on_trade( SOURCE, asset_a, asset_b, amount_in, amount_out, liquidity_asset_in, liquidity_asset_out, EmaPrice::new(liquidity_asset_in, liquidity_asset_out), Some(shares_issuance)) - .map_err(|(_w, e)| e) + .map_err(|(_w, err)| err) ); } verify { assert_ok!(*res.borrow()); entries.push(((SOURCE, ordered_pair(asset_a, asset_b)), entry.clone())); - assert_eq!(pallet_ema_oracle::Pallet::::accumulator().into_inner(), entries.into_iter().collect()); + assert_eq!(pallet_ema_oracle::Pallet::::accumulator(), entries.into_iter().collect()); } + // See `on_trade_multiple_tokens` for the worst-case external accumulator state. on_liquidity_changed_multiple_tokens { let b in 1 .. (<::MaxUniqueEntries as Get>::get() - 1); + let max_entries = <::MaxUniqueEntries as Get>::get(); + let max_external = BENCH_EXTERNAL_ENTRIES; fill_whitelist_storage(max_entries); + let ext_source: Source = *b"benchex2"; + EmaOracle::register_external_source(RawOrigin::Root.into(), ext_source) + .expect("error when registering external source"); + let initial_data_block: BlockNumberFor = 5u32; let block_num = initial_data_block.saturating_add(1_000_000u32); @@ -314,6 +364,17 @@ runtime_benchmarks! { SOURCE, asset_a, asset_b, amount_a, amount_b, liquidity_asset_a, liquidity_asset_b, EmaPrice::new(liquidity_asset_a, liquidity_asset_b), Some(shares_issuance))); } + for i in 0 .. max_external { + let asset_a = 100_000 + (i + 1) * 1_000; + let asset_b = asset_a + 500; + + register_asset_with_id([b"EX1", asset_a.to_string().as_bytes()].concat(), asset_a).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + register_asset_with_id([b"EX2", asset_b.to_string().as_bytes()].concat(), asset_b).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + + assert_ok!(OnActivityHandler::::on_trade( + ext_source, asset_a, asset_b, amount_a, amount_b, liquidity_asset_a, liquidity_asset_b, + EmaPrice::new(liquidity_asset_a, liquidity_asset_b), Some(shares_issuance))); + } as frame_support::traits::OnFinalize>>::on_finalize(initial_data_block); frame_system::Pallet::::set_block_number(block_num); @@ -333,6 +394,14 @@ runtime_benchmarks! { EmaPrice::new(liquidity_asset_a, liquidity_asset_b), Some(shares_issuance))); entries.push(((SOURCE, ordered_pair(asset_a, asset_b)), entry.clone())); } + for i in 0 .. max_external { + let asset_a = 100_000 + (i + 1) * 1_000; + let asset_b = asset_a + 500; + assert_ok!(OnActivityHandler::::on_trade( + ext_source, asset_a, asset_b, amount_a, amount_b, liquidity_asset_a, liquidity_asset_b, + EmaPrice::new(liquidity_asset_a, liquidity_asset_b), Some(shares_issuance))); + entries.push(((ext_source, ordered_pair(asset_a, asset_b)), entry.clone())); + } let asset_a = (b + 1) * 1_000; let asset_b = asset_a + 500; register_asset_with_id([b"AS1", asset_a.to_string().as_bytes()].concat(), asset_a).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; @@ -344,7 +413,7 @@ runtime_benchmarks! { OnActivityHandler::::on_liquidity_changed( SOURCE, asset_a, asset_b, amount_a, amount_b, liquidity_asset_a, liquidity_asset_b, EmaPrice::new(liquidity_asset_a, liquidity_asset_b), Some(shares_issuance)) - .map_err(|(_w, e)| e) + .map_err(|(_w, err)| err) ); } verify { @@ -358,7 +427,7 @@ runtime_benchmarks! { }; entries.push(((SOURCE, ordered_pair(asset_a, asset_b)), liquidity_entry)); - assert_eq!(pallet_ema_oracle::Pallet::::accumulator().into_inner(), entries.into_iter().collect()); + assert_eq!(pallet_ema_oracle::Pallet::::accumulator(), entries.into_iter().collect()); } get_entry { @@ -411,7 +480,13 @@ runtime_benchmarks! { update_bifrost_oracle { let max_entries = <::MaxUniqueEntries as Get>::get(); fill_whitelist_storage(max_entries - 1); - EmaOracle::add_oracle(RawOrigin::Root.into(), pallet_ema_oracle::BIFROST_SOURCE, (0, 3)).expect("error when adding oracle"); + + let asset_a_id: AssetId = 0; + let asset_b_id: AssetId = 3; + + // Register BIFROST_SOURCE as external source and authorize bifrost account for this pair + EmaOracle::register_external_source(RawOrigin::Root.into(), pallet_ema_oracle::BIFROST_SOURCE).expect("error when registering external source"); + EmaOracle::add_authorized_account(RawOrigin::Root.into(), pallet_ema_oracle::BIFROST_SOURCE, (asset_a_id, asset_b_id), bifrost_account()).expect("error when adding authorized account"); let initial_data_block: BlockNumberFor = 5u32; let oracle_age: BlockNumberFor = 7u32; @@ -419,15 +494,13 @@ runtime_benchmarks! { frame_system::Pallet::::set_block_number(initial_data_block); as frame_support::traits::OnInitialize>>::on_initialize(initial_data_block); - let asset_a = 0; - let asset_b = 3; let hdx_loc = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]); let dot_loc = polkadot_xcm::v5::Location::new(1, [polkadot_xcm::v5::Junction::Parachain(1000), polkadot_xcm::v5::Junction::GeneralIndex(0)]); let dot_asset_loc = AssetLocation::try_from(dot_loc.clone()).unwrap(); - register_asset_with_id_and_loc(b"AS2".to_vec(), asset_b, dot_asset_loc).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + register_asset_with_id_and_loc(b"AS2".to_vec(), asset_b_id, dot_asset_loc).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; let asset_a = Box::new(hdx_loc.into_versioned()); let asset_b = Box::new(dot_loc.into_versioned()); @@ -441,6 +514,119 @@ runtime_benchmarks! { let entry = pallet_ema_oracle::Pallet::::oracle((pallet_ema_oracle::BIFROST_SOURCE, pallet_ema_oracle::ordered_pair(0, 3), hydradx_traits::oracle::OraclePeriod::Short)); assert!(entry.is_some()); } + + set_external_oracle { + let max_entries = <::MaxUniqueEntries as Get>::get(); + fill_whitelist_storage(max_entries - 1); + + let external_source: Source = *b"external"; + // Pair expected after LocationToAssetIdConversion for (hdx_loc, dot_loc) used below. + let auth_pair: (AssetId, AssetId) = (0, 3); + EmaOracle::register_external_source(RawOrigin::Root.into(), external_source).expect("error when registering external source"); + EmaOracle::add_authorized_account(RawOrigin::Root.into(), external_source, auth_pair, bifrost_account()).expect("error when adding authorized account"); + + let initial_data_block: BlockNumberFor = 5u32; + frame_system::Pallet::::set_block_number(initial_data_block); + as frame_support::traits::OnInitialize>>::on_initialize(initial_data_block); + + let hdx_loc = polkadot_xcm::v5::Location::new(0, [polkadot_xcm::v5::Junction::GeneralIndex(0)]); + let dot_loc = polkadot_xcm::v5::Location::new(1, [polkadot_xcm::v5::Junction::Parachain(1000), polkadot_xcm::v5::Junction::GeneralIndex(0)]); + + let dot_asset_loc = AssetLocation::try_from(dot_loc.clone()).unwrap(); + register_asset_with_id_and_loc(b"AS2".to_vec(), 3, dot_asset_loc).map_err(|_| BenchmarkError::Stop("Failed to register asset"))?; + + let asset_a = Box::new(hdx_loc.into_versioned()); + let asset_b = Box::new(dot_loc.into_versioned()); + + }: _(RawOrigin::Signed(bifrost_account()), external_source, asset_a, asset_b, (100,99)) + verify { + assert!(!Accumulator::::get().is_empty()); + } + + set_external_oracle_by_ids { + let max_entries = <::MaxUniqueEntries as Get>::get(); + fill_whitelist_storage(max_entries - 1); + + let external_source: Source = *b"extbyids"; + let asset_a_id: AssetId = HDX; + let asset_b_id: AssetId = DOT; + let auth_pair = pallet_ema_oracle::ordered_pair(asset_a_id, asset_b_id); + EmaOracle::register_external_source(RawOrigin::Root.into(), external_source).expect("error when registering external source"); + EmaOracle::add_authorized_account(RawOrigin::Root.into(), external_source, auth_pair, bifrost_account()).expect("error when adding authorized account"); + + let initial_data_block: BlockNumberFor = 5u32; + frame_system::Pallet::::set_block_number(initial_data_block); + as frame_support::traits::OnInitialize>>::on_initialize(initial_data_block); + + }: _(RawOrigin::Signed(bifrost_account()), external_source, asset_a_id, asset_b_id, (100,99)) + verify { + assert!(!Accumulator::::get().is_empty()); + } + + register_external_source { + let source: Source = *b"newsrcxx"; + }: _(RawOrigin::Root, source) + verify { + assert!(pallet_ema_oracle::ExternalSources::::contains_key(source)); + } + + // Worst-case: remove a source that has `n` (pair, account) authorization entries — each must + // be dropped by `clear_prefix` on both `AuthorizedAccounts` and `Oracles`. For every + // authorized pair we also pre-commit one full set of `Oracles` rows (one per supported + // period), so the benchmark measures the combined cost of the two clear_prefix calls plus + // the accumulator retain. + remove_external_source { + let n in 0 .. pallet_ema_oracle::MAX_AUTHORIZED_ENTRIES_PER_SOURCE; + let source: Source = *b"newsrcxx"; + EmaOracle::register_external_source(RawOrigin::Root.into(), source).expect("error when registering external source"); + let periods = ::SupportedPeriods::get(); + let seed_entry: OracleEntry> = OracleEntry::new( + EmaPrice::new(1u128, 1u128), + Volume::default(), + Liquidity::default(), + None, + 0u32, + ); + for i in 0..n { + let account: AccountId = frame_benchmarking::account("authorized", i, 0); + // Spread entries across distinct pairs so clear_prefix must actually delete n entries + // in each storage. Using (i, i+n+1) keeps pairs disjoint and non-degenerate. + let pair = (i, i + n + 1); + let ordered = ordered_pair(pair.0, pair.1); + EmaOracle::add_authorized_account(RawOrigin::Root.into(), source, pair, account).expect("error when adding authorized account"); + // Pre-populate `Oracles` rows for every supported period so the NEW cleanup path has + // the worst-case number of rows to clear. + for period in periods.iter().copied() { + pallet_ema_oracle::Oracles::::insert((source, ordered, period), (seed_entry.clone(), 0u32)); + } + } + }: _(RawOrigin::Root, source) + verify { + assert!(!pallet_ema_oracle::ExternalSources::::contains_key(source)); + // All per-source Oracles rows must be gone. + assert_eq!(pallet_ema_oracle::Oracles::::iter_prefix((source,)).count(), 0); + } + + add_authorized_account { + let source: Source = *b"newsrcxx"; + let pair: (AssetId, AssetId) = (0, 3); + EmaOracle::register_external_source(RawOrigin::Root.into(), source).expect("error when registering external source"); + }: _(RawOrigin::Root, source, pair, bifrost_account()) + verify { + let ordered = ordered_pair(pair.0, pair.1); + assert!(pallet_ema_oracle::AuthorizedAccounts::::contains_key((source, ordered, bifrost_account()))); + } + + remove_authorized_account { + let source: Source = *b"newsrcxx"; + let pair: (AssetId, AssetId) = (0, 3); + EmaOracle::register_external_source(RawOrigin::Root.into(), source).expect("error when registering external source"); + EmaOracle::add_authorized_account(RawOrigin::Root.into(), source, pair, bifrost_account()).expect("error when adding authorized account"); + }: _(RawOrigin::Root, source, pair, bifrost_account()) + verify { + let ordered = ordered_pair(pair.0, pair.1); + assert!(!pallet_ema_oracle::AuthorizedAccounts::::contains_key((source, ordered, bifrost_account()))); + } } #[cfg(test)] diff --git a/runtime/hydradx/src/migrations/mod.rs b/runtime/hydradx/src/migrations/mod.rs index bce66f6851..cf4edd684b 100644 --- a/runtime/hydradx/src/migrations/mod.rs +++ b/runtime/hydradx/src/migrations/mod.rs @@ -16,7 +16,8 @@ use crate::Runtime; // New migrations which need to be cleaned up after every Runtime upgrade -pub type UnreleasedSingleBlockMigrations = (); +pub type UnreleasedSingleBlockMigrations = + pallet_ema_oracle::migrations::v2::MigrateV1ToV2; // These migrations can run on every runtime upgrade pub type PermanentSingleBlockMigrations = pallet_xcm::migration::MigrateToLatestXcmVersion; diff --git a/runtime/hydradx/src/weights/pallet_ema_oracle.rs b/runtime/hydradx/src/weights/pallet_ema_oracle.rs index 0e49fea49c..fef13c79a4 100644 --- a/runtime/hydradx/src/weights/pallet_ema_oracle.rs +++ b/runtime/hydradx/src/weights/pallet_ema_oracle.rs @@ -19,7 +19,7 @@ //! Autogenerated weights for `pallet_ema_oracle` //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 -//! DATE: 2026-03-17, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-04-17, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `bench-bot`, CPU: `Intel(R) Core(TM) i7-7700K CPU @ 4.20GHz` //! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` @@ -30,7 +30,7 @@ // pallet // --wasm-execution=compiled // --pallet -// pallet_ema_oracle +// pallet-ema-oracle // --extrinsic // * // --heap-pages @@ -66,86 +66,71 @@ impl pallet_ema_oracle::WeightInfo for HydraWeight { // Proof Size summary in bytes: // Measured: `1872` // Estimated: `2126` - // Minimum execution time: 29_524_000 picoseconds. - Weight::from_parts(29_999_000, 2126) + // Minimum execution time: 29_147_000 picoseconds. + Weight::from_parts(29_556_000, 2126) .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } /// Storage: `EmaOracle::WhitelistedAssets` (r:1 w:1) /// Proof: `EmaOracle::WhitelistedAssets` (`max_values`: Some(1), `max_size`: Some(641), added: 1136, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Accumulator` (r:1 w:1) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) /// Storage: `EmaOracle::Oracles` (r:0 w:3) /// Proof: `EmaOracle::Oracles` (`max_values`: None, `max_size`: Some(194), added: 2669, mode: `MaxEncodedLen`) fn remove_oracle() -> Weight { // Proof Size summary in bytes: // Measured: `1888` - // Estimated: `8086` - // Minimum execution time: 47_892_000 picoseconds. - Weight::from_parts(48_472_000, 8086) - .saturating_add(T::DbWeight::get().reads(2_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + // Estimated: `2126` + // Minimum execution time: 46_471_000 picoseconds. + Weight::from_parts(47_285_000, 2126) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) } - /// Storage: `EmaOracle::Accumulator` (r:1 w:0) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) fn on_finalize_no_entry() -> Weight { // Proof Size summary in bytes: // Measured: `742` - // Estimated: `8086` - // Minimum execution time: 4_539_000 picoseconds. - Weight::from_parts(4_685_000, 8086) - .saturating_add(T::DbWeight::get().reads(1_u64)) + // Estimated: `0` + // Minimum execution time: 4_325_000 picoseconds. + Weight::from_parts(4_499_000, 0) } - /// Storage: `EmaOracle::Accumulator` (r:1 w:1) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Oracles` (r:117 w:117) + /// Storage: `EmaOracle::Oracles` (r:30 w:30) /// Proof: `EmaOracle::Oracles` (`max_values`: None, `max_size`: Some(194), added: 2669, mode: `MaxEncodedLen`) - /// The range of component `b` is `[1, 39]`. + /// The range of component `b` is `[1, 10]`. fn on_finalize_multiple_tokens(b: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1195 + b * (891 ±0)` - // Estimated: `8086 + b * (8007 ±0)` - // Minimum execution time: 74_080_000 picoseconds. - Weight::from_parts(32_223_799, 8086) - // Standard Error: 19_079 - .saturating_add(Weight::from_parts(42_980_904, 0).saturating_mul(b.into())) - .saturating_add(T::DbWeight::get().reads(1_u64)) + // Measured: `1206 + b * (890 ±0)` + // Estimated: `990 + b * (8007 ±0)` + // Minimum execution time: 72_570_000 picoseconds. + Weight::from_parts(32_479_579, 990) + // Standard Error: 19_402 + .saturating_add(Weight::from_parts(42_186_697, 0).saturating_mul(b.into())) .saturating_add(T::DbWeight::get().reads((3_u64).saturating_mul(b.into()))) - .saturating_add(T::DbWeight::get().writes(1_u64)) .saturating_add(T::DbWeight::get().writes((3_u64).saturating_mul(b.into()))) .saturating_add(Weight::from_parts(0, 8007).saturating_mul(b.into())) } /// Storage: `AssetRegistry::Assets` (r:2 w:0) /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Accumulator` (r:1 w:1) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) /// The range of component `b` is `[1, 39]`. fn on_trade_multiple_tokens(b: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1809 + b * (179 ±0)` - // Estimated: `8086` - // Minimum execution time: 34_792_000 picoseconds. - Weight::from_parts(35_808_121, 8086) - // Standard Error: 5_266 - .saturating_add(Weight::from_parts(685_980, 0).saturating_mul(b.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + // Measured: `3719 + b * (172 ±0)` + // Estimated: `6190` + // Minimum execution time: 42_568_000 picoseconds. + Weight::from_parts(43_759_780, 6190) + // Standard Error: 4_417 + .saturating_add(Weight::from_parts(556_769, 0).saturating_mul(b.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) } /// Storage: `AssetRegistry::Assets` (r:2 w:0) /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Accumulator` (r:1 w:1) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) /// The range of component `b` is `[1, 39]`. fn on_liquidity_changed_multiple_tokens(b: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1809 + b * (179 ±0)` - // Estimated: `8086` - // Minimum execution time: 34_829_000 picoseconds. - Weight::from_parts(35_897_109, 8086) - // Standard Error: 5_344 - .saturating_add(Weight::from_parts(684_032, 0).saturating_mul(b.into())) - .saturating_add(T::DbWeight::get().reads(3_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + // Measured: `3719 + b * (172 ±0)` + // Estimated: `6190` + // Minimum execution time: 42_786_000 picoseconds. + Weight::from_parts(43_613_978, 6190) + // Standard Error: 4_034 + .saturating_add(Weight::from_parts(558_459, 0).saturating_mul(b.into())) + .saturating_add(T::DbWeight::get().reads(2_u64)) } /// Storage: `EmaOracle::Oracles` (r:2 w:0) /// Proof: `EmaOracle::Oracles` (`max_values`: None, `max_size`: Some(194), added: 2669, mode: `MaxEncodedLen`) @@ -153,27 +138,118 @@ impl pallet_ema_oracle::WeightInfo for HydraWeight { // Proof Size summary in bytes: // Measured: `1546` // Estimated: `6328` - // Minimum execution time: 32_782_000 picoseconds. - Weight::from_parts(33_314_000, 6328) + // Minimum execution time: 32_984_000 picoseconds. + Weight::from_parts(33_460_000, 6328) .saturating_add(T::DbWeight::get().reads(2_u64)) } /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `AssetRegistry::LocationAssets` (r:1 w:0) /// Proof: `AssetRegistry::LocationAssets` (`max_values`: None, `max_size`: Some(622), added: 3097, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Oracles` (r:1 w:0) - /// Proof: `EmaOracle::Oracles` (`max_values`: None, `max_size`: Some(194), added: 2669, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::ExternalSources` (r:1 w:0) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:1 w:0) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) /// Storage: `AssetRegistry::Assets` (r:2 w:0) /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) - /// Storage: `EmaOracle::Accumulator` (r:1 w:1) - /// Proof: `EmaOracle::Accumulator` (`max_values`: Some(1), `max_size`: Some(6601), added: 7096, mode: `MaxEncodedLen`) fn update_bifrost_oracle() -> Weight { // Proof Size summary in bytes: - // Measured: `1924` - // Estimated: `8086` - // Minimum execution time: 54_421_000 picoseconds. - Weight::from_parts(55_183_000, 8086) + // Measured: `2055` + // Estimated: `6190` + // Minimum execution time: 60_048_000 picoseconds. + Weight::from_parts(60_606_000, 6190) + .saturating_add(T::DbWeight::get().reads(6_u64)) + } + /// Storage: `ParachainInfo::ParachainId` (r:1 w:0) + /// Proof: `ParachainInfo::ParachainId` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `AssetRegistry::LocationAssets` (r:1 w:0) + /// Proof: `AssetRegistry::LocationAssets` (`max_values`: None, `max_size`: Some(622), added: 3097, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::ExternalSources` (r:1 w:0) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:1 w:0) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + /// Storage: `AssetRegistry::Assets` (r:2 w:0) + /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) + fn set_external_oracle() -> Weight { + // Proof Size summary in bytes: + // Measured: `2055` + // Estimated: `6190` + // Minimum execution time: 60_034_000 picoseconds. + Weight::from_parts(60_678_000, 6190) .saturating_add(T::DbWeight::get().reads(6_u64)) + } + /// Storage: `EmaOracle::ExternalSources` (r:1 w:0) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:1 w:0) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + /// Storage: `AssetRegistry::Assets` (r:1 w:0) + /// Proof: `AssetRegistry::Assets` (`max_values`: None, `max_size`: Some(125), added: 2600, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::WhitelistedAssets` (r:1 w:0) + /// Proof: `EmaOracle::WhitelistedAssets` (`max_values`: Some(1), `max_size`: Some(641), added: 1136, mode: `MaxEncodedLen`) + fn set_external_oracle_by_ids() -> Weight { + // Proof Size summary in bytes: + // Measured: `2351` + // Estimated: `3590` + // Minimum execution time: 43_137_000 picoseconds. + Weight::from_parts(43_840_000, 3590) + .saturating_add(T::DbWeight::get().reads(4_u64)) + } + /// Storage: `EmaOracle::ExternalSources` (r:1 w:1) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + fn register_external_source() -> Weight { + // Proof Size summary in bytes: + // Measured: `1220` + // Estimated: `3481` + // Minimum execution time: 26_002_000 picoseconds. + Weight::from_parts(26_706_000, 3481) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `EmaOracle::ExternalSources` (r:1 w:1) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:40 w:40) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::Oracles` (r:120 w:120) + /// Proof: `EmaOracle::Oracles` (`max_values`: None, `max_size`: Some(194), added: 2669, mode: `MaxEncodedLen`) + /// The range of component `n` is `[0, 40]`. + fn remove_external_source(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `1331 + n * (216 ±0)` + // Estimated: `3481 + n * (8007 ±0)` + // Minimum execution time: 38_431_000 picoseconds. + Weight::from_parts(45_145_462, 3481) + // Standard Error: 8_749 + .saturating_add(Weight::from_parts(4_815_890, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().reads((4_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1_u64)) + .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 8007).saturating_mul(n.into())) + } + /// Storage: `EmaOracle::ExternalSources` (r:1 w:0) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:0 w:1) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + fn add_authorized_account() -> Weight { + // Proof Size summary in bytes: + // Measured: `1261` + // Estimated: `3481` + // Minimum execution time: 30_632_000 picoseconds. + Weight::from_parts(31_148_000, 3481) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `EmaOracle::ExternalSources` (r:1 w:0) + /// Proof: `EmaOracle::ExternalSources` (`max_values`: None, `max_size`: Some(16), added: 2491, mode: `MaxEncodedLen`) + /// Storage: `EmaOracle::AuthorizedAccounts` (r:0 w:1) + /// Proof: `EmaOracle::AuthorizedAccounts` (`max_values`: None, `max_size`: Some(72), added: 2547, mode: `MaxEncodedLen`) + fn remove_authorized_account() -> Weight { + // Proof Size summary in bytes: + // Measured: `1295` + // Estimated: `3481` + // Minimum execution time: 30_637_000 picoseconds. + Weight::from_parts(30_912_000, 3481) + .saturating_add(T::DbWeight::get().reads(1_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } } \ No newline at end of file diff --git a/scripts/init-testnet/chopsticks/fakeSignatureBifrost.js b/scripts/init-testnet/chopsticks/fakeSignatureBifrost.js new file mode 100644 index 0000000000..c4289da131 --- /dev/null +++ b/scripts/init-testnet/chopsticks/fakeSignatureBifrost.js @@ -0,0 +1,179 @@ +// Exercises the PRE-PR `emaOracle.updateBifrostOracle` path against a chopsticks fork. +// +// The old runtime gates this call with `BifrostOrigin = EnsureSignedBy`, so the +// caller must literally be the Bifrost sibling sovereign account. We use chopsticks' +// `signFakeWithApi` to bypass signature verification. No storage injection is needed because +// the `ExternalSources` / `AuthorizedAccounts` maps don't exist yet in the old runtime. +// +// The old code also has a 10% `is_within_range` guard that kicks in if a TenMinutes oracle +// entry already exists for (BIFROST_SOURCE, DOT/asset_15). We query the current entry and +// reuse its exact price to avoid tripping it. + +import {signFakeWithApi} from '@acala-network/chopsticks-utils'; +import {ApiPromise, WsProvider} from "@polkadot/api"; + +// Bifrost sovereign (sibling:2030) +const BIFROST_SOVEREIGN = "7LCt6dFs6sraSg31uKfbRH7soQ66GRb3LAkGZJ1ie3369crq"; + +// "bifrosto" as [u8; 8] +const BIFROST_SOURCE = "0x626966726f73746f"; + +// Asset whose price Bifrost feeds in production. 15 = vDOT on Hydration. +const QUOTE_ASSET_ID = 15; + +const main = async () => { + const uri = "ws://127.0.0.1:8000"; + const provider = new WsProvider(uri); + + // Same custom signed extensions as the PR script — needed so extrinsics encode + // against this runtime's metadata. + const api = await ApiPromise.create({ + provider, + signedExtensions: { + ValidateClaim: {extrinsic: {}, payload: {}}, + CheckMetadataHash: {extrinsic: {mode: "u8"}, payload: {}}, + StorageWeightReclaim: {extrinsic: {}, payload: {}}, + }, + }); + + // Instant block build so our tx lands in a block as soon as it's submitted. + await provider.send("dev_setBlockBuildMode", ["Instant"]); + + // Resolve the quote asset's XCM location from the asset registry. + const quoteLocationOpt = await api.query.assetRegistry.assetLocations(QUOTE_ASSET_ID); + if (quoteLocationOpt.isNone) { + console.error(`Asset ${QUOTE_ASSET_ID} has no XCM location registered`); + process.exit(1); + } + const quoteVersionedLocation = {V4: quoteLocationOpt.unwrap().toJSON()}; + // DOT = relay chain, Location::parent() + const dotVersionedLocation = {V4: {parents: 1, interior: "Here"}}; + + console.log("Asset location:", JSON.stringify(quoteVersionedLocation)); + + // Determine a price that passes the 10% range check: reuse the current TenMinutes + // entry's price if one already exists for this pair under BIFROST_SOURCE. + // + // Storage key uses the ORDERED pair (smaller asset id first). DOT (asset 5 on Hydration) + // < vDOT (15), so the ordered pair is (5, 15). + const DOT_ASSET_ID = 5; + const orderedPair = DOT_ASSET_ID < QUOTE_ASSET_ID + ? [DOT_ASSET_ID, QUOTE_ASSET_ID] + : [QUOTE_ASSET_ID, DOT_ASSET_ID]; + + const existing = await api.query.emaOracle.oracles( + BIFROST_SOURCE, + orderedPair, + 'TenMinutes', + ); + + let price; + let prevLastBlockUpdatedAt = null; + if (existing.isSome) { + const [entry] = existing.unwrap(); + const p = entry.price; + // price is EmaPrice { n, d } + price = [p.n.toString(), p.d.toString()]; + console.log("Reusing current TenMinutes price:", price); + } else { + price = ["1000000000000", "1000000000000"]; // 1.0 — no range check will apply + console.log("No existing oracle, using default price:", price); + } + + // Snapshot pre-tx LastBlock entry so we can detect the accumulator flush. + const prevLastBlock = await api.query.emaOracle.oracles( + BIFROST_SOURCE, + orderedPair, + 'LastBlock', + ); + if (prevLastBlock.isSome) { + const [entry] = prevLastBlock.unwrap(); + prevLastBlockUpdatedAt = entry.updatedAt.toNumber(); + console.log("Pre-tx LastBlock.updated_at:", prevLastBlockUpdatedAt); + } + + const tx = api.tx.emaOracle.updateBifrostOracle( + dotVersionedLocation, + quoteVersionedLocation, + price, + ); + + console.log("Fake-signing as Bifrost sovereign:", BIFROST_SOVEREIGN); + await signFakeWithApi(api, tx, BIFROST_SOVEREIGN); + + console.log("Submitting..."); + await new Promise((resolve, reject) => { + tx.send((result) => { + console.log("Status:", result.status.type); + if (result.dispatchError) { + const err = result.dispatchError; + if (err.isModule) { + const decoded = api.registry.findMetaError(err.asModule); + console.error(`DispatchError: ${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`); + } else { + console.error("DispatchError:", err.toString()); + } + reject(new Error("dispatch error")); + return; + } + if (result.status.isInBlock) { + console.log("Included in block:", result.status.asInBlock.toHex()); + for (const {event} of result.events) { + console.log(` event: ${event.section}.${event.method}`); + } + resolve(result); + } + }).catch(reject); + }); + + // === Post-tx verification ================================================== + // Prove the PR's v1->v2 migration actually seeded the new storages and the + // deprecated extrinsic's write-path landed. + + console.log("\n--- Post-tx storage verification ---"); + + // 1. ExternalSources[BIFROST_SOURCE] must exist (seeded by MigrateV1ToV2). + const extSrc = await api.query.emaOracle.externalSources(BIFROST_SOURCE); + console.log("ExternalSources[bifrosto]:", extSrc.isSome ? "PRESENT" : "MISSING"); + + // 2. AuthorizedAccounts[(BIFROST_SOURCE, orderedPair, bifrost_sovereign)] must exist. + // NMap with 3 keys: (Source, (AssetId, AssetId), AccountId). + const auth = await api.query.emaOracle.authorizedAccounts(BIFROST_SOURCE, orderedPair, BIFROST_SOVEREIGN); + console.log(`AuthorizedAccounts[bifrosto, (${orderedPair.join(',')}), ${BIFROST_SOVEREIGN}]:`, auth.isSome ? "PRESENT" : "MISSING"); + + // 3. The extrinsic should have pushed an entry into the accumulator, and + // `on_finalize` should have flushed it into LastBlock with the current + // block's `updated_at`. If `prevLastBlockUpdatedAt` was set, the new one + // must be strictly greater. + const newLastBlock = await api.query.emaOracle.oracles( + BIFROST_SOURCE, + orderedPair, + 'LastBlock', + ); + if (newLastBlock.isSome) { + const [entry] = newLastBlock.unwrap(); + const nowUpdatedAt = entry.updatedAt.toNumber(); + console.log("Post-tx LastBlock.updated_at:", nowUpdatedAt); + if (prevLastBlockUpdatedAt !== null) { + const advanced = nowUpdatedAt > prevLastBlockUpdatedAt; + console.log( + `LastBlock entry advanced: ${advanced ? "YES" : "NO"}`, + `(${prevLastBlockUpdatedAt} -> ${nowUpdatedAt})`, + ); + } + } else { + console.log("Post-tx LastBlock entry: MISSING"); + } + + const ok = extSrc.isSome && auth.isSome; + console.log("\nRESULT:", ok ? "PASS" : "FAIL"); + if (!ok) process.exitCode = 1; + + console.log("\nDONE"); + await api.disconnect(); +}; + +main().catch((e) => { + console.error(e); + process.exit(1); +});