diff --git a/substrate-node/pallets/pallet-smart-contract/spec.md b/substrate-node/pallets/pallet-smart-contract/spec.md index 6a0eba693..9fdd64faf 100644 --- a/substrate-node/pallets/pallet-smart-contract/spec.md +++ b/substrate-node/pallets/pallet-smart-contract/spec.md @@ -64,6 +64,16 @@ Billing will be done in Database Tokens and will be send to the corresponding fa The main currency of this chain. More information on this is explained here: TODO +## Grace period for contracts + +Implements a grace period state `GracePeriod(startBlockNumber)` for all contract types +A grace period is a static amount of time defined by the runtime configuration. + + +Grace period is triggered if the amount due for a billing cycle is larger than the user's balance. +A grace period is removed on a contract if the next billing cycles notices that the user reloaded the balance on his account. +If this happens, the contract is set back to created state. If a user ignores a graced-out contract, the contract is deleted after the time defined by Grace Period configuration trait. + ## Footnote Sending the workloads encrypted to the chain makes sure that nobody except the destination Node can read the deployment's information as this can contain sensitive data. This way we also don't need to convert all the Zero OS primitive types to a Rust implementation and we can keep it relatively simple. \ No newline at end of file diff --git a/substrate-node/pallets/pallet-smart-contract/src/lib.rs b/substrate-node/pallets/pallet-smart-contract/src/lib.rs index b8ce475f2..e930fc735 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/lib.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/lib.rs @@ -39,6 +39,8 @@ pub trait Config: type Currency: LockableCurrency; type StakingPoolAccount: Get; type BillingFrequency: Get; + type DistributionFrequency: Get; + type GracePeriod: Get; type WeightInfo: WeightInfo; } @@ -67,6 +69,8 @@ decl_event!( UpdatedUsedResources(types::ContractResources), NruConsumptionReportReceived(types::NruConsumption), RentContractCanceled(u64), + ContractGracePeriodStarted(u64, u32, u32, u64), + ContractGracePeriodEnded(u64, u32, u32), } ); @@ -466,7 +470,7 @@ impl Module { // Update state Self::_update_contract_state(&mut contract, &types::ContractState::Deleted(cause))?; // Bill contract - Self::bill_contract(&contract)?; + Self::bill_contract(&mut contract)?; // Remove all associated storage Self::remove_contract(contract.contract_id); @@ -645,8 +649,7 @@ impl Module { // Bills a contract (NodeContract or NameContract) // Calculates how much TFT is due by the user and distributes the rewards - // Will also cancel the contract if there is not enough funds on the users wallet - fn bill_contract(contract: &types::Contract) -> DispatchResult { + fn bill_contract(contract: &mut types::Contract) -> DispatchResult { if !pallet_tfgrid::Twins::::contains_key(contract.twin_id) { return Err(DispatchError::from(Error::::TwinNotExists)); } @@ -655,34 +658,99 @@ impl Module { let mut seconds_elapsed = T::BillingFrequency::get() * 6; // Calculate amount of seconds elapsed based on the contract lock struct - let mut contract_lock = ContractLock::::get(contract.contract_id); let now = >::get().saturated_into::() / 1000; - // this will set the seconds elapsed to the default billing cycle duration in seconds // if there is no contract lock object yet. A contract lock object will be created later in this function // https://github.com/threefoldtech/tfchain/issues/261 + let contract_lock = ContractLock::::get(contract.contract_id); if contract_lock.lock_updated != 0 { seconds_elapsed = now.checked_sub(contract_lock.lock_updated).unwrap_or(0); } - let (mut amount_due, discount_received) = + let (amount_due, discount_received) = Self::calculate_contract_cost_tft(contract, usable_balance, seconds_elapsed)?; + // If there is nothing to be paid, return if amount_due == BalanceOf::::saturated_from(0 as u128) { return Ok(()); }; + + // Handle grace + let contract = Self::handle_grace(contract, usable_balance, amount_due)?; + + // Handle contract lock operations + Self::handle_lock(contract, amount_due); + + // Always emit a contract billed event + let contract_bill = types::ContractBill { + contract_id: contract.contract_id, + timestamp: >::get().saturated_into::() / 1000, + discount_level: discount_received.clone(), + amount_billed: amount_due.saturated_into::(), + }; + Self::deposit_event(RawEvent::ContractBilled(contract_bill)); + + // set the amount unbilled back to 0 + let mut contract_billing_info = ContractBillingInformationByID::get(contract.contract_id); + contract_billing_info.amount_unbilled = 0; + ContractBillingInformationByID::insert(contract.contract_id, &contract_billing_info); + + // If the contract is in delete state, remove all associated storage + if matches!(contract.state, types::ContractState::Deleted(_)) { + Self::remove_contract( + contract.contract_id, + ); + } - // if the total amount due exceeds the twin's balance we must decomission the contract - let mut decomission = false; - if amount_due >= usable_balance { - decomission = true; - amount_due = usable_balance; + Ok(()) + } + + fn handle_grace(contract: &mut types::Contract, usable_balance: BalanceOf, amount_due: BalanceOf) -> Result<&mut types::Contract, DispatchError> { + let current_block = >::block_number().saturated_into::(); + let node_id = contract.get_node_id(); + + match contract.state { + types::ContractState::GracePeriod(grace_start) => { + // if the usable balance is recharged, we can move the contract to created state again + if usable_balance > amount_due { + Self::_update_contract_state(contract, &types::ContractState::Created)?; + Self::deposit_event(RawEvent::ContractGracePeriodEnded(contract.contract_id, node_id, contract.twin_id)) + } else { + let diff = current_block - grace_start; + // If the contract grace period ran out, we can decomission the contract + if diff >= T::GracePeriod::get() { + Self::_update_contract_state(contract, &types::ContractState::Deleted(types::Cause::OutOfFunds))?; + } + } + } + _ => { + // if the user ran out of funds, move the contract to be in a grace period + // dont lock the tokens because there is nothing to lock + // we can still update the internal contract lock object to figure out later how much was due + // whilst in grace period + if amount_due >= usable_balance { + Self::_update_contract_state(contract, &types::ContractState::GracePeriod(current_block))?; + // We can't lock the amount due on the contract's lock because the user ran out of funds + Self::deposit_event(RawEvent::ContractGracePeriodStarted(contract.contract_id, node_id, contract.twin_id, current_block.saturated_into())); + } + } } + Ok(contract) + } + + fn handle_lock(contract: &mut types::Contract, amount_due: BalanceOf) { + if matches!(contract.state, types::ContractState::GracePeriod(_)) { + return + } + let twin = pallet_tfgrid::Twins::::get(contract.twin_id); + let now = >::get().saturated_into::() / 1000; + let mut contract_lock = ContractLock::::get(contract.contract_id); let new_amount_locked = contract_lock.amount_locked + amount_due; + // Only lock an amount from the user's balance if the contract is in create state // Update lock for contract and ContractLock in storage ::Currency::extend_lock( contract.contract_id.to_be_bytes(), @@ -690,16 +758,18 @@ impl Module { new_amount_locked.into(), WithdrawReasons::RESERVE, ); - // increment cycles billed + + // increment cycles billed and update the internal lock struct contract_lock.lock_updated = now; contract_lock.cycles += 1; contract_lock.amount_locked = new_amount_locked; ContractLock::::insert(contract.contract_id, &contract_lock); let is_canceled = matches!(contract.state, types::ContractState::Deleted(_)); - // When the cultivation rewards are ready to be distributed or we have to decomission the contract (due to out of funds) or it's canceled by the user + let canceled_and_not_zero = is_canceled && contract_lock.amount_locked.saturated_into::() > 0; + // When the cultivation rewards are ready to be distributed or it's in delete state // Unlock all reserved balance and distribute - if contract_lock.cycles == 24 || decomission || is_canceled { + if contract_lock.cycles == T::DistributionFrequency::get() || canceled_and_not_zero { // Remove lock ::Currency::remove_lock( contract.contract_id.to_be_bytes(), @@ -722,28 +792,6 @@ impl Module { contract_lock.cycles = 0; ContractLock::::insert(contract.contract_id, &contract_lock); } - - let contract_bill = types::ContractBill { - contract_id: contract.contract_id, - timestamp: >::get().saturated_into::() / 1000, - discount_level: discount_received.clone(), - amount_billed: amount_due.saturated_into::(), - }; - Self::deposit_event(RawEvent::ContractBilled(contract_bill)); - - // set the amount unbilled back to 0 - let mut contract_billing_info = ContractBillingInformationByID::get(contract.contract_id); - contract_billing_info.amount_unbilled = 0; - ContractBillingInformationByID::insert(contract.contract_id, &contract_billing_info); - - // If total balance exceeds the twin's balance, we can decomission contract - if decomission { - Self::remove_contract( - contract.contract_id, - ); - } - - Ok(()) } fn calculate_contract_cost_tft( diff --git a/substrate-node/pallets/pallet-smart-contract/src/mock.rs b/substrate-node/pallets/pallet-smart-contract/src/mock.rs index 91a88d1b7..af030c3c0 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/mock.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/mock.rs @@ -105,6 +105,8 @@ impl pallet_timestamp::Config for TestRuntime { parameter_types! { pub const BillingFrequency: u64 = 10; + pub const GracePeriod: u64 = 100; + pub const DistributionFrequency: u16 = 24; } use weights; @@ -113,6 +115,8 @@ impl Config for TestRuntime { type Currency = Balances; type StakingPoolAccount = StakingPoolAccount; type BillingFrequency = BillingFrequency; + type DistributionFrequency = DistributionFrequency; + type GracePeriod = GracePeriod; type WeightInfo = weights::SubstrateWeight; } diff --git a/substrate-node/pallets/pallet-smart-contract/src/tests.rs b/substrate-node/pallets/pallet-smart-contract/src/tests.rs index 6b16d3afe..199af3b68 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/tests.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/tests.rs @@ -876,7 +876,7 @@ fn test_node_contract_billing_cycles_cancel_contract_during_cycle_works() { 1 )); - run_to_block(32); + run_to_block(29); check_report_cost(1, 5, amount_due_as_u128, 28, discount_received); let contract = SmartContractModule::contracts(1); @@ -888,7 +888,7 @@ fn test_node_contract_billing_cycles_cancel_contract_during_cycle_works() { } #[test] -fn test_node_contract_billing_cycles_cancels_contract_when_out_of_funds_works() { +fn test_node_contract_out_of_funds_should_move_state_to_graceperiod_works() { new_test_ext().execute_with(|| { prepare_farm_and_node(); run_to_block(1); @@ -912,10 +912,54 @@ fn test_node_contract_billing_cycles_cancels_contract_when_out_of_funds_works() run_to_block(22); let c1 = SmartContractModule::contracts(1); - assert_eq!(c1, types::Contract::default()); + assert_eq!(c1.state, types::ContractState::GracePeriod(21)); - let contract_billing_info = SmartContractModule::contract_billing_information_by_id(1); - assert_eq!(contract_billing_info.amount_unbilled, 0); //this amount in unit USD = 1/1e7 + let our_events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let Event::pallet_smart_contract(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>(); + + let mut expected_events: std::vec::Vec>> = + Vec::new(); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 21)); + + assert_eq!(our_events[3], expected_events[0]); + }); +} + +#[test] +fn test_restore_node_contract_in_grace_works() { + new_test_ext().execute_with(|| { + prepare_farm_and_node(); + run_to_block(1); + TFTPriceModule::set_prices(Origin::signed(bob()), U16F16::from_num(0.05), 101).unwrap(); + + assert_ok!(SmartContractModule::create_node_contract( + Origin::signed(charlie()), + 1, + "some_data".as_bytes().to_vec(), + "hash".as_bytes().to_vec(), + 0 + )); + + push_contract_resources_used(1); + + // cycle 1 + run_to_block(12); + + // cycle 2 + // user does not have enough funds to pay for 2 cycles + run_to_block(22); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::GracePeriod(21)); let our_events = System::events() .into_iter() @@ -931,16 +975,93 @@ fn test_node_contract_billing_cycles_cancels_contract_when_out_of_funds_works() let mut expected_events: std::vec::Vec>> = Vec::new(); - expected_events.push(RawEvent::NodeContractCanceled(1, 1, 3)); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 21)); - assert_eq!(our_events[5], expected_events[0]); + assert_eq!(our_events[3], expected_events[0]); let contract_to_bill = SmartContractModule::contract_to_bill_at_block(31); - assert_eq!(contract_to_bill.len(), 0); + assert_eq!(contract_to_bill.len(), 1); run_to_block(32); let contract_to_bill = SmartContractModule::contract_to_bill_at_block(41); - assert_eq!(contract_to_bill.len(), 0); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(42); + + // Transfer some balance to the owner of the contract to trigger the grace period to stop + Balances::transfer(Origin::signed(bob()), charlie(), 100000000).unwrap(); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(51); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(52); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(61); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(62); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::Created); + }); +} + +#[test] +fn test_node_contract_grace_period_cancels_contract_when_grace_period_ends_works() { + new_test_ext().execute_with(|| { + prepare_farm_and_node(); + run_to_block(1); + TFTPriceModule::set_prices(Origin::signed(bob()), U16F16::from_num(0.05), 101).unwrap(); + + assert_ok!(SmartContractModule::create_node_contract( + Origin::signed(charlie()), + 1, + "some_data".as_bytes().to_vec(), + "hash".as_bytes().to_vec(), + 0 + )); + + push_contract_resources_used(1); + + // cycle 1 + run_to_block(12); + + // cycle 2 + // user does not have enough funds to pay for 2 cycles + run_to_block(22); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::GracePeriod(21)); + + let our_events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let Event::pallet_smart_contract(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>(); + + let mut expected_events: std::vec::Vec>> = + Vec::new(); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 21)); + + assert_eq!(our_events[3], expected_events[0]); + + run_to_block(32); + run_to_block(42); + run_to_block(52); + run_to_block(62); + run_to_block(72); + run_to_block(82); + run_to_block(92); + run_to_block(102); + run_to_block(112); + run_to_block(122); + run_to_block(132); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1, types::Contract::default()); }); } @@ -1192,6 +1313,17 @@ fn test_rent_contract_canceled_due_to_out_of_funds_should_cancel_node_contracts_ push_contract_resources_used(2); run_to_block(12); + run_to_block(22); + run_to_block(32); + run_to_block(42); + run_to_block(52); + run_to_block(62); + run_to_block(72); + run_to_block(82); + run_to_block(92); + run_to_block(102); + run_to_block(112); + run_to_block(122); // let (amount_due_as_u128, discount_received) = calculate_tft_cost(1, 2, 11); // assert_ne!(amount_due_as_u128, 0); @@ -1215,18 +1347,18 @@ fn test_rent_contract_canceled_due_to_out_of_funds_should_cancel_node_contracts_ // Event 1: Rent contract created // Event 2: Node Contract created // Event 3: Updated used resources - // Event 4: Tokens burned - // Event 5: Rent contract billed - // Event 6: Node contract canceled - // Event 7: Rent contract Canceled + // Event 4: Grace period started + // Event 5-15: Rent contract billed + // Event 16: Node contract canceled + // Event 17: Rent contract Canceled // => no Node Contract billed event - assert_eq!(our_events.len(), 7); + assert_eq!(our_events.len(), 17); let expected_events: std::vec::Vec>> = vec![RawEvent::NodeContractCanceled(2, 1, 3), RawEvent::RentContractCanceled(1)]; - assert_eq!(our_events[5], expected_events[0]); - assert_eq!(our_events[6], expected_events[1]); + assert_eq!(our_events[15], expected_events[0]); + assert_eq!(our_events[16], expected_events[1]); }); } @@ -1282,6 +1414,163 @@ fn test_create_rent_contract_and_node_contract_with_ip_billing_works() { }); } +#[test] +fn test_rent_contract_out_of_funds_should_move_state_to_graceperiod_works() { + new_test_ext().execute_with(|| { + prepare_dedicated_farm_and_node(); + run_to_block(1); + TFTPriceModule::set_prices(Origin::signed(bob()), U16F16::from_num(0.05), 101).unwrap(); + + let node_id = 1; + assert_ok!(SmartContractModule::create_rent_contract( + Origin::signed(charlie()), + node_id + )); + + // cycle 1 + // user does not have enough funds to pay for 1 cycle + run_to_block(12); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::GracePeriod(11)); + + let our_events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let Event::pallet_smart_contract(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>(); + + let mut expected_events: std::vec::Vec>> = + Vec::new(); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 11)); + + assert_eq!(our_events[1], expected_events[0]); + }); +} + + +#[test] +fn test_restore_rent_contract_in_grace_works() { + new_test_ext().execute_with(|| { + prepare_dedicated_farm_and_node(); + run_to_block(1); + TFTPriceModule::set_prices(Origin::signed(bob()), U16F16::from_num(0.05), 101).unwrap(); + + let node_id = 1; + assert_ok!(SmartContractModule::create_rent_contract( + Origin::signed(charlie()), + node_id + )); + + // cycle 1 + run_to_block(12); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::GracePeriod(11)); + + let our_events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let Event::pallet_smart_contract(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>(); + + let mut expected_events: std::vec::Vec>> = + Vec::new(); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 11)); + + assert_eq!(our_events[1], expected_events[0]); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(21); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(22); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(31); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(32); + + // Transfer some balance to the owner of the contract to trigger the grace period to stop + Balances::transfer(Origin::signed(bob()), charlie(), 100000000).unwrap(); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(41); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(42); + + let contract_to_bill = SmartContractModule::contract_to_bill_at_block(51); + assert_eq!(contract_to_bill.len(), 1); + run_to_block(52); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::Created); + }); +} + +#[test] +fn test_rent_contract_grace_period_cancels_contract_when_grace_period_ends_works() { + new_test_ext().execute_with(|| { + prepare_dedicated_farm_and_node(); + run_to_block(1); + TFTPriceModule::set_prices(Origin::signed(bob()), U16F16::from_num(0.05), 101).unwrap(); + + let node_id = 1; + assert_ok!(SmartContractModule::create_rent_contract( + Origin::signed(charlie()), + node_id + )); + + // cycle 1 + run_to_block(12); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1.state, types::ContractState::GracePeriod(11)); + + let our_events = System::events() + .into_iter() + .map(|r| r.event) + .filter_map(|e| { + if let Event::pallet_smart_contract(inner) = e { + Some(inner) + } else { + None + } + }) + .collect::>(); + + let mut expected_events: std::vec::Vec>> = + Vec::new(); + expected_events.push(RawEvent::ContractGracePeriodStarted(1, 1, 3, 11)); + + assert_eq!(our_events[1], expected_events[0]); + + run_to_block(22); + run_to_block(32); + run_to_block(42); + run_to_block(52); + run_to_block(62); + run_to_block(72); + run_to_block(82); + run_to_block(92); + run_to_block(102); + run_to_block(112); + run_to_block(122); + run_to_block(132); + + let c1 = SmartContractModule::contracts(1); + assert_eq!(c1, types::Contract::default()); + }); +} + // MODULE FUNCTION TESTS // // ---------------------- // diff --git a/substrate-node/pallets/pallet-smart-contract/src/types.rs b/substrate-node/pallets/pallet-smart-contract/src/types.rs index da168e0ec..27ab0282b 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/types.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/types.rs @@ -4,6 +4,8 @@ use substrate_fixed::types::U64F64; use pallet_tfgrid::types; +pub type BlockNumber = u64; + /// Utility type for managing upgrades/migrations. #[derive(Encode, Decode, Clone, Debug, PartialEq)] pub enum PalletStorageVersion { @@ -26,6 +28,14 @@ impl Contract { pub fn is_state_delete(&self) -> bool { matches!(self.state, ContractState::Deleted(_)) } + + pub fn get_node_id(&self) -> u32 { + match self.contract_type.clone() { + ContractData::RentContract(c) => c.node_id, + ContractData::NodeContract(c) => c.node_id, + ContractData::NameContract(_) => 0, + } + } } #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Encode, Decode, Default, Debug)] @@ -75,6 +85,7 @@ pub struct ContractBillingInformation { pub enum ContractState { Created, Deleted(Cause), + GracePeriod(BlockNumber) } #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Encode, Decode, Debug)] diff --git a/substrate-node/runtime/src/lib.rs b/substrate-node/runtime/src/lib.rs index 9564fcb3f..ec8b014ee 100644 --- a/substrate-node/runtime/src/lib.rs +++ b/substrate-node/runtime/src/lib.rs @@ -312,6 +312,8 @@ impl pallet_tfgrid::Config for Runtime { parameter_types! { pub StakingPoolAccount: AccountId = get_staking_pool_account(); pub BillingFrequency: u64 = 600; + pub GracePeriod: u64 = (6 * HOURS).into(); + pub DistributionFrequency: u16 = 24; } pub fn get_staking_pool_account() -> AccountId { @@ -324,6 +326,8 @@ impl pallet_smart_contract::Config for Runtime { type Currency = Balances; type StakingPoolAccount = StakingPoolAccount; type BillingFrequency = BillingFrequency; + type DistributionFrequency = DistributionFrequency; + type GracePeriod = GracePeriod; type WeightInfo = pallet_smart_contract::weights::SubstrateWeight; }