diff --git a/Cargo.lock b/Cargo.lock index d7b580bc84a..06aa3d594d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4197,6 +4197,7 @@ dependencies = [ "bs58", "derive_more", "enum-map", + "expect-test", "insta", "near-account-id", "num-rational", diff --git a/chain/chain/src/test_utils/kv_runtime.rs b/chain/chain/src/test_utils/kv_runtime.rs index 9b761a9b918..861132f9b99 100644 --- a/chain/chain/src/test_utils/kv_runtime.rs +++ b/chain/chain/src/test_utils/kv_runtime.rs @@ -1242,8 +1242,10 @@ impl RuntimeAdapter for KeyValueRuntime { |state| *state.amounts.get(account_id).unwrap_or(&0), ), 0, + 0, CryptoHash::default(), 0, + PROTOCOL_VERSION, ) .into(), ), diff --git a/chain/chain/src/tests/simple_chain.rs b/chain/chain/src/tests/simple_chain.rs index e213c859840..c99f3f5c681 100644 --- a/chain/chain/src/tests/simple_chain.rs +++ b/chain/chain/src/tests/simple_chain.rs @@ -52,7 +52,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, @"DyeyWKAniYDFcxkf3n1VZdZfckguyvNs4qwoETYW2ofW"); + insta::assert_display_snapshot!(hash, @"9JRdRkSfVRwYBVFNzfKwuhuUabMkQN14b1Ge7z1FRnMF"); } else { insta::assert_display_snapshot!(hash, @"HJmRPXT4JM9tt6mXw2gM75YaSoqeDCphhFK26uRpd1vw"); } @@ -82,7 +82,7 @@ fn build_chain() { let hash = chain.head().unwrap().last_block_hash; if cfg!(feature = "nightly") { - insta::assert_display_snapshot!(hash, @"NuKEKpsFRb3ZwTe9TtEuQcK1qkPKRBvkv8cNd9GrzN2"); + insta::assert_display_snapshot!(hash, @"HkPEDHWJHuW8Yzw9FonrJarVAHi51mQt7A4DQUtBagko"); } else { insta::assert_display_snapshot!(hash, @"HbQVGVZ3WGxsNqeM3GfSwDoxwYZ2RBP1SinAze9SYR3C"); } diff --git a/chain/client/src/test_utils/test_env.rs b/chain/client/src/test_utils/test_env.rs index d93d788ff2b..1c7ccf38c3e 100644 --- a/chain/client/src/test_utils/test_env.rs +++ b/chain/client/src/test_utils/test_env.rs @@ -12,6 +12,7 @@ use near_async::time::Clock; use near_chain::test_utils::ValidatorSchedule; use near_chain::{ChainGenesis, Provenance}; use near_chain_configs::GenesisConfig; +use near_chain_primitives::error::QueryError; use near_chunks::client::ShardsManagerResponse; use near_chunks::test_utils::{MockClientAdapterForShardsManager, SynchronousShardsManagerAdapter}; use near_crypto::{InMemorySigner, KeyType, Signer}; @@ -36,8 +37,10 @@ use near_primitives::types::{AccountId, Balance, BlockHeight, EpochId, NumSeats} use near_primitives::utils::MaybeValidated; use near_primitives::version::ProtocolVersion; use near_primitives::views::{ - AccountView, FinalExecutionOutcomeView, QueryRequest, QueryResponseKind, StateItem, + AccountView, FinalExecutionOutcomeView, QueryRequest, QueryResponse, QueryResponseKind, + StateItem, }; +use near_store::ShardUId; use once_cell::sync::OnceCell; use super::setup::setup_client_with_runtime; @@ -462,6 +465,22 @@ impl TestEnv { panic!("No client tracks shard {}", shard_id); } + /// Passes the given query to the runtime adapter using the current head and returns a result. + pub fn query_view(&mut self, request: QueryRequest) -> Result { + let head = self.clients[0].chain.head().unwrap(); + let head_block = self.clients[0].chain.get_block(&head.last_block_hash).unwrap(); + self.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(), + &request, + ) + } + pub fn query_state(&mut self, account_id: AccountId) -> Vec { let client = &self.clients[0]; let head = client.chain.head().unwrap(); diff --git a/chain/jsonrpc/res/rpc_errors_schema.json b/chain/jsonrpc/res/rpc_errors_schema.json index 1279c61751e..85a209dc090 100644 --- a/chain/jsonrpc/res/rpc_errors_schema.json +++ b/chain/jsonrpc/res/rpc_errors_schema.json @@ -46,7 +46,8 @@ "DelegateActionExpired", "DelegateActionAccessKeyError", "DelegateActionInvalidNonce", - "DelegateActionNonceTooLarge" + "DelegateActionNonceTooLarge", + "NonRefundableBalanceToExistingAccount" ], "props": { "index": "" @@ -640,6 +641,13 @@ "subtypes": [], "props": {} }, + "NonRefundableBalanceToExistingAccount": { + "name": "NonRefundableBalanceToExistingAccount", + "subtypes": [], + "props": { + "account_id": "" + } + }, "NonceTooLarge": { "name": "NonceTooLarge", "subtypes": [], diff --git a/chain/rosetta-rpc/Cargo.toml b/chain/rosetta-rpc/Cargo.toml index 5d3cc1fa8d6..a51721c8586 100644 --- a/chain/rosetta-rpc/Cargo.toml +++ b/chain/rosetta-rpc/Cargo.toml @@ -43,6 +43,7 @@ insta.workspace = true near-actix-test-utils.workspace = true [features] +protocol_feature_nonrefundable_transfer_nep491 = [] nightly_protocol = [ "near-actix-test-utils/nightly_protocol", "near-chain-configs/nightly_protocol", @@ -65,4 +66,5 @@ nightly = [ "near-primitives/nightly", "nightly_protocol", "node-runtime/nightly", + "protocol_feature_nonrefundable_transfer_nep491", ] diff --git a/chain/rosetta-rpc/src/adapters/mod.rs b/chain/rosetta-rpc/src/adapters/mod.rs index cdeedf9aea8..e4eca108050 100644 --- a/chain/rosetta-rpc/src/adapters/mod.rs +++ b/chain/rosetta-rpc/src/adapters/mod.rs @@ -352,6 +352,36 @@ impl From for Vec { ); } + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + // Both refundable and non-refundable transfers are considered as available balance. + // TODO(nonrefundable) Merge with the arm above on stabilization. + near_primitives::transaction::Action::NonrefundableStorageTransfer(action) => { + let transfer_amount = crate::models::Amount::from_yoctonear(action.deposit); + + let sender_transfer_operation_id = + crate::models::OperationIdentifier::new(&operations); + operations.push( + validated_operations::TransferOperation { + account: sender_account_identifier.clone(), + amount: -transfer_amount.clone(), + predecessor_id: Some(sender_account_identifier.clone()), + } + .into_operation(sender_transfer_operation_id.clone()), + ); + + operations.push( + validated_operations::TransferOperation { + account: receiver_account_identifier.clone(), + amount: transfer_amount, + predecessor_id: Some(sender_account_identifier.clone()), + } + .into_related_operation( + crate::models::OperationIdentifier::new(&operations), + vec![sender_transfer_operation_id], + ), + ); + } + near_primitives::transaction::Action::Stake(action) => { operations.push( validated_operations::StakeOperation { @@ -837,6 +867,35 @@ mod tests { use near_primitives::action::delegate::{DelegateAction, SignedDelegateAction}; use near_primitives::transaction::{Action, TransferAction}; + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + #[test] + fn test_convert_nonrefundable_storage_transfer_action() { + let transfer_actions = vec![near_primitives::transaction::TransferAction { + deposit: near_primitives::types::Balance::MAX, + } + .into()]; + let nonrefundable_transfer_actions = + vec![near_primitives::transaction::NonrefundableStorageTransferAction { + deposit: near_primitives::types::Balance::MAX, + } + .into()]; + let near_transfer_actions = NearActions { + sender_account_id: "sender.near".parse().unwrap(), + receiver_account_id: "receiver.near".parse().unwrap(), + actions: transfer_actions, + }; + let near_nonrefundable_transfer_actions = NearActions { + sender_account_id: "sender.near".parse().unwrap(), + receiver_account_id: "receiver.near".parse().unwrap(), + actions: nonrefundable_transfer_actions, + }; + let transfer_operations_converted: Vec = + near_transfer_actions.into(); + let nonrefundable_transfer_operations_converted: Vec = + near_nonrefundable_transfer_actions.into(); + assert_eq!(transfer_operations_converted, nonrefundable_transfer_operations_converted); + } + #[test] fn test_convert_block_changes_to_transactions() { run_actix(async { @@ -860,6 +919,8 @@ mod tests { amount: 5000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, @@ -875,6 +936,8 @@ mod tests { amount: 4000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, @@ -888,6 +951,8 @@ mod tests { amount: 7000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, @@ -903,6 +968,8 @@ mod tests { amount: 8000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, @@ -916,6 +983,8 @@ mod tests { amount: 4000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, @@ -926,6 +995,8 @@ mod tests { amount: 6000000000000000000, code_hash: near_primitives::hash::CryptoHash::default(), locked: 400000000000000000000000000000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, storage_paid_at: 0, storage_usage: 200000, }, diff --git a/chain/rosetta-rpc/src/lib.rs b/chain/rosetta-rpc/src/lib.rs index af26c728b61..e8be28019ed 100644 --- a/chain/rosetta-rpc/src/lib.rs +++ b/chain/rosetta-rpc/src/lib.rs @@ -17,7 +17,7 @@ pub use config::RosettaRpcConfig; use near_chain_configs::Genesis; use near_client::{ClientActor, ViewClientActor}; use near_o11y::WithSpanContextExt; -use near_primitives::borsh::BorshDeserialize; +use near_primitives::{borsh::BorshDeserialize, version::PROTOCOL_VERSION}; mod adapters; mod config; @@ -368,7 +368,15 @@ async fn account_balance( Err(crate::errors::ErrorKind::NotFound(_)) => ( block.header.hash, block.header.height, - near_primitives::account::Account::new(0, 0, Default::default(), 0).into(), + near_primitives::account::Account::new( + 0, + 0, + 0, + Default::default(), + 0, + PROTOCOL_VERSION, + ) + .into(), ), Err(err) => return Err(err.into()), }; diff --git a/core/chain-configs/src/genesis_validate.rs b/core/chain-configs/src/genesis_validate.rs index b2674cbbae2..8e917de61f6 100644 --- a/core/chain-configs/src/genesis_validate.rs +++ b/core/chain-configs/src/genesis_validate.rs @@ -58,7 +58,7 @@ impl<'a> GenesisValidator<'a> { format!("Duplicate account id {} in genesis records", account_id); self.validation_errors.push_genesis_semantics_error(error_message) } - self.total_supply += account.locked() + account.amount(); + self.total_supply += account.locked() + account.amount() + account.nonrefundable(); self.account_ids.insert(account_id.clone()); if account.locked() > 0 { self.staked_accounts.insert(account_id.clone(), account.locked()); @@ -200,11 +200,31 @@ mod test { use near_crypto::{KeyType, PublicKey}; use near_primitives::account::{AccessKey, Account}; use near_primitives::types::AccountInfo; + use near_primitives::version::PROTOCOL_VERSION; const VALID_ED25519_RISTRETTO_KEY: &str = "ed25519:KuTCtARNzxZQ3YvXDeLjx83FDqxv2SdQTSbiq876zR7"; fn create_account() -> Account { - Account::new(100, 10, Default::default(), 0) + Account::new(100, 10, 0, Default::default(), 0, PROTOCOL_VERSION) + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + #[test] + fn test_total_supply_includes_nonrefundable_amount() { + let mut config = GenesisConfig::default(); + config.epoch_length = 42; + config.total_supply = 111; + config.validators = vec![AccountInfo { + account_id: "test".parse().unwrap(), + public_key: VALID_ED25519_RISTRETTO_KEY.parse().unwrap(), + amount: 10, + }]; + let records = GenesisRecords(vec![StateRecord::Account { + account_id: "test".parse().unwrap(), + account: Account::new(100, 10, 1, Default::default(), 0, PROTOCOL_VERSION), + }]); + let genesis = &Genesis::new(config, records).unwrap(); + validate_genesis(genesis).unwrap(); } #[test] diff --git a/core/primitives-core/Cargo.toml b/core/primitives-core/Cargo.toml index 8de9c32254f..2d15499b5d0 100644 --- a/core/primitives-core/Cargo.toml +++ b/core/primitives-core/Cargo.toml @@ -29,17 +29,20 @@ near-account-id.workspace = true [dev-dependencies] serde_json.workspace = true insta.workspace = true +expect-test.workspace = true [features] default = [] protocol_feature_fix_staking_threshold = [] protocol_feature_fix_contract_loading_cost = [] protocol_feature_reject_blocks_with_outdated_protocol_version = [] +protocol_feature_nonrefundable_transfer_nep491 = [] nightly = [ "nightly_protocol", "protocol_feature_fix_contract_loading_cost", "protocol_feature_fix_staking_threshold", + "protocol_feature_nonrefundable_transfer_nep491", "protocol_feature_reject_blocks_with_outdated_protocol_version", ] diff --git a/core/primitives-core/src/account.rs b/core/primitives-core/src/account.rs index 380bf449421..96b44379b09 100644 --- a/core/primitives-core/src/account.rs +++ b/core/primitives-core/src/account.rs @@ -1,6 +1,8 @@ +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +use crate::checked_feature; use crate::hash::CryptoHash; use crate::serialize::dec_format; -use crate::types::{Balance, Nonce, StorageUsage}; +use crate::types::{Balance, Nonce, ProtocolVersion, StorageUsage}; use borsh::{BorshDeserialize, BorshSerialize}; pub use near_account_id as id; use std::io; @@ -9,6 +11,7 @@ use std::io; BorshSerialize, BorshDeserialize, PartialEq, + PartialOrd, Eq, Clone, Copy, @@ -18,24 +21,48 @@ use std::io; serde::Deserialize, )] pub enum AccountVersion { - #[default] + #[cfg_attr(not(feature = "protocol_feature_nonrefundable_transfer_nep491"), default)] V1, + #[default] + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + V2, +} + +impl TryFrom for AccountVersion { + type Error = (); + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(AccountVersion::V1), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + 2 => Ok(AccountVersion::V2), + _ => Err(()), + } + } } /// Per account information stored in the state. -#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug, Clone)] +#[cfg_attr( + not(feature = "protocol_feature_nonrefundable_transfer_nep491"), + derive(serde::Deserialize) +)] +#[derive(serde::Serialize, PartialEq, Eq, Debug, Clone)] pub struct Account { - /// The total not locked tokens. + /// The total not locked, refundable tokens. #[serde(with = "dec_format")] amount: Balance, /// The amount locked due to staking. #[serde(with = "dec_format")] locked: Balance, + /// Tokens that are not available to withdraw, stake, or refund, but can be used to cover storage usage. + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + #[serde(with = "dec_format")] + nonrefundable: Balance, /// Hash of the code stored in the storage for this account. code_hash: CryptoHash, /// Storage used by the given account, includes account id, this struct, access keys and other data. storage_usage: StorageUsage, - /// Version of Account in re migrations and similar + /// Version of Account in re migrations and similar. #[serde(default)] version: AccountVersion, } @@ -44,14 +71,44 @@ impl Account { /// Max number of bytes an account can have in its state (excluding contract code) /// before it is infeasible to delete. pub const MAX_ACCOUNT_DELETION_STORAGE_USAGE: u64 = 10_000; + /// HACK: Using u128::MAX as a sentinel value, there are not enough tokens + /// in total supply which makes it an invalid value. We use it to + /// differentiate AccountVersion V1 from newer versions. + const SERIALIZATION_SENTINEL: u128 = u128::MAX; + // TODO(nonrefundable) Consider using consider some additional newtypes + // or a different way to write down constructor (e.g. builder pattern.) pub fn new( amount: Balance, locked: Balance, + nonrefundable: Balance, code_hash: CryptoHash, storage_usage: StorageUsage, + #[cfg_attr(not(feature = "protocol_feature_nonrefundable_transfer_nep491"), allow(unused))] + protocol_version: ProtocolVersion, ) -> Self { - Account { amount, locked, code_hash, storage_usage, version: AccountVersion::V1 } + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + let account_version = AccountVersion::V1; + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + let account_version = if checked_feature!("stable", NonRefundableBalance, protocol_version) + { + AccountVersion::V2 + } else { + AccountVersion::V1 + }; + if account_version == AccountVersion::V1 { + assert_eq!(nonrefundable, 0); + } + Account { + amount, + locked, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable, + code_hash, + storage_usage, + version: account_version, + } } #[inline] @@ -59,6 +116,18 @@ impl Account { self.amount } + #[inline] + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + pub fn nonrefundable(&self) -> Balance { + self.nonrefundable + } + + #[inline] + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + pub fn nonrefundable(&self) -> Balance { + 0 + } + #[inline] pub fn locked(&self) -> Balance { self.locked @@ -84,6 +153,12 @@ impl Account { self.amount = amount; } + #[inline] + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + pub fn set_nonrefundable(&mut self, nonrefundable: Balance) { + self.nonrefundable = nonrefundable; + } + #[inline] pub fn set_locked(&mut self, locked: Balance) { self.locked = locked; @@ -104,7 +179,9 @@ impl Account { } } -#[derive(BorshSerialize, BorshDeserialize)] +/// These accounts are serialized in merklized state. +/// We keep old accounts in the old format to avoid migration of the MPT. +#[derive(BorshSerialize, serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug, Clone)] struct LegacyAccount { amount: Balance, locked: Balance, @@ -112,31 +189,184 @@ struct LegacyAccount { storage_usage: StorageUsage, } +/// We only allow nonrefundable storage on new accounts (see `LegacyAccount`). +#[derive(BorshSerialize, BorshDeserialize)] +struct AccountV2 { + amount: Balance, + locked: Balance, + code_hash: CryptoHash, + storage_usage: StorageUsage, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: Balance, +} + +/// We need custom serde deserialization in order to parse mainnet genesis accounts (LegacyAccounts) +/// as accounts V1. This preserves the mainnet genesis hash. +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +impl<'de> serde::Deserialize<'de> for Account { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct AccountData { + #[serde(with = "dec_format")] + amount: Balance, + #[serde(with = "dec_format")] + locked: Balance, + // If the field is missing, serde will use None as the default. + #[serde(default, with = "dec_format")] + nonrefundable: Option, + code_hash: CryptoHash, + storage_usage: StorageUsage, + #[serde(default)] + version: Option, + } + + let account_data = AccountData::deserialize(deserializer)?; + + match account_data.nonrefundable { + Some(nonrefundable) => { + // Given that the `nonrefundable` field has been serialized, the `version` field must has been serialized too. + let version = match account_data.version { + Some(version) => version, + None => { + return Err(serde::de::Error::custom("missing `version` field")); + } + }; + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + if version < AccountVersion::V2 && nonrefundable > 0 { + return Err(serde::de::Error::custom( + "non-refundable positive amount exists for account version older than V2", + )); + } + + Ok(Account { + amount: account_data.amount, + locked: account_data.locked, + code_hash: account_data.code_hash, + storage_usage: account_data.storage_usage, + nonrefundable, + version, + }) + } + None => Ok(Account { + amount: account_data.amount, + locked: account_data.locked, + code_hash: account_data.code_hash, + storage_usage: account_data.storage_usage, + nonrefundable: 0, + version: AccountVersion::V1, + }), + } + } +} + impl BorshDeserialize for Account { fn deserialize_reader(rd: &mut R) -> io::Result { - // This should only ever happen if we have pre-transition account serialized in state - // See test_account_size - let deserialized_account = LegacyAccount::deserialize_reader(rd)?; - Ok(Account { - amount: deserialized_account.amount, - locked: deserialized_account.locked, - code_hash: deserialized_account.code_hash, - storage_usage: deserialized_account.storage_usage, - version: AccountVersion::V1, - }) + // The first value of all Account serialization formats is a u128, + // either a sentinel or a balance. + let sentinel_or_amount = u128::deserialize_reader(rd)?; + if sentinel_or_amount == Account::SERIALIZATION_SENTINEL { + if cfg!(not(feature = "protocol_feature_nonrefundable_transfer_nep491")) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("account serialization sentinel not allowed for AccountV1"), + )); + } + + // Account v2 or newer. + let version_byte = u8::deserialize_reader(rd)?; + let version = AccountVersion::try_from(version_byte).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "error deserializing account: invalid account version {}", + version_byte + ), + ) + })?; + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + if version < AccountVersion::V2 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("expected account version 2 or higher, got {:?}", version), + )); + } + let account = AccountV2::deserialize_reader(rd)?; + + Ok(Account { + amount: account.amount, + locked: account.locked, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: account.nonrefundable, + code_hash: account.code_hash, + storage_usage: account.storage_usage, + version, + }) + } else { + // Account v1 + let locked = u128::deserialize_reader(rd)?; + let code_hash = CryptoHash::deserialize_reader(rd)?; + let storage_usage = StorageUsage::deserialize_reader(rd)?; + + Ok(Account { + amount: sentinel_or_amount, + locked, + code_hash, + storage_usage, + version: AccountVersion::V1, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, + }) + } } } impl BorshSerialize for Account { fn serialize(&self, writer: &mut W) -> io::Result<()> { - match self.version { - AccountVersion::V1 => LegacyAccount { - amount: self.amount, - locked: self.locked, - code_hash: self.code_hash, - storage_usage: self.storage_usage, + let legacy_account = LegacyAccount { + amount: self.amount(), + locked: self.locked(), + code_hash: self.code_hash(), + storage_usage: self.storage_usage(), + }; + + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + { + legacy_account.serialize(writer) + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + { + match self.version { + // It might be tempting to lazily convert old V1 to V2 + // while serializing. But that would break the borsh assumptions + // of unique binary representation. + AccountVersion::V1 => { + if self.nonrefundable > 0 { + panic!("Trying to serialize V1 account with nonrefundable amount"); + } + legacy_account.serialize(writer) + } + AccountVersion::V2 => { + let account = AccountV2 { + amount: self.amount(), + locked: self.locked(), + code_hash: self.code_hash(), + storage_usage: self.storage_usage(), + nonrefundable: self.nonrefundable(), + }; + let sentinel = Account::SERIALIZATION_SENTINEL; + // For now a constant, but if we need V3 later we can use this + // field instead of sentinel magic. + let version = 2u8; + BorshSerialize::serialize(&sentinel, writer)?; + BorshSerialize::serialize(&version, writer)?; + account.serialize(writer) + } } - .serialize(writer), } } } @@ -236,18 +466,48 @@ pub struct FunctionCallPermission { mod tests { use crate::hash::hash; + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + use crate::version::ProtocolFeature; use super::*; #[test] - fn test_account_serialization() { - let acc = Account::new(1_000_000, 1_000_000, CryptoHash::default(), 100); - let bytes = borsh::to_vec(&acc).unwrap(); - assert_eq!(hash(&bytes).to_string(), "EVk5UaxBe8LQ8r8iD5EAxVBs6TJcMDKqyH7PBuho6bBJ"); + #[should_panic] + fn test_v1_account_cannot_have_nonrefundable_amount() { + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + let protocol_version = crate::version::PROTOCOL_VERSION; + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + let protocol_version = ProtocolFeature::NonRefundableBalance.protocol_version() - 1; + + Account::new(0, 0, 1, CryptoHash::default(), 0, protocol_version); + } + + #[test] + fn test_legacy_account_serde_serialization() { + let old_account = LegacyAccount { + amount: 1_000_000, + locked: 1_000_000, + code_hash: CryptoHash::default(), + storage_usage: 100, + }; + + let serialized_account = serde_json::to_string(&old_account).unwrap(); + let new_account: Account = serde_json::from_str(&serialized_account).unwrap(); + assert_eq!(new_account.amount(), old_account.amount); + assert_eq!(new_account.locked(), old_account.locked); + assert_eq!(new_account.code_hash(), old_account.code_hash); + assert_eq!(new_account.storage_usage(), old_account.storage_usage); + assert_eq!(new_account.nonrefundable(), 0); + assert_eq!(new_account.version, AccountVersion::V1); + + let new_serialized_account = serde_json::to_string(&new_account).unwrap(); + let deserialized_account: Account = serde_json::from_str(&new_serialized_account).unwrap(); + assert_eq!(deserialized_account, new_account); } #[test] - fn test_account_deserialization() { + fn test_legacy_account_borsh_serialization() { let old_account = LegacyAccount { amount: 100, locked: 200, @@ -256,14 +516,148 @@ mod tests { }; let mut old_bytes = &borsh::to_vec(&old_account).unwrap()[..]; let new_account = ::deserialize(&mut old_bytes).unwrap(); - assert_eq!(new_account.amount, old_account.amount); - assert_eq!(new_account.locked, old_account.locked); - assert_eq!(new_account.code_hash, old_account.code_hash); - assert_eq!(new_account.storage_usage, old_account.storage_usage); + + assert_eq!(new_account.amount(), old_account.amount); + assert_eq!(new_account.locked(), old_account.locked); + assert_eq!(new_account.code_hash(), old_account.code_hash); + assert_eq!(new_account.storage_usage(), old_account.storage_usage); assert_eq!(new_account.version, AccountVersion::V1); + let mut new_bytes = &borsh::to_vec(&new_account).unwrap()[..]; let deserialized_account = ::deserialize(&mut new_bytes).unwrap(); assert_eq!(deserialized_account, new_account); } + + #[test] + fn test_account_v1_serde_serialization() { + let account = Account { + amount: 10_000_000, + locked: 100_000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, + code_hash: CryptoHash::default(), + storage_usage: 1000, + version: AccountVersion::V1, + }; + let serialized_account = serde_json::to_string(&account).unwrap(); + let deserialized_account: Account = serde_json::from_str(&serialized_account).unwrap(); + assert_eq!(deserialized_account, account); + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + /// It is impossible to construct V1 account with nonrefundable amount greater than 0. + /// So the situation in this test is theoretical. + /// + /// Serialization of account V1 with non-refundable amount greater than 0 would pass without an error, + /// but an error would be raised on deserialization of such invalid data. + #[test] + fn test_account_v1_serde_serialization_invalid_data() { + let account = Account { + amount: 10_000_000, + locked: 100_000, + nonrefundable: 1, + code_hash: CryptoHash::default(), + storage_usage: 1000, + version: AccountVersion::V1, + }; + let serialized_account = serde_json::to_string(&account).unwrap(); + let deserialization_result: Result = + serde_json::from_str(&serialized_account); + assert!(deserialization_result.is_err()); + } + + #[test] + fn test_account_v1_borsh_serialization() { + let account = Account { + amount: 1_000_000, + locked: 1_000_000, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: 0, + code_hash: CryptoHash::default(), + storage_usage: 100, + version: AccountVersion::V1, + }; + let serialized_account = borsh::to_vec(&account).unwrap(); + assert_eq!( + &hash(&serialized_account).to_string(), + "EVk5UaxBe8LQ8r8iD5EAxVBs6TJcMDKqyH7PBuho6bBJ" + ); + let deserialized_account = + ::deserialize(&mut &serialized_account[..]).unwrap(); + assert_eq!(deserialized_account, account); + } + + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + #[test] + #[should_panic(expected = "account serialization sentinel not allowed for AccountV1")] + fn test_account_v1_borsh_serialization_sentinel() { + let account = Account { + amount: Account::SERIALIZATION_SENTINEL, + locked: 1_000_000, + code_hash: CryptoHash::default(), + storage_usage: 100, + version: AccountVersion::V1, + }; + let serialized_account = borsh::to_vec(&account).unwrap(); + ::deserialize(&mut &serialized_account[..]).unwrap(); + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + /// It is impossible to construct V1 account with nonrefundable amount greater than 0. + /// So the situation in this test is theoretical. + /// + /// If a V1 account had nonrefundable amount greater than zero, it would panic during Borsh serialization. + #[test] + #[should_panic(expected = "Trying to serialize V1 account with nonrefundable amount")] + fn test_account_v1_borsh_serialization_invalid_data() { + let account = Account { + amount: 1_000_000, + locked: 1_000_000, + nonrefundable: 1, + code_hash: CryptoHash::default(), + storage_usage: 100, + version: AccountVersion::V1, + }; + let _ = borsh::to_vec(&account); + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + #[test] + fn test_account_v2_serde_serialization() { + let account = Account { + amount: 10_000_000, + locked: 100_000, + nonrefundable: 37, + code_hash: CryptoHash::default(), + storage_usage: 1000, + version: AccountVersion::V2, + }; + let serialized_account = serde_json::to_string(&account).unwrap(); + let deserialized_account: Account = serde_json::from_str(&serialized_account).unwrap(); + assert_eq!(deserialized_account, account); + } + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + #[test] + fn test_account_v2_borsh_serialization() { + let account = Account { + amount: 1_000_000, + locked: 1_000_000, + nonrefundable: 42, + code_hash: CryptoHash::default(), + storage_usage: 100, + version: AccountVersion::V2, + }; + let serialized_account = borsh::to_vec(&account).unwrap(); + if cfg!(feature = "protocol_feature_nonrefundable_transfer_nep491") { + expect_test::expect!("A3Ypkhkm6G5PYwHZw1eKYVunEzafLu8fbTAYLGts2AGy") + } else { + expect_test::expect!("EVk5UaxBe8LQ8r8iD5EAxVBs6TJcMDKqyH7PBuho6bBJ") + } + .assert_eq(&hash(&serialized_account).to_string()); + let deserialized_account = + ::deserialize(&mut &serialized_account[..]).unwrap(); + assert_eq!(deserialized_account, account); + } } diff --git a/core/primitives-core/src/version.rs b/core/primitives-core/src/version.rs index 4ab612ea702..39bb0140d40 100644 --- a/core/primitives-core/src/version.rs +++ b/core/primitives-core/src/version.rs @@ -123,6 +123,10 @@ pub enum ProtocolFeature { FixContractLoadingCost, #[cfg(feature = "protocol_feature_reject_blocks_with_outdated_protocol_version")] RejectBlocksWithOutdatedProtocolVersions, + /// Allows creating an account with a non refundable balance to cover storage costs. + /// NEP: https://github.com/near/NEPs/pull/491 + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + NonRefundableBalance, RestrictTla, /// Increases the number of chunk producers. TestnetFewerBlockProducers, @@ -194,6 +198,8 @@ impl ProtocolFeature { #[cfg(feature = "protocol_feature_reject_blocks_with_outdated_protocol_version")] ProtocolFeature::RejectBlocksWithOutdatedProtocolVersions => 132, ProtocolFeature::EthImplicitAccounts => 138, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + ProtocolFeature::NonRefundableBalance => 140, } } } @@ -209,7 +215,7 @@ pub const PROTOCOL_VERSION: ProtocolVersion = if cfg!(feature = "statelessnet_pr 82 } else if cfg!(feature = "nightly_protocol") { // On nightly, pick big enough version to support all features. - 139 + 140 } else { // Enable all stable features. STABLE_PROTOCOL_VERSION diff --git a/core/primitives/Cargo.toml b/core/primitives/Cargo.toml index 953f1695798..89005b609b8 100644 --- a/core/primitives/Cargo.toml +++ b/core/primitives/Cargo.toml @@ -55,6 +55,7 @@ dump_errors_schema = ["near-rpc-error-macro/dump_errors_schema"] protocol_feature_fix_staking_threshold = ["near-primitives-core/protocol_feature_fix_staking_threshold"] protocol_feature_fix_contract_loading_cost = ["near-primitives-core/protocol_feature_fix_contract_loading_cost"] protocol_feature_reject_blocks_with_outdated_protocol_version = ["near-primitives-core/protocol_feature_reject_blocks_with_outdated_protocol_version"] +protocol_feature_nonrefundable_transfer_nep491 = ["near-primitives-core/protocol_feature_nonrefundable_transfer_nep491"] nightly = [ "near-fmt/nightly", "near-o11y/nightly", @@ -64,6 +65,7 @@ nightly = [ "nightly_protocol", "protocol_feature_fix_contract_loading_cost", "protocol_feature_fix_staking_threshold", + "protocol_feature_nonrefundable_transfer_nep491", "protocol_feature_reject_blocks_with_outdated_protocol_version", ] diff --git a/core/primitives/src/action/mod.rs b/core/primitives/src/action/mod.rs index e57a7ec220a..aa266a0f321 100644 --- a/core/primitives/src/action/mod.rs +++ b/core/primitives/src/action/mod.rs @@ -152,6 +152,22 @@ pub struct TransferAction { pub deposit: Balance, } +#[derive( + BorshSerialize, + BorshDeserialize, + PartialEq, + Eq, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +pub struct NonrefundableStorageTransferAction { + #[serde(with = "dec_format")] + pub deposit: Balance, +} + #[derive( BorshSerialize, BorshDeserialize, @@ -177,6 +193,11 @@ pub enum Action { DeleteKey(Box), DeleteAccount(DeleteAccountAction), Delegate(Box), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + /// Makes a non-refundable transfer for storage allowance. + /// Only possible during new account creation. + /// For implicit account creation, it has to be the only action in the receipt. + NonrefundableStorageTransfer(NonrefundableStorageTransferAction), } const _: () = assert!( @@ -198,6 +219,8 @@ impl Action { match self { Action::FunctionCall(a) => a.deposit, Action::Transfer(a) => a.deposit, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(a) => a.deposit, _ => 0, } } @@ -227,6 +250,13 @@ impl From for Action { } } +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +impl From for Action { + fn from(nonrefundable_transfer_action: NonrefundableStorageTransferAction) -> Self { + Self::NonrefundableStorageTransfer(nonrefundable_transfer_action) + } +} + impl From for Action { fn from(stake_action: StakeAction) -> Self { Self::Stake(Box::new(stake_action)) diff --git a/core/primitives/src/errors.rs b/core/primitives/src/errors.rs index 4569f3d98c4..36c9d82e02a 100644 --- a/core/primitives/src/errors.rs +++ b/core/primitives/src/errors.rs @@ -508,6 +508,8 @@ pub enum ActionErrorKind { DelegateActionInvalidNonce { delegate_nonce: Nonce, ak_nonce: Nonce }, /// DelegateAction nonce is larger than the upper bound given by the block height DelegateActionNonceTooLarge { delegate_nonce: Nonce, upper_bound: Nonce }, + /// Sending non-refundable balance to an existing account is not allowed according to NEP-491. + NonRefundableBalanceToExistingAccount { account_id: AccountId }, } impl From for ActionError { @@ -832,6 +834,9 @@ impl Display for ActionErrorKind { ActionErrorKind::DelegateActionAccessKeyError(access_key_error) => Display::fmt(&access_key_error, f), ActionErrorKind::DelegateActionInvalidNonce { delegate_nonce, ak_nonce } => write!(f, "DelegateAction nonce {} must be larger than nonce of the used access key {}", delegate_nonce, ak_nonce), ActionErrorKind::DelegateActionNonceTooLarge { delegate_nonce, upper_bound } => write!(f, "DelegateAction nonce {} must be smaller than the access key nonce upper bound {}", delegate_nonce, upper_bound), + ActionErrorKind::NonRefundableBalanceToExistingAccount { account_id} => { + write!(f, "Can't send non-refundable balance to {} because it already exists", account_id) + } } } } diff --git a/core/primitives/src/test_utils.rs b/core/primitives/src/test_utils.rs index ee378fb9218..68e69ae4934 100644 --- a/core/primitives/src/test_utils.rs +++ b/core/primitives/src/test_utils.rs @@ -27,7 +27,7 @@ use crate::version::PROTOCOL_VERSION; use crate::views::{ExecutionStatusView, FinalExecutionOutcomeView, FinalExecutionStatus}; pub fn account_new(amount: Balance, code_hash: CryptoHash) -> Account { - Account::new(amount, 0, code_hash, std::mem::size_of::() as u64) + Account::new(amount, 0, 0, code_hash, std::mem::size_of::() as u64, PROTOCOL_VERSION) } impl Transaction { @@ -681,4 +681,10 @@ impl FinalExecutionOutcomeView { ); } } + + /// Calculates how much NEAR was burnt for gas, after refunds. + pub fn tokens_burnt(&self) -> Balance { + self.transaction_outcome.outcome.tokens_burnt + + self.receipts_outcome.iter().map(|r| r.outcome.tokens_burnt).sum::() + } } diff --git a/core/primitives/src/transaction.rs b/core/primitives/src/transaction.rs index 000a23568ad..1a7759c95f9 100644 --- a/core/primitives/src/transaction.rs +++ b/core/primitives/src/transaction.rs @@ -14,6 +14,8 @@ use std::borrow::Borrow; use std::fmt; use std::hash::{Hash, Hasher}; +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +pub use crate::action::NonrefundableStorageTransferAction; pub use crate::action::{ Action, AddKeyAction, CreateAccountAction, DeleteAccountAction, DeleteKeyAction, DeployContractAction, FunctionCallAction, StakeAction, TransferAction, diff --git a/core/primitives/src/views.rs b/core/primitives/src/views.rs index e1b470a3dd6..bbad977da90 100644 --- a/core/primitives/src/views.rs +++ b/core/primitives/src/views.rs @@ -23,6 +23,8 @@ use crate::sharding::{ ChunkHash, ShardChunk, ShardChunkHeader, ShardChunkHeaderInner, ShardChunkHeaderInnerV2, ShardChunkHeaderV3, }; +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +use crate::transaction::NonrefundableStorageTransferAction; use crate::transaction::{ Action, AddKeyAction, CreateAccountAction, DeleteAccountAction, DeleteKeyAction, DeployContractAction, ExecutionMetadata, ExecutionOutcome, ExecutionOutcomeWithIdAndProof, @@ -41,6 +43,7 @@ use chrono::DateTime; use near_crypto::{PublicKey, Signature}; use near_fmt::{AbbrBytes, Slice}; use near_parameters::{ActionCosts, ExtCosts}; +use near_primitives_core::version::PROTOCOL_VERSION; use serde_with::base64::Base64; use serde_with::serde_as; use std::collections::HashMap; @@ -57,6 +60,9 @@ pub struct AccountView { pub amount: Balance, #[serde(with = "dec_format")] pub locked: Balance, + #[serde(with = "dec_format")] + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + pub nonrefundable: Balance, pub code_hash: CryptoHash, pub storage_usage: StorageUsage, /// TODO(2271): deprecated. @@ -100,6 +106,8 @@ impl From<&Account> for AccountView { AccountView { amount: account.amount(), locked: account.locked(), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + nonrefundable: account.nonrefundable(), code_hash: account.code_hash(), storage_usage: account.storage_usage(), storage_paid_at: 0, @@ -115,7 +123,18 @@ impl From for AccountView { impl From<&AccountView> for Account { fn from(view: &AccountView) -> Self { - Account::new(view.amount, view.locked, view.code_hash, view.storage_usage) + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + let nonrefundable = view.nonrefundable; + #[cfg(not(feature = "protocol_feature_nonrefundable_transfer_nep491"))] + let nonrefundable = 0; + Account::new( + view.amount, + view.locked, + nonrefundable, + view.code_hash, + view.storage_usage, + PROTOCOL_VERSION, + ) } } @@ -1169,6 +1188,11 @@ pub enum ActionView { #[serde(with = "dec_format")] deposit: Balance, }, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + NonrefundableStorageTransfer { + #[serde(with = "dec_format")] + deposit: Balance, + }, Stake { #[serde(with = "dec_format")] stake: Balance, @@ -1205,6 +1229,10 @@ impl From for ActionView { deposit: action.deposit, }, Action::Transfer(action) => ActionView::Transfer { deposit: action.deposit }, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(action) => { + ActionView::NonrefundableStorageTransfer { deposit: action.deposit } + } Action::Stake(action) => { ActionView::Stake { stake: action.stake, public_key: action.public_key } } @@ -1242,6 +1270,10 @@ impl TryFrom for Action { })) } ActionView::Transfer { deposit } => Action::Transfer(TransferAction { deposit }), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + ActionView::NonrefundableStorageTransfer { deposit } => { + Action::NonrefundableStorageTransfer(NonrefundableStorageTransferAction { deposit }) + } ActionView::Stake { stake, public_key } => { Action::Stake(Box::new(StakeAction { stake, public_key })) } diff --git a/genesis-tools/genesis-csv-to-json/src/csv_parser.rs b/genesis-tools/genesis-csv-to-json/src/csv_parser.rs index f79c23c605e..4a16c517e59 100644 --- a/genesis-tools/genesis-csv-to-json/src/csv_parser.rs +++ b/genesis-tools/genesis-csv-to-json/src/csv_parser.rs @@ -10,6 +10,7 @@ use near_primitives::receipt::{ActionReceipt, Receipt, ReceiptEnum}; use near_primitives::state_record::StateRecord; use near_primitives::transaction::{Action, FunctionCallAction}; use near_primitives::types::{AccountId, AccountInfo, Balance, Gas}; +use near_primitives::version::PROTOCOL_VERSION; use std::fs::File; use std::io::Read; use std::path::PathBuf; @@ -187,7 +188,14 @@ fn account_records(row: &Row, gas_price: Balance) -> Vec { let mut res = vec![StateRecord::Account { account_id: row.account_id.clone(), - account: Account::new(row.amount, row.validator_stake, smart_contract_hash, 0), + account: Account::new( + row.amount, + row.validator_stake, + 0, + smart_contract_hash, + 0, + PROTOCOL_VERSION, + ), }]; // Add restricted access keys. diff --git a/genesis-tools/genesis-populate/src/lib.rs b/genesis-tools/genesis-populate/src/lib.rs index 64e982eeac7..3c2d7651152 100644 --- a/genesis-tools/genesis-populate/src/lib.rs +++ b/genesis-tools/genesis-populate/src/lib.rs @@ -280,8 +280,10 @@ impl GenesisBuilder { let account = Account::new( testing_init_balance, testing_init_stake, + 0, self.additional_accounts_code_hash, 0, + self.genesis.config.protocol_version, ); set_account(&mut state_update, account_id.clone(), &account); let account_record = StateRecord::Account { account_id: account_id.clone(), account }; diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index e5544e9d45e..7302772140f 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -84,6 +84,9 @@ protocol_feature_reject_blocks_with_outdated_protocol_version = [ "near-primitives/protocol_feature_reject_blocks_with_outdated_protocol_version", "near-chain/protocol_feature_reject_blocks_with_outdated_protocol_version", ] +protocol_feature_nonrefundable_transfer_nep491 = [ + "near-primitives/protocol_feature_nonrefundable_transfer_nep491", +] nightly = [ "near-actix-test-utils/nightly", @@ -112,6 +115,7 @@ nightly = [ "nightly_protocol", "node-runtime/nightly", "protocol_feature_fix_contract_loading_cost", + "protocol_feature_nonrefundable_transfer_nep491", "protocol_feature_reject_blocks_with_outdated_protocol_version", "testlib/nightly", ] diff --git a/integration-tests/src/tests/client/features.rs b/integration-tests/src/tests/client/features.rs index 3e1ff6ee9bd..e4c069e63fd 100644 --- a/integration-tests/src/tests/client/features.rs +++ b/integration-tests/src/tests/client/features.rs @@ -16,6 +16,8 @@ mod increase_storage_compute_cost; mod limit_contract_functions_number; mod lower_storage_key_limit; mod nearvm; +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +mod nonrefundable_transfer; mod restore_receipts_after_fix_apply_chunks; mod restrict_tla; mod stateless_validation; diff --git a/integration-tests/src/tests/client/features/in_memory_tries.rs b/integration-tests/src/tests/client/features/in_memory_tries.rs index 4b5231f624b..0ede291801c 100644 --- a/integration-tests/src/tests/client/features/in_memory_tries.rs +++ b/integration-tests/src/tests/client/features/in_memory_tries.rs @@ -105,7 +105,14 @@ fn test_in_memory_trie_node_consistency() { let staked = if i < 2 { validator_stake } else { 0 }; records.push(StateRecord::Account { account_id: account.clone(), - account: Account::new(initial_balance, staked, CryptoHash::default(), 0), + account: Account::new( + initial_balance, + staked, + 0, + CryptoHash::default(), + 0, + PROTOCOL_VERSION, + ), }); records.push(StateRecord::AccessKey { account_id: account.clone(), @@ -540,7 +547,14 @@ fn test_in_memory_trie_consistency_with_state_sync_base_case(track_all_shards: b let staked = if i < NUM_VALIDATORS { validator_stake } else { 0 }; records.push(StateRecord::Account { account_id: account.clone(), - account: Account::new(initial_balance, staked, CryptoHash::default(), 0), + account: Account::new( + initial_balance, + staked, + 0, + CryptoHash::default(), + 0, + genesis_config.protocol_version, + ), }); records.push(StateRecord::AccessKey { account_id: account.clone(), diff --git a/integration-tests/src/tests/client/features/nonrefundable_transfer.rs b/integration-tests/src/tests/client/features/nonrefundable_transfer.rs new file mode 100644 index 00000000000..66ceb6c5469 --- /dev/null +++ b/integration-tests/src/tests/client/features/nonrefundable_transfer.rs @@ -0,0 +1,496 @@ +//! Non-refundable transfers during account creation allow to sponsor an +//! accounts storage staking balance without that someone being able to run off +//! with the money. +//! +//! This feature introduces the NonrefundableStorageTransfer action. +//! +//! NEP: https://github.com/near/NEPs/pull/491 + +use near_chain_configs::Genesis; +use near_client::test_utils::TestEnv; +use near_crypto::{InMemorySigner, KeyType, PublicKey}; +use near_primitives::errors::{ + ActionError, ActionErrorKind, ActionsValidationError, InvalidTxError, TxExecutionError, +}; +use near_primitives::transaction::{ + Action, AddKeyAction, CreateAccountAction, DeleteAccountAction, DeployContractAction, + NonrefundableStorageTransferAction, SignedTransaction, TransferAction, +}; +use near_primitives::types::{AccountId, Balance}; +use near_primitives::utils::{derive_eth_implicit_account_id, derive_near_implicit_account_id}; +use near_primitives::version::{ProtocolFeature, ProtocolVersion}; +use near_primitives::views::{ + ExecutionStatusView, FinalExecutionOutcomeView, QueryRequest, QueryResponseKind, +}; +use near_primitives_core::account::{AccessKey, AccessKeyPermission}; +use nearcore::config::GenesisExt; +use nearcore::test_utils::TestEnvNightshadeSetupExt; +use nearcore::NEAR_BASE; +use testlib::fees_utils::FeeHelper; + +use crate::node::RuntimeNode; + +#[derive(Clone, Debug)] +struct Transfers { + /// Regular transfer amount (if any). + regular_amount: Balance, + /// Non-refundable transfer amount (if any). + nonrefundable_amount: Balance, + /// Whether non-refundable transfer action should be first in the receipt. + nonrefundable_transfer_first: bool, +} + +/// Different `Transfers` configurations, where we only test cases where non-refundable transfer happens. +const TEST_CASES: [Transfers; 3] = [ + Transfers { regular_amount: 0, nonrefundable_amount: 1, nonrefundable_transfer_first: true }, + Transfers { regular_amount: 1, nonrefundable_amount: 1, nonrefundable_transfer_first: true }, + Transfers { regular_amount: 1, nonrefundable_amount: 1, nonrefundable_transfer_first: false }, +]; + +struct TransferConfig { + /// Describes transfers configuration we are interested in. + transfers: Transfers, + /// True if the receipt should create account. + account_creation: bool, + /// Differentaties between named and implicit account creation, if `account_creation` is true. + implicit_account_creation: bool, + /// Whether the last action in the receipt should deploy a contract. + deploy_contract: bool, +} + +/// Default sender to use in tests of this module. +fn sender() -> AccountId { + "test0".parse().unwrap() +} + +/// Default receiver to use in tests of this module. +fn receiver() -> AccountId { + "test1".parse().unwrap() +} + +/// Default signer (corresponding to the default sender) to use in tests of this module. +fn signer() -> InMemorySigner { + InMemorySigner::from_seed(sender(), KeyType::ED25519, "test0") +} + +/// Creates a test environment using given protocol version (if some). +fn setup_env_with_protocol_version(protocol_version: Option) -> TestEnv { + let mut genesis = Genesis::test(vec![sender(), receiver()], 1); + if let Some(protocol_version) = protocol_version { + genesis.config.protocol_version = protocol_version; + } + TestEnv::builder(&genesis.config).nightshade_runtimes(&genesis).build() +} + +/// Creates a test environment using default protocol version. +fn setup_env() -> TestEnv { + setup_env_with_protocol_version(None) +} + +fn fee_helper() -> FeeHelper { + let node = RuntimeNode::new(&sender()); + crate::tests::standard_cases::fee_helper(&node) +} + +fn get_nonce(env: &mut TestEnv, signer: &InMemorySigner) -> u64 { + let request = QueryRequest::ViewAccessKey { + account_id: signer.account_id.clone(), + public_key: signer.public_key.clone(), + }; + match env.query_view(request).unwrap().kind { + QueryResponseKind::AccessKey(view) => view.nonce, + _ => panic!("wrong query response"), + } +} + +fn account_exists(env: &mut TestEnv, account_id: AccountId) -> bool { + let request = QueryRequest::ViewAccount { account_id }; + env.query_view(request).is_ok() +} + +fn execute_transaction_from_actions( + env: &mut TestEnv, + actions: Vec, + signer: &InMemorySigner, + receiver: AccountId, +) -> Result { + let tip = env.clients[0].chain.head().unwrap(); + let nonce = get_nonce(env, signer); + let tx = SignedTransaction::from_actions( + nonce + 1, + signer.account_id.clone(), + receiver, + signer, + actions, + tip.last_block_hash, + ); + let tx_result = env.execute_tx(tx); + let height = env.clients[0].chain.head().unwrap().height; + for i in 0..2 { + env.produce_block(0, height + 1 + i); + } + tx_result +} + +/// Submits a transfer (regular, non-refundable, or both). +/// Can possibly create an account or deploy a contract, depending on the `config`. +/// +/// This methods checks that the balance is subtracted from the sender and added +/// to the receiver, if the status was ok. No checks are done on an error. +fn exec_transfers( + env: &mut TestEnv, + signer: InMemorySigner, + receiver: AccountId, + config: TransferConfig, +) -> Result { + let sender_pre_balance = env.query_balance(sender()); + let (receiver_before_amount, receiver_before_nonrefundable) = if config.account_creation { + (0, 0) + } else { + let receiver_before = env.query_account(receiver.clone()); + (receiver_before.amount, receiver_before.nonrefundable) + }; + + let mut actions = vec![]; + + if config.account_creation && !config.implicit_account_creation { + actions.push(Action::CreateAccount(CreateAccountAction {})); + actions.push(Action::AddKey(Box::new(AddKeyAction { + public_key: PublicKey::from_seed(KeyType::ED25519, receiver.as_str()), + access_key: AccessKey { nonce: 0, permission: AccessKeyPermission::FullAccess }, + }))); + } + + if config.transfers.nonrefundable_transfer_first && config.transfers.nonrefundable_amount > 0 { + actions.push(Action::NonrefundableStorageTransfer(NonrefundableStorageTransferAction { + deposit: config.transfers.nonrefundable_amount, + })); + } + if config.transfers.regular_amount > 0 { + actions.push(Action::Transfer(TransferAction { deposit: config.transfers.regular_amount })); + } + if !config.transfers.nonrefundable_transfer_first && config.transfers.nonrefundable_amount > 0 { + actions.push(Action::NonrefundableStorageTransfer(NonrefundableStorageTransferAction { + deposit: config.transfers.nonrefundable_amount, + })); + } + + if config.deploy_contract { + let contract = near_test_contracts::sized_contract(1500 as usize); + actions.push(Action::DeployContract(DeployContractAction { code: contract.to_vec() })) + } + + let tx_result = execute_transaction_from_actions(env, actions, &signer, receiver.clone()); + + let outcome = match &tx_result { + Ok(outcome) => outcome, + _ => { + return tx_result; + } + }; + + if !matches!(outcome.status, near_primitives::views::FinalExecutionStatus::SuccessValue(_)) { + return tx_result; + } + + let gas_cost = outcome.tokens_burnt(); + assert_eq!( + sender_pre_balance + - config.transfers.regular_amount + - config.transfers.nonrefundable_amount + - gas_cost, + env.query_balance(sender()) + ); + + let receiver_expected_amount_after = receiver_before_amount + config.transfers.regular_amount; + let receiver_expected_non_refundable_after = + receiver_before_nonrefundable + config.transfers.nonrefundable_amount; + let receiver_after = env.query_account(receiver); + assert_eq!(receiver_after.amount, receiver_expected_amount_after); + assert_eq!(receiver_after.nonrefundable, receiver_expected_non_refundable_after); + + tx_result +} + +fn delete_account( + env: &mut TestEnv, + signer: &InMemorySigner, + beneficiary_id: AccountId, +) -> Result { + let actions = vec![Action::DeleteAccount(DeleteAccountAction { beneficiary_id })]; + execute_transaction_from_actions(env, actions, &signer, signer.account_id.clone()) +} + +/// Can delete account with non-refundable storage. +#[test] +fn deleting_account_with_non_refundable_storage() { + let mut env = setup_env(); + let new_account_id: AccountId = "subaccount.test0".parse().unwrap(); + let new_account = InMemorySigner::from_seed( + new_account_id.clone(), + KeyType::ED25519, + new_account_id.as_str(), + ); + let regular_amount = 10u128.pow(20); + let nonrefundable_amount = NEAR_BASE; + // Create account with non-refundable storage. + // Send some NEAR (refundable) so that the new account is able to pay the gas for its deletion in the next transaction. + // Deploy a contract that does not fit within Zero-balance account limit. + let create_account_tx_result = exec_transfers( + &mut env, + signer(), + new_account_id.clone(), + TransferConfig { + transfers: Transfers { + regular_amount, + nonrefundable_amount, + nonrefundable_transfer_first: true, + }, + account_creation: true, + implicit_account_creation: false, + deploy_contract: true, + }, + ); + create_account_tx_result.unwrap().assert_success(); + + // Delete the new account (that has 1 NEAR of non-refundable balance). + let beneficiary_id = receiver(); + let beneficiary_before = env.query_account(beneficiary_id.clone()); + let delete_account_tx_result = delete_account(&mut env, &new_account, beneficiary_id.clone()); + delete_account_tx_result.unwrap().assert_success(); + assert!(!account_exists(&mut env, new_account_id)); + + // Check that the beneficiary account received the remaining balance from the deleted account, + // but none of the non-refundable balance. + let beneficiary_after = env.query_account(beneficiary_id); + assert_eq!( + beneficiary_after.amount, + beneficiary_before.amount + regular_amount - fee_helper().prepaid_delete_account_cost() + ); + assert_eq!(beneficiary_after.nonrefundable, beneficiary_before.nonrefundable); +} + +/// Non-refundable balance cannot be transferred. +#[test] +fn non_refundable_balance_cannot_be_transferred() { + let mut env = setup_env(); + let new_account_id: AccountId = "subaccount.test0".parse().unwrap(); + let new_account = InMemorySigner::from_seed( + new_account_id.clone(), + KeyType::ED25519, + new_account_id.as_str(), + ); + // The `new_account` is created with 1 NEAR non-refundable balance. + let create_account_tx_result = exec_transfers( + &mut env, + signer(), + new_account_id.clone(), + TransferConfig { + transfers: Transfers { + regular_amount: 0, + nonrefundable_amount: NEAR_BASE, + nonrefundable_transfer_first: true, + }, + account_creation: true, + implicit_account_creation: false, + deploy_contract: false, + }, + ); + create_account_tx_result.unwrap().assert_success(); + + // Although `new_account` has 1 NEAR non-refundable balance, it cannot make neither refundable nor non-refundable transfer of 1 yoctoNEAR. + for nonrefundable in [false, true] { + let transfer_tx_result = exec_transfers( + &mut env, + new_account.clone(), + receiver(), + TransferConfig { + transfers: Transfers { + regular_amount: if nonrefundable { 0 } else { 1 }, + nonrefundable_amount: if nonrefundable { 1 } else { 0 }, + nonrefundable_transfer_first: true, + }, + account_creation: false, + implicit_account_creation: false, + deploy_contract: false, + }, + ); + match transfer_tx_result { + Err(InvalidTxError::NotEnoughBalance { signer_id, balance, .. }) => { + assert_eq!(signer_id, new_account_id); + assert_eq!(balance, 0); + } + _ => panic!("Expected NotEnoughBalance error"), + } + } +} + +/// Non-refundable balance allows to have account with zero balance and more than 1kB of state. +#[test] +fn non_refundable_balance_allows_1kb_state_with_zero_balance() { + let mut env = setup_env(); + let new_account_id: AccountId = "subaccount.test0".parse().unwrap(); + let tx_result = exec_transfers( + &mut env, + signer(), + new_account_id, + TransferConfig { + transfers: Transfers { + regular_amount: 0, + nonrefundable_amount: NEAR_BASE / 5, + nonrefundable_transfer_first: true, + }, + account_creation: true, + implicit_account_creation: false, + deploy_contract: true, + }, + ); + tx_result.unwrap().assert_success(); +} + +/// Non-refundable transfer successfully adds non-refundable balance when creating named account. +#[test] +fn non_refundable_transfer_create_named_account() { + for (index, transfers) in TEST_CASES.iter().enumerate() { + let account_name = format!("subaccount{}.test0", index).to_string(); + let new_account_id: AccountId = account_name.parse().unwrap(); + let tx_result = exec_transfers( + &mut setup_env(), + signer(), + new_account_id, + TransferConfig { + transfers: transfers.clone(), + account_creation: true, + implicit_account_creation: false, + deploy_contract: false, + }, + ); + tx_result.unwrap().assert_success(); + } +} + +/// Non-refundable transfer successfully adds non-refundable balance when creating NEAR-implicit account. +#[test] +fn non_refundable_transfer_create_near_implicit_account() { + for (index, transfers) in TEST_CASES.iter().enumerate() { + let public_key = + PublicKey::from_seed(KeyType::ED25519, &format!("near{}", index).to_string()); + let new_account_id = derive_near_implicit_account_id(public_key.unwrap_as_ed25519()); + let tx_result = exec_transfers( + &mut setup_env(), + signer(), + new_account_id.clone(), + TransferConfig { + transfers: transfers.clone(), + account_creation: true, + implicit_account_creation: true, + deploy_contract: false, + }, + ); + if transfers.regular_amount == 0 { + tx_result.unwrap().assert_success(); + } else { + // Non-refundable transfer must be the only action in an implicit account creation transaction. + let status = &tx_result.unwrap().receipts_outcome[0].outcome.status; + assert!(matches!( + status, + ExecutionStatusView::Failure(TxExecutionError::ActionError( + ActionError { kind: ActionErrorKind::AccountDoesNotExist { account_id }, .. } + )) if *account_id == new_account_id, + )); + } + } +} + +/// Non-refundable transfer successfully adds non-refundable balance when creating ETH-implicit account. +#[test] +fn non_refundable_transfer_create_eth_implicit_account() { + for (index, transfers) in TEST_CASES.iter().enumerate() { + let public_key = + PublicKey::from_seed(KeyType::SECP256K1, &format!("eth{}", index).to_string()); + let new_account_id = derive_eth_implicit_account_id(public_key.unwrap_as_secp256k1()); + let tx_result = exec_transfers( + &mut setup_env(), + signer(), + new_account_id.clone(), + TransferConfig { + transfers: transfers.clone(), + account_creation: true, + implicit_account_creation: true, + deploy_contract: false, + }, + ); + if transfers.regular_amount == 0 { + tx_result.unwrap().assert_success(); + } else { + // Non-refundable transfer must be the only action in an implicit account creation transaction. + let status = &tx_result.unwrap().receipts_outcome[0].outcome.status; + assert!(matches!( + status, + ExecutionStatusView::Failure(TxExecutionError::ActionError( + ActionError { kind: ActionErrorKind::AccountDoesNotExist { account_id }, .. } + )) if *account_id == new_account_id, + )); + } + } +} + +/// Non-refundable transfer is rejected on existing account. +#[test] +fn reject_non_refundable_transfer_existing_account() { + for transfers in TEST_CASES { + let tx_result = exec_transfers( + &mut setup_env(), + signer(), + receiver(), + TransferConfig { + transfers, + account_creation: false, + implicit_account_creation: false, + deploy_contract: false, + }, + ); + let status = &tx_result.unwrap().receipts_outcome[0].outcome.status; + assert!(matches!( + status, + ExecutionStatusView::Failure(TxExecutionError::ActionError( + ActionError { kind: ActionErrorKind::NonRefundableBalanceToExistingAccount { account_id }, .. } + )) if *account_id == receiver(), + )); + } +} + +/// During the protocol upgrade phase, before the voting completes, we must not +/// include non-refundable transfer actions on the chain. +/// +/// The correct way to handle it is to reject transaction before they even get +/// into the transaction pool. Hence, we check that an `InvalidTxError` error is +/// returned for older protocol versions. +#[test] +fn reject_non_refundable_transfer_in_older_versions() { + let mut env = setup_env_with_protocol_version(Some( + ProtocolFeature::NonRefundableBalance.protocol_version() - 1, + )); + for transfers in TEST_CASES { + let tx_result = exec_transfers( + &mut env, + signer(), + receiver(), + TransferConfig { + transfers, + account_creation: false, + implicit_account_creation: false, + deploy_contract: false, + }, + ); + assert_eq!( + tx_result, + Err(InvalidTxError::ActionsValidation( + ActionsValidationError::UnsupportedProtocolFeature { + protocol_feature: "NonRefundableBalance".to_string(), + version: ProtocolFeature::NonRefundableBalance.protocol_version() + } + )) + ); + } +} diff --git a/integration-tests/src/tests/client/features/stateless_validation.rs b/integration-tests/src/tests/client/features/stateless_validation.rs index 589d2f0d72a..0f378a4cace 100644 --- a/integration-tests/src/tests/client/features/stateless_validation.rs +++ b/integration-tests/src/tests/client/features/stateless_validation.rs @@ -108,7 +108,14 @@ fn run_chunk_validation_test(seed: u64, prob_missing_chunk: f64) { let staked = if i < num_validators { validator_stake } else { 0 }; records.push(StateRecord::Account { account_id: account.clone(), - account: Account::new(initial_balance, staked, CryptoHash::default(), 0), + account: Account::new( + initial_balance, + staked, + 0, + CryptoHash::default(), + 0, + PROTOCOL_VERSION, + ), }); records.push(StateRecord::AccessKey { account_id: account.clone(), diff --git a/integration-tests/src/tests/runtime/state_viewer.rs b/integration-tests/src/tests/runtime/state_viewer.rs index b7bfb646067..3179d1af3a6 100644 --- a/integration-tests/src/tests/runtime/state_viewer.rs +++ b/integration-tests/src/tests/runtime/state_viewer.rs @@ -360,7 +360,7 @@ fn test_view_state_too_large() { set_account( &mut state_update, alice_account(), - &Account::new(0, 0, CryptoHash::default(), 50_001), + &Account::new(0, 0, 0, CryptoHash::default(), 50_001, PROTOCOL_VERSION), ); let trie_viewer = TrieViewer::new(Some(50_000), None); let result = trie_viewer.view_state(&state_update, &alice_account(), b"", false); @@ -375,7 +375,7 @@ fn test_view_state_with_large_contract() { set_account( &mut state_update, alice_account(), - &Account::new(0, 0, sha256(&contract_code), 50_001), + &Account::new(0, 0, 0, sha256(&contract_code), 50_001, PROTOCOL_VERSION), ); state_update.set(TrieKey::ContractCode { account_id: alice_account() }, contract_code); let trie_viewer = TrieViewer::new(Some(50_000), None); diff --git a/nearcore/Cargo.toml b/nearcore/Cargo.toml index 4a1fee36f17..0361e695a7f 100644 --- a/nearcore/Cargo.toml +++ b/nearcore/Cargo.toml @@ -116,6 +116,9 @@ protocol_feature_fix_staking_threshold = [ protocol_feature_fix_contract_loading_cost = [ "near-vm-runner/protocol_feature_fix_contract_loading_cost", ] +protocol_feature_nonrefundable_transfer_nep491 = [ + "near-primitives/protocol_feature_nonrefundable_transfer_nep491", +] new_epoch_sync = [ "near-client/new_epoch_sync" ] @@ -147,6 +150,7 @@ nightly = [ "node-runtime/nightly", "protocol_feature_fix_contract_loading_cost", "protocol_feature_fix_staking_threshold", + "protocol_feature_nonrefundable_transfer_nep491", "serialize_all_state_changes", "testlib/nightly", ] diff --git a/nearcore/src/config.rs b/nearcore/src/config.rs index d903edbaede..c42550810ed 100644 --- a/nearcore/src/config.rs +++ b/nearcore/src/config.rs @@ -755,7 +755,7 @@ fn add_account_with_key( ) { records.push(StateRecord::Account { account_id: account_id.clone(), - account: Account::new(amount, staked, code_hash, 0), + account: Account::new(amount, staked, 0, code_hash, 0, PROTOCOL_VERSION), }); records.push(StateRecord::AccessKey { account_id, diff --git a/neard/Cargo.toml b/neard/Cargo.toml index 230892ed2bb..104fc664486 100644 --- a/neard/Cargo.toml +++ b/neard/Cargo.toml @@ -73,6 +73,7 @@ no_cache = ["nearcore/no_cache"] rosetta_rpc = ["nearcore/rosetta_rpc"] json_rpc = ["nearcore/json_rpc"] protocol_feature_fix_staking_threshold = ["nearcore/protocol_feature_fix_staking_threshold"] +protocol_feature_nonrefundable_transfer_nep491 = ["near-state-viewer/protocol_feature_nonrefundable_transfer_nep491"] serialize_all_state_changes = ["nearcore/serialize_all_state_changes"] new_epoch_sync = ["nearcore/new_epoch_sync", "dep:near-epoch-sync-tool"] @@ -95,6 +96,7 @@ nightly = [ "nearcore/nightly", "nightly_protocol", "protocol_feature_fix_staking_threshold", + "protocol_feature_nonrefundable_transfer_nep491", "serialize_all_state_changes", ] nightly_protocol = [ diff --git a/runtime/runtime/Cargo.toml b/runtime/runtime/Cargo.toml index c37b29bde0b..aa976286459 100644 --- a/runtime/runtime/Cargo.toml +++ b/runtime/runtime/Cargo.toml @@ -47,9 +47,11 @@ nightly = [ "near-vm-runner/nightly", "near-wallet-contract/nightly", "nightly_protocol", + "protocol_feature_nonrefundable_transfer_nep491", "testlib/nightly", ] default = [] +protocol_feature_nonrefundable_transfer_nep491 = [] nightly_protocol = [ "near-chain-configs/nightly_protocol", "near-o11y/nightly_protocol", diff --git a/runtime/runtime/src/actions.rs b/runtime/runtime/src/actions.rs index e5a33121daf..046ab923c38 100644 --- a/runtime/runtime/src/actions.rs +++ b/runtime/runtime/src/actions.rs @@ -17,10 +17,12 @@ use near_primitives::hash::CryptoHash; use near_primitives::receipt::{ActionReceipt, Receipt, ReceiptEnum}; use near_primitives::transaction::{ Action, AddKeyAction, DeleteAccountAction, DeleteKeyAction, DeployContractAction, - FunctionCallAction, StakeAction, TransferAction, + FunctionCallAction, StakeAction, }; use near_primitives::types::validator_stake::ValidatorStake; -use near_primitives::types::{AccountId, BlockHeight, EpochInfoProvider, Gas, TrieCacheMode}; +use near_primitives::types::{ + AccountId, Balance, BlockHeight, EpochInfoProvider, Gas, TrieCacheMode, +}; use near_primitives::utils::{account_is_implicit, create_random_seed}; use near_primitives::version::{ ProtocolFeature, ProtocolVersion, DELETE_KEY_STORAGE_USAGE_PROTOCOL_VERSION, @@ -374,7 +376,7 @@ pub(crate) fn try_refund_allowance( state_update: &mut TrieUpdate, account_id: &AccountId, public_key: &PublicKey, - transfer: &TransferAction, + deposit: Balance, ) -> Result<(), StorageError> { if let Some(mut access_key) = get_access_key(state_update, account_id, public_key)? { let mut updated = false; @@ -382,7 +384,7 @@ pub(crate) fn try_refund_allowance( &mut access_key.permission { if let Some(allowance) = function_call_permission.allowance.as_mut() { - let new_allowance = allowance.saturating_add(transfer.deposit); + let new_allowance = allowance.saturating_add(deposit); if new_allowance > *allowance { *allowance = new_allowance; updated = true; @@ -396,12 +398,22 @@ pub(crate) fn try_refund_allowance( Ok(()) } -pub(crate) fn action_transfer( +pub(crate) fn action_transfer(account: &mut Account, deposit: Balance) -> Result<(), StorageError> { + account.set_amount(account.amount().checked_add(deposit).ok_or_else(|| { + StorageError::StorageInconsistentState("Account balance integer overflow".to_string()) + })?); + Ok(()) +} + +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +pub(crate) fn action_nonrefundable_storage_transfer( account: &mut Account, - transfer: &TransferAction, + deposit: Balance, ) -> Result<(), StorageError> { - account.set_amount(account.amount().checked_add(transfer.deposit).ok_or_else(|| { - StorageError::StorageInconsistentState("Account balance integer overflow".to_string()) + account.set_nonrefundable(account.nonrefundable().checked_add(deposit).ok_or_else(|| { + StorageError::StorageInconsistentState( + "non-refundable account balance integer overflow".to_string(), + ) })?); Ok(()) } @@ -414,6 +426,7 @@ pub(crate) fn action_create_account( account_id: &AccountId, predecessor_id: &AccountId, result: &mut ActionResult, + protocol_version: ProtocolVersion, ) { if account_id.is_top_level() { if account_id.len() < account_creation_config.min_allowed_top_level_account_length as usize @@ -444,10 +457,12 @@ pub(crate) fn action_create_account( *actor_id = account_id.clone(); *account = Some(Account::new( + 0, 0, 0, CryptoHash::default(), fee_config.storage_usage_config.num_bytes_account, + protocol_version, )); } @@ -459,12 +474,16 @@ pub(crate) fn action_implicit_account_creation_transfer( account: &mut Option, actor_id: &mut AccountId, account_id: &AccountId, - transfer: &TransferAction, + deposit: Balance, block_height: BlockHeight, current_protocol_version: ProtocolVersion, + nonrefundable: bool, ) { *actor_id = account_id.clone(); + let (refundable_balance, nonrefundable_balance) = + if nonrefundable { (0, deposit) } else { (deposit, 0) }; + match account_id.get_account_type() { AccountType::NearImplicitAccount => { let mut access_key = AccessKey::full_access(); @@ -483,13 +502,15 @@ pub(crate) fn action_implicit_account_creation_transfer( let public_key = PublicKey::from_near_implicit_account(account_id).unwrap(); *account = Some(Account::new( - transfer.deposit, + refundable_balance, 0, + nonrefundable_balance, CryptoHash::default(), fee_config.storage_usage_config.num_bytes_account + public_key.len() as u64 + borsh::object_length(&access_key).unwrap() as u64 + fee_config.storage_usage_config.num_extra_bytes_record, + current_protocol_version, )); set_access_key(state_update, account_id.clone(), public_key, &access_key); @@ -507,8 +528,14 @@ pub(crate) fn action_implicit_account_creation_transfer( + magic_bytes.code().len() as u64 + fee_config.storage_usage_config.num_extra_bytes_record; - *account = - Some(Account::new(transfer.deposit, 0, *magic_bytes.hash(), storage_usage)); + *account = Some(Account::new( + refundable_balance, + 0, + nonrefundable_balance, + *magic_bytes.hash(), + storage_usage, + current_protocol_version, + )); set_code(state_update, account_id.clone(), &magic_bytes); // Precompile Wallet Contract and store result (compiled code or error) in the database. @@ -929,6 +956,8 @@ pub(crate) fn check_actor_permissions( } Action::CreateAccount(_) | Action::FunctionCall(_) | Action::Transfer(_) => (), Action::Delegate(_) => (), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(_) => (), }; Ok(()) } @@ -938,8 +967,12 @@ pub(crate) fn check_account_existence( account: &Option, account_id: &AccountId, config: &RuntimeConfig, - is_the_only_action: bool, - is_refund: bool, + implicit_account_creation_eligible: bool, + #[cfg_attr( + not(feature = "protocol_feature_nonrefundable_transfer_nep491"), + allow(unused_variables) + )] + receipt_starts_with_create_account: bool, ) -> Result<(), ActionError> { match action { Action::CreateAccount(_) => { @@ -971,24 +1004,35 @@ pub(crate) fn check_account_existence( } Action::Transfer(_) => { if account.is_none() { - return if config.wasm_config.implicit_account_creation - && is_the_only_action - && account_is_implicit(account_id, config.wasm_config.eth_implicit_accounts) - && !is_refund - { - // OK. It's implicit account creation. - // Notes: - // - The transfer action has to be the only action in the transaction to avoid - // abuse by hijacking this account with other public keys or contracts. - // - Refunds don't automatically create accounts, because refunds are free and - // we don't want some type of abuse. - // - Account deletion with beneficiary creates a refund, so it'll not create a - // new account. - Ok(()) - } else { - Err(ActionErrorKind::AccountDoesNotExist { account_id: account_id.clone() } - .into()) - }; + return check_transfer_to_nonexisting_account( + config, + account_id, + implicit_account_creation_eligible, + ); + } + } + // TODO(nonrefundable) Merge with arm above on stabilization. + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(_) => { + if account.is_none() { + return check_transfer_to_nonexisting_account( + config, + account_id, + implicit_account_creation_eligible, + ); + } else if !receipt_starts_with_create_account { + // If the account already existed before the current receipt, + // non-refundable transfer is not allowed. But for named + // accounts, it could be that the account was created in this + // receipt which is allowed. Checking for the first action of + // the receipt being a `CreateAccount` action serves this + // purpose. + // For implicit accounts creation with non-refundable storage + // we require that this is the only action in the receipt. + return Err(ActionErrorKind::NonRefundableBalanceToExistingAccount { + account_id: account_id.clone(), + } + .into()); } } Action::DeployContract(_) @@ -1009,6 +1053,29 @@ pub(crate) fn check_account_existence( Ok(()) } +fn check_transfer_to_nonexisting_account( + config: &RuntimeConfig, + account_id: &AccountId, + implicit_account_creation_eligible: bool, +) -> Result<(), ActionError> { + if config.wasm_config.implicit_account_creation + && implicit_account_creation_eligible + && account_is_implicit(account_id, config.wasm_config.eth_implicit_accounts) + { + // OK. It's implicit account creation. + // Notes: + // - Transfer action has to be the only action in the transaction to avoid + // abuse by hijacking this account with other public keys or contracts. + // - Refunds don't automatically create accounts, because refunds are free and + // we don't want some type of abuse. + // - Account deletion with beneficiary creates a refund, so it'll not create a + // new account. + Ok(()) + } else { + Err(ActionErrorKind::AccountDoesNotExist { account_id: account_id.clone() }.into()) + } +} + #[cfg(test)] mod tests { @@ -1022,6 +1089,7 @@ mod tests { use near_primitives::transaction::CreateAccountAction; use near_primitives::trie_key::TrieKey; use near_primitives::types::{EpochId, StateChangeCause}; + use near_primitives_core::version::PROTOCOL_VERSION; use near_store::set_account; use near_store::test_utils::TestTriesBuilder; use std::sync::Arc; @@ -1045,6 +1113,7 @@ mod tests { &account_id, &predecessor_id, &mut action_result, + PROTOCOL_VERSION, ); if action_result.result.is_ok() { assert!(account.is_some()); @@ -1130,7 +1199,8 @@ mod tests { storage_usage: u64, state_update: &mut TrieUpdate, ) -> ActionResult { - let mut account = Some(Account::new(100, 0, *code_hash, storage_usage)); + let mut account = + Some(Account::new(100, 0, 0, *code_hash, storage_usage, PROTOCOL_VERSION)); let mut actor_id = account_id.clone(); let mut action_result = ActionResult::default(); let receipt = Receipt::new_balance_refund(&"alice.near".parse().unwrap(), 0); @@ -1268,7 +1338,7 @@ mod tests { let tries = TestTriesBuilder::new().build(); let mut state_update = tries.new_trie_update(ShardUId::single_shard(), CryptoHash::default()); - let account = Account::new(100, 0, CryptoHash::default(), 100); + let account = Account::new(100, 0, 0, CryptoHash::default(), 100, PROTOCOL_VERSION); set_account(&mut state_update, account_id.clone(), &account); set_access_key(&mut state_update, account_id.clone(), public_key.clone(), access_key); diff --git a/runtime/runtime/src/balance_checker.rs b/runtime/runtime/src/balance_checker.rs index de91b165ab1..714067c8512 100644 --- a/runtime/runtime/src/balance_checker.rs +++ b/runtime/runtime/src/balance_checker.rs @@ -76,11 +76,11 @@ fn total_accounts_balance( accounts_ids: &HashSet, ) -> Result { accounts_ids.iter().try_fold(0u128, |accumulator, account_id| { - let (amount, locked) = match get_account(state, account_id)? { + let (amount, locked, nonrefundable) = match get_account(state, account_id)? { None => return Ok(accumulator), - Some(account) => (account.amount(), account.locked()), + Some(account) => (account.amount(), account.locked(), account.nonrefundable()), }; - Ok(safe_add_balance_apply!(accumulator, amount, locked)) + Ok(safe_add_balance_apply!(accumulator, amount, locked, nonrefundable)) }) } @@ -426,6 +426,7 @@ mod tests { .unwrap(); } + /// This tests shows how overflow (which we do not expect) would be handled on a transfer. #[test] fn test_total_balance_overflow_returns_unexpected_overflow() { let tries = TestTriesBuilder::new().build(); @@ -436,8 +437,10 @@ mod tests { let deposit = 1000; let mut initial_state = tries.new_trie_update(ShardUId::single_shard(), root); - let alice = account_new(u128::MAX, hash(&[])); - let bob = account_new(1u128, hash(&[])); + // We use `u128::MAX - 1`, because `u128::MAX` is used as a sentinel value for accounts version 2 or higher. + // See NEP-491 for more details: https://github.com/near/NEPs/pull/491. + let alice = account_new(u128::MAX - 1, hash(&[])); + let bob = account_new(2u128, hash(&[])); set_account(&mut initial_state, alice_id.clone(), &alice); set_account(&mut initial_state, bob_id.clone(), &bob); @@ -446,8 +449,9 @@ mod tests { let signer = InMemorySigner::from_seed(alice_id.clone(), KeyType::ED25519, alice_id.as_ref()); + // Sending 2 yoctoNEAR, so that we have an overflow when adding to alice's balance. let tx = - SignedTransaction::send_money(0, alice_id, bob_id, &signer, 1, CryptoHash::default()); + SignedTransaction::send_money(0, alice_id, bob_id, &signer, 2, CryptoHash::default()); let receipt = Receipt { predecessor_id: tx.transaction.signer_id.clone(), @@ -476,4 +480,62 @@ mod tests { Err(RuntimeError::UnexpectedIntegerOverflow) ); } + + /// This tests shows what would happen if the total balance becomes u128::MAX + /// which is also the sentinel value use to distinguish between accounts version 1 and 2 or higher + /// See NEP-491 for more details: https://github.com/near/NEPs/pull/491. + #[test] + fn test_total_balance_u128_max() { + let tries = TestTriesBuilder::new().build(); + let root = MerkleHash::default(); + let alice_id = alice_account(); + let bob_id = bob_account(); + let gas_price = 100; + let deposit = 1000; + + let mut initial_state = tries.new_trie_update(ShardUId::single_shard(), root); + let alice = account_new(u128::MAX - 1, hash(&[])); + let bob = account_new(1u128, hash(&[])); + + set_account(&mut initial_state, alice_id.clone(), &alice); + set_account(&mut initial_state, bob_id.clone(), &bob); + initial_state.commit(StateChangeCause::NotWritableToDisk); + + let signer = + InMemorySigner::from_seed(alice_id.clone(), KeyType::ED25519, alice_id.as_ref()); + + let tx = + SignedTransaction::send_money(0, alice_id, bob_id, &signer, 1, CryptoHash::default()); + + let receipt = Receipt { + predecessor_id: tx.transaction.signer_id.clone(), + receiver_id: tx.transaction.receiver_id.clone(), + receipt_id: Default::default(), + receipt: ReceiptEnum::Action(ActionReceipt { + signer_id: tx.transaction.signer_id.clone(), + signer_public_key: tx.transaction.public_key.clone(), + gas_price, + output_data_receivers: vec![], + input_data_ids: vec![], + actions: vec![Action::Transfer(TransferAction { deposit })], + }), + }; + + // Alice's balance becomes u128::MAX, which causes it is interpreted as + // the Alice's account version to be 2 or higher, instead of being interpreted + // as Alice's balance. Another field is then interpreted as the balance which causes + // `BalanceMismatchError`. + assert_matches!( + check_balance( + &RuntimeConfig::test(), + &initial_state, + &None, + &[receipt], + &[tx], + &[], + &ApplyStats::default(), + ), + Err(RuntimeError::BalanceMismatchError { .. }) + ); + } } diff --git a/runtime/runtime/src/config.rs b/runtime/runtime/src/config.rs index 6e371a86a31..ed8a6b3364c 100644 --- a/runtime/runtime/src/config.rs +++ b/runtime/runtime/src/config.rs @@ -102,6 +102,19 @@ pub fn total_send_fees( receiver_id.get_account_type(), ) } + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + // TODO(nonrefundable) Before stabilizing, consider using separate gas cost parameters + // for non-refundable and regular transfers. + NonrefundableStorageTransfer(_) => { + // Account for implicit account creation + transfer_send_fee( + fees, + sender_is_receiver, + config.wasm_config.implicit_account_creation, + config.wasm_config.eth_implicit_accounts, + receiver_id.get_account_type(), + ) + } Stake(_) => fees.fee(ActionCosts::stake).send_fee(sender_is_receiver), AddKey(add_key_action) => match &add_key_action.access_key.permission { AccessKeyPermission::FunctionCall(call_perm) => { @@ -197,6 +210,16 @@ pub fn exec_fee(config: &RuntimeConfig, action: &Action, receiver_id: &AccountId receiver_id.get_account_type(), ) } + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + NonrefundableStorageTransfer(_) => { + // Account for implicit account creation + transfer_exec_fee( + fees, + config.wasm_config.implicit_account_creation, + config.wasm_config.eth_implicit_accounts, + receiver_id.get_account_type(), + ) + } Stake(_) => fees.fee(ActionCosts::stake).exec_fee(), AddKey(add_key_action) => match &add_key_action.access_key.permission { AccessKeyPermission::FunctionCall(call_perm) => { diff --git a/runtime/runtime/src/lib.rs b/runtime/runtime/src/lib.rs index 822b76630d9..314b91a03d5 100644 --- a/runtime/runtime/src/lib.rs +++ b/runtime/runtime/src/lib.rs @@ -23,10 +23,12 @@ use near_primitives::receipt::{ use near_primitives::runtime::migration_data::{MigrationData, MigrationFlags}; use near_primitives::sandbox::state_patch::SandboxStatePatch; use near_primitives::state_record::StateRecord; -use near_primitives::transaction::ExecutionMetadata; use near_primitives::transaction::{ - Action, ExecutionOutcome, ExecutionOutcomeWithId, ExecutionStatus, LogEntry, SignedTransaction, + Action, ExecutionMetadata, ExecutionOutcome, ExecutionOutcomeWithId, ExecutionStatus, LogEntry, + SignedTransaction, TransferAction, }; +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +use near_primitives::transaction::{DeleteAccountAction, NonrefundableStorageTransferAction}; use near_primitives::trie_key::TrieKey; use near_primitives::types::{ validator_stake::ValidatorStake, AccountId, Balance, BlockHeight, Compute, EpochHeight, @@ -342,16 +344,20 @@ impl Runtime { // TODO(#8806): Support compute costs for actions. For now they match burnt gas. result.compute_usage = exec_fees; let account_id = &receipt.receiver_id; - let is_the_only_action = actions.len() == 1; let is_refund = receipt.predecessor_id.is_system(); + let is_the_only_action = actions.len() == 1; + let implicit_account_creation_eligible = is_the_only_action && !is_refund; + + let receipt_starts_with_create_account = + matches!(actions.get(0), Some(Action::CreateAccount(_))); // Account validation if let Err(e) = check_account_existence( action, account, account_id, &apply_state.config, - is_the_only_action, - is_refund, + implicit_account_creation_eligible, + receipt_starts_with_create_account, ) { result.result = Err(e); return Ok(result); @@ -372,6 +378,7 @@ impl Runtime { &receipt.receiver_id, &receipt.predecessor_id, &mut result, + apply_state.current_protocol_version, ); } Action::DeployContract(deploy_contract) => { @@ -400,34 +407,34 @@ impl Runtime { epoch_info_provider, )?; } - Action::Transfer(transfer) => { - if let Some(account) = account.as_mut() { - action_transfer(account, transfer)?; - // Check if this is a gas refund, then try to refund the access key allowance. - if is_refund && action_receipt.signer_id == receipt.receiver_id { - try_refund_allowance( - state_update, - &receipt.receiver_id, - &action_receipt.signer_public_key, - transfer, - )?; - } - } else { - // Implicit account creation - debug_assert!(apply_state.config.wasm_config.implicit_account_creation); - debug_assert!(!is_refund); - action_implicit_account_creation_transfer( - state_update, - apply_state, - &apply_state.config.fees, - account, - actor_id, - &receipt.receiver_id, - transfer, - apply_state.block_height, - apply_state.current_protocol_version, - ); - } + Action::Transfer(TransferAction { deposit }) => { + action_transfer_or_implicit_account_creation( + account, + *deposit, + false, + is_refund, + action_receipt, + receipt, + state_update, + apply_state, + actor_id, + )?; + } + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(NonrefundableStorageTransferAction { + deposit, + }) => { + action_transfer_or_implicit_account_creation( + account, + *deposit, + true, + is_refund, + action_receipt, + receipt, + state_update, + apply_state, + actor_id, + )?; } Action::Stake(stake) => { action_stake( @@ -502,6 +509,10 @@ impl Runtime { _ => unreachable!("given receipt should be an action receipt"), }; let account_id = &receipt.receiver_id; + + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + let account_before_update = get_account(state_update, account_id)?; + // Collecting input data and removing it from the state let promise_results = action_receipt .input_data_ids @@ -578,6 +589,19 @@ impl Runtime { res.index = Some(action_index as u64); break; } + + // We update `other_burnt_amount` statistic with the non-refundable amount being burnt on account deletion. + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + if matches!(action, Action::DeleteAccount(DeleteAccountAction { beneficiary_id: _ })) { + // The `account_before_update` can be None if the account is both created and deleted within + // a single action receipt (see `test_create_account_add_key_call_delete_key_delete_account`). + if let Some(ref account_before_update) = account_before_update { + stats.other_burnt_amount = safe_add_balance( + stats.other_burnt_amount, + account_before_update.nonrefundable(), + )? + } + } } // Going to check balance covers account's storage. @@ -1551,6 +1575,53 @@ impl Runtime { } } +fn action_transfer_or_implicit_account_creation( + account: &mut Option, + deposit: u128, + nonrefundable: bool, + is_refund: bool, + action_receipt: &ActionReceipt, + receipt: &Receipt, + state_update: &mut TrieUpdate, + apply_state: &ApplyState, + actor_id: &mut AccountId, +) -> Result<(), RuntimeError> { + Ok(if let Some(account) = account.as_mut() { + if nonrefundable { + assert!(cfg!(feature = "protocol_feature_nonrefundable_transfer_nep491")); + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + action_nonrefundable_storage_transfer(account, deposit)?; + } else { + action_transfer(account, deposit)?; + } + // Check if this is a gas refund, then try to refund the access key allowance. + if is_refund && action_receipt.signer_id == receipt.receiver_id { + try_refund_allowance( + state_update, + &receipt.receiver_id, + &action_receipt.signer_public_key, + deposit, + )?; + } + } else { + // Implicit account creation + debug_assert!(apply_state.config.wasm_config.implicit_account_creation); + debug_assert!(!is_refund); + action_implicit_account_creation_transfer( + state_update, + &apply_state, + &apply_state.config.fees, + account, + actor_id, + &receipt.receiver_id, + deposit, + apply_state.block_height, + apply_state.current_protocol_version, + nonrefundable, + ); + }) +} + #[cfg(test)] mod tests { use assert_matches::assert_matches; diff --git a/runtime/runtime/src/verifier.rs b/runtime/runtime/src/verifier.rs index 1483ceacea4..74c27160b14 100644 --- a/runtime/runtime/src/verifier.rs +++ b/runtime/runtime/src/verifier.rs @@ -54,11 +54,13 @@ pub fn check_storage_stake( let available_amount = account .amount() .checked_add(account.locked()) + .and_then(|amount| amount.checked_add(account.nonrefundable())) .ok_or_else(|| { format!( - "Account's amount {} and locked {} overflow addition", + "Account's amount {}, locked {}, and non-refundable {} overflow addition", account.amount(), - account.locked() + account.locked(), + account.nonrefundable(), ) }) .map_err(StorageStakingError::StorageError)?; @@ -397,6 +399,10 @@ pub fn validate_action( Action::DeployContract(a) => validate_deploy_contract_action(limit_config, a), Action::FunctionCall(a) => validate_function_call_action(limit_config, a), Action::Transfer(_) => Ok(()), + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + Action::NonrefundableStorageTransfer(_) => { + check_feature_enabled(ProtocolFeature::NonRefundableBalance, current_protocol_version) + } Action::Stake(a) => validate_stake_action(a), Action::AddKey(a) => validate_add_key_action(limit_config, a), Action::DeleteKey(_) => Ok(()), @@ -526,6 +532,21 @@ fn validate_delete_action(action: &DeleteAccountAction) -> Result<(), ActionsVal Ok(()) } +#[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] +fn check_feature_enabled( + feature: ProtocolFeature, + current_protocol_version: ProtocolVersion, +) -> Result<(), ActionsValidationError> { + if feature.protocol_version() <= current_protocol_version { + Ok(()) + } else { + Err(ActionsValidationError::UnsupportedProtocolFeature { + protocol_feature: format!("{feature:?}"), + version: feature.protocol_version(), + }) + } +} + fn truncate_string(s: &str, limit: usize) -> String { for i in (0..=limit).rev() { if let Some(s) = s.get(..i) { diff --git a/runtime/runtime/tests/runtime_group_tools/mod.rs b/runtime/runtime/tests/runtime_group_tools/mod.rs index 5936f3dcc2d..8b64717adb1 100644 --- a/runtime/runtime/tests/runtime_group_tools/mod.rs +++ b/runtime/runtime/tests/runtime_group_tools/mod.rs @@ -220,7 +220,14 @@ impl RuntimeGroup { if (i as u64) < num_existing_accounts { state_records.push(StateRecord::Account { account_id: account_id.clone(), - account: Account::new(TESTING_INIT_BALANCE, TESTING_INIT_STAKE, code_hash, 0), + account: Account::new( + TESTING_INIT_BALANCE, + TESTING_INIT_STAKE, + 0, + code_hash, + 0, + PROTOCOL_VERSION, + ), }); state_records.push(StateRecord::AccessKey { account_id: account_id.clone(), diff --git a/test-utils/testlib/src/runtime_utils.rs b/test-utils/testlib/src/runtime_utils.rs index eafbf8396be..7dcc5153d2f 100644 --- a/test-utils/testlib/src/runtime_utils.rs +++ b/test-utils/testlib/src/runtime_utils.rs @@ -4,6 +4,7 @@ use near_primitives::account::{AccessKey, Account}; use near_primitives::hash::hash; use near_primitives::state_record::StateRecord; use near_primitives::types::{AccountId, Balance}; +use near_primitives::version::PROTOCOL_VERSION; pub fn alice_account() -> AccountId { "alice.near".parse().unwrap() @@ -46,7 +47,7 @@ pub fn add_contract(genesis: &mut Genesis, account_id: &AccountId, code: Vec if !is_account_record_found { records.push(StateRecord::Account { account_id: account_id.clone(), - account: Account::new(0, 0, hash, 0), + account: Account::new(0, 0, 0, hash, 0, PROTOCOL_VERSION), }); } records.push(StateRecord::Contract { account_id: account_id.clone(), code }); @@ -63,7 +64,7 @@ pub fn add_account_with_access_key( let records = genesis.force_read_records().as_mut(); records.push(StateRecord::Account { account_id: account_id.clone(), - account: Account::new(balance, 0, Default::default(), 0), + account: Account::new(balance, 0, 0, Default::default(), 0, PROTOCOL_VERSION), }); records.push(StateRecord::AccessKey { account_id, public_key, access_key }); } diff --git a/tools/amend-genesis/src/lib.rs b/tools/amend-genesis/src/lib.rs index a7b4017ab11..e4511e6e426 100644 --- a/tools/amend-genesis/src/lib.rs +++ b/tools/amend-genesis/src/lib.rs @@ -10,6 +10,7 @@ use near_primitives::utils; use near_primitives::version::ProtocolVersion; use near_primitives_core::account::{AccessKey, Account}; use near_primitives_core::types::{Balance, BlockHeightDelta, NumBlocks, NumSeats, NumShards}; +use near_primitives_core::version::PROTOCOL_VERSION; use num_rational::Rational32; use serde::ser::{SerializeSeq, Serializer}; use std::collections::{hash_map, HashMap}; @@ -47,22 +48,40 @@ fn set_total_balance(dst: &mut Account, src: &Account) { } impl AccountRecords { - fn new(amount: Balance, locked: Balance, num_bytes_account: u64) -> Self { + fn new( + amount: Balance, + locked: Balance, + nonrefundable: Balance, + num_bytes_account: u64, + ) -> Self { let mut ret = Self::default(); - ret.set_account(amount, locked, num_bytes_account); + ret.set_account(amount, locked, nonrefundable, num_bytes_account); ret } fn new_validator(stake: Balance, num_bytes_account: u64) -> Self { let mut ret = Self::default(); - ret.set_account(0, stake, num_bytes_account); + ret.set_account(0, stake, 0, num_bytes_account); ret.amount_needed = true; ret } - fn set_account(&mut self, amount: Balance, locked: Balance, num_bytes_account: u64) { + fn set_account( + &mut self, + amount: Balance, + locked: Balance, + nonrefundable: Balance, + num_bytes_account: u64, + ) { assert!(self.account.is_none()); - let account = Account::new(amount, locked, CryptoHash::default(), num_bytes_account); + let account = Account::new( + amount, + locked, + nonrefundable, + CryptoHash::default(), + num_bytes_account, + PROTOCOL_VERSION, + ); self.account = Some(account); } @@ -181,6 +200,7 @@ fn parse_extra_records( let r = AccountRecords::new( account.amount(), account.locked(), + account.nonrefundable(), num_bytes_account, ); e.insert(r); @@ -194,7 +214,12 @@ fn parse_extra_records( &account_id )); } - r.set_account(account.amount(), account.locked(), num_bytes_account); + r.set_account( + account.amount(), + account.locked(), + account.nonrefundable(), + num_bytes_account, + ); } } } @@ -447,8 +472,16 @@ mod test { fn parse(&self) -> StateRecord { match &self { Self::Account { account_id, amount, locked, storage_usage } => { - let account = - Account::new(*amount, *locked, CryptoHash::default(), *storage_usage); + // `nonrefundable_balance` can be implemented if this is required in state records. + let nonrefundable_balance = 0; + let account = Account::new( + *amount, + *locked, + nonrefundable_balance, + CryptoHash::default(), + *storage_usage, + PROTOCOL_VERSION, + ); StateRecord::Account { account_id: account_id.parse().unwrap(), account } } Self::AccessKey { account_id, public_key } => StateRecord::AccessKey { diff --git a/tools/fork-network/src/cli.rs b/tools/fork-network/src/cli.rs index 11f0610e9e1..bf491df89d2 100644 --- a/tools/fork-network/src/cli.rs +++ b/tools/fork-network/src/cli.rs @@ -749,8 +749,10 @@ impl ForkNetworkCommand { Account::new( liquid_balance, validator_account.amount, + 0, CryptoHash::default(), storage_bytes, + PROTOCOL_VERSION, ), )?; storage_mutator.set_access_key( diff --git a/tools/state-viewer/Cargo.toml b/tools/state-viewer/Cargo.toml index 16b3348a21c..8cdcb64182e 100644 --- a/tools/state-viewer/Cargo.toml +++ b/tools/state-viewer/Cargo.toml @@ -56,6 +56,10 @@ testlib.workspace = true [features] sandbox = ["node-runtime/sandbox", "near-chain/sandbox", "near-client/sandbox"] +protocol_feature_nonrefundable_transfer_nep491 = [ + "near-primitives/protocol_feature_nonrefundable_transfer_nep491", +] + nightly = [ "near-chain-configs/nightly", "near-chain/nightly", @@ -69,6 +73,7 @@ nightly = [ "nearcore/nightly", "nightly_protocol", "node-runtime/nightly", + "protocol_feature_nonrefundable_transfer_nep491", "testlib/nightly", ] nightly_protocol = [ diff --git a/tools/state-viewer/src/contract_accounts.rs b/tools/state-viewer/src/contract_accounts.rs index d13e92472b9..13b3bc0939d 100644 --- a/tools/state-viewer/src/contract_accounts.rs +++ b/tools/state-viewer/src/contract_accounts.rs @@ -128,6 +128,8 @@ pub(crate) enum ActionType { DeployContract, FunctionCall, Transfer, + #[cfg(feature = "protocol_feature_nonrefundable_transfer_nep491")] + NonrefundableStorageTransfer, Stake, AddKey, DeleteKey, @@ -333,6 +335,12 @@ fn try_find_actions_spawned_by_receipt( Action::DeployContract(_) => ActionType::DeployContract, Action::FunctionCall(_) => ActionType::FunctionCall, Action::Transfer(_) => ActionType::Transfer, + #[cfg( + feature = "protocol_feature_nonrefundable_transfer_nep491" + )] + Action::NonrefundableStorageTransfer(_) => { + ActionType::NonrefundableStorageTransfer + } Action::Stake(_) => ActionType::Stake, Action::AddKey(_) => ActionType::AddKey, Action::DeleteKey(_) => ActionType::DeleteKey,