diff --git a/runtime/common/src/lib.rs b/runtime/common/src/lib.rs index d32db38e05be..72a4ae3bfdd0 100644 --- a/runtime/common/src/lib.rs +++ b/runtime/common/src/lib.rs @@ -25,6 +25,7 @@ pub mod slot_range; pub mod registrar; pub mod slots; pub mod crowdfund; +pub mod purchase; pub mod impls; use primitives::v0::BlockNumber; diff --git a/runtime/common/src/purchase.rs b/runtime/common/src/purchase.rs new file mode 100644 index 000000000000..b1b61ebb439b --- /dev/null +++ b/runtime/common/src/purchase.rs @@ -0,0 +1,1014 @@ +// Copyright 2017-2020 Parity Technologies (UK) Ltd. +// This file is part of Polkadot. + +// Substrate is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Substrate is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Substrate. If not, see . + +//! Module to process purchase of DOTs. + +use codec::{Encode, Decode}; +use sp_runtime::{Permill, RuntimeDebug, DispatchResult, DispatchError, AnySignature}; +use sp_runtime::traits::{Zero, CheckedAdd, Verify, Saturating}; +use frame_support::{decl_event, decl_storage, decl_module, decl_error, ensure}; +use frame_support::traits::{ + EnsureOrigin, Currency, ExistenceRequirement, VestingSchedule, Get +}; +use system::ensure_signed; +use sp_core::sr25519; +use sp_std::prelude::*; + +/// Configuration trait. +pub trait Trait: system::Trait { + /// The overarching event type. + type Event: From> + Into<::Event>; + /// Balances Pallet + type Currency: Currency; + /// Vesting Pallet + type VestingSchedule: VestingSchedule; + /// The origin allowed to set account status. + type ValidityOrigin: EnsureOrigin; + /// The origin allowed to make configurations to the pallet. + type ConfigurationOrigin: EnsureOrigin; + /// The maximum statement length for the statement users to sign when creating an account. + type MaxStatementLength: Get; + /// The amount of purchased locked DOTs that we will unlock for basic actions on the chain. + type UnlockedProportion: Get; + /// The maximum amount of locked DOTs that we will unlock. + type MaxUnlocked: Get>; +} + +type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + +/// The kind of a statement an account needs to make for a claim to be valid. +#[derive(Encode, Decode, Clone, Copy, Eq, PartialEq, RuntimeDebug)] +pub enum AccountValidity { + /// Account is not valid. + Invalid, + /// Account has initiated the account creation process. + Initiated, + /// Account is pending validation. + Pending, + /// Account is valid with a low contribution amount. + ValidLow, + /// Account is valid with a high contribution amount. + ValidHigh, + /// Account has completed the purchase process. + Completed, +} + +impl Default for AccountValidity { + fn default() -> Self { + AccountValidity::Invalid + } +} + +impl AccountValidity { + fn is_valid(&self) -> bool { + match self { + Self::Invalid => false, + Self::Initiated => false, + Self::Pending => false, + Self::ValidLow => true, + Self::ValidHigh => true, + Self::Completed => false, + } + } +} + +/// All information about an account regarding the purchase of DOTs. +#[derive(Encode, Decode, Default, Clone, Eq, PartialEq, RuntimeDebug)] +pub struct AccountStatus { + /// The current validity status of the user. Will denote if the user has passed KYC, + /// how much they are able to purchase, and when their purchase process has completed. + validity: AccountValidity, + /// The amount of free DOTs they have purchased. + free_balance: Balance, + /// The amount of locked DOTs they have purchased. + locked_balance: Balance, + /// Their sr25519/ed25519 signature verifying they have signed our required statement. + signature: Vec, + /// The percentage of VAT the purchaser is responsible for. This is already factored into account balance. + vat: Permill, +} + +decl_event!( + pub enum Event where + AccountId = ::AccountId, + Balance = BalanceOf, + BlockNumber = ::BlockNumber, + { + /// A new account was created + AccountCreated(AccountId), + /// Someone's account validity was updated + ValidityUpdated(AccountId, AccountValidity), + /// Someone's purchase balance was updated. (Free, Locked) + BalanceUpdated(AccountId, Balance, Balance), + /// A payout was made to a purchaser. + PaymentComplete(AccountId, Balance, Balance), + /// A new payment account was set. + PaymentAccountSet(AccountId), + /// A new statement was set. + StatementUpdated, + /// A new statement was set. + UnlockBlockUpdated(BlockNumber), + } +); + +decl_error! { + pub enum Error for Module { + /// Account is not currently valid to use. + InvalidAccount, + /// Account used in the purchase already exists. + ExistingAccount, + /// Provided signature is invalid + InvalidSignature, + /// Account has already completed the purchase process. + AlreadyCompleted, + /// An overflow occurred when doing calculations. + Overflow, + /// The statement is too long to be stored on chain. + InvalidStatement, + /// The unlock block is in the past! + InvalidUnlockBlock, + /// Vesting schedule already exists for this account. + VestingScheduleExists, + } +} + +decl_storage! { + trait Store for Module as Purchase { + // A map of all participants in the DOT purchase process. + Accounts: map hasher(blake2_128_concat) T::AccountId => AccountStatus>; + // The account that will be used to payout participants of the DOT purchase process. + PaymentAccount: T::AccountId; + // The statement purchasers will need to sign to participate. + Statement: Vec; + // The block where all locked dots will unlock. + UnlockBlock: T::BlockNumber; + } +} + +decl_module! { + pub struct Module for enum Call where origin: T::Origin, system = system { + type Error = Error; + + /// The maximum statement length for the statement users to sign when creating an account. + const MaxStatementLength: u32 = T::MaxStatementLength::get() as u32; + /// The amount of purchased locked DOTs that we will unlock for basic actions on the chain. + const UnlockedProportion: Permill = T::UnlockedProportion::get(); + /// The maximum amount of locked DOTs that we will unlock. + const MaxUnlocked: BalanceOf = T::MaxUnlocked::get(); + + /// Deposit one of this module's events by using the default implementation. + fn deposit_event() = default; + + /// Create a new account. Proof of existence through a valid signed message. + /// + /// We check that the account does not exist at this stage. + /// + /// Origin must match the `ValidityOrigin`. + #[weight = 200_000_000 + T::DbWeight::get().reads_writes(4, 1)] + fn create_account(origin, + who: T::AccountId, + signature: Vec + ) { + T::ValidityOrigin::ensure_origin(origin)?; + // Account is already being tracked by the pallet. + ensure!(!Accounts::::contains_key(&who), Error::::ExistingAccount); + // Account should not have a vesting schedule. + ensure!(T::VestingSchedule::vesting_balance(&who).is_none(), Error::::VestingScheduleExists); + + // Verify the signature provided is valid for the statement. + Self::verify_signature(&who, &signature)?; + + // Create a new pending account. + let status = AccountStatus { + validity: AccountValidity::Initiated, + signature, + free_balance: Zero::zero(), + locked_balance: Zero::zero(), + vat: Permill::zero(), + }; + Accounts::::insert(&who, status); + Self::deposit_event(RawEvent::AccountCreated(who)); + } + + /// Update the validity status of an existing account. If set to completed, the account + /// will no longer be able to continue through the crowdfund process. + /// + /// We check tht the account exists at this stage, but has not completed the process. + /// + /// Origin must match the `ValidityOrigin`. + #[weight = T::DbWeight::get().reads_writes(1, 1)] + fn update_validity_status(origin, + who: T::AccountId, + validity: AccountValidity + ) { + T::ValidityOrigin::ensure_origin(origin)?; + ensure!(Accounts::::contains_key(&who), Error::::InvalidAccount); + Accounts::::try_mutate(&who, |status: &mut AccountStatus>| -> DispatchResult { + ensure!(status.validity != AccountValidity::Completed, Error::::AlreadyCompleted); + status.validity = validity; + Ok(()) + })?; + Self::deposit_event(RawEvent::ValidityUpdated(who, validity)); + } + + /// Update the balance of a valid account. + /// + /// We check tht the account is valid for a balance transfer at this point. + /// + /// Origin must match the `ValidityOrigin`. + #[weight = T::DbWeight::get().reads_writes(2, 1)] + fn update_balance(origin, + who: T::AccountId, + free_balance: BalanceOf, + locked_balance: BalanceOf, + vat: Permill, + ) { + T::ValidityOrigin::ensure_origin(origin)?; + + Accounts::::try_mutate(&who, |status: &mut AccountStatus>| -> DispatchResult { + // Account has a valid status (not Invalid, Pending, or Completed)... + ensure!(status.validity.is_valid(), Error::::InvalidAccount); + + free_balance.checked_add(&locked_balance).ok_or(Error::::Overflow)?; + status.free_balance = free_balance; + status.locked_balance = locked_balance; + status.vat = vat; + Ok(()) + })?; + Self::deposit_event(RawEvent::BalanceUpdated(who, free_balance, locked_balance)); + } + + /// Pay the user and complete the purchase process. + /// + /// We reverify all assumptions about the state of an account, and complete the process. + /// + /// Origin must match the configured `PaymentAccount`. + #[weight = T::DbWeight::get().reads_writes(4, 2)] + fn payout(origin, who: T::AccountId) { + // Payments must be made directly by the `PaymentAccount`. + let payment_account = ensure_signed(origin)?; + ensure!(payment_account == PaymentAccount::::get(), DispatchError::BadOrigin); + + // Account should not have a vesting schedule. + ensure!(T::VestingSchedule::vesting_balance(&who).is_none(), Error::::VestingScheduleExists); + + Accounts::::try_mutate(&who, |status: &mut AccountStatus>| -> DispatchResult { + // Account has a valid status (not Invalid, Pending, or Completed)... + ensure!(status.validity.is_valid(), Error::::InvalidAccount); + + // Transfer funds from the payment account into the purchasing user. + let total_balance = status.free_balance + .checked_add(&status.locked_balance) + .ok_or(Error::::Overflow)?; + T::Currency::transfer(&payment_account, &who, total_balance, ExistenceRequirement::AllowDeath)?; + + if !status.locked_balance.is_zero() { + let unlock_block = UnlockBlock::::get(); + // We allow some configurable portion of the purchased locked DOTs to be unlocked for basic usage. + let unlocked = (T::UnlockedProportion::get() * status.locked_balance).min(T::MaxUnlocked::get()); + let locked = status.locked_balance.saturating_sub(unlocked); + // We checked that this account has no existing vesting schedule. So this function should + // never fail, however if it does, not much we can do about it at this point. + let _ = T::VestingSchedule::add_vesting_schedule( + // Apply vesting schedule to this user + &who, + // For this much amount + locked, + // Unlocking the full amount after one block + locked, + // When everything unlocks + unlock_block + ); + } + + // Setting the user account to `Completed` ends the purchase process for this user. + status.validity = AccountValidity::Completed; + Self::deposit_event(RawEvent::PaymentComplete(who.clone(), status.free_balance, status.locked_balance)); + Ok(()) + })?; + } + + /* Configuration Operations */ + + /// Set the account that will be used to payout users in the DOT purchase process. + /// + /// Origin must match the `ConfigurationOrigin` + #[weight = T::DbWeight::get().writes(1)] + fn set_payment_account(origin, who: T::AccountId) { + T::ConfigurationOrigin::ensure_origin(origin)?; + // Possibly this is worse than having the caller account be the payment account? + PaymentAccount::::set(who.clone()); + Self::deposit_event(RawEvent::PaymentAccountSet(who)); + } + + /// Set the statement that must be signed for a user to participate on the DOT sale. + /// + /// Origin must match the `ConfigurationOrigin` + #[weight = T::DbWeight::get().writes(1)] + fn set_statement(origin, statement: Vec) { + T::ConfigurationOrigin::ensure_origin(origin)?; + ensure!(statement.len() < T::MaxStatementLength::get(), Error::::InvalidStatement); + // Possibly this is worse than having the caller account be the payment account? + Statement::set(statement); + Self::deposit_event(RawEvent::StatementUpdated); + } + + /// Set the block where locked DOTs will become unlocked. + /// + /// Origin must match the `ConfigurationOrigin` + #[weight = T::DbWeight::get().writes(1)] + fn set_unlock_block(origin, unlock_block: T::BlockNumber) { + T::ConfigurationOrigin::ensure_origin(origin)?; + ensure!(unlock_block > system::Module::::block_number(), Error::::InvalidUnlockBlock); + // Possibly this is worse than having the caller account be the payment account? + UnlockBlock::::set(unlock_block); + Self::deposit_event(RawEvent::UnlockBlockUpdated(unlock_block)); + } + } +} + +impl Module { + fn verify_signature(who: &T::AccountId, signature: &[u8]) -> Result<(), DispatchError> { + // sr25519 always expects a 64 byte signature. + ensure!(signature.len() == 64, Error::::InvalidSignature); + let signature: AnySignature = sr25519::Signature::from_slice(signature).into(); + + // In Polkadot, the AccountId is always the same as the 32 byte public key. + let account_bytes: [u8; 32] = account_to_bytes(who)?; + let public_key = sr25519::Public::from_raw(account_bytes); + + let message = Statement::get(); + + // Check if everything is good or not. + match signature.verify(message.as_slice(), &public_key) { + true => Ok(()), + false => Err(Error::::InvalidSignature)?, + } + } +} + +// This function converts a 32 byte AccountId to its byte-array equivalent form. +fn account_to_bytes(account: &AccountId) -> Result<[u8; 32], DispatchError> + where AccountId: Encode, +{ + let account_vec = account.encode(); + ensure!(account_vec.len() == 32, "AccountId must be 32 bytes."); + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(&account_vec); + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + use sp_core::{H256, Pair, Public, crypto::AccountId32, ed25519}; + // The testing primitives are very useful for avoiding having to work with signatures + // or public keys. `u64` is used as the `AccountId` and no `Signature`s are required. + use sp_runtime::{ + Perbill, MultiSignature, + traits::{BlakeTwo256, IdentityLookup, Identity, Verify, IdentifyAccount, Dispatchable}, + testing::Header + }; + use frame_support::{ + impl_outer_origin, impl_outer_dispatch, assert_ok, assert_noop, parameter_types, + ord_parameter_types, dispatch::DispatchError::BadOrigin, + }; + use frame_support::traits::Currency; + use balances::Error as BalancesError; + + impl_outer_origin! { + pub enum Origin for Test {} + } + + impl_outer_dispatch! { + pub enum Call for Test where origin: Origin { + purchase::Purchase, + vesting::Vesting, + } + } + + type AccountId = AccountId32; + + // For testing the module, we construct most of a mock runtime. This means + // first constructing a configuration type (`Test`) which `impl`s each of the + // configuration traits of modules we want to use. + #[derive(Clone, Eq, PartialEq)] + pub struct Test; + parameter_types! { + pub const BlockHashCount: u32 = 250; + pub const MaximumBlockWeight: u32 = 4 * 1024 * 1024; + pub const MaximumBlockLength: u32 = 4 * 1024 * 1024; + pub const AvailableBlockRatio: Perbill = Perbill::from_percent(75); + } + impl system::Trait for Test { + type BaseCallFilter = (); + type Origin = Origin; + type Call = Call; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type Event = (); + type BlockHashCount = BlockHashCount; + type MaximumBlockWeight = MaximumBlockWeight; + type DbWeight = (); + type BlockExecutionWeight = (); + type ExtrinsicBaseWeight = (); + type MaximumExtrinsicWeight = MaximumBlockWeight; + type MaximumBlockLength = MaximumBlockLength; + type AvailableBlockRatio = AvailableBlockRatio; + type Version = (); + type ModuleToIndex = (); + type AccountData = balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = Balances; + type SystemWeightInfo = (); + } + + parameter_types! { + pub const ExistentialDeposit: u64 = 1; + } + + impl balances::Trait for Test { + type Balance = u64; + type Event = (); + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + } + + parameter_types! { + pub const MinVestedTransfer: u64 = 0; + } + + impl vesting::Trait for Test { + type Event = (); + type Currency = Balances; + type BlockNumberToBalance = Identity; + type MinVestedTransfer = MinVestedTransfer; + type WeightInfo = (); + } + + parameter_types! { + pub const MaxStatementLength: usize = 1_000; + pub const UnlockedProportion: Permill = Permill::from_percent(10); + pub const MaxUnlocked: u64 = 10; + } + + ord_parameter_types! { + pub const ValidityOrigin: AccountId = AccountId32::from([0u8; 32]); + pub const PaymentOrigin: AccountId = AccountId32::from([1u8; 32]); + pub const ConfigurationOrigin: AccountId = AccountId32::from([2u8; 32]); + } + + impl Trait for Test { + type Event = (); + type Currency = Balances; + type VestingSchedule = Vesting; + type ValidityOrigin = system::EnsureSignedBy; + type ConfigurationOrigin = system::EnsureSignedBy; + type MaxStatementLength = MaxStatementLength; + type UnlockedProportion = UnlockedProportion; + type MaxUnlocked = MaxUnlocked; + } + + type System = system::Module; + type Balances = balances::Module; + type Vesting = vesting::Module; + type Purchase = Module; + + // This function basically just builds a genesis storage key/value store according to + // our desired mockup. It also executes our `setup` function which sets up this pallet for use. + pub fn new_test_ext() -> sp_io::TestExternalities { + let t = system::GenesisConfig::default().build_storage::().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| setup()); + ext + } + + fn setup() { + let statement = b"Hello, World".to_vec(); + let unlock_block = 100; + Purchase::set_statement(Origin::signed(configuration_origin()), statement).unwrap(); + Purchase::set_unlock_block(Origin::signed(configuration_origin()), unlock_block).unwrap(); + Purchase::set_payment_account(Origin::signed(configuration_origin()), payment_account()).unwrap(); + Balances::make_free_balance_be(&payment_account(), 100_000); + } + + type AccountPublic = ::Signer; + + /// Helper function to generate a crypto pair from seed + fn get_from_seed(seed: &str) -> ::Public { + TPublic::Pair::from_string(&format!("//{}", seed), None) + .expect("static values are valid; qed") + .public() + } + + /// Helper function to generate an account ID from seed + fn get_account_id_from_seed(seed: &str) -> AccountId where + AccountPublic: From<::Public> + { + AccountPublic::from(get_from_seed::(seed)).into_account() + } + + fn alice() -> AccountId { + get_account_id_from_seed::("Alice") + } + + fn alice_ed25519() -> AccountId { + get_account_id_from_seed::("Alice") + } + + fn bob() -> AccountId { + get_account_id_from_seed::("Bob") + } + + fn alice_signature() -> [u8; 64] { + // echo -n "Hello, World" | subkey -s sign "bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice" + hex_literal::hex!("20e0faffdf4dfe939f2faa560f73b1d01cde8472e2b690b7b40606a374244c3a2e9eb9c8107c10b605138374003af8819bd4387d7c24a66ee9253c2e688ab881") + } + + fn bob_signature() -> [u8; 64] { + // echo -n "Hello, World" | subkey -s sign "bottom drive obey lake curtain smoke basket hold race lonely fit walk//Bob" + hex_literal::hex!("d6d460187ecf530f3ec2d6e3ac91b9d083c8fbd8f1112d92a82e4d84df552d18d338e6da8944eba6e84afaacf8a9850f54e7b53a84530d649be2e0119c7ce889") + } + + fn alice_signature_ed25519() -> [u8; 64] { + // echo -n "Hello, World" | subkey -e sign "bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice" + hex_literal::hex!("ee3f5a6cbfc12a8f00c18b811dc921b550ddf272354cda4b9a57b1d06213fcd8509f5af18425d39a279d13622f14806c3e978e2163981f2ec1c06e9628460b0e") + } + + fn validity_origin() -> AccountId { + ValidityOrigin::get() + } + + fn configuration_origin() -> AccountId { + ConfigurationOrigin::get() + } + + fn payment_account() -> AccountId { + [42u8; 32].into() + } + + #[test] + fn set_statement_works_and_handles_basic_errors() { + new_test_ext().execute_with(|| { + let statement = b"Test Set Statement".to_vec(); + // Invalid origin + assert_noop!( + Purchase::set_statement(Origin::signed(alice()), statement.clone()), + BadOrigin, + ); + // Too Long + let long_statement = [0u8; 10_000].to_vec(); + assert_noop!( + Purchase::set_statement(Origin::signed(configuration_origin()), long_statement), + Error::::InvalidStatement, + ); + // Just right... + assert_ok!(Purchase::set_statement(Origin::signed(configuration_origin()), statement.clone())); + assert_eq!(Statement::get(), statement); + }); + } + + #[test] + fn set_unlock_block_works_and_handles_basic_errors() { + new_test_ext().execute_with(|| { + let unlock_block = 69; + // Invalid origin + assert_noop!( + Purchase::set_unlock_block(Origin::signed(alice()), unlock_block), + BadOrigin, + ); + // Block Number in Past + let bad_unlock_block = 50; + System::set_block_number(bad_unlock_block); + assert_noop!( + Purchase::set_unlock_block(Origin::signed(configuration_origin()), bad_unlock_block), + Error::::InvalidUnlockBlock, + ); + // Just right... + assert_ok!(Purchase::set_unlock_block(Origin::signed(configuration_origin()), unlock_block)); + assert_eq!(UnlockBlock::::get(), unlock_block); + }); + } + + #[test] + fn set_payment_account_works_and_handles_basic_errors() { + new_test_ext().execute_with(|| { + let payment_account: AccountId = [69u8; 32].into(); + // Invalid Origin + assert_noop!( + Purchase::set_payment_account(Origin::signed(alice()), payment_account.clone()), + BadOrigin, + ); + // Just right... + assert_ok!(Purchase::set_payment_account(Origin::signed(configuration_origin()), payment_account.clone())); + assert_eq!(PaymentAccount::::get(), payment_account); + }); + } + + #[test] + fn signature_verification_works() { + new_test_ext().execute_with(|| { + assert_ok!(Purchase::verify_signature(&alice(), &alice_signature())); + assert_ok!(Purchase::verify_signature(&alice_ed25519(), &alice_signature_ed25519())); + assert_ok!(Purchase::verify_signature(&bob(), &bob_signature())); + + // Mixing and matching fails + assert_noop!(Purchase::verify_signature(&alice(), &bob_signature()), Error::::InvalidSignature); + assert_noop!(Purchase::verify_signature(&bob(), &alice_signature()), Error::::InvalidSignature); + }); + } + + #[test] + fn account_creation_works() { + new_test_ext().execute_with(|| { + assert!(!Accounts::::contains_key(alice())); + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec(), + )); + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::Initiated, + free_balance: Zero::zero(), + locked_balance: Zero::zero(), + signature: alice_signature().to_vec(), + vat: Permill::zero(), + } + ); + }); + } + + #[test] + fn account_creation_handles_basic_errors() { + new_test_ext().execute_with(|| { + // Wrong Origin + assert_noop!( + Purchase::create_account(Origin::signed(alice()), alice(), alice_signature().to_vec()), + BadOrigin, + ); + + // Wrong Account/Signature + assert_noop!( + Purchase::create_account(Origin::signed(validity_origin()), alice(), bob_signature().to_vec()), + Error::::InvalidSignature, + ); + + // Account with vesting + assert_ok!(::VestingSchedule::add_vesting_schedule( + &alice(), + 100, + 1, + 50 + )); + assert_noop!( + Purchase::create_account(Origin::signed(validity_origin()), alice(), alice_signature().to_vec()), + Error::::VestingScheduleExists, + ); + + // Duplicate Purchasing Account + assert_ok!( + Purchase::create_account(Origin::signed(validity_origin()), bob(), bob_signature().to_vec()) + ); + assert_noop!( + Purchase::create_account(Origin::signed(validity_origin()), bob(), bob_signature().to_vec()), + Error::::ExistingAccount, + ); + }); + } + + #[test] + fn update_validity_status_works() { + new_test_ext().execute_with(|| { + // Alice account is created. + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec(), + )); + // She submits KYC, and we update the status to `Pending`. + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::Pending, + )); + // KYC comes back negative, so we mark the account invalid. + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::Invalid, + )); + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::Invalid, + free_balance: Zero::zero(), + locked_balance: Zero::zero(), + signature: alice_signature().to_vec(), + vat: Permill::zero(), + } + ); + // She fixes it, we mark her account valid. + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::ValidLow, + )); + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::ValidLow, + free_balance: Zero::zero(), + locked_balance: Zero::zero(), + signature: alice_signature().to_vec(), + vat: Permill::zero(), + } + ); + }); + } + + #[test] + fn update_validity_status_handles_basic_errors() { + new_test_ext().execute_with(|| { + // Wrong Origin + assert_noop!(Purchase::update_validity_status( + Origin::signed(alice()), + alice(), + AccountValidity::Pending, + ), BadOrigin); + // Inactive Account + assert_noop!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::Pending, + ), Error::::InvalidAccount); + // Already Completed + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec(), + )); + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::Completed, + )); + assert_noop!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::Pending, + ), Error::::AlreadyCompleted); + }); + } + + #[test] + fn update_balance_works() { + new_test_ext().execute_with(|| { + // Alice account is created + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec()) + ); + // And approved for basic contribution + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::ValidLow, + )); + // We set a balance on the user based on the payment they made. 50 locked, 50 free. + assert_ok!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + 50, + 50, + Permill::from_rational_approximation(77u32, 1000u32), + )); + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::ValidLow, + free_balance: 50, + locked_balance: 50, + signature: alice_signature().to_vec(), + vat: Permill::from_parts(77000), + } + ); + // We can update the balance based on new information. + assert_ok!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + 25, + 50, + Permill::zero(), + )); + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::ValidLow, + free_balance: 25, + locked_balance: 50, + signature: alice_signature().to_vec(), + vat: Permill::zero(), + } + ); + }); + } + + #[test] + fn update_balance_handles_basic_errors() { + new_test_ext().execute_with(|| { + // Wrong Origin + assert_noop!(Purchase::update_balance( + Origin::signed(alice()), + alice(), + 50, + 50, + Permill::zero(), + ), BadOrigin); + // Inactive Account + assert_noop!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + 50, + 50, + Permill::zero(), + ), Error::::InvalidAccount); + // Overflow + assert_noop!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + u64::max_value(), + u64::max_value(), + Permill::zero(), + ), Error::::InvalidAccount); + }); + } + + #[test] + fn payout_works() { + new_test_ext().execute_with(|| { + // Alice and Bob accounts are created + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec()) + ); + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + bob(), + bob_signature().to_vec()) + ); + // Alice is approved for basic contribution + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::ValidLow, + )); + // Bob is approved for high contribution + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + bob(), + AccountValidity::ValidHigh, + )); + // We set a balance on the users based on the payment they made. 50 locked, 50 free. + assert_ok!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + 50, + 50, + Permill::zero(), + )); + assert_ok!(Purchase::update_balance( + Origin::signed(validity_origin()), + bob(), + 100, + 150, + Permill::zero(), + )); + // Now we call payout for Alice and Bob. + assert_ok!(Purchase::payout( + Origin::signed(payment_account()), + alice(), + )); + assert_ok!(Purchase::payout( + Origin::signed(payment_account()), + bob(), + )); + // Payment is made. + assert_eq!(::Currency::free_balance(&payment_account()), 99_650); + assert_eq!(::Currency::free_balance(&alice()), 100); + // 10% of the 50 units is unlocked automatically for Alice + assert_eq!(::VestingSchedule::vesting_balance(&alice()), Some(45)); + assert_eq!(::Currency::free_balance(&bob()), 250); + // A max of 10 units is unlocked automatically for Bob + assert_eq!(::VestingSchedule::vesting_balance(&bob()), Some(140)); + // Status is completed. + assert_eq!( + Accounts::::get(alice()), + AccountStatus { + validity: AccountValidity::Completed, + free_balance: 50, + locked_balance: 50, + signature: alice_signature().to_vec(), + vat: Permill::zero(), + } + ); + assert_eq!( + Accounts::::get(bob()), + AccountStatus { + validity: AccountValidity::Completed, + free_balance: 100, + locked_balance: 150, + signature: bob_signature().to_vec(), + vat: Permill::zero(), + } + ); + // Vesting lock is removed in whole on block 101 (100 blocks after block 1) + System::set_block_number(100); + let vest_call = Call::Vesting(vesting::Call::::vest()); + assert_ok!(vest_call.clone().dispatch(Origin::signed(alice()))); + assert_ok!(vest_call.clone().dispatch(Origin::signed(bob()))); + assert_eq!(::VestingSchedule::vesting_balance(&alice()), Some(45)); + assert_eq!(::VestingSchedule::vesting_balance(&bob()), Some(140)); + System::set_block_number(101); + assert_ok!(vest_call.clone().dispatch(Origin::signed(alice()))); + assert_ok!(vest_call.clone().dispatch(Origin::signed(bob()))); + assert_eq!(::VestingSchedule::vesting_balance(&alice()), None); + assert_eq!(::VestingSchedule::vesting_balance(&bob()), None); + }); + } + + #[test] + fn payout_handles_basic_errors() { + new_test_ext().execute_with(|| { + // Wrong Origin + assert_noop!(Purchase::payout( + Origin::signed(alice()), + alice(), + ), BadOrigin); + // Account with Existing Vesting Schedule + assert_ok!(::VestingSchedule::add_vesting_schedule( + &bob(), 100, 1, 50, + )); + assert_noop!(Purchase::payout( + Origin::signed(payment_account()), + bob(), + ), Error::::VestingScheduleExists); + // Invalid Account (never created) + assert_noop!(Purchase::payout( + Origin::signed(payment_account()), + alice(), + ), Error::::InvalidAccount); + // Invalid Account (created, but not valid) + assert_ok!(Purchase::create_account( + Origin::signed(validity_origin()), + alice(), + alice_signature().to_vec()) + ); + assert_noop!(Purchase::payout( + Origin::signed(payment_account()), + alice(), + ), Error::::InvalidAccount); + // Not enough funds in payment account + assert_ok!(Purchase::update_validity_status( + Origin::signed(validity_origin()), + alice(), + AccountValidity::ValidHigh, + )); + assert_ok!(Purchase::update_balance( + Origin::signed(validity_origin()), + alice(), + 100_000, + 100_000, + Permill::zero(), + )); + assert_noop!(Purchase::payout( + Origin::signed(payment_account()), + alice(), + ), BalancesError::::InsufficientBalance); + }); + } +} diff --git a/runtime/polkadot/Cargo.toml b/runtime/polkadot/Cargo.toml index 2c8373cb8314..fa6d8efb5dd6 100644 --- a/runtime/polkadot/Cargo.toml +++ b/runtime/polkadot/Cargo.toml @@ -68,7 +68,7 @@ frame-benchmarking = { git = "https://github.com/paritytech/substrate", branch = frame-system-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "master", default-features = false, optional = true } pallet-offences-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "master", default-features = false, optional = true } pallet-session-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "master", default-features = false, optional = true } -hex-literal = { version = "0.2.1", optional = true } +hex-literal = { version = "0.2.1" } runtime-common = { package = "polkadot-runtime-common", path = "../common", default-features = false } primitives = { package = "polkadot-primitives", path = "../../primitives", default-features = false } @@ -163,7 +163,8 @@ runtime-benchmarks = [ "vesting/runtime-benchmarks", "pallet-offences-benchmarking", "pallet-session-benchmarking", - "hex-literal", + # renable when optional + # "hex-literal", ] # When enabled, the runtime api will not be build. # diff --git a/runtime/polkadot/src/lib.rs b/runtime/polkadot/src/lib.rs index 177bf7e26e37..5e1e29262573 100644 --- a/runtime/polkadot/src/lib.rs +++ b/runtime/polkadot/src/lib.rs @@ -25,7 +25,7 @@ use runtime_common::{ impls::{CurrencyToVoteHandler, ToAuthor}, NegativeImbalance, BlockHashCount, MaximumBlockWeight, AvailableBlockRatio, MaximumBlockLength, BlockExecutionWeight, ExtrinsicBaseWeight, RocksDbWeight, - MaximumExtrinsicWeight, + MaximumExtrinsicWeight, purchase, }; use sp_std::prelude::*; @@ -57,11 +57,11 @@ use version::NativeVersion; use sp_core::OpaqueMetadata; use sp_staking::SessionIndex; use frame_support::{ - parameter_types, construct_runtime, debug, RuntimeDebug, + parameter_types, ord_parameter_types, construct_runtime, debug, RuntimeDebug, traits::{KeyOwnerProofSystem, SplitTwoWays, Randomness, LockIdentifier, Filter}, weights::Weight, }; -use system::{EnsureRoot, EnsureOneOf}; +use system::{EnsureRoot, EnsureOneOf, EnsureSignedBy}; use im_online::sr25519::AuthorityId as ImOnlineId; use authority_discovery_primitives::AuthorityId as AuthorityDiscoveryId; use transaction_payment_rpc_runtime_api::RuntimeDispatchInfo; @@ -135,7 +135,8 @@ impl Filter for BaseFilter { Call::Session(_) | Call::FinalityTracker(_) | Call::Grandpa(_) | Call::ImOnline(_) | Call::AuthorityDiscovery(_) | Call::Utility(_) | Call::Claims(_) | Call::Vesting(_) | Call::Sudo(_) | - Call::Identity(_) | Call::Proxy(_) | Call::Multisig(_) | Call::Poll(_) => + Call::Identity(_) | Call::Proxy(_) | Call::Multisig(_) | Call::Poll(_) | + Call::Purchase(_) => true, } } @@ -944,6 +945,46 @@ impl poll::Trait for Runtime { type End = PollEnd; } +parameter_types! { + pub const MaxStatementLength: usize = 1_000; + pub const UnlockedProportion: Permill = Permill::zero(); + pub const MaxUnlocked: Balance = 0; +} + +ord_parameter_types! { + pub const W3FValidity: AccountId = AccountId::from( + // 142wAF65SK7PxhyzzrWz5m5PXDtooehgePBd7rc2NWpfc8Wa + hex_literal::hex!("862e432e0cf75693899c62691ac0f48967f815add97ae85659dcde8332708551") + ); + pub const W3FConfiguration: AccountId = AccountId::from( + // 1KvKReVmUiTc2LW2a4qyHsaJJ9eE9LRsywZkMk5hyBeyHgw + hex_literal::hex!("0e6de68b13b82479fbe988ab9ecb16bad446b67b993cdd9198cd41c7c6259c49") + ); +} + +type ValidityOrigin = EnsureOneOf< + AccountId, + EnsureRoot, + EnsureSignedBy, +>; + +type ConfigurationOrigin = EnsureOneOf< + AccountId, + EnsureRoot, + EnsureSignedBy, +>; + +impl purchase::Trait for Runtime { + type Event = Event; + type Currency = Balances; + type VestingSchedule = Vesting; + type ValidityOrigin = ValidityOrigin; + type ConfigurationOrigin = ConfigurationOrigin; + type MaxStatementLength = MaxStatementLength; + type UnlockedProportion = UnlockedProportion; + type MaxUnlocked = MaxUnlocked; +} + construct_runtime! { pub enum Runtime where Block = Block, @@ -1012,6 +1053,9 @@ construct_runtime! { // Poll module. Poll: poll::{Module, Call, Storage, Event}, + + // DOT Purchase module. Late addition. + Purchase: purchase::{Module, Call, Storage, Event}, } }