diff --git a/chain/chain/src/tests/simple_chain.rs b/chain/chain/src/tests/simple_chain.rs index b7a9795440f..4eb49842a9a 100644 --- a/chain/chain/src/tests/simple_chain.rs +++ b/chain/chain/src/tests/simple_chain.rs @@ -43,7 +43,7 @@ fn build_chain() { // cargo insta test --accept -p near-chain --features nightly -- tests::simple_chain::build_chain let hash = chain.head().unwrap().last_block_hash; if cfg!(feature = "nightly") { - insta::assert_display_snapshot!(hash, @"96KiRJdbMN8A9cFPXarZdaRQ8U2HvYcrGTGC8a4EgFzM"); + insta::assert_display_snapshot!(hash, @"7f7HTgxYS9NbF61kqqQSD4ivtPj7hCReXqyMMqiUfMZq"); } else { insta::assert_display_snapshot!(hash, @"7r5VSLXhkxHHEeiAAPQbKPGv3rr877obehGYwPbKZMA7"); } @@ -73,7 +73,7 @@ fn build_chain() { let hash = chain.head().unwrap().last_block_hash; if cfg!(feature = "nightly") { - insta::assert_display_snapshot!(hash, @"4eW4jvyu1Ek6WmY3EuUoFFkrascC7svRww5UcZbNMkUf"); + insta::assert_display_snapshot!(hash, @"5GXKgTvpLBcSDncu3P8tdmFZ48m89rzUivJEtDTjp6oq"); } else { insta::assert_display_snapshot!(hash, @"9772sSKzm1eGPV3pRi17YaZkotrcN6dAkJUn226CopTm"); } diff --git a/core/primitives/Cargo.toml b/core/primitives/Cargo.toml index 7ab3a5d001c..484a2e21c69 100644 --- a/core/primitives/Cargo.toml +++ b/core/primitives/Cargo.toml @@ -51,6 +51,7 @@ protocol_feature_reject_blocks_with_outdated_protocol_version = [] protocol_feature_ed25519_verify = [ "near-primitives-core/protocol_feature_ed25519_verify" ] +protocol_feature_zero_balance_account = [] protocol_feature_nep366_delegate_action = [ "near-primitives-core/protocol_feature_nep366_delegate_action" ] @@ -61,6 +62,7 @@ nightly = [ "protocol_feature_reject_blocks_with_outdated_protocol_version", "protocol_feature_ed25519_verify", "protocol_feature_nep366_delegate_action", + "protocol_feature_zero_balance_account" ] nightly_protocol = [] diff --git a/core/primitives/src/runtime/mod.rs b/core/primitives/src/runtime/mod.rs index 7f7968e1c53..64fa00408bf 100644 --- a/core/primitives/src/runtime/mod.rs +++ b/core/primitives/src/runtime/mod.rs @@ -1,42 +1,8 @@ pub use near_primitives_core::runtime::fees; pub use near_primitives_core::runtime::*; -use crate::account::Account; -use crate::runtime::config::RuntimeConfig; -use crate::types::Balance; - pub mod apply_state; pub mod config; pub mod config_store; pub mod migration_data; pub mod parameter_table; - -/// Checks if given account has enough balance for storage stake, and returns: -/// - None if account has enough balance, -/// - Some(insufficient_balance) if account doesn't have enough and how much need to be added, -/// - Err(message) if account has invalid storage usage or amount/locked. -/// -/// Read details of state staking -/// . -pub fn get_insufficient_storage_stake( - account: &Account, - runtime_config: &RuntimeConfig, -) -> Result, String> { - let required_amount = Balance::from(account.storage_usage()) - .checked_mul(runtime_config.storage_amount_per_byte()) - .ok_or_else(|| { - format!("Account's storage_usage {} overflows multiplication", account.storage_usage()) - })?; - let available_amount = account.amount().checked_add(account.locked()).ok_or_else(|| { - format!( - "Account's amount {} and locked {} overflow addition", - account.amount(), - account.locked() - ) - })?; - if available_amount >= required_amount { - Ok(None) - } else { - Ok(Some(required_amount - available_amount)) - } -} diff --git a/core/primitives/src/version.rs b/core/primitives/src/version.rs index 4baca61f4df..7ecc76c4ce0 100644 --- a/core/primitives/src/version.rs +++ b/core/primitives/src/version.rs @@ -149,6 +149,9 @@ pub enum ProtocolFeature { RejectBlocksWithOutdatedProtocolVersions, #[cfg(feature = "protocol_feature_nep366_delegate_action")] DelegateAction, + #[cfg(feature = "protocol_feature_zero_balance_account")] + /// NEP 448: https://github.com/near/NEPs/pull/448 + ZeroBalanceAccount, } /// Both, outgoing and incoming tcp connections to peers, will be rejected if `peer's` @@ -163,7 +166,7 @@ const STABLE_PROTOCOL_VERSION: ProtocolVersion = 58; /// Largest protocol version supported by the current binary. pub const PROTOCOL_VERSION: ProtocolVersion = if cfg!(feature = "nightly_protocol") { // On nightly, pick big enough version to support all features. - 133 + 134 } else { // Enable all stable features. STABLE_PROTOCOL_VERSION @@ -238,6 +241,8 @@ impl ProtocolFeature { ProtocolFeature::RejectBlocksWithOutdatedProtocolVersions => 132, #[cfg(feature = "protocol_feature_nep366_delegate_action")] ProtocolFeature::DelegateAction => 133, + #[cfg(feature = "protocol_feature_zero_balance_account")] + ProtocolFeature::ZeroBalanceAccount => 134, } } } diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index 1d356ba7e70..15884e0a4fc 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -75,12 +75,15 @@ protocol_feature_reject_blocks_with_outdated_protocol_version = [ "near-chain/protocol_feature_reject_blocks_with_outdated_protocol_version" ] protocol_feature_flat_state = ["nearcore/protocol_feature_flat_state"] +protocol_feature_zero_balance_account = ["node-runtime/protocol_feature_zero_balance_account"] + nightly = [ "nightly_protocol", "nearcore/nightly", "protocol_feature_fix_contract_loading_cost", "protocol_feature_reject_blocks_with_outdated_protocol_version", "protocol_feature_flat_state", + "protocol_feature_zero_balance_account" ] nightly_protocol = ["nearcore/nightly_protocol"] sandbox = [ diff --git a/integration-tests/src/tests/client/features.rs b/integration-tests/src/tests/client/features.rs index d65abc878d1..144d1ba898d 100644 --- a/integration-tests/src/tests/client/features.rs +++ b/integration-tests/src/tests/client/features.rs @@ -13,3 +13,5 @@ mod limit_contract_functions_number; mod lower_storage_key_limit; mod restore_receipts_after_fix_apply_chunks; mod wasmer2; +#[cfg(feature = "protocol_feature_zero_balance_account")] +mod zero_balance_account; diff --git a/integration-tests/src/tests/client/features/zero_balance_account.rs b/integration-tests/src/tests/client/features/zero_balance_account.rs new file mode 100644 index 00000000000..1f358e307d9 --- /dev/null +++ b/integration-tests/src/tests/client/features/zero_balance_account.rs @@ -0,0 +1,290 @@ +use std::path::Path; +use std::sync::Arc; + +use assert_matches::assert_matches; + +use near_chain::ChainGenesis; +use near_chain_configs::Genesis; +use near_client::adapter::ProcessTxResponse; +use near_client::test_utils::TestEnv; +use near_crypto::{InMemorySigner, KeyType, PublicKey}; +use near_primitives::account::id::AccountId; +use near_primitives::account::AccessKey; +use near_primitives::config::ExtCostsConfig; +use near_primitives::errors::{ActionError, ActionErrorKind, InvalidTxError, TxExecutionError}; +use near_primitives::runtime::config::RuntimeConfig; +use near_primitives::runtime::config_store::RuntimeConfigStore; +use near_primitives::runtime::fees::StorageUsageConfig; +use near_primitives::shard_layout::ShardUId; +use near_primitives::transaction::Action::AddKey; +use near_primitives::transaction::{Action, AddKeyAction, DeleteKeyAction, SignedTransaction}; +use near_primitives::version::ProtocolFeature; +use near_primitives::views::{FinalExecutionStatus, QueryRequest, QueryResponseKind}; +use near_store::test_utils::create_test_store; +use nearcore::config::GenesisExt; +use nearcore::{NightshadeRuntime, TrackedConfig}; + +use crate::tests::client::runtimes::create_nightshade_runtimes; + +/// Assert that an account exists and has zero balance +fn assert_zero_balance_account(env: &mut TestEnv, account_id: &AccountId) { + let head = env.clients[0].chain.head().unwrap(); + let head_block = env.clients[0].chain.get_block(&head.last_block_hash).unwrap(); + let response = env.clients[0] + .runtime_adapter + .query( + ShardUId::single_shard(), + &head_block.chunks()[0].prev_state_root(), + head.height, + 0, + &head.prev_block_hash, + &head.last_block_hash, + head_block.header().epoch_id(), + &QueryRequest::ViewAccount { account_id: account_id.clone() }, + ) + .unwrap(); + match response.kind { + QueryResponseKind::ViewAccount(view) => { + assert_eq!(view.amount, 0); + } + _ => panic!("wrong query response"), + } +} + +/// Test 2 things: 1) a valid zero balance account can be created and 2) a nonzero balance account +/// (one with a contract deployed) cannot be created without maintaining an initial balance +#[cfg(feature = "nightly_protocol")] +#[test] +fn test_zero_balance_account_creation() { + let epoch_length = 1000; + let mut genesis = Genesis::test(vec!["test0".parse().unwrap(), "test1".parse().unwrap()], 1); + genesis.config.epoch_length = epoch_length; + genesis.config.protocol_version = ProtocolFeature::ZeroBalanceAccount.protocol_version(); + let mut env = TestEnv::builder(ChainGenesis::test()) + .runtime_adapters(create_nightshade_runtimes(&genesis, 1)) + .build(); + let genesis_block = env.clients[0].chain.get_block_by_height(0).unwrap(); + + let new_account_id: AccountId = "hello.test0".parse().unwrap(); + let signer0 = InMemorySigner::from_seed("test0".parse().unwrap(), KeyType::ED25519, "test0"); + let new_signer = + InMemorySigner::from_seed(new_account_id.clone(), KeyType::ED25519, "hello.test0"); + + // create a valid zero balance account. Transaction should succeed + let create_account_tx = SignedTransaction::create_account( + 1, + signer0.account_id.clone(), + new_account_id.clone(), + 0, + new_signer.public_key.clone(), + &signer0, + *genesis_block.hash(), + ); + let res = env.clients[0].process_tx(create_account_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 1..5 { + env.produce_block(0, i); + } + // new account should have been created + assert_zero_balance_account(&mut env, &new_account_id); + + // create a zero balance account with contract deployed. The transaction should fail + let new_account_id: AccountId = "hell.test0".parse().unwrap(); + let create_account_tx = SignedTransaction::create_contract( + 2, + signer0.account_id.clone(), + new_account_id.clone(), + vec![1, 2, 3], + 0, + new_signer.public_key.clone(), + &signer0, + *genesis_block.hash(), + ); + let tx_hash = create_account_tx.get_hash(); + let res = env.clients[0].process_tx(create_account_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 5..10 { + env.produce_block(0, i); + } + let outcome = env.clients[0].chain.get_final_transaction_result(&tx_hash).unwrap(); + assert_matches!( + outcome.status, + FinalExecutionStatus::Failure(TxExecutionError::ActionError(ActionError { + kind: ActionErrorKind::LackBalanceForState { .. }, + .. + })) + ); +} + +/// Test that if a zero balance account becomes a regular account (through adding more keys), +/// it has to pay for storage cost of the account structure and the keys that +/// it didn't have to pay while it was a zero balance account. +#[cfg(feature = "nightly_protocol")] +#[test] +fn test_zero_balance_account_add_key() { + let epoch_length = 1000; + let mut genesis = Genesis::test(vec!["test0".parse().unwrap(), "test1".parse().unwrap()], 1); + genesis.config.epoch_length = epoch_length; + genesis.config.protocol_version = ProtocolFeature::ZeroBalanceAccount.protocol_version(); + // create free runtime config for transaction costs to make it easier to assert + // the exact amount of tokens on accounts + let mut runtime_config = RuntimeConfig::free(); + runtime_config.fees.storage_usage_config = StorageUsageConfig { + storage_amount_per_byte: 10u128.pow(19), + num_bytes_account: 100, + num_extra_bytes_record: 40, + }; + runtime_config.wasm_config.ext_costs = ExtCostsConfig::test(); + let runtime_config_store = RuntimeConfigStore::with_one_config(runtime_config); + let nightshade_runtime = Arc::new(NightshadeRuntime::test_with_runtime_config_store( + Path::new("."), + create_test_store(), + &genesis, + TrackedConfig::new_empty(), + runtime_config_store, + )); + let mut env = + TestEnv::builder(ChainGenesis::test()).runtime_adapters(vec![nightshade_runtime]).build(); + let genesis_block = env.clients[0].chain.get_block_by_height(0).unwrap(); + + let new_account_id: AccountId = "hello.test0".parse().unwrap(); + let signer0 = InMemorySigner::from_seed("test0".parse().unwrap(), KeyType::ED25519, "test0"); + let new_signer = + InMemorySigner::from_seed(new_account_id.clone(), KeyType::ED25519, "hello.test0"); + + let amount = 10u128.pow(24); + let create_account_tx = SignedTransaction::create_account( + 1, + signer0.account_id.clone(), + new_account_id.clone(), + amount, + new_signer.public_key.clone(), + &signer0, + *genesis_block.hash(), + ); + let res = env.clients[0].process_tx(create_account_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 1..5 { + env.produce_block(0, i); + } + + // add two more keys so that the account is no longer a zero balance account + let new_key1 = PublicKey::from_seed(KeyType::ED25519, "random1"); + let new_key2 = PublicKey::from_seed(KeyType::ED25519, "random2"); + + let head = env.clients[0].chain.head().unwrap(); + let nonce = head.height * AccessKey::ACCESS_KEY_NONCE_RANGE_MULTIPLIER + 1; + let add_key_tx = SignedTransaction::from_actions( + nonce, + new_account_id.clone(), + new_account_id.clone(), + &new_signer, + vec![ + AddKey(AddKeyAction { public_key: new_key1, access_key: AccessKey::full_access() }), + AddKey(AddKeyAction { + public_key: new_key2.clone(), + access_key: AccessKey::full_access(), + }), + ], + *genesis_block.hash(), + ); + let res = env.clients[0].process_tx(add_key_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 5..10 { + env.produce_block(0, i); + } + + // since the account is no longer zero balance account, it cannot transfer all its tokens out + // and must keep some amount for storage staking + let send_money_tx = SignedTransaction::send_money( + nonce + 10, + new_account_id.clone(), + signer0.account_id.clone(), + &new_signer, + amount, + *genesis_block.hash(), + ); + let res = env.clients[0].process_tx(send_money_tx.clone(), false, false); + assert_matches!(res, ProcessTxResponse::InvalidTx(InvalidTxError::LackBalanceForState { .. })); + + let delete_key_tx = SignedTransaction::from_actions( + nonce + 1, + new_account_id.clone(), + new_account_id.clone(), + &new_signer, + vec![Action::DeleteKey(DeleteKeyAction { public_key: new_key2 })], + *genesis_block.hash(), + ); + env.clients[0].process_tx(delete_key_tx, false, false); + for i in 10..15 { + env.produce_block(0, i); + } + let res = env.clients[0].process_tx(send_money_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 15..20 { + env.produce_block(0, i); + } + assert_zero_balance_account(&mut env, &new_account_id); +} + +/// Test that zero balance accounts cannot be created before the upgrade but can succeed after +/// the protocol upgrade +#[cfg(feature = "nightly_protocol")] +#[test] +fn test_zero_balance_account_upgrade() { + let epoch_length = 5; + let mut genesis = Genesis::test(vec!["test0".parse().unwrap(), "test1".parse().unwrap()], 1); + genesis.config.epoch_length = epoch_length; + genesis.config.protocol_version = ProtocolFeature::ZeroBalanceAccount.protocol_version() - 1; + let mut env = TestEnv::builder(ChainGenesis::test()) + .runtime_adapters(create_nightshade_runtimes(&genesis, 1)) + .build(); + let genesis_block = env.clients[0].chain.get_block_by_height(0).unwrap(); + + let new_account_id: AccountId = "hello.test0".parse().unwrap(); + let signer0 = InMemorySigner::from_seed("test0".parse().unwrap(), KeyType::ED25519, "test0"); + let new_signer = + InMemorySigner::from_seed(new_account_id.clone(), KeyType::ED25519, "hello.test0"); + + // before protocol upgrade, should not be possible to create a zero balance account + let create_account_tx = SignedTransaction::create_account( + 1, + signer0.account_id.clone(), + new_account_id.clone(), + 0, + new_signer.public_key.clone(), + &signer0, + *genesis_block.hash(), + ); + let tx_hash = create_account_tx.get_hash(); + let res = env.clients[0].process_tx(create_account_tx, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 1..12 { + env.produce_block(0, i); + } + let outcome = env.clients[0].chain.get_final_transaction_result(&tx_hash).unwrap(); + assert_matches!( + outcome.status, + FinalExecutionStatus::Failure(TxExecutionError::ActionError(ActionError { + kind: ActionErrorKind::LackBalanceForState { .. }, + .. + })) + ); + let create_account_tx2 = SignedTransaction::create_account( + 2, + signer0.account_id.clone(), + new_account_id.clone(), + 0, + new_signer.public_key.clone(), + &signer0, + *genesis_block.hash(), + ); + let tx_hash2 = create_account_tx2.get_hash(); + let res = env.clients[0].process_tx(create_account_tx2, false, false); + assert_matches!(res, ProcessTxResponse::ValidTx); + for i in 12..20 { + env.produce_block(0, i); + } + let outcome = env.clients[0].chain.get_final_transaction_result(&tx_hash2).unwrap(); + assert_matches!(outcome.status, FinalExecutionStatus::SuccessValue(_)); +} diff --git a/integration-tests/src/tests/standard_cases/mod.rs b/integration-tests/src/tests/standard_cases/mod.rs index 314751fb02e..3d409b2ae6b 100644 --- a/integration-tests/src/tests/standard_cases/mod.rs +++ b/integration-tests/src/tests/standard_cases/mod.rs @@ -622,6 +622,7 @@ pub fn test_create_account_failure_no_funds(node: impl Node) { let transaction_result = node_user .create_account(account_id.clone(), eve_dot_alice_account(), node.signer().public_key(), 0) .unwrap(); + #[cfg(not(feature = "protocol_feature_zero_balance_account"))] assert_matches!( &transaction_result.status, FinalExecutionStatus::Failure(e) => match &e { @@ -631,6 +632,8 @@ pub fn test_create_account_failure_no_funds(node: impl Node) { }, _ => panic!("should be ActionError") }); + #[cfg(feature = "protocol_feature_zero_balance_account")] + assert_matches!(transaction_result.status, FinalExecutionStatus::SuccessValue(_)); } pub fn test_create_account_failure_already_exists(node: impl Node) { diff --git a/nearcore/Cargo.toml b/nearcore/Cargo.toml index 202caa0777d..47f3f62eb6d 100644 --- a/nearcore/Cargo.toml +++ b/nearcore/Cargo.toml @@ -111,6 +111,7 @@ protocol_feature_nep366_delegate_action = [ "near-primitives/protocol_feature_nep366_delegate_action", "near-rosetta-rpc/protocol_feature_nep366_delegate_action", ] +protocol_feature_zero_balance_account = ["node-runtime/protocol_feature_zero_balance_account"] nightly = [ "nightly_protocol", @@ -127,6 +128,7 @@ nightly = [ nightly_protocol = [ "near-primitives/nightly_protocol", "near-jsonrpc/nightly_protocol", + "node-runtime/nightly_protocol" ] # Force usage of a specific wasm vm irrespective of protocol version. diff --git a/runtime/runtime/Cargo.toml b/runtime/runtime/Cargo.toml index 78adeeea04a..bf60b0e9cd2 100644 --- a/runtime/runtime/Cargo.toml +++ b/runtime/runtime/Cargo.toml @@ -34,6 +34,8 @@ near-vm-runner = { path = "../../runtime/near-vm-runner" } default = [] dump_errors_schema = ["near-vm-errors/dump_errors_schema"] protocol_feature_flat_state = ["near-store/protocol_feature_flat_state", "near-vm-logic/protocol_feature_flat_state"] +protocol_feature_zero_balance_account = ["near-primitives/protocol_feature_zero_balance_account"] +nightly_protocol = ["near-primitives/nightly_protocol"] no_cpu_compatibility_checks = ["near-vm-runner/no_cpu_compatibility_checks"] protocol_feature_nep366_delegate_action = [] diff --git a/runtime/runtime/src/lib.rs b/runtime/runtime/src/lib.rs index 5b21809c55a..281ca1f097d 100644 --- a/runtime/runtime/src/lib.rs +++ b/runtime/runtime/src/lib.rs @@ -4,7 +4,6 @@ use std::rc::Rc; use std::sync::Arc; use config::total_prepaid_send_fees; -use near_primitives::sandbox::state_patch::SandboxStatePatch; use tracing::debug; use near_chain_configs::Genesis; @@ -15,8 +14,8 @@ use near_primitives::contract::ContractCode; use near_primitives::profile::ProfileDataV3; pub use near_primitives::runtime::apply_state::ApplyState; use near_primitives::runtime::fees::RuntimeFeesConfig; -use near_primitives::runtime::get_insufficient_storage_stake; use near_primitives::runtime::migration_data::{MigrationData, MigrationFlags}; +use near_primitives::sandbox::state_patch::SandboxStatePatch; use near_primitives::transaction::ExecutionMetadata; use near_primitives::version::{ is_implicit_account_creation_enabled, ProtocolFeature, ProtocolVersion, @@ -61,7 +60,7 @@ use crate::config::{ }; use crate::genesis::{GenesisStateApplier, StorageComputer}; use crate::prefetch::TriePrefetcher; -use crate::verifier::validate_receipt; +use crate::verifier::{check_storage_stake, validate_receipt, StorageStakingError}; pub use crate::verifier::{validate_transaction, verify_and_charge_transaction}; mod actions; @@ -550,21 +549,33 @@ impl Runtime { // Going to check balance covers account's storage. if result.result.is_ok() { if let Some(ref mut account) = account { - if let Some(amount) = get_insufficient_storage_stake(account, &apply_state.config) - .map_err(StorageError::StorageInconsistentState)? - { - result.merge(ActionResult { - result: Err(ActionError { - index: None, - kind: ActionErrorKind::LackBalanceForState { - account_id: account_id.clone(), - amount, - }, - }), - ..Default::default() - })?; - } else { - set_account(state_update, account_id.clone(), account); + match check_storage_stake( + account_id, + account, + &apply_state.config, + state_update, + apply_state.current_protocol_version, + ) { + Ok(()) => { + set_account(state_update, account_id.clone(), account); + } + Err(StorageStakingError::LackBalanceForStorageStaking(amount)) => { + result.merge(ActionResult { + result: Err(ActionError { + index: None, + kind: ActionErrorKind::LackBalanceForState { + account_id: account_id.clone(), + amount, + }, + }), + ..Default::default() + })?; + } + Err(StorageStakingError::StorageError(err)) => { + return Err(RuntimeError::StorageError( + StorageError::StorageInconsistentState(err), + )) + } } } } @@ -2472,8 +2483,6 @@ mod tests { /// Interface provided for gas cost estimations. pub mod estimator { - use super::Runtime; - use crate::ApplyStats; use near_primitives::errors::RuntimeError; use near_primitives::receipt::Receipt; use near_primitives::runtime::apply_state::ApplyState; @@ -2482,6 +2491,10 @@ pub mod estimator { use near_primitives::types::EpochInfoProvider; use near_store::TrieUpdate; + use crate::ApplyStats; + + use super::Runtime; + pub fn apply_action_receipt( state_update: &mut TrieUpdate, apply_state: &ApplyState, diff --git a/runtime/runtime/src/verifier.rs b/runtime/runtime/src/verifier.rs index e7df43e7a3a..79a1fac1410 100644 --- a/runtime/runtime/src/verifier.rs +++ b/runtime/runtime/src/verifier.rs @@ -1,5 +1,7 @@ use near_crypto::key_conversion::is_valid_staking_key; -use near_primitives::runtime::get_insufficient_storage_stake; +use near_primitives::checked_feature; +use near_primitives::runtime::config::RuntimeConfig; +use near_primitives::types::BlockHeight; use near_primitives::{ account::AccessKeyPermission, config::VMLimitConfig, @@ -20,10 +22,114 @@ use near_store::{ }; use crate::config::{total_prepaid_gas, tx_cost, TransactionCost}; +use crate::near_primitives::account::{Account, FunctionCallPermission}; +use crate::near_primitives::trie_key::trie_key_parsers; use crate::VerificationResult; -use near_primitives::checked_feature; -use near_primitives::runtime::config::RuntimeConfig; -use near_primitives::types::BlockHeight; +use near_primitives::hash::CryptoHash; + +/// Possible errors when checking whether an account has enough tokens for storage staking +/// Read details of state staking +/// . +pub enum StorageStakingError { + /// An account does not have enough and the additional amount needed for storage staking + LackBalanceForStorageStaking(Balance), + /// Storage consistency error: an account has invalid storage usage or amount or locked amount + StorageError(String), +} + +/// Checks if given account has enough balance for storage stake, and returns: +/// - Ok(()) if account has enough balance or is a zero-balance account +/// - Err(StorageStakingError::LackBalanceForStorageStaking(amount)) if account doesn't have enough and how much need to be added, +/// - Err(StorageStakingError::StorageError(err)) if account has invalid storage usage or amount/locked. +pub fn check_storage_stake( + account_id: &AccountId, + account: &Account, + runtime_config: &RuntimeConfig, + state_update: &mut TrieUpdate, + current_protocol_version: ProtocolVersion, +) -> Result<(), StorageStakingError> { + let required_amount = Balance::from(account.storage_usage()) + .checked_mul(runtime_config.storage_amount_per_byte()) + .ok_or_else(|| { + format!("Account's storage_usage {} overflows multiplication", account.storage_usage()) + }) + .map_err(StorageStakingError::StorageError)?; + let available_amount = account + .amount() + .checked_add(account.locked()) + .ok_or_else(|| { + format!( + "Account's amount {} and locked {} overflow addition", + account.amount(), + account.locked() + ) + }) + .map_err(StorageStakingError::StorageError)?; + if available_amount >= required_amount { + Ok(()) + } else { + // Check if the account is a zero balance account. The check is delayed until here because + // it requires storage reads + if checked_feature!( + "protocol_feature_zero_balance_account", + ZeroBalanceAccount, + current_protocol_version + ) && is_zero_balance_account(account_id, account, state_update) + .map_err(|e| StorageStakingError::StorageError(e.to_string()))? + { + return Ok(()); + } + Err(StorageStakingError::LackBalanceForStorageStaking(required_amount - available_amount)) + } +} + +/// Zero Balance Account introduced in NEP 448 https://github.com/near/NEPs/pull/448 +/// An account is a zero balance account if and only if the following holds: +/// - it has at most 2 full access keys and at most 2 function call access keys +/// - it does not have any contract deployed or store any contract data +fn is_zero_balance_account( + account_id: &AccountId, + account: &Account, + state_update: &mut TrieUpdate, +) -> Result { + // Check whether the account has a contract deployed + if account.code_hash() != CryptoHash::default() { + return Ok(false); + } + // Check whether the account has any data in contract storage + if let Some(_) = state_update + .iter(&trie_key_parsers::get_raw_prefix_for_contract_data(account_id, &[]))? + .next() + { + return Ok(false); + } + // Check access keys. There can be at most 2 full access keys and at most 2 function call access keys. + // Function call access keys must not specify method names. + let mut full_access_key_count = 0; + let mut function_call_access_key_count = 0; + let raw_prefix = &trie_key_parsers::get_raw_prefix_for_access_keys(account_id); + for raw_key in state_update.iter(raw_prefix)? { + let raw_key = raw_key?; + let access_key = + near_store::get_access_key_raw(state_update, &raw_key)?.ok_or_else(|| { + StorageError::StorageInconsistentState("Missing access key".to_string()) + })?; + match access_key.permission { + AccessKeyPermission::FullAccess => full_access_key_count += 1, + AccessKeyPermission::FunctionCall(FunctionCallPermission { method_names, .. }) => { + if !method_names.is_empty() { + return Ok(false); + } + function_call_access_key_count += 1 + } + } + if full_access_key_count > 2 || function_call_access_key_count > 2 { + return Ok(false); + } + } + + Ok(true) +} #[cfg(feature = "protocol_feature_nep366_delegate_action")] use near_primitives::transaction::SignedDelegateAction; @@ -154,16 +260,16 @@ pub fn verify_and_charge_transaction( } } - match get_insufficient_storage_stake(&signer, config) { - Ok(None) => {} - Ok(Some(amount)) => { + match check_storage_stake(signer_id, &signer, config, state_update, current_protocol_version) { + Ok(()) => {} + Err(StorageStakingError::LackBalanceForStorageStaking(amount)) => { return Err(InvalidTxError::LackBalanceForState { signer_id: signer_id.clone(), amount, } .into()) } - Err(err) => { + Err(StorageStakingError::StorageError(err)) => { return Err(RuntimeError::StorageError(StorageError::StorageInconsistentState(err))) } }; @@ -491,9 +597,13 @@ mod tests { use near_store::test_utils::create_tries; use testlib::runtime_utils::{alice_account, bob_account, eve_dot_alice_account}; - use super::*; use crate::near_primitives::shard_layout::ShardUId; + use super::*; + use crate::near_primitives::contract::ContractCode; + use crate::near_primitives::trie_key::TrieKey; + use near_store::{set, set_code}; + #[cfg(feature = "protocol_feature_nep366_delegate_action")] use near_crypto::Signature; #[cfg(feature = "protocol_feature_nep366_delegate_action")] @@ -510,11 +620,21 @@ mod tests { initial_locked: Balance, access_key: Option, ) -> (Arc, TrieUpdate, Balance) { - setup_accounts(vec![(alice_account(), initial_balance, initial_locked, access_key)]) + let access_keys = if let Some(key) = access_key { vec![key] } else { vec![] }; + setup_accounts(vec![( + alice_account(), + initial_balance, + initial_locked, + access_keys, + false, + false, + )]) } fn setup_accounts( - accounts: Vec<(AccountId, Balance, Balance, Option)>, + // two bools: first one is whether the account has a contract, second one is whether the + // account has data + accounts: Vec<(AccountId, Balance, Balance, Vec, bool, bool)>, ) -> (Arc, TrieUpdate, Balance) { let tries = create_tries(); let root = MerkleHash::default(); @@ -527,16 +647,38 @@ mod tests { )); let mut initial_state = tries.new_trie_update(ShardUId::single_shard(), root); - for (account_id, initial_balance, initial_locked, access_key) in accounts { - let mut initial_account = account_new(initial_balance, hash(&[])); + for (account_id, initial_balance, initial_locked, access_keys, has_contract, has_data) in + accounts + { + let mut initial_account = account_new(initial_balance, CryptoHash::default()); initial_account.set_locked(initial_locked); set_account(&mut initial_state, account_id.clone(), &initial_account); - if let Some(access_key) = access_key { - set_access_key( + let mut key_count = 0; + for access_key in access_keys { + let public_key = if key_count == 0 { + signer.public_key() + } else { + PublicKey::from_seed(KeyType::ED25519, format!("{}", key_count).as_str()) + }; + set_access_key(&mut initial_state, account_id.clone(), public_key, &access_key); + key_count += 1; + } + if has_contract { + let code = vec![1, 2, 3]; + let code_hash = hash(&code); + set_code( &mut initial_state, account_id.clone(), - signer.public_key(), - &access_key, + &ContractCode::new(code, Some(code_hash)), + ); + initial_account.set_code_hash(code_hash); + set_account(&mut initial_state, account_id.clone(), &initial_account); + } + if has_data { + set( + &mut initial_state, + TrieKey::ContractData { account_id, key: b"test".to_vec() }, + &[1, 2, 3, 4, 5], ); } } @@ -576,6 +718,121 @@ mod tests { ); } + mod zero_balance_account_tests { + use crate::near_primitives::account::id::AccountId; + use crate::near_primitives::account::{ + AccessKeyPermission, Account, FunctionCallPermission, + }; + use crate::verifier::is_zero_balance_account; + use crate::verifier::tests::{setup_accounts, TESTING_INIT_BALANCE}; + use near_primitives::account::AccessKey; + use near_store::{get_account, TrieUpdate}; + use testlib::runtime_utils::{alice_account, bob_account}; + + fn set_up_test_account( + account_id: &AccountId, + num_full_access_keys: u64, + num_function_call_access_keys: u64, + has_contract_code: bool, + has_contract_data: bool, + ) -> (Account, TrieUpdate) { + let mut access_keys = vec![]; + for _ in 0..num_full_access_keys { + access_keys.push(AccessKey::full_access()); + } + for _ in 0..num_function_call_access_keys { + let access_key = AccessKey { + nonce: 0, + permission: AccessKeyPermission::FunctionCall(FunctionCallPermission { + allowance: Some(100), + receiver_id: bob_account().into(), + method_names: vec![], + }), + }; + access_keys.push(access_key); + } + let (_, state_update, _) = setup_accounts(vec![( + account_id.clone(), + TESTING_INIT_BALANCE, + 0, + access_keys, + has_contract_code, + has_contract_data, + )]); + let account = get_account(&state_update, account_id).unwrap().unwrap(); + (account, state_update) + } + + /// Testing all combination of access keys and contract code/data deployed in this test + /// to make sure that an account is zero balance only if it has <=2 full access keys and + /// <= function call access keys and doesn't have contract code or contract data + #[test] + fn test_zero_balance_account_with_keys_and_contract() { + for num_full_access_key in 0..10 { + for num_function_call_access_key in 0..10 { + for i in 0..=1 { + for j in 0..=1 { + let account_id: AccountId = format!( + "alice{}.near", + num_full_access_key * 1000 + + num_function_call_access_key * 100 + + i * 10 + + j + ) + .parse() + .unwrap(); + let has_contract_code = i == 0; + let has_contract_data = j == 0; + let (account, mut state_update) = set_up_test_account( + &account_id, + num_full_access_key, + num_function_call_access_key, + has_contract_code, + has_contract_data, + ); + let res = + is_zero_balance_account(&account_id, &account, &mut state_update); + if num_full_access_key <= 2 + && num_function_call_access_key <= 2 + && !has_contract_code + && !has_contract_data + { + assert_eq!(res, Ok(true)); + } else { + assert_eq!(res, Ok(false)); + } + } + } + } + } + } + + #[test] + fn test_zero_balance_account_with_invalid_access_key() { + let account_id = alice_account(); + let (_, mut state_update, _) = setup_accounts(vec![( + account_id.clone(), + 0, + 0, + vec![AccessKey { + nonce: 0, + permission: AccessKeyPermission::FunctionCall(FunctionCallPermission { + allowance: Some(100), + receiver_id: bob_account().into(), + method_names: vec!["hello".to_string()], + }), + }], + false, + false, + )]); + let account = get_account(&state_update, &account_id).unwrap().unwrap(); + assert_eq!( + is_zero_balance_account(&account_id, &account, &mut state_update), + Ok(false) + ); + } + } + // Transactions #[test] @@ -893,6 +1150,7 @@ mod tests { /// Setup: account has 1B yoctoN and is 180 bytes. Storage requirement is 1M per byte. /// Test that such account can not send 950M yoctoN out as that will leave it under storage requirements. + /// If zero balance account is enabled, however, the transaction should succeed #[test] fn test_validate_transaction_invalid_low_balance() { let mut config = RuntimeConfig::free(); @@ -902,24 +1160,26 @@ mod tests { let (signer, mut state_update, gas_price) = setup_common(initial_balance, 0, Some(AccessKey::full_access())); + let res = verify_and_charge_transaction( + &config, + &mut state_update, + gas_price, + &SignedTransaction::send_money( + 1, + alice_account(), + bob_account(), + &*signer, + transfer_amount, + CryptoHash::default(), + ), + true, + None, + PROTOCOL_VERSION, + ); + + #[cfg(not(feature = "protocol_feature_zero_balance_account"))] assert_eq!( - verify_and_charge_transaction( - &config, - &mut state_update, - gas_price, - &SignedTransaction::send_money( - 1, - alice_account(), - bob_account(), - &*signer, - transfer_amount, - CryptoHash::default(), - ), - true, - None, - PROTOCOL_VERSION, - ) - .expect_err("expected an error"), + res.expect_err("expected an error"), RuntimeError::InvalidTxError(InvalidTxError::LackBalanceForState { signer_id: alice_account(), amount: Balance::from(std::mem::size_of::() as u64) @@ -927,6 +1187,59 @@ mod tests { - (initial_balance - transfer_amount) }) ); + #[cfg(feature = "protocol_feature_zero_balance_account")] + { + let verification_result = res.unwrap(); + assert_eq!(verification_result.gas_burnt, 0); + assert_eq!(verification_result.gas_remaining, 0); + assert_eq!(verification_result.burnt_amount, 0); + } + } + + #[test] + fn test_validate_transaction_invalid_low_balance_many_keys() { + let mut config = RuntimeConfig::free(); + config.fees.storage_usage_config.storage_amount_per_byte = 10_000_000; + let initial_balance = 1_000_000_000; + let transfer_amount = 950_000_000; + let account_id = alice_account(); + let access_keys = vec![AccessKey::full_access(); 3]; + let (signer, mut state_update, gas_price) = setup_accounts(vec![( + account_id.clone(), + initial_balance, + 0, + access_keys, + false, + false, + )]); + + let res = verify_and_charge_transaction( + &config, + &mut state_update, + gas_price, + &SignedTransaction::send_money( + 1, + account_id.clone(), + bob_account(), + &*signer, + transfer_amount, + CryptoHash::default(), + ), + true, + None, + PROTOCOL_VERSION, + ) + .expect_err("expected an error"); + + assert_eq!( + res, + RuntimeError::InvalidTxError(InvalidTxError::LackBalanceForState { + signer_id: account_id.clone(), + amount: Balance::from(std::mem::size_of::() as u64) + * config.storage_amount_per_byte() + - (initial_balance - transfer_amount) + }) + ); } #[test]