diff --git a/Cargo.lock b/Cargo.lock index a796fbf00..1c8f152bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5787,7 +5787,7 @@ dependencies = [ [[package]] name = "hydradx-runtime" -version = "437.0.0" +version = "438.0.0" dependencies = [ "alloy-primitives 0.7.7", "alloy-sol-types 0.7.7", @@ -9153,7 +9153,7 @@ dependencies = [ [[package]] name = "pallet-dca" -version = "1.18.2" +version = "1.19.0" dependencies = [ "cumulus-pallet-parachain-system", "cumulus-primitives-core", @@ -13903,7 +13903,7 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "runtime-integration-tests" -version = "1.106.0" +version = "1.107.0" dependencies = [ "cumulus-pallet-aura-ext", "cumulus-pallet-parachain-system", diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index f714081cd..7855b8121 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runtime-integration-tests" -version = "1.106.0" +version = "1.107.0" description = "Integration tests" authors = ["GalacticCouncil"] edition = "2021" diff --git a/integration-tests/src/dca.rs b/integration-tests/src/dca.rs index 72dd9f8c2..aedcd234e 100644 --- a/integration-tests/src/dca.rs +++ b/integration-tests/src/dca.rs @@ -12,7 +12,7 @@ use hydradx_runtime::XYK; use hydradx_runtime::{AssetPairAccountIdFor, NamedReserveId}; use hydradx_runtime::{ AssetRegistry, Balances, Currencies, FeeProcessor, InsufficientEDinHDX, Omnipool, Router, Runtime, RuntimeEvent, - RuntimeOrigin, Stableswap, Tokens, Treasury, DCA, + RuntimeOrigin, Stableswap, System, Tokens, Treasury, DCA, }; use hydradx_traits::registry::{AssetKind, Create}; use hydradx_traits::router::AssetPair; @@ -23,7 +23,7 @@ use orml_traits::MultiCurrency; use orml_traits::MultiReservableCurrency; use orml_traits::NamedMultiReservableCurrency; use pallet_broadcast::types::*; -use pallet_dca::types::{Order, Schedule}; +use pallet_dca::types::{Order, Schedule, ScheduleId}; use pallet_omnipool::types::Tradability; use pallet_route_executor::MAX_NUMBER_OF_TRADES; use pallet_stableswap::MAX_ASSETS_IN_POOL; @@ -57,7 +57,7 @@ mod omnipool { go_to_block(block_id); let budget = 1000 * UNITS; - let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, 100 * UNITS, budget); + let schedule1 = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, budget, HDX, DAI, 100 * UNITS); //Act assert_ok!(DCA::schedule( @@ -84,6 +84,27 @@ mod omnipool { .into()]); }); } + + #[test] + fn create_schedule_should_fail_when_order_is_buy() { + TestNet::reset(); + Hydra::execute_with(|| { + //Arrange + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + + let budget = 1000 * UNITS; + let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, 100 * UNITS, budget); + + //Act and assert + assert_noop!( + DCA::schedule(RuntimeOrigin::signed(ALICE.into()), schedule1, None), + pallet_dca::Error::::NoLongerSupported + ); + assert!(DCA::schedules(0).is_none()); + }); + } + #[test] fn create_schedule_should_work_when_insufficient_asset_as_fee() { TestNet::reset(); @@ -136,7 +157,7 @@ mod omnipool { let budget = 50000 * UNITS; let schedule1 = - schedule_fake_with_buy_order(PoolType::XYK, insufficient_asset, DOT, 1000 * UNITS, budget); + schedule_fake_with_sell_order(ALICE, PoolType::XYK, budget, insufficient_asset, DOT, 1000 * UNITS); //Act assert_ok!(Currencies::update_balance( @@ -185,7 +206,7 @@ mod omnipool { let amount_out = 100 * UNITS; let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, amount_out, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), HDX, ALICE_INITIAL_NATIVE_BALANCE - dca_budget); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); @@ -217,7 +238,7 @@ mod omnipool { let amount_out = 100 * UNITS; let schedule_id = 0; let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, amount_out, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); //Act run_to_block(11, 12); @@ -327,7 +348,7 @@ mod omnipool { let amount_out = 100 * UNITS; let no_route = vec![]; let schedule1 = schedule_fake_with_buy_order_with_route(HDX, DAI, amount_out, dca_budget, no_route); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), HDX, ALICE_INITIAL_NATIVE_BALANCE - dca_budget); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); @@ -379,7 +400,7 @@ mod omnipool { }]), }, }; - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), HDX, ALICE_INITIAL_NATIVE_BALANCE - dca_budget); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); @@ -432,7 +453,7 @@ mod omnipool { let amount_out = 100 * UNITS; let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, LRNA, DAI, amount_out, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), LRNA, alice_init_hub_balance - dca_budget); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); @@ -469,7 +490,7 @@ mod omnipool { assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, amount_out, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); //Act go_to_block(12); @@ -553,7 +574,7 @@ mod omnipool { assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, LRNA, DAI, amount_out, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); //Act go_to_block(12); @@ -633,7 +654,7 @@ mod omnipool { let dca_budget = 1000 * UNITS; let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, 100 * UNITS, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); assert_balance!(ALICE.into(), HDX, ALICE_INITIAL_NATIVE_BALANCE - dca_budget); @@ -670,9 +691,10 @@ mod omnipool { let trade_size = 500 * UNITS; let dca_budget = 0; // rolling Balances::force_set_balance(RuntimeOrigin::root(), ALICE.into(), balance).unwrap(); - create_schedule( + insert_schedule_into_storage( ALICE, schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, trade_size, dca_budget), + None, ); let reserved = Balances::reserved_balance(&ALICE.into()); assert!(Balances::free_balance(&ALICE.into()) <= balance - reserved); @@ -689,26 +711,20 @@ mod omnipool { } #[test] - fn rolling_sell_dca_should_complete_gracefully_when_user_runs_out_of_funds() { + fn rolling_buy_dca_should_complete_gracefully_when_user_runs_out_of_funds() { TestNet::reset(); Hydra::execute_with(|| { //Arrange init_omnipool_with_oracle_for_block_10(); let balance = 5000 * UNITS; - let trade_size = 500 * UNITS; - let dca_budget = 0; // rolling DCA - takes all available balance + let trade_size = 500 * UNITS; // amount_out to buy + let dca_budget = 0; // rolling DCA Balances::force_set_balance(RuntimeOrigin::root(), ALICE.into(), balance).unwrap(); - - let sell_schedule = - schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, dca_budget, HDX, DAI, trade_size); - let fee = DCA::get_transaction_fee(&sell_schedule.order, None).unwrap(); - - let default_reserved_amount_for_rolling_dca = 2 * (trade_size + fee); - let not_enough_leftover_in_the_end = balance - 9 * (trade_size + fee); - - create_schedule(ALICE, sell_schedule); - assert_reserved_balance!(&ALICE.into(), HDX, default_reserved_amount_for_rolling_dca); - + insert_schedule_into_storage( + ALICE, + schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, trade_size, dca_budget), + None, + ); let dai_balance = Currencies::free_balance(DAI, &ALICE.into()); //Act - run until user runs out of funds @@ -717,31 +733,32 @@ mod omnipool { //Assert assert!(DCA::schedules(0).is_none()); assert!(Currencies::free_balance(DAI, &ALICE.into()) > dai_balance); - //The initial reserved amount + the last trade size minus for rolling DCA should be returned to free balance as it failed with FundsUnavailable error - let fee_in_last_failing_round = fee; - assert_eq!( - Currencies::free_balance(HDX, &ALICE.into()), - default_reserved_amount_for_rolling_dca + not_enough_leftover_in_the_end - fee_in_last_failing_round - ); assert_reserved_balance!(&ALICE.into(), HDX, 0); check_if_dcas_completed_without_failed_or_terminated_events(); }); } #[test] - fn rolling_buy_dca_should_complete_gracefully_when_user_runs_out_of_funds() { + fn rolling_sell_dca_should_complete_gracefully_when_user_runs_out_of_funds() { TestNet::reset(); Hydra::execute_with(|| { //Arrange init_omnipool_with_oracle_for_block_10(); let balance = 5000 * UNITS; - let trade_size = 500 * UNITS; // amount_out to buy - let dca_budget = 0; // rolling DCA + let trade_size = 500 * UNITS; + let dca_budget = 0; // rolling DCA - takes all available balance Balances::force_set_balance(RuntimeOrigin::root(), ALICE.into(), balance).unwrap(); - create_schedule( - ALICE, - schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, trade_size, dca_budget), - ); + + let sell_schedule = + schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, dca_budget, HDX, DAI, trade_size); + let fee = DCA::get_transaction_fee(&sell_schedule.order, None).unwrap(); + + let default_reserved_amount_for_rolling_dca = 2 * (trade_size + fee); + let not_enough_leftover_in_the_end = balance - 9 * (trade_size + fee); + + create_schedule(ALICE, sell_schedule); + assert_reserved_balance!(&ALICE.into(), HDX, default_reserved_amount_for_rolling_dca); + let dai_balance = Currencies::free_balance(DAI, &ALICE.into()); //Act - run until user runs out of funds @@ -750,6 +767,12 @@ mod omnipool { //Assert assert!(DCA::schedules(0).is_none()); assert!(Currencies::free_balance(DAI, &ALICE.into()) > dai_balance); + //The initial reserved amount + the last trade size minus for rolling DCA should be returned to free balance as it failed with FundsUnavailable error + let fee_in_last_failing_round = fee; + assert_eq!( + Currencies::free_balance(HDX, &ALICE.into()), + default_reserved_amount_for_rolling_dca + not_enough_leftover_in_the_end - fee_in_last_failing_round + ); assert_reserved_balance!(&ALICE.into(), HDX, 0); check_if_dcas_completed_without_failed_or_terminated_events(); }); @@ -1137,7 +1160,7 @@ mod omnipool { let budget = 5000 * UNITS; let schedule1 = - schedule_fake_with_buy_order(PoolType::XYK, insufficient_asset, DOT, 100 * UNITS, budget); + schedule_fake_with_sell_order(ALICE, PoolType::XYK, budget, insufficient_asset, DOT, 100 * UNITS); //Act assert_ok!(Currencies::update_balance( @@ -3152,7 +3175,7 @@ mod stableswap { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); assert_balance!(ALICE.into(), HDX, alice_init_hdx_balance - dca_budget); assert_balance!(ALICE.into(), stable_asset_1, 0); @@ -3221,7 +3244,7 @@ mod stableswap { ); go_to_block(12); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), asset_a, alice_init_asset_a_balance - dca_budget); assert_balance!(ALICE.into(), asset_b, 0); @@ -3333,7 +3356,7 @@ mod stableswap { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); assert_balance!(ALICE.into(), stable_asset_1, alice_init_stable1_balance - dca_budget); assert_balance!(ALICE.into(), HDX, ALICE_INITIAL_NATIVE_BALANCE); @@ -3449,7 +3472,7 @@ mod stableswap { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); let alice_hdx_before = Currencies::free_balance(HDX, &ALICE.into()); assert_balance!(ALICE.into(), stable_asset_1, alice_init_stable1_balance - dca_budget); @@ -3571,7 +3594,7 @@ mod stableswap { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); let alice_hdx_before = Currencies::free_balance(HDX, &ALICE.into()); @@ -3702,7 +3725,7 @@ mod stableswap { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); let alice_pool_id_before = Currencies::free_balance(pool_id, &ALICE.into()); assert_eq!(alice_pool_id_before, 0, "ALICE should start with 0 pool_id shares"); @@ -3729,6 +3752,8 @@ mod stableswap { }); } + /// It fails because stable share is locked in an intermediary trade, + /// and in the next hop the user has not enough balance to continue the trade #[test] fn sell_should_be_retried_when_stableshare_is_in_lockdown() { TestNet::reset(); @@ -3965,7 +3990,6 @@ mod xyk { assert_reserved_balance!(&ALICE.into(), HDX, dca_budget - amount_to_sell - fee); }); } - #[test] fn buy_should_work_for_xyk() { TestNet::reset(); @@ -4001,7 +4025,7 @@ mod xyk { let dca_budget = 1100 * UNITS; let amount_to_buy = 150 * UNITS; let schedule1 = schedule_fake_with_buy_order(PoolType::XYK, HDX, DAI, amount_to_buy, dca_budget); - create_schedule(ALICE, schedule1); + insert_schedule_into_storage(ALICE, schedule1, None); assert_balance!(ALICE.into(), HDX, alice_init_hdx_balance - dca_budget); assert_balance!(ALICE.into(), DAI, ALICE_INITIAL_DAI_BALANCE); @@ -4271,7 +4295,7 @@ mod with_onchain_route { }, }; - create_schedule(ALICE, schedule); + insert_schedule_into_storage(ALICE, schedule, None); assert_balance!(ALICE.into(), HDX, alice_init_hdx_balance - dca_budget); assert_balance!(ALICE.into(), stable_asset_1, 0); @@ -4782,7 +4806,7 @@ fn terminate_should_work_for_freshly_created_dca() { go_to_block(block_id); let budget = 1000 * UNITS; - let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, 100 * UNITS, budget); + let schedule1 = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, budget, HDX, DAI, 100 * UNITS); assert_ok!(DCA::schedule( RuntimeOrigin::signed(ALICE.into()), @@ -4814,7 +4838,7 @@ fn unlock_should_not_work_when_user_has_active_schedule() { go_to_block(block_id); let budget = 1000 * UNITS; - let schedule1 = schedule_fake_with_buy_order(PoolType::Omnipool, HDX, DAI, 100 * UNITS, budget); + let schedule1 = schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, budget, HDX, DAI, 100 * UNITS); assert_ok!(DCA::schedule( RuntimeOrigin::signed(ALICE.into()), @@ -4865,6 +4889,135 @@ fn unclock_should_work_when_user_has_leftover() { }); } +/// Guards `insert_schedule_into_storage` against drift from the `schedule` extrinsic: an order the +/// extrinsic still accepts must land in identical state either way. +mod storage_injection_fidelity { + use super::*; + + #[test] + fn storage_injected_schedule_should_match_extrinsic_created_schedule() { + TestNet::reset(); + Hydra::execute_with(|| { + //Arrange + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + fund_alice_for_two_schedules(); + + let schedule = + schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, 1000 * UNITS, HDX, DAI, 100 * UNITS); + + //Act + create_schedule(ALICE, schedule.clone()); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE.into()); + let injected_id = insert_schedule_into_storage(ALICE, schedule, None); + + //Assert + assert_schedules_are_identical(0, injected_id, HDX, reserved_by_extrinsic); + }); + } + + #[test] + fn storage_injected_rolling_schedule_should_match_extrinsic_created_schedule() { + TestNet::reset(); + Hydra::execute_with(|| { + //Arrange + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + fund_alice_for_two_schedules(); + + let rolling_budget = 0; + let schedule = + schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, rolling_budget, HDX, DAI, 500 * UNITS); + + //Act + create_schedule(ALICE, schedule.clone()); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE.into()); + let injected_id = insert_schedule_into_storage(ALICE, schedule, None); + + //Assert + assert_schedules_are_identical(0, injected_id, HDX, reserved_by_extrinsic); + }); + } + + #[test] + fn storage_injected_schedule_should_match_extrinsic_created_schedule_when_start_block_is_given() { + TestNet::reset(); + Hydra::execute_with(|| { + //Arrange + init_omnipool_with_oracle_for_block_10(); + go_to_block(11); + fund_alice_for_two_schedules(); + + let schedule = + schedule_fake_with_sell_order(ALICE, PoolType::Omnipool, 1000 * UNITS, HDX, DAI, 100 * UNITS); + let start_execution_block = 22; + + //Act + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE.into()), + schedule.clone(), + Some(start_execution_block) + )); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE.into()); + let injected_id = insert_schedule_into_storage(ALICE, schedule, Some(start_execution_block)); + + //Assert + assert_eq!(DCA::schedule_execution_block(injected_id), Some(25)); + assert_schedules_are_identical(0, injected_id, HDX, reserved_by_extrinsic); + }); + } + + /// Both schedules reserve the full budget, which exceeds ALICE's default balance. + fn fund_alice_for_two_schedules() { + assert_ok!(Balances::force_set_balance( + RuntimeOrigin::root(), + ALICE.into(), + 20_000 * UNITS + )); + } + + fn assert_schedules_are_identical( + extrinsic_id: ScheduleId, + injected_id: ScheduleId, + reserve_asset: AssetId, + reserved_by_extrinsic: Balance, + ) { + assert_eq!(injected_id, extrinsic_id + 1); + assert_eq!(DCA::schedules(injected_id), DCA::schedules(extrinsic_id)); + assert_eq!( + DCA::owner_of(AccountId::from(ALICE), injected_id), + DCA::owner_of(AccountId::from(ALICE), extrinsic_id) + ); + assert_eq!( + DCA::remaining_amounts(injected_id), + DCA::remaining_amounts(extrinsic_id) + ); + assert_eq!(DCA::retries_on_error(injected_id), DCA::retries_on_error(extrinsic_id)); + assert_eq!( + DCA::schedule_extra_gas(injected_id), + DCA::schedule_extra_gas(extrinsic_id) + ); + + let execution_block = DCA::schedule_execution_block(extrinsic_id).unwrap(); + assert_eq!(DCA::schedule_execution_block(injected_id), Some(execution_block)); + assert_eq!( + DCA::schedule_ids_per_block(execution_block).to_vec(), + vec![extrinsic_id, injected_id] + ); + + assert_reserved_balance!(&AccountId::from(ALICE), reserve_asset, 2 * reserved_by_extrinsic); + + let events = last_hydra_events(10); + assert!(events + .iter() + .any(|e| matches!(e, RuntimeEvent::DCA(pallet_dca::Event::Scheduled { id, .. }) if *id == injected_id))); + assert!(events.iter().any( + |e| matches!(e, RuntimeEvent::DCA(pallet_dca::Event::ExecutionPlanned { id, block, .. }) + if *id == injected_id && *block == execution_block) + )); + } +} + mod aave_atoken { use super::*; use hydradx_runtime::DCA; @@ -5077,6 +5230,88 @@ pub fn create_schedule(owner: [u8; 32], schedule1: Schedule, + start_execution_block: Option, +) -> ScheduleId { + let who: AccountId = owner.into(); + assert_eq!(schedule.owner, who, "owner must match the schedule owner"); + + let asset_in = schedule.order.get_asset_in(); + let route = schedule.order.get_route_or_default::(); + let amount_in = match schedule.order { + Order::Sell { amount_in, .. } => amount_in, + Order::Buy { amount_out, .. } => Router::calculate_expected_amount_in(&route, amount_out).unwrap(), + }; + let transaction_fee = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + + let reserve_amount = if schedule.is_rolling() { + amount_in.saturating_add(transaction_fee).saturating_mul(2) + } else { + schedule.total_amount + }; + + let schedule_id = pallet_dca::ScheduleIdSequencer::::mutate(|current_id| { + let schedule_id = *current_id; + *current_id += 1; + schedule_id + }); + + pallet_dca::Schedules::::insert(schedule_id, &schedule); + pallet_dca::ScheduleOwnership::::insert(&who, schedule_id, ()); + pallet_dca::RemainingAmounts::::insert(schedule_id, reserve_amount); + pallet_dca::RetriesOnError::::insert(schedule_id, 0); + + assert_ok!(Currencies::reserve_named( + &NamedReserveId::get(), + asset_in, + &who, + reserve_amount + )); + + let execution_block = first_execution_block(start_execution_block); + pallet_dca::ScheduleIdsPerBlock::::mutate(execution_block, |schedule_ids| { + schedule_ids.try_push(schedule_id).expect("execution block is full"); + }); + pallet_dca::ScheduleExecutionBlock::::insert(schedule_id, execution_block); + + System::deposit_event(RuntimeEvent::DCA(pallet_dca::Event::Scheduled { + id: schedule_id, + who: who.clone(), + period: schedule.period, + total_amount: schedule.total_amount, + order: schedule.order, + })); + System::deposit_event(RuntimeEvent::DCA(pallet_dca::Event::ExecutionPlanned { + id: schedule_id, + who, + block: execution_block, + })); + + schedule_id +} + +/// Mirrors `pallet_dca::Pallet::get_first_execution_block`. +fn first_execution_block(start_execution_block: Option) -> BlockNumber { + let next_block = System::block_number().saturating_add(2); + match start_execution_block { + Some(block) => { + let number = next_block.max(block); + match number % 5 { + 0 => number, + remainder => number.saturating_add(5 - remainder), + } + } + None => next_block, + } +} + fn schedule_fake_with_buy_order( pool: PoolType, asset_in: AssetId, @@ -5084,19 +5319,26 @@ fn schedule_fake_with_buy_order( amount: Balance, budget: Balance, ) -> Schedule { - schedule_fake_with_buy_order_with_route( - asset_in, - asset_out, - amount, - budget, - vec![Trade { - pool, + Schedule { + owner: AccountId::from(ALICE), + period: 5u32, + total_amount: budget, + max_retries: None, + stability_threshold: None, + slippage: Some(Permill::from_percent(10)), + order: Order::Buy { asset_in, asset_out, - }], - ) + amount_out: amount, + max_amount_in: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool, + asset_in, + asset_out, + }]), + }, + } } - fn schedule_fake_with_buy_order_with_route( asset_in: AssetId, asset_out: AssetId, @@ -6193,7 +6435,7 @@ fn rolling_buy_dca_completes_prematurely_when_price_increases() { }, }; - create_schedule(ALICE, rolling_buy_schedule); + insert_schedule_into_storage(ALICE, rolling_buy_schedule, None); let schedule_id = 0; diff --git a/pallets/dca/Cargo.toml b/pallets/dca/Cargo.toml index 91ee6eaae..45aedea4e 100644 --- a/pallets/dca/Cargo.toml +++ b/pallets/dca/Cargo.toml @@ -1,6 +1,6 @@ [package] name = 'pallet-dca' -version = "1.18.2" +version = "1.19.0" description = 'A pallet to manage DCA scheduling' authors = ['GalacticCouncil'] edition = '2021' diff --git a/pallets/dca/src/lib.rs b/pallets/dca/src/lib.rs index cc7b6d441..652aa4a4f 100644 --- a/pallets/dca/src/lib.rs +++ b/pallets/dca/src/lib.rs @@ -424,6 +424,8 @@ pub mod pallet { HasActiveSchedules, ///No reserves are locked for the user for the given asset NoReservesLocked, + ///Buy orders can no longer be scheduled. Existing buy schedules keep executing. + NoLongerSupported, } /// Id sequencer for schedules @@ -477,6 +479,10 @@ pub mod pallet { /// Creates a new DCA (Dollar-Cost Averaging) schedule and plans the next execution /// for the specified block. /// + /// Only `Order::Sell` schedules can be created. Buy orders are rejected with + /// `NoLongerSupported`; buy schedules already stored keep executing until they complete + /// or are terminated. + /// /// If the block is not specified, the execution is planned for the next block. /// If the given block is full, the execution will be planned in the subsequent block. /// @@ -500,16 +506,22 @@ pub mod pallet { /// Emits `Scheduled` and `ExecutionPlanned` event when successful. /// #[pallet::call_index(0)] - #[pallet::weight(::WeightInfo::schedule() - + ::AmmTradeWeights::calculate_buy_trade_amounts_weight(&schedule.order.get_route_or_default::()))] + #[pallet::weight(::WeightInfo::schedule())] #[transactional] pub fn schedule( origin: OriginFor, schedule: Schedule>, start_execution_block: Option>, ) -> DispatchResult { - let who = ensure_signed(origin.clone())?; + let who = ensure_signed(origin)?; ensure!(who == schedule.owner, Error::::Forbidden); + // Buy execution stays live for schedules stored before this restriction, so the + // benchmarks that measure it still need to create one. + #[cfg(not(feature = "runtime-benchmarks"))] + ensure!( + matches!(schedule.order, Order::Sell { .. }), + Error::::NoLongerSupported + ); let min_budget = Self::convert_native_amount_to_currency( schedule.order.get_asset_in(), diff --git a/pallets/dca/src/tests/mod.rs b/pallets/dca/src/tests/mod.rs index 56ed2b4ae..d7981dbe1 100644 --- a/pallets/dca/src/tests/mod.rs +++ b/pallets/dca/src/tests/mod.rs @@ -1,13 +1,20 @@ use crate::tests::mock::*; -use crate::{Balance, Order, Schedule, ScheduleId}; +use crate::{ + Balance, Event, Order, RemainingAmounts, RetriesOnError, Schedule, ScheduleExecutionBlock, ScheduleId, + ScheduleIdSequencer, ScheduleIdsPerBlock, ScheduleOwnership, Schedules, +}; +use frame_support::assert_ok; use hydradx_traits::router::PoolType; +use hydradx_traits::router::RouterT; use hydradx_traits::router::Trade; +use orml_traits::NamedMultiReservableCurrency; use sp_runtime::traits::ConstU32; use sp_runtime::{BoundedVec, Permill}; pub mod mock; pub mod on_initialize; pub mod schedule; +pub mod storage_injection_fidelity; pub mod terminate; pub mod unlock_reserves; @@ -37,11 +44,11 @@ impl ScheduleBuilder { slippage: Some(None), total_amount: Some(1000 * ONE), max_retries: Some(None), - order: Some(Order::Buy { + order: Some(Order::Sell { asset_in: HDX, asset_out: BTC, - amount_out: ONE, - max_amount_in: 2 * ONE, + amount_in: 10 * ONE, + min_amount_out: 0, route: create_bounded_vec(vec![Trade { pool: PoolType::Omnipool, asset_in: HDX, @@ -99,6 +106,93 @@ impl ScheduleBuilder { } } +/// Stores a schedule by writing exactly the state the `schedule` extrinsic writes. +/// +/// Buy orders can no longer be scheduled, but buy schedules stored before that restriction keep +/// executing, so their execution still needs coverage. Fidelity against the extrinsic is pinned by +/// the tests in `storage_injection_fidelity`. +pub fn insert_schedule_into_storage( + who: AccountId, + schedule: Schedule, + start_execution_block: Option, +) -> ScheduleId { + assert_eq!(schedule.owner, who, "owner must match the schedule owner"); + + let asset_in = schedule.order.get_asset_in(); + let route = schedule.order.get_route_or_default::(); + let amount_in = match schedule.order { + Order::Sell { amount_in, .. } => amount_in, + Order::Buy { amount_out, .. } => { + RouteExecutor::calculate_buy_trade_amounts(&route, amount_out) + .unwrap() + .last() + .unwrap() + .amount_in + } + }; + let transaction_fee = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + + let reserve_amount = if schedule.is_rolling() { + amount_in.saturating_add(transaction_fee).saturating_mul(2) + } else { + schedule.total_amount + }; + + let schedule_id = ScheduleIdSequencer::::mutate(|current_id| { + let schedule_id = *current_id; + *current_id += 1; + schedule_id + }); + + Schedules::::insert(schedule_id, &schedule); + ScheduleOwnership::::insert(who, schedule_id, ()); + RemainingAmounts::::insert(schedule_id, reserve_amount); + RetriesOnError::::insert(schedule_id, 0); + + assert_ok!(Currencies::reserve_named( + &NamedReserveId::get(), + asset_in, + &who, + reserve_amount + )); + + let execution_block = first_execution_block(start_execution_block); + ScheduleIdsPerBlock::::mutate(execution_block, |schedule_ids| { + schedule_ids.try_push(schedule_id).expect("execution block is full"); + }); + ScheduleExecutionBlock::::insert(schedule_id, execution_block); + + System::deposit_event(RuntimeEvent::DCA(Event::Scheduled { + id: schedule_id, + who, + period: schedule.period, + total_amount: schedule.total_amount, + order: schedule.order, + })); + System::deposit_event(RuntimeEvent::DCA(Event::ExecutionPlanned { + id: schedule_id, + who, + block: execution_block, + })); + + schedule_id +} + +/// Mirrors `crate::Pallet::get_first_execution_block`. +fn first_execution_block(start_execution_block: Option) -> BlockNumber { + let next_block = System::block_number().saturating_add(2); + match start_execution_block { + Some(block) => { + let number = next_block.max(block); + match number % 5 { + 0 => number, + remainder => number.saturating_add(5 - remainder), + } + } + None => next_block, + } +} + pub fn create_bounded_vec(trades: Vec>) -> BoundedVec, ConstU32<9>> { let bounded_vec: BoundedVec, sp_runtime::traits::ConstU32<9>> = trades.try_into().unwrap(); bounded_vec diff --git a/pallets/dca/src/tests/on_initialize.rs b/pallets/dca/src/tests/on_initialize.rs index f36677864..01ac4d3b7 100644 --- a/pallets/dca/src/tests/on_initialize.rs +++ b/pallets/dca/src/tests/on_initialize.rs @@ -117,7 +117,7 @@ fn successful_buy_dca_execution_should_emit_trade_executed_event() { }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); //Act set_to_blocknumber(502); @@ -359,11 +359,7 @@ fn one_buy_dca_execution_should_unreserve_exact_amount_in() { }) .build(); - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); + insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); //Act @@ -422,11 +418,7 @@ fn one_buy_dca_execution_should_calculate_exact_amount_in_when_multiple_pools_in }) .build(); - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); + insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); //Act @@ -611,7 +603,7 @@ fn full_buy_should_be_completed_when_with_default_routes() { }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); //Act @@ -660,7 +652,7 @@ fn full_buy_dca_should_be_completed_when_some_successful_dca_execution_happened_ .with_order(order) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); //Act @@ -924,7 +916,7 @@ fn full_buy_dca_should_be_completed_when_some_execution_is_successful_but_not_en }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); assert_balance!(ALICE, HDX, alice_init_hdx_balance - total_amount); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); @@ -981,7 +973,7 @@ fn full_buy_dca_should_be_completed_without_leftover_fees_are_included_in_budget .with_order(order) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); assert_balance!(ALICE, HDX, alice_init_hdx_balance - total_amount); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); @@ -1027,7 +1019,7 @@ fn one_buy_dca_execution_should_use_default_max_price_diff_for_max_limit_calcula }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); //Act @@ -1053,44 +1045,6 @@ fn nothing_should_happen_when_no_schedule_in_storage_for_block() { }); } -#[test] -fn schedule_is_planned_for_next_block_when_one_execution_finished() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let schedule = ScheduleBuilder::new() - .with_period(ONE_HUNDRED_BLOCKS) - .with_slippage(Some(Permill::from_percent(20))) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: 10 * ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); - - //Act - set_to_blocknumber(502); - - //Assert - assert_number_of_executed_buy_trades!(1); - - let schedule_id = 0; - assert_scheduled_ids!(602, vec![schedule_id]); - }); -} - #[test] fn schedule_is_planned_with_period_when_block_has_already_planned_schedule() { ExtBuilder::default() @@ -1162,7 +1116,7 @@ fn buy_dca_schedule_should_be_retried_when_trade_limit_error_happens() { }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); set_to_blocknumber(502); @@ -1243,59 +1197,6 @@ fn sell_dca_schedule_should_be_retried_when_trade_limit_error_happens() { }); } -#[test] -fn dca_trade_unallocation_should_be_rolled_back_when_trade_fails() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let total_amount = 1000 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_period(ONE_HUNDRED_BLOCKS) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY, - max_amount_in: 5 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - let schedule_id = 0; - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - assert_eq!(Currencies::reserved_balance(HDX, &ALICE), total_amount); - assert_eq!(DCA::remaining_amounts(schedule_id).unwrap(), total_amount); - - set_to_blocknumber(502); - - assert_number_of_executed_buy_trades!(0); - assert_scheduled_ids!(522, vec![schedule_id]); - - let buy_fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - assert_eq!( - Currencies::reserved_balance(HDX, &ALICE), - total_amount - buy_fee_in_native - ); - assert_eq!( - DCA::remaining_amounts(schedule_id).unwrap(), - total_amount - buy_fee_in_native - ); - }); -} - #[test] fn dca_schedule_should_terminate_when_error_is_not_configured_to_continue_on() { ExtBuilder::default() @@ -1328,7 +1229,7 @@ fn dca_schedule_should_terminate_when_error_is_not_configured_to_continue_on() { //Assert let schedule_id = 0; - assert_number_of_executed_buy_trades!(0); + assert_number_of_executed_sell_trades!(0); assert!(DCA::schedule_ids_per_block(602).is_empty()); assert_that_dca_is_terminated(ALICE, schedule_id, pallet_omnipool::Error::::NotAllowed.into()); }); @@ -1381,104 +1282,6 @@ fn dca_schedule_should_retry_when_error_is_configured_to_retry_on() { }); } -#[test] -fn dca_schedule_should_continue_on_multiple_failures_then_terminated() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let schedule = ScheduleBuilder::new() - .with_period(ONE_HUNDRED_BLOCKS) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY, - max_amount_in: 5 * ONE, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); - - //Act and assert - let schedule_id = 0; - set_to_blocknumber(502); - assert_scheduled_ids!(522, vec![schedule_id]); - - set_to_blocknumber(522); - assert_scheduled_ids!(562, vec![schedule_id]); - - set_to_blocknumber(562); - assert_scheduled_ids!(642, vec![schedule_id]); - - set_to_blocknumber(642); - assert!(DCA::schedules(schedule_id).is_none()); - assert_number_of_executed_buy_trades!(0); - }); -} - -#[test] -fn dca_schedule_should_use_specified_max_retry_count() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - let max_retries = Some(5); - - let schedule = ScheduleBuilder::new() - .with_period(ONE_HUNDRED_BLOCKS) - .with_max_retries(max_retries) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY, - max_amount_in: 5 * ONE, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); - - //Act and assert - let schedule_id = 0; - set_to_blocknumber(502); - assert_scheduled_ids!(522, vec![schedule_id]); - - set_to_blocknumber(522); - assert_scheduled_ids!(562, vec![schedule_id]); - - set_to_blocknumber(562); - assert_scheduled_ids!(642, vec![schedule_id]); - - set_to_blocknumber(642); - assert_scheduled_ids!(802, vec![schedule_id]); - let retries = DCA::retries_on_error(schedule_id); - assert_eq!(4, retries); - - set_to_blocknumber(802); - assert_scheduled_ids!(1122, vec![schedule_id]); - let retries = DCA::retries_on_error(schedule_id); - assert_eq!(5, retries); - - set_to_blocknumber(1122); - assert!(DCA::schedules(schedule_id).is_none()); - assert_number_of_executed_buy_trades!(0); - }); -} - #[test] fn buy_dca_schedule_should_continue_on_slippage_error() { ExtBuilder::default() @@ -1503,7 +1306,7 @@ fn buy_dca_schedule_should_continue_on_slippage_error() { }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); //Act and assert let schedule_id = 0; @@ -1605,24 +1408,26 @@ fn dca_schedule_retry_should_be_reset_when_successful_trade_after_failed_ones() } #[test] -fn execution_fee_should_be_taken_from_user_in_sold_currency_in_case_of_successful_buy_trade() { +fn execution_fee_should_be_taken_from_user_in_sold_currency_in_case_of_successful_sell_trade() { + let alice_init_native_balance = 5000 * ONE; ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE), (ALICE, DAI, 5000 * ONE)]) + .with_endowed_accounts(vec![(ALICE, HDX, alice_init_native_balance), (ALICE, DAI, 5000 * ONE)]) .build() .execute_with(|| { //Arrange proceed_to_blocknumber(1, 500); + let amount_in = *AMOUNT_OUT_FOR_OMNIPOOL_SELL; + let budget = 1000 * ONE; let schedule = ScheduleBuilder::new() .with_period(ONE_HUNDRED_BLOCKS) .with_total_amount(budget) - .with_slippage(Some(Permill::from_percent(20))) - .with_order(Order::Buy { + .with_order(Order::Sell { asset_in: DAI, asset_out: BTC, - amount_out: 10 * ONE, - max_amount_in: 50 * ONE, + amount_in, + min_amount_out: Balance::MIN, route: create_bounded_vec(vec![Trade { pool: Omnipool, asset_in: DAI, @@ -1639,41 +1444,44 @@ fn execution_fee_should_be_taken_from_user_in_sold_currency_in_case_of_successfu //Act assert_balance!(TreasuryAccount::get(), DAI, 0); + set_to_blocknumber(502); //Assert - let buy_fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - assert_balance!(TreasuryAccount::get(), DAI, buy_fee_in_dai); - assert_number_of_executed_buy_trades!(1); + let sell_fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + assert_balance!(TreasuryAccount::get(), DAI, sell_fee_in_dai); assert_eq!( Currencies::reserved_balance(DAI, &ALICE), - budget - CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY - buy_fee_in_dai + budget - amount_in - sell_fee_in_dai ); - assert_balance!(ALICE, BTC, CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY); + assert_balance!(ALICE, BTC, *AMOUNT_OUT_FOR_OMNIPOOL_SELL); + assert_number_of_executed_sell_trades!(1); }); } #[test] -fn execution_fee_should_be_still_taken_from_user_in_sold_currency_in_case_of_failed_trade() { +fn sell_dca_native_execution_fee_should_be_taken_and_sent_to_treasury() { ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE), (ALICE, DAI, 5000 * ONE)]) + .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) .build() .execute_with(|| { //Arrange proceed_to_blocknumber(1, 500); - let budget = 1000 * ONE; + let total_amount = 3 * *AMOUNT_OUT_FOR_OMNIPOOL_SELL; + let amount_to_sell = *AMOUNT_OUT_FOR_OMNIPOOL_SELL; + let schedule = ScheduleBuilder::new() + .with_total_amount(total_amount) .with_period(ONE_HUNDRED_BLOCKS) - .with_total_amount(budget) - .with_order(Order::Buy { - asset_in: DAI, + .with_order(Order::Sell { + asset_in: HDX, asset_out: BTC, - amount_out: CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY, - max_amount_in: 5 * ONE, + amount_in: amount_to_sell, + min_amount_out: Balance::MIN, route: create_bounded_vec(vec![Trade { pool: Omnipool, - asset_in: DAI, + asset_in: HDX, asset_out: BTC, }]), }) @@ -1684,108 +1492,8 @@ fn execution_fee_should_be_still_taken_from_user_in_sold_currency_in_case_of_fai schedule.clone(), Option::None )); - - //Act - assert_balance!(TreasuryAccount::get(), DAI, 0); - assert_balance!(ALICE, BTC, 0); - - set_to_blocknumber(502); - - //Assert - let fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - assert_balance!(TreasuryAccount::get(), DAI, fee_in_dai); - assert_number_of_executed_buy_trades!(0); - assert_eq!(Currencies::reserved_balance(DAI, &ALICE), budget - fee_in_dai); - }); -} - -#[test] -fn execution_fee_should_be_taken_from_user_in_sold_currency_in_case_of_successful_sell_trade() { - let alice_init_native_balance = 5000 * ONE; - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, alice_init_native_balance), (ALICE, DAI, 5000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let amount_in = *AMOUNT_OUT_FOR_OMNIPOOL_SELL; - - let budget = 1000 * ONE; - let schedule = ScheduleBuilder::new() - .with_period(ONE_HUNDRED_BLOCKS) - .with_total_amount(budget) - .with_order(Order::Sell { - asset_in: DAI, - asset_out: BTC, - amount_in, - min_amount_out: Balance::MIN, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: DAI, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - - //Act - assert_balance!(TreasuryAccount::get(), DAI, 0); - - set_to_blocknumber(502); - - //Assert - let sell_fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - assert_balance!(TreasuryAccount::get(), DAI, sell_fee_in_dai); - assert_eq!( - Currencies::reserved_balance(DAI, &ALICE), - budget - amount_in - sell_fee_in_dai - ); - assert_balance!(ALICE, BTC, *AMOUNT_OUT_FOR_OMNIPOOL_SELL); - assert_number_of_executed_sell_trades!(1); - }); -} - -#[test] -fn sell_dca_native_execution_fee_should_be_taken_and_sent_to_treasury() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let total_amount = 3 * *AMOUNT_OUT_FOR_OMNIPOOL_SELL; - let amount_to_sell = *AMOUNT_OUT_FOR_OMNIPOOL_SELL; - - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_period(ONE_HUNDRED_BLOCKS) - .with_order(Order::Sell { - asset_in: HDX, - asset_out: BTC, - amount_in: amount_to_sell, - min_amount_out: Balance::MIN, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); - assert_balance!(TreasuryAccount::get(), HDX, 0); + assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); + assert_balance!(TreasuryAccount::get(), HDX, 0); //Act set_to_blocknumber(502); @@ -1870,11 +1578,7 @@ fn buy_dca_native_execution_fee_should_be_taken_and_sent_to_treasury() { }) .build(); - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); + insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); assert_eq!(budget, Currencies::reserved_balance(HDX, &ALICE)); assert_balance!(TreasuryAccount::get(), HDX, 0); @@ -1922,7 +1626,7 @@ fn slippage_limit_should_be_used_for_buy_dca_when_it_is_smaller_than_specified_t }) .build(); - assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + insert_schedule_into_storage(ALICE, schedule, Option::None); //Act set_to_blocknumber(502); @@ -2058,115 +1762,6 @@ fn one_sell_dca_execution_should_be_rescheduled_when_price_diff_is_more_than_use }); } -#[test] -fn one_buy_dca_execution_should_be_rescheduled_when_price_diff_is_more_than_max_allowed() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) - .with_max_price_difference(Permill::from_percent(9)) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let total_amount = 50 * ONE; - let amount_to_buy = 10 * ONE; - let max_limit = 20 * ONE; - - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_period(ONE_HUNDRED_BLOCKS) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: amount_to_buy, - max_amount_in: max_limit, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); - - //Act - set_to_blocknumber(502); - - let buy_fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - //Assert - assert_executed_buy_trades!(vec![]); - assert_eq!( - total_amount - buy_fee_in_native, - Currencies::reserved_balance(HDX, &ALICE) - ); - - let schedule_id = 0; - assert_scheduled_ids!(522, vec![schedule_id]); - }); -} - -#[test] -fn specified_slippage_should_be_used_in_circuit_breaker_price_check() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let total_amount = 50 * ONE; - let amount_to_buy = 10 * ONE; - let max_limit = 20 * ONE; - - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_period(ONE_HUNDRED_BLOCKS) - .with_slippage(Some(Permill::from_percent(9))) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: amount_to_buy, - max_amount_in: max_limit, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); - - //Act - set_to_blocknumber(502); - - //Assert - let buy_fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - assert_executed_buy_trades!(vec![]); - assert_eq!( - total_amount - buy_fee_in_native, - Currencies::reserved_balance(HDX, &ALICE) - ); - - let schedule_id = 0; - assert_scheduled_ids!(522, vec![schedule_id]); - - let retries = DCA::retries_on_error(schedule_id); - assert_eq!(1, retries); - }); -} - #[test] fn dca_should_be_terminated_when_dca_cannot_be_planned_due_to_not_free_blocks() { ExtBuilder::default() @@ -2715,44 +2310,6 @@ fn execution_is_still_successful_when_no_parent_hash_present() { }); } -#[test] -fn dca_schedule_should_still_take_fee_when_order_fails() { - ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) - .build() - .execute_with(|| { - //Arrange - proceed_to_blocknumber(1, 500); - - let schedule = ScheduleBuilder::new() - .with_period(ONE_HUNDRED_BLOCKS) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY, - max_amount_in: 5 * ONE, - route: create_bounded_vec(vec![Trade { - pool: Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); - - assert_ok!(DCA::schedule( - RuntimeOrigin::signed(ALICE), - schedule.clone(), - Option::None - )); - - //Act and assert - let buy_fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); - set_to_blocknumber(502); - assert_number_of_executed_buy_trades!(0); - assert_balance!(TreasuryAccount::get(), HDX, buy_fee_in_native); - }); -} - #[test] fn dca_should_include_extra_gas_in_fee_calculation_on_retry() { ExtBuilder::default() @@ -2814,6 +2371,425 @@ fn dca_should_include_extra_gas_in_fee_calculation_on_retry() { }); } +#[test] +fn schedule_is_planned_for_next_block_when_one_execution_finished() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_slippage(Some(Permill::from_percent(20))) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: ONE, + min_amount_out: Balance::MIN, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + + //Act + set_to_blocknumber(502); + + //Assert + assert_number_of_executed_sell_trades!(1); + + let schedule_id = 0; + assert_scheduled_ids!(602, vec![schedule_id]); + }); +} + +#[test] +fn dca_trade_unallocation_should_be_rolled_back_when_trade_fails() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let total_amount = 1000 * ONE; + let schedule = ScheduleBuilder::new() + .with_total_amount(total_amount) + .with_period(ONE_HUNDRED_BLOCKS) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: *AMOUNT_OUT_FOR_OMNIPOOL_SELL, + min_amount_out: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool: PoolType::Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + let schedule_id = 0; + + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + assert_eq!(Currencies::reserved_balance(HDX, &ALICE), total_amount); + assert_eq!(DCA::remaining_amounts(schedule_id).unwrap(), total_amount); + + set_to_blocknumber(502); + + assert_number_of_executed_sell_trades!(0); + assert_scheduled_ids!(522, vec![schedule_id]); + + let fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + assert_eq!(Currencies::reserved_balance(HDX, &ALICE), total_amount - fee_in_native); + assert_eq!( + DCA::remaining_amounts(schedule_id).unwrap(), + total_amount - fee_in_native + ); + }); +} + +#[test] +fn dca_schedule_should_continue_on_multiple_failures_then_terminated() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: *AMOUNT_OUT_FOR_OMNIPOOL_SELL, + min_amount_out: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + + //Act and assert + let schedule_id = 0; + set_to_blocknumber(502); + assert_scheduled_ids!(522, vec![schedule_id]); + + set_to_blocknumber(522); + assert_scheduled_ids!(562, vec![schedule_id]); + + set_to_blocknumber(562); + assert_scheduled_ids!(642, vec![schedule_id]); + + set_to_blocknumber(642); + assert!(DCA::schedules(schedule_id).is_none()); + assert_number_of_executed_sell_trades!(0); + }); +} + +#[test] +fn dca_schedule_should_use_specified_max_retry_count() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + let max_retries = Some(5); + + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_max_retries(max_retries) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: *AMOUNT_OUT_FOR_OMNIPOOL_SELL, + min_amount_out: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); + + //Act and assert + let schedule_id = 0; + set_to_blocknumber(502); + assert_scheduled_ids!(522, vec![schedule_id]); + + set_to_blocknumber(522); + assert_scheduled_ids!(562, vec![schedule_id]); + + set_to_blocknumber(562); + assert_scheduled_ids!(642, vec![schedule_id]); + + set_to_blocknumber(642); + assert_scheduled_ids!(802, vec![schedule_id]); + let retries = DCA::retries_on_error(schedule_id); + assert_eq!(4, retries); + + set_to_blocknumber(802); + assert_scheduled_ids!(1122, vec![schedule_id]); + let retries = DCA::retries_on_error(schedule_id); + assert_eq!(5, retries); + + set_to_blocknumber(1122); + assert!(DCA::schedules(schedule_id).is_none()); + assert_number_of_executed_sell_trades!(0); + }); +} + +#[test] +fn execution_fee_should_be_taken_from_user_in_sold_currency_in_case_of_successful_buy_trade() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE), (ALICE, DAI, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let budget = 1000 * ONE; + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_total_amount(budget) + .with_slippage(Some(Permill::from_percent(20))) + .with_order(Order::Buy { + asset_in: DAI, + asset_out: BTC, + amount_out: 10 * ONE, + max_amount_in: 50 * ONE, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: DAI, + asset_out: BTC, + }]), + }) + .build(); + + insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); + + //Act + assert_balance!(TreasuryAccount::get(), DAI, 0); + set_to_blocknumber(502); + + //Assert + let buy_fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + assert_balance!(TreasuryAccount::get(), DAI, buy_fee_in_dai); + assert_number_of_executed_buy_trades!(1); + assert_eq!( + Currencies::reserved_balance(DAI, &ALICE), + budget - CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY - buy_fee_in_dai + ); + assert_balance!(ALICE, BTC, CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY); + }); +} + +#[test] +fn execution_fee_should_be_still_taken_from_user_in_sold_currency_in_case_of_failed_trade() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE), (ALICE, DAI, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let budget = 1000 * ONE; + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_total_amount(budget) + .with_order(Order::Sell { + asset_in: DAI, + asset_out: BTC, + amount_in: *AMOUNT_OUT_FOR_OMNIPOOL_SELL, + min_amount_out: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: DAI, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + + //Act + assert_balance!(TreasuryAccount::get(), DAI, 0); + assert_balance!(ALICE, BTC, 0); + + set_to_blocknumber(502); + + //Assert + let fee_in_dai = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + assert_balance!(TreasuryAccount::get(), DAI, fee_in_dai); + assert_number_of_executed_sell_trades!(0); + assert_eq!(Currencies::reserved_balance(DAI, &ALICE), budget - fee_in_dai); + }); +} + +#[test] +fn one_buy_dca_execution_should_be_rescheduled_when_price_diff_is_more_than_max_allowed() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) + .with_max_price_difference(Permill::from_percent(9)) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let total_amount = 50 * ONE; + let amount_to_buy = 10 * ONE; + let max_limit = 20 * ONE; + + let schedule = ScheduleBuilder::new() + .with_total_amount(total_amount) + .with_period(ONE_HUNDRED_BLOCKS) + .with_order(Order::Buy { + asset_in: HDX, + asset_out: BTC, + amount_out: amount_to_buy, + max_amount_in: max_limit, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); + assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); + + //Act + set_to_blocknumber(502); + + let buy_fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + //Assert + assert_executed_buy_trades!(vec![]); + assert_eq!( + total_amount - buy_fee_in_native, + Currencies::reserved_balance(HDX, &ALICE) + ); + + let schedule_id = 0; + assert_scheduled_ids!(522, vec![schedule_id]); + }); +} + +#[test] +fn specified_slippage_should_be_used_in_circuit_breaker_price_check() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let total_amount = 50 * ONE; + + // Short oracle prices the sell of ONE at 0.8 * ONE, so the limit is 0.728 * ONE at the + // schedule's 9% and 0.72 * ONE at the pallet default. 0.725 * ONE sits between the two: + // it only fails if the specified slippage is the one being applied. + set_sell_amount_out(ONE * 725 / 1000); + let schedule = ScheduleBuilder::new() + .with_total_amount(total_amount) + .with_period(ONE_HUNDRED_BLOCKS) + .with_slippage(Some(Permill::from_percent(9))) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: ONE, + min_amount_out: Balance::MIN, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + assert_eq!(total_amount, Currencies::reserved_balance(HDX, &ALICE)); + + //Act + set_to_blocknumber(502); + + //Assert + let fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + assert_executed_sell_trades!(vec![]); + assert_eq!(total_amount - fee_in_native, Currencies::reserved_balance(HDX, &ALICE)); + + let schedule_id = 0; + assert_scheduled_ids!(522, vec![schedule_id]); + + let retries = DCA::retries_on_error(schedule_id); + assert_eq!(1, retries); + }); +} + +#[test] +fn dca_schedule_should_still_take_fee_when_order_fails() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 5000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + proceed_to_blocknumber(1, 500); + + let schedule = ScheduleBuilder::new() + .with_period(ONE_HUNDRED_BLOCKS) + .with_order(Order::Sell { + asset_in: HDX, + asset_out: BTC, + amount_in: *AMOUNT_OUT_FOR_OMNIPOOL_SELL, + min_amount_out: Balance::MAX, + route: create_bounded_vec(vec![Trade { + pool: Omnipool, + asset_in: HDX, + asset_out: BTC, + }]), + }) + .build(); + + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + + //Act and assert + let fee_in_native = DCA::get_transaction_fee(&schedule.order, None).unwrap(); + set_to_blocknumber(502); + assert_number_of_executed_sell_trades!(0); + assert_balance!(TreasuryAccount::get(), HDX, fee_in_native); + }); +} + pub fn proceed_to_blocknumber(from: u64, to: u64) { for block_number in RangeInclusive::new(from, to) { System::set_block_number(block_number); diff --git a/pallets/dca/src/tests/schedule.rs b/pallets/dca/src/tests/schedule.rs index be3f4519c..169a659dc 100644 --- a/pallets/dca/src/tests/schedule.rs +++ b/pallets/dca/src/tests/schedule.rs @@ -36,20 +36,7 @@ fn schedule_should_reserve_all_total_amount_as_named_reserve() { //Arrange let total_amount = 100 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule = ScheduleBuilder::new().with_total_amount(total_amount).build(); //Act set_block_number(500); assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); @@ -71,20 +58,7 @@ fn schedule_should_store_total_amounts_in_storage() { //Arrange let total_amount = 100 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule = ScheduleBuilder::new().with_total_amount(total_amount).build(); //Act set_block_number(500); assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); @@ -104,36 +78,10 @@ fn schedule_should_compound_named_reserve_for_multiple_schedules() { //Arrange let total_amount = 10000 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 100 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule = ScheduleBuilder::new().with_total_amount(total_amount).build(); let total_amount_2 = 20000 * ONE; - let schedule_2 = ScheduleBuilder::new() - .with_total_amount(total_amount_2) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 1000 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule_2 = ScheduleBuilder::new().with_total_amount(total_amount_2).build(); //Act set_block_number(500); @@ -408,14 +356,14 @@ fn sell_schedule_should_throw_error_when_total_budget_is_smaller_than_amount_to_ .build() .execute_with(|| { //Arrange - let buy_fee = get_fee_for_buy_in_hdx(); + let sell_fee = get_fee_for_sell_in_hdx(); let schedule = ScheduleBuilder::new() .with_total_amount(budget) .with_period(ONE_HUNDRED_BLOCKS) .with_order(Order::Sell { asset_in: HDX, asset_out: BTC, - amount_in: budget + buy_fee, + amount_in: budget + sell_fee, min_amount_out: Balance::MIN, route: create_bounded_vec(vec![Trade { pool: PoolType::Omnipool, @@ -437,23 +385,20 @@ fn sell_schedule_should_throw_error_when_total_budget_is_smaller_than_amount_to_ } #[test] -fn buy_schedule_should_throw_error_when_total_budget_is_smaller_than_amount_in_plus_fee() { +fn schedule_should_fail_when_order_is_buy() { ExtBuilder::default() - .with_endowed_accounts(vec![(ALICE, HDX, 100 * ONE)]) + .with_endowed_accounts(vec![(ALICE, HDX, 10000 * ONE)]) .build() .execute_with(|| { //Arrange - let buy_fee = get_fee_for_buy_in_hdx(); - let budget = CALCULATED_AMOUNT_IN_FOR_OMNIPOOL_BUY + buy_fee - 1; - let schedule = ScheduleBuilder::new() - .with_total_amount(budget) + .with_total_amount(100 * ONE) .with_period(ONE_HUNDRED_BLOCKS) .with_order(Order::Buy { asset_in: HDX, asset_out: BTC, amount_out: 10 * ONE, - max_amount_in: budget + buy_fee, + max_amount_in: 50 * ONE, route: create_bounded_vec(vec![Trade { pool: PoolType::Omnipool, asset_in: HDX, @@ -468,7 +413,7 @@ fn buy_schedule_should_throw_error_when_total_budget_is_smaller_than_amount_in_p //Assert assert_noop!( DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None), - Error::::BudgetTooLow + Error::::NoLongerSupported ); }); } @@ -607,17 +552,6 @@ fn schedule_should_fail_when_total_amount_is_smaller_than_min_budget_and_sold_cu let schedule = ScheduleBuilder::new() .with_total_amount(*ORIGINAL_MIN_BUDGET_IN_NATIVE - 1) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 100 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) .build(); //Act and Assert @@ -640,11 +574,11 @@ fn schedule_should_fail_when_total_amount_in_non_native_currency_is_smaller_than let schedule = ScheduleBuilder::new() .with_total_amount(*ORIGINAL_MIN_BUDGET_IN_NATIVE / 3) - .with_order(Order::Buy { + .with_order(Order::Sell { asset_in: DAI, asset_out: HDX, - amount_out: ONE, - max_amount_in: 100 * ONE, + amount_in: 10 * ONE, + min_amount_out: 0, route: create_bounded_vec(vec![Trade { pool: PoolType::Omnipool, asset_in: HDX, @@ -801,20 +735,7 @@ fn schedule_should_init_retries_to_zero() { //Arrange let total_amount = 100 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule = ScheduleBuilder::new().with_total_amount(total_amount).build(); //Act set_block_number(500); assert_ok!(DCA::schedule(RuntimeOrigin::signed(ALICE), schedule, Option::None)); @@ -837,17 +758,6 @@ fn schedule_should_fail_when_wrong_user_is_specified_in_schedule() { let schedule = ScheduleBuilder::new() .with_owner(BOB) .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) .build(); set_block_number(500); @@ -872,11 +782,11 @@ fn schedule_should_be_created_when_no_routes_specified() { let total_amount = 100 * ONE; let schedule = ScheduleBuilder::new() .with_total_amount(total_amount) - .with_order(Order::Buy { + .with_order(Order::Sell { asset_in: HDX, asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, + amount_in: 10 * ONE, + min_amount_out: 0, route: create_bounded_vec(vec![]), }) .build(); @@ -894,20 +804,7 @@ fn thousands_of_dcas_can_be_scheduled_on_a_specific_block_because_of_salt_added_ .execute_with(|| { //Arrange let total_amount = 100 * ONE; - let schedule = ScheduleBuilder::new() - .with_total_amount(total_amount) - .with_order(Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: ONE, - max_amount_in: 10 * ONE, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: HDX, - asset_out: BTC, - }]), - }) - .build(); + let schedule = ScheduleBuilder::new().with_total_amount(total_amount).build(); use_prod_randomness(); @@ -942,18 +839,3 @@ pub fn get_fee_for_sell_in_hdx() -> Balance { DCA::get_transaction_fee(&order, None).unwrap() } -pub fn get_fee_for_buy_in_hdx() -> Balance { - let order = Order::Buy { - asset_in: HDX, - asset_out: BTC, - amount_out: 10 * ONE, - max_amount_in: u128::MAX, - route: create_bounded_vec(vec![Trade { - pool: PoolType::Omnipool, - asset_in: crate::tests::mock::HDX, - asset_out: crate::tests::mock::BTC, - }]), - }; - - DCA::get_transaction_fee(&order, None).unwrap() -} diff --git a/pallets/dca/src/tests/storage_injection_fidelity.rs b/pallets/dca/src/tests/storage_injection_fidelity.rs new file mode 100644 index 000000000..7ae34b144 --- /dev/null +++ b/pallets/dca/src/tests/storage_injection_fidelity.rs @@ -0,0 +1,152 @@ +// This file is part of HydraDX. + +// Copyright (C) 2020-2022 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. + +//! Guards `insert_schedule_into_storage` against drift from the `schedule` extrinsic: an order the +//! extrinsic still accepts must land in identical state either way. + +use crate::assert_scheduled_ids; +use crate::tests::schedule::set_block_number; +use crate::tests::*; +use crate::{Event as DcaEvent, ScheduleId}; +use frame_support::assert_ok; +use orml_traits::MultiReservableCurrency; +use pretty_assertions::assert_eq; + +#[test] +fn storage_injected_schedule_should_match_extrinsic_created_schedule() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10_000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + set_block_number(500); + let schedule = ScheduleBuilder::new().with_total_amount(1000 * ONE).build(); + + //Act + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE); + let injected_id = insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); + + //Assert + assert_schedules_are_identical(0, injected_id, &schedule, reserved_by_extrinsic); + }); +} + +#[test] +fn storage_injected_rolling_schedule_should_match_extrinsic_created_schedule() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10_000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + set_block_number(500); + let rolling_budget = 0; + let schedule = ScheduleBuilder::new().with_total_amount(rolling_budget).build(); + + //Act + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::None + )); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE); + let injected_id = insert_schedule_into_storage(ALICE, schedule.clone(), Option::None); + + //Assert + assert_schedules_are_identical(0, injected_id, &schedule, reserved_by_extrinsic); + }); +} + +#[test] +fn storage_injected_schedule_should_match_extrinsic_created_schedule_when_start_block_is_given() { + ExtBuilder::default() + .with_endowed_accounts(vec![(ALICE, HDX, 10_000 * ONE)]) + .build() + .execute_with(|| { + //Arrange + set_block_number(500); + let schedule = ScheduleBuilder::new().with_total_amount(1000 * ONE).build(); + let start_execution_block = 511; + + //Act + assert_ok!(DCA::schedule( + RuntimeOrigin::signed(ALICE), + schedule.clone(), + Option::Some(start_execution_block) + )); + let reserved_by_extrinsic = Currencies::reserved_balance(HDX, &ALICE); + let injected_id = + insert_schedule_into_storage(ALICE, schedule.clone(), Option::Some(start_execution_block)); + + //Assert + assert_eq!(DCA::schedule_execution_block(injected_id), Some(515)); + assert_schedules_are_identical(0, injected_id, &schedule, reserved_by_extrinsic); + }); +} + +fn assert_schedules_are_identical( + extrinsic_id: ScheduleId, + injected_id: ScheduleId, + schedule: &Schedule, + reserved_by_extrinsic: Balance, +) { + assert_eq!(injected_id, extrinsic_id + 1); + assert_eq!(DCA::schedules(injected_id), DCA::schedules(extrinsic_id)); + assert_eq!( + DCA::owner_of(schedule.owner, injected_id), + DCA::owner_of(schedule.owner, extrinsic_id) + ); + assert_eq!( + DCA::remaining_amounts(injected_id), + DCA::remaining_amounts(extrinsic_id) + ); + assert_eq!(DCA::retries_on_error(injected_id), DCA::retries_on_error(extrinsic_id)); + assert_eq!( + DCA::schedule_extra_gas(injected_id), + DCA::schedule_extra_gas(extrinsic_id) + ); + + let execution_block = DCA::schedule_execution_block(extrinsic_id).unwrap(); + assert_eq!(DCA::schedule_execution_block(injected_id), Some(execution_block)); + assert_scheduled_ids!(execution_block, vec![extrinsic_id, injected_id]); + + assert_eq!( + Currencies::reserved_balance(schedule.order.get_asset_in(), &schedule.owner), + 2 * reserved_by_extrinsic + ); + + expect_events(vec![ + DcaEvent::Scheduled { + id: injected_id, + who: schedule.owner, + period: schedule.period, + total_amount: schedule.total_amount, + order: schedule.order.clone(), + } + .into(), + DcaEvent::ExecutionPlanned { + id: injected_id, + who: schedule.owner, + block: execution_block, + } + .into(), + ]); +} diff --git a/runtime/hydradx/Cargo.toml b/runtime/hydradx/Cargo.toml index 851850fa0..427e02f2e 100644 --- a/runtime/hydradx/Cargo.toml +++ b/runtime/hydradx/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hydradx-runtime" -version = "437.0.0" +version = "438.0.0" authors = ["GalacticCouncil"] edition = "2021" license = "Apache 2.0" diff --git a/runtime/hydradx/src/benchmarking/dca.rs b/runtime/hydradx/src/benchmarking/dca.rs index 6829f5cf0..0f563650e 100644 --- a/runtime/hydradx/src/benchmarking/dca.rs +++ b/runtime/hydradx/src/benchmarking/dca.rs @@ -69,11 +69,11 @@ fn schedule_fake( max_retries: None, stability_threshold: None, slippage: Some(Permill::from_percent(15)), - order: Order::Buy { + order: Order::Sell { asset_in, asset_out, - amount_out: amount, - max_amount_in: Balance::MAX, + amount_in: amount, + min_amount_out: Balance::MIN, route: create_bounded_vec(vec![Trade { pool: PoolType::Omnipool, asset_in, @@ -482,11 +482,11 @@ runtime_benchmarks! { max_retries: None, stability_threshold: None, slippage: Some(Permill::from_percent(15)), - order: Order::Buy { + order: Order::Sell { asset_in: asset_1, asset_out: DAI, - amount_out: amount_sell, - max_amount_in: Balance::MAX, + amount_in: amount_sell, + min_amount_out: Balance::MIN, route: create_bounded_vec(route), }, }; diff --git a/runtime/hydradx/src/lib.rs b/runtime/hydradx/src/lib.rs index e3e8f85d6..f86bc3079 100644 --- a/runtime/hydradx/src/lib.rs +++ b/runtime/hydradx/src/lib.rs @@ -129,7 +129,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("hydradx"), impl_name: Cow::Borrowed("hydradx"), authoring_version: 1, - spec_version: 437, + spec_version: 438, impl_version: 0, apis: RUNTIME_API_VERSIONS, transaction_version: 1,