diff --git a/Cargo.lock b/Cargo.lock index 582a28358..9eaa1db05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3170,6 +3170,7 @@ dependencies = [ "sp-core", "sp-finality-grandpa", "sp-inherents", + "sp-io", "sp-keystore", "sp-panic-handler", "sp-runtime", @@ -3247,6 +3248,7 @@ dependencies = [ "frame-system-rpc-runtime-api", "frame-try-runtime", "frontier-api", + "hex", "hex-literal", "keystore-bioauth-account-id", "libsecp256k1 0.7.0", @@ -3286,6 +3288,7 @@ dependencies = [ "robonode-crypto", "scale-info", "serde", + "serde_json", "sp-api", "sp-application-crypto", "sp-block-builder", diff --git a/crates/humanode-peer/Cargo.toml b/crates/humanode-peer/Cargo.toml index 5247a00f6..aceec64c4 100644 --- a/crates/humanode-peer/Cargo.toml +++ b/crates/humanode-peer/Cargo.toml @@ -79,6 +79,7 @@ url = "2" [dev-dependencies] indoc = "1.0" +sp-io = { git = "https://github.com/humanode-network/substrate", branch = "master" } [build-dependencies] vergen = { version = "7", default-features = false, features = ["cargo", "git"] } diff --git a/crates/humanode-peer/src/chain_spec.rs b/crates/humanode-peer/src/chain_spec.rs index 46423b65a..35de22243 100644 --- a/crates/humanode-peer/src/chain_spec.rs +++ b/crates/humanode-peer/src/chain_spec.rs @@ -355,9 +355,29 @@ fn testnet_genesis( #[cfg(test)] mod tests { use indoc::indoc; + use sp_runtime::BuildStorage; use super::*; + fn assert_genesis_config(chain_spec_result: Result) { + chain_spec_result.unwrap().build_storage().unwrap(); + } + + #[test] + fn local_testnet_config_works() { + assert_genesis_config(local_testnet_config()); + } + + #[test] + fn development_config_works() { + assert_genesis_config(development_config()); + } + + #[test] + fn benchmark_config_works() { + assert_genesis_config(benchmark_config()); + } + #[test] fn deserialize_bioauth_flow_params_extensions() { let expected = Extensions { diff --git a/crates/humanode-runtime/Cargo.toml b/crates/humanode-runtime/Cargo.toml index 3dc27417f..f347099b8 100644 --- a/crates/humanode-runtime/Cargo.toml +++ b/crates/humanode-runtime/Cargo.toml @@ -90,6 +90,8 @@ static_assertions = { version = "1.1.0", default-features = false } crypto-utils = { version = "0.1", path = "../crypto-utils" } eip712-common-test-utils = { version = "0.1", path = "../eip712-common-test-utils" } +hex = "0.4" +serde_json = "1" sp-io = { git = "https://github.com/humanode-network/substrate", branch = "master" } [features] diff --git a/crates/humanode-runtime/src/tests/claims_and_vesting.rs b/crates/humanode-runtime/src/tests/claims_and_vesting.rs new file mode 100644 index 000000000..b782e33ae --- /dev/null +++ b/crates/humanode-runtime/src/tests/claims_and_vesting.rs @@ -0,0 +1,1115 @@ +//! Tests to verify token claims and vesting logic. + +use eip712_common::EcdsaSignature; +use eip712_common_test_utils::{ecdsa_pair, ecdsa_sign, ethereum_address_from_seed, U256}; +use fp_self_contained::{CheckedExtrinsic, CheckedSignature}; +use frame_support::{ + assert_noop, assert_ok, assert_storage_noop, + pallet_prelude::InvalidTransaction, + traits::{OnFinalize, OnInitialize}, + weights::{DispatchClass, DispatchInfo, Pays}, +}; +use sp_runtime::{traits::Applyable, ModuleError}; +use vesting_schedule_linear::LinearSchedule; + +use super::*; +use crate::{ + dev_utils::{account_id, authority_keys}, + eip712::genesis_verifying_contract, + opaque::SessionKeys, + token_claims::types::ClaimInfo, +}; + +const INIT_BALANCE: u128 = 10u128.pow(18 + 6); +const VESTING_BALANCE: u128 = 1000; + +const START_TIMESTAMP: u64 = 1000; +const CLIFF: u64 = 1000; +const VESTING_DURATION: u64 = 3000; + +fn set_timestamp(inc: UnixMilliseconds) { + Timestamp::set(Origin::none(), inc).unwrap(); +} + +fn switch_block() { + if System::block_number() != 0 { + AllPalletsWithSystem::on_finalize(System::block_number()); + } + System::set_block_number(System::block_number() + 1); + AllPalletsWithSystem::on_initialize(System::block_number()); +} + +fn sign_sample_token_claim( + seed: &[u8], + account_id: AccountId, +) -> (EthereumAddress, EcdsaSignature) { + let chain_id: [u8; 32] = U256::from(EthereumChainId::get()).into(); + let verifying_contract = genesis_verifying_contract(); + let domain = eip712_common::Domain { + name: "Humanode Token Claim", + version: "1", + chain_id: &chain_id, + verifying_contract: &verifying_contract, + }; + + let pair = ecdsa_pair(seed); + let msg_hash = eip712_token_claim::make_message_hash(domain, account_id.as_ref()); + ( + ethereum_address_from_seed(seed), + ecdsa_sign(&pair, &msg_hash), + ) +} + +// We can avoid using signed extrinsic in assumption that it's already checked. So, we can just operate +// `CheckedExtrinsic`, `DispatchInfo` and go directly to checking the Extra using the Applyable trait +// (both apply and validate). +fn prepare_applyable_data( + call: Call, + account_id: AccountId, +) -> ( + CheckedExtrinsic, + DispatchInfo, + usize, +) { + let extra = ( + frame_system::CheckSpecVersion::::new(), + frame_system::CheckTxVersion::::new(), + frame_system::CheckGenesis::::new(), + frame_system::CheckEra::::from(sp_runtime::generic::Era::Immortal), + frame_system::CheckNonce::::from(0), + frame_system::CheckWeight::::new(), + pallet_bioauth::CheckBioauthTx::::new(), + pallet_transaction_payment::ChargeTransactionPayment::::from(0), + pallet_token_claims::CheckTokenClaim::::new(), + ); + + let normal_dispatch_info = DispatchInfo { + weight: 100, + class: DispatchClass::Normal, + pays_fee: Pays::No, + }; + let len = 0; + + let checked_extrinsic = CheckedExtrinsic { + signed: CheckedSignature::Signed(account_id, extra), + function: call, + }; + + (checked_extrinsic, normal_dispatch_info, len) +} + +/// Build test externalities from the custom genesis. +/// Using this call requires manual assertions on the genesis init logic. +fn new_test_ext() -> sp_io::TestExternalities { + let authorities = vec![authority_keys("Alice")]; + let bootnodes = vec![account_id("Alice")]; + let endowed_accounts = vec![account_id("Alice"), account_id("Bob")]; + // Build test genesis. + let config = GenesisConfig { + balances: BalancesConfig { + balances: { + let pot_accounts = vec![TreasuryPot::account_id(), FeesPot::account_id()]; + endowed_accounts + .iter() + .cloned() + .chain(pot_accounts.into_iter()) + .map(|k| (k, INIT_BALANCE)) + .chain( + [( + TokenClaimsPot::account_id(), + 2 * VESTING_BALANCE + >::minimum_balance(), + )] + .into_iter(), + ) + .collect() + }, + }, + session: SessionConfig { + keys: authorities + .iter() + .map(|x| { + ( + x.0.clone(), + x.0.clone(), + SessionKeys { + babe: x.1.clone(), + grandpa: x.2.clone(), + im_online: x.3.clone(), + }, + ) + }) + .collect::>(), + }, + babe: BabeConfig { + authorities: vec![], + epoch_config: Some(BABE_GENESIS_EPOCH_CONFIG), + }, + bootnodes: BootnodesConfig { + bootnodes: bootnodes.try_into().unwrap(), + }, + token_claims: TokenClaimsConfig { + claims: vec![ + ( + ethereum_address_from_seed(b"Dubai"), + ClaimInfo { + balance: VESTING_BALANCE, + vesting: vec![].try_into().unwrap(), + }, + ), + ( + ethereum_address_from_seed(b"Batumi"), + ClaimInfo { + balance: VESTING_BALANCE, + vesting: vec![LinearSchedule { + balance: VESTING_BALANCE, + cliff: CLIFF, + vesting: VESTING_DURATION, + }] + .try_into() + .unwrap(), + }, + ), + ], + total_claimable: Some(2 * VESTING_BALANCE), + }, + ethereum_chain_id: EthereumChainIdConfig { chain_id: 1 }, + ..Default::default() + }; + let storage = config.build_storage().unwrap(); + + // Make test externalities from the storage. + storage.into() +} + +fn assert_genesis_json(token_claims: &str, token_claim_pot_balance: u128) { + let json_input = prepare_genesis_json(token_claims, token_claim_pot_balance); + let config: GenesisConfig = serde_json::from_str(json_input.as_str()).unwrap(); + let _ = config.build_storage(); +} + +fn assert_applyable_validate_all_transaction_sources( + checked_extrinsic: &CheckedExtrinsic, + normal_dispatch_info: &DispatchInfo, + len: usize, +) { + assert_storage_noop!(assert_ok!(Applyable::validate::( + checked_extrinsic, + sp_runtime::transaction_validity::TransactionSource::Local, + normal_dispatch_info, + len, + ))); + assert_storage_noop!(assert_ok!(Applyable::validate::( + checked_extrinsic, + sp_runtime::transaction_validity::TransactionSource::InBlock, + normal_dispatch_info, + len, + ))); + assert_storage_noop!(assert_ok!(Applyable::validate::( + checked_extrinsic, + sp_runtime::transaction_validity::TransactionSource::External, + normal_dispatch_info, + len, + ))); +} + +fn prepare_genesis_json(token_claims: &str, token_claim_pot_balance: u128) -> String { + format!( + r#"{{ + "system": {{ + "code": "" + }}, + "bootnodes": {{ + "bootnodes": ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"] + }}, + "bioauth": {{ + "robonodePublicKey": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "consumedAuthTicketNonces": [], + "activeAuthentications": [] + }}, + "babe": {{ + "authorities": [], + "epochConfig": {{ + "c": [1, 4], + "allowed_slots": "PrimaryAndSecondaryPlainSlots" + }} + }}, + "balances": {{ + "balances": [ + [ + "5EYCAe5h8DABNonHVCji5trNkxqKaz1WcvryauRMm4zYYDdQ", + 500 + ], + [ + "5EYCAe5h8DABNogda2AhGjVZCcYAxcoVhSTMZXwWiQhVx9sY", + 500 + ], + [ + "5EYCAe5h8DABNonG7tbqC8bjDUw9jM1ewHJWssszZYbjkH2e", + {token_claim_pot_balance} + ] + ] + }}, + "treasuryPot": {{ + "initialState": "Initialized" + }}, + "feesPot": {{ + "initialState": "Initialized" + }}, + "tokenClaimsPot": {{ + "initialState": "Initialized" + }}, + "transactionPayment": null, + "session": {{ + "keys": [ + [ + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + {{ + "babe": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "grandpa": "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu", + "im_online": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + }} + ] + ] + }}, + "chainProperties": {{ + "ss58Prefix": 1 + }}, + "ethereumChainId": {{ + "chainId": 1 + }}, + "sudo": {{ + "key": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + }}, + "grandpa": {{ + "authorities": [] + }}, + "ethereum": {{}}, + "evm": {{ + "accounts": {{}} + }}, + "dynamicFee": {{ + "minGasPrice": "0x0" + }}, + "baseFee": {{ + "baseFeePerGas": "0x0", + "isActive": true, + "elasticity": 0, + "marker": null + }}, + "imOnline": {{ + "keys": [] + }}, + "evmAccountsMapping": {{ + "mappings": [] + }}, + "tokenClaims": {token_claims} + }}"# + ) +} + +/// This test verifies that `GenesisConfig` with claims is parsed in happy path. +#[test] +fn genesis_claims_works() { + let token_claims = r#" + { + "claims": [ + [ + "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b", + { + "balance": 1000000000000000000000000, + "vesting": [{"balance":1000000000000000000000000,"cliff":10,"vesting":10}] + } + ] + ], + "totalClaimable": 1000000000000000000000000 + }"#; + assert_genesis_json(token_claims, 1000000000000000000000500); +} + +/// This test verifies that `GenesisConfig` with claims fails due to invalid pot balance. +#[test] +#[should_panic = "invalid balance in the token claims pot account: got 500, expected 1000000000000000000000500"] +fn genesis_claims_invalid_pot_balance() { + let token_claims = r#" + { + "claims": [ + [ + "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b", + { + "balance": 1000000000000000000000000, + "vesting": [{"balance":1000000000000000000000000,"cliff":10,"vesting":10}] + } + ] + ], + "totalClaimable": 1000000000000000000000000 + }"#; + assert_genesis_json(token_claims, 500); +} + +/// This test verifies that `GenesisConfig` with claims fails due to invalid vesting initialization with null. +#[test] +#[should_panic = "invalid type: null, expected a sequence"] +fn genesis_claims_invalid_vesting_inititalization_with_null() { + let token_claims = r#" + { + "claims": [ + [ + "0x6be02d1d3665660d22ff9624b7be0551ee1ac91b", + { + "balance": 1000000000000000000000000, + "vesting": null + } + ] + ], + "totalClaimable": 1000000000000000000000000 + }"#; + assert_genesis_json(token_claims, 1000000000000000000000500); +} + +/// This test verifies that claiming without vesting works (direct runtime call) in the happy path. +#[test] +fn direct_claiming_without_vesting_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = sign_sample_token_claim(b"Dubai", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Invoke the claim call. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + )); + + // Ensure the claim is gone from the state after the extrinsic is processed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + + // Ensure the balance of the target account is properly adjusted. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure that the balance is not locked. + assert_eq!( + Balances::usable_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that claiming with vesting (direct runtime call) works in the happy path. +#[test] +fn direct_claiming_with_vesting_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = sign_sample_token_claim(b"Batumi", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert!(Vesting::locks(account_id("Alice")).is_none()); + + // Invoke the claim call. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + )); + + // Ensure the claim is gone from the state after the extrinsic is processed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + + // Ensure the balance of the target account is properly adjusted. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure that the vesting balance is locked. + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Ensure that the vesting is armed for the given account and matches the parameters. + assert_eq!( + Vesting::locks(account_id("Alice")), + Some( + vec![LinearSchedule { + balance: VESTING_BALANCE, + cliff: CLIFF, + vesting: VESTING_DURATION, + }] + .try_into() + .unwrap() + ) + ); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that unlocking full balance (direct runtime call) works in the happy path. +#[test] +fn direct_unlock_full_balance_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = sign_sample_token_claim(b"Batumi", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Invoke the claim call for future unlocking. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + )); + + // Run blocks with setting proper timestamp to make full unlocking. + set_timestamp(START_TIMESTAMP + CLIFF + VESTING_DURATION); + switch_block(); + + // Invoke the unlock call. + assert_ok!(Vesting::unlock(Some(account_id("Alice")).into())); + + // Ensure funds are unlocked. + assert_eq!( + Balances::usable_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure the vesting is gone from the state. + assert!(Vesting::locks(account_id("Alice")).is_none()); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that unlocking partial balance works (direct runtime call) in the happy path. +#[test] +fn direct_unlock_partial_balance_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // 2/3 from VESTING_DURATION. + const PARTIAL_DURATION: u64 = 2000; + const PARTIAL_VESTING_TIMESTAMP: u64 = START_TIMESTAMP + CLIFF + PARTIAL_DURATION; + // 2/3 from VESTING_BALANCE rounded up. + const EXPECTED_PARTIAL_UNLOCKED_FUNDS: u128 = 667; + + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = sign_sample_token_claim(b"Batumi", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Invoke the claim call for future unlocking. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + )); + + // Run blocks with setting proper timestamp to make full unlocking. + set_timestamp(PARTIAL_VESTING_TIMESTAMP); + switch_block(); + + // Invoke the unlock call. + assert_ok!(Vesting::unlock(Some(account_id("Alice")).into())); + + let unlocked_balance = Balances::usable_balance(account_id("Alice")) - INIT_BALANCE; + + // Ensure funds are partially unlocked and rounding works as expected. + assert_eq!(unlocked_balance, EXPECTED_PARTIAL_UNLOCKED_FUNDS); + + // Ensure the vesting still exists. + assert!(Vesting::locks(account_id("Alice")).is_some()); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that direct claiming fails if ethereum_address +/// doesn't correspond to submitted ethereum_signature. +#[test] +fn direct_claiming_fails_when_eth_signature_invalid() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, _) = sign_sample_token_claim(b"Dubai", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Invoke the claim call. + assert_noop!( + TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + EcdsaSignature::default() + ), + sp_runtime::DispatchError::Module(ModuleError { + index: 27, + error: [0u8; 4], + message: Some("InvalidSignature") + }) + ); + + // Ensure claims related state hasn't been changed. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that direct claiming fails in case not existing claim. +#[test] +fn direct_claiming_fails_when_no_claim() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = + sign_sample_token_claim(b"Invalid", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_none()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Invoke the claim call. + assert_noop!( + TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + ), + sp_runtime::DispatchError::Module(ModuleError { + index: 27, + error: [1u8, 0u8, 0u8, 0u8], + message: Some("NoClaim") + }) + ); + + // Ensure claims related state hasn't been changed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that direct unlock fails in case not existing vesting. +#[test] +fn direct_unlock_fails_when_no_vesting() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, signature) = sign_sample_token_claim(b"Batumi", account_id("Alice")); + + let total_issuance_before = Balances::total_issuance(); + + // Invoke the claim call for future unlocking. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + signature + )); + + // Run blocks with setting proper timestamp to make full unlocking. + set_timestamp(START_TIMESTAMP + CLIFF + VESTING_DURATION); + switch_block(); + + // Invoke the unlock call. + assert_noop!( + Vesting::unlock(Some(account_id("Unknown")).into()), + sp_runtime::DispatchError::Module(ModuleError { + index: 28, + error: [1u8, 0u8, 0u8, 0u8], + message: Some("NoVesting") + }) + ); + + // Ensure funds are still locked. + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Ensure the vesting isn't gone from the state. + assert!(Vesting::locks(account_id("Alice")).is_some()); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that claiming without vesting (dispatch call) works in the happy path. +#[test] +fn dispatch_claiming_without_vesting_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Dubai", account_id("Alice")); + + // Prepare token claim data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::TokenClaims(pallet_token_claims::Call::claim { + ethereum_address, + ethereum_signature, + }), + account_id("Alice"), + ); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Validate already checked extrinsic with all possible transaction sources. + assert_applyable_validate_all_transaction_sources( + &checked_extrinsic, + &normal_dispatch_info, + len, + ); + // Apply already checked extrinsic. + assert_ok!(Applyable::apply::( + checked_extrinsic, + &normal_dispatch_info, + len + )); + + // Ensure the claim is gone from the state after the extrinsic is processed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + + // Ensure the balance of the target account is properly adjusted. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure that the balance is not locked. + assert_eq!( + Balances::usable_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that claiming with vesting (dispatch call) works in the happy path. +#[test] +fn dispatch_claiming_with_vesting_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Batumi", account_id("Alice")); + + // Prepare token claim data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::TokenClaims(pallet_token_claims::Call::claim { + ethereum_address, + ethereum_signature, + }), + account_id("Alice"), + ); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Alice")), INIT_BALANCE); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert!(Vesting::locks(account_id("Alice")).is_none()); + + // Validate already checked extrinsic with all possible transaction sources. + assert_applyable_validate_all_transaction_sources( + &checked_extrinsic, + &normal_dispatch_info, + len, + ); + // Apply already checked extrinsic. + assert_ok!(Applyable::apply::( + checked_extrinsic, + &normal_dispatch_info, + len + )); + + // Ensure the claim is gone from the state after the extrinsic is processed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + + // Ensure the balance of the target account is properly adjusted. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure that the vesting balance is locked. + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + + // Ensure that the vesting is armed for the given account and matches the parameters. + assert_eq!( + Vesting::locks(account_id("Alice")), + Some( + vec![LinearSchedule { + balance: VESTING_BALANCE, + cliff: CLIFF, + vesting: VESTING_DURATION, + }] + .try_into() + .unwrap() + ) + ); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that unlocking full balance (dispatch call) works in the happy path. +#[test] +fn dispatch_unlock_full_balance_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Batumi", account_id("Alice")); + + // Invoke the direct runtime claim call for future unlocking. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + ethereum_signature + )); + + // Run blocks with setting proper timestamp to make full unlocking. + set_timestamp(START_TIMESTAMP + CLIFF + VESTING_DURATION); + switch_block(); + + // Prepare unlock data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::Vesting(pallet_vesting::Call::unlock {}), + account_id("Alice"), + ); + + // Test preconditions. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert!(Vesting::locks(account_id("Alice")).is_some()); + + let total_issuance_before = Balances::total_issuance(); + + // Validate already checked extrinsic with all possible transaction sources. + assert_applyable_validate_all_transaction_sources( + &checked_extrinsic, + &normal_dispatch_info, + len, + ); + // Apply already checked extrinsic. + assert_ok!(Applyable::apply::( + checked_extrinsic, + &normal_dispatch_info, + len + )); + + // Ensure funds are unlocked. + assert_eq!( + Balances::usable_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + + // Ensure the vesting is gone from the state. + assert!(Vesting::locks(account_id("Alice")).is_none()); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that unlocking partial balance (dispatch call) works in the happy path. +#[test] +fn dispatch_unlock_partial_balance_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // 2/3 from VESTING_DURATION. + const PARTIAL_DURATION: u64 = 2000; + const PARTIAL_VESTING_TIMESTAMP: u64 = START_TIMESTAMP + CLIFF + PARTIAL_DURATION; + // 2/3 from VESTING_BALANCE rounded up. + const EXPECTED_PARTIAL_UNLOCKED_FUNDS: u128 = 667; + + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Batumi", account_id("Alice")); + + // Invoke the direct runtime claim call for future unlocking. + assert_ok!(TokenClaims::claim( + Some(account_id("Alice")).into(), + ethereum_address, + ethereum_signature + )); + + // Run blocks with setting proper timestamp to make full unlocking. + set_timestamp(PARTIAL_VESTING_TIMESTAMP); + switch_block(); + + // Prepare unlock data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::Vesting(pallet_vesting::Call::unlock {}), + account_id("Alice"), + ); + + // Test preconditions. + assert_eq!( + Balances::free_balance(account_id("Alice")), + INIT_BALANCE + VESTING_BALANCE + ); + assert_eq!(Balances::usable_balance(account_id("Alice")), INIT_BALANCE); + assert!(Vesting::locks(account_id("Alice")).is_some()); + + let total_issuance_before = Balances::total_issuance(); + + // Validate already checked extrinsic with all possible transaction sources. + assert_applyable_validate_all_transaction_sources( + &checked_extrinsic, + &normal_dispatch_info, + len, + ); + // Apply already checked extrinsic. + assert_ok!(Applyable::apply::( + checked_extrinsic, + &normal_dispatch_info, + len + )); + + let unlocked_balance = Balances::usable_balance(account_id("Alice")) - INIT_BALANCE; + + // Ensure funds are partially unlocked and rounding works as expected. + assert_eq!(unlocked_balance, EXPECTED_PARTIAL_UNLOCKED_FUNDS); + + // Ensure the vesting still exists. + assert!(Vesting::locks(account_id("Alice")).is_some()); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} + +/// This test verifies that dispatch claiming fails if ethereum_address +/// doesn't correspond to submitted ethereum_signature. +#[test] +fn dispatch_claiming_fails_when_eth_signature_invalid() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Prepare token claim data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::TokenClaims(pallet_token_claims::Call::claim { + ethereum_address: EthereumAddress::default(), + ethereum_signature: EcdsaSignature::default(), + }), + account_id("Alice"), + ); + + // Validate already checked extrinsic. + assert_noop!( + Applyable::validate::( + &checked_extrinsic, + sp_runtime::transaction_validity::TransactionSource::Local, + &normal_dispatch_info, + len, + ), + TransactionValidityError::Invalid(InvalidTransaction::BadProof) + ); + // Apply already checked extrinsic. + // + // We don't use assert_noop as apply invokes pre_dispatch that uses fee. + // As a result state is changed. + assert_eq!( + Applyable::apply::(checked_extrinsic, &normal_dispatch_info, len), + Err(TransactionValidityError::Invalid( + InvalidTransaction::BadProof + )) + ); + }) +} + +/// This test verifies that dispatch claiming fails in case not existing claim. +#[test] +fn dispatch_claiming_fails_when_no_claim() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Invalid", account_id("Alice")); + + // Prepare token claim data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::TokenClaims(pallet_token_claims::Call::claim { + ethereum_address, + ethereum_signature, + }), + account_id("Alice"), + ); + + // Validate already checked extrinsic. + assert_noop!( + Applyable::validate::( + &checked_extrinsic, + sp_runtime::transaction_validity::TransactionSource::Local, + &normal_dispatch_info, + len, + ), + TransactionValidityError::Invalid(InvalidTransaction::Call) + ); + // Apply already checked extrinsic. + // + // We don't use assert_noop as apply invokes pre_dispatch that uses fee. + // As a result state is changed. + assert_eq!( + Applyable::apply::(checked_extrinsic, &normal_dispatch_info, len), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)) + ); + }) +} + +/// This test verifies that claiming without vesting (dispatch call) works in the happy path with zero balance. +/// So, we verify that the call is free in term of transaction fee. +#[test] +fn dispatch_claiming_zero_balance_works() { + // Build the state from the config. + new_test_ext().execute_with(move || { + // Run blocks to be vesting schedule ready. + switch_block(); + set_timestamp(START_TIMESTAMP); + switch_block(); + + // Prepare ethereum_address and signature test data based on EIP-712 type data json. + let (ethereum_address, ethereum_signature) = + sign_sample_token_claim(b"Dubai", account_id("Zero")); + + // Prepare token claim data that are used to validate and apply `CheckedExtrinsic`. + let (checked_extrinsic, normal_dispatch_info, len) = prepare_applyable_data( + Call::TokenClaims(pallet_token_claims::Call::claim { + ethereum_address, + ethereum_signature, + }), + account_id("Zero"), + ); + + let total_issuance_before = Balances::total_issuance(); + + // Test preconditions. + assert!(TokenClaims::claims(ethereum_address).is_some()); + assert_eq!(Balances::free_balance(account_id("Zero")), 0); + assert_eq!(Balances::usable_balance(account_id("Zero")), 0); + + // Validate already checked extrinsic with all possible transaction sources. + assert_applyable_validate_all_transaction_sources( + &checked_extrinsic, + &normal_dispatch_info, + len, + ); + // Apply already checked extrinsic. + assert_ok!(Applyable::apply::( + checked_extrinsic, + &normal_dispatch_info, + len + )); + + // Ensure the claim is gone from the state after the extrinsic is processed. + assert!(TokenClaims::claims(ethereum_address).is_none()); + + // Ensure the balance of the target account is properly adjusted. + assert_eq!(Balances::free_balance(account_id("Zero")), VESTING_BALANCE); + + // Ensure that the balance is not locked. + assert_eq!( + Balances::usable_balance(account_id("Zero")), + VESTING_BALANCE + ); + + // Ensure total issuance did not change. + assert_eq!(Balances::total_issuance(), total_issuance_before); + }) +} diff --git a/crates/humanode-runtime/src/tests/genesis_config.rs b/crates/humanode-runtime/src/tests/genesis_config.rs new file mode 100644 index 000000000..cf4531f30 --- /dev/null +++ b/crates/humanode-runtime/src/tests/genesis_config.rs @@ -0,0 +1,138 @@ +//! Tests to verify general GenesisConfig parsing. + +use frame_support::assert_ok; + +use super::*; + +/// This test verifies that `GenesisConfig` is parsed in happy path. +#[test] +fn works() { + let json_input = r#"{ + "system": { + "code": "" + }, + "bootnodes": { + "bootnodes": ["5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"] + }, + "bioauth": { + "robonodePublicKey": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "consumedAuthTicketNonces": [], + "activeAuthentications": [] + }, + "babe": { + "authorities": [], + "epochConfig": { + "c": [1, 4], + "allowed_slots": "PrimaryAndSecondaryPlainSlots" + } + }, + "balances": { + "balances": [ + [ + "5EYCAe5h8DABNonHVCji5trNkxqKaz1WcvryauRMm4zYYDdQ", + 500 + ], + [ + "5EYCAe5h8DABNogda2AhGjVZCcYAxcoVhSTMZXwWiQhVx9sY", + 500 + ], + [ + "5EYCAe5h8DABNonG7tbqC8bjDUw9jM1ewHJWssszZYbjkH2e", + 500 + ] + ] + }, + "treasuryPot": { + "initialState": "Initialized" + }, + "feesPot": { + "initialState": "Initialized" + }, + "tokenClaimsPot": { + "initialState": "Initialized" + }, + "transactionPayment": null, + "session": { + "keys": [ + [ + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + { + "babe": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY", + "grandpa": "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu", + "im_online": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + } + ] + ] + }, + "chainProperties": { + "ss58Prefix": 1 + }, + "ethereumChainId": { + "chainId": 1 + }, + "sudo": { + "key": "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" + }, + "grandpa": { + "authorities": [] + }, + "ethereum": {}, + "evm": { + "accounts": {} + }, + "dynamicFee": { + "minGasPrice": "0x0" + }, + "baseFee": { + "baseFeePerGas": "0x0", + "isActive": true, + "elasticity": 0, + "marker": null + }, + "imOnline": { + "keys": [] + }, + "evmAccountsMapping": { + "mappings": [] + }, + "tokenClaims": { + "claims": [], + "totalClaimable": 0 + } + }"#; + let config: GenesisConfig = serde_json::from_str(json_input).unwrap(); + assert_ok!(config.build_storage()); +} + +/// This test verifies that `GenesisConfig` parsing fails in case having unknown field at json. +#[test] +fn unknown_field() { + let json_input = r#"{"qwe":"rty"}"#; + let err = serde_json::from_str::(json_input) + .err() + .unwrap(); + assert_eq!( + err.to_string(), + "unknown field `qwe`, expected one of \ + `system`, `bootnodes`, `bioauth`, `babe`, `balances`, `treasuryPot`, \ + `feesPot`, `tokenClaimsPot`, `transactionPayment`, `session`, `chainProperties`, \ + `ethereumChainId`, `sudo`, `grandpa`, `ethereum`, `evm`, `dynamicFee`, `baseFee`, \ + `imOnline`, `evmAccountsMapping`, `tokenClaims` at line 1 column 6" + ); +} + +/// This test verifies that `GenesisConfig` parsing fails in case missing field. +#[test] +fn missing_field() { + let data = r#"{ + "system": { + "code": "0x0001" + } + }"#; + let err = serde_json::from_str::(data).err().unwrap(); + assert_eq!( + err.to_string(), + "missing field `bootnodes` at line 5 column 5" + ); +} diff --git a/crates/humanode-runtime/src/tests/mod.rs b/crates/humanode-runtime/src/tests/mod.rs index 0a4759c26..255e0f83f 100644 --- a/crates/humanode-runtime/src/tests/mod.rs +++ b/crates/humanode-runtime/src/tests/mod.rs @@ -1,3 +1,5 @@ use super::*; +mod claims_and_vesting; mod fixed_supply; +mod genesis_config;