diff --git a/docs/architecture/0003-third-party-service-contract.md b/docs/architecture/0003-third-party-service-contract.md new file mode 100644 index 000000000..6329d0078 --- /dev/null +++ b/docs/architecture/0003-third-party-service-contract.md @@ -0,0 +1,25 @@ +# 3. Third party service contract + +Date: 2022-10-17 + +## Status + +Accepted + +## Context + +See [here](https://github.com/threefoldtech/tfchain/issues/445) for more details. + +## Decision + +The third party service contract flow is described [here](../../substrate-node/pallets/pallet-smart-contract/third-party_service_contract.md#flow). + +## Consequences + +### the good + +- It is now possible to create generic contract between two `TFChain` users (without restriction of account type) for some service and bill for it. + +### the worrying + +- Keep eyes on potential abuses and be prepared to handle all the malicious cases that can show up. \ No newline at end of file diff --git a/substrate-node/pallets/pallet-smart-contract/src/cost.rs b/substrate-node/pallets/pallet-smart-contract/src/cost.rs index fbae0c3c7..d7270801e 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/cost.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/cost.rs @@ -2,12 +2,11 @@ use crate::pallet; use crate::pallet::BalanceOf; use crate::pallet::Error; use crate::types; -use crate::types::{Contract, ContractBillingInformation}; +use crate::types::{Contract, ContractBillingInformation, ServiceContract, ServiceContractBill}; use crate::Config; -use frame_support::dispatch::DispatchErrorWithPostInfo; +use frame_support::{dispatch::DispatchErrorWithPostInfo, traits::Get}; use pallet_tfgrid::types as pallet_tfgrid_types; -use sp_runtime::Percent; -use sp_runtime::SaturatedConversion; +use sp_runtime::{Percent, SaturatedConversion}; use substrate_fixed::types::U64F64; use tfchain_support::{resources::Resources, types::NodeCertification}; @@ -101,7 +100,8 @@ impl Contract { // Calculate total cost for a name contract types::ContractData::NameContract(_) => { // bill user for name usage for number of seconds elapsed - let total_cost_u64f64 = (U64F64::from_num(pricing_policy.unique_name.value) / 3600) + let total_cost_u64f64 = (U64F64::from_num(pricing_policy.unique_name.value) + / U64F64::from_num(T::BillingReferencePeriod::get())) * U64F64::from_num(seconds_elapsed); total_cost_u64f64.to_num::() } @@ -111,6 +111,36 @@ impl Contract { } } +impl ServiceContract { + pub fn calculate_bill_cost_tft( + &self, + service_bill: ServiceContractBill, + ) -> Result, DispatchErrorWithPostInfo> { + // Calculate the cost in mUSD for service contract bill + let total_cost = self.calculate_bill_cost::(service_bill); + + if total_cost == 0 { + return Ok(BalanceOf::::saturated_from(0 as u128)); + } + + // Calculate the cost in TFT for service contract + let total_cost_tft_64 = calculate_cost_in_tft_from_musd::(total_cost)?; + + // convert to balance object + let amount_due: BalanceOf = BalanceOf::::saturated_from(total_cost_tft_64); + + return Ok(amount_due); + } + + pub fn calculate_bill_cost(&self, service_bill: ServiceContractBill) -> u64 { + // bill user for service usage for elpased usage (window) in seconds + let contract_cost = U64F64::from_num(self.base_fee) * U64F64::from_num(service_bill.window) + / U64F64::from_num(T::BillingReferencePeriod::get()) + + U64F64::from_num(service_bill.variable_amount); + contract_cost.round().to_num::() + } +} + // Calculates the total cost of a node contract. pub fn calculate_resources_cost( resources: Resources, @@ -130,14 +160,16 @@ pub fn calculate_resources_cost( let su_used = hru / 1200 + sru / 200; // the pricing policy su cost value is expressed in 1 hours or 3600 seconds. // we bill every 3600 seconds but here we need to calculate the cost per second and multiply it by the seconds elapsed. - let su_cost = (U64F64::from_num(pricing_policy.su.value) / 3600) + let su_cost = (U64F64::from_num(pricing_policy.su.value) + / U64F64::from_num(T::BillingReferencePeriod::get())) * U64F64::from_num(seconds_elapsed) * su_used; log::debug!("su cost: {:?}", su_cost); let cu = calculate_cu(cru, mru); - let cu_cost = (U64F64::from_num(pricing_policy.cu.value) / 3600) + let cu_cost = (U64F64::from_num(pricing_policy.cu.value) + / U64F64::from_num(T::BillingReferencePeriod::get())) * U64F64::from_num(seconds_elapsed) * cu; log::debug!("cu cost: {:?}", cu_cost); @@ -146,7 +178,8 @@ pub fn calculate_resources_cost( if ipu > 0 { let total_ip_cost = U64F64::from_num(ipu) - * (U64F64::from_num(pricing_policy.ipu.value) / 3600) + * (U64F64::from_num(pricing_policy.ipu.value) + / U64F64::from_num(T::BillingReferencePeriod::get())) * U64F64::from_num(seconds_elapsed); log::debug!("ip cost: {:?}", total_ip_cost); total_cost += total_ip_cost; diff --git a/substrate-node/pallets/pallet-smart-contract/src/lib.rs b/substrate-node/pallets/pallet-smart-contract/src/lib.rs index 83a97331d..1fd8ce53b 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/lib.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/lib.rs @@ -1,7 +1,5 @@ #![cfg_attr(not(feature = "std"), no_std)] -use sp_std::prelude::*; - use frame_support::{ dispatch::DispatchErrorWithPostInfo, dispatch::DispatchResultWithPostInfo, @@ -12,6 +10,7 @@ use frame_support::{ Currency, EnsureOrigin, ExistenceRequirement, ExistenceRequirement::KeepAlive, Get, LockableCurrency, OnUnbalanced, WithdrawReasons, }, + transactional, weights::Pays, BoundedVec, }; @@ -30,6 +29,7 @@ use sp_runtime::{ traits::{CheckedSub, Convert, SaturatedConversion}, Perbill, }; +use sp_std::prelude::*; use substrate_fixed::types::U64F64; use system::offchain::SignMessage; use tfchain_support::{ @@ -121,6 +121,7 @@ pub mod pallet { // Version constant that referenced the struct version pub const CONTRACT_VERSION: u32 = 4; + pub type BillingReferencePeriod = ::BillingReferencePeriod; pub type MaxNodeContractPublicIPs = ::MaxNodeContractPublicIps; pub type MaxDeploymentDataLength = ::MaxDeploymentDataLength; pub type DeploymentDataInput = BoundedVec>; @@ -201,6 +202,15 @@ pub mod pallet { #[pallet::getter(fn billing_frequency)] pub type BillingFrequency = StorageValue<_, u64, ValueQuery, DefaultBillingFrequency>; + #[pallet::storage] + #[pallet::getter(fn service_contracts)] + pub type ServiceContracts = + StorageMap<_, Blake2_128Concat, u64, ServiceContract, OptionQuery>; + + #[pallet::storage] + #[pallet::getter(fn service_contract_id)] + pub type ServiceContractID = StorageValue<_, u64, ValueQuery>; + #[pallet::config] pub trait Config: CreateSignedTransaction> @@ -218,6 +228,7 @@ pub mod pallet { type Burn: OnUnbalanced>; type StakingPoolAccount: Get; type BillingFrequency: Get; + type BillingReferencePeriod: Get; type DistributionFrequency: Get; type GracePeriod: Get; type WeightInfo: WeightInfo; @@ -309,6 +320,23 @@ pub mod pallet { }, SolutionProviderCreated(types::SolutionProvider), SolutionProviderApproved(u64, bool), + /// A Service contract is created + ServiceContractCreated(types::ServiceContract), + /// A Service contract is approved + ServiceContractApproved { + service_contract_id: u64, + }, + /// A Service contract is canceled + ServiceContractCanceled { + service_contract_id: u64, + cause: types::Cause, + }, + /// A Service contract is billed + ServiceContractBilled { + service_contract_id: u64, + bill: types::ServiceContractBill, + amount: BalanceOf, + }, BillingFrequencyChanged(u64), } @@ -349,6 +377,17 @@ pub mod pallet { InvalidProviderConfiguration, NoSuchSolutionProvider, SolutionProviderNotApproved, + TwinNotAuthorized, + ServiceContractNotExists, + ServiceContractCreationNotAllowed, + ServiceContractModificationNotAllowed, + ServiceContractApprovalNotAllowed, + ServiceContractRejectionNotAllowed, + ServiceContractBillingNotApprovedByBoth, + ServiceContractBillingVariableAmountTooHigh, + ServiceContractBillMetadataTooLong, + ServiceContractMetadataTooLong, + ServiceContractNotEnoughFundsToPayBill, CanOnlyIncreaseFrequency, IsNotAnAuthority, WrongAuthority, @@ -495,6 +534,88 @@ pub mod pallet { Self::bill_contract(contract_id) } + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_create( + origin: OriginFor, + service_account: T::AccountId, + consumer_account: T::AccountId, + ) -> DispatchResultWithPostInfo { + let caller_account = ensure_signed(origin)?; + Self::_service_contract_create(caller_account, service_account, consumer_account) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_set_metadata( + origin: OriginFor, + service_contract_id: u64, + metadata: Vec, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + Self::_service_contract_set_metadata(account_id, service_contract_id, metadata) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_set_fees( + origin: OriginFor, + service_contract_id: u64, + base_fee: u64, + variable_fee: u64, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + Self::_service_contract_set_fees( + account_id, + service_contract_id, + base_fee, + variable_fee, + ) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_approve( + origin: OriginFor, + service_contract_id: u64, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + Self::_service_contract_approve(account_id, service_contract_id) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_reject( + origin: OriginFor, + service_contract_id: u64, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + Self::_service_contract_reject(account_id, service_contract_id) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_cancel( + origin: OriginFor, + service_contract_id: u64, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + Self::_service_contract_cancel( + twin_id, + service_contract_id, + types::Cause::CanceledByUser, + ) + } + + #[pallet::weight(10_000 + T::DbWeight::get().writes(1))] + pub fn service_contract_bill( + origin: OriginFor, + service_contract_id: u64, + variable_amount: u64, + metadata: Vec, + ) -> DispatchResultWithPostInfo { + let account_id = ensure_signed(origin)?; + Self::_service_contract_bill(account_id, service_contract_id, variable_amount, metadata) + } + #[pallet::weight(10_000 + T::DbWeight::get().writes(1) + T::DbWeight::get().reads(1))] pub fn change_billing_frequency( origin: OriginFor, @@ -925,7 +1046,8 @@ impl Pallet { // calculate NRU used and the cost let used_nru = U64F64::from_num(report.nru) / pricing_policy.nu.factor_base_1000(); let nu_cost = used_nru - * (U64F64::from_num(pricing_policy.nu.value) / 3600) + * (U64F64::from_num(pricing_policy.nu.value) + / U64F64::from_num(T::BillingReferencePeriod::get())) * U64F64::from_num(seconds_elapsed); log::info!("nu cost: {:?}", nu_cost); @@ -1727,6 +1849,384 @@ impl Pallet { now % BillingFrequency::::get() } + pub fn _service_contract_create( + caller: T::AccountId, + service: T::AccountId, + consumer: T::AccountId, + ) -> DispatchResultWithPostInfo { + let caller_twin_id = + pallet_tfgrid::TwinIdByAccountID::::get(&caller).ok_or(Error::::TwinNotExists)?; + + let service_twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&service) + .ok_or(Error::::TwinNotExists)?; + + let consumer_twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&consumer) + .ok_or(Error::::TwinNotExists)?; + + // Only service or consumer can create contract + ensure!( + caller_twin_id == service_twin_id || caller_twin_id == consumer_twin_id, + Error::::TwinNotAuthorized, + ); + + // Service twin and consumer twin can not be the same + ensure!( + service_twin_id != consumer_twin_id, + Error::::ServiceContractCreationNotAllowed, + ); + + // Get the service contract ID map and increment + let mut id = ServiceContractID::::get(); + id = id + 1; + + // Create service contract + let service_contract = types::ServiceContract { + service_contract_id: id, + service_twin_id, + consumer_twin_id, + base_fee: 0, + variable_fee: 0, + metadata: vec![].try_into().unwrap(), + accepted_by_service: false, + accepted_by_consumer: false, + last_bill: 0, + state: types::ServiceContractState::Created, + }; + + // Insert into service contract map + ServiceContracts::::insert(id, &service_contract); + + // Update Contract ID + ServiceContractID::::put(id); + + // Trigger event for service contract creation + Self::deposit_event(Event::ServiceContractCreated(service_contract)); + + Ok(().into()) + } + + pub fn _service_contract_set_metadata( + account_id: T::AccountId, + service_contract_id: u64, + metadata: Vec, + ) -> DispatchResultWithPostInfo { + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + let mut service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Only service or consumer can set metadata + ensure!( + twin_id == service_contract.service_twin_id + || twin_id == service_contract.consumer_twin_id, + Error::::TwinNotAuthorized, + ); + + // Only allow to modify metadata if contract still not approved by both parties + ensure!( + !matches!( + service_contract.state, + types::ServiceContractState::ApprovedByBoth + ), + Error::::ServiceContractModificationNotAllowed, + ); + + service_contract.metadata = BoundedVec::try_from(metadata) + .map_err(|_| Error::::ServiceContractMetadataTooLong)?; + + // If base_fee is set and non-zero (mandatory) + if service_contract.base_fee != 0 { + service_contract.state = types::ServiceContractState::AgreementReady; + } + + // Update service contract in map after modification + ServiceContracts::::insert(service_contract_id, service_contract); + + Ok(().into()) + } + + pub fn _service_contract_set_fees( + account_id: T::AccountId, + service_contract_id: u64, + base_fee: u64, + variable_fee: u64, + ) -> DispatchResultWithPostInfo { + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + let mut service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Only service can set fees + ensure!( + twin_id == service_contract.service_twin_id, + Error::::TwinNotAuthorized, + ); + + // Only allow to modify fees if contract still not approved by both parties + ensure!( + !matches!( + service_contract.state, + types::ServiceContractState::ApprovedByBoth + ), + Error::::ServiceContractModificationNotAllowed, + ); + + service_contract.base_fee = base_fee; + service_contract.variable_fee = variable_fee; + + // If metadata is filled and not empty (mandatory) + if !service_contract.metadata.is_empty() { + service_contract.state = types::ServiceContractState::AgreementReady; + } + + // Update service contract in map after modification + ServiceContracts::::insert(service_contract_id, service_contract); + + Ok(().into()) + } + + pub fn _service_contract_approve( + account_id: T::AccountId, + service_contract_id: u64, + ) -> DispatchResultWithPostInfo { + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + let mut service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Allow to approve contract only if agreement is ready + ensure!( + matches!( + service_contract.state, + types::ServiceContractState::AgreementReady + ), + Error::::ServiceContractApprovalNotAllowed, + ); + + // Only service or consumer can accept agreement + if twin_id == service_contract.service_twin_id { + service_contract.accepted_by_service = true; + } else if twin_id == service_contract.consumer_twin_id { + service_contract.accepted_by_consumer = true + } else { + return Err(DispatchErrorWithPostInfo::from( + Error::::TwinNotAuthorized, + )); + } + + // If both parties (service and consumer) accept then contract is approved and can be billed + if service_contract.accepted_by_service && service_contract.accepted_by_consumer { + // Change contract state to approved and emit event + service_contract.state = types::ServiceContractState::ApprovedByBoth; + + // Trigger event for service contract approval + Self::deposit_event(Event::ServiceContractApproved { + service_contract_id, + }); + // Initialize billing time + let now = >::get().saturated_into::() / 1000; + service_contract.last_bill = now; + } + + // Update service contract in map after modification + ServiceContracts::::insert(service_contract_id, service_contract); + + Ok(().into()) + } + + pub fn _service_contract_reject( + account_id: T::AccountId, + service_contract_id: u64, + ) -> DispatchResultWithPostInfo { + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + let service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Only service or consumer can reject agreement + ensure!( + twin_id == service_contract.service_twin_id + || twin_id == service_contract.consumer_twin_id, + Error::::TwinNotAuthorized, + ); + + // Allow to reject contract only if agreement is ready + ensure!( + matches!( + service_contract.state, + types::ServiceContractState::AgreementReady + ), + Error::::ServiceContractRejectionNotAllowed, + ); + + // If one party (service or consumer) rejects agreement + // then contract is canceled and removed from service contract map + Self::_service_contract_cancel(twin_id, service_contract_id, types::Cause::CanceledByUser)?; + + Ok(().into()) + } + + pub fn _service_contract_cancel( + twin_id: u32, + service_contract_id: u64, + cause: types::Cause, + ) -> DispatchResultWithPostInfo { + let service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Only service or consumer can cancel contract + ensure!( + twin_id == service_contract.service_twin_id + || twin_id == service_contract.consumer_twin_id, + Error::::TwinNotAuthorized, + ); + + // Remove contract from service contract map + // Can be done at any state of contract + // so no need to handle state validation + ServiceContracts::::remove(service_contract_id); + + // Trigger event for service contract cancelation + Self::deposit_event(Event::ServiceContractCanceled { + service_contract_id, + cause, + }); + + log::info!( + "successfully removed service contract with id {:?}", + service_contract_id, + ); + + Ok(().into()) + } + + #[transactional] + pub fn _service_contract_bill( + account_id: T::AccountId, + service_contract_id: u64, + variable_amount: u64, + metadata: Vec, + ) -> DispatchResultWithPostInfo { + let twin_id = pallet_tfgrid::TwinIdByAccountID::::get(&account_id) + .ok_or(Error::::TwinNotExists)?; + + let mut service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + + // Only service can bill consumer for service contract + ensure!( + twin_id == service_contract.service_twin_id, + Error::::TwinNotAuthorized, + ); + + // Allow to bill contract only if approved by both + ensure!( + matches!( + service_contract.state, + types::ServiceContractState::ApprovedByBoth + ), + Error::::ServiceContractBillingNotApprovedByBoth, + ); + + // Get elapsed time (in seconds) to bill for service + let now = >::get().saturated_into::() / 1000; + let elapsed_seconds_since_last_bill = now - service_contract.last_bill; + + // Billing time (window) is max 1h by design + // So extra time will not be billed + // It is the service responsability to bill on right frequency + let window = elapsed_seconds_since_last_bill.min(T::BillingReferencePeriod::get()); + + // Billing variable amount is bounded by contract variable fee + ensure!( + variable_amount + <= ((U64F64::from_num(window) + / U64F64::from_num(T::BillingReferencePeriod::get())) + * U64F64::from_num(service_contract.variable_fee)) + .round() + .to_num::(), + Error::::ServiceContractBillingVariableAmountTooHigh, + ); + + let bill_metadata = BoundedVec::try_from(metadata) + .map_err(|_| Error::::ServiceContractBillMetadataTooLong)?; + + // Create service contract bill + let service_contract_bill = types::ServiceContractBill { + variable_amount, + window, + metadata: bill_metadata, + }; + + // Make consumer pay for service contract bill + Self::_service_contract_pay_bill(service_contract_id, service_contract_bill)?; + + // Update contract in list after modification + service_contract.last_bill = now; + ServiceContracts::::insert(service_contract_id, service_contract); + + Ok(().into()) + } + + // Pay a service contract bill + // Calculates how much TFT is due by the consumer and pay the amount to the service + fn _service_contract_pay_bill( + service_contract_id: u64, + bill: types::ServiceContractBill, + ) -> DispatchResultWithPostInfo { + let service_contract = ServiceContracts::::get(service_contract_id) + .ok_or(Error::::ServiceContractNotExists)?; + let amount = service_contract.calculate_bill_cost_tft::(bill.clone())?; + + let service_twin_id = service_contract.service_twin_id; + let service_twin = + pallet_tfgrid::Twins::::get(service_twin_id).ok_or(Error::::TwinNotExists)?; + + let consumer_twin = pallet_tfgrid::Twins::::get(service_contract.consumer_twin_id) + .ok_or(Error::::TwinNotExists)?; + + let usable_balance = Self::get_usable_balance(&consumer_twin.account_id); + + // If consumer is out of funds then contract is canceled + // by service and removed from service contract map + if usable_balance < amount { + Self::_service_contract_cancel( + service_twin_id, + service_contract_id, + types::Cause::OutOfFunds, + )?; + return Err(DispatchErrorWithPostInfo::from( + Error::::ServiceContractNotEnoughFundsToPayBill, + )); + } + + // Transfer amount due from consumer account to service account + ::Currency::transfer( + &consumer_twin.account_id, + &service_twin.account_id, + amount, + ExistenceRequirement::KeepAlive, + )?; + + // Trigger event for service contract billing + Self::deposit_event(Event::ServiceContractBilled { + service_contract_id, + bill, + amount, + }); + + log::info!( + "bill successfully payed by consumer for service contract with id {:?}", + service_contract_id, + ); + + Ok(().into()) + } + pub fn _change_billing_frequency(frequency: u64) -> DispatchResultWithPostInfo { let billing_frequency = BillingFrequency::::get(); ensure!( diff --git a/substrate-node/pallets/pallet-smart-contract/src/mock.rs b/substrate-node/pallets/pallet-smart-contract/src/mock.rs index 56258149a..775f6f4d0 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/mock.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/mock.rs @@ -1,6 +1,4 @@ #![cfg(test)] -use std::{panic, thread}; - use super::*; use crate::name_contract::NameContractName; use crate::{self as pallet_smart_contract}; @@ -12,17 +10,15 @@ use frame_support::{ BoundedVec, }; use frame_system::EnsureRoot; -use pallet_tfgrid::node::{CityName, CountryName}; use pallet_tfgrid::{ farm::FarmName, interface::{InterfaceIp, InterfaceMac, InterfaceName}, + node::{CityName, CountryName}, node::{Location, SerialNumber}, terms_cond::TermsAndConditions, twin::TwinIp, - DocumentHashInput, DocumentLinkInput, TwinIpInput, -}; -use pallet_tfgrid::{ - CityNameInput, CountryNameInput, Gw4Input, Ip4Input, LatitudeInput, LongitudeInput, + CityNameInput, CountryNameInput, DocumentHashInput, DocumentLinkInput, Gw4Input, Ip4Input, + LatitudeInput, LongitudeInput, TwinIpInput, }; use parking_lot::RwLock; use sp_core::{ @@ -45,9 +41,12 @@ use sp_runtime::{ AccountId32, MultiSignature, }; use sp_std::convert::{TryFrom, TryInto}; +use std::{cell::RefCell, panic, thread}; +use tfchain_support::{ + constants::time::{MINUTES, SECS_PER_HOUR}, + traits::ChangeNode, +}; use sp_std::marker::PhantomData; -use std::cell::RefCell; -use tfchain_support::{constants::time::MINUTES, traits::ChangeNode}; impl_opaque_keys! { pub struct MockSessionKeys { @@ -250,11 +249,13 @@ impl pallet_timestamp::Config for TestRuntime { parameter_types! { pub const BillingFrequency: u64 = 10; + pub const BillingReferencePeriod: u64 = SECS_PER_HOUR; pub const GracePeriod: u64 = 100; pub const DistributionFrequency: u16 = 24; pub const MaxNameContractNameLength: u32 = 64; pub const MaxNodeContractPublicIPs: u32 = 512; pub const MaxDeploymentDataLength: u32 = 512; + pub const SecondsPerHour: u64 = 3600; } pub(crate) type TestNameContractName = NameContractName; @@ -266,6 +267,7 @@ impl pallet_smart_contract::Config for TestRuntime { type Burn = (); type StakingPoolAccount = StakingPoolAccount; type BillingFrequency = BillingFrequency; + type BillingReferencePeriod = BillingReferencePeriod; 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 922bebcd9..7733bd640 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/tests.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/tests.rs @@ -18,13 +18,16 @@ use sp_core::H256; use sp_runtime::{assert_eq_error_rate, traits::SaturatedConversion, Perbill, Percent}; use sp_std::convert::{TryFrom, TryInto}; use substrate_fixed::types::U64F64; -use tfchain_support::types::IP4; +use tfchain_support::constants::time::SECS_PER_HOUR; use tfchain_support::{ resources::Resources, - types::{FarmCertification, NodeCertification, PublicIP}, + types::{FarmCertification, NodeCertification, PublicIP, IP4}, }; const GIGABYTE: u64 = 1024 * 1024 * 1024; +const BASE_FEE: u64 = 1000; +const VARIABLE_FEE: u64 = 1000; +const VARIABLE_AMOUNT: u64 = 100; // NODE CONTRACT TESTS // // -------------------- // @@ -2269,6 +2272,7 @@ fn test_rent_contract_and_node_contract_canceled_when_node_is_deleted_works() { // SOLUTION PROVIDER TESTS // // ------------------------ // + #[test] fn test_create_solution_provider_works() { new_test_ext().execute_with(|| { @@ -2369,6 +2373,614 @@ fn test_create_node_contract_with_solution_provider_fails_if_not_approved() { }); } +// SERVICE CONTRACT TESTS // +// ---------------------- // + +#[test] +fn test_service_contract_create_works() { + new_test_ext().execute_with(|| { + run_to_block(1, None); + create_service_consumer_contract(); + + let service_contract = get_service_contract(); + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule( + SmartContractEvent::ServiceContractCreated(service_contract) + )), + ); + }); +} + +#[test] +fn test_service_contract_create_by_anyone_fails() { + new_test_ext().execute_with(|| { + create_twin(alice()); + create_twin(bob()); + create_twin(charlie()); + + assert_noop!( + SmartContractModule::service_contract_create( + Origin::signed(charlie()), + alice(), + bob(), + ), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_create_same_account_fails() { + new_test_ext().execute_with(|| { + create_twin(alice()); + + assert_noop!( + SmartContractModule::service_contract_create( + Origin::signed(alice()), + alice(), + alice(), + ), + Error::::ServiceContractCreationNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_set_metadata_works() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_ok!(SmartContractModule::service_contract_set_metadata( + Origin::signed(alice()), + 1, + b"some_metadata".to_vec(), + )); + + let mut service_contract = get_service_contract(); + service_contract.metadata = BoundedVec::try_from(b"some_metadata".to_vec()).unwrap(); + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + }); +} + +#[test] +fn test_service_contract_set_metadata_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + create_twin(charlie()); + + assert_noop!( + SmartContractModule::service_contract_set_metadata( + Origin::signed(charlie()), + 1, + b"some_metadata".to_vec(), + ), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_set_metadata_already_approved_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_set_metadata( + Origin::signed(alice()), + 1, + b"some_metadata".to_vec(), + ), + Error::::ServiceContractModificationNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_set_metadata_too_long_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_set_metadata( + Origin::signed(alice()), + 1, + b"very_loooooooooooooooooooooooooooooooooooooooooooooooooong_metadata".to_vec(), + ), + Error::::ServiceContractMetadataTooLong + ); + }); +} + +#[test] +fn test_service_contract_set_fees_works() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_ok!(SmartContractModule::service_contract_set_fees( + Origin::signed(alice()), + 1, + BASE_FEE, + VARIABLE_FEE, + )); + + let mut service_contract = get_service_contract(); + service_contract.base_fee = BASE_FEE; + service_contract.variable_fee = VARIABLE_FEE; + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + }); +} + +#[test] +fn test_service_contract_set_fees_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_set_fees( + Origin::signed(bob()), + 1, + BASE_FEE, + VARIABLE_FEE, + ), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_set_fees_already_approved_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_set_fees( + Origin::signed(alice()), + 1, + BASE_FEE, + VARIABLE_FEE, + ), + Error::::ServiceContractModificationNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_approve_works() { + new_test_ext().execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + + let mut service_contract = get_service_contract(); + service_contract.base_fee = BASE_FEE; + service_contract.variable_fee = VARIABLE_FEE; + service_contract.metadata = BoundedVec::try_from(b"some_metadata".to_vec()).unwrap(); + service_contract.state = types::ServiceContractState::AgreementReady; + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + + // Service approves + assert_ok!(SmartContractModule::service_contract_approve( + Origin::signed(alice()), + 1, + )); + + service_contract.accepted_by_service = true; + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + + // Consumer approves + assert_ok!(SmartContractModule::service_contract_approve( + Origin::signed(bob()), + 1, + )); + + service_contract.accepted_by_consumer = true; + service_contract.last_bill = get_timestamp_in_seconds_for_block(1); + service_contract.state = types::ServiceContractState::ApprovedByBoth; + assert_eq!( + service_contract, + SmartContractModule::service_contracts(1).unwrap(), + ); + + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule(SmartContractEvent::< + TestRuntime, + >::ServiceContractApproved { + service_contract_id: 1, + })), + ); + }); +} + +#[test] +fn test_service_contract_approve_agreement_not_ready_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_approve(Origin::signed(alice()), 1,), + Error::::ServiceContractApprovalNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_approve_already_approved_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_approve(Origin::signed(alice()), 1,), + Error::::ServiceContractApprovalNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_approve_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + create_twin(charlie()); + + assert_noop!( + SmartContractModule::service_contract_approve(Origin::signed(charlie()), 1,), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_reject_works() { + new_test_ext().execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + + assert_ok!(SmartContractModule::service_contract_reject( + Origin::signed(alice()), + 1, + )); + + assert_eq!(SmartContractModule::service_contracts(1).is_none(), true); + + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule(SmartContractEvent::< + TestRuntime, + >::ServiceContractCanceled { + service_contract_id: 1, + cause: types::Cause::CanceledByUser, + })), + ); + }); +} + +#[test] +fn test_service_contract_reject_agreement_not_ready_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_reject(Origin::signed(alice()), 1,), + Error::::ServiceContractRejectionNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_reject_already_approved_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_reject(Origin::signed(alice()), 1,), + Error::::ServiceContractRejectionNotAllowed + ); + }); +} + +#[test] +fn test_service_contract_reject_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + create_twin(charlie()); + + assert_noop!( + SmartContractModule::service_contract_reject(Origin::signed(charlie()), 1,), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_cancel_works() { + new_test_ext().execute_with(|| { + run_to_block(1, None); + create_service_consumer_contract(); + + assert_ok!(SmartContractModule::service_contract_cancel( + Origin::signed(alice()), + 1, + )); + + assert_eq!(SmartContractModule::service_contracts(1).is_none(), true); + + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule(SmartContractEvent::< + TestRuntime, + >::ServiceContractCanceled { + service_contract_id: 1, + cause: types::Cause::CanceledByUser, + })), + ); + }); +} + +#[test] +fn test_service_contract_cancel_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + create_service_consumer_contract(); + create_twin(charlie()); + + assert_noop!( + SmartContractModule::service_contract_cancel(Origin::signed(charlie()), 1,), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_bill_works() { + let (mut ext, mut pool_state) = new_test_ext_with_pool_state(0); + ext.execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + + let service_contract = SmartContractModule::service_contracts(1).unwrap(); + assert_eq!(service_contract.last_bill, 0); + + approve_service_consumer_contract(); + + let service_contract = SmartContractModule::service_contracts(1).unwrap(); + assert_eq!( + service_contract.last_bill, + get_timestamp_in_seconds_for_block(1) + ); + + let consumer_twin = TfgridModule::twins(2).unwrap(); + let consumer_balance = Balances::free_balance(&consumer_twin.account_id); + assert_eq!(consumer_balance, 2500000000); + + // Bill 20 min after contract approval + run_to_block(201, Some(&mut pool_state)); + assert_ok!(SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + VARIABLE_AMOUNT, + b"bill_metadata_1".to_vec(), + )); + + let service_contract = SmartContractModule::service_contracts(1).unwrap(); + assert_eq!( + service_contract.last_bill, + get_timestamp_in_seconds_for_block(201) + ); + + // Check consumer balance after first billing + let consumer_balance = Balances::free_balance(&consumer_twin.account_id); + let window = + get_timestamp_in_seconds_for_block(201) - get_timestamp_in_seconds_for_block(1); + let bill_1 = types::ServiceContractBill { + variable_amount: VARIABLE_AMOUNT, + window, + metadata: BoundedVec::try_from(b"bill_metadata_1".to_vec()).unwrap(), + }; + let billed_amount_1 = service_contract + .calculate_bill_cost_tft::(bill_1.clone()) + .unwrap(); + assert_eq!(2500000000 - consumer_balance, billed_amount_1); + + // Check event triggering + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule(SmartContractEvent::< + TestRuntime, + >::ServiceContractBilled { + service_contract_id: 1, + bill: bill_1, + amount: billed_amount_1, + })), + ); + + // Bill a second time, 1h10min after first billing + run_to_block(901, Some(&mut pool_state)); + assert_ok!(SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + VARIABLE_AMOUNT, + b"bill_metadata_2".to_vec(), + )); + + let service_contract = SmartContractModule::service_contracts(1).unwrap(); + assert_eq!( + service_contract.last_bill, + get_timestamp_in_seconds_for_block(901) + ); + + // Check consumer balance after second billing + let consumer_balance = Balances::free_balance(&consumer_twin.account_id); + let bill_2 = types::ServiceContractBill { + variable_amount: VARIABLE_AMOUNT, + window: SECS_PER_HOUR, // force a 1h bill here + metadata: BoundedVec::try_from(b"bill_metadata_2".to_vec()).unwrap(), + }; + let billed_amount_2 = service_contract + .calculate_bill_cost_tft::(bill_2.clone()) + .unwrap(); + // Second billing was equivalent to a 1h + // billing even if window is greater than 1h + assert_eq!( + 2500000000 - consumer_balance - billed_amount_1, + billed_amount_2 + ); + + // Check event triggering + let our_events = System::events(); + assert_eq!(!our_events.is_empty(), true); + assert_eq!( + our_events.last().unwrap(), + &record(MockEvent::SmartContractModule(SmartContractEvent::< + TestRuntime, + >::ServiceContractBilled { + service_contract_id: 1, + bill: bill_2, + amount: billed_amount_2, + })), + ); + }); +} + +#[test] +fn test_service_contract_bill_by_unauthorized_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_bill( + Origin::signed(bob()), + 1, + VARIABLE_AMOUNT, + b"bill_metadata".to_vec(), + ), + Error::::TwinNotAuthorized + ); + }); +} + +#[test] +fn test_service_contract_bill_not_approved_fails() { + new_test_ext().execute_with(|| { + prepare_service_consumer_contract(); + + assert_noop!( + SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + VARIABLE_AMOUNT, + b"bill_metadata".to_vec(), + ), + Error::::ServiceContractBillingNotApprovedByBoth + ); + }); +} + +#[test] +fn test_service_contract_bill_variable_amount_too_high_fails() { + let (mut ext, mut pool_state) = new_test_ext_with_pool_state(0); + ext.execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + // Bill 1h after contract approval + run_to_block(601, Some(&mut pool_state)); + // set variable amount a bit higher than variable fee to trigger error + let variable_amount = VARIABLE_FEE + 1; + assert_noop!( + SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + variable_amount, + b"bill_metadata".to_vec(), + ), + Error::::ServiceContractBillingVariableAmountTooHigh + ); + }); +} + +#[test] +fn test_service_contract_bill_metadata_too_long_fails() { + let (mut ext, mut pool_state) = new_test_ext_with_pool_state(0); + ext.execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + // Bill 1h after contract approval + run_to_block(601, Some(&mut pool_state)); + assert_noop!( + SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + VARIABLE_AMOUNT, + b"very_loooooooooooooooooooooooooooooooooooooooooooooooooong_metadata".to_vec(), + ), + Error::::ServiceContractBillMetadataTooLong + ); + }); +} + +#[test] +fn test_service_contract_bill_out_of_funds_fails() { + let (mut ext, mut pool_state) = new_test_ext_with_pool_state(0); + ext.execute_with(|| { + run_to_block(1, None); + prepare_service_consumer_contract(); + approve_service_consumer_contract(); + + // Drain consumer account + let consumer_twin = TfgridModule::twins(2).unwrap(); + let consumer_balance = Balances::free_balance(&consumer_twin.account_id); + Balances::transfer(Origin::signed(bob()), alice(), consumer_balance).unwrap(); + let consumer_balance = Balances::free_balance(&consumer_twin.account_id); + assert_eq!(consumer_balance, 0); + + // Bill 1h after contract approval + run_to_block(601, Some(&mut pool_state)); + assert_noop!( + SmartContractModule::service_contract_bill( + Origin::signed(alice()), + 1, + VARIABLE_AMOUNT, + b"bill_metadata".to_vec(), + ), + Error::::ServiceContractNotEnoughFundsToPayBill, + ); + }); +} + // MODULE FUNCTION TESTS // // ---------------------- // @@ -2550,7 +3162,7 @@ fn push_nru_report_for_contract(contract_id: u64, block_number: u64) { consumption_reports.push(super::types::NruConsumption { contract_id, nru: 3 * gigabyte, - timestamp: 1628082000 + (6 * block_number), + timestamp: get_timestamp_in_seconds_for_block(block_number), window: 6 * block_number, }); @@ -2588,7 +3200,7 @@ fn check_report_cost( let contract_bill = types::ContractBill { contract_id, - timestamp: 1628082000 + (6 * block_number), + timestamp: get_timestamp_in_seconds_for_block(block_number), discount_level, amount_billed: amount_billed as u128, }; @@ -2869,3 +3481,64 @@ fn get_deployment_data() -> crate::DeploymentDataInput { ) .unwrap() } + +fn create_service_consumer_contract() { + create_twin(alice()); + create_twin(bob()); + + // create contract between service (Alice) and consumer (Bob) + assert_ok!(SmartContractModule::service_contract_create( + Origin::signed(alice()), + alice(), + bob(), + )); +} + +fn prepare_service_consumer_contract() { + create_service_consumer_contract(); + + assert_ok!(SmartContractModule::service_contract_set_metadata( + Origin::signed(alice()), + 1, + b"some_metadata".to_vec(), + )); + + assert_ok!(SmartContractModule::service_contract_set_fees( + Origin::signed(alice()), + 1, + BASE_FEE, + VARIABLE_FEE, + )); +} + +fn approve_service_consumer_contract() { + // Service approves + assert_ok!(SmartContractModule::service_contract_approve( + Origin::signed(alice()), + 1, + )); + // Consumer approves + assert_ok!(SmartContractModule::service_contract_approve( + Origin::signed(bob()), + 1, + )); +} + +fn get_service_contract() -> types::ServiceContract { + types::ServiceContract { + service_contract_id: 1, + service_twin_id: 1, //Alice + consumer_twin_id: 2, //Bob + base_fee: 0, + variable_fee: 0, + metadata: bounded_vec![], + accepted_by_service: false, + accepted_by_consumer: false, + last_bill: 0, + state: types::ServiceContractState::Created, + } +} + +fn get_timestamp_in_seconds_for_block(block_number: u64) -> u64 { + 1628082000 + (6 * block_number) +} diff --git a/substrate-node/pallets/pallet-smart-contract/src/types.rs b/substrate-node/pallets/pallet-smart-contract/src/types.rs index dfd649434..e7f2fb351 100644 --- a/substrate-node/pallets/pallet-smart-contract/src/types.rs +++ b/substrate-node/pallets/pallet-smart-contract/src/types.rs @@ -1,14 +1,11 @@ use crate::pallet::{MaxDeploymentDataLength, MaxNodeContractPublicIPs}; use crate::Config; use codec::{Decode, Encode, MaxEncodedLen}; -use frame_support::{BoundedVec, RuntimeDebugNoBound}; +use frame_support::{pallet_prelude::ConstU32, BoundedVec, RuntimeDebugNoBound}; use scale_info::TypeInfo; use sp_std::prelude::*; use substrate_fixed::types::U64F64; -use tfchain_support::{ - resources::Resources, - types::PublicIP, -}; +use tfchain_support::{resources::Resources, types::PublicIP}; pub type BlockNumber = u64; @@ -229,3 +226,38 @@ pub struct Provider { pub who: AccountId, pub take: u8, } + +pub const MAX_METADATA_LENGTH: u32 = 64; // limited to 64 bytes (2 public keys) +pub const MAX_BILL_METADATA_LENGTH: u32 = 50; // limited to 50 bytes for now + +#[derive(Clone, Eq, PartialEq, RuntimeDebugNoBound, Encode, Decode, TypeInfo, MaxEncodedLen)] +#[scale_info(skip_type_params(T))] +#[codec(mel_bound())] +pub struct ServiceContract { + pub service_contract_id: u64, + pub service_twin_id: u32, + pub consumer_twin_id: u32, + pub base_fee: u64, + pub variable_fee: u64, + pub metadata: BoundedVec>, + pub accepted_by_service: bool, + pub accepted_by_consumer: bool, + pub last_bill: u64, + pub state: ServiceContractState, +} + +#[derive( + PartialEq, Eq, PartialOrd, Ord, Clone, Encode, Decode, Default, Debug, TypeInfo, MaxEncodedLen, +)] +pub struct ServiceContractBill { + pub variable_amount: u64, // variable amount which is billed + pub window: u64, // amount of time (in seconds) covered since last bill + pub metadata: BoundedVec>, +} + +#[derive(Clone, Eq, PartialEq, RuntimeDebugNoBound, Encode, Decode, TypeInfo, MaxEncodedLen)] +pub enum ServiceContractState { + Created, + AgreementReady, + ApprovedByBoth, +} diff --git a/substrate-node/pallets/pallet-smart-contract/third-party_service_contract.md b/substrate-node/pallets/pallet-smart-contract/third-party_service_contract.md new file mode 100644 index 000000000..01889170b --- /dev/null +++ b/substrate-node/pallets/pallet-smart-contract/third-party_service_contract.md @@ -0,0 +1,56 @@ +# Third party service contract + +Since we don't want to do work on the chain for every possible 3rd party service, we will keep this as generic as possible. While custom implementations for services might offer small advantages in the flow, the extra effort to develop and most importantly maintain these implementations is not worth it compared to a proper generic flow which would technically also be reusable. + +## Contract structure + +A contract will work simple client - server principle (i.e. the "buyer" and the "seller", a "consumer" of a service and one who "offers" the service). Both parties are identified by a twin id to fit in the current flow (we could use a generic address as well here). Contract is laid out as such + +- consumer twin id +- service twin id +- base fee, this is the fixed amount which will be billed hourly +- variable fee, this is the maximum amount which can be billed on top of the base fee (for variable consumption metrics, to be defined by the service) +- metadata, a field which just holds some bytes. The service can use this any way it likes (including having stuff set by the user). We limit this field to some size now, suggested 64 bytes (2 public keys generally) + +Additionally, we also keep track of some metadata, which will be: + +- accepted by consumer +- accepted by service +- last bill received (we keep track of this to make sure the service does not send overlapping bills) + +## Billing + +Once a contract is accepted by both the consumer and the service, the chain can start accepting "bill reports" from the service for the contract. Only the twin of the service can send these, as specified in the contract. The bill contains the following: + +- Variable amount which is billed. The chain checks that this is less than or equal to the variable amount as specified in the contract, if it is higher the bill is rejected for overcharging. Technically the service could send a report every second to drain the user. To protect against this, the max variable amount in the contract is interpreted as being "per hour", and the value set in the contract is divided by 3600, multiplied by window size, to find the actual maximum which can be billed by the contract. +- Window, this is the amount of time (in seconds) covered since last contract. The chain verifies that `current time - window >= contract.last_bill`, such that no bills overlap to avoid overcharging. Combined with the previous limit to variable amount this prevents the service from overcharging the user. +- Some optional metadata, this will again just be some bytes (the service decides how this can be interpreted). For now we'll limit this to 50 bytes or so. + +## Chain calls + +### Callable by anyone + +- `create_contract(consumer twin ID, service twin ID)`: Creates the contract and sets the id's. Base fee and variable fee are left at 0 + +### Callable by consumer or service + +- `set_metadata(data)`: Sets the custom metadata on the contract. This can be done by either the client of the service, depending on how it is interpreted (as specified by the service). For now, we will assume that setting metadata is a one off operation. As a result, if metadata is already present when this is called, an error is thrown (i.e. only the first call of this function can succeed). + +### Callable by service + +- `set_fees(base, variable)`: Sets the base fee and variable fee on the contract +- `reject_by_service()`: Rejects the contract, deleting it. +- `approve_by_service()`: Sets the `service_accepted` flag on the contract. After this, no more modifications to fees or metadata can be done + +### Callable by user + +- `reject_by_consumer()`: Rejects the contract, deleting it. +- `approve_by_consumer()`: Sets the `consumer_accepted` flag on the contract. After this, no more modifications to fees or metadata can be done + +## Flow + +We start of by creating a contract. This can technically be done by anyone, but in practice will likely end up being done by either the service or the consumer (depending on what the service expects). This will be followed by the service or consumer setting the metadata (again depending on how the service expects things to be), and the service setting a base fee + variable fee. Note that part of the communication can and should be off chain, the contract is only the finalized agreement. When the fees and metadata are set, both the consumer and service need to explicitly approve the contract, setting the appropriate flag on the contract. Note that as soon as either party accepted (i.e. either flag is set), the fees and metadata cannot be changed anymore. It is technically possible for consumers to accept a contract as soon as it is created, thereby not giving the service a chance to set the fees. Though this basically means the contract is invalid and the service should just outright reject it. + +Once the contract is accepted by both the consumer and the service, it can be billed (i.e. bills send before both flags are set must be rejected). Because a service should not charge the user if it doesn't work, we will require that bills be send every hour, by limiting the window size to 3600. Anything with a bigger window is rejected. This way if the service is down (for some longer period), it for sure can't bill for the time it was down. When the bill is received, the chain calculates `contract.base_fee * bill.window / 3600 + variable fee` (keeping in mind the constraint for variable fee as outlined above), and this amount is transferred from the consumer twin account to the service twin account. + +We will not implement a grace period for this right now, as the service should define on an individual basis how this is handled. If needed in the future this can of course change. diff --git a/substrate-node/runtime/src/constants.rs b/substrate-node/runtime/src/constants.rs index 663311f44..2f64f9c7e 100644 --- a/substrate-node/runtime/src/constants.rs +++ b/substrate-node/runtime/src/constants.rs @@ -28,22 +28,6 @@ pub mod currency { } } -/// Time and blocks. -pub mod time { - use crate::BlockNumber; - pub const MILLISECS_PER_BLOCK: u64 = 6000; - pub const SLOT_DURATION: u64 = MILLISECS_PER_BLOCK; - pub const EPOCH_DURATION_IN_BLOCKS: BlockNumber = 1 * HOURS; - - // These time units are defined in number of blocks. - pub const MINUTES: BlockNumber = 60_000 / (MILLISECS_PER_BLOCK as BlockNumber); - pub const HOURS: BlockNumber = MINUTES * 60; - pub const DAYS: BlockNumber = HOURS * 24; - - // 1 in 4 blocks (on average, not counting collisions) will be primary babe blocks. - pub const PRIMARY_PROBABILITY: (u64, u64) = (1, 4); -} - /// Fee-related. pub mod fee { use crate::Balance; @@ -103,8 +87,8 @@ mod tests { let max_block_weight: u128 = (MaximumBlockWeight::get() as u128) * precision; let ext_base_weight: u128 = ExtrinsicBaseWeight::get() as u128; let x = WeightToFeeStruct::weight_to_fee(&MaximumBlockWeight::get()); - let cost_extrinsic:u128 = WeightToFeeStruct::weight_to_fee(&ExtrinsicBaseWeight::get()); - let y:u128 = (cost_extrinsic * (max_block_weight/ext_base_weight)) / precision; + let cost_extrinsic: u128 = WeightToFeeStruct::weight_to_fee(&ExtrinsicBaseWeight::get()); + let y: u128 = (cost_extrinsic * (max_block_weight / ext_base_weight)) / precision; // Difference should be less then the cost of an extrinsic devided by 2 as we can execute Z amount of extrinsics and Z was calculated by deviding // the max amount of weight per block by the weight of one extrinsic. That operation results in a loss of precision (from float to integer). assert!(x.max(y) - x.min(y) < cost_extrinsic / 2); diff --git a/substrate-node/runtime/src/lib.rs b/substrate-node/runtime/src/lib.rs index cbd97c088..dd0bdc3f1 100644 --- a/substrate-node/runtime/src/lib.rs +++ b/substrate-node/runtime/src/lib.rs @@ -27,6 +27,7 @@ use sp_std::{cmp::Ordering, prelude::*}; use sp_version::NativeVersion; use sp_version::RuntimeVersion; use tfchain_support::{ + constants::time::*, traits::{ChangeNode, PublicIpModifier}, types::PublicIP, }; @@ -149,21 +150,6 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { state_version: 0, }; -/// This determines the average expected block time that we are targeting. -/// Blocks will be produced at a minimum duration defined by `SLOT_DURATION`. -/// `SLOT_DURATION` is picked up by `pallet_timestamp` which is in turn picked -/// up by `pallet_aura` to implement `fn slot_duration()`. -/// -/// Change this to adjust the block time. -pub const MILLISECS_PER_BLOCK: u64 = 6000; - -pub const SLOT_DURATION: u64 = MILLISECS_PER_BLOCK; - -// Time is measured by number of blocks. -pub const MINUTES: BlockNumber = 60_000 / (MILLISECS_PER_BLOCK as BlockNumber); -pub const HOURS: BlockNumber = MINUTES * 60; -pub const DAYS: BlockNumber = HOURS * 24; - /// The version information used to identify this runtime when compiled natively. #[cfg(feature = "std")] pub fn native_version() -> NativeVersion { @@ -381,6 +367,7 @@ impl pallet_tfgrid::Config for Runtime { parameter_types! { pub StakingPoolAccount: AccountId = get_staking_pool_account(); pub BillingFrequency: u64 = 600; + pub BillingReferencePeriod: u64 = SECS_PER_HOUR; pub GracePeriod: u64 = (14 * DAYS).into(); pub DistributionFrequency: u16 = 24; pub RetryInterval: u32 = 20; @@ -401,6 +388,7 @@ impl pallet_smart_contract::Config for Runtime { type Currency = Balances; type StakingPoolAccount = StakingPoolAccount; type BillingFrequency = BillingFrequency; + type BillingReferencePeriod = BillingReferencePeriod; type DistributionFrequency = DistributionFrequency; type GracePeriod = GracePeriod; type WeightInfo = pallet_smart_contract::weights::SubstrateWeight; diff --git a/substrate-node/support/src/constants.rs b/substrate-node/support/src/constants.rs index 42aa0b462..709165c04 100644 --- a/substrate-node/support/src/constants.rs +++ b/substrate-node/support/src/constants.rs @@ -1,12 +1,19 @@ pub type BlockNumber = u32; /// Time and blocks. pub mod time { + /// This determines the average expected block time that we are targeting. + /// Blocks will be produced at a minimum duration defined by `SLOT_DURATION`. + /// `SLOT_DURATION` is picked up by `pallet_timestamp` which is in turn picked + /// up by `pallet_aura` to implement `fn slot_duration()`. + /// + /// Change this to adjust the block time. pub const MILLISECS_PER_BLOCK: u64 = 6000; pub const SLOT_DURATION: u64 = MILLISECS_PER_BLOCK; + pub const SECS_PER_HOUR: u64 = 3600; pub const EPOCH_DURATION_IN_BLOCKS: super::BlockNumber = 1 * HOURS; // These time units are defined in number of blocks. pub const MINUTES: super::BlockNumber = 60_000 / (MILLISECS_PER_BLOCK as super::BlockNumber); pub const HOURS: super::BlockNumber = MINUTES * 60; pub const DAYS: super::BlockNumber = HOURS * 24; -} \ No newline at end of file +}