diff --git a/Cargo.lock b/Cargo.lock index 308905ff15..18b6e8ab57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,22 @@ dependencies = [ "casper-types", ] +[[package]] +name = "burn-main-purse" +version = "0.1.0" +dependencies = [ + "casper-contract", + "casper-types", +] + +[[package]] +name = "burn-then-out-of-gas" +version = "0.1.0" +dependencies = [ + "casper-contract", + "casper-types", +] + [[package]] name = "bytecheck" version = "0.6.12" @@ -684,7 +700,7 @@ dependencies = [ [[package]] name = "casper-binary-port" -version = "1.1.1" +version = "1.2.1" dependencies = [ "bincode", "bytes", @@ -705,7 +721,7 @@ dependencies = [ [[package]] name = "casper-contract" -version = "5.1.1" +version = "5.1.2" dependencies = [ "casper-types", "hex_fmt", @@ -773,7 +789,7 @@ version = "0.1.3" [[package]] name = "casper-engine-test-support" -version = "9.0.0" +version = "9.0.1" dependencies = [ "blake2 0.9.2", "casper-execution-engine", @@ -832,7 +848,7 @@ dependencies = [ [[package]] name = "casper-execution-engine" -version = "9.0.0" +version = "9.0.1" dependencies = [ "anyhow", "assert_matches", @@ -977,7 +993,7 @@ dependencies = [ [[package]] name = "casper-node" -version = "2.2.0" +version = "2.2.2" dependencies = [ "ansi_term", "anyhow", @@ -1075,7 +1091,7 @@ dependencies = [ [[package]] name = "casper-storage" -version = "5.0.0" +version = "5.0.1" dependencies = [ "anyhow", "assert_matches", @@ -1108,7 +1124,7 @@ dependencies = [ [[package]] name = "casper-types" -version = "7.0.0" +version = "7.1.1" dependencies = [ "base16", "base64 0.13.1", @@ -1748,9 +1764,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -4786,9 +4802,9 @@ dependencies = [ [[package]] name = "pprof" -version = "0.14.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afad4d4df7b31280028245f152d5a575083e2abb822d05736f5e47653e77689f" +checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" dependencies = [ "aligned-vec", "backtrace", @@ -4804,7 +4820,7 @@ dependencies = [ "spin 0.10.0", "symbolic-demangle", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] diff --git a/Makefile b/Makefile index da78b011ec..de20c72121 100644 --- a/Makefile +++ b/Makefile @@ -147,7 +147,7 @@ lint-smart-contracts: .PHONY: audit-rs audit-rs: - $(CARGO) audit --ignore RUSTSEC-2024-0437 --ignore RUSTSEC-2025-0022 --ignore RUSTSEC-2025-0055 --ignore RUSTSEC-2026-0001 --ignore RUSTSEC-2026-0007 --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0068 --ignore RUSTSEC-2026-0067 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 + $(CARGO) audit --ignore RUSTSEC-2024-0437 --ignore RUSTSEC-2025-0022 --ignore RUSTSEC-2025-0055 --ignore RUSTSEC-2026-0001 --ignore RUSTSEC-2026-0007 --ignore RUSTSEC-2026-0049 --ignore RUSTSEC-2026-0068 --ignore RUSTSEC-2026-0067 --ignore RUSTSEC-2026-0098 --ignore RUSTSEC-2026-0099 --ignore RUSTSEC-2026-0104 --ignore RUSTSEC-2026-0194 --ignore RUSTSEC-2026-0195 .PHONY: audit audit: audit-rs diff --git a/binary_port/Cargo.toml b/binary_port/Cargo.toml index f98d07032a..057a1a3d0e 100644 --- a/binary_port/Cargo.toml +++ b/binary_port/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-binary-port" -version = "1.1.1" +version = "1.2.1" edition = "2018" description = "Types for the casper node binary port" documentation = "https://docs.rs/casper-binary-port" @@ -13,7 +13,7 @@ exclude = ["proptest-regressions"] [dependencies] bincode = "1.3.3" bytes = "1.0.1" -casper-types = { version = "7.0.0", path = "../types", features = ["datasize", "json-schema", "std"] } +casper-types = { version = "7.1.1", path = "../types", features = ["datasize", "json-schema", "std"] } num-derive = { workspace = true } num-traits = { workspace = true } once_cell = { version = "1.5.2" } diff --git a/binary_port/src/error_code.rs b/binary_port/src/error_code.rs index 6bcb8ee8a1..ca6b1378d8 100644 --- a/binary_port/src/error_code.rs +++ b/binary_port/src/error_code.rs @@ -370,6 +370,8 @@ pub enum ErrorCode { InvalidDelegationAmount = 116, #[error("the transaction invocation target is unsupported under V2 runtime")] UnsupportedInvocationTarget = 117, + #[error("the transaction contained an invalid initiator")] + InvalidInitiator, } impl TryFrom for ErrorCode { @@ -454,6 +456,7 @@ impl From for ErrorCode { } InvalidDeploy::InvalidPaymentAmount => ErrorCode::InvalidDeployInvalidPaymentAmount, InvalidDeploy::PricingModeNotSupported => ErrorCode::PricingModeNotSupported, + InvalidDeploy::InvalidInitiator => ErrorCode::InvalidInitiator, _ => ErrorCode::InvalidDeployUnspecified, } } @@ -571,6 +574,7 @@ impl From for ErrorCode { InvalidTransactionV1::UnsupportedInvocationTarget { .. } => { ErrorCode::UnsupportedInvocationTarget } + InvalidTransactionV1::InvalidInitiator => ErrorCode::InvalidInitiator, _other => ErrorCode::InvalidTransactionUnspecified, } } diff --git a/execution_engine/Cargo.toml b/execution_engine/Cargo.toml index 6871568866..3ecddbbe3f 100644 --- a/execution_engine/Cargo.toml +++ b/execution_engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-execution-engine" -version = "9.0.0" # when updating, also update 'html_root_url' in lib.rs +version = "9.0.1" # when updating, also update 'html_root_url' in lib.rs authors = ["Henry Till ", "Ed Hastings ", "Michał Papierski "] edition = "2021" description = "Casper execution engine crates." @@ -17,8 +17,8 @@ bincode = "1.3.1" blake2 = { version = "0.10.6", default-features = false } blake3 = { version = "1.5.0", default-features = false, features = ["pure"] } sha2 = { version = "0.10.8", default-features = false } -casper-storage = { version = "5.0.0", path = "../storage", default-features = true } -casper-types = { version = "7.0.0", path = "../types", default-features = false, features = ["datasize", "gens", "json-schema", "std"] } +casper-storage = { version = "5.0.1", path = "../storage", default-features = true } +casper-types = { version = "7.1.1", path = "../types", default-features = false, features = ["datasize", "gens", "json-schema", "std"] } casper-wasm = { version = "1.0.0", default-features = false, features = ["sign_ext", "call_indirect_overlong"] } casper-wasm-utils = { version = "4.0.0", default-features = false, features = ["sign_ext", "call_indirect_overlong"] } casper-wasmi = { version = "1.0.0", features = ["sign_ext", "call_indirect_overlong"] } diff --git a/execution_engine/src/engine_state/wasm_v1.rs b/execution_engine/src/engine_state/wasm_v1.rs index 19cb5c91fe..48bb1d8db4 100644 --- a/execution_engine/src/engine_state/wasm_v1.rs +++ b/execution_engine/src/engine_state/wasm_v1.rs @@ -496,6 +496,11 @@ pub struct WasmV1Result { ret: Option, /// Tracking copy cache captured during execution. cache: Option, + /// Remaining approved spending limit on the caller's main purse at the end of execution, if + /// the runtime context produced one. Used to forward the leftover budget from a + /// custom-payment Wasm into the following session phase so a transaction cannot reset its + /// approved spending limit twice. + remaining_spending_limit: Option, } impl WasmV1Result { @@ -520,9 +525,21 @@ impl WasmV1Result { error, ret, cache, + remaining_spending_limit: None, } } + /// Sets the post-execution remaining spending limit. Builder-style. + pub fn with_remaining_spending_limit(mut self, remaining: U512) -> Self { + self.remaining_spending_limit = Some(remaining); + self + } + + /// Returns the post-execution remaining approved spending limit, if captured. + pub fn remaining_spending_limit(&self) -> Option { + self.remaining_spending_limit + } + /// Error, if any. pub fn error(&self) -> Option<&EngineError> { self.error.as_ref() @@ -574,6 +591,7 @@ impl WasmV1Result { error: Some(EngineError::RootNotFound(state_hash)), ret: None, cache: None, + remaining_spending_limit: None, } } @@ -588,6 +606,7 @@ impl WasmV1Result { error: Some(error), ret: None, cache: None, + remaining_spending_limit: None, } } @@ -602,6 +621,7 @@ impl WasmV1Result { error: Some(EngineError::InvalidExecutableItem(error)), ret: None, cache: None, + remaining_spending_limit: None, } } @@ -633,6 +653,7 @@ impl WasmV1Result { error: None, ret: None, cache: Some(cache), + remaining_spending_limit: None, }), TransferResult::Failure(te) => { Some(WasmV1Result { @@ -644,25 +665,55 @@ impl WasmV1Result { error: Some(EngineError::Transfer(te)), ret: None, cache: None, + remaining_spending_limit: None, }) } } } - /// Checks effects for an AddUInt512 transform to a balance at imputed addr - /// and for exactly the imputed amount. + /// Returns true if the effects on the balance at `addr` leave its value at >= `amount`. pub fn balance_increased_by_amount(&self, addr: URefAddr, amount: U512) -> bool { + match self.balance_after_effects(addr) { + Some(total) => total >= amount, + None => false, + } + } + + /// Returns the running balance value the effects leave at `addr`, folding `AddUInt512` and + /// `Write(CLValue)` transforms in execution order. Returns `None` if no balance-shape + /// effect on this key is observed. + /// + /// - The payment purse starts each transaction empty (system invariant maintained by + /// audit-082's `Phase::Session` guard on `get_payment_purse`); + /// - `mint::transfer` writes the new total balance via `write_balance`, producing a `Write` + /// transform whose CLValue contains the post-transfer purse balance; + /// - `mint`'s `add_balance` path produces `AddUInt512`. + pub fn balance_after_effects(&self, addr: URefAddr) -> Option { if self.effects.is_empty() || self.effects.transforms().is_empty() { - return false; + return None; } let key = Key::Balance(addr); - if let Some(transform) = self.effects.transforms().iter().find(|x| x.key() == &key) { - if let TransformKindV2::AddUInt512(added) = transform.kind() { - return *added == amount; + let mut total = U512::zero(); + let mut saw_balance_effect = false; + for transform in self.effects.transforms().iter().filter(|x| x.key() == &key) { + match transform.kind() { + TransformKindV2::AddUInt512(added) => { + saw_balance_effect = true; + total = total.saturating_add(*added); + } + TransformKindV2::Write(stored) => { + if let Some(cl_value) = stored.as_cl_value() { + if let Ok(written) = cl_value.clone().into_t::() { + saw_balance_effect = true; + total = written; + } + } + } + _ => {} } } - false + saw_balance_effect.then_some(total) } } diff --git a/execution_engine/src/execution/executor.rs b/execution_engine/src/execution/executor.rs index 372fe02188..70f67cb3bb 100644 --- a/execution_engine/src/execution/executor.rs +++ b/execution_engine/src/execution/executor.rs @@ -150,6 +150,7 @@ impl Executor { stack, ), }; + let remaining_spending_limit = runtime.context().remaining_spending_limit(); match result { Ok(ret) => WasmV1Result::new( gas_limit, @@ -160,7 +161,8 @@ impl Executor { None, Some(ret), Some(runtime.context().cache()), - ), + ) + .with_remaining_spending_limit(remaining_spending_limit), Err(error) => WasmV1Result::new( gas_limit, runtime.context().gas_counter(), @@ -170,7 +172,8 @@ impl Executor { Some(error.into()), None, None, - ), + ) + .with_remaining_spending_limit(remaining_spending_limit), } } diff --git a/execution_engine/src/lib.rs b/execution_engine/src/lib.rs index 3bc2dbc234..8caa674182 100644 --- a/execution_engine/src/lib.rs +++ b/execution_engine/src/lib.rs @@ -1,6 +1,6 @@ //! The engine which executes smart contracts on the Casper network. -#![doc(html_root_url = "https://docs.rs/casper-execution-engine/9.0.0")] +#![doc(html_root_url = "https://docs.rs/casper-execution-engine/9.0.1")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon_48.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon.png", diff --git a/execution_engine/src/runtime/mod.rs b/execution_engine/src/runtime/mod.rs index a07fe8cf27..96f1d03c88 100644 --- a/execution_engine/src/runtime/mod.rs +++ b/execution_engine/src/runtime/mod.rs @@ -1372,6 +1372,12 @@ where let transfers = self.context.transfers_mut(); runtime.context.transfers().clone_into(transfers); } + // Propagate the child auction runtime's reduced remaining spending limit back to the + // parent context (mirroring `call_host_mint` and `execute_contract`). Without this a + // session that calls auction `add_bid` / `delegate` multiple times would receive the + // original approved amount on every call, letting it spend more than `amount` approved. + self.context + .set_remaining_spending_limit(runtime.context.remaining_spending_limit()); Ok(ret) } @@ -2190,6 +2196,15 @@ where .set_emit_message_cost(runtime.context.emit_message_cost()); let transfers = self.context.transfers_mut(); runtime.context.transfers().clone_into(transfers); + // Propagate the payment-purse marker from the child context back to the parent. + // Without this a stored helper subcall can resolve `handle_payment.get_payment_purse`, + // mark only its own context, and then return the URef so the parent runtime persists + // it under a named key (the put_key guard in the parent never sees the marker). + if self.context.maybe_payment_purse().is_none() { + if let Some(payment_purse) = runtime.context.maybe_payment_purse() { + self.context.set_payment_purse(payment_purse); + } + } match result { Ok(_) => { @@ -3775,6 +3790,7 @@ where id: Option, ) -> Result { let _scoped_host_function_flag = self.host_function_flag.enter_host_function_scope(); + self.context.validate_uref(&source)?; let target_key = Key::Account(target); // Look up the account at the given key @@ -3929,6 +3945,7 @@ where Err(error) => return Ok(Err(error.into())), } }; + self.context.validate_uref(&purse)?; let balance = match self.available_balance(purse)? { Some(balance) => balance, @@ -4093,14 +4110,12 @@ where None => return Ok(Err(ApiError::MissingArgument)), }; - if arg.inner_bytes().len() > output_size { + let arg_bytes = arg.inner_bytes(); + if arg_bytes.len() > output_size { return Ok(Err(ApiError::OutOfMemory)); } - if let Err(error) = self - .try_get_memory()? - .set(output_ptr, &arg.inner_bytes()[..output_size]) - { + if let Err(error) = self.try_get_memory()?.set(output_ptr, arg_bytes) { return Err(ExecError::Interpreter(error.into()).into()); } @@ -4510,6 +4525,10 @@ where } let dictionary_key = self.key_from_mem(key_ptr, key_size)?; + if !dictionary_key.is_dictionary_key() { + return Ok(Err(ApiError::UnexpectedKeyVariant)); + } + let cl_value = match self.context.dictionary_read(dictionary_key)? { Some(cl_value) => cl_value, None => return Ok(Err(ApiError::ValueNotFound)), diff --git a/execution_engine/src/runtime_context/mod.rs b/execution_engine/src/runtime_context/mod.rs index 301432debf..a0b1e47f83 100644 --- a/execution_engine/src/runtime_context/mod.rs +++ b/execution_engine/src/runtime_context/mod.rs @@ -584,12 +584,14 @@ where .map_err(Into::into) } - /// Returns all key's that start with prefix, if any. + /// Returns all key's that start with prefix, if any. Uses the cache-aware path so that + /// same-execution prunes are filtered and same-execution writes are included; the previous + /// implementation went through `reader()` and could not see in-flight changes (e.g. a + /// delegator pruned earlier in the same `add_bid` call would still be counted). pub fn get_keys_with_prefix(&mut self, prefix: &[u8]) -> Result, ExecError> { self.tracking_copy - .borrow_mut() - .reader() - .keys_with_prefix(prefix) + .borrow() + .keys_with_prefix_cached(prefix) .map_err(Into::into) } @@ -1578,7 +1580,10 @@ where }) } - pub(super) fn remaining_spending_limit(&self) -> U512 { + /// Returns the runtime context's remaining approved spending limit on the caller's main + /// purse. Public so the executor can forward the post-execution leftover into the next + /// phase of a custom-payment transaction. + pub fn remaining_spending_limit(&self) -> U512 { self.remaining_spending_limit } diff --git a/execution_engine_testing/test_support/Cargo.toml b/execution_engine_testing/test_support/Cargo.toml index c597633c61..b8989e0b9b 100644 --- a/execution_engine_testing/test_support/Cargo.toml +++ b/execution_engine_testing/test_support/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-engine-test-support" -version = "9.0.0" # when updating, also update 'html_root_url' in lib.rs +version = "9.0.1" # when updating, also update 'html_root_url' in lib.rs authors = ["Fraser Hutchison ", "Michał Papierski "] edition = "2021" description = "Library to support testing of Wasm smart contracts for use on the Casper network." @@ -12,10 +12,10 @@ license = "Apache-2.0" [dependencies] blake2 = "0.9.0" -casper-storage = { version = "5.0.0", path = "../../storage" } -casper-types = { version = "7.0.0", path = "../../types" } +casper-storage = { version = "5.0.1", path = "../../storage" } +casper-types = { version = "7.1.1", path = "../../types" } env_logger = "0.10.0" -casper-execution-engine = { version = "9.0.0", path = "../../execution_engine", features = ["test-support"] } +casper-execution-engine = { version = "9.0.1", path = "../../execution_engine", features = ["test-support"] } humantime = "2" filesize = "0.2.0" lmdb-rkv = "0.14" @@ -29,7 +29,7 @@ tempfile = "3.4.0" toml = "0.5.6" [dev-dependencies] -casper-types = { version = "7.0.0", path = "../../types", features = ["std"] } +casper-types = { version = "7.1.1", path = "../../types", features = ["std"] } version-sync = "0.9.3" [build-dependencies] diff --git a/execution_engine_testing/test_support/src/lib.rs b/execution_engine_testing/test_support/src/lib.rs index c6ca2f0827..0dfd00fa6e 100644 --- a/execution_engine_testing/test_support/src/lib.rs +++ b/execution_engine_testing/test_support/src/lib.rs @@ -1,6 +1,6 @@ //! A library to support testing of Wasm smart contracts for use on the Casper Platform. -#![doc(html_root_url = "https://docs.rs/casper-engine-test-support/9.0.0")] +#![doc(html_root_url = "https://docs.rs/casper-engine-test-support/9.0.1")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon_48.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon.png", diff --git a/execution_engine_testing/tests/src/test/contract_api/add_contract_version.rs b/execution_engine_testing/tests/src/test/contract_api/add_contract_version.rs index baf2446da7..9001ecc5a4 100644 --- a/execution_engine_testing/tests/src/test/contract_api/add_contract_version.rs +++ b/execution_engine_testing/tests/src/test/contract_api/add_contract_version.rs @@ -137,7 +137,7 @@ pub fn new_transaction_v1_session( timestamp, TimeDiff::from_millis(30 * 60 * 1_000), PricingMode::Fixed { - gas_price_tolerance: 5, + gas_price_tolerance: 1, additional_computation_factor: 0, }, fields, diff --git a/execution_engine_testing/tests/src/test/regression/burn_spending_limit.rs b/execution_engine_testing/tests/src/test/regression/burn_spending_limit.rs new file mode 100644 index 0000000000..04933bbd8a --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/burn_spending_limit.rs @@ -0,0 +1,52 @@ +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, DEFAULT_PAYMENT, + LOCAL_GENESIS_REQUEST, +}; +use casper_execution_engine::{engine_state::Error, execution::ExecError}; +use casper_types::{ + api_error::ApiError, + runtime_args, + system::mint::{self, ARG_AMOUNT}, + U512, +}; + +const CONTRACT_BURN: &str = "burn_main_purse.wasm"; + +/// Regression for audit-confirmed-83: mint `burn` must enforce the approved spending limit when +/// the source purse is the caller's main purse, mirroring `Mint::transfer`. The session's outer +/// `amount` runtime arg becomes the transaction's approved spending limit; we set it small and +/// then ask the contract to burn a much larger inner amount from the main purse. Without the fix +/// the burn succeeds because `Mint::burn` skips the spending-limit check entirely. +#[ignore] +#[test] +fn mint_burn_from_main_purse_must_enforce_spending_limit() { + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let outer_spending_limit = U512::one(); + let burn_amount = *DEFAULT_PAYMENT * U512::from(10u64); + + let exec_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_BURN, + runtime_args! { + ARG_AMOUNT => outer_spending_limit, + "burn_amount" => burn_amount, + }, + ) + .build(); + + builder.exec(exec_request).commit(); + + let error = builder + .get_error() + .expect("burn beyond approved spending limit should error"); + assert!( + matches!( + error, + Error::Exec(ExecError::Revert(ApiError::Mint(code))) + if code == mint::Error::UnapprovedSpendingAmount as u8 + ), + "mint burn bypassed the approved spending limit: {error:?}", + ); +} diff --git a/execution_engine_testing/tests/src/test/regression/dictionary_read_uref_bypass.rs b/execution_engine_testing/tests/src/test/regression/dictionary_read_uref_bypass.rs new file mode 100644 index 0000000000..8af4ec59f7 --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/dictionary_read_uref_bypass.rs @@ -0,0 +1,91 @@ +use std::fmt::Write; + +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, LOCAL_GENESIS_REQUEST, +}; +use casper_types::{ + bytesrepr::ToBytes, AccessRights, CLValue, Key, RuntimeArgs, StoredValue, URef, +}; + +const SECRET_UREF_NAME: &str = "no_rights_secret"; +const SECRET_VALUE: &str = "dictionary_read must not read this"; + +fn wat_bytes(bytes: &[u8]) -> String { + let mut escaped = String::new(); + for byte in bytes { + write!(&mut escaped, "\\{byte:02x}").expect("must write byte escape"); + } + escaped +} + +/// Builds a WAT module that imports `casper_dictionary_read` directly and traps on success. The +/// session should never succeed because the supplied key is `Key::URef`, not `Key::Dictionary`. +fn raw_dictionary_read_with_uref_key_wasm(key: Key) -> Vec { + let key_bytes = key.to_bytes().expect("key must serialize"); + let key_len = key_bytes.len(); + let key_data = wat_bytes(&key_bytes); + let module = format!( + r#" + (module + (import "env" "casper_dictionary_read" + (func $dictionary_read (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{key_data}") + (func (export "call") + i32.const 0 + i32.const {key_len} + i32.const 1024 + call $dictionary_read + i32.eqz + if + unreachable + end + ) + ) + "# + ); + wat::parse_str(module).expect("wat must parse") +} + +/// Regression for audit-confirmed-05: the raw `casper_dictionary_read` host function must not +/// accept a `Key::URef` and act as a generic read primitive. +#[ignore] +#[test] +fn should_reject_raw_dictionary_read_with_non_dictionary_uref_key() { + let secret_uref = URef::new([7; 32], AccessRights::NONE); + let secret_key = Key::URef(secret_uref); + + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let mut account = builder + .get_account(*DEFAULT_ACCOUNT_ADDR) + .expect("default account must exist"); + account + .named_keys_mut() + .insert(SECRET_UREF_NAME.to_string(), secret_key); + + builder.write_data_and_commit( + vec![ + ( + Key::Account(*DEFAULT_ACCOUNT_ADDR), + StoredValue::Account(account), + ), + ( + secret_key, + StoredValue::CLValue(CLValue::from_t(SECRET_VALUE).expect("must encode secret")), + ), + ] + .into_iter(), + ); + + let module_bytes = raw_dictionary_read_with_uref_key_wasm(secret_key); + let exec_request = ExecuteRequestBuilder::module_bytes( + *DEFAULT_ACCOUNT_ADDR, + module_bytes, + RuntimeArgs::default(), + ) + .build(); + + builder.exec(exec_request).expect_success().commit(); +} diff --git a/execution_engine_testing/tests/src/test/regression/get_balance_uref_bypass.rs b/execution_engine_testing/tests/src/test/regression/get_balance_uref_bypass.rs new file mode 100644 index 0000000000..72bc275ff4 --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/get_balance_uref_bypass.rs @@ -0,0 +1,78 @@ +use std::fmt::Write; + +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, DEFAULT_PROPOSER_ADDR, + LOCAL_GENESIS_REQUEST, +}; +use casper_types::{bytesrepr::ToBytes, RuntimeArgs, URef}; + +fn wat_bytes(bytes: &[u8]) -> String { + let mut escaped = String::new(); + for byte in bytes { + write!(&mut escaped, "\\{byte:02x}").expect("must write byte escape"); + } + escaped +} + +/// Builds a WAT session that imports `casper_get_balance` directly and traps if the host call +/// returns success. The host function must reject a purse URef that was not granted to the +/// caller. +fn raw_get_balance_with_unknown_purse_wasm(purse: URef) -> Vec { + let purse_bytes = purse.to_bytes().expect("purse URef must serialize"); + let purse_len = purse_bytes.len(); + let purse_data = wat_bytes(&purse_bytes); + + let module = format!( + r#" + (module + (import "env" "casper_get_balance" + (func $get_balance (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{purse_data}") + (func (export "call") + i32.const 0 + i32.const {purse_len} + i32.const 64 + call $get_balance + i32.eqz + if + unreachable + end + ) + ) + "# + ); + + wat::parse_str(module).expect("wat must parse") +} + +/// Regression for audit-confirmed-09: `casper_get_balance` must validate the caller's +/// access-rights for the supplied purse URef before reading its balance. +#[ignore] +#[test] +fn should_reject_raw_get_balance_with_unknown_purse_uref() { + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let proposer = builder + .get_entity_by_account_hash(*DEFAULT_PROPOSER_ADDR) + .expect("proposer account must exist"); + let proposer_main_purse = proposer.main_purse(); + + let module_bytes = raw_get_balance_with_unknown_purse_wasm(proposer_main_purse); + let exec_request = ExecuteRequestBuilder::module_bytes( + *DEFAULT_ACCOUNT_ADDR, + module_bytes, + RuntimeArgs::default(), + ) + .build(); + + builder.exec(exec_request).commit(); + let error_message = builder + .get_error_message() + .expect("unknown purse URef must be rejected"); + assert!( + error_message.contains("Forged reference"), + "unexpected error: {error_message}" + ); +} diff --git a/execution_engine_testing/tests/src/test/regression/get_named_arg_oversized_buffer.rs b/execution_engine_testing/tests/src/test/regression/get_named_arg_oversized_buffer.rs new file mode 100644 index 0000000000..13f4f72c83 --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/get_named_arg_oversized_buffer.rs @@ -0,0 +1,52 @@ +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, LOCAL_GENESIS_REQUEST, +}; +use casper_types::runtime_args; + +/// Builds a WAT session that imports `casper_get_named_arg` directly and requests the named +/// argument `arg` with a 64-byte destination size, even though the supplied `u32` only serializes +/// to 4 bytes. Without the fix the host slice `arg.inner_bytes()[..64]` panics. +fn raw_get_named_arg_with_oversized_buffer_wasm() -> Vec { + wat::parse_str( + r#" + (module + (import "env" "casper_get_named_arg" + (func $get_named_arg (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "arg") + (func (export "call") + i32.const 0 + i32.const 3 + i32.const 64 + i32.const 64 + call $get_named_arg + drop + ) + ) + "#, + ) + .expect("wat must parse") +} + +/// Regression for audit-confirmed-06: `casper_get_named_arg` must not panic when the caller- +/// supplied destination size exceeds the argument's serialized length. +#[ignore] +#[test] +fn should_not_panic_when_get_named_arg_buffer_is_larger_than_value() { + let module_bytes = raw_get_named_arg_with_oversized_buffer_wasm(); + let exec_request = ExecuteRequestBuilder::module_bytes( + *DEFAULT_ACCOUNT_ADDR, + module_bytes, + runtime_args! { + "arg" => 1_u32, + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + builder + .run_genesis(LOCAL_GENESIS_REQUEST.clone()) + .exec(exec_request) + .expect_success() + .commit(); +} diff --git a/execution_engine_testing/tests/src/test/regression/mod.rs b/execution_engine_testing/tests/src/test/regression/mod.rs index 6a1ea76739..df9b6160ec 100644 --- a/execution_engine_testing/tests/src/test/regression/mod.rs +++ b/execution_engine_testing/tests/src/test/regression/mod.rs @@ -1,3 +1,5 @@ +mod burn_spending_limit; +mod dictionary_read_uref_bypass; mod ee_1045; mod ee_1071; mod ee_1103; @@ -30,6 +32,8 @@ mod ee_601; mod ee_771; mod ee_890; mod ee_966; +mod get_balance_uref_bypass; +mod get_named_arg_oversized_buffer; mod gh_1470; mod gh_1688; mod gh_1902; @@ -63,6 +67,8 @@ mod regression_20220303; mod regression_20220727; mod regression_20240105; mod regression_20250812; +mod session_payment_purse_deposit; mod slow_input; pub(crate) mod test_utils; +mod transfer_from_purse_balance_oracle; mod transforms_must_be_ordered; diff --git a/execution_engine_testing/tests/src/test/regression/regression_20220223.rs b/execution_engine_testing/tests/src/test/regression/regression_20220223.rs index 9f443ad844..82d864168a 100644 --- a/execution_engine_testing/tests/src/test/regression/regression_20220223.rs +++ b/execution_engine_testing/tests/src/test/regression/regression_20220223.rs @@ -34,6 +34,7 @@ const CONTRACT_REGRESSION_TRANSFER: &str = "regression_transfer.wasm"; const ARG_TARGET: &str = "target"; const ARG_PURSE_NAME: &str = "purse_name"; +const ARG_CALL_COUNT: &str = "call_count"; const TEST_PURSE: &str = "test_purse"; const TRANSFER_AMOUNT: u64 = MINIMUM_ACCOUNT_CREATION_BALANCE + 1000; const ADD_BID_AMOUNT_1: u64 = 95_000; @@ -236,6 +237,61 @@ fn should_fail_to_update_existing_delegator_over_the_approved_amount() { ); } +/// Regression for audit-confirmed-59: a session that calls auction `delegate` twice within one +/// transaction must hit `UnapprovedSpendingAmount` on the second call. Without the fix +/// `call_host_auction` does not propagate the child auction runtime's reduced +/// `remaining_spending_limit` back to the parent context, so each call sees the full approved +/// amount and the session can spend twice from the caller's main purse. +#[ignore] +#[test] +fn should_fail_to_delegate_twice_over_the_approved_amount() { + let mut builder = setup(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => VALIDATOR_1_PUBLIC_KEY.clone(), + ARG_AMOUNT => U512::from(ADD_BID_AMOUNT_1), + ARG_DELEGATION_RATE => ADD_BID_DELEGATION_RATE_1, + }, + ) + .build(); + + builder + .exec(validator_1_add_bid_request) + .expect_success() + .commit(); + + let delegator_1_delegate_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_REGRESSION_DELEGATE, + runtime_args! { + ARG_AMOUNT => U512::from(DELEGATE_AMOUNT_1), + ARG_VALIDATOR => VALIDATOR_1_PUBLIC_KEY.clone(), + ARG_DELEGATOR => DEFAULT_ACCOUNT_PUBLIC_KEY.clone(), + ARG_CALL_COUNT => 2u32, + }, + ) + .build(); + + builder + .exec(delegator_1_delegate_request) + .expect_failure() + .commit(); + + let error = builder.get_error().expect("should have returned an error"); + assert!( + matches!( + error, + engine_state::Error::Exec(ExecError::Revert(ApiError::Mint(mint_error))) + if mint_error == mint::Error::UnapprovedSpendingAmount as u8 + ), + "auction system calls spent more than the approved amount: {:?}", + error + ); +} + #[ignore] #[test] fn should_fail_to_mint_transfer_over_the_limit() { diff --git a/execution_engine_testing/tests/src/test/regression/session_payment_purse_deposit.rs b/execution_engine_testing/tests/src/test/regression/session_payment_purse_deposit.rs new file mode 100644 index 0000000000..f9e1c4a15a --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/session_payment_purse_deposit.rs @@ -0,0 +1,104 @@ +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, DEFAULT_PROTOCOL_VERSION, + LOCAL_GENESIS_REQUEST, +}; +use casper_storage::{ + data_access_layer::{ + BalanceIdentifier, BalanceIdentifierPurseRequest, BalanceIdentifierPurseResult, + }, + global_state::state::StateProvider, +}; +use casper_types::{runtime_args, Key, StoredValue, URef, U512}; + +const CONTRACT_CREATE_PURSE: &str = "transfer_main_purse_to_new_purse.wasm"; +const CONTRACT_NON_STANDARD_PAYMENT: &str = "non_standard_payment.wasm"; +const ARG_AMOUNT: &str = "amount"; +const ARG_DESTINATION: &str = "destination"; +const ARG_SOURCE_UREF: &str = "source"; +const SOURCE_PURSE_NAME: &str = "audit-82-source-purse"; + +fn handle_payment_payment_purse_balance(builder: &LmdbWasmTestBuilder) -> U512 { + let state_hash = builder.get_post_state_hash(); + let protocol_version = DEFAULT_PROTOCOL_VERSION; + let request = BalanceIdentifierPurseRequest::new( + state_hash, + protocol_version, + BalanceIdentifier::Payment, + ); + let purse_addr = match builder.data_access_layer().balance_purse(request) { + BalanceIdentifierPurseResult::Success { purse_addr } => purse_addr, + other => panic!("unexpected balance_purse result: {:?}", other), + }; + let balance_key = Key::Balance(purse_addr); + match builder.query(None, balance_key, &[]) { + Ok(StoredValue::CLValue(cl_value)) => cl_value + .into_t::() + .unwrap_or_else(|err| panic!("failed to read payment purse balance: {:?}", err)), + Ok(other) => panic!( + "expected CLValue for payment purse balance, got {:?}", + other + ), + Err(err) => panic!("failed to query payment purse balance: {}", err), + } +} + +fn named_account_purse(builder: &LmdbWasmTestBuilder, name: &str) -> Option { + let entity = builder + .get_entity_with_named_keys_by_account_hash(*DEFAULT_ACCOUNT_ADDR) + .expect("default account should exist after genesis"); + entity + .named_keys() + .get(name) + .and_then(|key| key.into_uref()) +} + +/// Regression for audit-confirmed-82: session code must not be able to obtain the system +/// handle-payment payment purse and transfer funds into it. Without the fix the deposit lands +/// and is never refunded, burned, or paid to the proposer - any account can strand funds in the +/// shared payment purse, breaking the payment-purse invariant. +#[ignore] +#[test] +fn session_code_cannot_deposit_into_shared_payment_purse() { + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let setup_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_CREATE_PURSE, + runtime_args! { + ARG_DESTINATION => SOURCE_PURSE_NAME.to_string(), + ARG_AMOUNT => U512::from(1_000u64), + }, + ) + .build(); + builder.exec(setup_request).expect_success().commit(); + + let pre_balance = handle_payment_payment_purse_balance(&builder); + assert!( + pre_balance.is_zero(), + "audit-82 regression requires an initially empty payment purse, found {pre_balance}", + ); + let source = + named_account_purse(&builder, SOURCE_PURSE_NAME).expect("source purse was not created"); + + let exec_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_NON_STANDARD_PAYMENT, + runtime_args! { + ARG_AMOUNT => U512::from(100u64), + ARG_SOURCE_UREF => source, + }, + ) + .build(); + builder.exec(exec_request).commit(); + + // Either the session call must error out (the phase guard rejects `get_payment_purse` outside + // of payment), or the call succeeds without leaving any motes in the shared payment purse. + if builder.get_error().is_none() { + let post_balance = handle_payment_payment_purse_balance(&builder); + assert!( + post_balance.is_zero(), + "session call to get_payment_purse left {post_balance} motes in the shared payment purse", + ); + } +} diff --git a/execution_engine_testing/tests/src/test/regression/transfer_from_purse_balance_oracle.rs b/execution_engine_testing/tests/src/test/regression/transfer_from_purse_balance_oracle.rs new file mode 100644 index 0000000000..8b955091b6 --- /dev/null +++ b/execution_engine_testing/tests/src/test/regression/transfer_from_purse_balance_oracle.rs @@ -0,0 +1,105 @@ +use std::fmt::Write; + +use casper_engine_test_support::{ + ExecuteRequestBuilder, LmdbWasmTestBuilder, DEFAULT_ACCOUNT_ADDR, + DEFAULT_ACCOUNT_INITIAL_BALANCE, DEFAULT_PROPOSER_ADDR, LOCAL_GENESIS_REQUEST, +}; +use casper_types::{account::AccountHash, bytesrepr::ToBytes, RuntimeArgs, URef, U512}; + +fn wat_bytes(bytes: &[u8]) -> String { + let mut escaped = String::new(); + for byte in bytes { + write!(&mut escaped, "\\{byte:02x}").expect("must write byte escape"); + } + escaped +} + +/// Builds a WAT session that imports `casper_transfer_from_purse_to_account` directly and traps +/// if the host function returns at all. The source URef belongs to a purse the caller does not +/// possess and the amount exceeds that purse's balance: the host must reject the unknown source +/// rather than fall through to an `InsufficientFunds` mint error that would leak the balance. +fn raw_transfer_from_unknown_purse_wasm( + source: URef, + target: AccountHash, + amount: U512, +) -> Vec { + let source_bytes = source.to_bytes().expect("source URef must serialize"); + let target_bytes = target.to_bytes().expect("target account must serialize"); + let amount_bytes = amount.to_bytes().expect("amount must serialize"); + let id_bytes = Option::::None + .to_bytes() + .expect("transfer id must serialize"); + let source_len = source_bytes.len(); + let target_len = target_bytes.len(); + let amount_len = amount_bytes.len(); + let id_len = id_bytes.len(); + let source_data = wat_bytes(&source_bytes); + let target_data = wat_bytes(&target_bytes); + let amount_data = wat_bytes(&amount_bytes); + let id_data = wat_bytes(&id_bytes); + + let module = format!( + r#" + (module + (import "env" "casper_transfer_from_purse_to_account" + (func $transfer_from_purse_to_account + (param i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{source_data}") + (data (i32.const 64) "{target_data}") + (data (i32.const 128) "{amount_data}") + (data (i32.const 192) "{id_data}") + (func (export "call") + i32.const 0 + i32.const {source_len} + i32.const 64 + i32.const {target_len} + i32.const 128 + i32.const {amount_len} + i32.const 192 + i32.const {id_len} + i32.const 256 + call $transfer_from_purse_to_account + drop + unreachable + ) + ) + "# + ); + + wat::parse_str(module).expect("wat must parse") +} + +/// Regression for audit-confirmed-10: the raw `casper_transfer_from_purse_to_account` host +/// function must reject an ungranted source URef before any mint precondition (balance) check, +/// so callers cannot use the host as a balance oracle for purses they do not possess. +#[ignore] +#[test] +fn should_reject_unknown_transfer_source_before_checking_balance() { + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let proposer = builder + .get_entity_by_account_hash(*DEFAULT_PROPOSER_ADDR) + .expect("proposer account must exist"); + let proposer_main_purse = proposer.main_purse(); + let target = AccountHash::new([99; 32]); + let amount = U512::from(DEFAULT_ACCOUNT_INITIAL_BALANCE) + U512::one(); + + let module_bytes = raw_transfer_from_unknown_purse_wasm(proposer_main_purse, target, amount); + let exec_request = ExecuteRequestBuilder::module_bytes( + *DEFAULT_ACCOUNT_ADDR, + module_bytes, + RuntimeArgs::default(), + ) + .build(); + + builder.exec(exec_request).commit(); + let error_message = builder + .get_error_message() + .expect("unknown source URef must be rejected"); + assert!( + error_message.contains("Forged reference"), + "unexpected error: {error_message}" + ); +} diff --git a/execution_engine_testing/tests/src/test/system_contracts/auction/bids.rs b/execution_engine_testing/tests/src/test/system_contracts/auction/bids.rs index 72db8c368c..34a1c87640 100644 --- a/execution_engine_testing/tests/src/test/system_contracts/auction/bids.rs +++ b/execution_engine_testing/tests/src/test/system_contracts/auction/bids.rs @@ -34,10 +34,12 @@ use casper_types::{ Error as AuctionError, UnbondKind, ValidatorWeights, ARG_AMOUNT, ARG_DELEGATION_RATE, ARG_DELEGATOR, ARG_ENTRY_POINT, ARG_MAXIMUM_DELEGATION_AMOUNT, ARG_MINIMUM_DELEGATION_AMOUNT, ARG_NEW_PUBLIC_KEY, ARG_NEW_VALIDATOR, ARG_PUBLIC_KEY, - ARG_REWARDS_MAP, ARG_VALIDATOR, ERA_ID_KEY, INITIAL_ERA_ID, METHOD_DISTRIBUTE, + ARG_REWARDS_MAP, ARG_VALIDATOR, ERA_END_TIMESTAMP_MILLIS_KEY, ERA_ID_KEY, INITIAL_ERA_ID, + METHOD_DISTRIBUTE, }, - EntityAddr, EraId, GenesisAccount, GenesisValidator, HoldBalanceHandling, Key, Motes, - ProtocolVersion, PublicKey, SecretKey, TransactionHash, DEFAULT_MINIMUM_BID_AMOUNT, U256, U512, + CLValue, EntityAddr, EraId, GenesisAccount, GenesisValidator, HoldBalanceHandling, Key, Motes, + ProtocolVersion, PublicKey, SecretKey, StoredValue, TransactionHash, + DEFAULT_MINIMUM_BID_AMOUNT, U256, U512, }; const ARG_TARGET: &str = "target"; @@ -5145,6 +5147,469 @@ fn should_fail_bid_public_key_change_if_conflicting_validator_bid_exists() { if auction_error == AuctionError::ValidatorBidExistsAlready as u8)); } +/// Regression for audit-confirmed-65: `change_bid_public_key` must reject `PublicKey::System` as +/// the replacement key. Otherwise a validator can move its active bid into a `ValidatorBid` +/// whose `validator_public_key` is the reserved system identity, leaving bridge records pointing +/// to `PublicKey::System` and polluting auction state. +#[ignore] +#[test] +fn should_not_change_validator_bid_public_key_to_system() { + let validator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT), + }, + ) + .build(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_AMOUNT => U512::from(ADD_BID_AMOUNT_1), + ARG_DELEGATION_RATE => ADD_BID_DELEGATION_RATE_1, + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + builder + .exec(validator_1_fund_request) + .commit() + .expect_success(); + builder + .exec(validator_1_add_bid_request) + .commit() + .expect_success(); + + let change_bid_public_key_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_CHANGE_BID_PUBLIC_KEY, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_NEW_PUBLIC_KEY => PublicKey::System, + }, + ) + .build(); + + builder.exec(change_bid_public_key_request).commit(); + + if let Some(error) = builder.get_error() { + assert!(matches!( + error, + Error::Exec(ExecError::Revert(ApiError::AuctionError(auction_error))) + if auction_error == AuctionError::InvalidPublicKey as u8 + )); + return; + } + + let bids = builder.get_bids(); + assert!( + bids.validator_bid(&PublicKey::System).is_none(), + "validator bid was moved to the system public key" + ); +} + +/// Regression for audit-confirmed-77: when a validator raises `minimum_delegation_amount` and +/// the resulting forced delegator unbond hits `DelegatorFundsLocked` (e.g. vesting table not yet +/// initialized but era past validator lockout), the unbond record must not be written. +/// Otherwise auction state holds a pending unbond against an unchanged locked delegator bid - +/// the same motes are both still delegated and queued for release once the unbond matures. +#[ignore] +#[test] +fn should_not_create_forced_unbond_for_locked_delegator_without_reducing_stake() { + let accounts = { + let mut tmp: Vec = DEFAULT_ACCOUNTS.clone(); + let validator_1 = GenesisAccount::account( + VALIDATOR_1.clone(), + Motes::new(DEFAULT_ACCOUNT_INITIAL_BALANCE), + Some(GenesisValidator::new( + Motes::new(VALIDATOR_1_STAKE), + VALIDATOR_1_DELEGATION_RATE, + )), + ); + let delegator_1 = GenesisAccount::delegator( + VALIDATOR_1.clone(), + DELEGATOR_1.clone(), + Motes::new(DELEGATOR_1_BALANCE), + Motes::new(DELEGATOR_1_STAKE), + ); + tmp.push(validator_1); + tmp.push(delegator_1); + tmp + }; + + let run_genesis_request = { + let exec_config = GenesisConfigBuilder::default() + .with_accounts(accounts) + .with_locked_funds_period_millis(CASPER_LOCKED_FUNDS_PERIOD_MILLIS) + .build(); + + GenesisRequest::new( + DEFAULT_GENESIS_CONFIG_HASH, + DEFAULT_PROTOCOL_VERSION, + exec_config, + DEFAULT_CHAINSPEC_REGISTRY.clone(), + ) + }; + let chainspec = ChainspecConfig::default() + .with_vesting_schedule_period_millis(CASPER_VESTING_SCHEDULE_PERIOD_MILLIS); + + let mut builder = LmdbWasmTestBuilder::new_temporary_with_config(chainspec); + builder.run_genesis(run_genesis_request); + + let boundary_update_time = EXPECTED_INITIAL_RELEASE_TIMESTAMP_MILLIS + + CASPER_VESTING_SCHEDULE_PERIOD_MILLIS + + WEEK_MILLIS; + let auction_named_keys = + builder.get_named_keys_for_system_contract(builder.get_auction_contract_hash()); + let era_end_timestamp_key = *auction_named_keys + .get(ERA_END_TIMESTAMP_MILLIS_KEY) + .expect("auction should have era end timestamp key"); + let era_end_timestamp_uref = era_end_timestamp_key + .as_uref() + .expect("era end timestamp should be stored under a uref"); + let era_end_timestamp_value = CLValue::from_t(boundary_update_time) + .expect("era end timestamp should serialize to CLValue"); + builder.write_data_and_commit( + [( + Key::URef(*era_end_timestamp_uref), + StoredValue::CLValue(era_end_timestamp_value), + )] + .into_iter(), + ); + + let forced_boundary_update = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + ARG_AMOUNT => U512::one(), + ARG_DELEGATION_RATE => VALIDATOR_1_DELEGATION_RATE, + ARG_MINIMUM_DELEGATION_AMOUNT => DELEGATOR_1_STAKE + 1, + ARG_MAXIMUM_DELEGATION_AMOUNT => DEFAULT_MAXIMUM_DELEGATION_AMOUNT, + }, + ) + .build(); + + builder + .exec(forced_boundary_update) + .commit() + .expect_success(); + + let bids = builder.get_bids(); + let delegator = bids + .delegator_by_kind(&VALIDATOR_1, &DelegatorKind::PublicKey(DELEGATOR_1.clone())) + .expect("locked delegator bid should still exist"); + assert_eq!( + delegator.staked_amount(), + U512::from(DELEGATOR_1_STAKE), + "locked delegator stake should not be reduced by the failed forced unbond" + ); + + let unbond_kind = UnbondKind::DelegatedPublicKey(DELEGATOR_1.clone()); + assert!( + !builder.get_unbonds().contains_key(&unbond_kind), + "forced boundary update created an unbond record for a locked delegator without reducing stake" + ); +} + +/// Regression for audit-confirmed-72: during reward distribution, when a reward map entry uses +/// the *old* (pre-rotation) validator key and the resolved delegator reward pushes the delegator +/// above the validator's `maximum_delegation_amount`, the queued automatic undelegation for the +/// overage must use the *current* validator key. Otherwise `undelegate` later looks for the bid +/// under the stale bridged key and fails with `ValidatorNotFound`, blocking the whole +/// distribution call. +#[ignore] +#[test] +fn bridged_validator_delegator_reward_over_max_unbonds_from_current_bid() { + let system_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *SYSTEM_ADDR, + ARG_AMOUNT => U512::from(SYSTEM_TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_2_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_2_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let delegator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *BID_ACCOUNT_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_AMOUNT => U512::from(ADD_BID_AMOUNT_1), + ARG_DELEGATION_RATE => ADD_BID_DELEGATION_RATE_1, + ARG_MAXIMUM_DELEGATION_AMOUNT => DELEGATE_AMOUNT_1, + }, + ) + .build(); + + let delegator_1_validator_1_delegate_request = ExecuteRequestBuilder::standard( + *BID_ACCOUNT_1_ADDR, + CONTRACT_DELEGATE, + runtime_args! { + ARG_AMOUNT => U512::from(DELEGATE_AMOUNT_1), + ARG_VALIDATOR => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_DELEGATOR => BID_ACCOUNT_1_PK.clone(), + }, + ) + .build(); + + let change_bid_public_key_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_CHANGE_BID_PUBLIC_KEY, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_NEW_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_2_PK.clone() + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + for request in [ + system_fund_request, + validator_1_fund_request, + validator_2_fund_request, + delegator_1_fund_request, + validator_1_add_bid_request, + delegator_1_validator_1_delegate_request, + ] { + builder.exec(request).commit().expect_success(); + } + + builder.advance_eras_by_default_auction_delay(); + builder + .exec(change_bid_public_key_request) + .commit() + .expect_success(); + + let protocol_version = DEFAULT_PROTOCOL_VERSION; + let total_payout = builder.base_round_reward(None, protocol_version); + let mut rewards = BTreeMap::new(); + rewards.insert(NON_FOUNDER_VALIDATOR_1_PK.clone(), vec![total_payout]); + let distribute_request = ExecuteRequestBuilder::contract_call_by_hash( + *SYSTEM_ADDR, + builder.get_auction_contract_hash(), + METHOD_DISTRIBUTE, + runtime_args! { + ARG_ENTRY_POINT => METHOD_DISTRIBUTE, + ARG_REWARDS_MAP => rewards + }, + ) + .build(); + + builder.exec(distribute_request).commit(); + + if let Some(error) = builder.get_error() { + panic!("bridged validator delegator reward overage failed to unbond from the current bid: {error}"); + } + + let delegator_kind = DelegatorKind::PublicKey(BID_ACCOUNT_1_PK.clone()); + let delegator = builder + .get_bids() + .delegator_by_kind(&NON_FOUNDER_VALIDATOR_2_PK, &delegator_kind) + .expect("delegator should remain under the current validator key"); + assert_eq!( + delegator.staked_amount(), + U512::from(DELEGATE_AMOUNT_1), + "delegator reward overage was not unbonded from the current validator bid" + ); +} + +/// Regression for audit-confirmed-68: a `run_auction` with the *old* (pre-rotation) validator +/// public key in `evicted_validators` must still deactivate the active bridged bid that lives +/// under the new key. Without the fix the eviction was silently ignored because `run_auction` +/// compared evicted keys to active-bid keys directly, missing the bridge. +#[ignore] +#[test] +fn eviction_of_bridged_validator_key_deactivates_current_bid() { + let validator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_2_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_2_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_AMOUNT => U512::from(ADD_BID_AMOUNT_1), + ARG_DELEGATION_RATE => ADD_BID_DELEGATION_RATE_1, + }, + ) + .build(); + + let change_bid_public_key_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_CHANGE_BID_PUBLIC_KEY, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_NEW_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_2_PK.clone() + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + builder + .exec(validator_1_fund_request) + .commit() + .expect_success(); + builder + .exec(validator_2_fund_request) + .commit() + .expect_success(); + builder + .exec(validator_1_add_bid_request) + .commit() + .expect_success(); + builder + .exec(change_bid_public_key_request) + .commit() + .expect_success(); + + builder.run_auction( + DEFAULT_GENESIS_TIMESTAMP_MILLIS + TIMESTAMP_MILLIS_INCREMENT, + vec![NON_FOUNDER_VALIDATOR_1_PK.clone()], + ); + + let bids_after_eviction = builder.get_bids(); + let current_bid = bids_after_eviction + .validator_bid(&NON_FOUNDER_VALIDATOR_2_PK) + .expect("current validator key should still own the bridged bid"); + assert!( + current_bid.inactive(), + "evicting the old bridged validator key did not deactivate the current bid" + ); +} + +/// Regression for audit-confirmed-67: a zero-amount `withdraw_bid` must not create a validator +/// unbond entry. Without the fix the call goes through, decreases stake by zero, and still +/// commits a zero-value unbond era. +#[ignore] +#[test] +fn should_not_create_zero_amount_validator_unbond() { + let validator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *NON_FOUNDER_VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT), + }, + ) + .build(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_AMOUNT => U512::from(ADD_BID_AMOUNT_1), + ARG_DELEGATION_RATE => ADD_BID_DELEGATION_RATE_1, + }, + ) + .build(); + + let zero_withdraw_bid_request = ExecuteRequestBuilder::standard( + *NON_FOUNDER_VALIDATOR_1_ADDR, + CONTRACT_WITHDRAW_BID, + runtime_args! { + ARG_PUBLIC_KEY => NON_FOUNDER_VALIDATOR_1_PK.clone(), + ARG_AMOUNT => U512::zero(), + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + builder + .exec(validator_1_fund_request) + .commit() + .expect_success(); + builder + .exec(validator_1_add_bid_request) + .commit() + .expect_success(); + + builder.exec(zero_withdraw_bid_request).commit(); + + if let Some(error) = builder.get_error() { + assert!(matches!( + error, + Error::Exec(ExecError::Revert(ApiError::AuctionError(auction_error))) + if auction_error == AuctionError::BondTooSmall as u8 + )); + return; + } + + let unbond_kind = UnbondKind::Validator(NON_FOUNDER_VALIDATOR_1_PK.clone()); + let has_zero_unbond = builder + .get_unbonds() + .get(&unbond_kind) + .into_iter() + .flat_map(|unbonds| unbonds.iter()) + .flat_map(|unbond| unbond.eras().iter()) + .any(|era| era.amount().is_zero()); + + assert!( + !has_zero_unbond, + "zero-amount withdraw_bid created a validator unbond entry" + ); +} + #[ignore] #[test] fn should_change_validator_bid_public_key() { diff --git a/execution_engine_testing/tests/src/test/system_contracts/auction/distribute.rs b/execution_engine_testing/tests/src/test/system_contracts/auction/distribute.rs index bc9c8db9e4..3af719375e 100644 --- a/execution_engine_testing/tests/src/test/system_contracts/auction/distribute.rs +++ b/execution_engine_testing/tests/src/test/system_contracts/auction/distribute.rs @@ -22,8 +22,8 @@ use casper_types::{ system::auction::{ self, BidsExt, DelegationRate, DelegatorBid, DelegatorKind, SeigniorageAllocation, SeigniorageRecipientsSnapshotV2, UnbondKind, ARG_AMOUNT, ARG_DELEGATION_RATE, - ARG_DELEGATOR, ARG_PUBLIC_KEY, ARG_REWARDS_MAP, ARG_VALIDATOR, DELEGATION_RATE_DENOMINATOR, - METHOD_DISTRIBUTE, SEIGNIORAGE_RECIPIENTS_SNAPSHOT_KEY, + ARG_DELEGATOR, ARG_NEW_PUBLIC_KEY, ARG_PUBLIC_KEY, ARG_REWARDS_MAP, ARG_VALIDATOR, + DELEGATION_RATE_DENOMINATOR, METHOD_DISTRIBUTE, SEIGNIORAGE_RECIPIENTS_SNAPSHOT_KEY, }, EntityAddr, EraId, GenesisAccount, ProtocolVersion, PublicKey, RewardsHandling, SecretKey, Timestamp, DEFAULT_MINIMUM_BID_AMOUNT, U512, @@ -3950,3 +3950,296 @@ fn delegator_full_unbond_during_first_reward_era() { let delegator = get_delegator_bid(&mut builder, VALIDATOR_1.clone(), DELEGATOR_1.clone()); assert!(delegator.is_none()); } + +#[ignore] +#[test] +fn should_correctly_find_unbond_purse_after_change_in_public_key() { + const VALIDATOR_1_STAKE: u64 = 1_000_000_000_000; + const DELEGATOR_1_STAKE: u64 = 1_000_000_000_000; + const DELEGATOR_2_STAKE: u64 = 1_000_000_000_000; + const CONTRACT_CHANGE_BID_PUBLIC_KEY: &str = "change_bid_public_key.wasm"; + + const VALIDATOR_1_DELEGATION_RATE: DelegationRate = DELEGATION_RATE_DENOMINATOR; + + let system_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *SYSTEM_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_2_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *VALIDATOR_2_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let delegator_1_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *DELEGATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let delegator_2_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *DELEGATOR_2_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_1_add_bid_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_AMOUNT => U512::from(VALIDATOR_1_STAKE), + ARG_DELEGATION_RATE => VALIDATOR_1_DELEGATION_RATE, + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + }, + ) + .build(); + + let delegator_1_delegate_request = ExecuteRequestBuilder::standard( + *DELEGATOR_1_ADDR, + CONTRACT_DELEGATE, + runtime_args! { + ARG_AMOUNT => U512::from(DELEGATOR_1_STAKE), + ARG_VALIDATOR => VALIDATOR_1.clone(), + ARG_DELEGATOR => DELEGATOR_1.clone(), + }, + ) + .build(); + + let delegator_2_delegate_request = ExecuteRequestBuilder::standard( + *DELEGATOR_2_ADDR, + CONTRACT_DELEGATE, + runtime_args! { + ARG_AMOUNT => U512::from(DELEGATOR_2_STAKE), + ARG_VALIDATOR => VALIDATOR_1.clone(), + ARG_DELEGATOR => DELEGATOR_2.clone(), + }, + ) + .build(); + + let post_genesis_requests = vec![ + system_fund_request, + validator_1_fund_request, + validator_2_fund_request, + delegator_1_fund_request, + delegator_2_fund_request, + validator_1_add_bid_request, + delegator_1_delegate_request, + delegator_2_delegate_request, + ]; + + let mut builder = LmdbWasmTestBuilder::default(); + + builder.run_genesis(LOCAL_GENESIS_REQUEST.clone()); + + let protocol_version = DEFAULT_PROTOCOL_VERSION; + // initial token supply + let initial_supply = builder.total_supply(protocol_version, None); + let expected_total_reward_before = *GENESIS_ROUND_SEIGNIORAGE_RATE * initial_supply; + let expected_total_reward_integer = expected_total_reward_before.to_integer(); + + for request in post_genesis_requests { + builder.exec(request).commit().expect_success(); + } + + for _ in 0..5 { + builder.advance_era(); + } + + // change validator 1 bid public key to validator 2 public key + let change_bid_public_key_request = ExecuteRequestBuilder::standard( + VALIDATOR_1.clone().to_account_hash(), + CONTRACT_CHANGE_BID_PUBLIC_KEY, + runtime_args! { + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + ARG_NEW_PUBLIC_KEY => VALIDATOR_2.clone() + }, + ) + .build(); + + builder + .exec(change_bid_public_key_request) + .commit() + .expect_success(); + + withdraw_bid( + &mut builder, + VALIDATOR_2.to_account_hash(), + VALIDATOR_2.clone(), + U512::from(VALIDATOR_1_STAKE), + ); + + let mut rewards = BTreeMap::new(); + rewards.insert(VALIDATOR_1.clone(), vec![expected_total_reward_integer]); + + let distribute_request = ExecuteRequestBuilder::contract_call_by_hash( + *SYSTEM_ADDR, + builder.get_auction_contract_hash(), + METHOD_DISTRIBUTE, + runtime_args! { + ARG_ENTRY_POINT => METHOD_DISTRIBUTE, + ARG_REWARDS_MAP => rewards + }, + ) + .build(); + + builder.exec(distribute_request).commit().expect_success(); + + let era_info = get_era_info(&mut builder); + let validator_reward = match era_info.select(VALIDATOR_1.clone()).next() { + Some(SeigniorageAllocation::Validator { + validator_public_key, + amount, + }) if *validator_public_key == *VALIDATOR_1 => *amount, + other => panic!("expected validator allocation for old key, got {other:?}"), + }; + assert!( + !validator_reward.is_zero(), + "test setup should distribute a non-zero validator reward" + ); + + let withdraws = builder.get_unbonds(); + let validator_unbond_kind = UnbondKind::Validator(VALIDATOR_2.clone()); + let validator_unbonds = withdraws + .get(&validator_unbond_kind) + .expect("should keep the bridged validator unbond under the new key"); + assert_eq!( + validator_unbonds.len(), + 1, + "reward distribution should update the resolved new-key unbond without creating a duplicate" + ); + + let validator_unbond_amount = validator_unbonds[0] + .eras() + .first() + .expect("should have an unbond era") + .amount(); + assert_eq!( + *validator_unbond_amount, + U512::from(VALIDATOR_1_STAKE) + validator_reward, + "validator reward should be added to the existing new-key unbond amount" + ); + + let unbonding_delay = builder.get_unbonding_delay(); + builder.advance_eras_by(unbonding_delay + 1); +} + +/// Regression for audit-confirmed-123: with sustain rewards enabled, a rewards map containing +/// only `PublicKey::System` must not mint a sustain share (and must not move total supply), +/// since the per-validator processing filters out `PublicKey::System`. +#[ignore] +#[test] +fn system_key_rewards_must_not_mint_sustain_share() { + const VALIDATOR_1_STAKE: u64 = 1_000_000_000_000; + + let validator_fund_request = ExecuteRequestBuilder::standard( + *DEFAULT_ACCOUNT_ADDR, + CONTRACT_TRANSFER_TO_ACCOUNT, + runtime_args! { + ARG_TARGET => *VALIDATOR_1_ADDR, + ARG_AMOUNT => U512::from(TRANSFER_AMOUNT) + }, + ) + .build(); + + let validator_add_bid_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_AMOUNT => U512::from(VALIDATOR_1_STAKE), + ARG_DELEGATION_RATE => DelegationRate::from(0u8), + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + }, + ) + .build(); + + let mut builder = LmdbWasmTestBuilder::default(); + let mut default_request = LOCAL_GENESIS_REQUEST.clone(); + default_request.push_genesis_account(GenesisAccount::SustainAccount { + public_key: DEFAULT_SUSTAIN_PUBLIC_KEY.clone(), + }); + default_request.push_rewards_ratio(Ratio::new(1, 4)); + builder.run_genesis(default_request); + + for request in [validator_fund_request, validator_add_bid_request] { + builder.exec(request).commit().expect_success(); + } + + for _ in 0..=builder.get_auction_delay() { + let step_request = StepRequestBuilder::new() + .with_parent_state_hash(builder.get_post_state_hash()) + .with_protocol_version(ProtocolVersion::V1_0_0) + .with_next_era_id(builder.get_era().successor()) + .with_run_auction(true) + .build(); + assert!( + builder.step(step_request).is_success(), + "must execute step successfully" + ); + } + + let sustain_purse = builder + .get_account(DEFAULT_SUSTAIN_PUBLIC_KEY.to_account_hash()) + .expect("must have sustain account as part of genesis setup") + .main_purse(); + + let supply_before = builder.total_supply(DEFAULT_PROTOCOL_VERSION, None); + let sustain_balance_before = builder.get_purse_balance(sustain_purse); + + let mut block_rewards = BTreeMap::new(); + block_rewards.insert(PublicKey::System, vec![U512::from(1_000_000u64)]); + + let result = builder.distribute_with_rewards_handling( + None, + ProtocolVersion::V2_0_0, + block_rewards, + 0, + RewardsHandling::Sustain { + ratio: Ratio::new(1, 4), + purse_address: sustain_purse.to_formatted_string(), + }, + ); + assert!( + result.is_success(), + "distribute with only PublicKey::System rewards must succeed without minting" + ); + + let supply_after = builder.total_supply(DEFAULT_PROTOCOL_VERSION, None); + let sustain_balance_after = builder.get_purse_balance(sustain_purse); + + assert_eq!( + sustain_balance_after, sustain_balance_before, + "system-key reward must not credit the sustain purse" + ); + assert_eq!( + supply_after, supply_before, + "system-key reward must not increase total supply" + ); +} diff --git a/execution_engine_testing/tests/src/test/system_contracts/auction/reservations.rs b/execution_engine_testing/tests/src/test/system_contracts/auction/reservations.rs index 34e4407d2a..b2411e2dae 100644 --- a/execution_engine_testing/tests/src/test/system_contracts/auction/reservations.rs +++ b/execution_engine_testing/tests/src/test/system_contracts/auction/reservations.rs @@ -25,8 +25,9 @@ use casper_types::{ system::auction::{ BidsExt, DelegationRate, DelegatorKind, Error as AuctionError, Reservation, SeigniorageAllocation, ARG_AMOUNT, ARG_DELEGATION_RATE, ARG_DELEGATOR, ARG_DELEGATORS, - ARG_ENTRY_POINT, ARG_PUBLIC_KEY, ARG_RESERVATIONS, ARG_RESERVED_SLOTS, ARG_REWARDS_MAP, - ARG_VALIDATOR, DELEGATION_RATE_DENOMINATOR, METHOD_DISTRIBUTE, + ARG_ENTRY_POINT, ARG_MINIMUM_DELEGATION_AMOUNT, ARG_NEW_PUBLIC_KEY, ARG_PUBLIC_KEY, + ARG_RESERVATIONS, ARG_RESERVED_SLOTS, ARG_REWARDS_MAP, ARG_VALIDATOR, + DELEGATION_RATE_DENOMINATOR, METHOD_DISTRIBUTE, }, ProtocolVersion, PublicKey, SecretKey, U512, }; @@ -39,6 +40,7 @@ const CONTRACT_DELEGATE: &str = "delegate.wasm"; const CONTRACT_UNDELEGATE: &str = "undelegate.wasm"; const CONTRACT_ADD_RESERVATIONS: &str = "add_reservations.wasm"; const CONTRACT_CANCEL_RESERVATIONS: &str = "cancel_reservations.wasm"; +const CONTRACT_CHANGE_BID_PUBLIC_KEY: &str = "change_bid_public_key.wasm"; const ADD_BID_AMOUNT_1: u64 = 1_000_000_000_000; const ADD_BID_RESERVED_SLOTS: u32 = 1; @@ -47,6 +49,10 @@ static VALIDATOR_1: Lazy = Lazy::new(|| { let secret_key = SecretKey::ed25519_from_bytes([3; SecretKey::ED25519_LENGTH]).unwrap(); PublicKey::from(&secret_key) }); +static VALIDATOR_2: Lazy = Lazy::new(|| { + let secret_key = SecretKey::ed25519_from_bytes([5; SecretKey::ED25519_LENGTH]).unwrap(); + PublicKey::from(&secret_key) +}); static DELEGATOR_1: Lazy = Lazy::new(|| { let secret_key = SecretKey::ed25519_from_bytes([205; SecretKey::ED25519_LENGTH]).unwrap(); PublicKey::from(&secret_key) @@ -979,3 +985,124 @@ fn should_distribute_rewards_with_reserved_slots() { if *delegator_public_key == *DELEGATOR_2 && *amount == delegator_2_expected_payout )); } + +/// Regression for audit-confirmed-64: an `add_bid` call that raises `minimum_delegation_amount` +/// (forcing an existing under-minimum delegator into an unbond in the same execution) and at the +/// same time sets `reserved_slots = 1` must be accepted - the freed delegator slot should make +/// room for the new reservation. Without the fix the auction's reservation-slot validation does +/// a prefix scan via the raw state reader, which doesn't see the same-execution prune of the +/// freed delegator, so the bid is rejected with `ExceededReservationSlotsLimit`. +#[test] +fn should_reserve_slot_after_forced_delegator_unbond_in_same_add_bid_call() { + let mut builder = setup_accounts(1); + setup_validator_bid(&mut builder, 0); + + let delegation_request = ExecuteRequestBuilder::standard( + *DELEGATOR_1_ADDR, + CONTRACT_DELEGATE, + runtime_args! { + ARG_AMOUNT => U512::from(DEFAULT_MINIMUM_DELEGATION_AMOUNT), + ARG_VALIDATOR => VALIDATOR_1.clone(), + ARG_DELEGATOR => DELEGATOR_1.clone(), + }, + ) + .build(); + builder.exec(delegation_request).expect_success().commit(); + + let update_bid_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_BID, + runtime_args! { + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + ARG_AMOUNT => U512::from(1), + ARG_DELEGATION_RATE => VALIDATOR_1_DELEGATION_RATE, + ARG_MINIMUM_DELEGATION_AMOUNT => DEFAULT_MINIMUM_DELEGATION_AMOUNT + 1, + ARG_RESERVED_SLOTS => 1u32, + }, + ) + .build(); + + builder.exec(update_bid_request).expect_success().commit(); + + let bids = builder.get_bids(); + assert!( + bids.delegator_by_kind(&VALIDATOR_1, &DelegatorKind::PublicKey(DELEGATOR_1.clone())) + .is_none(), + "delegator below the raised minimum should be fully unbonded" + ); + + let validator_bid = get_validator_bid(&mut builder, VALIDATOR_1.clone()) + .expect("validator bid should remain after updating delegation constraints"); + assert_eq!(validator_bid.reserved_slots(), 1); +} + +/// Regression for audit-confirmed-61: `change_bid_public_key` must migrate reservation bid +/// records (and their embedded `validator_public_key`) to the new validator key, not leave them +/// indexed under the old key. +#[ignore] +#[test] +fn should_move_reservations_when_validator_bid_public_key_changes() { + let mut builder = setup_accounts(3); + setup_validator_bid(&mut builder, ADD_BID_RESERVED_SLOTS); + + let reservation = Reservation::new( + VALIDATOR_1.clone(), + DelegatorKind::PublicKey(DELEGATOR_1.clone()), + VALIDATOR_1_RESERVATION_DELEGATION_RATE, + ); + let reservation_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_ADD_RESERVATIONS, + runtime_args! { + ARG_RESERVATIONS => vec![reservation], + }, + ) + .build(); + builder.exec(reservation_request).expect_success().commit(); + + let reservations = builder + .get_bids() + .reservations_by_validator_public_key(&VALIDATOR_1) + .expect("old validator key should have a reservation before key rotation"); + assert_eq!(reservations.len(), 1); + + let change_bid_public_key_request = ExecuteRequestBuilder::standard( + *VALIDATOR_1_ADDR, + CONTRACT_CHANGE_BID_PUBLIC_KEY, + runtime_args! { + ARG_PUBLIC_KEY => VALIDATOR_1.clone(), + ARG_NEW_PUBLIC_KEY => VALIDATOR_2.clone(), + }, + ) + .build(); + builder + .exec(change_bid_public_key_request) + .expect_success() + .commit(); + + let old_key_reservations = builder + .get_bids() + .reservations_by_validator_public_key(&VALIDATOR_1); + assert!( + old_key_reservations.is_none(), + "reservation stayed under the old validator key after key rotation" + ); + + let new_key_reservations = builder + .get_bids() + .reservations_by_validator_public_key(&VALIDATOR_2) + .expect("reservation did not move to the new validator key"); + assert_eq!(new_key_reservations.len(), 1); + assert_eq!( + new_key_reservations[0].validator_public_key(), + &*VALIDATOR_2 + ); + assert_eq!( + new_key_reservations[0].delegator_kind(), + &DelegatorKind::PublicKey(DELEGATOR_1.clone()) + ); + assert_eq!( + *new_key_reservations[0].delegation_rate(), + VALIDATOR_1_RESERVATION_DELEGATION_RATE + ); +} diff --git a/executor/wasm/Cargo.toml b/executor/wasm/Cargo.toml index 698f70a837..844606dc24 100644 --- a/executor/wasm/Cargo.toml +++ b/executor/wasm/Cargo.toml @@ -16,9 +16,9 @@ casper-executor-wasm-common = { version = "0.1.3", path = "../wasm_common" } casper-executor-wasm-host = { version = "0.1.3", path = "../wasm_host" } casper-executor-wasm-interface = { version = "0.1.3", path = "../wasm_interface" } casper-executor-wasmer-backend = { version = "0.1.3", path = "../wasmer_backend" } -casper-storage = { version = "5.0.0", path = "../../storage" } -casper-types = { version = "7.0.0", path = "../../types", features = ["std"] } -casper-execution-engine = { version = "9.0.0", path = "../../execution_engine", features = [ +casper-storage = { version = "5.0.1", path = "../../storage" } +casper-types = { version = "7.1.1", path = "../../types", features = ["std"] } +casper-execution-engine = { version = "9.0.1", path = "../../execution_engine", features = [ "test-support", ] } digest = "0.10.7" diff --git a/executor/wasm/src/lib.rs b/executor/wasm/src/lib.rs index d4eaa32736..9b12010344 100644 --- a/executor/wasm/src/lib.rs +++ b/executor/wasm/src/lib.rs @@ -22,7 +22,7 @@ use casper_executor_wasm_interface::{ ExecuteError, ExecuteRequest, ExecuteRequestBuilder, ExecuteResult, ExecuteWithProviderError, ExecuteWithProviderResult, ExecutionKind, Executor, }, - ConfigBuilder, GasUsage, VMError, WasmInstance, + ConfigBuilder, GasUsage, VMError, WasmInstance, WasmPreparationError, }; use casper_executor_wasmer_backend::WasmerEngine; use casper_storage::{ @@ -696,7 +696,11 @@ impl ExecutorV2 { let executable_item = ExecutableItem::Invocation(TransactionInvocationTarget::ByHash(entity_addr.value())); let entry_point = entry_point.clone(); - let args = bytesrepr::deserialize_from_slice(input).expect("should deserialize"); + let args = bytesrepr::deserialize_from_slice(input).map_err(|_| { + ExecuteError::WasmPreparation(WasmPreparationError::Compile( + "Unexpected input deserialization error".to_string(), + )) + })?; let phase = Phase::Session; let wasm_v1_result = { diff --git a/executor/wasm_host/Cargo.toml b/executor/wasm_host/Cargo.toml index 52c5633cb2..b3fa08bb53 100644 --- a/executor/wasm_host/Cargo.toml +++ b/executor/wasm_host/Cargo.toml @@ -13,8 +13,8 @@ base16 = "0.2" bytes = "1.10" casper-executor-wasm-common = { version = "0.1.3", path = "../wasm_common" } casper-executor-wasm-interface = { version = "0.1.3", path = "../wasm_interface" } -casper-storage = { version = "5.0.0", path = "../../storage" } -casper-types = { version = "7.0.0", path = "../../types" } +casper-storage = { version = "5.0.1", path = "../../storage" } +casper-types = { version = "7.1.1", path = "../../types" } either = "1.15" num-derive = { workspace = true } num-traits = { workspace = true } diff --git a/executor/wasm_interface/Cargo.toml b/executor/wasm_interface/Cargo.toml index ef8596da01..450ea615e8 100644 --- a/executor/wasm_interface/Cargo.toml +++ b/executor/wasm_interface/Cargo.toml @@ -12,7 +12,7 @@ license = "Apache-2.0" bytes = "1.10" borsh = { version = "1.5", features = ["derive"] } casper-executor-wasm-common = { version = "0.1.3", path = "../wasm_common" } -casper-storage = { version = "5.0.0", path = "../../storage" } -casper-types = { version = "7.0.0", path = "../../types" } +casper-storage = { version = "5.0.1", path = "../../storage" } +casper-types = { version = "7.1.1", path = "../../types" } parking_lot = "0.12" thiserror = "2" diff --git a/executor/wasmer_backend/Cargo.toml b/executor/wasmer_backend/Cargo.toml index 1e248b5625..3074ad1236 100644 --- a/executor/wasmer_backend/Cargo.toml +++ b/executor/wasmer_backend/Cargo.toml @@ -13,9 +13,9 @@ bytes = "1.10" casper-executor-wasm-common = { version = "0.1.3", path = "../wasm_common" } casper-executor-wasm-interface = { version = "0.1.3", path = "../wasm_interface" } casper-executor-wasm-host = { version = "0.1.0", path = "../wasm_host" } -casper-storage = { version = "5.0.0", path = "../../storage" } +casper-storage = { version = "5.0.1", path = "../../storage" } casper-contract-sdk-sys = { version = "0.1.3", path = "../../smart_contracts/sdk_sys" } -casper-types = { version = "7.0.0", path = "../../types" } +casper-types = { version = "7.1.1", path = "../../types" } regex = "1.11" # Added rustls-webpki dependency to fix audit issue rustls-webpki = "0.103.13" diff --git a/node/Cargo.toml b/node/Cargo.toml index 1bc06b2e86..7264910fbe 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-node" -version = "2.2.0" # when updating, also update 'html_root_url' in lib.rs +version = "2.2.2" # when updating, also update 'html_root_url' in lib.rs authors = ["Ed Hastings ", "Karan Dhareshwar "] edition = "2021" description = "The Casper blockchain node" @@ -22,10 +22,10 @@ base16 = "0.2.1" base64 = "0.13.0" bincode = "1" bytes = "1.11.0" -casper-binary-port = { version = "1.1.1", path = "../binary_port" } -casper-storage = { version = "5.0.0", path = "../storage" } -casper-types = { version = "7.0.0", path = "../types", features = ["datasize", "json-schema", "std-fs-io"] } -casper-execution-engine = { version = "9.0.0", path = "../execution_engine" } +casper-binary-port = { version = "1.2.1", path = "../binary_port" } +casper-storage = { version = "5.0.1", path = "../storage" } +casper-types = { version = "7.1.1", path = "../types", features = ["datasize", "json-schema", "std-fs-io"] } +casper-execution-engine = { version = "9.0.1", path = "../execution_engine" } datasize = { version = "0.2.11", features = ["detailed", "fake_clock-types", "futures-types", "smallvec-types"] } derive_more = "0.99.7" either = { version = "1", features = ["serde"] } @@ -99,7 +99,7 @@ casper-executor-wasm-interface = { version = "0.1.3", path = "../executor/wasm_i fs_extra = "1.3.0" [dev-dependencies] -casper-binary-port = { version = "1.1.1", path = "../binary_port", features = ["testing"] } +casper-binary-port = { version = "1.2.1", path = "../binary_port", features = ["testing"] } assert-json-diff = "2.0.1" assert_matches = "1.5.0" casper-types = { path = "../types", features = ["datasize", "json-schema", "std-fs-io", "testing"] } diff --git a/node/src/components/binary_port.rs b/node/src/components/binary_port.rs index 45f1eca4fa..87cd02e106 100644 --- a/node/src/components/binary_port.rs +++ b/node/src/components/binary_port.rs @@ -502,7 +502,15 @@ where .await } GlobalStateEntityQualifier::ItemsByPrefix { key_prefix } => { - handle_get_items_by_prefix(state_identifier, key_prefix, effect_builder).await + if !config.allow_request_get_all_values { + debug!( + ?key_prefix, + "received an items-by-prefix request while the all-values feature is disabled", + ); + BinaryResponse::new_error(ErrorCode::FunctionDisabled) + } else { + handle_get_items_by_prefix(state_identifier, key_prefix, effect_builder).await + } } } } diff --git a/node/src/components/binary_port/tests.rs b/node/src/components/binary_port/tests.rs index cda56ff013..5d53192e8b 100644 --- a/node/src/components/binary_port/tests.rs +++ b/node/src/components/binary_port/tests.rs @@ -6,12 +6,13 @@ use rand::Rng; use serde::Serialize; use casper_binary_port::{ - BinaryResponse, Command, GetRequest, GlobalStateEntityQualifier, GlobalStateRequest, RecordId, + BinaryResponse, Command, GetRequest, GlobalStateEntityQualifier, GlobalStateRequest, KeyPrefix, + RecordId, }; use casper_types::{ - BlockHeader, Digest, GlobalStateIdentifier, KeyTag, PublicKey, Timestamp, Transaction, - TransactionV1, + BlockHeader, Digest, EntityAddr, GlobalStateIdentifier, KeyTag, PublicKey, Timestamp, + Transaction, TransactionV1, }; use crate::{ @@ -149,6 +150,39 @@ async fn should_return_error_for_disabled_functions() { } } +/// Regression for audit-confirmed-04: `ItemsByPrefix` must be gated behind +/// `allow_request_get_all_values` (default-deny), matching `AllItems`. Without the gate a remote +/// caller can drive the node to materialize/serialize an unbounded prefix result before the +/// outgoing message-size check rejects the response. +#[tokio::test] +async fn should_reject_items_by_prefix_when_all_values_disabled() { + let mut rng = TestRng::new(); + + let test_case = TestCase { + allow_request_get_all_values: DISABLED, + allow_request_get_trie: rng.gen(), + allow_request_speculative_exec: rng.gen(), + request_generator: Either::Left(|_| items_by_prefix_request()), + }; + + let (receiver, mut runner) = run_test_case(test_case, &mut rng).await; + + let result = tokio::select! { + result = receiver => result.expect("expected successful response"), + _ = runner.crank_until( + &mut rng, + got_contract_runtime_request, + Duration::from_secs(10), + ) => { + panic!( + "expected ItemsByPrefix to be rejected with FunctionDisabled before any \ + contract-runtime request was enqueued", + ) + } + }; + assert_eq!(result.error_code(), ErrorCode::FunctionDisabled as u16); +} + #[tokio::test] async fn should_return_empty_response_when_fetching_empty_key() { let mut rng = TestRng::new(); @@ -427,6 +461,16 @@ fn all_values_request() -> Command { )))) } +fn items_by_prefix_request() -> Command { + let state_identifier = GlobalStateIdentifier::StateRootHash(Digest::hash([1u8; 32])); + Command::Get(GetRequest::State(Box::new(GlobalStateRequest::new( + Some(state_identifier), + GlobalStateEntityQualifier::ItemsByPrefix { + key_prefix: KeyPrefix::MessagesByEntity(EntityAddr::SmartContract([7u8; 32])), + }, + )))) +} + #[cfg(test)] fn record_requests_with_empty_keys() -> Vec { let mut data = Vec::new(); diff --git a/node/src/components/block_validator.rs b/node/src/components/block_validator.rs index 289f0c7609..bb8ad4f89c 100644 --- a/node/src/components/block_validator.rs +++ b/node/src/components/block_validator.rs @@ -23,8 +23,7 @@ use tracing::{debug, error, trace, warn}; use casper_types::{ Approval, ApprovalsHash, Chainspec, EraId, FinalitySignature, FinalitySignatureId, PublicKey, - RewardedSignatures, SingleBlockRewardedSignatures, Timestamp, Transaction, TransactionHash, - TransactionId, + RewardedSignatures, SingleBlockRewardedSignatures, Timestamp, TransactionHash, TransactionId, }; use crate::{ @@ -40,7 +39,8 @@ use crate::{ }, fatal, types::{ - BlockWithMetadata, InvalidProposalError, NodeId, TransactionFootprint, ValidatorMatrix, + transaction::ProposedTransaction, BlockWithMetadata, InvalidProposalError, NodeId, + TransactionFootprint, ValidatorMatrix, }, NodeRng, }; @@ -120,7 +120,7 @@ impl BlockValidator { ) -> MaybeHandled where REv: From - + From> + + From> + From> + Send, { @@ -189,7 +189,7 @@ impl BlockValidator { ) -> Effects where REv: From - + From> + + From> + From> + From + From @@ -198,6 +198,13 @@ impl BlockValidator { debug!(sender = %request.sender, block = %request.block, "validating new proposed block"); debug_assert!(!self.validation_states.contains_key(&request.block)); + let signature_rewards_max_delay = self.chainspec.core_config.signature_rewards_max_delay; + if request.block.value().rewarded_signatures().len() > signature_rewards_max_delay as usize + { + let error = Box::new(InvalidProposalError::ExceedsSignatureMaxDelay); + return respond_invalid(error, Some(request.responder)); + } + if request.block.value().rewarded_signatures().has_some() { // The block contains cited signatures - we have to read the relevant blocks and find // out who the validators are in order to decode the signature IDs @@ -337,7 +344,7 @@ impl BlockValidator { ) -> Effects where REv: From - + From> + + From> + From> + From + Send, @@ -384,7 +391,6 @@ impl BlockValidator { ?missing_sigs, "handle_got_past_blocks_with_metadata missing_sigs" ); - self.handle_new_request_with_signatures(effect_builder, request, missing_sigs) } Err(error) => { @@ -415,7 +421,7 @@ impl BlockValidator { where REv: From + From - + From> + + From> + From> + From + Send, @@ -450,7 +456,7 @@ impl BlockValidator { ) -> Effects where REv: From - + From> + + From> + From> + From + Send, @@ -563,11 +569,11 @@ impl BlockValidator { &mut self, effect_builder: EffectBuilder, transaction_hash: TransactionHash, - result: FetchResult, + result: FetchResult, ) -> Effects where REv: From - + From> + + From> + From> + Send, { @@ -582,6 +588,7 @@ impl BlockValidator { } match result { Ok(FetchedData::FromStorage { item } | FetchedData::FromPeer { item, .. }) => { + let item = item.transaction(); let item_hash = item.hash(); if item_hash != transaction_hash { // Hard failure - change state to Invalid. @@ -598,8 +605,7 @@ impl BlockValidator { responders, ); } - let transaction_footprint = match TransactionFootprint::new(&self.chainspec, &item) - { + let transaction_footprint = match TransactionFootprint::new(&self.chainspec, item) { Ok(footprint) => footprint, Err(invalid_transaction_error) => { warn!( @@ -711,7 +717,7 @@ impl BlockValidator { ) -> Effects where REv: From - + From> + + From> + From> + Send, { @@ -822,7 +828,7 @@ fn fetch_transactions_and_signatures( ) -> Effects where REv: From - + From> + + From> + From> + Send, { @@ -836,7 +842,11 @@ where }; effects.extend( effect_builder - .fetch::(transaction_id, holder, Box::new(EmptyValidationMetadata)) + .fetch::( + transaction_id, + holder, + Box::new(EmptyValidationMetadata), + ) .event(move |result| Event::TransactionFetched { transaction_hash, result, @@ -885,7 +895,7 @@ impl Component for BlockValidator where REv: From + From - + From> + + From> + From> + From + From diff --git a/node/src/components/block_validator/event.rs b/node/src/components/block_validator/event.rs index 19365ac507..7cbe3c695a 100644 --- a/node/src/components/block_validator/event.rs +++ b/node/src/components/block_validator/event.rs @@ -1,10 +1,11 @@ use derive_more::{Display, From}; -use casper_types::{EraId, FinalitySignature, FinalitySignatureId, Transaction, TransactionHash}; +use casper_types::{EraId, FinalitySignature, FinalitySignatureId, TransactionHash}; use crate::{ - components::fetcher::FetchResult, effect::requests::BlockValidationRequest, - types::BlockWithMetadata, + components::fetcher::FetchResult, + effect::requests::BlockValidationRequest, + types::{transaction::ProposedTransaction, BlockWithMetadata}, }; #[derive(Debug, From, Display)] @@ -24,7 +25,7 @@ pub(crate) enum Event { #[display(fmt = "{} fetched", transaction_hash)] TransactionFetched { transaction_hash: TransactionHash, - result: FetchResult, + result: FetchResult, }, #[display(fmt = "{} fetched", finality_signature_id)] diff --git a/node/src/components/block_validator/tests.rs b/node/src/components/block_validator/tests.rs index d97da1f41a..3ffe397c3c 100644 --- a/node/src/components/block_validator/tests.rs +++ b/node/src/components/block_validator/tests.rs @@ -1,4 +1,4 @@ -use std::{collections::VecDeque, sync::Arc, time::Duration}; +use std::{collections::VecDeque, mem, sync::Arc, time::Duration}; use derive_more::From; use itertools::Itertools; @@ -7,8 +7,9 @@ use rand::Rng; use casper_types::{ bytesrepr::Bytes, runtime_args, system::standard_payment::ARG_AMOUNT, testing::TestRng, Block, BlockSignatures, BlockSignaturesV2, Chainspec, ChainspecRawBytes, Deploy, ExecutableDeployItem, - FinalitySignatureV2, RuntimeArgs, SecretKey, TestBlockBuilder, TimeDiff, Transaction, - TransactionHash, TransactionId, TransactionV1, TransactionV1Config, AUCTION_LANE_ID, + FinalitySignatureV2, PricingMode, RuntimeArgs, SecretKey, TestBlockBuilder, TimeDiff, + Transaction, TransactionArgs, TransactionHash, TransactionId, TransactionInvocationTarget, + TransactionRuntimeParams, TransactionV1, TransactionV1Config, AUCTION_LANE_ID, INSTALL_UPGRADE_LANE_ID, MINT_LANE_ID, U512, }; @@ -20,7 +21,9 @@ use crate::{ effect::requests::StorageRequest, reactor::{EventQueueHandle, QueueKind, Scheduler}, testing::LARGE_WASM_LANE_ID, - types::{BlockPayload, ValidatorMatrix}, + types::{ + transaction::transaction_v1_builder::TransactionV1Builder, BlockPayload, ValidatorMatrix, + }, utils::{self, Loadable}, }; @@ -31,7 +34,7 @@ enum ReactorEvent { #[from] BlockValidator(Event), #[from] - TransactionFetcher(FetcherRequest), + TransactionFetcher(FetcherRequest), #[from] FinalitySigFetcher(FetcherRequest), #[from] @@ -84,7 +87,7 @@ impl MockReactor { }) => { if let Some(transaction) = context.get_transaction(id) { let response = FetchedData::FromPeer { - item: Box::new(transaction), + item: Box::new(ProposedTransaction::new(transaction)), peer, }; responder.respond(Ok(response)).await; @@ -360,6 +363,14 @@ impl ValidationContext { self } + fn with_vm_casper_v2(mut self, enabled: bool) -> Self { + self.chainspec + .transaction_config + .runtime_config + .vm_casper_v2 = enabled; + self + } + fn with_block_gas_limit(mut self, block_gas_limit: u64) -> Self { self.chainspec.transaction_config.block_gas_limit = block_gas_limit; self @@ -409,8 +420,7 @@ impl ValidationContext { fn get_delayed_blocks(&mut self) -> Vec { let heights = self.delayed_blocks.keys().cloned().collect(); - self.past_blocks - .extend(std::mem::take(&mut self.delayed_blocks)); + self.past_blocks.extend(mem::take(&mut self.delayed_blocks)); heights } @@ -575,26 +585,46 @@ impl ValidationContext { }) } - fn proposed_block(&self, timestamp: Timestamp) -> ProposedBlock { + fn proposed_block( + &self, + timestamp: Timestamp, + include_past_delay: bool, + ) -> ProposedBlock { let rewards_window = self.chainspec.core_config.signature_rewards_max_delay; let rewarded_signatures = self .proposed_block_height .map(|proposed_block_height| { - RewardedSignatures::new( - (1..=rewards_window) - .filter_map(|height_diff| proposed_block_height.checked_sub(height_diff)) - .map(|height| { - let signing_validators = self - .signatures_to_include - .get(&height) - .cloned() - .unwrap_or_default(); - SingleBlockRewardedSignatures::from_validator_set( - &signing_validators, - self.secret_keys.keys(), - ) - }), - ) + if include_past_delay { + RewardedSignatures::new((0..proposed_block_height).map(|height| { + let signing_validators = self + .signatures_to_include + .get(&height) + .cloned() + .unwrap_or_default(); + SingleBlockRewardedSignatures::from_validator_set( + &signing_validators, + self.secret_keys.keys(), + ) + })) + } else { + RewardedSignatures::new( + (1..=rewards_window) + .filter_map(|height_diff| { + proposed_block_height.checked_sub(height_diff) + }) + .map(|height| { + let signing_validators = self + .signatures_to_include + .get(&height) + .cloned() + .unwrap_or_default(); + SingleBlockRewardedSignatures::from_validator_set( + &signing_validators, + self.secret_keys.keys(), + ) + }), + ) + } }) .unwrap_or_default(); new_proposed_block_with_cited_signatures( @@ -608,7 +638,19 @@ impl ValidationContext { } async fn proposal_is_valid(&mut self, rng: &mut TestRng, timestamp: Timestamp) -> bool { - self.validate_proposed_block(rng, timestamp).await.is_ok() + self.validate_proposed_block(rng, timestamp, false) + .await + .is_ok() + } + + async fn proposal_is_valid_with_sigs_past_delay( + &mut self, + rng: &mut TestRng, + timestamp: Timestamp, + ) -> bool { + self.validate_proposed_block(rng, timestamp, true) + .await + .is_ok() } /// Validates a block using a `BlockValidator` component, and returns the result. @@ -616,8 +658,9 @@ impl ValidationContext { &mut self, rng: &mut TestRng, timestamp: Timestamp, + include_past_delay: bool, ) -> Result<(), Box> { - let proposed_block = self.proposed_block(timestamp); + let proposed_block = self.proposed_block(timestamp, include_past_delay); // Create the reactor and component. let our_secret_key = self @@ -748,7 +791,9 @@ impl ValidationContext { for effect in effects { tokio::spawn(effect).await.unwrap(); } - return validation_result.await.unwrap(); + let result = validation_result.await; + println!("Foo: {:?}", result); + return result.unwrap(); } // Otherwise, we have more effects to handle. At this point, all the delayed blocks should @@ -1238,6 +1283,64 @@ async fn should_fail_if_unable_to_fetch_signature() { assert!(!context.proposal_is_valid(&mut rng, timestamp).await); } +#[tokio::test] +#[should_panic] +async fn should_fail_if_citing_signatures_past_delay_boundary() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + + let context = ValidationContext::new(); + let max_delay = context.chainspec.core_config.signature_rewards_max_delay; + let tip_height = 5 + max_delay; + + let context = context.with_num_validators(&mut rng, 3).with_past_blocks( + &mut rng, + 0, + tip_height, + 0.into(), + ); + + let validators = context.get_validators(); + + let mut context = context.with_signatures_for_block(0, tip_height, &validators); + + assert!( + !context + .proposal_is_valid_with_sigs_past_delay(&mut rng, timestamp) + .await + ); +} + +#[tokio::test] +async fn should_fail_if_citing_signature_past_max_delay_2() { + let mut rng = TestRng::new(); + let ttl = TimeDiff::from_millis(200); + let timestamp = Timestamp::from(1000); + let deploy = new_deploy(&mut rng, timestamp, ttl); + let transaction_v1 = new_v1_standard(&mut rng, timestamp, ttl); + let transfer = new_transfer(&mut rng, timestamp, ttl); + let transfer_v1 = new_v1_transfer(&mut rng, timestamp, ttl); + + let context = ValidationContext::new(); + let max_delay = context.chainspec.core_config.signature_rewards_max_delay; + let tip_height = 5 + max_delay; + + let context = context + .with_num_validators(&mut rng, 3) + .with_past_blocks(&mut rng, 0, tip_height, 0.into()) + .with_transactions(vec![deploy, transaction_v1]) + .with_transfers(vec![transfer, transfer_v1]) + .include_all_transactions() + .include_all_transfers(); + + let validators = context.get_validators(); + + let mut context = context + .with_signatures_for_block(0, tip_height, &validators) + .with_fetchable_signatures(0, tip_height, &validators); + assert!(!context.proposal_is_valid(&mut rng, timestamp).await); +} + #[tokio::test] async fn should_fail_if_unable_to_fetch_signature_for_block_without_transactions() { let mut rng = TestRng::new(); @@ -1285,3 +1388,311 @@ async fn should_validate_with_delayed_block() { assert!(context.proposal_is_valid(&mut rng, timestamp).await); } + +#[tokio::test] +async fn block_with_transaction_targetting_stored_vm2_contract_rejected_when_vm2_disabled() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + + let (chainspec, _) = <(Chainspec, ChainspecRawBytes)>::from_resources("local"); + assert!( + !chainspec.transaction_config.runtime_config.vm_casper_v2, + "test requires vm_casper_v2 = false" + ); + + let vm2_tx = Transaction::from( + TransactionV1Builder::new_targeting_stored( + TransactionInvocationTarget::ByHash([1u8; 32]), + "do_something", + TransactionRuntimeParams::VmCasperV2 { + transferred_value: 0, + seed: None, + }, + ) + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_transaction_args(TransactionArgs::Bytesrepr(Bytes::new())) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_num_validators(&mut rng, 1) + .with_transactions(vec![vm2_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transactions(); + + assert!( + !context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a VmCasperV2 transaction should be rejected when \ + vm_casper_v2 = false in the chainspec" + ); +} + +#[tokio::test] +async fn block_with_transaction_targetting_stored_vm2_contract_not_rejected_when_vm2_enabled() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + + let vm2_tx = Transaction::from( + TransactionV1Builder::new_targeting_stored( + TransactionInvocationTarget::ByHash([1u8; 32]), + "do_something", + TransactionRuntimeParams::VmCasperV2 { + transferred_value: 0, + seed: None, + }, + ) + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_transaction_args(TransactionArgs::Bytesrepr(Bytes::new())) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_vm_casper_v2(true) + .with_num_validators(&mut rng, 1) + .with_transactions(vec![vm2_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transactions(); + + assert!( + context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a VmCasperV2 transaction should not be rejected when \ + vm_casper_v2 = true in the chainspec" + ); +} + +#[tokio::test] +async fn block_with_session_targetting_vm2_rejected_when_vm2_disabled() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + + let (chainspec, _) = <(Chainspec, ChainspecRawBytes)>::from_resources("local"); + assert!( + !chainspec.transaction_config.runtime_config.vm_casper_v2, + "test requires vm_casper_v2 = false" + ); + + let vm2_tx = Transaction::from( + TransactionV1Builder::new_session( + false, + Bytes::from(vec![]), + TransactionRuntimeParams::VmCasperV2 { + transferred_value: 0, + seed: None, + }, + ) + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_transaction_args(TransactionArgs::Bytesrepr(Bytes::new())) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_num_validators(&mut rng, 1) + .with_transactions(vec![vm2_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transactions(); + + assert!( + !context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a VmCasperV2 transaction should be rejected when \ + vm_casper_v2 = false in the chainspec" + ); +} + +#[tokio::test] +async fn block_with_session_targetting_vm2_not_rejected_when_vm2_enabled() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + + let vm2_tx = Transaction::from( + TransactionV1Builder::new_session( + false, + Bytes::from(vec![]), + TransactionRuntimeParams::VmCasperV2 { + transferred_value: 0, + seed: None, + }, + ) + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_transaction_args(TransactionArgs::Bytesrepr(Bytes::new())) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_vm_casper_v2(true) + .with_num_validators(&mut rng, 1) + .with_transactions(vec![vm2_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transactions(); + + assert!( + context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a VmCasperV2 transaction should not be rejected when \ + vm_casper_v2 = true in the chainspec" + ); +} + +#[tokio::test] +async fn block_with_transaction_gas_price_tolerance_within_bounds_accepted() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + let target_public_key = PublicKey::random(&mut rng); + + let (chainspec, _) = <(Chainspec, ChainspecRawBytes)>::from_resources("local"); + let min_gas_price = chainspec.vacancy_config.min_gas_price; + let max_gas_price = chainspec.vacancy_config.max_gas_price; + assert!( + max_gas_price > min_gas_price, + "test requires max_gas_price > min_gas_price" + ); + let gas_price_tolerance = min_gas_price + (max_gas_price - min_gas_price) / 2; + + let transfer_tx = Transaction::from( + TransactionV1Builder::new_transfer(10_000_000_000u64, None, target_public_key, None) + .expect("must get builder") + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 10_000_000_000, + gas_price_tolerance, + standard_payment: true, + }) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_num_validators(&mut rng, 1) + .with_transfers(vec![transfer_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transfers(); + + assert!( + context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a transaction with gas_price_tolerance \ + ({gas_price_tolerance}) within [{min_gas_price}, {max_gas_price}] should not be \ + rejected" + ); +} + +#[tokio::test] +async fn block_with_transaction_gas_price_tolerance_below_min_rejected() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + let target_public_key = PublicKey::random(&mut rng); + + let (chainspec, _) = <(Chainspec, ChainspecRawBytes)>::from_resources("local"); + let min_gas_price = chainspec.vacancy_config.min_gas_price; + assert!( + min_gas_price > 0, + "test requires min_gas_price > 0 so a below-min tolerance can be constructed" + ); + let gas_price_tolerance = min_gas_price - 1; + + let transfer_tx = Transaction::from( + TransactionV1Builder::new_transfer(10_000_000_000u64, None, target_public_key, None) + .expect("must get builder") + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 10_000_000_000, + gas_price_tolerance, + standard_payment: true, + }) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_num_validators(&mut rng, 1) + .with_transfers(vec![transfer_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transfers(); + + assert!( + !context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a transaction with gas_price_tolerance \ + ({gas_price_tolerance}) below min_gas_price ({min_gas_price}) should be rejected" + ); +} + +#[tokio::test] +async fn block_with_transaction_gas_price_tolerance_above_max_rejected() { + let mut rng = TestRng::new(); + let timestamp = Timestamp::from(1000); + let ttl = TimeDiff::from_millis(30_000); + let secret_key = SecretKey::random(&mut rng); + let target_public_key = PublicKey::random(&mut rng); + + let (chainspec, _) = <(Chainspec, ChainspecRawBytes)>::from_resources("local"); + let max_gas_price = chainspec.vacancy_config.max_gas_price; + let gas_price_tolerance = max_gas_price.checked_add(1).expect( + "test requires max_gas_price < u8::MAX so an above-max tolerance can be constructed", + ); + + let transfer_tx = Transaction::from( + TransactionV1Builder::new_transfer(10_000_000_000u64, None, target_public_key, None) + .expect("must get builder") + .with_chain_name("casper-example") + .with_secret_key(&secret_key) + .with_timestamp(timestamp) + .with_ttl(ttl) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 10_000_000_000, + gas_price_tolerance, + standard_payment: true, + }) + .build() + .unwrap(), + ); + + let mut context = ValidationContext::new() + .with_num_validators(&mut rng, 1) + .with_transfers(vec![transfer_tx]) + .with_count_limits(Some(3000), Some(3000), Some(3000), Some(3000)) + .with_block_gas_limit(15_300_000_000_000) + .include_all_transfers(); + + assert!( + !context.proposal_is_valid(&mut rng, timestamp).await, + "block proposal containing a transaction with gas_price_tolerance \ + ({gas_price_tolerance}) above max_gas_price ({max_gas_price}) should be rejected" + ); +} diff --git a/node/src/components/consensus/protocols/zug.rs b/node/src/components/consensus/protocols/zug.rs index f11e180f5e..e63109762f 100644 --- a/node/src/components/consensus/protocols/zug.rs +++ b/node/src/components/consensus/protocols/zug.rs @@ -1110,6 +1110,16 @@ impl Zug { return Err(FaultySender(sender)); }; + if signed_msg.instance_id != *self.instance_id() { + warn!( + our_idx, + ?signed_msg, + %sender, + "invalid incoming message: signed instance ID does not match current instance", + ); + return Err(FaultySender(sender)); + } + if self.faults.contains_key(&validator_idx) { debug!( our_idx, @@ -1195,6 +1205,15 @@ impl Zug { ); return Err(FaultySender(sender)); }; + if signed_msg.instance_id != *self.instance_id() { + warn!( + our_idx, + ?signed_msg, + %sender, + "invalid incoming evidence: signed instance ID does not match current instance", + ); + return Err(FaultySender(sender)); + } if !signed_msg.content.contradicts(&content2) { warn!( our_idx, diff --git a/node/src/components/consensus/protocols/zug/tests.rs b/node/src/components/consensus/protocols/zug/tests.rs index a1d900fd86..8c646bc6e1 100644 --- a/node/src/components/consensus/protocols/zug/tests.rs +++ b/node/src/components/consensus/protocols/zug/tests.rs @@ -75,8 +75,18 @@ fn create_signed_message( content: Content, keypair: &Keypair, ) -> SignedMessage { - let validator_idx = validators.get_index(keypair.public_key()).unwrap(); let instance_id = ClContext::hash(INSTANCE_ID_DATA); + create_signed_message_with_instance(validators, round_id, instance_id, content, keypair) +} + +fn create_signed_message_with_instance( + validators: &Validators, + round_id: RoundId, + instance_id: ::InstanceId, + content: Content, + keypair: &Keypair, +) -> SignedMessage { + let validator_idx = validators.get_index(keypair.public_key()).unwrap(); SignedMessage::sign_new(round_id, instance_id, content, validator_idx, keypair) } @@ -354,6 +364,107 @@ fn abc_weights( (weights, validators) } +#[test] +fn rejects_nested_sync_response_signed_message_from_wrong_instance() { + let mut rng = crate::new_rng(); + let (weights, validators) = abc_weights(60, 30, 10); + let alice_idx = validators.get_index(&*ALICE_PUBLIC_KEY).unwrap(); + let mut zug = new_test_zug(weights, vec![], &[alice_idx]); + let alice_kp = Keypair::from(ALICE_SECRET_KEY.clone()); + let sender = *ALICE_NODE_ID; + let timestamp = Timestamp::from(100000); + let wrong_instance_id = ClContext::hash(&[42]); + assert_ne!(wrong_instance_id, *zug.instance_id()); + + let signed_msg = create_signed_message_with_instance( + &validators, + 0, + wrong_instance_id, + vote(false), + &alice_kp, + ); + let sync_id = zug.sent_sync_requests.create_and_register_new_id(&mut rng); + let msg = Message::SyncResponse(SyncResponse { + round_id: 0, + proposal_or_hash: None, + echo_sigs: BTreeMap::new(), + true_vote_sigs: BTreeMap::new(), + false_vote_sigs: BTreeMap::new(), + signed_messages: vec![signed_msg], + evidence: Vec::new(), + instance_id: *zug.instance_id(), + sync_id, + }); + + let outcomes = zug.handle_message( + &mut rng, + sender, + SerializedMessage::from_message(&msg), + timestamp, + ); + + assert!( + outcomes.contains(&ProtocolOutcome::Disconnect(sender)), + "expected wrong-instance signed message to disconnect the peer, got {outcomes:?}", + ); + assert!(zug + .round(0) + .is_none_or(|round| round.votes(false)[alice_idx].is_none())); +} + +#[test] +fn rejects_nested_sync_response_evidence_from_wrong_instance() { + let mut rng = crate::new_rng(); + let (weights, validators) = abc_weights(60, 30, 10); + let alice_idx = validators.get_index(&*ALICE_PUBLIC_KEY).unwrap(); + let mut zug = new_test_zug(weights, vec![], &[alice_idx]); + let alice_kp = Keypair::from(ALICE_SECRET_KEY.clone()); + let sender = *ALICE_NODE_ID; + let timestamp = Timestamp::from(100000); + let wrong_instance_id = ClContext::hash(&[42]); + assert_ne!(wrong_instance_id, *zug.instance_id()); + + let signed_msg = create_signed_message_with_instance( + &validators, + 0, + wrong_instance_id, + vote(true), + &alice_kp, + ); + let contradicting_msg = create_signed_message_with_instance( + &validators, + 0, + wrong_instance_id, + vote(false), + &alice_kp, + ); + let sync_id = zug.sent_sync_requests.create_and_register_new_id(&mut rng); + let msg = Message::SyncResponse(SyncResponse { + round_id: 0, + proposal_or_hash: None, + echo_sigs: BTreeMap::new(), + true_vote_sigs: BTreeMap::new(), + false_vote_sigs: BTreeMap::new(), + signed_messages: Vec::new(), + evidence: vec![(signed_msg, vote(false), contradicting_msg.signature)], + instance_id: *zug.instance_id(), + sync_id, + }); + + let outcomes = zug.handle_message( + &mut rng, + sender, + SerializedMessage::from_message(&msg), + timestamp, + ); + + assert!( + outcomes.contains(&ProtocolOutcome::Disconnect(sender)), + "expected wrong-instance evidence to disconnect the peer, got {outcomes:?}", + ); + assert!(!zug.faults.contains_key(&alice_idx)); +} + /// Tests the core logic of the consensus protocol, i.e. the criteria for sending votes and echoes /// and finalizing blocks. /// diff --git a/node/src/components/contract_runtime/operations.rs b/node/src/components/contract_runtime/operations.rs index 4dfb6a2d2e..cb388cc887 100644 --- a/node/src/components/contract_runtime/operations.rs +++ b/node/src/components/contract_runtime/operations.rs @@ -28,6 +28,7 @@ use casper_storage::{ StateProvider, StateReader, }, system::runtime_native::Config as NativeRuntimeConfig, + tracking_copy::TrackingCopyEntityExt, }; use casper_types::{ bytesrepr::{self, ToBytes, U32_SERIALIZED_LENGTH}, @@ -35,8 +36,8 @@ use casper_types::{ system::handle_payment::ARG_AMOUNT, BlockHash, BlockHeader, BlockTime, BlockV2, CLValue, Chainspec, ChecksumRegistry, Digest, EntityAddr, EraEndV2, EraId, FeeHandling, Gas, InvalidTransaction, InvalidTransactionV1, Key, - ProtocolVersion, PublicKey, RefundHandling, TimeDiff, Transaction, TransactionEntryPoint, - AUCTION_LANE_ID, MINT_LANE_ID, U512, + Motes, ProtocolVersion, PublicKey, RefundHandling, TimeDiff, Transaction, + TransactionEntryPoint, AUCTION_LANE_ID, MINT_LANE_ID, U512, }; use super::{ @@ -206,13 +207,11 @@ pub fn execute_finalized_block( } } - let transaction_config = &chainspec.transaction_config; - for stored_transaction in executable_block.transactions { let transaction = MetaTransaction::from_transaction( &stored_transaction, chainspec.core_config.pricing_handling, - transaction_config, + chainspec, ) .map_err(|err| BlockExecutionError::TransactionConversion(err.to_string()))?; @@ -299,36 +298,56 @@ pub fn execute_finalized_block( let is_v1_wasm = transaction.is_v1_wasm(); let is_v2_wasm = transaction.is_v2_wasm(); let refund_purse_active = is_custom_payment; - if refund_purse_active { - // if custom payment before doing any processing, initialize the initiator's main purse - // to be the refund purse for this transaction. - // NOTE: when executed, custom payment logic has the option to call set_refund_purse - // on the handle payment contract to set up a different refund purse, if desired. - let handle_refund_request = HandleRefundRequest::new( - native_runtime_config.clone(), - state_root_hash, - protocol_version, - transaction_hash, - HandleRefundMode::SetRefundPurse { - target: Box::new(initiator_addr.clone().into()), - }, - ); - let handle_refund_result = scratch_state.handle_refund(handle_refund_request); - if let Err(root_not_found) = - artifact_builder.with_set_refund_purse_result(&handle_refund_result) - { - if root_not_found { - return Err(BlockExecutionError::RootNotFound(state_root_hash)); + + // Authorize the declared initiator against the transaction's signer set *before* any + // payment / fee work happens. Otherwise a forged transaction (initiator = victim, signed + // by an unrelated key) reaches block execution, contract-runtime selects the victim as + // the standard-payment payer, the Wasm execution then fails with Authorization, and fee + // finalization charges the victim's main purse for the full declared payment amount. + let initiator_account_hash = initiator_addr.clone().account_hash(); + if initiator_account_hash != PublicKey::System.to_account_hash() { + let administrative_accounts: std::collections::BTreeSet< + casper_types::account::AccountHash, + > = chainspec + .core_config + .administrators + .iter() + .map(|pk| pk.to_account_hash()) + .collect(); + let admin_signed = !administrative_accounts.is_empty() + && administrative_accounts + .intersection(&authorization_keys) + .next() + .is_some(); + if !admin_signed { + let mut tc = match scratch_state.tracking_copy(state_root_hash) { + Ok(Some(tc)) => tc, + Ok(None) => return Err(BlockExecutionError::RootNotFound(state_root_hash)), + Err(err) => { + return Err(BlockExecutionError::BlockGlobal(format!("{:?}", err))); + } + }; + let authorized = match tc + .runtime_footprint_by_account_hash(protocol_version, initiator_account_hash) + { + Ok((_addr, footprint)) => { + footprint.can_authorize(&authorization_keys) + && footprint.can_deploy_with(&authorization_keys) + } + Err(_) => false, + }; + if !authorized { + debug!(%transaction_hash, "forged-initiator authorization failure; skipping payment/fee"); + artifact_builder.with_error_message("Authorization failure".to_string()); + artifacts.push(artifact_builder.build()); + continue; } - artifacts.push(artifact_builder.build()); - continue; // don't commit effects, move on } - state_root_hash = scratch_state - .commit_effects(state_root_hash, handle_refund_result.effects().clone())?; } { - // Ensure the initiator's main purse can cover the penalty payment before proceeding. + // Ensure the initiator's main purse can cover the penalty payment before proceeding, + // and before any effects (e.g. SetRefundPurse for custom payment) are committed. let initial_balance_result = scratch_state.balance(BalanceRequest::new( state_root_hash, protocol_version, @@ -352,6 +371,43 @@ pub fn execute_finalized_block( } } + if refund_purse_active { + // if custom payment before doing any processing, initialize the initiator's main purse + // to be the refund purse for this transaction. + // NOTE: when executed, custom payment logic has the option to call set_refund_purse + // on the handle payment contract to set up a different refund purse, if desired. + let handle_refund_request = HandleRefundRequest::new( + native_runtime_config.clone(), + state_root_hash, + protocol_version, + transaction_hash, + HandleRefundMode::SetRefundPurse { + target: Box::new(initiator_addr.clone().into()), + }, + ); + let handle_refund_result = scratch_state.handle_refund(handle_refund_request); + if let Err(root_not_found) = + artifact_builder.with_set_refund_purse_result(&handle_refund_result) + { + if root_not_found { + return Err(BlockExecutionError::RootNotFound(state_root_hash)); + } + artifacts.push(artifact_builder.build()); + continue; // don't commit effects, move on + } + state_root_hash = scratch_state + .commit_effects(state_root_hash, handle_refund_result.effects().clone())?; + } + + // For custom payment, track how much of the payment-purse balance is attributable to + // *this* transaction (success: full required amount; failure: penalty amount transferred + // from the initiator's main purse). The shared payment purse may already contain + // unrelated funds; without this cap, fee finalization treats those unrelated funds as + // available for this transaction and can settle them as proposer fee / burn / accumulate. + let mut custom_payment_unwind_amount: Option = None; + // Captured from successful custom payment so the same approved-spending-limit budget is + // not handed fresh to the session phase. + let mut custom_payment_remaining_spending_limit: Option = None; let mut balance_identifier = { if is_standard_payment { let contract_might_pay = @@ -388,9 +444,13 @@ pub fn execute_finalized_block( } else if is_custom_payment { // this is the custom payment flow // the initiating account will pay, but wants to do so with a different purse or - // in a custom way. If anything goes wrong, penalize the sender, do not execute - let custom_payment_gas_limit = - Gas::new(chainspec.transaction_config.native_transfer_minimum_motes * 5); + // in a custom way. If anything goes wrong, penalize the sender, do not execute. + // + // Custom payment execution must be bounded by the transaction's declared + // payment-limited gas budget, not an unrelated constant. Otherwise payment-phase + // Wasm can spend significantly more gas than the transaction limit while cost is + // later capped at the limit, undercharging the sender. + let custom_payment_gas_limit = artifact_builder.gas_limit(); let pay_result = match WasmV1Request::new_custom_payment( BlockInfo::new( state_root_hash, @@ -420,9 +480,18 @@ pub fn execute_finalized_block( ); if insufficient_payment_deposited || pay_result.error().is_some() { - // Charge initiator for the penalty payment amount - // the most expedient way to do this that aligns with later code - // is to transfer from the initiator's main purse to the payment purse + // Charge initiator for the failed-payment penalty. The transfer amount must + // cover the gas burned by the failed payment Wasm (capped by the transaction + // cost), with the baseline as the minimum so cheap failures still pay it. + let consumed_payment_motes = + Motes::from_gas(pay_result.consumed(), current_gas_price) + .map(|m| m.value()) + .unwrap_or(U512::zero()); + let transaction_cost = artifact_builder.actual_cost(); + let penalty_amount = consumed_payment_motes + .min(transaction_cost) + .max(baseline_motes_amount); + custom_payment_unwind_amount = Some(penalty_amount); let transfer_result = scratch_state.transfer(TransferRequest::new_indirect( native_runtime_config.clone(), state_root_hash, @@ -434,7 +503,7 @@ pub fn execute_finalized_block( None, initiator_addr.clone().into(), BalanceIdentifier::Payment, - baseline_motes_amount, + penalty_amount, None, ), )); @@ -456,7 +525,12 @@ pub fn execute_finalized_block( // commit penalty payment effects state_root_hash = scratch_state .commit_effects(state_root_hash, transfer_result.effects().clone())?; + // Failed custom payment must still record the gas the payment Wasm burned, + // so the eventual cost reflects the work the sender forced the validator to + // perform; otherwise expensive payment work that underdeposits would pay + // only the baseline penalty. artifact_builder + .with_added_consumed(pay_result.consumed()) .with_error_message(msg) .with_transfer_result(transfer_result) .map_err(|_| BlockExecutionError::RootNotFound(state_root_hash))?; @@ -466,6 +540,23 @@ pub fn execute_finalized_block( // commit successful effects state_root_hash = scratch_state .commit_effects(state_root_hash, pay_result.effects().clone())?; + // Cap fee/refund finalization to this transaction's actual deposit into the + // shared payment purse, not just the required cost. The payment purse is + // empty before custom payment (audit-082 guard), so the balance the effects + // leave is exactly this txn's contribution; using it as the unwind amount + // keeps audit-080's "don't drain unrelated balance" invariant while also + // settling any overpayment the payment Wasm pushed in past the declared cost + // (audit-156's "no funds stuck in shared payment purse" invariant). Floor at + // `cost_to_use()` so we never lose money the runtime promised to charge. + let actual_deposit = pay_result + .balance_after_effects(payment_balance_addr) + .unwrap_or_else(|| artifact_builder.cost_to_use()); + custom_payment_unwind_amount = + Some(actual_deposit.max(artifact_builder.cost_to_use())); + // Carry over the *post-payment* approved-spending-limit budget so the + // following V1 session phase cannot re-spend the full transaction amount + // from the caller's main purse. + custom_payment_remaining_spending_limit = pay_result.remaining_spending_limit(); artifact_builder .with_wasm_v1_result(pay_result) .map_err(|_| BlockExecutionError::RootNotFound(state_root_hash))?; @@ -485,7 +576,18 @@ pub fn execute_finalized_block( ProofHandling::NoProofs, )); - artifact_builder.with_available(post_payment_balance_result.available_balance().copied()); + // For custom payment, the *available* the artifact builder uses to derive cost/fee must + // be bounded by the amount this transaction itself put into the shared payment purse + // (`custom_payment_unwind_amount`), not the full post-payment purse balance. Otherwise a + // failed custom payment whose penalty is smaller than the transaction's payment_limit + // can settle pre-existing payment-purse funds as its own fee. + let post_payment_available = post_payment_balance_result.available_balance().copied(); + let transaction_available = if is_custom_payment { + custom_payment_unwind_amount.or(post_payment_available) + } else { + post_payment_available + }; + artifact_builder.with_available(transaction_available); let lane_id = transaction.transaction_lane(); let allow_execution = { @@ -636,15 +738,44 @@ pub fn execute_finalized_block( artifact_builder.gas_limit(), &session_input_data, ) { - Ok(wasm_v1_request) => { + Ok(mut wasm_v1_request) => { + // For VM1 custom payment, the session phase must inherit the + // *remaining* approved-spending-limit budget from the payment phase. + // Otherwise the runtime would derive it freshly from the transaction + // `amount` arg, letting the session debit the caller main purse for + // the same amount that was already approved (and possibly spent) by + // custom payment. `RuntimeArgs::insert` appends rather than replaces, + // so we rebuild the args with `amount` taken from the post-payment + // remaining spending limit. + if let Some(remaining) = custom_payment_remaining_spending_limit { + let mut new_args = casper_types::RuntimeArgs::new(); + if new_args.insert(ARG_AMOUNT, remaining).is_ok() { + for named in wasm_v1_request.args.named_args() { + if named.name() != ARG_AMOUNT { + new_args.insert_cl_value( + named.name(), + named.cl_value().clone(), + ); + } + } + wasm_v1_request.args = new_args; + } + } trace!(%transaction_hash, ?lane_id, ?wasm_v1_request, "able to get wasm v1 request"); let wasm_v1_result = execution_engine_v1.execute(&scratch_state, wasm_v1_request); trace!(%transaction_hash, ?lane_id, ?wasm_v1_result, "able to get wasm v1 result"); - state_root_hash = scratch_state.commit_effects( - state_root_hash, - wasm_v1_result.effects().clone(), - )?; + // Only commit session effects when the Wasm execution itself + // succeeded. Otherwise (e.g. "Out of gas error" after a mint + // `burn` call) the failed transaction would leave its + // state-changing side effects, including total-supply burns, + // applied even though the execution result reports an error. + if wasm_v1_result.error().is_none() { + state_root_hash = scratch_state.commit_effects( + state_root_hash, + wasm_v1_result.effects().clone(), + )?; + } // note: consumed is scraped from wasm_v1_result along w/ other fields artifact_builder .with_wasm_v1_result(wasm_v1_result) @@ -840,9 +971,17 @@ pub fn execute_finalized_block( }; artifact_builder.with_refund_amount(refund_amount); - // take the lower of the difference between cost - refund OR available - let fee_amount = artifact_builder - .cost_to_use() + // For custom payment, fee finalization must consume whatever amount this transaction put + // into the shared payment purse after refund processing. Otherwise a payment Wasm that + // deposits more than the declared cost leaves the excess stranded in the payment purse. + let fee_basis = if is_custom_payment { + custom_payment_unwind_amount.unwrap_or_else(|| artifact_builder.cost_to_use()) + } else { + artifact_builder.cost_to_use() + }; + + // take the lower of the difference between the fee basis - refund OR available + let fee_amount = fee_basis .saturating_sub(refund_amount) .min(artifact_builder.available().unwrap_or(U512::zero())); @@ -1343,11 +1482,10 @@ pub(super) fn speculatively_execute( where S: StateProvider, { - let transaction_config = &chainspec.transaction_config; let maybe_transaction = MetaTransaction::from_transaction( &input_transaction, chainspec.core_config.pricing_handling, - transaction_config, + chainspec, ); if let Err(error) = maybe_transaction { return SpeculativeExecutionResult::invalid_transaction(error); diff --git a/node/src/components/contract_runtime/rewards.rs b/node/src/components/contract_runtime/rewards.rs index a8cd4a1f8b..ef5e0dba06 100644 --- a/node/src/components/contract_runtime/rewards.rs +++ b/node/src/components/contract_runtime/rewards.rs @@ -138,7 +138,7 @@ impl RewardsInfo { // We do not attempt to reward blocks from before an upgrade! previous_era_switch_block_header.height() } else { - // Here we do not substract 1, because we want one block more: + // Here we do not subtract 1, because we want one block more: previous_era_switch_block_header .height() .saturating_sub(signature_rewards_max_delay) diff --git a/node/src/components/contract_runtime/types.rs b/node/src/components/contract_runtime/types.rs index 5af037dc8f..2eab08457e 100644 --- a/node/src/components/contract_runtime/types.rs +++ b/node/src/components/contract_runtime/types.rs @@ -240,7 +240,7 @@ impl ExecutionArtifactBuilder { base16::encode_lower(&purse), minimum_amount )); - return Ok(self); + return Err(false); } } Ok(self) diff --git a/node/src/components/fetcher/event.rs b/node/src/components/fetcher/event.rs index 5401c79a22..17f22cd74e 100644 --- a/node/src/components/fetcher/event.rs +++ b/node/src/components/fetcher/event.rs @@ -74,6 +74,7 @@ impl From for Event { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + .. } => Event::GotRemotely { item: Box::new((*transaction).clone()), source, diff --git a/node/src/components/fetcher/fetcher_impls.rs b/node/src/components/fetcher/fetcher_impls.rs index 668c9c74ed..db7cb62eed 100644 --- a/node/src/components/fetcher/fetcher_impls.rs +++ b/node/src/components/fetcher/fetcher_impls.rs @@ -4,6 +4,7 @@ mod block_fetcher; mod block_header_fetcher; mod finality_signature_fetcher; mod legacy_deploy_fetcher; +mod proposed_transaction; mod sync_leap_fetcher; mod transaction_fetcher; mod trie_or_chunk_fetcher; diff --git a/node/src/components/fetcher/fetcher_impls/proposed_transaction.rs b/node/src/components/fetcher/fetcher_impls/proposed_transaction.rs new file mode 100644 index 0000000000..897741a4fa --- /dev/null +++ b/node/src/components/fetcher/fetcher_impls/proposed_transaction.rs @@ -0,0 +1,90 @@ +use std::{collections::HashMap, time::Duration}; + +use async_trait::async_trait; +use futures::FutureExt; + +use casper_types::{InvalidTransaction, TransactionId}; + +use crate::{ + components::fetcher::{ + metrics::Metrics, EmptyValidationMetadata, FetchItem, Fetcher, ItemFetcher, ItemHandle, + StoringState, Tag, + }, + effect::{requests::StorageRequest, EffectBuilder}, + types::{transaction::ProposedTransaction, NodeId}, +}; +impl FetchItem for ProposedTransaction { + type Id = TransactionId; + type ValidationError = InvalidTransaction; + type ValidationMetadata = EmptyValidationMetadata; + + const TAG: Tag = Tag::ProposedTransaction; + + fn fetch_id(&self) -> Self::Id { + self.transaction().compute_id() + } + + fn validate(&self, _metadata: &EmptyValidationMetadata) -> Result<(), Self::ValidationError> { + self.transaction().verify() + } +} + +#[async_trait] +impl ItemFetcher for Fetcher { + const SAFE_TO_RESPOND_TO_ALL: bool = true; + + fn item_handles( + &mut self, + ) -> &mut HashMap>> { + &mut self.item_handles + } + + fn metrics(&mut self) -> &Metrics { + &self.metrics + } + + fn peer_timeout(&self) -> Duration { + self.get_from_peer_timeout + } + + async fn get_locally + Send>( + effect_builder: EffectBuilder, + id: TransactionId, + ) -> Option { + effect_builder + .get_stored_transaction(id) + .await + .map(ProposedTransaction::new) + } + + fn put_to_storage<'a, REv: From + Send>( + effect_builder: EffectBuilder, + item: ProposedTransaction, + ) -> StoringState<'a, ProposedTransaction> { + StoringState::Enqueued( + async move { + let transaction = item.transaction(); + + let is_new = effect_builder + .put_transaction_to_storage(transaction.clone()) + .await; + // If `is_new` is `false`, the transaction was previously stored, and the incoming + // transaction could have a different set of approvals to the one already stored. + // We can treat the incoming approvals as finalized and now try and store them. + if !is_new { + effect_builder + .store_finalized_approvals(transaction.hash(), transaction.approvals()) + .await; + } + } + .boxed(), + ) + } + + async fn announce_fetched_new_item( + _effect_builder: EffectBuilder, + _item: ProposedTransaction, + _peer: NodeId, + ) { + } +} diff --git a/node/src/components/fetcher/tag.rs b/node/src/components/fetcher/tag.rs index 980f0b1896..a3f7983e96 100644 --- a/node/src/components/fetcher/tag.rs +++ b/node/src/components/fetcher/tag.rs @@ -53,4 +53,7 @@ pub enum Tag { /// The execution results for a single block. #[display(fmt = "block execution results")] BlockExecutionResults, + /// A proposed transaction identified by its hash and its approvals hash. + #[display(fmt = "proposed transaction")] + ProposedTransaction, } diff --git a/node/src/components/fetcher/tests.rs b/node/src/components/fetcher/tests.rs index 4807d80a9a..5c00bd0116 100644 --- a/node/src/components/fetcher/tests.rs +++ b/node/src/components/fetcher/tests.rs @@ -43,7 +43,7 @@ use crate::{ network::{NetworkedReactor, TestingNetwork}, ConditionCheckReactor, FakeTransactionAcceptor, }, - types::NodeId, + types::{GossipedTransaction, NodeId, TransactionProvenance}, utils::WithDir, }; @@ -125,6 +125,8 @@ enum Event { #[from] GossiperIncomingTransaction(GossiperIncoming), #[from] + GossiperIncomingGossipedTransaction(GossiperIncoming), + #[from] GossiperIncomingBlock(GossiperIncoming), #[from] GossiperIncomingFinalitySignature(GossiperIncoming), @@ -229,6 +231,8 @@ impl ReactorTrait for Reactor { transaction, source: Source::Client, maybe_responder: Some(responder), + provenance: TransactionProvenance::Client, + maybe_block_hash: None, }; reactor::wrap_effects( Event::FakeTransactionAcceptor, @@ -257,6 +261,7 @@ impl ReactorTrait for Reactor { | Event::BlockAccumulatorRequest(_) | Event::BlocklistAnnouncement(_) | Event::GossiperIncomingTransaction(_) + | Event::GossiperIncomingGossipedTransaction(_) | Event::GossiperIncomingBlock(_) | Event::GossiperIncomingFinalitySignature(_) | Event::GossiperIncomingGossipedAddress(_) @@ -360,6 +365,8 @@ impl Reactor { transaction, source: Source::Peer(response.sender), maybe_responder: None, + provenance: TransactionProvenance::Gossiped, + maybe_block_hash: None, }), ) } diff --git a/node/src/components/gossiper/provider_impls.rs b/node/src/components/gossiper/provider_impls.rs index 0d73bae263..6ba7d3a8fc 100644 --- a/node/src/components/gossiper/provider_impls.rs +++ b/node/src/components/gossiper/provider_impls.rs @@ -1,3 +1,4 @@ +mod accepted_transaction_provider; mod address_provider; mod block_provider; mod finality_signature_provider; diff --git a/node/src/components/gossiper/provider_impls/accepted_transaction_provider.rs b/node/src/components/gossiper/provider_impls/accepted_transaction_provider.rs new file mode 100644 index 0000000000..73039a9660 --- /dev/null +++ b/node/src/components/gossiper/provider_impls/accepted_transaction_provider.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; + +use crate::{ + components::gossiper::{GossipItem, GossipTarget, Gossiper, ItemProvider, LargeGossipItem}, + effect::{requests::StorageRequest, EffectBuilder}, + types::{GossipedTransaction, GossipedTransactionId}, +}; + +impl GossipItem for GossipedTransaction { + type Id = GossipedTransactionId; + + const ID_IS_COMPLETE_ITEM: bool = false; + const REQUIRES_GOSSIP_RECEIVED_ANNOUNCEMENT: bool = false; + + fn gossip_id(&self) -> Self::Id { + self.accepted_id() + } + + fn gossip_target(&self) -> GossipTarget { + GossipTarget::All + } +} + +impl LargeGossipItem for GossipedTransaction {} + +#[async_trait] +impl ItemProvider + for Gossiper<{ GossipedTransaction::ID_IS_COMPLETE_ITEM }, GossipedTransaction> +{ + async fn is_stored + Send>( + effect_builder: EffectBuilder, + item_id: GossipedTransactionId, + ) -> bool { + let block_hash = item_id.block_hash(); + let transaction_id = item_id.transaction_id(); + + effect_builder.is_transaction_stored(transaction_id).await + && effect_builder.is_block_stored(block_hash).await + } + + async fn get_from_storage + Send>( + effect_builder: EffectBuilder, + item_id: GossipedTransactionId, + ) -> Option> { + let block_id = item_id.block_hash(); + + if !effect_builder.is_block_stored(block_id).await { + return None; + } + + let transaction_id = item_id.transaction_id(); + + effect_builder + .get_stored_transaction(transaction_id) + .await + .map(|txn| Box::new(GossipedTransaction::new(txn, block_id))) + } +} diff --git a/node/src/components/gossiper/tests.rs b/node/src/components/gossiper/tests.rs index 55cfbffa19..422236dc0b 100644 --- a/node/src/components/gossiper/tests.rs +++ b/node/src/components/gossiper/tests.rs @@ -18,8 +18,8 @@ use tokio::time; use tracing::debug; use casper_types::{ - testing::TestRng, BlockV2, Chainspec, ChainspecRawBytes, EraId, FinalitySignatureV2, - ProtocolVersion, TimeDiff, Transaction, TransactionConfig, + testing::TestRng, Block, BlockHash, BlockV2, Chainspec, ChainspecRawBytes, EraId, + FinalitySignatureV2, ProtocolVersion, TimeDiff, Transaction, TransactionConfig, }; use super::*; @@ -49,7 +49,7 @@ use crate::{ network::{NetworkedReactor, TestingNetwork}, ConditionCheckReactor, FakeTransactionAcceptor, }, - types::NodeId, + types::{GossipedTransaction, NodeId, TransactionProvenance}, utils::WithDir, NodeRng, }; @@ -69,7 +69,7 @@ enum Event { #[from] TransactionAcceptor(#[serde(skip_serializing)] transaction_acceptor::Event), #[from] - TransactionGossiper(super::Event), + TransactionGossiper(super::Event), #[from] NetworkRequest(NetworkRequest), #[from] @@ -79,9 +79,11 @@ enum Event { #[from] TransactionAcceptorAnnouncement(#[serde(skip_serializing)] TransactionAcceptorAnnouncement), #[from] - TransactionGossiperAnnouncement(#[serde(skip_serializing)] GossiperAnnouncement), + TransactionGossiperAnnouncement( + #[serde(skip_serializing)] GossiperAnnouncement, + ), #[from] - TransactionGossiperIncoming(GossiperIncoming), + TransactionGossiperIncoming(GossiperIncoming), } impl ReactorEvent for Event { @@ -100,6 +102,12 @@ impl From>> for Event { } } +impl From>> for Event { + fn from(request: NetworkRequest>) -> Self { + Event::NetworkRequest(request.map_payload(NodeMessage::from)) + } +} + trait Unhandled {} impl From for Event { @@ -114,6 +122,7 @@ impl Unhandled for FatalAnnouncement {} impl Unhandled for ConsensusMessageIncoming {} impl Unhandled for GossiperIncoming {} impl Unhandled for GossiperIncoming {} +impl Unhandled for GossiperIncoming {} impl Unhandled for GossiperIncoming {} impl Unhandled for NetRequestIncoming {} impl Unhandled for NetResponseIncoming {} @@ -133,7 +142,8 @@ struct Reactor { network: InMemoryNetwork, storage: Storage, fake_transaction_acceptor: FakeTransactionAcceptor, - transaction_gossiper: Gossiper<{ Transaction::ID_IS_COMPLETE_ITEM }, Transaction>, + transaction_gossiper: + Gossiper<{ GossipedTransaction::ID_IS_COMPLETE_ITEM }, GossipedTransaction>, _storage_tempdir: TempDir, } @@ -174,11 +184,12 @@ impl reactor::Reactor for Reactor { .unwrap(); let fake_transaction_acceptor = FakeTransactionAcceptor::new(); - let transaction_gossiper = Gossiper::<{ Transaction::ID_IS_COMPLETE_ITEM }, _>::new( - "transaction_gossiper", - config, - registry, - )?; + let transaction_gossiper = + Gossiper::<{ GossipedTransaction::ID_IS_COMPLETE_ITEM }, _>::new( + "transaction_gossiper", + config, + registry, + )?; let network = NetworkController::create_node(event_queue, rng); let reactor = Reactor { @@ -273,6 +284,8 @@ impl reactor::Reactor for Reactor { transaction, source: Source::Client, maybe_responder: Some(responder), + provenance: TransactionProvenance::Client, + maybe_block_hash: None, }; self.dispatch_event(effect_builder, rng, Event::TransactionAcceptor(event)) } @@ -280,12 +293,16 @@ impl reactor::Reactor for Reactor { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + provenance: _, + block_hash, }, ) => { + let accepted_transaction = + GossipedTransaction::new((*transaction).clone(), block_hash); let event = super::Event::ItemReceived { - item_id: transaction.gossip_id(), + item_id: accepted_transaction.gossip_id(), source, - target: transaction.gossip_target(), + target: accepted_transaction.gossip_target(), }; self.dispatch_event(effect_builder, rng, Event::TransactionGossiper(event)) } @@ -304,9 +321,11 @@ impl reactor::Reactor for Reactor { effect_builder, rng, transaction_acceptor::Event::Accept { - transaction: *item, + transaction: item.transaction().clone(), source: Source::Peer(sender), maybe_responder: None, + provenance: TransactionProvenance::Gossiped, + maybe_block_hash: None, }, ), ), @@ -406,7 +425,7 @@ async fn should_gossip() { async fn should_get_from_alternate_source() { const NETWORK_SIZE: usize = 3; const POLL_DURATION: Duration = Duration::from_millis(10); - const TIMEOUT: Duration = Duration::from_secs(2); + const TIMEOUT: Duration = Duration::from_secs(10); NetworkController::::create_active(); let mut network = TestingNetwork::::new(); @@ -435,13 +454,14 @@ async fn should_get_from_alternate_source() { .await; assert!(network.remove_node(&node_ids[0]).is_some()); debug!("removed node {}", &node_ids[0]); - // Run node 2 until it receives and responds to the gossip request from node 0. let node_id_0 = node_ids[0]; let sent_gossip_response = move |event: &Event| -> bool { match event { Event::NetworkRequest(NetworkRequest::SendMessage { dest, payload, .. }) => { - if let NodeMessage::TransactionGossiper(Message::GossipResponse { .. }) = **payload + if let NodeMessage::GossipedTransactionGossiper(Message::GossipResponse { + .. + }) = **payload { **dest == node_id_0 } else { @@ -627,7 +647,19 @@ async fn should_not_gossip_old_stored_item_again() { let node_ids = network.add_nodes(rng, NETWORK_SIZE).await; let node_0 = node_ids[0]; + let fake_block = Block::example(); + let hash = fake_block.hash(); let txn = Transaction::random(rng); + let accepted_transaction = GossipedTransaction::new(txn.clone(), *hash); + + let store_block = |effect_builder: EffectBuilder| { + effect_builder + .put_block_to_storage(Arc::new(fake_block.clone())) + .ignore() + }; + network + .process_injected_effect_on(&node_0, store_block) + .await; // Store the transaction on node 0. let store_txn = |effect_builder: EffectBuilder| { @@ -642,7 +674,7 @@ async fn should_not_gossip_old_stored_item_again() { .process_injected_effect_on(&node_0, |effect_builder| { let event = Event::TransactionGossiperIncoming(GossiperIncoming { sender: node_ids[1], - message: Box::new(Message::Gossip(txn.gossip_id())), + message: Box::new(Message::Gossip(accepted_transaction.gossip_id())), }); effect_builder .into_inner() @@ -697,7 +729,10 @@ async fn should_ignore_unexpected_message(message_type: Unexpected) { let node_ids = network.add_nodes(rng, NETWORK_SIZE).await; let node_0 = node_ids[0]; - let txn = Box::new(Transaction::random(rng)); + let txn = Box::new(GossipedTransaction::new( + Transaction::random(rng), + BlockHash::default(), + )); let message = match message_type { Unexpected::Response => Message::GossipResponse { diff --git a/node/src/components/network/message.rs b/node/src/components/network/message.rs index bcdec60f3a..76e8550f89 100644 --- a/node/src/components/network/message.rs +++ b/node/src/components/network/message.rs @@ -340,6 +340,8 @@ pub(crate) enum MessageKind { BlockTransfer, /// Tries transferred, usually as part of chain syncing. TrieTransfer, + /// Accepted transactions being gossiped + GossipedTransactionGossip, /// Any other kind of payload (or missing classification). Other, } @@ -350,6 +352,7 @@ impl Display for MessageKind { MessageKind::Protocol => f.write_str("protocol"), MessageKind::Consensus => f.write_str("consensus"), MessageKind::TransactionGossip => f.write_str("transaction_gossip"), + MessageKind::GossipedTransactionGossip => f.write_str("gossiped_transaction_gossip"), MessageKind::BlockGossip => f.write_str("block_gossip"), MessageKind::FinalitySignatureGossip => f.write_str("finality_signature_gossip"), MessageKind::AddressGossip => f.write_str("address_gossip"), diff --git a/node/src/components/network/metrics.rs b/node/src/components/network/metrics.rs index 3f296f7a86..5e5c215b05 100644 --- a/node/src/components/network/metrics.rs +++ b/node/src/components/network/metrics.rs @@ -24,6 +24,8 @@ pub(super) struct Metrics { pub(super) out_count_consensus: IntCounter, /// Count of outgoing messages with deploy gossiper payload. pub(super) out_count_deploy_gossip: IntCounter, + /// Count of outgoing messages with deploy gossiper payload. + pub(super) out_count_accepted_transaction_gossip: IntCounter, pub(super) out_count_block_gossip: IntCounter, pub(super) out_count_finality_signature_gossip: IntCounter, /// Count of outgoing messages with address gossiper payload. @@ -43,6 +45,8 @@ pub(super) struct Metrics { pub(super) out_bytes_consensus: IntCounter, /// Volume in bytes of outgoing messages with deploy gossiper payload. pub(super) out_bytes_deploy_gossip: IntCounter, + /// Volume in bytes of outgoing messages with deploy gossiper payload. + pub(super) out_bytes_accepted_transaction_gossip: IntCounter, pub(super) out_bytes_block_gossip: IntCounter, pub(super) out_bytes_finality_signature_gossip: IntCounter, /// Volume in bytes of outgoing messages with address gossiper payload. @@ -114,6 +118,10 @@ pub(super) struct Metrics { pub(super) accumulated_outgoing_limiter_delay: Counter, /// Total time spent delaying incoming traffic from non-validators due to limiter, in seconds. pub(super) accumulated_incoming_limiter_delay: Counter, + /// Volume in bytes of incoming messages with accepted transaction gossiper payload. + pub(super) in_bytes_accepted_transaction_gossip: IntCounter, + /// Count of incoming messages with accepted transaction gossiper payload. + pub(super) in_count_accepted_transaction_gossip: IntCounter, /// Registry instance. registry: Registry, @@ -146,6 +154,10 @@ impl Metrics { "net_out_count_deploy_gossip", "count of outgoing messages with deploy gossiper payload", )?; + let out_count_accepted_transaction_gossip = IntCounter::new( + "net_out_count_accepted_transaction_gossip", + "count of outgoing messages with deploy gossiper payload", + )?; let out_count_block_gossip = IntCounter::new( "net_out_count_block_gossip", "count of outgoing messages with block gossiper payload", @@ -187,6 +199,10 @@ impl Metrics { "net_out_bytes_deploy_gossip", "volume in bytes of outgoing messages with deploy gossiper payload", )?; + let out_bytes_accepted_transaction_gossip = IntCounter::new( + "net_out_bytes_accepted_transaction_gossip", + "volume in bytes of outgoing messages with deploy gossiper payload", + )?; let out_bytes_block_gossip = IntCounter::new( "net_out_bytes_block_gossip", "volume in bytes of outgoing messages with block gossiper payload", @@ -249,6 +265,10 @@ impl Metrics { "net_in_count_deploy_gossip", "count of incoming messages with deploy gossiper payload", )?; + let in_count_accepted_transaction_gossip = IntCounter::new( + "net_in_count_accepted_transaction_gossip", + "count of incoming messages with deploy gossiper payload", + )?; let in_count_block_gossip = IntCounter::new( "net_in_count_block_gossip", "count of incoming messages with block gossiper payload", @@ -290,6 +310,10 @@ impl Metrics { "net_in_bytes_deploy_gossip", "volume in bytes of incoming messages with deploy gossiper payload", )?; + let in_bytes_accepted_transaction_gossip = IntCounter::new( + "net_in_bytes_accepted_transaction_gossip", + "volume in bytes of incoming messages with accepted transaction gossiper payload", + )?; let in_bytes_block_gossip = IntCounter::new( "net_in_bytes_block_gossip", "volume in bytes of incoming messages with block gossiper payload", @@ -345,6 +369,7 @@ impl Metrics { registry.register(Box::new(out_count_protocol.clone()))?; registry.register(Box::new(out_count_consensus.clone()))?; registry.register(Box::new(out_count_deploy_gossip.clone()))?; + registry.register(Box::new(out_count_accepted_transaction_gossip.clone()))?; registry.register(Box::new(out_count_block_gossip.clone()))?; registry.register(Box::new(out_count_finality_signature_gossip.clone()))?; registry.register(Box::new(out_count_address_gossip.clone()))?; @@ -356,6 +381,7 @@ impl Metrics { registry.register(Box::new(out_bytes_protocol.clone()))?; registry.register(Box::new(out_bytes_consensus.clone()))?; registry.register(Box::new(out_bytes_deploy_gossip.clone()))?; + registry.register(Box::new(out_bytes_accepted_transaction_gossip.clone()))?; registry.register(Box::new(out_bytes_block_gossip.clone()))?; registry.register(Box::new(out_bytes_finality_signature_gossip.clone()))?; registry.register(Box::new(out_bytes_address_gossip.clone()))?; @@ -373,6 +399,7 @@ impl Metrics { registry.register(Box::new(in_count_protocol.clone()))?; registry.register(Box::new(in_count_consensus.clone()))?; registry.register(Box::new(in_count_deploy_gossip.clone()))?; + registry.register(Box::new(in_count_accepted_transaction_gossip.clone()))?; registry.register(Box::new(in_count_block_gossip.clone()))?; registry.register(Box::new(in_count_finality_signature_gossip.clone()))?; registry.register(Box::new(in_count_address_gossip.clone()))?; @@ -384,6 +411,7 @@ impl Metrics { registry.register(Box::new(in_bytes_protocol.clone()))?; registry.register(Box::new(in_bytes_consensus.clone()))?; registry.register(Box::new(in_bytes_deploy_gossip.clone()))?; + registry.register(Box::new(in_bytes_accepted_transaction_gossip.clone()))?; registry.register(Box::new(in_bytes_block_gossip.clone()))?; registry.register(Box::new(in_bytes_finality_signature_gossip.clone()))?; registry.register(Box::new(in_bytes_address_gossip.clone()))?; @@ -406,6 +434,7 @@ impl Metrics { out_count_protocol, out_count_consensus, out_count_deploy_gossip, + out_count_accepted_transaction_gossip, out_count_block_gossip, out_count_finality_signature_gossip, out_count_address_gossip, @@ -416,6 +445,7 @@ impl Metrics { out_bytes_protocol, out_bytes_consensus, out_bytes_deploy_gossip, + out_bytes_accepted_transaction_gossip, out_bytes_block_gossip, out_bytes_finality_signature_gossip, out_bytes_address_gossip, @@ -452,6 +482,8 @@ impl Metrics { requests_for_trie_finished, accumulated_outgoing_limiter_delay, accumulated_incoming_limiter_delay, + in_bytes_accepted_transaction_gossip, + in_count_accepted_transaction_gossip, registry: registry.clone(), }) } @@ -472,6 +504,10 @@ impl Metrics { metrics.out_bytes_deploy_gossip.inc_by(size); metrics.out_count_deploy_gossip.inc(); } + MessageKind::GossipedTransactionGossip => { + metrics.out_bytes_accepted_transaction_gossip.inc_by(size); + metrics.out_count_accepted_transaction_gossip.inc(); + } MessageKind::BlockGossip => { metrics.out_bytes_block_gossip.inc_by(size); metrics.out_count_block_gossip.inc(); @@ -550,6 +586,10 @@ impl Metrics { metrics.in_bytes_other.inc_by(size); metrics.in_count_other.inc(); } + MessageKind::GossipedTransactionGossip => { + metrics.in_bytes_accepted_transaction_gossip.inc_by(size); + metrics.in_count_accepted_transaction_gossip.inc(); + } } } else { debug!("not recording metrics, component already shut down"); @@ -648,5 +688,11 @@ impl Drop for Metrics { unregister_metric!(self.registry, self.accumulated_outgoing_limiter_delay); unregister_metric!(self.registry, self.accumulated_incoming_limiter_delay); + + unregister_metric!(self.registry, self.in_count_accepted_transaction_gossip); + unregister_metric!(self.registry, self.in_bytes_accepted_transaction_gossip); + + unregister_metric!(self.registry, self.out_count_accepted_transaction_gossip); + unregister_metric!(self.registry, self.out_bytes_accepted_transaction_gossip); } } diff --git a/node/src/components/storage.rs b/node/src/components/storage.rs index 5d5dfcff22..156a5377c0 100644 --- a/node/src/components/storage.rs +++ b/node/src/components/storage.rs @@ -98,6 +98,7 @@ use crate::{ utils::{display_error, WithDir}, }; +use crate::types::transaction::ProposedTransaction; pub use config::Config; use disjoint_sequences::{DisjointSequences, Sequence}; pub use error::FatalStorageError; @@ -510,6 +511,20 @@ impl Storage { fetch_response, )?) } + NetRequest::ProposedTransaction(ref serialized_id) => { + let id = decode_item_id::(serialized_id)?; + let opt_item = self + .get_transaction_by_id(id)? + .map(ProposedTransaction::new); + let fetch_response = FetchResponse::from_opt(id, opt_item); + + Ok(self.update_pool_and_send( + effect_builder, + incoming.sender, + serialized_id, + fetch_response, + )?) + } NetRequest::LegacyDeploy(ref serialized_id) => { let id = decode_item_id::(serialized_id)?; let opt_item = self.get_legacy_deploy(id)?; @@ -1354,11 +1369,15 @@ impl Storage { }; let mut transactions = vec![]; - for (transaction, _) in (self + for (transaction, maybe_finalized_approvals) in (self .get_transactions_with_finalized_approvals(block.all_transactions())?) .into_iter() .flatten() { + let transaction = match maybe_finalized_approvals { + Some(finalized_approvals) => transaction.with_approvals(finalized_approvals), + None => transaction, + }; transactions.push(transaction); } diff --git a/node/src/components/storage/tests.rs b/node/src/components/storage/tests.rs index 3c0b482337..a23b4f4636 100644 --- a/node/src/components/storage/tests.rs +++ b/node/src/components/storage/tests.rs @@ -16,17 +16,21 @@ use serde::{Deserialize, Serialize}; use smallvec::smallvec; use casper_storage::block_store::{ - types::{ApprovalsHashes, BlockHashHeightAndEra, BlockTransfers}, + types::{ + ApprovalsHashes, BlockHashHeightAndEra, BlockTransfers, TransactionFinalizedApprovals, + }, BlockStoreProvider, BlockStoreTransaction, DataReader, DataWriter, }; use casper_types::{ execution::{Effects, ExecutionResult, ExecutionResultV2}, generate_ed25519_keypair, + global_state::TrieMerkleProof, testing::TestRng, - ApprovalsHash, AvailableBlockRange, Block, BlockHash, BlockHeader, BlockHeaderWithSignatures, - BlockSignatures, BlockSignaturesV2, BlockV2, ChainNameDigest, Chainspec, ChainspecRawBytes, - Deploy, DeployHash, Digest, EraId, ExecutionInfo, FinalitySignature, FinalitySignatureV2, Gas, - InitiatorAddr, ProtocolVersion, PublicKey, SecretKey, TestBlockBuilder, TestBlockV1Builder, + Approval, ApprovalsHash, AvailableBlockRange, Block, BlockHash, BlockHeader, + BlockHeaderWithSignatures, BlockSignatures, BlockSignaturesV2, BlockV2, CLValue, + ChainNameDigest, Chainspec, ChainspecRawBytes, ChecksumRegistry, Deploy, DeployHash, Digest, + EraId, ExecutionInfo, FinalitySignature, FinalitySignatureV2, Gas, InitiatorAddr, Key, + ProtocolVersion, PublicKey, SecretKey, StoredValue, TestBlockBuilder, TestBlockV1Builder, TimeDiff, Timestamp, Transaction, TransactionConfig, TransactionHash, TransactionV1Hash, Transfer, TransferV2, U512, }; @@ -3227,3 +3231,73 @@ fn storage_warm_up_should_ignore_old_disjoint_sequence() { 0 ); } + +/// Regression for audit-confirmed-81: `make_executable_block` must apply per-transaction +/// `TransactionFinalizedApprovals` to the executable transaction list so that the block-level +/// approvals-hash check sees the finalized approvals, not the originally stored ones. +#[test] +fn make_executable_block_applies_finalized_approvals() { + let mut harness = ComponentHarness::default(); + let mut storage = storage_fixture(&harness); + let rng = &mut harness.rng; + + // Original stored transaction. + let original_transaction = Transaction::random(rng); + let transaction_hash = original_transaction.hash(); + let original_approvals = original_transaction.approvals(); + + // Distinct finalized approval set for the same transaction hash. + let mut finalized_approvals = original_approvals.clone(); + while finalized_approvals == original_approvals { + finalized_approvals.insert(Approval::random(rng)); + } + let finalized_transaction = original_transaction + .clone() + .with_approvals(finalized_approvals.clone()); + let finalized_approvals_hash = finalized_transaction + .compute_approvals_hash() + .expect("should compute finalized approvals hash"); + + // Build a block referencing the transaction hash. + let block = TestBlockBuilder::new() + .transactions(&[finalized_transaction]) + .build_versioned(rng); + let approvals_hashes = ApprovalsHashes::new( + *block.hash(), + vec![finalized_approvals_hash], + TrieMerkleProof::new( + Key::ChecksumRegistry, + StoredValue::CLValue( + CLValue::from_t(ChecksumRegistry::new()).expect("should build checksum"), + ), + std::collections::VecDeque::new(), + ), + ); + + { + let mut txn = storage + .block_store + .checkout_rw() + .expect("should open rw txn"); + txn.write(&original_transaction) + .expect("should write transaction"); + txn.write(&block).expect("should write block"); + txn.write(&TransactionFinalizedApprovals { + transaction_hash, + finalized_approvals, + }) + .expect("should write finalized approvals"); + txn.write(&approvals_hashes) + .expect("should write approvals hashes"); + txn.commit().expect("should commit"); + } + + let result = storage + .make_executable_block(block.hash()) + .expect("make_executable_block must not fail"); + assert!( + result.is_some(), + "make_executable_block must apply finalized approvals and return an executable block, \ + not stall by returning None" + ); +} diff --git a/node/src/components/transaction_acceptor.rs b/node/src/components/transaction_acceptor.rs index 1fc6ad6010..ff78dfec34 100644 --- a/node/src/components/transaction_acceptor.rs +++ b/node/src/components/transaction_acceptor.rs @@ -7,7 +7,8 @@ mod tests; use std::{collections::BTreeSet, fmt::Debug, sync::Arc}; use casper_types::{ - contracts::ProtocolVersionMajor, ContractRuntimeTag, InvalidTransaction, InvalidTransactionV1, + contracts::ProtocolVersionMajor, BlockHash, ContractRuntimeTag, InvalidTransaction, + InvalidTransactionV1, }; use datasize::DataSize; use prometheus::Registry; @@ -30,12 +31,12 @@ use crate::{ requests::{ContractRuntimeRequest, StorageRequest}, EffectBuilder, EffectExt, Effects, Responder, }, - fatal, types::MetaTransaction, utils::Source, NodeRng, }; +use crate::types::TransactionProvenance; pub(crate) use config::Config; pub(crate) use error::{DeployParameterFailure, Error, ParameterFailure}; pub(crate) use event::{Event, EventMetadata}; @@ -111,14 +112,15 @@ impl TransactionAcceptor { input_transaction: Transaction, source: Source, maybe_responder: Option>>, + provenance: TransactionProvenance, + maybe_block_hash: Option, ) -> Effects { trace!(%source, %input_transaction, "checking transaction before accepting"); let verification_start_timestamp = Timestamp::now(); - let transaction_config = &self.chainspec.as_ref().transaction_config; let maybe_meta_transaction = MetaTransaction::from_transaction( &input_transaction, self.chainspec.as_ref().core_config.pricing_handling, - transaction_config, + self.chainspec.as_ref(), ); let meta_transaction = match maybe_meta_transaction { Ok(transaction) => transaction, @@ -140,6 +142,8 @@ impl TransactionAcceptor { source, maybe_responder, verification_start_timestamp, + provenance, + maybe_block_hash, )); if meta_transaction.is_install_or_upgrade() @@ -184,18 +188,26 @@ impl TransactionAcceptor { ); } - effect_builder - .get_highest_complete_block_header_from_storage() - .event(move |maybe_block_header| Event::GetBlockHeaderResult { - event_metadata, - maybe_block_header: maybe_block_header.map(Box::new), - }) + match event_metadata.maybe_block_hash { + Some(block_hash) => effect_builder + .get_block_header_from_storage(block_hash, true) + .event(move |maybe_block_header| Event::GetBlockHeaderResult { + event_metadata, + maybe_block_header: maybe_block_header.map(Box::new), + }), + None => effect_builder + .get_highest_complete_block_header_from_storage() + .event(move |maybe_block_header| Event::GetBlockHeaderResult { + event_metadata, + maybe_block_header: maybe_block_header.map(Box::new), + }), + } } fn handle_get_block_header_result( &mut self, effect_builder: EffectBuilder, - event_metadata: Box, + mut event_metadata: Box, maybe_block_header: Option>, ) -> Effects { let mut effects = Effects::new(); @@ -211,22 +223,22 @@ impl TransactionAcceptor { } }; - if event_metadata.source.is_client() { - let account_hash = match event_metadata.transaction.initiator_addr() { - InitiatorAddr::PublicKey(public_key) => public_key.to_account_hash(), - InitiatorAddr::AccountHash(account_hash) => account_hash, - }; - let entity_addr = EntityAddr::Account(account_hash.value()); - effect_builder - .get_addressable_entity(*block_header.state_root_hash(), entity_addr) - .event(move |result| Event::GetAddressableEntityResult { - event_metadata, - maybe_entity: result.into_option(), - block_header, - }) - } else { - self.verify_payment(effect_builder, event_metadata, block_header) + if event_metadata.maybe_block_hash.is_none() { + event_metadata.maybe_block_hash = Some(block_header.block_hash()) } + + let account_hash = match event_metadata.transaction.initiator_addr() { + InitiatorAddr::PublicKey(public_key) => public_key.to_account_hash(), + InitiatorAddr::AccountHash(account_hash) => account_hash, + }; + let entity_addr = EntityAddr::Account(account_hash.value()); + effect_builder + .get_addressable_entity(*block_header.state_root_hash(), entity_addr) + .event(move |result| Event::GetAddressableEntityResult { + event_metadata, + maybe_entity: result.into_option(), + block_header, + }) } fn handle_get_entity_result( @@ -280,15 +292,6 @@ impl TransactionAcceptor { block_header: Box, maybe_balance: Option, ) -> Effects { - if !event_metadata.source.is_client() { - // This would only happen due to programmer error and should crash the node. Balance - // checks for transactions received from a peer will cause the network to stall. - return fatal!( - effect_builder, - "Balance checks for transactions received from peers should never occur." - ) - .ignore(); - } match maybe_balance { None => { let initiator_addr = event_metadata.transaction.initiator_addr(); @@ -876,6 +879,8 @@ impl TransactionAcceptor { source, maybe_responder, verification_start_timestamp, + provenance: _, + maybe_block_hash: _, } = event_metadata; self.reject_transaction_direct( effect_builder, @@ -896,7 +901,7 @@ impl TransactionAcceptor { verification_start_timestamp: Timestamp, error: Error, ) -> Effects { - trace!(%error, transaction = %transaction, "rejected transaction"); + error!(%error, transaction = %transaction, "rejected transaction"); self.metrics.observe_rejected(verification_start_timestamp); let mut effects = Effects::new(); if let Some(responder) = maybe_responder { @@ -922,11 +927,16 @@ impl TransactionAcceptor { let mut effects = Effects::new(); if is_new { debug!(transaction = %event_metadata.transaction, "accepted transaction"); + let block_hash = event_metadata + .maybe_block_hash + .expect("must have block hash before committing to storage"); effects.extend( effect_builder .announce_new_transaction_accepted( Arc::new(event_metadata.transaction), event_metadata.source, + event_metadata.provenance, + block_hash, ) .ignore(), ); @@ -969,14 +979,22 @@ impl TransactionAcceptor { source, maybe_responder, verification_start_timestamp, + provenance, + maybe_block_hash, } = *event_metadata; debug!(%transaction, "accepted transaction"); self.metrics.observe_accepted(verification_start_timestamp); let mut effects = Effects::new(); + let block_hash = maybe_block_hash.expect("this value must be set previously in this flow"); if is_new { effects.extend( effect_builder - .announce_new_transaction_accepted(Arc::new(transaction), source) + .announce_new_transaction_accepted( + Arc::new(transaction), + source, + provenance, + block_hash, + ) .ignore(), ); } @@ -1007,7 +1025,16 @@ impl Component for TransactionAcceptor { transaction, source, maybe_responder: responder, - } => self.accept(effect_builder, transaction, source, responder), + provenance, + maybe_block_hash, + } => self.accept( + effect_builder, + transaction, + source, + responder, + provenance, + maybe_block_hash, + ), Event::GetBlockHeaderResult { event_metadata, maybe_block_header, diff --git a/node/src/components/transaction_acceptor/event.rs b/node/src/components/transaction_acceptor/event.rs index d3b443f0cc..9f9389a515 100644 --- a/node/src/components/transaction_acceptor/event.rs +++ b/node/src/components/transaction_acceptor/event.rs @@ -3,12 +3,15 @@ use std::fmt::{self, Display, Formatter}; use serde::Serialize; use casper_types::{ - contracts::ProtocolVersionMajor, AddressableEntity, AddressableEntityHash, BlockHeader, - EntityVersion, Package, PackageHash, Timestamp, Transaction, U512, + contracts::ProtocolVersionMajor, AddressableEntity, AddressableEntityHash, BlockHash, + BlockHeader, EntityVersion, Package, PackageHash, Timestamp, Transaction, U512, }; use super::{Error, Source}; -use crate::{effect::Responder, types::MetaTransaction}; +use crate::{ + effect::Responder, + types::{MetaTransaction, TransactionProvenance}, +}; /// A utility struct to hold duplicated information across events. #[derive(Debug, Serialize)] @@ -18,6 +21,8 @@ pub(crate) struct EventMetadata { pub(crate) source: Source, pub(crate) maybe_responder: Option>>, pub(crate) verification_start_timestamp: Timestamp, + pub(crate) provenance: TransactionProvenance, + pub(crate) maybe_block_hash: Option, } impl EventMetadata { @@ -27,6 +32,8 @@ impl EventMetadata { source: Source, maybe_responder: Option>>, verification_start_timestamp: Timestamp, + provenance: TransactionProvenance, + maybe_block_hash: Option, ) -> Self { EventMetadata { transaction, @@ -34,6 +41,8 @@ impl EventMetadata { source, maybe_responder, verification_start_timestamp, + provenance, + maybe_block_hash, } } } @@ -46,6 +55,8 @@ pub(crate) enum Event { Accept { transaction: Transaction, source: Source, + provenance: TransactionProvenance, + maybe_block_hash: Option, maybe_responder: Option>>, }, /// The result of the `TransactionAcceptor` putting a `Transaction` to the storage diff --git a/node/src/components/transaction_acceptor/tests.rs b/node/src/components/transaction_acceptor/tests.rs index fb979e2be7..75341aa713 100644 --- a/node/src/components/transaction_acceptor/tests.rs +++ b/node/src/components/transaction_acceptor/tests.rs @@ -250,6 +250,12 @@ enum TestScenario { ContractVersionExistance, ), VmCasperV2ByPackageHash, + // For both these scenarios, + // true means use public key + // false means use account hash + FromPeerWithSystemInitiator(bool), + FromClientWithSystemInitiator(bool), + FromPeerInsufficientBalance(TxnType), } impl TestScenario { @@ -268,7 +274,9 @@ impl TestScenario { | TestScenario::FromPeerCustomPaymentContractPackage(_) | TestScenario::FromPeerSessionContract(..) | TestScenario::FromPeerSessionContractPackage(..) - | TestScenario::InvalidFieldsFromPeer => Source::Peer(NodeId::random(rng)), + | TestScenario::InvalidFieldsFromPeer + | TestScenario::FromPeerInsufficientBalance(_) + | TestScenario::FromPeerWithSystemInitiator(_) => Source::Peer(NodeId::random(rng)), TestScenario::FromClientInvalidTransaction(_) | TestScenario::FromClientInvalidTransactionZeroPayment(_) | TestScenario::FromClientSlightlyFutureDatedTransaction(_) @@ -305,7 +313,8 @@ impl TestScenario { | TestScenario::RedelegateExceedingMaximumDelegation | TestScenario::DelegateExceedingMaximumDelegation | TestScenario::VmCasperV2ByPackageHash - | TestScenario::V1ByPackage(..) => Source::Client, + | TestScenario::V1ByPackage(..) + | TestScenario::FromClientWithSystemInitiator(_) => Source::Client, } } @@ -324,6 +333,28 @@ impl TestScenario { txn.invalidate(); Transaction::from(txn) } + TestScenario::FromPeerWithSystemInitiator(should_use_public_key) + | TestScenario::FromClientWithSystemInitiator(should_use_public_key) => { + let txn = TransactionV1::random_with_system_initiator( + rng, + *should_use_public_key, + None, + None, + ); + let cloned = txn.clone(); + if *should_use_public_key { + assert_eq!( + cloned.initiator_addr(), + &InitiatorAddr::PublicKey(PublicKey::System) + ) + } else { + assert_eq!( + cloned.initiator_addr(), + &InitiatorAddr::AccountHash(PublicKey::System.to_account_hash()) + ) + }; + Transaction::from(txn) + } TestScenario::FromClientInvalidTransactionZeroPayment(TxnType::V1) => { let txn = TransactionV1Builder::new_session( false, @@ -332,7 +363,7 @@ impl TestScenario { ) .with_pricing_mode(PricingMode::PaymentLimited { standard_payment: true, - gas_price_tolerance: 5, + gas_price_tolerance: 1, payment_amount: 0, }) .with_chain_name("casper-example") @@ -350,7 +381,7 @@ impl TestScenario { ) .with_pricing_mode(PricingMode::PaymentLimited { standard_payment: true, - gas_price_tolerance: 5, + gas_price_tolerance: 1, payment_amount: 0, }) .with_chain_name("casper-example") @@ -391,6 +422,7 @@ impl TestScenario { | TestScenario::FromPeerAccountWithInsufficientWeight(txn_type) | TestScenario::FromClientMissingAccount(txn_type) | TestScenario::FromClientInsufficientBalance(txn_type) + | TestScenario::FromPeerInsufficientBalance(txn_type) | TestScenario::FromClientValidTransaction(txn_type) | TestScenario::FromClientRepeatedValidTransaction(txn_type) | TestScenario::FromClientAccountWithInvalidAssociatedKeys(txn_type) @@ -695,7 +727,7 @@ impl TestScenario { TestScenario::InvalidPricingModeForTransactionV1 => { let payment_limited_mode_transaction = TransactionV1Builder::new_random(rng) .with_pricing_mode(PricingMode::Fixed { - gas_price_tolerance: 5, + gas_price_tolerance: 1, additional_computation_factor: 0, }) .with_chain_name("casper-example") @@ -868,9 +900,7 @@ impl TestScenario { TestScenario::FromPeerRepeatedValidTransaction(_) | TestScenario::FromPeerExpired(_) | TestScenario::FromPeerValidTransaction(_) - | TestScenario::FromPeerMissingAccount(_) // account check skipped if from peer - | TestScenario::FromPeerAccountWithInsufficientWeight(_) // account check skipped if from peer - | TestScenario::FromPeerAccountWithInvalidAssociatedKeys(_) // account check skipped if from peer + | TestScenario::BalanceCheckForDeploySentByPeer | TestScenario::FromClientRepeatedValidTransaction(_) | TestScenario::FromClientValidTransaction(_) | TestScenario::FromClientSlightlyFutureDatedTransaction(_) @@ -878,6 +908,7 @@ impl TestScenario { TestScenario::FromPeerInvalidTransaction(_) | TestScenario::FromPeerInvalidTransactionZeroPayment(_) | TestScenario::FromClientInsufficientBalance(_) + | TestScenario::FromPeerInsufficientBalance(_) | TestScenario::FromClientMissingAccount(_) | TestScenario::FromClientInvalidTransaction(_) | TestScenario::FromClientInvalidTransactionZeroPayment(_) @@ -893,7 +924,10 @@ impl TestScenario { | TestScenario::DeployWithoutTransferAmount | TestScenario::DeployWithoutTransferTarget | TestScenario::DeployWithPaymentOne - | TestScenario::BalanceCheckForDeploySentByPeer + + | TestScenario::FromPeerMissingAccount(_) // account check skipped if from peer + | TestScenario::FromPeerAccountWithInsufficientWeight(_) // account check skipped if from peer + | TestScenario::FromPeerAccountWithInvalidAssociatedKeys(_) // account check skipped if from peer | TestScenario::FromClientExpired(_) => false, TestScenario::FromPeerCustomPaymentContract(contract_scenario) | TestScenario::FromPeerSessionContract(_, contract_scenario) @@ -937,6 +971,7 @@ impl TestScenario { HashOrName::Name => true, } }, + TestScenario::FromPeerWithSystemInitiator(_) | TestScenario::FromClientWithSystemInitiator(_) => false, } } @@ -1175,6 +1210,7 @@ impl reactor::Reactor for Reactor { let motes = if matches!( self.test_scenario, TestScenario::FromClientInsufficientBalance(_) + | TestScenario::FromPeerInsufficientBalance(_) ) { baseline_amount - 1 } else { @@ -1391,6 +1427,8 @@ fn schedule_accept_transaction( transaction, source, maybe_responder: Some(responder), + provenance: TransactionProvenance::Client, + maybe_block_hash: None, }, QueueKind::Validation, ) @@ -1408,12 +1446,9 @@ fn inject_balance_check_for_peer( let txn = txn.clone(); let block = TestBlockBuilder::new().build(rng); let block_header = Box::new(block.header().clone().into()); - let meta_transaction = MetaTransaction::from_transaction( - &txn, - chainspec.core_config.pricing_handling, - &chainspec.transaction_config, - ) - .unwrap(); + let meta_transaction = + MetaTransaction::from_transaction(&txn, chainspec.core_config.pricing_handling, chainspec) + .unwrap(); |effect_builder: EffectBuilder| { let event_metadata = Box::new(EventMetadata::new( txn, @@ -1421,6 +1456,8 @@ fn inject_balance_check_for_peer( source, Some(responder), Timestamp::now(), + TransactionProvenance::Gossiped, + None, )); effect_builder .into_inner() @@ -1627,7 +1664,10 @@ async fn run_transaction_acceptor_without_timeout( // announcement with the appropriate source. TestScenario::FromPeerInvalidTransaction(_) | TestScenario::FromPeerInvalidTransactionZeroPayment(_) - | TestScenario::BalanceCheckForDeploySentByPeer + | TestScenario::FromPeerMissingAccount(_) + | TestScenario::FromPeerAccountWithInvalidAssociatedKeys(_) + | TestScenario::FromPeerAccountWithInsufficientWeight(_) + | TestScenario::FromPeerInsufficientBalance(_) | TestScenario::InvalidFieldsFromPeer => { matches!( event, @@ -1642,9 +1682,7 @@ async fn run_transaction_acceptor_without_timeout( // Check that a new and valid, transaction sent by a peer raises an // `AcceptedNewTransaction` announcement with the appropriate source. TestScenario::FromPeerValidTransaction(_) - | TestScenario::FromPeerMissingAccount(_) - | TestScenario::FromPeerAccountWithInvalidAssociatedKeys(_) - | TestScenario::FromPeerAccountWithInsufficientWeight(_) + | TestScenario::BalanceCheckForDeploySentByPeer | TestScenario::FromPeerExpired(_) => { matches!( event, @@ -1756,6 +1794,15 @@ async fn run_transaction_acceptor_without_timeout( ) ), }, + TestScenario::FromPeerWithSystemInitiator(_) + | TestScenario::FromClientWithSystemInitiator(_) => { + matches!( + event, + Event::TransactionAcceptorAnnouncement( + TransactionAcceptorAnnouncement::InvalidTransaction { .. } + ) + ) + } } }; runner @@ -1859,13 +1906,25 @@ async fn should_reject_zero_payment_transaction_v1_from_peer() { async fn should_accept_valid_deploy_from_peer_for_missing_account() { let result = run_transaction_acceptor(TestScenario::FromPeerMissingAccount(TxnType::Deploy)).await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::NoSuchAddressableEntity { .. }, + .. + }) + )) } #[tokio::test] async fn should_accept_valid_transaction_v1_from_peer_for_missing_account() { let result = run_transaction_acceptor(TestScenario::FromPeerMissingAccount(TxnType::V1)).await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::NoSuchAddressableEntity { .. }, + .. + }) + )) } #[tokio::test] @@ -1874,7 +1933,13 @@ async fn should_accept_valid_deploy_from_peer_for_account_with_invalid_associate TxnType::Deploy, )) .await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) } #[tokio::test] @@ -1883,7 +1948,13 @@ async fn should_accept_valid_transaction_v1_from_peer_for_account_with_invalid_a TxnType::V1, )) .await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) } #[tokio::test] @@ -1892,7 +1963,13 @@ async fn should_accept_valid_deploy_from_peer_for_account_with_insufficient_weig TxnType::Deploy, )) .await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InsufficientSignatureWeight, + .. + }) + )) } #[tokio::test] @@ -1901,7 +1978,13 @@ async fn should_accept_valid_transaction_v1_from_peer_for_account_with_insuffici TxnType::V1, )) .await; - assert!(result.is_ok()) + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InsufficientSignatureWeight, + .. + }) + )) } #[tokio::test] @@ -2111,6 +2194,32 @@ async fn should_reject_valid_transaction_v1_from_client_for_insufficient_balance )) } +#[tokio::test] +async fn should_reject_valid_deploy_from_peer_for_insufficient_balance() { + let result = + run_transaction_acceptor(TestScenario::FromPeerInsufficientBalance(TxnType::Deploy)).await; + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InsufficientBalance { .. }, + .. + }) + )) +} + +#[tokio::test] +async fn should_reject_valid_transaction_v1_from_peer_for_insufficient_balance() { + let result = + run_transaction_acceptor(TestScenario::FromPeerInsufficientBalance(TxnType::V1)).await; + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InsufficientBalance { .. }, + .. + }) + )) +} + #[tokio::test] async fn should_reject_valid_deploy_from_client_for_unknown_balance() { let result = run_transaction_acceptor(TestScenario::AccountWithUnknownBalance).await; @@ -2799,7 +2908,6 @@ async fn should_accept_expired_transaction_v1_from_peer() { } #[tokio::test] -#[should_panic] async fn should_panic_when_balance_checking_for_deploy_sent_by_peer() { let test_scenario = TestScenario::BalanceCheckForDeploySentByPeer; let result = run_transaction_acceptor(test_scenario).await; @@ -3065,3 +3173,59 @@ async fn should_succeed_when_asking_for_active_exact_version() { .await; assert!(result.is_ok()) } + +#[tokio::test] +async fn should_reject_txn_with_system_public_key_as_initiator_from_peer() { + let scenario = TestScenario::FromPeerWithSystemInitiator(true); + let result = run_transaction_acceptor(scenario).await; + + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) +} + +#[tokio::test] +async fn should_reject_txn_with_system_public_key_as_initiator_from_client() { + let scenario = TestScenario::FromClientWithSystemInitiator(true); + let result = run_transaction_acceptor(scenario).await; + + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) +} + +#[tokio::test] +async fn should_reject_txn_with_system_account_hash_as_initiator_from_peer() { + let scenario = TestScenario::FromPeerWithSystemInitiator(false); + let result = run_transaction_acceptor(scenario).await; + + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) +} + +#[tokio::test] +async fn should_reject_txn_with_system_account_hash_as_initiator_from_client() { + let scenario = TestScenario::FromClientWithSystemInitiator(false); + let result = run_transaction_acceptor(scenario).await; + + assert!(matches!( + result, + Err(super::Error::Parameters { + failure: ParameterFailure::InvalidAssociatedKeys, + .. + }) + )) +} diff --git a/node/src/effect.rs b/node/src/effect.rs index 6004d0efaa..06bc0dc1de 100644 --- a/node/src/effect.rs +++ b/node/src/effect.rs @@ -160,6 +160,7 @@ use crate::{ appendable_block::AppendableBlock, BlockExecutionResultsOrChunk, BlockExecutionResultsOrChunkId, BlockWithMetadata, ExecutableBlock, FinalizedBlock, InvalidProposalError, LegacyDeploy, MetaBlock, MetaBlockState, NodeId, TransactionHeader, + TransactionProvenance, }, utils::{fmt_limit::FmtLimit, SharedFlag, Source}, }; @@ -876,6 +877,8 @@ impl EffectBuilder { self, transaction: Arc, source: Source, + provenance: TransactionProvenance, + block_hash: BlockHash, ) -> impl Future where REv: From, @@ -884,6 +887,8 @@ impl EffectBuilder { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + provenance, + block_hash, }, QueueKind::Validation, ) diff --git a/node/src/effect/announcements.rs b/node/src/effect/announcements.rs index 06c5d3d3bf..1639e557e5 100644 --- a/node/src/effect/announcements.rs +++ b/node/src/effect/announcements.rs @@ -15,8 +15,8 @@ use itertools::Itertools; use serde::Serialize; use casper_types::{ - execution::Effects, Block, EraId, FinalitySignature, FinalitySignatureV2, NextUpgrade, - PublicKey, Timestamp, Transaction, TransactionHash, U512, + execution::Effects, Block, BlockHash, EraId, FinalitySignature, FinalitySignatureV2, + NextUpgrade, PublicKey, Timestamp, Transaction, TransactionHash, U512, }; use crate::{ @@ -29,7 +29,7 @@ use crate::{ }, effect::Responder, failpoints::FailpointActivation, - types::{FinalizedBlock, MetaBlock, NodeId}, + types::{FinalizedBlock, MetaBlock, NodeId, TransactionProvenance}, utils::Source, }; @@ -208,6 +208,10 @@ pub(crate) enum TransactionAcceptorAnnouncement { transaction: Arc, /// The source (peer or client) of the transaction. source: Source, + /// Is this transaction part of a proposal + provenance: TransactionProvenance, + /// The block hash for which the transaction was accepted. + block_hash: BlockHash, }, /// An invalid transaction was received. @@ -225,6 +229,7 @@ impl Display for TransactionAcceptorAnnouncement { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + .. } => write!( formatter, "accepted new transaction {} from {}", diff --git a/node/src/effect/incoming.rs b/node/src/effect/incoming.rs index 3572371d47..fee66ce508 100644 --- a/node/src/effect/incoming.rs +++ b/node/src/effect/incoming.rs @@ -98,6 +98,7 @@ pub(crate) enum NetRequest { SyncLeap(Vec), ApprovalsHashes(Vec), BlockExecutionResults(Vec), + ProposedTransaction(Vec), } impl Display for NetRequest { @@ -115,6 +116,7 @@ impl Display for NetRequest { NetRequest::BlockExecutionResults(_) => { f.write_str("request for block execution results") } + NetRequest::ProposedTransaction(_) => f.write_str("request for a proposed transaction"), } } } @@ -130,7 +132,8 @@ impl NetRequest { | NetRequest::FinalitySignature(ref id) | NetRequest::SyncLeap(ref id) | NetRequest::ApprovalsHashes(ref id) - | NetRequest::BlockExecutionResults(ref id) => id, + | NetRequest::BlockExecutionResults(ref id) + | NetRequest::ProposedTransaction(ref id) => id, }; let mut unique_id = Vec::with_capacity(id.len() + 1); unique_id.push(self.tag() as u8); @@ -150,6 +153,7 @@ impl NetRequest { NetRequest::SyncLeap(_) => Tag::SyncLeap, NetRequest::ApprovalsHashes(_) => Tag::ApprovalsHashes, NetRequest::BlockExecutionResults(_) => Tag::BlockExecutionResults, + NetRequest::ProposedTransaction(_) => Tag::ProposedTransaction, } } } @@ -167,6 +171,7 @@ pub(crate) enum NetResponse { SyncLeap(Arc<[u8]>), ApprovalsHashes(Arc<[u8]>), BlockExecutionResults(Arc<[u8]>), + ProposedTransaction(Arc<[u8]>), } // `NetResponse` uses `Arcs`, so we count all data as 0. @@ -193,6 +198,9 @@ impl Display for NetResponse { NetResponse::BlockExecutionResults(_) => { f.write_str("response for block execution results") } + NetResponse::ProposedTransaction(_) => { + f.write_str("response for a proposed transaction") + } } } } diff --git a/node/src/lib.rs b/node/src/lib.rs index 723014ce4e..970df3f33b 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -8,7 +8,7 @@ //! While the [`main`](fn.main.html) function is the central entrypoint for the node application, //! its core event loop is found inside the [reactor](reactor/index.html). -#![doc(html_root_url = "https://docs.rs/casper-node/2.2.0")] +#![doc(html_root_url = "https://docs.rs/casper-node/2.2.2")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon_48.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon.png", diff --git a/node/src/protocol.rs b/node/src/protocol.rs index 5e50a82613..91088ae369 100644 --- a/node/src/protocol.rs +++ b/node/src/protocol.rs @@ -29,7 +29,7 @@ use crate::{ }, AutoClosingResponder, EffectBuilder, }, - types::NodeId, + types::{GossipedTransaction, NodeId}, }; /// Reactor message. @@ -48,6 +48,9 @@ pub(crate) enum Message { /// Deploy gossiper component message. #[from] TransactionGossiper(gossiper::Message), + /// Deploy gossiper component message. + #[from] + GossipedTransactionGossiper(gossiper::Message), #[from] FinalitySignatureGossiper(gossiper::Message), /// Address gossiper component message. @@ -80,9 +83,12 @@ impl Payload for Message { Message::ConsensusRequest(_) => MessageKind::Consensus, Message::BlockGossiper(_) => MessageKind::BlockGossip, Message::TransactionGossiper(_) => MessageKind::TransactionGossip, + Message::GossipedTransactionGossiper(_) => MessageKind::GossipedTransactionGossip, Message::AddressGossiper(_) => MessageKind::AddressGossip, Message::GetRequest { tag, .. } | Message::GetResponse { tag, .. } => match tag { - Tag::Transaction | Tag::LegacyDeploy => MessageKind::TransactionTransfer, + Tag::Transaction | Tag::LegacyDeploy | Tag::ProposedTransaction => { + MessageKind::TransactionTransfer + } Tag::Block => MessageKind::BlockTransfer, Tag::BlockHeader => MessageKind::BlockTransfer, Tag::TrieOrChunk => MessageKind::TrieTransfer, @@ -103,6 +109,7 @@ impl Payload for Message { Message::Consensus(_) => false, Message::ConsensusRequest(_) => false, Message::TransactionGossiper(_) => false, + Message::GossipedTransactionGossiper(_) => false, Message::BlockGossiper(_) => false, Message::FinalitySignatureGossiper(_) => false, Message::AddressGossiper(_) => false, @@ -120,10 +127,11 @@ impl Payload for Message { Message::ConsensusRequest(_) => weights.consensus, Message::BlockGossiper(_) => weights.block_gossip, Message::TransactionGossiper(_) => weights.transaction_gossip, + Message::GossipedTransactionGossiper(_) => weights.transaction_gossip, Message::FinalitySignatureGossiper(_) => weights.finality_signature_gossip, Message::AddressGossiper(_) => weights.address_gossip, Message::GetRequest { tag, .. } => match tag { - Tag::Transaction => weights.transaction_requests, + Tag::Transaction | Tag::ProposedTransaction => weights.transaction_requests, Tag::LegacyDeploy => weights.legacy_deploy_requests, Tag::Block => weights.block_requests, Tag::BlockHeader => weights.block_header_requests, @@ -134,7 +142,7 @@ impl Payload for Message { Tag::BlockExecutionResults => weights.execution_results_requests, }, Message::GetResponse { tag, .. } => match tag { - Tag::Transaction => weights.transaction_responses, + Tag::Transaction | Tag::ProposedTransaction => weights.transaction_responses, Tag::LegacyDeploy => weights.legacy_deploy_responses, Tag::Block => weights.block_responses, Tag::BlockHeader => weights.block_header_responses, @@ -154,6 +162,7 @@ impl Payload for Message { Message::ConsensusRequest(_) => false, Message::BlockGossiper(_) => false, Message::TransactionGossiper(_) => false, + Message::GossipedTransactionGossiper(_) => false, Message::FinalitySignatureGossiper(_) => false, Message::AddressGossiper(_) => false, // Trie requests can deadlock between syncing nodes. @@ -198,6 +207,10 @@ impl Debug for Message { Message::ConsensusRequest(c) => f.debug_tuple("ConsensusRequest").field(&c).finish(), Message::BlockGossiper(dg) => f.debug_tuple("BlockGossiper").field(&dg).finish(), Message::TransactionGossiper(dg) => f.debug_tuple("DeployGossiper").field(&dg).finish(), + Message::GossipedTransactionGossiper(dg) => f + .debug_tuple("GossipedTransactionGossiper") + .field(&dg) + .finish(), Message::FinalitySignatureGossiper(sig) => f .debug_tuple("FinalitySignatureGossiper") .field(&sig) @@ -250,6 +263,11 @@ mod specimen_support { MessageDiscriminants::TransactionGossiper => Message::TransactionGossiper( LargestSpecimen::largest_specimen(estimator, cache), ), + MessageDiscriminants::GossipedTransactionGossiper => { + Message::GossipedTransactionGossiper(LargestSpecimen::largest_specimen( + estimator, cache, + )) + } MessageDiscriminants::FinalitySignatureGossiper => { Message::FinalitySignatureGossiper(LargestSpecimen::largest_specimen( estimator, cache, @@ -276,6 +294,9 @@ impl Display for Message { Message::ConsensusRequest(consensus) => write!(f, "ConsensusRequest({})", consensus), Message::BlockGossiper(deploy) => write!(f, "BlockGossiper::{}", deploy), Message::TransactionGossiper(txn) => write!(f, "TransactionGossiper::{}", txn), + Message::GossipedTransactionGossiper(txn) => { + write!(f, "AcceptedTransactionGossiper::{}", txn) + } Message::FinalitySignatureGossiper(sig) => { write!(f, "FinalitySignatureGossiper::{}", sig) } @@ -302,6 +323,7 @@ where + From + From> + From> + + From> + From> + From> + From @@ -332,6 +354,11 @@ where message: Box::new(message), } .into(), + Message::GossipedTransactionGossiper(message) => GossiperIncoming { + sender, + message: Box::new(message), + } + .into(), Message::FinalitySignatureGossiper(message) => GossiperIncoming { sender, message: Box::new(message), @@ -348,6 +375,11 @@ where message: Box::new(NetRequest::Transaction(serialized_id)), } .into(), + Tag::ProposedTransaction => NetRequestIncoming { + sender, + message: Box::new(NetRequest::ProposedTransaction(serialized_id)), + } + .into(), Tag::LegacyDeploy => NetRequestIncoming { sender, message: Box::new(NetRequest::LegacyDeploy(serialized_id)), @@ -398,6 +430,11 @@ where message: Box::new(NetResponse::Transaction(serialized_item)), } .into(), + Tag::ProposedTransaction => NetResponseIncoming { + sender, + message: Box::new(NetResponse::ProposedTransaction(serialized_item)), + } + .into(), Tag::LegacyDeploy => NetResponseIncoming { sender, message: Box::new(NetResponse::LegacyDeploy(serialized_item)), diff --git a/node/src/reactor.rs b/node/src/reactor.rs index 636273279f..385ebe2a51 100644 --- a/node/src/reactor.rs +++ b/node/src/reactor.rs @@ -65,7 +65,7 @@ use crate::components::ComponentState; #[cfg(test)] use casper_types::testing::TestRng; use casper_types::{ - Block, BlockHeader, Chainspec, ChainspecRawBytes, FinalitySignature, Transaction, + Block, BlockHeader, Chainspec, ChainspecRawBytes, FinalitySignature, Transaction, TransactionId, }; #[cfg(target_os = "linux")] @@ -76,7 +76,7 @@ use crate::testing::{network::NetworkedReactor, ConditionCheckReactor}; use crate::{ components::{ block_accumulator, - fetcher::{self, FetchItem}, + fetcher::{self, FetchItem, Tag}, network::{blocklist::BlocklistJustification, Identity as NetworkIdentity}, transaction_acceptor, }, @@ -86,9 +86,12 @@ use crate::{ Effect, EffectBuilder, EffectExt, Effects, }, failpoints::FailpointActivation, - types::{BlockExecutionResultsOrChunk, ExitCode, LegacyDeploy, NodeId, SyncLeap, TrieOrChunk}, + types::{ + transaction::ProposedTransaction, BlockExecutionResultsOrChunk, ExitCode, LegacyDeploy, + NodeId, SyncLeap, TransactionProvenance, TrieOrChunk, + }, unregister_metric, - utils::{self, SharedFlag, WeightedRoundRobin}, + utils::{self, SharedFlag, Source, WeightedRoundRobin}, NodeRng, TERMINATION_REQUESTED, }; use casper_storage::block_store::types::ApprovalsHashes; @@ -1049,6 +1052,31 @@ where sender, serialized_item, ), + NetResponse::ProposedTransaction(ref serialized_item) => { + match bincode::deserialize::>( + serialized_item, + ) { + Ok(fetcher::FetchResponse::Fetched(item)) => { + let transaction = item.transaction().clone(); + let acceptor_event = transaction_acceptor::Event::Accept { + transaction, + source: Source::Peer(sender), + maybe_responder: None, + provenance: TransactionProvenance::Proposed, + maybe_block_hash: None, + }; + Reactor::dispatch_event(reactor, effect_builder, rng, acceptor_event.into()) + } + Ok(_) | Err(_) => effect_builder + .announce_block_peer_with_justification( + sender, + BlocklistJustification::SentBadItem { + tag: Tag::ProposedTransaction, + }, + ) + .ignore(), + } + } NetResponse::LegacyDeploy(ref serialized_item) => handle_fetch_response::( reactor, effect_builder, diff --git a/node/src/reactor/main_reactor.rs b/node/src/reactor/main_reactor.rs index 9375fde4dd..4dfba12f77 100644 --- a/node/src/reactor/main_reactor.rs +++ b/node/src/reactor/main_reactor.rs @@ -81,7 +81,8 @@ use crate::{ EventQueueHandle, QueueKind, }, types::{ - ForwardMetaBlock, MetaBlock, MetaBlockState, SyncHandling, TrieOrChunk, ValidatorMatrix, + ForwardMetaBlock, GossipedTransaction, MetaBlock, MetaBlockState, SyncHandling, + TransactionProvenance, TrieOrChunk, ValidatorMatrix, }, utils::{Source, WithDir}, NodeRng, @@ -162,7 +163,8 @@ pub(crate) struct MainReactor { // gossiping components address_gossiper: Gossiper<{ GossipedAddress::ID_IS_COMPLETE_ITEM }, GossipedAddress>, - transaction_gossiper: Gossiper<{ Transaction::ID_IS_COMPLETE_ITEM }, Transaction>, + transaction_gossiper: + Gossiper<{ GossipedTransaction::ID_IS_COMPLETE_ITEM }, GossipedTransaction>, block_gossiper: Gossiper<{ BlockV2::ID_IS_COMPLETE_ITEM }, BlockV2>, finality_signature_gossiper: Gossiper<{ FinalitySignatureV2::ID_IS_COMPLETE_ITEM }, FinalitySignatureV2>, @@ -740,6 +742,8 @@ impl reactor::Reactor for MainReactor { transaction, source, maybe_responder: Some(responder), + provenance: TransactionProvenance::Client, + maybe_block_hash: None, }; reactor::wrap_effects( MainEvent::TransactionAcceptor, @@ -751,6 +755,8 @@ impl reactor::Reactor for MainReactor { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + provenance, + block_hash, }, ) => { let mut effects = Effects::new(); @@ -766,19 +772,24 @@ impl reactor::Reactor for MainReactor { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + provenance, + block_hash, }, ), )); } Source::Client | Source::PeerGossiped(_) => { + let accepted_transaction = + GossipedTransaction::new((*transaction).clone(), block_hash); + // we must attempt to gossip onwards effects.extend(self.dispatch_event( effect_builder, rng, - MainEvent::TransactionGossiper(gossiper::Event::ItemReceived { - item_id: transaction.gossip_id(), + MainEvent::GossipedTransactionGossiper(gossiper::Event::ItemReceived { + item_id: accepted_transaction.gossip_id(), source, - target: transaction.gossip_target(), + target: accepted_transaction.gossip_target(), }), )); // notify event stream @@ -802,16 +813,8 @@ impl reactor::Reactor for MainReactor { source: _, }, ) => Effects::new(), - MainEvent::TransactionGossiper(event) => reactor::wrap_effects( - MainEvent::TransactionGossiper, - self.transaction_gossiper - .handle_event(effect_builder, rng, event), - ), - MainEvent::TransactionGossiperIncoming(incoming) => reactor::wrap_effects( - MainEvent::TransactionGossiper, - self.transaction_gossiper - .handle_event(effect_builder, rng, incoming.into()), - ), + MainEvent::TransactionGossiper(_) => Effects::new(), + MainEvent::TransactionGossiperIncoming(_) => Effects::new(), MainEvent::TransactionGossiperAnnouncement(GossiperAnnouncement::GossipReceived { .. }) => { @@ -825,25 +828,64 @@ impl reactor::Reactor for MainReactor { Effects::new() } MainEvent::TransactionGossiperAnnouncement(GossiperAnnouncement::NewItemBody { - item, - sender, - }) => reactor::wrap_effects( - MainEvent::TransactionAcceptor, - self.transaction_acceptor.handle_event( - effect_builder, - rng, - transaction_acceptor::Event::Accept { - transaction: *item, - source: Source::PeerGossiped(sender), - maybe_responder: None, - }, - ), - ), + .. + }) => Effects::new(), MainEvent::TransactionGossiperAnnouncement( + GossiperAnnouncement::FinishedGossiping(_gossiped_txn_id), + ) => { + // Ignore the announcement. + Effects::new() + } + MainEvent::GossipedTransactionGossiper(event) => reactor::wrap_effects( + MainEvent::GossipedTransactionGossiper, + self.transaction_gossiper + .handle_event(effect_builder, rng, event), + ), + MainEvent::GossipedTransactionGossiperIncoming(incoming) => reactor::wrap_effects( + MainEvent::GossipedTransactionGossiper, + self.transaction_gossiper + .handle_event(effect_builder, rng, incoming.into()), + ), + MainEvent::GossipedTransactionGossiperAnnouncement( + GossiperAnnouncement::GossipReceived { .. }, + ) => { + // Ignore the announcement. + Effects::new() + } + MainEvent::GossipedTransactionGossiperAnnouncement( + GossiperAnnouncement::NewCompleteItem(gossiped_transaction_id), + ) => { + error!(%gossiped_transaction_id, "gossiper should not announce new transaction"); + Effects::new() + } + MainEvent::GossipedTransactionGossiperAnnouncement( + GossiperAnnouncement::NewItemBody { item, sender }, + ) => { + let transaction = item.transaction().clone(); + let block_hash = item.block_hash(); + + reactor::wrap_effects( + MainEvent::TransactionAcceptor, + self.transaction_acceptor.handle_event( + effect_builder, + rng, + transaction_acceptor::Event::Accept { + transaction, + source: Source::PeerGossiped(sender), + maybe_responder: None, + provenance: TransactionProvenance::Gossiped, + maybe_block_hash: Some(block_hash), + }, + ), + ) + } + MainEvent::GossipedTransactionGossiperAnnouncement( GossiperAnnouncement::FinishedGossiping(gossiped_txn_id), ) => { let reactor_event = MainEvent::TransactionBuffer( - transaction_buffer::Event::ReceiveTransactionGossiped(gossiped_txn_id), + transaction_buffer::Event::ReceiveTransactionGossiped( + gossiped_txn_id.transaction_id(), + ), ); self.dispatch_event(effect_builder, rng, reactor_event) } @@ -1049,6 +1091,8 @@ impl reactor::Reactor for MainReactor { | MainEvent::BlockFetcherRequest(..) | MainEvent::TransactionFetcher(..) | MainEvent::TransactionFetcherRequest(..) + | MainEvent::ProposedTransactionFetcher(..) + | MainEvent::ProposedTransactionFetcherRequest(..) | MainEvent::BlockHeaderFetcher(..) | MainEvent::BlockHeaderFetcherRequest(..) | MainEvent::TrieOrChunkFetcher(..) diff --git a/node/src/reactor/main_reactor/event.rs b/node/src/reactor/main_reactor/event.rs index b15f5e9046..4a7e4aac49 100644 --- a/node/src/reactor/main_reactor/event.rs +++ b/node/src/reactor/main_reactor/event.rs @@ -45,7 +45,10 @@ use crate::{ }, protocol::Message, reactor::ReactorEvent, - types::{BlockExecutionResultsOrChunk, LegacyDeploy, SyncLeap, TrieOrChunk}, + types::{ + transaction::ProposedTransaction, BlockExecutionResultsOrChunk, GossipedTransaction, + LegacyDeploy, SyncLeap, TrieOrChunk, + }, }; use casper_storage::block_store::types::ApprovalsHashes; @@ -194,6 +197,14 @@ pub(crate) enum MainEvent { #[from] TransactionGossiperAnnouncement(#[serde(skip_serializing)] GossiperAnnouncement), #[from] + GossipedTransactionGossiper(#[serde(skip_serializing)] gossiper::Event), + #[from] + GossipedTransactionGossiperIncoming(GossiperIncoming), + #[from] + GossipedTransactionGossiperAnnouncement( + #[serde(skip_serializing)] GossiperAnnouncement, + ), + #[from] TransactionBuffer(#[serde(skip_serializing)] transaction_buffer::Event), #[from] TransactionBufferAnnouncement(#[serde(skip_serializing)] TransactionBufferAnnouncement), @@ -245,6 +256,12 @@ pub(crate) enum MainEvent { UnexecutedBlockAnnouncement(UnexecutedBlockAnnouncement), #[from] NonExecutableBlockAnnouncement(NonExecutableBlockAnnouncement), + #[from] + ProposedTransactionFetcher(#[serde(skip_serializing)] fetcher::Event), + #[from] + ProposedTransactionFetcherRequest( + #[serde(skip_serializing)] FetcherRequest, + ), // Event related to figuring out validators for blocks after upgrades. GotBlockAfterUpgradeEraValidators(EraId, EraValidators, EraValidators), @@ -279,6 +296,7 @@ impl ReactorEvent for MainEvent { MainEvent::AcceptTransactionRequest(_) => "AcceptTransactionRequest", MainEvent::LegacyDeployFetcher(_) => "LegacyDeployFetcher", MainEvent::TransactionFetcher(_) => "TransactionFetcher", + MainEvent::ProposedTransactionFetcher(_) => "ProposedTransactionFetcher", MainEvent::TransactionGossiper(_) => "TransactionGossiper", MainEvent::FinalitySignatureGossiper(_) => "FinalitySignatureGossiper", MainEvent::AddressGossiper(_) => "AddressGossiper", @@ -303,6 +321,7 @@ impl ReactorEvent for MainEvent { } MainEvent::LegacyDeployFetcherRequest(_) => "LegacyDeployFetcherRequest", MainEvent::TransactionFetcherRequest(_) => "TransactionFetcherRequest", + MainEvent::ProposedTransactionFetcherRequest(_) => "ProposedTransactionFetcherRequest", MainEvent::FinalitySignatureFetcherRequest(_) => "FinalitySignatureFetcherRequest", MainEvent::SyncLeapFetcherRequest(_) => "SyncLeapFetcherRequest", MainEvent::ApprovalsHashesFetcherRequest(_) => "ApprovalsHashesFetcherRequest", @@ -364,6 +383,13 @@ impl ReactorEvent for MainEvent { } MainEvent::BinaryPort(_) => "BinaryPort", MainEvent::NonExecutableBlockAnnouncement(_) => "NonExecutableBlockAnnouncement", + MainEvent::GossipedTransactionGossiper(_) => "GossipedTransactionGossiper", + MainEvent::GossipedTransactionGossiperIncoming(_) => { + "AcceptedTransactionGossiperIncoming" + } + MainEvent::GossipedTransactionGossiperAnnouncement(_) => { + "AcceptedTransactionGossiperAnnouncement" + } } } } @@ -386,7 +412,13 @@ impl Display for MainEvent { MainEvent::AcceptTransactionRequest(req) => write!(f, "{}", req), MainEvent::LegacyDeployFetcher(event) => write!(f, "legacy deploy fetcher: {}", event), MainEvent::TransactionFetcher(event) => write!(f, "transaction fetcher: {}", event), + MainEvent::ProposedTransactionFetcher(event) => { + write!(f, "proposed transaction fetcher: {}", event) + } MainEvent::TransactionGossiper(event) => write!(f, "transaction gossiper: {}", event), + MainEvent::GossipedTransactionGossiper(event) => { + write!(f, "accepted transaction gossiper: {}", event) + } MainEvent::FinalitySignatureGossiper(event) => { write!(f, "block signature gossiper: {}", event) } @@ -463,6 +495,9 @@ impl Display for MainEvent { MainEvent::TransactionFetcherRequest(request) => { write!(f, "transaction fetcher request: {}", request) } + MainEvent::ProposedTransactionFetcherRequest(request) => { + write!(f, "proposed transaction fetcher request: {}", request) + } MainEvent::FinalitySignatureFetcherRequest(request) => { write!(f, "finality signature fetcher request: {}", request) } @@ -499,6 +534,9 @@ impl Display for MainEvent { MainEvent::TransactionGossiperAnnouncement(ann) => { write!(f, "transaction gossiper announcement: {}", ann) } + MainEvent::GossipedTransactionGossiperAnnouncement(ann) => { + write!(f, "accepted transaction gossiper announcement: {}", ann) + } MainEvent::FinalitySignatureGossiperAnnouncement(ann) => { write!(f, "block signature gossiper announcement: {}", ann) } @@ -520,6 +558,7 @@ impl Display for MainEvent { MainEvent::ConsensusMessageIncoming(inner) => Display::fmt(inner, f), MainEvent::ConsensusDemand(inner) => Display::fmt(inner, f), MainEvent::TransactionGossiperIncoming(inner) => Display::fmt(inner, f), + MainEvent::GossipedTransactionGossiperIncoming(inner) => Display::fmt(inner, f), MainEvent::FinalitySignatureGossiperIncoming(inner) => Display::fmt(inner, f), MainEvent::AddressGossiperIncoming(inner) => Display::fmt(inner, f), MainEvent::NetworkPeerRequestingData(inner) => Display::fmt(inner, f), @@ -601,6 +640,12 @@ impl From>> for MainEvent { } } +impl From>> for MainEvent { + fn from(request: NetworkRequest>) -> Self { + MainEvent::NetworkRequest(request.map_payload(Message::from)) + } +} + impl From>> for MainEvent { fn from(request: NetworkRequest>) -> Self { MainEvent::NetworkRequest(request.map_payload(Message::from)) diff --git a/node/src/reactor/main_reactor/fetchers.rs b/node/src/reactor/main_reactor/fetchers.rs index 057447294c..486f521b74 100644 --- a/node/src/reactor/main_reactor/fetchers.rs +++ b/node/src/reactor/main_reactor/fetchers.rs @@ -8,7 +8,10 @@ use crate::{ effect::{announcements::TransactionAcceptorAnnouncement, EffectBuilder, Effects}, reactor, reactor::main_reactor::MainEvent, - types::{BlockExecutionResultsOrChunk, LegacyDeploy, SyncLeap, TrieOrChunk}, + types::{ + transaction::ProposedTransaction, BlockExecutionResultsOrChunk, LegacyDeploy, SyncLeap, + TrieOrChunk, + }, utils::Source, FetcherConfig, NodeRng, }; @@ -25,6 +28,7 @@ pub(super) struct Fetchers { transaction_fetcher: Fetcher, trie_or_chunk_fetcher: Fetcher, block_execution_results_or_chunk_fetcher: Fetcher, + proposed_transaction_fetcher: Fetcher, } impl Fetchers { @@ -50,6 +54,11 @@ impl Fetchers { config, metrics_registry, )?, + proposed_transaction_fetcher: Fetcher::new( + "proposed_transaction", + config, + metrics_registry, + )?, }) } @@ -129,6 +138,16 @@ impl Fetchers { self.transaction_fetcher .handle_event(effect_builder, rng, request.into()), ), + MainEvent::ProposedTransactionFetcher(event) => reactor::wrap_effects( + MainEvent::ProposedTransactionFetcher, + self.proposed_transaction_fetcher + .handle_event(effect_builder, rng, event), + ), + MainEvent::ProposedTransactionFetcherRequest(request) => reactor::wrap_effects( + MainEvent::ProposedTransactionFetcher, + self.proposed_transaction_fetcher + .handle_event(effect_builder, rng, request.into()), + ), MainEvent::TrieOrChunkFetcher(event) => reactor::wrap_effects( MainEvent::TrieOrChunkFetcher, self.trie_or_chunk_fetcher @@ -163,18 +182,36 @@ impl Fetchers { TransactionAcceptorAnnouncement::AcceptedNewTransaction { transaction, source, + provenance, + block_hash: _, }, - ) if matches!(source, Source::Peer(..)) => reactor::wrap_effects( - MainEvent::TransactionFetcher, - self.transaction_fetcher.handle_event( - effect_builder, - rng, - fetcher::Event::GotRemotely { - item: Box::new((*transaction).clone()), - source, - }, - ), - ), + ) if matches!(source, Source::Peer(..)) => { + if !provenance.is_proposed() { + reactor::wrap_effects( + MainEvent::TransactionFetcher, + self.transaction_fetcher.handle_event( + effect_builder, + rng, + fetcher::Event::GotRemotely { + item: Box::new((*transaction).clone()), + source, + }, + ), + ) + } else { + reactor::wrap_effects( + MainEvent::ProposedTransactionFetcher, + self.proposed_transaction_fetcher.handle_event( + effect_builder, + rng, + fetcher::Event::GotRemotely { + item: Box::new(ProposedTransaction::new((*transaction).clone())), + source, + }, + ), + ) + } + } // allow non-fetcher events to fall thru _ => Effects::new(), } diff --git a/node/src/reactor/main_reactor/tests/configs_override.rs b/node/src/reactor/main_reactor/tests/configs_override.rs index 5734f1e776..fb00930dab 100644 --- a/node/src/reactor/main_reactor/tests/configs_override.rs +++ b/node/src/reactor/main_reactor/tests/configs_override.rs @@ -32,6 +32,7 @@ pub(crate) struct ConfigsOverride { pub pricing_handling_override: Option, pub allow_prepaid_override: Option, pub balance_hold_interval_override: Option, + pub baseline_motes_amount_override: Option, pub administrators: Option>, pub chain_name: Option, pub gas_hold_balance_handling: Option, @@ -67,6 +68,11 @@ impl ConfigsOverride { self } + pub(crate) fn with_baseline_motes_amount(mut self, baseline_motes_amount: u64) -> Self { + self.baseline_motes_amount_override = Some(baseline_motes_amount); + self + } + pub(crate) fn with_min_gas_price(mut self, min_gas_price: u8) -> Self { self.min_gas_price = min_gas_price; self @@ -167,6 +173,7 @@ impl Default for ConfigsOverride { pricing_handling_override: None, allow_prepaid_override: None, balance_hold_interval_override: None, + baseline_motes_amount_override: None, administrators: None, chain_name: None, gas_hold_balance_handling: None, diff --git a/node/src/reactor/main_reactor/tests/fixture.rs b/node/src/reactor/main_reactor/tests/fixture.rs index 4bbda7c74e..02030eb89e 100644 --- a/node/src/reactor/main_reactor/tests/fixture.rs +++ b/node/src/reactor/main_reactor/tests/fixture.rs @@ -45,7 +45,7 @@ use crate::{ testing::{ self, filter_reactor::FilterReactor, network::TestingNetwork, ConditionCheckReactor, }, - types::NodeId, + types::{NodeId, TransactionProvenance}, utils::{External, Loadable, Source, RESOURCES_PATH}, WithDir, }; @@ -175,6 +175,7 @@ impl TestFixture { pricing_handling_override, allow_prepaid_override, balance_hold_interval_override, + baseline_motes_amount_override, administrators, chain_name, gas_hold_balance_handling, @@ -221,6 +222,9 @@ impl TestFixture { if let Some(balance_hold_interval) = balance_hold_interval_override { chainspec.core_config.gas_hold_interval = balance_hold_interval; } + if let Some(baseline_motes_amount) = baseline_motes_amount_override { + chainspec.core_config.baseline_motes_amount = baseline_motes_amount; + } if let Some(administrators) = administrators { chainspec.core_config.administrators = administrators; } @@ -857,10 +861,23 @@ impl TestFixture { .ignore() }) .await; + + let highest_block_header = *runner + .main_reactor() + .storage + .read_highest_block() + .expect("must have block") + .hash(); + runner .process_injected_effects(|effect_builder| { effect_builder - .announce_new_transaction_accepted(Arc::new(txn.clone()), Source::Client) + .announce_new_transaction_accepted( + Arc::new(txn.clone()), + Source::Client, + TransactionProvenance::Client, + highest_block_header, + ) .ignore() }) .await; diff --git a/node/src/reactor/main_reactor/tests/network_general.rs b/node/src/reactor/main_reactor/tests/network_general.rs index 5d3e2fc5a8..1d3e3edead 100644 --- a/node/src/reactor/main_reactor/tests/network_general.rs +++ b/node/src/reactor/main_reactor/tests/network_general.rs @@ -20,7 +20,7 @@ use casper_types::{ system::{auction::BidAddr, AUCTION}, testing::TestRng, AvailableBlockRange, Deploy, Key, Peers, PublicKey, SecretKey, StoredValue, TimeDiff, - Timestamp, Transaction, + Timestamp, Transaction, U512, }; use crate::{ @@ -39,7 +39,10 @@ use crate::{ Runner, }, testing::{filter_reactor::FilterReactor, network::TestingNetwork, ConditionCheckReactor}, - types::{ExitCode, NodeId, SyncHandling}, + types::{ + transaction::transaction_v1_builder::TransactionV1Builder, ExitCode, NodeId, SyncHandling, + TransactionProvenance, + }, utils::Source, }; @@ -643,13 +646,26 @@ async fn should_store_finalized_approvals() { let bob_secret_key = Arc::clone(&fixture.node_contexts[1].secret_key); let charlie_secret_key = Arc::new(SecretKey::random(&mut fixture.rng)); // just for ordering testing purposes + let transfer_target = Arc::new(SecretKey::random(&mut fixture.rng)); + let target_public_key = PublicKey::from(&*transfer_target); + // Wait for all nodes to complete era 0. fixture.run_until_consensus_in_era(ERA_ONE, ONE_MIN).await; // Submit a transaction. - let mut transaction_alice_bob = Transaction::from( - Deploy::random_valid_native_transfer_without_deps(&mut fixture.rng), - ); + let txn = TransactionV1Builder::new_transfer( + U512::from(2_500_000_000u64), + None, + target_public_key, + None, + ) + .expect("should build") + .with_initiator_addr(alice_public_key.clone()) + .with_chain_name(fixture.chainspec.network_config.name.clone()) + .build() + .expect("must builder transaction v1"); + + let mut transaction_alice_bob = Transaction::from(txn); let mut transaction_alice_bob_charlie = transaction_alice_bob.clone(); let mut transaction_bob_alice = transaction_alice_bob.clone(); @@ -694,10 +710,23 @@ async fn should_store_finalized_approvals() { .ignore() }) .await; + + let highest_block_header = *runner + .main_reactor() + .storage + .read_highest_block() + .expect("must have block") + .hash(); + runner .process_injected_effects(|effect_builder| { effect_builder - .announce_new_transaction_accepted(Arc::new(transaction), Source::Client) + .announce_new_transaction_accepted( + Arc::new(transaction), + Source::Client, + TransactionProvenance::Client, + highest_block_header, + ) .ignore() }) .await; @@ -771,10 +800,21 @@ async fn should_update_last_progress_after_block_execution() { }) .await; + let highest_block_header = *runner + .main_reactor() + .storage + .read_highest_block() + .expect("must have block") + .hash(); runner .process_injected_effects(|eff| { - eff.announce_new_transaction_accepted(Arc::new(transaction), Source::Client) - .ignore() + eff.announce_new_transaction_accepted( + Arc::new(transaction), + Source::Client, + TransactionProvenance::Client, + highest_block_header, + ) + .ignore() }) .await; } diff --git a/node/src/reactor/main_reactor/tests/transaction_scenario.rs b/node/src/reactor/main_reactor/tests/transaction_scenario.rs index ab137befdb..feb57f3790 100644 --- a/node/src/reactor/main_reactor/tests/transaction_scenario.rs +++ b/node/src/reactor/main_reactor/tests/transaction_scenario.rs @@ -17,7 +17,7 @@ use crate::{ transaction_scenario::asertions::BalanceChange, transactions::{ invalid_wasm_txn, ALICE_PUBLIC_KEY, ALICE_SECRET_KEY, BOB_PUBLIC_KEY, BOB_SECRET_KEY, - CHARLIE_PUBLIC_KEY, MIN_GAS_PRICE, + CHARLIE_PUBLIC_KEY, CHARLIE_SECRET_KEY, MIN_GAS_PRICE, }, ONE_MIN, }, @@ -183,7 +183,7 @@ async fn erroneous_native_transfer_nofee_norefund_fixed() { .build() .unwrap(), ); - txn.sign(&ALICE_SECRET_KEY); + txn.sign(&CHARLIE_SECRET_KEY); let hash = txn.hash(); test_scenario.run(vec![txn]).await.unwrap(); test_scenario.assert(TransactionFailure::new(hash)).await; // transaction should have failed. diff --git a/node/src/reactor/main_reactor/tests/transactions.rs b/node/src/reactor/main_reactor/tests/transactions.rs index 0529709f39..83f5f8fff7 100644 --- a/node/src/reactor/main_reactor/tests/transactions.rs +++ b/node/src/reactor/main_reactor/tests/transactions.rs @@ -3,17 +3,20 @@ use crate::{ testing::LARGE_WASM_LANE_ID, types::{transaction::calculate_transaction_lane_for_transaction, MetaTransaction}, }; -use casper_storage::data_access_layer::{ - AddressableEntityRequest, BalanceIdentifier, BalanceIdentifierPurseRequest, - BalanceIdentifierPurseResult, ProofHandling, QueryRequest, QueryResult, +use casper_storage::{ + data_access_layer::{ + AddressableEntityRequest, BalanceIdentifier, BalanceIdentifierPurseRequest, + BalanceIdentifierPurseResult, ProofHandling, QueryRequest, QueryResult, + }, + global_state::state::CommitProvider, }; use casper_types::{ account::AccountHash, addressable_entity::NamedKeyAddr, runtime_args, system::mint::{ARG_AMOUNT, ARG_TARGET}, - AccessRights, AddressableEntity, Digest, EntityAddr, ExecutableDeployItem, ExecutionInfo, - TransactionRuntimeParams, URef, URefAddr, DEFAULT_TRANSFER_COST, + AccessRights, AddressableEntity, CLValue, Digest, EntityAddr, ExecutableDeployItem, + ExecutionInfo, TransactionRuntimeParams, URef, URefAddr, DEFAULT_TRANSFER_COST, }; use once_cell::sync::Lazy; use std::collections::BTreeMap; @@ -532,6 +535,86 @@ fn get_bids(fixture: &mut TestFixture, block_height: Option) -> Option) -> bool { + let (_node_id, runner) = fixture.network.nodes().iter().next().unwrap(); + let protocol_version = fixture.chainspec.protocol_version(); + let state_hash = state_root_hash_at(fixture, block_height); + let request = + BalanceIdentifierPurseRequest::new(state_hash, protocol_version, BalanceIdentifier::Refund); + + matches!( + runner + .main_reactor() + .contract_runtime() + .data_access_layer() + .balance_purse(request), + BalanceIdentifierPurseResult::Success { .. } + ) +} + +fn state_root_hash_at(fixture: &TestFixture, block_height: Option) -> Digest { + let (_node_id, runner) = fixture.network.nodes().iter().next().unwrap(); + let block_height = block_height.unwrap_or( + runner + .main_reactor() + .storage() + .highest_complete_block_height() + .expect("missing highest completed block"), + ); + let block_header = runner + .main_reactor() + .storage() + .read_block_header_by_height(block_height, true) + .expect("failure to read block header") + .expect("should have header"); + *block_header.state_root_hash() +} + +fn seed_payment_purse_balance_at_tip(fixture: &mut TestFixture, amount: U512) { + let protocol_version = fixture.chainspec.protocol_version(); + + for runner in fixture.network.runners_mut() { + let reactor = runner.reactor_mut().inner_mut().inner_mut(); + let highest_block = reactor + .storage() + .read_highest_block() + .expect("highest block should exist"); + let state_root_hash = *highest_block.state_root_hash(); + let data_access_layer = reactor.contract_runtime().data_access_layer(); + let payment_purse_addr = + match data_access_layer.balance_purse(BalanceIdentifierPurseRequest::new( + state_root_hash, + protocol_version, + BalanceIdentifier::Payment, + )) { + BalanceIdentifierPurseResult::Success { purse_addr } => purse_addr, + other => panic!("payment purse should exist: {other:?}"), + }; + + let seeded_state_root_hash = data_access_layer + .commit_values( + state_root_hash, + vec![( + Key::Balance(payment_purse_addr), + StoredValue::CLValue( + CLValue::from_t(amount).expect("seeded amount is CLValue"), + ), + )], + BTreeSet::new(), + ) + .expect("payment purse seed should commit"); + + reactor.contract_runtime.set_execution_pre_state( + crate::components::contract_runtime::ExecutionPreState::new( + highest_block.height() + 1, + seeded_state_root_hash, + *highest_block.hash(), + *highest_block.accumulated_seed(), + ), + ); + } +} + fn get_payment_purse_balance( fixture: &mut TestFixture, block_height: Option, @@ -2147,7 +2230,7 @@ async fn should_gas_hold_fee_erroneous_wasm(txn_pricing_mode: PricingMode) { let meta_transaction = MetaTransaction::from_transaction( &txn, test.chainspec().core_config.pricing_handling, - &test.chainspec().transaction_config, + test.chainspec(), ) .unwrap(); // Fixed transaction pricing. @@ -3673,13 +3756,13 @@ async fn delegate_and_undelegate_bid_transaction() { ); txn.sign(&BOB_SECRET_KEY); - let (_txn_hash, _block_height, exec_result) = test.send_transaction(txn).await; - assert!(exec_result_is_success(&exec_result)); - test.fixture .run_until_consensus_in_era(ERA_ONE, ONE_MIN) .await; + let (_txn_hash, _block_height, exec_result) = test.send_transaction(txn).await; + assert!(exec_result_is_success(&exec_result)); + let mut txn = Transaction::from( TransactionV1Builder::new_undelegate( PublicKey::from(&**BOB_SECRET_KEY), @@ -3727,6 +3810,9 @@ async fn insufficient_funds_transfer_from_account() { let mut txn = Transaction::from(txn_v1); txn.sign(&BOB_SECRET_KEY); + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; let (_txn_hash, _block_height, exec_result) = test.send_transaction(txn).await; let ExecutionResult::V2(result) = exec_result else { panic!("Expected ExecutionResult::V2 but got {:?}", exec_result); @@ -3955,7 +4041,7 @@ async fn charge_when_session_code_succeeds() { .with_chain_name(CHAIN_NAME) .with_initiator_addr(BOB_PUBLIC_KEY.clone()) .with_pricing_mode(PricingMode::Fixed { - gas_price_tolerance: 5, + gas_price_tolerance: 1, additional_computation_factor: 2, /*Makes the transaction * "Large" despite the fact that the actual * WASM bytes categorize it as "Small" */ @@ -5311,6 +5397,10 @@ async fn should_allow_native_transfer_v1() { ) .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, THIRTY_SECS) + .await; + let transfer_amount = U512::from(100); let txn_v1 = @@ -5360,6 +5450,10 @@ async fn should_allow_native_burn() { ) .await; + test.fixture + .run_until_consensus_in_era(ERA_ONE, THIRTY_SECS) + .await; + let burn_amount = U512::from(100); let txn_v1 = TransactionV1Builder::new_burn(burn_amount, None) @@ -5811,3 +5905,1063 @@ async fn should_assign_deploy_to_largest_lane_by_payment_amount_only_in_payment_ .assert_execution_in_lane(&largest_txn_hash, largest_lane_id, TEN_SECS) .await; } + +/// Regression for audit-confirmed-13: when the initial-balance precheck fails (initiator's main +/// purse below `baseline_motes_amount`), `ExecutionArtifactBuilder::with_initial_balance_result` +/// must return `Err(false)` so `execute_finalized_block` stops the transaction. Without the fix +/// the precheck records an error but execution continues into custom payment and session code, +/// the session writes a named key, and the handle-payment refund purse is left set. +#[tokio::test] +async fn failed_custom_payment_precheck_does_not_leave_refund_purse_set() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::NoFee) + .with_baseline_motes_amount(2_500_000_000); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + assert!( + !handle_payment_refund_purse_is_set(&test.fixture, None), + "refund purse should start unset" + ); + + let base_path = RESOURCES_PATH + .parent() + .unwrap() + .join("target") + .join("wasm32-unknown-unknown") + .join("release"); + + let baseline_motes = test.chainspec().core_config.baseline_motes_amount_u512(); + let custom_payment_amount = baseline_motes; + let custom_payment_purse_name = "custom_payment_purse"; + let created_session_key = "hello-world"; + + let purse_setup_contract = base_path.join("transfer_main_purse_to_new_purse.wasm"); + let module_bytes = + Bytes::from(std::fs::read(purse_setup_contract).expect("cannot read module bytes")); + let mut purse_setup_txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "destination" => custom_payment_purse_name, + "amount" => custom_payment_amount, + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 100_000_000_000u64, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + purse_setup_txn.sign(&BOB_SECRET_KEY); + let (_txn_hash, _block_height, exec_result) = test.send_transaction(purse_setup_txn).await; + assert!(exec_result_is_success(&exec_result), "{:?}", exec_result); + + let (_, bob_initial_balance, _) = test.get_balances(None); + let drain_amount = bob_initial_balance.available.saturating_sub(baseline_motes) + U512::one(); + let transfer_hold = U512::from(test.chainspec().system_costs_config.mint_costs().transfer); + + let chain_name = test.chainspec().network_config.name.clone(); + let mut drain_txn = Transaction::from( + TransactionV1Builder::new_transfer(drain_amount, None, CHARLIE_PUBLIC_KEY.clone(), None) + .unwrap() + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: transfer_hold.as_u64(), + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_chain_name(chain_name.clone()) + .build() + .unwrap(), + ); + drain_txn.sign(&BOB_SECRET_KEY); + let drain_txn_hash = drain_txn.hash(); + + let custom_payment_txn = { + let timestamp = Timestamp::now(); + let ttl = TimeDiff::from_seconds(100); + let gas_price = 1; + + let payment = ExecutableDeployItem::ModuleBytes { + module_bytes: std::fs::read(base_path.join("named_purse_payment.wasm")) + .unwrap() + .into(), + args: runtime_args! { + "amount" => custom_payment_amount, + "purse_name" => custom_payment_purse_name.to_string(), + }, + }; + + let session = ExecutableDeployItem::ModuleBytes { + module_bytes: std::fs::read(base_path.join("named_keys.wasm")) + .unwrap() + .into(), + args: runtime_args! { + "command" => "create-uref1".to_string(), + }, + }; + + Transaction::Deploy(Deploy::new_signed( + timestamp, + ttl, + gas_price, + vec![], + chain_name, + payment, + session, + &BOB_SECRET_KEY, + Some(BOB_PUBLIC_KEY.clone()), + )) + }; + let custom_payment_txn_hash = custom_payment_txn.hash(); + + test.fixture.inject_transaction(drain_txn).await; + test.fixture.inject_transaction(custom_payment_txn).await; + + test.fixture + .run_until_executed_transaction(&custom_payment_txn_hash, TEN_SECS) + .await; + + let (_node_id, runner) = test.fixture.network.nodes().iter().next().unwrap(); + let drain_exec_info = runner + .main_reactor() + .storage() + .read_execution_info(drain_txn_hash) + .expect("drain transaction should be included"); + let custom_payment_exec_info = runner + .main_reactor() + .storage() + .read_execution_info(custom_payment_txn_hash) + .expect("custom payment transaction should be included"); + + assert_eq!( + drain_exec_info.block_height, custom_payment_exec_info.block_height, + "drain and custom payment transactions must execute in the same block" + ); + let drain_exec_result = drain_exec_info + .execution_result + .expect("drain transaction should have an execution result"); + assert!( + exec_result_is_success(&drain_exec_result), + "{:?}", + drain_exec_result + ); + + let exec_result = custom_payment_exec_info + .execution_result + .expect("custom payment transaction should have an execution result"); + let error_message = exec_result + .error_message() + .expect("transaction should fail the initial balance precheck"); + assert!( + error_message.contains(&format!("has less than {}", baseline_motes)), + "{error_message}" + ); + + let state_root_hash = + state_root_hash_at(&test.fixture, Some(custom_payment_exec_info.block_height)); + let bob_entity_addr = get_entity_addr_from_account_hash( + &mut test.fixture, + state_root_hash, + BOB_PUBLIC_KEY.to_account_hash(), + ); + assert!( + get_entity_named_key( + &mut test.fixture, + state_root_hash, + bob_entity_addr, + created_session_key, + ) + .is_none(), + "failed custom payment precheck must not execute session code" + ); + assert!( + !handle_payment_refund_purse_is_set( + &test.fixture, + Some(custom_payment_exec_info.block_height) + ), + "failed custom payment precheck must not leave the handle-payment refund purse set" + ); +} + +/// Regression for audit-confirmed-32: native burn must subtract the burned amount from the +/// purse's *total* balance, not overwrite it with `available - amount`. Otherwise burning while +/// a gas/processing hold is active permanently erases the held balance even though total supply +/// is reduced only by the burn amount. +#[tokio::test] +async fn native_burn_with_active_gas_hold_preserves_held_balance() { + const HELD_PAYMENT_AMOUNT: u64 = 2_500_000_000; + const BURN_AMOUNT: u64 = 1; + + let refund_ratio = Ratio::new(1, 2); + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::Refund { refund_ratio }) + .with_fee_handling(FeeHandling::NoFee); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let held_txn = invalid_wasm_txn( + BOB_SECRET_KEY.clone(), + PricingMode::PaymentLimited { + payment_amount: HELD_PAYMENT_AMOUNT, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }, + ); + let (_txn_hash, hold_block_height, hold_result) = test.send_transaction(held_txn).await; + assert!( + !exec_result_is_success(&hold_result), + "invalid wasm transaction should fail while creating a gas hold" + ); + + let (_alice_after_hold, bob_after_hold, _) = test.get_balances(Some(hold_block_height)); + let total_supply_after_hold = test.get_total_supply(Some(hold_block_height)); + assert!( + bob_after_hold.available < bob_after_hold.total, + "repro requires an active gas hold before burning" + ); + + let mut burn_txn = Transaction::from( + TransactionV1Builder::new_burn(BURN_AMOUNT, None) + .unwrap() + .with_chain_name(CHAIN_NAME) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: HELD_PAYMENT_AMOUNT, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .build() + .unwrap(), + ); + burn_txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, burn_block_height, burn_result) = test.send_transaction(burn_txn).await; + assert!( + exec_result_is_success(&burn_result), + "native burn should succeed: {:?}", + burn_result + ); + + let (_alice_after_burn, bob_after_burn, _) = test.get_balances(Some(burn_block_height)); + let total_supply_after_burn = test.get_total_supply(Some(burn_block_height)); + let expected_bob_total = bob_after_hold.total - U512::from(BURN_AMOUNT); + + assert_eq!( + total_supply_after_burn, + total_supply_after_hold - U512::from(BURN_AMOUNT), + "native burn should reduce total supply by exactly the burned amount" + ); + assert_eq!( + bob_after_burn.total, expected_bob_total, + "native burn with an active gas hold removed more from the purse than the burned amount: before={bob_after_hold:?}, after={bob_after_burn:?}" + ); +} + +/// Regression for audit-confirmed-35: V1 custom payment must not be executed with a gas budget +/// independent of the transaction's declared payment-limited gas. Otherwise payment-phase Wasm +/// can consume far more gas than the transaction limit (since cost is later capped at the limit), +/// undercharging the sender while still using validator execution resources. +#[tokio::test] +async fn custom_payment_cannot_consume_more_than_transaction_limit() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let contract_file = RESOURCES_PATH + .join("..") + .join("target") + .join("wasm32-unknown-unknown") + .join("release") + .join("overlimit_custom_payment.wasm"); + let module_bytes = Bytes::from(std::fs::read(contract_file).expect("cannot read module bytes")); + + let payment_amount = 2_500_000_000u64; + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "iterations" => 1u32, + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: false, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, _block_height, exec_result) = test.send_transaction(txn).await; + let ExecutionResult::V2(result) = exec_result else { + panic!("Expected ExecutionResult::V2 but got {:?}", exec_result); + }; + + assert!( + result.consumed <= result.limit, + "custom payment consumed more gas than the transaction limit: consumed={}, limit={}, cost={}, error={:?}", + result.consumed.value(), + result.limit.value(), + result.cost, + result.error_message, + ); +} + +/// Regression for audit-confirmed-51: a failed-custom-payment transaction must include the gas +/// consumed by the payment Wasm in `consumed` and charge for it. Without the fix expensive +/// payment work that underdeposits records `consumed = 0` and only the baseline penalty cost is +/// charged, letting senders burn unaccounted gas. +#[tokio::test] +async fn failed_custom_payment_charges_consumed_payment_gas() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let contract_file = RESOURCES_PATH + .join("..") + .join("target") + .join("wasm32-unknown-unknown") + .join("release") + .join("underpaying_custom_payment.wasm"); + let module_bytes = Bytes::from(std::fs::read(contract_file).expect("cannot read module bytes")); + + let bob_before = get_balance(&test.fixture, &BOB_PUBLIC_KEY, None, true) + .total_balance() + .copied() + .expect("Bob should have a balance"); + + let payment_amount = 20_000_000_000u64; + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "iterations" => 1u32, + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: false, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, block_height, exec_result) = test.send_transaction(txn).await; + let ExecutionResult::V2(result) = exec_result else { + panic!("Expected ExecutionResult::V2 but got {:?}", exec_result); + }; + assert!( + result + .error_message + .as_deref() + .expect("custom payment should fail") + .starts_with("Insufficient custom payment"), + "{:?}", + result.error_message + ); + + let baseline_motes = test.chainspec().core_config.baseline_motes_amount_u512(); + assert!( + result.consumed.value() > baseline_motes, + "failed custom payment recorded only {} consumed gas after expensive payment work; baseline={}, cost={}, limit={}, error={:?}", + result.consumed.value(), + baseline_motes, + result.cost, + result.limit.value(), + result.error_message, + ); + assert_eq!( + result.cost, + result.consumed.value(), + "failed custom payment charged {} despite consuming {} gas", + result.cost, + result.consumed.value(), + ); + + let bob_after = get_balance(&test.fixture, &BOB_PUBLIC_KEY, Some(block_height), true) + .total_balance() + .copied() + .expect("Bob should have a balance"); + assert_eq!( + bob_before - bob_after, + result.cost, + "payer balance delta should match charged failed-payment gas" + ); +} + +/// Regression for audit-confirmed-152: a failed VM1 session (here: burn from main purse then run +/// out of gas) must not leave its state-changing effects applied. With `FeeHandling::PayToProposer` +/// fee finalization itself does not move mint total supply, so the only way the supply could go +/// down is if the failed session's burn was nevertheless committed. Asserts total supply is +/// unchanged after the failed transaction. +#[tokio::test] +async fn failed_wasm_session_burn_must_not_reduce_total_supply() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let module_path = RESOURCES_PATH + .parent() + .unwrap() + .join("target") + .join("wasm32-unknown-unknown") + .join("release") + .join("burn_then_out_of_gas.wasm"); + let module_bytes = Bytes::from(std::fs::read(module_path).expect("burn_then_out_of_gas wasm")); + + let supply_before = test.get_total_supply(None); + + let burn_amount = U512::from(1_000_000u64); + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "amount" => burn_amount, + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 2_500_000_000u64, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, block_height, exec_result) = test.send_transaction(txn).await; + assert!( + !exec_result_is_success(&exec_result), + "burn_then_out_of_gas should fail: {:?}", + exec_result + ); + + let supply_after = test.get_total_supply(Some(block_height)); + assert_eq!( + supply_before, supply_after, + "failed Wasm session burn reduced total supply: before={supply_before}, after={supply_after}", + ); +} + +/// Regression for audit-confirmed-109: a forged V1 transaction whose declared `initiator_addr` +/// is a victim account but whose approvals only carry an unrelated attacker key must not let +/// fee finalization charge the victim. Without the fix, block execution selects the victim as +/// the standard-payment payer before the execution engine performs account authorization, the +/// session fails with `Authorization`, and fee finalization still debits the victim's main +/// purse for the full declared `payment_amount`. +#[tokio::test] +async fn forged_initiator_must_not_drain_victim_fees() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::Burn); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + // Use a no-op session module (`do_nothing.wasm`). It would never get to execute on a fixed + // runtime because authorization should fail before payment. + let do_nothing_path = RESOURCES_PATH + .parent() + .unwrap() + .join("target") + .join("wasm32-unknown-unknown") + .join("release") + .join("do_nothing.wasm"); + let module_bytes = Bytes::from(std::fs::read(do_nothing_path).expect("do_nothing wasm")); + + let payment_amount = 100_000_000_000u64; + // Declared initiator: Bob (the victim). Signature: Charlie (the attacker). + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + txn.sign(&CHARLIE_SECRET_KEY); + + let (_, bob_before, _) = test.get_balances(None); + let (_txn_hash, block_height, _exec_result) = test.send_transaction(txn).await; + let (_, bob_after, _) = test.get_balances(Some(block_height)); + + let victim_loss = bob_before.total.saturating_sub(bob_after.total); + assert_eq!( + victim_loss, + U512::zero(), + "forged-approval transaction charged victim initiator: victim_loss={victim_loss}", + ); +} + +/// Regression for audit-confirmed-85: a VM1 custom-payment transaction must not let custom +/// payment and the following session each spend the full approved amount from the caller's main +/// purse. The custom payment in this test transfers `payment_amount` from Bob's main purse into +/// the system payment purse (consuming the approved spending limit), and the session then tries +/// to transfer `payment_amount` again from the main purse into a fresh named purse. Without the +/// fix the session sees a fresh full spending limit derived from the same `amount` runtime arg +/// and the transaction debits Bob's main purse for `2 * payment_amount`. +#[tokio::test] +async fn custom_payment_and_session_share_spending_limit() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let base_path = RESOURCES_PATH + .parent() + .unwrap() + .join("target") + .join("wasm32-unknown-unknown") + .join("release"); + + let payment_amount = U512::from(500_000_000_000u64); + let new_purse_name = "audit-85-overflow-purse"; + let chain_name = test.chainspec().network_config.name.clone(); + + let payment_bytes = + std::fs::read(base_path.join("non_standard_payment.wasm")).expect("payment wasm"); + let session_bytes = std::fs::read(base_path.join("transfer_main_purse_to_new_purse.wasm")) + .expect("session wasm"); + + let custom_payment_txn = { + let timestamp = Timestamp::now(); + let ttl = TimeDiff::from_seconds(100); + let gas_price = 1; + + let payment = ExecutableDeployItem::ModuleBytes { + module_bytes: payment_bytes.into(), + args: runtime_args! { + "amount" => payment_amount, + }, + }; + + let session = ExecutableDeployItem::ModuleBytes { + module_bytes: session_bytes.into(), + args: runtime_args! { + "amount" => payment_amount, + "destination" => new_purse_name.to_string(), + }, + }; + + Transaction::Deploy(Deploy::new_signed( + timestamp, + ttl, + gas_price, + vec![], + chain_name, + payment, + session, + &BOB_SECRET_KEY, + Some(BOB_PUBLIC_KEY.clone()), + )) + }; + + let (_, bob_before, _) = test.get_balances(None); + let (_txn_hash, block_height, _exec_result) = test.send_transaction(custom_payment_txn).await; + let (_, bob_after, _) = test.get_balances(Some(block_height)); + + let caller_loss = bob_before.total.saturating_sub(bob_after.total); + // With the spending-limit reset bug, custom payment and session each transfer + // `payment_amount` out of Bob's main purse, so caller_loss > payment_amount * 2 - epsilon. + // The fixed runtime forwards the post-payment remaining spending limit into the session + // request, which surfaces as the session reverting with UnapprovedSpendingAmount (rolling + // back its main-purse debit); only the payment-phase transfer commits. + assert!( + caller_loss < payment_amount * U512::from(2u64), + "custom payment and session each spent the approved amount from the main purse: \ + approved={payment_amount}, caller_loss={caller_loss}" + ); +} + +/// Regression for audit-confirmed-80: a failed custom-payment transaction must not be allowed +/// to settle pre-existing shared payment-purse funds as its own fee. The repro seeds the shared +/// payment purse with 100 motes directly in test state, then runs a failing custom-payment whose +/// `payment_amount` exceeds the actual consumed payment gas. Asserts the 100 seeded motes are +/// still in the payment purse afterwards. Without the fix `fee_amount` was derived from the full +/// post-payment purse balance, so fee finalization (PayToProposer / Burn / Accumulate) could +/// pay or burn those unrelated motes. +#[tokio::test] +async fn failed_custom_payment_must_not_settle_preexisting_payment_purse_balance() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let seeded_amount = U512::from(100u64); + seed_payment_purse_balance_at_tip(&mut test.fixture, seeded_amount); + + let base_path = RESOURCES_PATH + .join("..") + .join("target") + .join("wasm32-unknown-unknown") + .join("release"); + + let underpaying_payment_bytes = Bytes::from( + std::fs::read(base_path.join("underpaying_custom_payment.wasm")) + .expect("cannot read underpaying custom payment module bytes"), + ); + let mut custom_payment_txn = Transaction::from( + TransactionV1Builder::new_session( + false, + underpaying_payment_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "iterations" => 1u32, + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 20_000_000_000u64, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: false, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + custom_payment_txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, custom_block_height, custom_result) = + test.send_transaction(custom_payment_txn).await; + assert!( + custom_result + .error_message() + .expect("custom payment should fail") + .starts_with("Insufficient custom payment"), + "{:?}", + custom_result + ); + + let payment_purse_balance = + get_payment_purse_balance(&mut test.fixture, Some(custom_block_height)); + assert_eq!( + *payment_purse_balance + .total_balance() + .expect("should have total balance"), + seeded_amount, + "failed custom payment fee finalization drained pre-existing shared payment-purse balance" + ); +} + +/// Regression for audit-confirmed-75: a VM1 custom payment that funds the payment purse with +/// multiple valid transfers must be accepted as long as the total deposit matches the requested +/// `payment_amount`. Without the fix `balance_increased_by_amount` only inspected the first +/// `AddUInt512` transform on the payment-purse balance and required an exact match, so a split +/// deposit was misclassified as `Insufficient custom payment` and the sender was penalized. +#[tokio::test] +async fn split_custom_payment_deposit_satisfies_payment_amount() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let contract_file = RESOURCES_PATH + .join("..") + .join("target") + .join("wasm32-unknown-unknown") + .join("release") + .join("split_custom_payment.wasm"); + let module_bytes = Bytes::from(std::fs::read(contract_file).expect("cannot read module bytes")); + + let payment_amount = 2_500_000_000u64; + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + module_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: false, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, _block_height, exec_result) = test.send_transaction(txn).await; + assert!( + exec_result_is_success(&exec_result), + "split custom payment was penalized despite depositing the full payment amount: {:?}", + exec_result + ); +} + +/// Regression for audit-confirmed-156: a VM1 custom payment that deposits more than the required +/// payment amount must not leave the excess stranded in the shared system payment purse. The +/// payment phase spends from a pre-funded named purse so the overfund is not blocked by the main +/// purse approved-spending limit. +#[tokio::test] +async fn overfunded_custom_payment_drains_payment_purse() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::Refund { + refund_ratio: Ratio::new(75, 100), + }) + .with_fee_handling(FeeHandling::Burn); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let base_path = RESOURCES_PATH + .join("..") + .join("target") + .join("wasm32-unknown-unknown") + .join("release"); + let payment_amount = 20_000_000_000u64; + let overfund_amount = U512::from(1_000u64); + let source_purse_name = "audit-156-overfund-source"; + + let setup_bytes = Bytes::from( + std::fs::read(base_path.join("transfer_main_purse_to_new_purse.wasm")) + .expect("cannot read setup module bytes"), + ); + let mut setup_txn = Transaction::from( + TransactionV1Builder::new_session(true, setup_bytes, TransactionRuntimeParams::VmCasperV1) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .with_runtime_args(runtime_args! { + "destination" => source_purse_name.to_string(), + "amount" => U512::from(payment_amount) + overfund_amount, + }) + .build() + .unwrap(), + ); + setup_txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, _setup_block_height, setup_result) = test.send_transaction(setup_txn).await; + assert!( + exec_result_is_success(&setup_result), + "source purse setup should succeed: {:?}", + setup_result + ); + + let payment_bytes = Bytes::from( + std::fs::read(base_path.join("overfund_custom_payment.wasm")) + .expect("cannot read overfund custom payment module bytes"), + ); + let mut txn = Transaction::from( + TransactionV1Builder::new_session( + false, + payment_bytes, + TransactionRuntimeParams::VmCasperV1, + ) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: false, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .with_runtime_args(runtime_args! { + "source" => source_purse_name.to_string(), + "extra" => overfund_amount, + }) + .build() + .unwrap(), + ); + txn.sign(&BOB_SECRET_KEY); + + let (_txn_hash, block_height, exec_result) = test.send_transaction(txn).await; + assert!( + exec_result_is_success(&exec_result), + "overfunded custom payment should succeed: {:?}", + exec_result + ); + + let payment_purse_balance = get_payment_purse_balance(&mut test.fixture, Some(block_height)); + assert_eq!( + *payment_purse_balance + .total_balance() + .expect("should have total balance"), + U512::zero(), + "overfunded custom payment left motes in the shared payment purse" + ); +} + +/// Regression for audit-confirmed-57: custom-payment code must not be able to persist the system +/// payment purse via a stored-helper subcall that resolves `handle_payment.get_payment_purse`. +/// The parent runtime previously failed to see the marker set by the helper's context and let +/// `put_key` accept the payment purse URef. +#[tokio::test] +async fn custom_payment_subcall_returned_payment_purse_cannot_be_persisted() { + let config = SingleTransactionTestCase::default_test_config() + .with_pricing_handling(PricingHandling::PaymentLimited) + .with_refund_handling(RefundHandling::NoRefund) + .with_fee_handling(FeeHandling::PayToProposer); + + let mut test = SingleTransactionTestCase::new( + ALICE_SECRET_KEY.clone(), + BOB_SECRET_KEY.clone(), + CHARLIE_SECRET_KEY.clone(), + Some(config), + ) + .await; + + test.fixture + .run_until_consensus_in_era(ERA_ONE, ONE_MIN) + .await; + + let base_path = RESOURCES_PATH + .parent() + .unwrap() + .join("target") + .join("wasm32-unknown-unknown") + .join("release"); + let payment_purse_persist_bytes = Bytes::from( + std::fs::read(base_path.join("payment_purse_persist.wasm")) + .expect("cannot read payment-purse-persist module bytes"), + ); + + let mut install_txn = Transaction::from( + TransactionV1Builder::new_session( + true, + payment_purse_persist_bytes.clone(), + TransactionRuntimeParams::VmCasperV1, + ) + .with_runtime_args(runtime_args! { + "method" => "install_helper".to_string(), + }) + .with_chain_name(CHAIN_NAME) + .with_pricing_mode(PricingMode::PaymentLimited { + payment_amount: 100_000_000_000u64, + gas_price_tolerance: MIN_GAS_PRICE, + standard_payment: true, + }) + .with_initiator_addr(BOB_PUBLIC_KEY.clone()) + .build() + .unwrap(), + ); + install_txn.sign(&BOB_SECRET_KEY); + let (_txn_hash, _block_height, install_result) = test.send_transaction(install_txn).await; + assert!( + exec_result_is_success(&install_result), + "helper install should succeed: {:?}", + install_result + ); + + let payment_amount = U512::from(2_500_000_000u64); + let custom_payment_txn = { + let timestamp = Timestamp::now(); + let ttl = TimeDiff::from_seconds(100); + let gas_price = 1; + let chain_name = test.chainspec().network_config.name.clone(); + + let payment = ExecutableDeployItem::ModuleBytes { + module_bytes: payment_purse_persist_bytes, + args: runtime_args! { + "method" => "subcall_put_key".to_string(), + "amount" => payment_amount, + }, + }; + + let session = ExecutableDeployItem::ModuleBytes { + module_bytes: std::fs::read(base_path.join("do_nothing.wasm")) + .unwrap() + .into(), + args: runtime_args! { + "this_is_session" => true, + }, + }; + + Transaction::Deploy(Deploy::new_signed( + timestamp, + ttl, + gas_price, + vec![], + chain_name, + payment, + session, + &BOB_SECRET_KEY, + Some(BOB_PUBLIC_KEY.clone()), + )) + }; + + let (_txn_hash, block_height, exec_result) = test.send_transaction(custom_payment_txn).await; + if exec_result_is_success(&exec_result) { + let state_root_hash = state_root_hash_at(&test.fixture, Some(block_height)); + let bob_entity_addr = get_entity_addr_from_account_hash( + &mut test.fixture, + state_root_hash, + BOB_PUBLIC_KEY.to_account_hash(), + ); + assert!( + get_entity_named_key( + &mut test.fixture, + state_root_hash, + bob_entity_addr, + "this_should_fail", + ) + .is_none(), + "payment purse returned from subcall was persisted as a named key" + ); + panic!("custom payment unexpectedly succeeded without persisting the payment purse"); + } + + let error_message = exec_result + .error_message() + .expect("custom payment should reject payment-purse persistence"); + assert!( + error_message.contains("HandlePayment") + || error_message.contains("Handle Payment") + || error_message.contains("[40]") + || error_message.contains("error: 40"), + "custom payment failed before reaching the payment-purse persistence guard: {error_message}" + ); +} diff --git a/node/src/testing/fake_transaction_acceptor.rs b/node/src/testing/fake_transaction_acceptor.rs index a6fa6a86a4..fac0cd9c45 100644 --- a/node/src/testing/fake_transaction_acceptor.rs +++ b/node/src/testing/fake_transaction_acceptor.rs @@ -9,9 +9,9 @@ use std::sync::Arc; -use tracing::debug; +use tracing::{debug, trace}; -use casper_types::{Chainspec, Timestamp, Transaction}; +use casper_types::{Block, BlockHeader, Chainspec, Timestamp, Transaction}; pub(crate) use crate::components::transaction_acceptor::{Error, Event}; use crate::{ @@ -20,7 +20,7 @@ use crate::{ announcements::TransactionAcceptorAnnouncement, requests::StorageRequest, EffectBuilder, EffectExt, Effects, Responder, }, - types::MetaTransaction, + types::{MetaTransaction, TransactionProvenance}, utils::Source, NodeRng, }; @@ -65,18 +65,44 @@ impl FakeTransactionAcceptor { let meta_transaction = MetaTransaction::from_transaction( &transaction, self.chainspec.core_config.pricing_handling, - &self.chainspec.transaction_config, + &self.chainspec, ) .unwrap(); + let provenance = match source { + Source::PeerGossiped(_) | Source::Peer(_) => TransactionProvenance::Gossiped, + Source::Client | Source::Ourself => TransactionProvenance::Client, + }; let event_metadata = Box::new(EventMetadata::new( transaction.clone(), meta_transaction, source, maybe_responder, Timestamp::now(), + provenance, + None, )); + + let fake_block = Arc::new(Block::example().clone()); + + effect_builder + .put_block_to_storage(Arc::clone(&fake_block)) + .event(move |_| Event::GetBlockHeaderResult { + event_metadata, + maybe_block_header: Some(Box::new(fake_block.clone_header())), + }) + } + + fn handle_get_block_header( + &self, + effect_builder: EffectBuilder, + mut event_metadata: Box, + maybe_block_header: Option>, + ) -> Effects { + event_metadata.maybe_block_hash = + Some(maybe_block_header.expect("must have header").block_hash()); + effect_builder - .put_transaction_to_storage(transaction) + .put_transaction_to_storage(event_metadata.transaction.clone()) .event(move |is_new| Event::PutToStorageResult { event_metadata, is_new, @@ -94,13 +120,21 @@ impl FakeTransactionAcceptor { transaction, source, maybe_responder, - verification_start_timestamp: _, + maybe_block_hash, + provenance, + .. } = *event_metadata; let mut effects = Effects::new(); + let block_hash = maybe_block_hash.expect("must have set block hash correctly"); if is_new { effects.extend( effect_builder - .announce_new_transaction_accepted(Arc::new(transaction), source) + .announce_new_transaction_accepted( + Arc::new(transaction), + source, + provenance, + block_hash, + ) .ignore(), ); } @@ -128,13 +162,19 @@ impl Component for FakeTransactionAcceptor { ); return Effects::new(); } - debug!(?event, "FakeTransactionAcceptor: handling event"); + trace!(?event, "FakeTransactionAcceptor: handling event"); match event { Event::Accept { transaction, source, maybe_responder, + provenance: _, + maybe_block_hash: _, } => self.accept(effect_builder, transaction, source, maybe_responder), + Event::GetBlockHeaderResult { + event_metadata, + maybe_block_header, + } => self.handle_get_block_header(effect_builder, event_metadata, maybe_block_header), Event::PutToStorageResult { event_metadata, is_new, diff --git a/node/src/types.rs b/node/src/types.rs index 4fd6a8e7aa..57ca8ddf68 100644 --- a/node/src/types.rs +++ b/node/src/types.rs @@ -37,7 +37,8 @@ pub(crate) use node_id::NodeId; pub use status_feed::{ChainspecInfo, GetStatusResult, StatusFeed}; pub(crate) use sync_leap::{GlobalStatesMetadata, SyncLeap, SyncLeapIdentifier}; pub(crate) use transaction::{ - LegacyDeploy, MetaTransaction, TransactionFootprint, TransactionHeader, + GossipedTransaction, GossipedTransactionId, LegacyDeploy, MetaTransaction, + TransactionFootprint, TransactionHeader, TransactionProvenance, }; pub(crate) use validator_matrix::{EraValidatorWeights, SignatureWeight, ValidatorMatrix}; pub use value_or_chunk::{ diff --git a/node/src/types/block/invalid_proposal_error.rs b/node/src/types/block/invalid_proposal_error.rs index 83d71e259c..8e37d05b42 100644 --- a/node/src/types/block/invalid_proposal_error.rs +++ b/node/src/types/block/invalid_proposal_error.rs @@ -44,6 +44,7 @@ pub(crate) enum InvalidProposalError { transaction_era_id: u64, proposed_block_era_id: u64, }, + ExceedsSignatureMaxDelay, } impl From for Box { diff --git a/node/src/types/transaction.rs b/node/src/types/transaction.rs index 2a96b91d4d..20fcc6cf0c 100644 --- a/node/src/types/transaction.rs +++ b/node/src/types/transaction.rs @@ -1,11 +1,18 @@ pub(crate) mod arg_handling; mod deploy; +mod gossiped_transaction; mod meta_transaction; +mod proposed_transaction; + mod transaction_footprint; pub(crate) use deploy::LegacyDeploy; +pub(crate) use gossiped_transaction::{ + GossipedTransaction, GossipedTransactionId, TransactionProvenance, +}; #[cfg(test)] pub(crate) use meta_transaction::calculate_transaction_lane_for_transaction; pub(crate) use meta_transaction::{MetaTransaction, TransactionHeader}; +pub(crate) use proposed_transaction::ProposedTransaction; pub(crate) use transaction_footprint::TransactionFootprint; #[cfg(test)] pub(crate) mod fields_container; diff --git a/node/src/types/transaction/gossiped_transaction.rs b/node/src/types/transaction/gossiped_transaction.rs new file mode 100644 index 0000000000..bb2a4c4687 --- /dev/null +++ b/node/src/types/transaction/gossiped_transaction.rs @@ -0,0 +1,147 @@ +use crate::utils::specimen::{Cache, LargestSpecimen, SizeEstimator}; +use casper_types::{BlockHash, Transaction, TransactionHash, TransactionId}; +use core::fmt; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GossipedTransactionId { + transaction_id: TransactionId, + + block_hash: BlockHash, +} + +impl GossipedTransactionId {} + +impl Display for GossipedTransactionId { + fn fmt(&self, formatter: &mut Formatter) -> fmt::Result { + write!( + formatter, + "gossiped-transaction-id({}, {}, {})", + self.transaction_id.transaction_hash(), + self.transaction_id.approvals_hash(), + self.block_hash + ) + } +} + +impl GossipedTransactionId { + pub(crate) fn transaction_id(&self) -> TransactionId { + self.transaction_id + } + + pub(crate) fn block_hash(&self) -> BlockHash { + self.block_hash + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Clone)] +pub(crate) struct GossipedTransaction { + /// The transaction that has been accepted by the node gossiping this transaction, + transaction: Transaction, + /// The hash of the block the transaction was verified against. + block_hash: BlockHash, +} + +impl Display for GossipedTransaction { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Accepted Transaction({}) with block hash ({})", + self.transaction, self.block_hash + ) + } +} + +impl GossipedTransaction { + pub(crate) fn new(transaction: Transaction, block_hash: BlockHash) -> Self { + Self { + transaction, + block_hash, + } + } + + pub(crate) fn block_hash(&self) -> BlockHash { + self.block_hash + } + + pub(crate) fn accepted_id(&self) -> GossipedTransactionId { + let transaction_id = self.transaction.compute_id(); + GossipedTransactionId { + transaction_id, + block_hash: self.block_hash, + } + } + + pub(crate) fn transaction(&self) -> &Transaction { + &self.transaction + } +} + +impl LargestSpecimen for GossipedTransactionId { + fn largest_specimen(estimator: &E, cache: &mut Cache) -> Self { + let transaction_id = { + let deploy_hash = + TransactionHash::Deploy(LargestSpecimen::largest_specimen(estimator, cache)); + let v1_hash = TransactionHash::V1(LargestSpecimen::largest_specimen(estimator, cache)); + + let deploy = TransactionId::new( + deploy_hash, + LargestSpecimen::largest_specimen(estimator, cache), + ); + let v1 = + TransactionId::new(v1_hash, LargestSpecimen::largest_specimen(estimator, cache)); + + if estimator.estimate(&deploy) >= estimator.estimate(&v1) { + deploy + } else { + v1 + } + }; + + let block_hash = BlockHash::largest_specimen(estimator, cache); + + Self { + transaction_id, + block_hash, + } + } +} + +impl LargestSpecimen for GossipedTransaction { + fn largest_specimen(estimator: &E, cache: &mut Cache) -> Self { + let transaction = { + let deploy = Transaction::Deploy(LargestSpecimen::largest_specimen(estimator, cache)); + let v1 = Transaction::V1(LargestSpecimen::largest_specimen(estimator, cache)); + + if estimator.estimate(&deploy) >= estimator.estimate(&v1) { + deploy + } else { + v1 + } + }; + + let block_hash = BlockHash::largest_specimen(estimator, cache); + + Self { + transaction, + block_hash, + } + } +} + +#[derive(Debug, Serialize)] +pub(crate) enum TransactionProvenance { + /// A transaction sent from outside the network + Client, + /// This transaction flavor is normal gossiping + Gossiped, + /// This transaction is part of a block proposal + Proposed, +} + +impl TransactionProvenance { + pub(crate) fn is_proposed(&self) -> bool { + matches!(self, Self::Proposed) + } +} diff --git a/node/src/types/transaction/meta_transaction.rs b/node/src/types/transaction/meta_transaction.rs index 4c51808145..c8591adbe5 100644 --- a/node/src/types/transaction/meta_transaction.rs +++ b/node/src/types/transaction/meta_transaction.rs @@ -7,8 +7,8 @@ use casper_types::InvalidTransactionV1; use casper_types::{ account::AccountHash, bytesrepr::ToBytes, Approval, Chainspec, Digest, ExecutableDeployItem, Gas, GasLimited, HashAddr, InitiatorAddr, InvalidTransaction, Phase, PricingHandling, - PricingMode, TimeDiff, Timestamp, Transaction, TransactionArgs, TransactionConfig, - TransactionEntryPoint, TransactionHash, TransactionTarget, INSTALL_UPGRADE_LANE_ID, + PricingMode, TimeDiff, Timestamp, Transaction, TransactionArgs, TransactionEntryPoint, + TransactionHash, TransactionTarget, INSTALL_UPGRADE_LANE_ID, }; use core::fmt::{self, Debug, Display, Formatter}; use meta_deploy::MetaDeploy; @@ -242,20 +242,18 @@ impl MetaTransaction { pub(crate) fn from_transaction( transaction: &Transaction, pricing_handling: PricingHandling, - transaction_config: &TransactionConfig, + chainspec: &Chainspec, ) -> Result { match transaction { Transaction::Deploy(deploy) => MetaDeploy::from_deploy( deploy.clone(), pricing_handling, - &transaction_config.transaction_v1_config, + &chainspec.transaction_config.transaction_v1_config, ) .map(MetaTransaction::Deploy), - Transaction::V1(v1) => MetaTransactionV1::from_transaction_v1( - v1, - &transaction_config.transaction_v1_config, - ) - .map(MetaTransaction::V1), + Transaction::V1(v1) => { + MetaTransactionV1::from_transaction_v1(v1, chainspec).map(MetaTransaction::V1) + } } } @@ -437,7 +435,7 @@ pub(crate) fn calculate_transaction_lane_for_transaction( let meta = MetaTransaction::from_transaction( transaction, chainspec.core_config.pricing_handling, - &chainspec.transaction_config, + chainspec, )?; Ok(meta.transaction_lane()) } @@ -480,12 +478,19 @@ mod proptests { proptest! { #[test] fn construction_roundtrip(transaction in legal_transaction_arb()) { - let mut transaction_config = TransactionConfig::default(); - transaction_config.transaction_v1_config.set_wasm_lanes(vec![ + let mut chainspec = Chainspec::default(); + // These min/max values are in no way legal for running the node, but they should be + // considered for the structural integrity of the serialization itself + chainspec.vacancy_config.min_gas_price = 0; + chainspec.vacancy_config.max_gas_price = u8::MAX; + // Enable both runtimes so the proptest covers VmCasperV1 and VmCasperV2 transactions. + chainspec.transaction_config.runtime_config.vm_casper_v1 = true; + chainspec.transaction_config.runtime_config.vm_casper_v2 = true; + chainspec.transaction_config.transaction_v1_config.set_wasm_lanes(vec![ TransactionLaneDefinition::new(3, u64::MAX / 2, 10000, u64::MAX / 2, 10), TransactionLaneDefinition::new(4, u64::MAX, 10000, u64::MAX, 10), ]); - let maybe_transaction = MetaTransaction::from_transaction(&transaction, PricingHandling::PaymentLimited, &transaction_config); + let maybe_transaction = MetaTransaction::from_transaction(&transaction, PricingHandling::PaymentLimited, &chainspec); prop_assert!(maybe_transaction.is_ok(), "{:?}", maybe_transaction); } } diff --git a/node/src/types/transaction/meta_transaction/meta_transaction_v1.rs b/node/src/types/transaction/meta_transaction/meta_transaction_v1.rs index c0f92151e8..1763c2611b 100644 --- a/node/src/types/transaction/meta_transaction/meta_transaction_v1.rs +++ b/node/src/types/transaction/meta_transaction/meta_transaction_v1.rs @@ -2,11 +2,10 @@ use crate::types::transaction::arg_handling; use casper_types::{ bytesrepr::ToBytes, calculate_transaction_lane, crypto, Approval, Chainspec, ContractRuntimeTag, Digest, DisplayIter, Gas, HashAddr, InitiatorAddr, InvalidTransaction, - InvalidTransactionV1, PricingHandling, PricingMode, TimeDiff, Timestamp, TransactionArgs, - TransactionConfig, TransactionEntryPoint, TransactionInvocationTarget, + InvalidTransactionV1, PricingHandling, PricingMode, PublicKey, TimeDiff, Timestamp, + TransactionArgs, TransactionConfig, TransactionEntryPoint, TransactionInvocationTarget, TransactionRuntimeParams, TransactionScheduling, TransactionTarget, TransactionV1, - TransactionV1Config, TransactionV1ExcessiveSizeError, TransactionV1Hash, AUCTION_LANE_ID, - MINT_LANE_ID, U512, + TransactionV1ExcessiveSizeError, TransactionV1Hash, AUCTION_LANE_ID, MINT_LANE_ID, U512, }; use core::fmt::{self, Debug, Display, Formatter}; use datasize::DataSize; @@ -46,8 +45,9 @@ pub(crate) struct MetaTransactionV1 { impl MetaTransactionV1 { pub(crate) fn from_transaction_v1( v1: &TransactionV1, - transaction_v1_config: &TransactionV1Config, + chainspec: &Chainspec, ) -> Result { + let transaction_v1_config = &chainspec.transaction_config.transaction_v1_config; let args_binary_len = v1 .payload() .fields() @@ -60,6 +60,25 @@ impl MetaTransactionV1 { let target: TransactionTarget = v1.deserialize_field(TARGET_MAP_KEY).map_err(|error| { InvalidTransaction::V1(InvalidTransactionV1::CouldNotDeserializeField { error }) })?; + let vm_casper_v1 = chainspec.transaction_config.runtime_config.vm_casper_v1; + let vm_casper_v2 = chainspec.transaction_config.runtime_config.vm_casper_v2; + match &target { + TransactionTarget::Stored { runtime, .. } + | TransactionTarget::Session { runtime, .. } => match runtime { + TransactionRuntimeParams::VmCasperV1 if !vm_casper_v1 => Err( + InvalidTransaction::V1(InvalidTransactionV1::InvalidTransactionRuntime { + expected: ContractRuntimeTag::VmCasperV2, + }), + ), + TransactionRuntimeParams::VmCasperV2 { .. } if !vm_casper_v2 => Err( + InvalidTransaction::V1(InvalidTransactionV1::InvalidTransactionRuntime { + expected: ContractRuntimeTag::VmCasperV1, + }), + ), + _ => Ok(()), + }, + _ => Ok(()), + }?; let entry_point: TransactionEntryPoint = v1.deserialize_field(ENTRY_POINT_MAP_KEY).map_err(|error| { InvalidTransaction::V1(InvalidTransactionV1::CouldNotDeserializeField { error }) @@ -78,6 +97,35 @@ impl MetaTransactionV1 { let payload_hash = v1.payload_hash()?; let serialized_length = v1.serialized_length(); let pricing_mode = v1.payload().pricing_mode(); + let vacancy_config = &chainspec.vacancy_config; + match pricing_mode { + PricingMode::PaymentLimited { + gas_price_tolerance, + .. + } + | PricingMode::Fixed { + gas_price_tolerance, + .. + } => { + if *gas_price_tolerance < vacancy_config.min_gas_price { + Err(InvalidTransaction::V1( + InvalidTransactionV1::GasPriceToleranceTooLow { + min_gas_price_tolerance: vacancy_config.min_gas_price, + provided_gas_price_tolerance: *gas_price_tolerance, + }, + )) + } else if *gas_price_tolerance > vacancy_config.max_gas_price { + Err(InvalidTransaction::V1( + InvalidTransactionV1::InvalidPricingMode { + price_mode: pricing_mode.clone(), + }, + )) + } else { + Ok(()) + } + } + _ => Ok(()), + }?; let lane_id = calculate_transaction_lane( &entry_point, &target, @@ -228,6 +276,19 @@ impl MetaTransactionV1 { return Err(InvalidTransactionV1::EmptyApprovals); } + match &self.initiator_addr { + InitiatorAddr::PublicKey(public_key) => { + if public_key == &PublicKey::System { + return Err(InvalidTransactionV1::InvalidInitiator); + } + } + InitiatorAddr::AccountHash(account_hash) => { + if account_hash == &PublicKey::System.to_account_hash() { + return Err(InvalidTransactionV1::InvalidInitiator); + } + } + } + self.has_valid_hash().clone()?; for (index, approval) in self.approvals.iter().enumerate() { @@ -863,11 +924,17 @@ mod tests { use super::MetaTransactionV1; use crate::types::transaction::transaction_v1_builder::TransactionV1Builder; use casper_types::{ - testing::TestRng, InvalidTransaction, InvalidTransactionV1, PricingMode, SecretKey, - TransactionInvocationTarget, TransactionLaneDefinition, TransactionRuntimeParams, - TransactionV1Config, + testing::TestRng, Chainspec, InvalidTransaction, InvalidTransactionV1, PricingMode, + SecretKey, TransactionInvocationTarget, TransactionLaneDefinition, + TransactionRuntimeParams, TransactionV1Config, }; + fn chainspec_with_v1_config(config: TransactionV1Config) -> Chainspec { + let mut chainspec = Chainspec::default(); + chainspec.transaction_config.transaction_v1_config = config; + chainspec + } + #[test] fn limited_amount_should_determine_transaction_lane_for_session() { let rng = &mut TestRng::new(); @@ -888,9 +955,9 @@ mod tests { .with_secret_key(&secret_key) .build() .unwrap(); - let config = build_v1_config(); + let chainspec = chainspec_with_v1_config(build_v1_config()); - let meta_transaction = MetaTransactionV1::from_transaction_v1(&transaction_v1, &config) + let meta_transaction = MetaTransactionV1::from_transaction_v1(&transaction_v1, &chainspec) .expect("meta transaction should be valid"); assert_eq!(meta_transaction.lane_id(), 4); } @@ -915,9 +982,9 @@ mod tests { .with_secret_key(&secret_key) .build() .unwrap(); - let config = build_v1_config(); + let chainspec = chainspec_with_v1_config(build_v1_config()); - let res = MetaTransactionV1::from_transaction_v1(&transaction_v1, &config); + let res = MetaTransactionV1::from_transaction_v1(&transaction_v1, &chainspec); assert!(matches!( res, Err(InvalidTransaction::V1(InvalidTransactionV1::NoLaneMatch)) @@ -944,13 +1011,14 @@ mod tests { .with_secret_key(&secret_key) .build() .unwrap(); - let mut config = TransactionV1Config::default(); - config.set_wasm_lanes(vec![ + let mut v1_config = TransactionV1Config::default(); + v1_config.set_wasm_lanes(vec![ TransactionLaneDefinition::new(3, 200, 100, 100, 10), TransactionLaneDefinition::new(4, 500, 100, 10000, 10), ]); + let chainspec = chainspec_with_v1_config(v1_config); - let res = MetaTransactionV1::from_transaction_v1(&transaction_v1, &config); + let res = MetaTransactionV1::from_transaction_v1(&transaction_v1, &chainspec); assert!(matches!( res, Err(InvalidTransaction::V1(InvalidTransactionV1::NoLaneMatch)) @@ -977,9 +1045,9 @@ mod tests { .with_pricing_mode(pricing_mode) .build() .unwrap(); - let config = build_v1_config(); + let chainspec = chainspec_with_v1_config(build_v1_config()); - let meta_transaction = MetaTransactionV1::from_transaction_v1(&transaction_v1, &config) + let meta_transaction = MetaTransactionV1::from_transaction_v1(&transaction_v1, &chainspec) .expect("meta transaction should be valid"); assert_eq!(meta_transaction.lane_id(), 4); } diff --git a/node/src/types/transaction/proposed_transaction.rs b/node/src/types/transaction/proposed_transaction.rs new file mode 100644 index 0000000000..394d033add --- /dev/null +++ b/node/src/types/transaction/proposed_transaction.rs @@ -0,0 +1,43 @@ +use crate::utils::specimen::{Cache, LargestSpecimen, SizeEstimator}; +use casper_types::Transaction; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; + +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Clone)] +pub(crate) struct ProposedTransaction { + /// The transaction that has been accepted by the node gossiping this transaction, + transaction: Transaction, +} + +impl Display for ProposedTransaction { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "Accepted Transaction({})", self.transaction,) + } +} + +impl ProposedTransaction { + pub(crate) fn new(transaction: Transaction) -> Self { + Self { transaction } + } + + pub(crate) fn transaction(&self) -> &Transaction { + &self.transaction + } +} + +impl LargestSpecimen for ProposedTransaction { + fn largest_specimen(estimator: &E, cache: &mut Cache) -> Self { + let transaction = { + let deploy = Transaction::Deploy(LargestSpecimen::largest_specimen(estimator, cache)); + let v1 = Transaction::V1(LargestSpecimen::largest_specimen(estimator, cache)); + + if estimator.estimate(&deploy) >= estimator.estimate(&v1) { + deploy + } else { + v1 + } + }; + + Self { transaction } + } +} diff --git a/node/src/types/transaction/transaction_footprint.rs b/node/src/types/transaction/transaction_footprint.rs index 9b28ea04c1..04a98f46c9 100644 --- a/node/src/types/transaction/transaction_footprint.rs +++ b/node/src/types/transaction/transaction_footprint.rs @@ -44,7 +44,7 @@ impl TransactionFootprint { let transaction = MetaTransaction::from_transaction( transaction, chainspec.core_config.pricing_handling, - &chainspec.transaction_config, + chainspec, )?; Self::new_from_meta_transaction(chainspec, &transaction) } diff --git a/node/src/utils/specimen.rs b/node/src/utils/specimen.rs index aff01b82a7..1eccf0307d 100644 --- a/node/src/utils/specimen.rs +++ b/node/src/utils/specimen.rs @@ -40,8 +40,8 @@ use crate::{ }, protocol::Message, types::{ - BlockExecutionResultsOrChunk, BlockPayload, FinalizedBlock, InternalEraReport, - LegacyDeploy, SyncLeap, TrieOrChunk, + transaction::ProposedTransaction, BlockExecutionResultsOrChunk, BlockPayload, + FinalizedBlock, InternalEraReport, LegacyDeploy, SyncLeap, TrieOrChunk, }, }; use casper_storage::block_store::types::ApprovalsHashes; @@ -1172,6 +1172,9 @@ pub(crate) fn largest_get_request(estimator: &E, cache: &mut C Tag::Transaction => Message::new_get_request::( &LargestSpecimen::largest_specimen(estimator, cache), ), + Tag::ProposedTransaction => Message::new_get_request::( + &LargestSpecimen::largest_specimen(estimator, cache), + ), Tag::LegacyDeploy => Message::new_get_request::( &LargestSpecimen::largest_specimen(estimator, cache), ), @@ -1208,6 +1211,9 @@ pub(crate) fn largest_get_response(estimator: &E, cache: &mut Tag::Transaction => Message::new_get_response::( &LargestSpecimen::largest_specimen(estimator, cache), ), + Tag::ProposedTransaction => Message::new_get_response::( + &LargestSpecimen::largest_specimen(estimator, cache), + ), Tag::LegacyDeploy => Message::new_get_response::( &LargestSpecimen::largest_specimen(estimator, cache), ), diff --git a/resources/integration-test/chainspec.toml b/resources/integration-test/chainspec.toml index 0ec491a465..6325ed6cd3 100644 --- a/resources/integration-test/chainspec.toml +++ b/resources/integration-test/chainspec.toml @@ -1,6 +1,6 @@ [protocol] # Protocol version. -version = '2.2.2' +version = '2.2.4' # Whether we need to clear latest blocks back to the switch block just before the activation point or not. hard_reset = true # This protocol version becomes active at this point. @@ -11,7 +11,7 @@ hard_reset = true # in contract-runtime for computing genesis post-state hash. # # If it is an integer, it represents an era ID, meaning the protocol version becomes active at the start of this era. -activation_point = 19672 +activation_point = 20874 [network] # Human readable name for convenience; the genesis_hash is the true identifier. The name influences the genesis hash by diff --git a/resources/mainnet/chainspec.toml b/resources/mainnet/chainspec.toml index 3448dc9935..36ecd113e0 100644 --- a/resources/mainnet/chainspec.toml +++ b/resources/mainnet/chainspec.toml @@ -1,6 +1,6 @@ [protocol] # Protocol version. -version = '2.2.0' +version = '2.2.2' # Whether we need to clear latest blocks back to the switch block just before the activation point or not. hard_reset = true # This protocol version becomes active at this point. @@ -11,7 +11,7 @@ hard_reset = true # in contract-runtime for computing genesis post-state hash. # # If it is an integer, it represents an era ID, meaning the protocol version becomes active at the start of this era. -activation_point = 21742 +activation_point = 22931 [network] # Human readable name for convenience; the genesis_hash is the true identifier. The name influences the genesis hash by diff --git a/resources/testnet/chainspec.toml b/resources/testnet/chainspec.toml index 2158da03cb..f2f94b4020 100644 --- a/resources/testnet/chainspec.toml +++ b/resources/testnet/chainspec.toml @@ -1,6 +1,6 @@ [protocol] # Protocol version. -version = '2.2.0' +version = '2.2.2' # Whether we need to clear latest blocks back to the switch block just before the activation point or not. hard_reset = true # This protocol version becomes active at this point. @@ -11,7 +11,7 @@ hard_reset = true # in contract-runtime for computing genesis post-state hash. # # If it is an integer, it represents an era ID, meaning the protocol version becomes active at the start of this era. -activation_point = 21500 +activation_point = 22690 [network] # Human readable name for convenience; the genesis_hash is the true identifier. The name influences the genesis hash by @@ -180,7 +180,7 @@ baseline_motes_amount = 2_500_000_000 # Flag on whether ambiguous entity versions returns an execution error. trap_on_ambiguous_entity_version = false # Controls how rewards are handled by the network -# purse uref-b06a1ab0cfb52b5d4f9a08b68a5dbe78e999de0b0484c03e64f5c03897cf637b-007 belongs to +# purse uref-b06a1ab0cfb52b5d4f9a08b68a5dbe78e999de0b0484c03e64f5c03897cf637b-007 belongs to # account 018afa98ca4be12d613617f7339a2d576950a2f9a92102ca4d6508ee31b54d2c02 (faucet account for testnet) rewards_handling = { type = 'sustain', ratio = [2,8], purse_address = "uref-b06a1ab0cfb52b5d4f9a08b68a5dbe78e999de0b0484c03e64f5c03897cf637b-007" } diff --git a/smart_contracts/contract/Cargo.toml b/smart_contracts/contract/Cargo.toml index 618ab90884..b64dc6db93 100644 --- a/smart_contracts/contract/Cargo.toml +++ b/smart_contracts/contract/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-contract" -version = "5.1.1" # when updating, also update 'html_root_url' in lib.rs +version = "5.1.2" # when updating, also update 'html_root_url' in lib.rs authors = ["Michał Papierski ", "Mateusz Górski "] edition = "2021" description = "A library for developing Casper network smart contracts." @@ -11,7 +11,7 @@ repository = "https://github.com/casper-network/casper-node/tree/master/smart_co license = "Apache-2.0" [dependencies] -casper-types = { version = "7.0.0", path = "../../types" } +casper-types = { version = "7.1.1", path = "../../types" } hex_fmt = "0.3.0" version-sync = { version = "0.9", optional = true } wee_alloc = { version = "0.4.5", optional = true } diff --git a/smart_contracts/contract/src/lib.rs b/smart_contracts/contract/src/lib.rs index 1ce525aa4c..a7f236a232 100644 --- a/smart_contracts/contract/src/lib.rs +++ b/smart_contracts/contract/src/lib.rs @@ -51,7 +51,7 @@ all(not(test), feature = "no-std-helpers"), feature(alloc_error_handler, core_intrinsics, lang_items) )] -#![doc(html_root_url = "https://docs.rs/casper-contract/5.1.1")] +#![doc(html_root_url = "https://docs.rs/casper-contract/5.1.2")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon_48.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon.png" diff --git a/smart_contracts/contracts/test/burn-main-purse/Cargo.toml b/smart_contracts/contracts/test/burn-main-purse/Cargo.toml new file mode 100644 index 0000000000..a2e875cd92 --- /dev/null +++ b/smart_contracts/contracts/test/burn-main-purse/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "burn-main-purse" +version = "0.1.0" +authors = ["Michał Papierski "] +edition = "2021" + +[[bin]] +name = "burn_main_purse" +path = "src/main.rs" +bench = false +doctest = false +test = false + +[dependencies] +casper-contract = { path = "../../../contract" } +casper-types = { path = "../../../../types" } diff --git a/smart_contracts/contracts/test/burn-main-purse/src/main.rs b/smart_contracts/contracts/test/burn-main-purse/src/main.rs new file mode 100644 index 0000000000..e98d60d616 --- /dev/null +++ b/smart_contracts/contracts/test/burn-main-purse/src/main.rs @@ -0,0 +1,31 @@ +#![no_std] +#![no_main] + +use casper_contract::{ + contract_api::{account, runtime, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{runtime_args, system::mint, U512}; + +const ARG_AMOUNT: &str = "amount"; +const ARG_BURN_AMOUNT: &str = "burn_amount"; + +/// Calls mint `burn` directly on the caller's main purse for the inner `burn_amount`. The outer +/// `amount` runtime arg becomes the transaction's approved spending limit, so this contract is +/// the natural fixture for audit-confirmed-83: with `amount` small and `burn_amount` large, the +/// runtime must reject the burn with `mint::Error::UnapprovedSpendingAmount` instead of allowing +/// the larger amount through. +#[no_mangle] +pub extern "C" fn call() { + let _outer_amount: U512 = runtime::get_named_arg(ARG_AMOUNT); + let burn_amount: U512 = runtime::get_named_arg(ARG_BURN_AMOUNT); + let main_purse = account::get_main_purse(); + let mint_hash = system::get_mint(); + let args = runtime_args! { + mint::ARG_PURSE => main_purse, + mint::ARG_AMOUNT => burn_amount, + }; + let result: Result<(), mint::Error> = + runtime::call_contract(mint_hash, mint::METHOD_BURN, args); + result.unwrap_or_revert(); +} diff --git a/smart_contracts/contracts/test/burn-then-out-of-gas/Cargo.toml b/smart_contracts/contracts/test/burn-then-out-of-gas/Cargo.toml new file mode 100644 index 0000000000..35a19e3add --- /dev/null +++ b/smart_contracts/contracts/test/burn-then-out-of-gas/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "burn-then-out-of-gas" +version = "0.1.0" +authors = ["Michał Papierski "] +edition = "2021" + +[[bin]] +name = "burn_then_out_of_gas" +path = "src/main.rs" +bench = false +doctest = false +test = false + +[dependencies] +casper-contract = { path = "../../../contract" } +casper-types = { path = "../../../../types" } diff --git a/smart_contracts/contracts/test/burn-then-out-of-gas/src/main.rs b/smart_contracts/contracts/test/burn-then-out-of-gas/src/main.rs new file mode 100644 index 0000000000..f5e55257af --- /dev/null +++ b/smart_contracts/contracts/test/burn-then-out-of-gas/src/main.rs @@ -0,0 +1,35 @@ +#![no_std] +#![no_main] + +use casper_contract::{ + contract_api::{account, runtime, storage, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{runtime_args, system::mint, URef, U512}; + +const ARG_AMOUNT: &str = "amount"; + +fn burn(uref: URef, amount: U512) { + let mint_hash = system::get_mint(); + let args = runtime_args! { + mint::ARG_PURSE => uref, + mint::ARG_AMOUNT => amount, + }; + let result: Result<(), mint::Error> = + runtime::call_contract(mint_hash, mint::METHOD_BURN, args); + result.unwrap_or_revert(); +} + +/// Session that calls mint `burn` on the caller main purse and then deliberately runs out of gas +/// by repeatedly writing a growing value. Used as a regression contract for audit-confirmed-152: +/// after the fix, the failed (`Out of gas error`) execution must NOT commit the burn effect. +#[no_mangle] +pub extern "C" fn call() { + let amount: U512 = runtime::get_named_arg(ARG_AMOUNT); + burn(account::get_main_purse(), amount); + + // Now exhaust the gas budget. Each loop iteration creates a new uref + write. + loop { + let _uref = storage::new_uref(0u64); + } +} diff --git a/smart_contracts/contracts/test/payment-purse-persist/src/main.rs b/smart_contracts/contracts/test/payment-purse-persist/src/main.rs index c2b31f3706..554a721e0d 100644 --- a/smart_contracts/contracts/test/payment-purse-persist/src/main.rs +++ b/smart_contracts/contracts/test/payment-purse-persist/src/main.rs @@ -3,36 +3,114 @@ extern crate alloc; -use alloc::string::String; -use casper_contract::contract_api::{runtime, runtime::put_key, system}; -use casper_types::{contracts::ContractPackageHash, runtime_args, ApiError, RuntimeArgs, URef}; +use alloc::{ + string::{String, ToString}, + vec, +}; +use casper_contract::{ + contract_api::{account, runtime, runtime::put_key, storage, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{ + contracts::{ContractHash, ContractPackageHash}, + runtime_args, AddressableEntityHash, ApiError, CLType, CLValue, EntityEntryPoint, + EntryPointAccess, EntryPointPayment, EntryPointType, EntryPoints, Key, RuntimeArgs, URef, U512, +}; const GET_PAYMENT_PURSE: &str = "get_payment_purse"; const THIS_SHOULD_FAIL: &str = "this_should_fail"; +const HELPER_HASH_KEY: &str = "payment_purse_persist_helper_hash"; +const LEAK_PAYMENT_PURSE: &str = "leak_payment_purse"; +const ARG_AMOUNT: &str = "amount"; const ARG_METHOD: &str = "method"; -/// This logic is intended to be used as SESSION PAYMENT LOGIC -/// It gets the payment purse and attempts and attempts to persist it, -/// which should fail. #[no_mangle] -pub extern "C" fn call() { - let method: String = runtime::get_named_arg(ARG_METHOD); - - // handle payment contract +pub extern "C" fn leak_payment_purse() { let handle_payment_contract_hash = system::get_handle_payment(); - - // get payment purse for current execution let payment_purse: URef = runtime::call_contract( handle_payment_contract_hash, GET_PAYMENT_PURSE, RuntimeArgs::default(), ); + runtime::ret(CLValue::from_t(payment_purse).unwrap_or_revert()) +} + +fn install_helper() { + let mut entry_points = EntryPoints::new(); + entry_points.add_entry_point(EntityEntryPoint::new( + LEAK_PAYMENT_PURSE.to_string(), + vec![], + CLType::URef, + EntryPointAccess::Public, + EntryPointType::Called, + EntryPointPayment::Caller, + )); + + let (contract_hash, _) = storage::new_contract(entry_points, None, None, None, None); + runtime::put_key( + HELPER_HASH_KEY, + Key::contract_entity_key(AddressableEntityHash::new(contract_hash.value())), + ); +} + +/// This logic is intended to be used as SESSION PAYMENT LOGIC +/// It gets the payment purse and attempts and attempts to persist it, +/// which should fail. +#[no_mangle] +pub extern "C" fn call() { + let method: String = runtime::get_named_arg(ARG_METHOD); + + if method == "install_helper" { + install_helper(); + return; + } + if method == "put_key" { + // handle payment contract + let handle_payment_contract_hash = system::get_handle_payment(); + + // get payment purse for current execution + let payment_purse: URef = runtime::call_contract( + handle_payment_contract_hash, + GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + // attempt to persist the payment purse, which should fail put_key(THIS_SHOULD_FAIL, payment_purse.into()); + } else if method == "subcall_put_key" { + let helper_hash_key = runtime::get_key(HELPER_HASH_KEY).unwrap_or_revert(); + let helper_hash = helper_hash_key + .into_entity_hash_addr() + .map(ContractHash::new) + .unwrap_or_revert(); + let payment_purse: URef = + runtime::call_contract(helper_hash, LEAK_PAYMENT_PURSE, RuntimeArgs::default()); + + // attempt to persist the payment purse returned by a stored-contract subcall + put_key(THIS_SHOULD_FAIL, payment_purse.into()); + + let amount: U512 = runtime::get_named_arg(ARG_AMOUNT); + system::transfer_from_purse_to_purse( + account::get_main_purse(), + payment_purse, + amount, + None, + ) + .unwrap_or_revert(); } else if method == "call_contract" { + // handle payment contract + let handle_payment_contract_hash = system::get_handle_payment(); + + // get payment purse for current execution + let payment_purse: URef = runtime::call_contract( + handle_payment_contract_hash, + GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + // attempt to call a contract with the payment purse, which should fail let _payment_purse: URef = runtime::call_contract( handle_payment_contract_hash, @@ -45,6 +123,16 @@ pub extern "C" fn call() { // should never reach here runtime::revert(ApiError::User(1000)); } else if method == "call_versioned_contract" { + // handle payment contract + let handle_payment_contract_hash = system::get_handle_payment(); + + // get payment purse for current execution + let payment_purse: URef = runtime::call_contract( + handle_payment_contract_hash, + GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + // attempt to call a versioned contract with the payment purse, which should fail let _payment_purse: URef = runtime::call_versioned_contract( ContractPackageHash::new(handle_payment_contract_hash.value()), diff --git a/smart_contracts/contracts/test/regression-delegate/src/main.rs b/smart_contracts/contracts/test/regression-delegate/src/main.rs index eb99fee6b2..6450a03d64 100644 --- a/smart_contracts/contracts/test/regression-delegate/src/main.rs +++ b/smart_contracts/contracts/test/regression-delegate/src/main.rs @@ -10,22 +10,34 @@ const ARG_AMOUNT: &str = "amount"; const ARG_VALIDATOR: &str = "validator"; const ARG_DELEGATOR: &str = "delegator"; +const ARG_CALL_COUNT: &str = "call_count"; -fn delegate(delegator: PublicKey, validator: PublicKey, amount: U512) { +fn delegate(delegator: PublicKey, validator: PublicKey, amount: U512, over_limit: bool) { let contract_hash = system::get_auction(); + let delegate_amount = if over_limit { + amount + U512::one() + } else { + amount + }; let args = runtime_args! { auction::ARG_DELEGATOR => delegator, auction::ARG_VALIDATOR => validator, - auction::ARG_AMOUNT => amount + U512::one(), + auction::ARG_AMOUNT => delegate_amount, }; runtime::call_contract::(contract_hash, auction::METHOD_DELEGATE, args); } #[no_mangle] pub extern "C" fn call() { - let delegator = runtime::get_named_arg(ARG_DELEGATOR); - let validator = runtime::get_named_arg(ARG_VALIDATOR); - let amount = runtime::get_named_arg(ARG_AMOUNT); + let delegator: PublicKey = runtime::get_named_arg(ARG_DELEGATOR); + let validator: PublicKey = runtime::get_named_arg(ARG_VALIDATOR); + let amount: U512 = runtime::get_named_arg(ARG_AMOUNT); - delegate(delegator, validator, amount); + if let Some(call_count) = runtime::try_get_named_arg::(ARG_CALL_COUNT) { + for _ in 0..call_count { + delegate(delegator.clone(), validator.clone(), amount, false); + } + } else { + delegate(delegator, validator, amount, true); + } } diff --git a/smart_contracts/contracts/test/regression-payment/Cargo.toml b/smart_contracts/contracts/test/regression-payment/Cargo.toml index c049c318e4..00de16bbe5 100644 --- a/smart_contracts/contracts/test/regression-payment/Cargo.toml +++ b/smart_contracts/contracts/test/regression-payment/Cargo.toml @@ -11,6 +11,34 @@ bench = false doctest = false test = false +[[bin]] +name = "overlimit_custom_payment" +path = "src/overlimit_custom_payment.rs" +bench = false +doctest = false +test = false + +[[bin]] +name = "underpaying_custom_payment" +path = "src/underpaying_custom_payment.rs" +bench = false +doctest = false +test = false + +[[bin]] +name = "split_custom_payment" +path = "src/split_custom_payment.rs" +bench = false +doctest = false +test = false + +[[bin]] +name = "overfund_custom_payment" +path = "src/overfund_custom_payment.rs" +bench = false +doctest = false +test = false + [dependencies] casper-contract = { path = "../../../contract" } casper-types = { path = "../../../../types" } diff --git a/smart_contracts/contracts/test/regression-payment/src/overfund_custom_payment.rs b/smart_contracts/contracts/test/regression-payment/src/overfund_custom_payment.rs new file mode 100644 index 0000000000..b78bf1b08c --- /dev/null +++ b/smart_contracts/contracts/test/regression-payment/src/overfund_custom_payment.rs @@ -0,0 +1,48 @@ +#![no_std] +#![no_main] + +extern crate alloc; + +use alloc::string::{String, ToString}; + +use casper_contract::{ + contract_api::{runtime, storage, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{ + system::{handle_payment, standard_payment}, + Phase, RuntimeArgs, URef, U512, +}; + +const ARG_EXTRA: &str = "extra"; +const ARG_SOURCE: &str = "source"; +const SESSION_MARKER: &str = "overfund-custom-payment-session"; + +#[no_mangle] +pub extern "C" fn call() { + match runtime::get_phase() { + Phase::Payment => { + let amount: U512 = runtime::get_named_arg(standard_payment::ARG_AMOUNT); + let extra: U512 = runtime::get_named_arg(ARG_EXTRA); + let source_name: String = runtime::get_named_arg(ARG_SOURCE); + let source_purse = runtime::get_key(&source_name) + .and_then(|key| key.into_uref()) + .unwrap_or_revert(); + let payment_purse: URef = runtime::call_contract( + system::get_handle_payment(), + handle_payment::METHOD_GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + + system::transfer_from_purse_to_purse(source_purse, payment_purse, amount + extra, None) + .unwrap_or_revert(); + } + Phase::Session => { + runtime::put_key( + SESSION_MARKER, + storage::new_uref(SESSION_MARKER.to_string()).into(), + ); + } + _ => {} + } +} diff --git a/smart_contracts/contracts/test/regression-payment/src/overlimit_custom_payment.rs b/smart_contracts/contracts/test/regression-payment/src/overlimit_custom_payment.rs new file mode 100644 index 0000000000..b002449aad --- /dev/null +++ b/smart_contracts/contracts/test/regression-payment/src/overlimit_custom_payment.rs @@ -0,0 +1,42 @@ +#![no_std] +#![no_main] + +extern crate alloc; + +use alloc::vec; + +use casper_contract::{ + contract_api::{account, runtime, storage, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{ + bytesrepr::Bytes, + system::{handle_payment, standard_payment}, + Phase, RuntimeArgs, URef, U512, +}; + +const ARG_ITERATIONS: &str = "iterations"; +const SCRATCH_BYTES: usize = 4096; + +#[no_mangle] +pub extern "C" fn call() { + if runtime::get_phase() != Phase::Payment { + return; + } + + let iterations: u32 = runtime::get_named_arg(ARG_ITERATIONS); + let scratch: Bytes = vec![0u8; SCRATCH_BYTES].into(); + let scratch_uref = storage::new_uref(scratch.clone()); + for _ in 0..iterations { + storage::write(scratch_uref, scratch.clone()); + } + + let amount: U512 = runtime::get_named_arg(standard_payment::ARG_AMOUNT); + let payment_purse: URef = runtime::call_contract( + system::get_handle_payment(), + handle_payment::METHOD_GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + system::transfer_from_purse_to_purse(account::get_main_purse(), payment_purse, amount, None) + .unwrap_or_revert(); +} diff --git a/smart_contracts/contracts/test/regression-payment/src/split_custom_payment.rs b/smart_contracts/contracts/test/regression-payment/src/split_custom_payment.rs new file mode 100644 index 0000000000..e3feb47cca --- /dev/null +++ b/smart_contracts/contracts/test/regression-payment/src/split_custom_payment.rs @@ -0,0 +1,55 @@ +#![no_std] +#![no_main] + +extern crate alloc; + +use alloc::string::ToString; + +use casper_contract::{ + contract_api::{account, runtime, storage, system}, + unwrap_or_revert::UnwrapOrRevert, +}; +use casper_types::{ + system::{handle_payment, standard_payment}, + Phase, RuntimeArgs, URef, U512, +}; + +const SESSION_MARKER: &str = "split-custom-payment-session"; + +#[no_mangle] +pub extern "C" fn call() { + match runtime::get_phase() { + Phase::Payment => { + let amount: U512 = runtime::get_named_arg(standard_payment::ARG_AMOUNT); + let payment_purse: URef = runtime::call_contract( + system::get_handle_payment(), + handle_payment::METHOD_GET_PAYMENT_PURSE, + RuntimeArgs::default(), + ); + + let first = amount / U512::from(2); + let second = amount.saturating_sub(first); + system::transfer_from_purse_to_purse( + account::get_main_purse(), + payment_purse, + first, + None, + ) + .unwrap_or_revert(); + system::transfer_from_purse_to_purse( + account::get_main_purse(), + payment_purse, + second, + None, + ) + .unwrap_or_revert(); + } + Phase::Session => { + runtime::put_key( + SESSION_MARKER, + storage::new_uref(SESSION_MARKER.to_string()).into(), + ); + } + _ => {} + } +} diff --git a/smart_contracts/contracts/test/regression-payment/src/underpaying_custom_payment.rs b/smart_contracts/contracts/test/regression-payment/src/underpaying_custom_payment.rs new file mode 100644 index 0000000000..ded3f22c36 --- /dev/null +++ b/smart_contracts/contracts/test/regression-payment/src/underpaying_custom_payment.rs @@ -0,0 +1,26 @@ +#![no_std] +#![no_main] + +extern crate alloc; + +use alloc::vec; + +use casper_contract::contract_api::{runtime, storage}; +use casper_types::{bytesrepr::Bytes, Phase}; + +const ARG_ITERATIONS: &str = "iterations"; +const SCRATCH_BYTES: usize = 4096; + +#[no_mangle] +pub extern "C" fn call() { + if runtime::get_phase() != Phase::Payment { + return; + } + + let iterations: u32 = runtime::get_named_arg(ARG_ITERATIONS); + let scratch: Bytes = vec![0u8; SCRATCH_BYTES].into(); + let scratch_uref = storage::new_uref(scratch.clone()); + for _ in 0..iterations { + storage::write(scratch_uref, scratch.clone()); + } +} diff --git a/storage/Cargo.toml b/storage/Cargo.toml index 39b141f0ea..6c78327586 100644 --- a/storage/Cargo.toml +++ b/storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-storage" -version = "5.0.0" +version = "5.0.1" edition = "2018" authors = ["Ed Hastings "] description = "Storage for a node on the Casper network." @@ -12,7 +12,7 @@ license = "Apache-2.0" [dependencies] bincode = "1.3.1" -casper-types = { version = "7.0.0", path = "../types", features = ["datasize", "json-schema", "std"] } +casper-types = { version = "7.1.1", path = "../types", features = ["datasize", "json-schema", "std"] } datasize = "0.2.4" either = "1.8.1" lmdb-rkv = "0.14" @@ -42,7 +42,7 @@ rand = "0.8.3" serde_json = "1" base16 = "0.2.1" criterion = { version = "0.5.1", features = ["html_reports"] } -pprof = { version = "0.14.0", features = ["flamegraph", "criterion"] } +pprof = { version = "0.15.0", features = ["flamegraph", "criterion"] } [package.metadata.docs.rs] all-features = true diff --git a/storage/src/block_store/types/approvals_hashes.rs b/storage/src/block_store/types/approvals_hashes.rs index 3398dfcf7f..2ac116a7fc 100644 --- a/storage/src/block_store/types/approvals_hashes.rs +++ b/storage/src/block_store/types/approvals_hashes.rs @@ -99,6 +99,7 @@ impl ApprovalsHashes { &self, v1_block: &BlockV1, ) -> Result, ApprovalsHashesValidationError> { + self.ensure_approvals_hashes_len(v1_block.deploy_and_transfer_hashes().count())?; let deploy_approvals_hashes = self.approvals_hashes.clone(); Ok(v1_block .deploy_and_transfer_hashes() @@ -114,6 +115,7 @@ impl ApprovalsHashes { &self, v2_block: &BlockV2, ) -> Result, ApprovalsHashesValidationError> { + self.ensure_approvals_hashes_len(v2_block.all_transactions().count())?; v2_block .all_transactions() .zip(self.approvals_hashes.clone()) @@ -123,6 +125,19 @@ impl ApprovalsHashes { .collect() } + fn ensure_approvals_hashes_len( + &self, + expected: usize, + ) -> Result<(), ApprovalsHashesValidationError> { + let actual = self.approvals_hashes.len(); + if actual != expected { + return Err( + ApprovalsHashesValidationError::ApprovalsHashesLengthMismatch { expected, actual }, + ); + } + Ok(()) + } + /// Block hash. pub fn block_hash(&self) -> &BlockHash { &self.block_hash @@ -226,6 +241,17 @@ pub enum ApprovalsHashesValidationError { value_in_proof: Digest, }, + /// The number of approvals hashes doesn't match the number of block transactions. + #[error( + "approvals hashes count doesn't match block transaction count: expected {expected}, actual {actual}" + )] + ApprovalsHashesLengthMismatch { + /// The number of transactions in the block. + expected: usize, + /// The number of approvals hashes. + actual: usize, + }, + /// Variant mismatch. #[error("mismatch in variants: {0:?}")] #[data_size(skip)] @@ -251,3 +277,142 @@ impl From for ApprovalsHashes { ApprovalsHashes::new(block_hash, approvals_hashes, merkle_proof_approvals) } } + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, VecDeque}; + + use casper_types::{ + global_state::TrieMerkleProof, testing::TestRng, ApprovalsHash, CLValue, ChecksumRegistry, + StoredValue, TestBlockBuilder, Transaction, + }; + + use super::*; + + fn approvals_hashes_for_block( + transactions: &[Transaction], + block: &Block, + ) -> Vec { + let transaction_approvals_hashes: BTreeMap<_, _> = transactions + .iter() + .map(|transaction| { + ( + transaction.hash(), + transaction + .compute_approvals_hash() + .expect("should compute approvals hash"), + ) + }) + .collect(); + + match block { + Block::V1(_) => unreachable!("test helper builds V2 blocks"), + Block::V2(v2_block) => v2_block + .all_transactions() + .map(|txn_hash| { + *transaction_approvals_hashes + .get(txn_hash) + .expect("transaction should be in block") + }) + .collect(), + } + } + + fn approvals_hashes_with_proof( + rng: &mut TestRng, + transactions: &[Transaction], + supplied_approvals_hashes: Vec, + registry_approvals_hashes: Vec, + ) -> (Block, ApprovalsHashes) { + let ordering_block = TestBlockBuilder::new() + .transactions(transactions) + .build_versioned(rng); + let transaction_hashes: Vec<_> = match &ordering_block { + Block::V1(_) => unreachable!("test helper builds V2 blocks"), + Block::V2(v2_block) => v2_block.all_transactions().copied().collect(), + }; + let transaction_ids = transaction_hashes + .into_iter() + .zip(registry_approvals_hashes) + .map(|(txn_hash, txn_approvals_hash)| TransactionId::new(txn_hash, txn_approvals_hash)) + .collect(); + let approvals_checksum = + compute_approvals_checksum(transaction_ids).expect("should compute checksum"); + + let mut checksum_registry = ChecksumRegistry::new(); + checksum_registry.insert(APPROVALS_CHECKSUM_NAME, approvals_checksum); + let merkle_proof_approvals = TrieMerkleProof::new( + Key::ChecksumRegistry, + StoredValue::CLValue( + CLValue::from_t(checksum_registry).expect("should build checksum registry"), + ), + VecDeque::new(), + ); + let state_root_hash = + compute_state_hash(&merkle_proof_approvals).expect("should compute state root hash"); + + let block = TestBlockBuilder::new() + .state_root_hash(state_root_hash) + .transactions(transactions) + .build_versioned(rng); + + let approvals_hashes = ApprovalsHashes::new( + *block.hash(), + supplied_approvals_hashes, + merkle_proof_approvals, + ); + + (block, approvals_hashes) + } + + #[test] + fn verify_rejects_extra_approvals_hashes() { + let rng = &mut TestRng::new(); + let transactions: Vec<_> = (0..3).map(|_| Transaction::random(rng)).collect(); + let ordering_block = TestBlockBuilder::new() + .transactions(&transactions) + .build_versioned(rng); + let valid_approvals_hashes = approvals_hashes_for_block(&transactions, &ordering_block); + + let mut supplied_approvals_hashes = valid_approvals_hashes.clone(); + supplied_approvals_hashes.push(ApprovalsHash::from(Digest::from([42; Digest::LENGTH]))); + let (block, approvals_hashes) = approvals_hashes_with_proof( + rng, + &transactions, + supplied_approvals_hashes, + valid_approvals_hashes, + ); + + let result = approvals_hashes.verify(&block); + assert!( + result.is_err(), + "verify must reject overlong ApprovalsHashes, got {:?}", + result, + ); + } + + #[test] + fn verify_rejects_missing_approvals_hashes() { + let rng = &mut TestRng::new(); + let transactions: Vec<_> = (0..3).map(|_| Transaction::random(rng)).collect(); + let ordering_block = TestBlockBuilder::new() + .transactions(&transactions) + .build_versioned(rng); + let valid_approvals_hashes = approvals_hashes_for_block(&transactions, &ordering_block); + + let supplied_approvals_hashes = valid_approvals_hashes[..2].to_vec(); + let (block, approvals_hashes) = approvals_hashes_with_proof( + rng, + &transactions, + supplied_approvals_hashes, + valid_approvals_hashes, + ); + + let result = approvals_hashes.verify(&block); + assert!( + result.is_err(), + "verify must reject missing ApprovalsHashes entries, got {:?}", + result, + ); + } +} diff --git a/storage/src/lib.rs b/storage/src/lib.rs index fc242f4336..f43ec041c1 100644 --- a/storage/src/lib.rs +++ b/storage/src/lib.rs @@ -1,6 +1,6 @@ //! Storage for a node on the Casper network. -#![doc(html_root_url = "https://docs.rs/casper-storage/5.0.0")] +#![doc(html_root_url = "https://docs.rs/casper-storage/5.0.1")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/master/images/CasperLabs_Logo_Favicon_RGB_50px.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/master/images/CasperLabs_Logo_Symbol_RGB.png" diff --git a/storage/src/system/auction.rs b/storage/src/system/auction.rs index d8dd519ecf..9e8991ac94 100644 --- a/storage/src/system/auction.rs +++ b/storage/src/system/auction.rs @@ -226,6 +226,12 @@ pub trait Auction: return Err(Error::InvalidContext); } + // Mirror the zero-amount guard already applied to `add_bid` / `delegate`. Otherwise a + // zero withdraw would still persist a meaningless validator unbond era. + if amount.is_zero() { + return Err(Error::BondTooSmall); + } + let validator_bid_addr = BidAddr::from(public_key.clone()); let validator_bid_key = validator_bid_addr.into(); let mut validator_bid = read_validator_bid(self, &validator_bid_key)?; @@ -568,6 +574,27 @@ pub trait Auction: detail::process_unbond_requests(self, max_delegators_per_validator)?; debug!("processing unbond request successful"); + // Resolve any evicted public key that may identify a validator only via its prior + // (pre-rotation) key through the existing bridge chain. Without this step, an eviction + // for the old key would be silently ignored because the active bid lives under a new key. + let mut resolved_evicted: Vec = Vec::with_capacity(evicted_validators.len()); + for evicted in evicted_validators.iter() { + resolved_evicted.push(evicted.clone()); + let mut current_addr = BidAddr::from(evicted.clone()); + for _ in 0..detail::MAX_BRIDGE_CHAIN_LENGTH { + match self.read_bid(¤t_addr.into())? { + Some(BidKind::Bridge(bridge)) => { + let new_key = bridge.new_validator_public_key().clone(); + current_addr = BidAddr::from(new_key.clone()); + if !resolved_evicted.contains(&new_key) { + resolved_evicted.push(new_key); + } + } + _ => break, + } + } + } + let mut validator_bids_detail = detail::get_validator_bids(self, era_id)?; // Process bids @@ -584,7 +611,7 @@ pub trait Auction: bids_modified = true; } - if evicted_validators.contains(validator_public_key) { + if resolved_evicted.contains(validator_public_key) { validator_bid.deactivate(); bids_modified = true; } @@ -654,7 +681,10 @@ pub trait Auction: let total = { let mut ret = U512::zero(); - for rewards_vec in rewards.values() { + for (public_key, rewards_vec) in rewards.iter() { + if public_key == &PublicKey::System { + continue; + } for reward in rewards_vec { ret += *reward } @@ -710,10 +740,15 @@ pub trait Auction: let validator_bid_addr = BidAddr::Validator(validator_public_key.to_account_hash()); let mut maybe_bridged_validator_addrs: Option> = None; + // The validator key to use for any same-call undelegation effects. For a bridged + // validator this is the *current* (post-rotation) key, not the stale reward-map key + // - otherwise the queued `undelegate` call below would look up a bid under the old + // key and fail with `ValidatorNotFound`. + let mut current_validator_public_key = validator_public_key.clone(); let validator_reward_amount = reward_info.validator_reward(); let (validator_bonding_purse, min_del, max_del) = match detail::get_distribution_target(self, validator_bid_addr) { - Ok(target) => match target { + Ok((bridged_addrs, target)) => match target { DistributeTarget::Validator(mut validator_bid) => { debug!(?validator_public_key, "validator payout starting "); let validator_bonding_purse = *validator_bid.bonding_purse(); @@ -737,6 +772,8 @@ pub trait Auction: } => { debug!(?validator_public_key, "bridged validator payout starting "); maybe_bridged_validator_addrs = Some(bridged_validator_addrs); // <-- important + current_validator_public_key = + validator_bid.validator_public_key().clone(); let validator_bonding_purse = *validator_bid.bonding_purse(); validator_bid.increase_stake(validator_reward_amount)?; @@ -750,28 +787,41 @@ pub trait Auction: validator_bid.maximum_delegation_amount().into(), ) } - DistributeTarget::Unbond(unbond) => match unbond.target_unbond_era() { - Some(mut unbond_era) => { - let account_hash = validator_public_key.to_account_hash(); - let unbond_addr = BidAddr::UnbondAccount { + DistributeTarget::Unbond(mut unbond) => { + maybe_bridged_validator_addrs = Some(bridged_addrs); + let account_hash = unbond.validator_public_key().to_account_hash(); + let unbond_addr = match unbond.unbond_kind() { + UnbondKind::Validator(public_key) + | UnbondKind::DelegatedPublicKey(public_key) => { + BidAddr::UnbondAccount { + validator: account_hash, + unbonder: public_key.to_account_hash(), + } + } + UnbondKind::DelegatedPurse(purse) => BidAddr::UnbondPurse { validator: account_hash, - unbonder: account_hash, - }; - let validator_bonding_purse = *unbond_era.bonding_purse(); - let new_amount = - unbond_era.amount().saturating_add(validator_reward_amount); - unbond_era.with_amount(new_amount); - self.write_unbond(unbond_addr, Some(*unbond.clone()))?; - (validator_bonding_purse, U512::MAX, U512::MAX) - } - None => { - warn!( - ?validator_public_key, - "neither validator bid or unbond found" - ); - continue; + unbonder: *purse, + }, + }; + + match unbond.target_unbond_era_mut() { + Some(unbond_era) => { + let validator_bonding_purse = *unbond_era.bonding_purse(); + let new_amount = + unbond_era.amount().saturating_add(validator_reward_amount); + unbond_era.with_amount(new_amount); + self.write_unbond(unbond_addr, Some(*unbond.clone()))?; + (validator_bonding_purse, U512::MAX, U512::MAX) + } + None => { + warn!( + ?validator_public_key, + "neither validator bid or unbond found" + ); + continue; + } } - }, + } DistributeTarget::Delegator(_) => { return Err(Error::UnexpectedBidVariant); } @@ -815,7 +865,7 @@ pub trait Auction: } else { let delegator_bid_key = delegator_bid_addr.into(); match detail::get_distribution_target(self, delegator_bid_addr) { - Ok(target) => match target { + Ok((_bridged_addrs, target)) => match target { DistributeTarget::Delegator(mut delegator_bid) => { let delegator_bonding_purse = *delegator_bid.bonding_purse(); let increased_stake = @@ -825,7 +875,7 @@ pub trait Auction: // prune undelegates.push(( delegator_kind.clone(), - validator_public_key.clone(), + current_validator_public_key.clone(), increased_stake, )); prunes.push(delegator_bid_addr); @@ -835,7 +885,7 @@ pub trait Auction: if !unbond_amount.is_zero() { undelegates.push(( delegator_kind.clone(), - validator_public_key.clone(), + current_validator_public_key.clone(), unbond_amount, )); } @@ -973,6 +1023,13 @@ pub trait Auction: public_key: PublicKey, new_public_key: PublicKey, ) -> Result<(), Error> { + // The normal `add_bid` path cannot create a `PublicKey::System` validator bid (the + // caller must own the validator key). Reject the same identity in the key-rotation + // path so a validator can't move its active bid into the reserved system identity. + if new_public_key == PublicKey::System { + return Err(Error::InvalidPublicKey); + } + let validator_account_hash = AccountHash::from(&public_key); // check that the caller is the current bid's owner @@ -1035,6 +1092,25 @@ pub trait Auction: self.prune_bid(delegator_bid_addr); } + debug!("transferring reservation bids from validator bid {validator_bid_addr} to {new_validator_bid_addr}"); + let reservations = detail::read_reservation_bids(self, &public_key)?; + for mut reservation in reservations { + let reservation_bid_addr = + BidAddr::new_reservation_kind(&public_key, reservation.delegator_kind()); + + reservation.with_validator_public_key(new_public_key.clone()); + let new_reservation_bid_addr = + BidAddr::new_reservation_kind(&new_public_key, reservation.delegator_kind()); + + self.write_bid( + new_reservation_bid_addr.into(), + BidKind::Reservation(Box::new(reservation)), + )?; + + debug!("pruning reservation bid {reservation_bid_addr}"); + self.prune_bid(reservation_bid_addr); + } + Ok(()) } diff --git a/storage/src/system/auction/detail.rs b/storage/src/system/auction/detail.rs index 8b29d5ba00..48fe3730cd 100644 --- a/storage/src/system/auction/detail.rs +++ b/storage/src/system/auction/detail.rs @@ -22,7 +22,7 @@ use tracing::{debug, error, warn}; /// Maximum length of bridge records chain. /// Used when looking for the most recent bid record to avoid unbounded computations. -const MAX_BRIDGE_CHAIN_LENGTH: u64 = 20; +pub(super) const MAX_BRIDGE_CHAIN_LENGTH: u64 = 20; fn read_from(provider: &mut P, name: &str) -> Result where @@ -630,27 +630,30 @@ impl DistributeTarget { pub fn get_distribution_target( provider: &mut P, bid_addr: BidAddr, -) -> Result { +) -> Result<(Vec, DistributeTarget), Error> { let mut bridged_addrs = vec![]; let mut current_validator_bid_addr = bid_addr; for _ in 0..MAX_BRIDGE_CHAIN_LENGTH { match provider.read_bid(¤t_validator_bid_addr.into())? { Some(BidKind::Validator(validator_bid)) => { if !bridged_addrs.is_empty() { - return Ok(DistributeTarget::BridgedValidator { - requested_validator_bid_addr: bid_addr, - current_validator_bid_addr, - bridged_validator_addrs: bridged_addrs, - validator_bid, - }); + return Ok(( + bridged_addrs.clone(), + DistributeTarget::BridgedValidator { + requested_validator_bid_addr: bid_addr, + current_validator_bid_addr, + bridged_validator_addrs: bridged_addrs, + validator_bid, + }, + )); } - return Ok(DistributeTarget::Validator(validator_bid)); + return Ok((bridged_addrs, DistributeTarget::Validator(validator_bid))); } Some(BidKind::Delegator(delegator_bid)) => { - return Ok(DistributeTarget::Delegator(delegator_bid)); + return Ok((bridged_addrs, DistributeTarget::Delegator(delegator_bid))); } Some(BidKind::Unbond(unbond)) => { - return Ok(DistributeTarget::Unbond(unbond)); + return Ok((bridged_addrs, DistributeTarget::Unbond(unbond))); } Some(BidKind::Bridge(bridge)) => { current_validator_bid_addr = @@ -659,7 +662,7 @@ pub fn get_distribution_target( } None => { // in the case of missing validator or delegator bids, check unbonds - if let BidAddr::Validator(account_hash) = bid_addr { + if let BidAddr::Validator(account_hash) = current_validator_bid_addr { let validator_unbond_key = BidAddr::UnbondAccount { validator: account_hash, unbonder: account_hash, @@ -668,7 +671,7 @@ pub fn get_distribution_target( if let Some(BidKind::Unbond(unbond)) = provider.read_bid(&validator_unbond_key)? { - return Ok(DistributeTarget::Unbond(unbond)); + return Ok((bridged_addrs, DistributeTarget::Unbond(unbond))); } return Err(Error::ValidatorNotFound); } @@ -676,6 +679,8 @@ pub fn get_distribution_target( if let BidAddr::DelegatedAccount { validator, delegator, + // This fine to be the original bid addr for the delegator since + // we fetch those of the validator bid } = bid_addr { let delegator_unbond_key = BidAddr::UnbondAccount { @@ -686,7 +691,7 @@ pub fn get_distribution_target( if let Some(BidKind::Unbond(unbond)) = provider.read_bid(&delegator_unbond_key)? { - return Ok(DistributeTarget::Unbond(unbond)); + return Ok((bridged_addrs, DistributeTarget::Unbond(unbond))); } return Err(Error::DelegatorNotFound); } @@ -704,7 +709,7 @@ pub fn get_distribution_target( if let Some(BidKind::Unbond(unbond)) = provider.read_bid(&delegator_unbond_key)? { - return Ok(DistributeTarget::Unbond(unbond)); + return Ok((bridged_addrs, DistributeTarget::Unbond(unbond))); } return Err(Error::DelegatorNotFound); } @@ -1423,6 +1428,19 @@ pub fn process_updated_delegator_stake_boundaries( continue; } + // Reduce the delegator's active stake *before* writing the unbond record. Otherwise a + // locked delegator (vesting table uninitialized but era past validator lockout) leaves + // an unbond pending against unchanged active stake - the same motes are both still + // counted as delegated and queued for release once the unbond matures. + let updated_stake = match delegator.decrease_stake(unbond_amount, era_end_timestamp_millis) + { + Ok(updated_stake) => updated_stake, + // Work around the case when the locked amounts table has yet to be + // initialized (likely pre-90 day mark). + Err(Error::DelegatorFundsLocked) => continue, + Err(err) => return Err(err), + }; + let unbond_kind = delegator.unbond_kind(); create_unbonding_purse( provider, @@ -1433,15 +1451,6 @@ pub fn process_updated_delegator_stake_boundaries( None, )?; - let updated_stake = match delegator.decrease_stake(unbond_amount, era_end_timestamp_millis) - { - Ok(updated_stake) => updated_stake, - // Work around the case when the locked amounts table has yet to be - // initialized (likely pre-90 day mark). - Err(Error::DelegatorFundsLocked) => continue, - Err(err) => return Err(err), - }; - let delegator_bid_addr = delegator.bid_addr(); if updated_stake.is_zero() { debug!("pruning delegator bid {delegator_bid_addr}"); diff --git a/storage/src/system/handle_payment.rs b/storage/src/system/handle_payment.rs index a04225922c..872ca79caf 100644 --- a/storage/src/system/handle_payment.rs +++ b/storage/src/system/handle_payment.rs @@ -9,7 +9,7 @@ pub mod storage_provider; use casper_types::{ system::handle_payment::{Error, REFUND_PURSE_KEY}, - AccessRights, PublicKey, URef, U512, + AccessRights, Phase, PublicKey, URef, U512, }; use num_rational::Ratio; use tracing::error; @@ -23,6 +23,16 @@ use crate::system::handle_payment::{ pub trait HandlePayment: MintProvider + RuntimeProvider + StorageProvider + Sized { /// Get payment purse. fn get_payment_purse(&mut self) -> Result { + // Restrict to payment / finalize-payment / system contexts. Session code that resolves + // the payment purse can transfer into it; finalization only accounts for the expected + // payment amount, so stray deposits become a persistent nonzero balance in the shared + // system payment purse - hostile state for later custom-payment paths. + match self.get_phase() { + Phase::Payment | Phase::FinalizePayment | Phase::System => {} + Phase::Session => { + return Err(Error::GetPaymentPurseCalledOutsidePayment); + } + } let purse = internal::get_payment_purse(self)?; // Limit the access rights so only balance query and deposit are allowed. Ok(URef::new(purse.addr(), AccessRights::READ_ADD)) @@ -33,7 +43,13 @@ pub trait HandlePayment: MintProvider + RuntimeProvider + StorageProvider + Size // make sure the passed uref is actually a purse... // if it has a balance it is a purse and if not it isn't let _balance = self.available_balance(purse)?; - internal::set_refund(self, purse) + // Refund finalization only needs deposit authority; the original URef may be writeable + // (it commonly resolves to the initiator main purse for the default refund target). + // Strip access rights down to `ADD` so the refund-purse named-key write the handle + // payment contract emits does not expose a writeable main-purse capability through + // public execution effects. + let refund_purse = URef::new(purse.addr(), AccessRights::ADD); + internal::set_refund(self, refund_purse) } /// Get refund purse. diff --git a/storage/src/system/mint.rs b/storage/src/system/mint.rs index dbc2e5d6d0..63b6e85e85 100644 --- a/storage/src/system/mint.rs +++ b/storage/src/system/mint.rs @@ -65,18 +65,42 @@ pub trait Mint: RuntimeProvider + StorageProvider + SystemProvider { return Err(Error::ForgedReference); } - let source_available_balance: U512 = match self.balance(purse)? { + let source_available_balance: U512 = match self.available_balance(purse)? { Some(source_balance) => source_balance, None => return Err(Error::PurseNotFound), }; + let source_total_balance = self.total_balance(purse)?; + // The burned amount is capped at the available balance so a caller cannot consume motes + // currently held by a balance hold. + let burned_amount = if amount > source_available_balance { + source_available_balance + } else { + amount + }; + + // Apply the transaction's approved-spending-limit cap to mint burn from the caller's main + // purse, mirroring `Mint::transfer`. Without this check a non-system caller could burn + // arbitrarily large amounts from its own main purse with a tiny transaction + // `amount`, turning burn into an uncapped debit path that bypasses the spending limit. + if self.get_caller() != PublicKey::System.to_account_hash() { + let main_purse_addr = match self.get_main_purse() { + None => return Err(Error::InvalidURef), + Some(uref) => uref.addr(), + }; + if main_purse_addr == purse.addr() { + if burned_amount > self.get_approved_spending_limit() { + return Err(Error::UnapprovedSpendingAmount); + } + self.sub_approved_spending_limit(burned_amount); + } + } - let new_balance = source_available_balance - .checked_sub(amount) - .unwrap_or_else(U512::zero); + // The new purse total balance must be computed from the *total* balance, not the + // available balance: otherwise any active hold on the purse is silently erased. + let new_balance = source_total_balance.saturating_sub(burned_amount); // change balance self.write_balance(purse, new_balance)?; // reduce total supply AFTER changing balance in case changing balance errors - let burned_amount = source_available_balance.saturating_sub(new_balance); detail::reduce_total_supply_unsafe(self, burned_amount) } @@ -219,9 +243,6 @@ pub trait Mint: RuntimeProvider + StorageProvider + SystemProvider { } if !source.is_writeable() || !target.is_addable() { - // TODO: I don't think we should enforce is addable on the target - // Unlike other uses of URefs (such as a counter), in this context the value represents - // a deposit of token. Generally, deposit of a desirable resource is permissive. return Err(Error::InvalidAccessRights); } let source_available_balance: U512 = match self.available_balance(source)? { diff --git a/storage/src/tracking_copy/mod.rs b/storage/src/tracking_copy/mod.rs index c7be3cfe9e..783a4409ba 100644 --- a/storage/src/tracking_copy/mod.rs +++ b/storage/src/tracking_copy/mod.rs @@ -541,6 +541,29 @@ where Ok(ret) } + /// Returns all keys with `byte_prefix`, merging the backing state reader with the local cache: + /// keys marked for pruning in this execution are filtered out and cached writes are included. + /// Use this instead of `reader().keys_with_prefix(...)` whenever auction or other validation + /// must reflect the *current* in-execution state. + pub fn keys_with_prefix_cached( + &self, + byte_prefix: &[u8], + ) -> Result, TrackingCopyError> { + let keys = StateReader::::keys_with_prefix(&self, byte_prefix) + .map_err(TrackingCopyError::Storage)?; + // The cache-aware `keys_with_prefix` chains backing-state keys with cached writes, so a + // key that was both read from state and re-written in the current execution would appear + // twice. Dedup via the normalized form before returning. + let mut seen: BTreeSet = BTreeSet::new(); + let mut deduped = Vec::with_capacity(keys.len()); + for key in keys { + if seen.insert(key.normalize()) { + deduped.push(key); + } + } + Ok(deduped) + } + /// Reads the value stored under `key`. pub fn read(&mut self, key: &Key) -> Result, TrackingCopyError> { let normalized_key = key.normalize(); diff --git a/types/Cargo.toml b/types/Cargo.toml index 0c72a916c5..b6cf2a4ddd 100644 --- a/types/Cargo.toml +++ b/types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "casper-types" -version = "7.0.0" # when updating, also update 'html_root_url' in lib.rs +version = "7.1.1" # when updating, also update 'html_root_url' in lib.rs authors = ["Ed Hastings "] edition = "2021" description = "Types shared by many casper crates for use on the Casper network." diff --git a/types/src/block/rewarded_signatures.rs b/types/src/block/rewarded_signatures.rs index bbdab5153f..42b639fcd8 100644 --- a/types/src/block/rewarded_signatures.rs +++ b/types/src/block/rewarded_signatures.rs @@ -22,6 +22,16 @@ use tracing::error; #[cfg_attr(feature = "json-schema", derive(JsonSchema))] pub struct RewardedSignatures(Vec); +impl RewardedSignatures { + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + /// List of identifiers for finality signatures for a particular past block. /// /// That past block height is current_height - signature_rewards_max_delay, the latter being defined @@ -35,7 +45,7 @@ pub struct RewardedSignatures(Vec); pub struct SingleBlockRewardedSignatures(Vec); impl SingleBlockRewardedSignatures { - /// Creates a new set of recorded finality signaures from the era's validators + + /// Creates a new set of recorded finality signatures from the era's validators + /// the list of validators which signed. pub fn from_validator_set<'a>( public_keys: &BTreeSet, diff --git a/types/src/lib.rs b/types/src/lib.rs index 5b038d9f21..8bd917cdb0 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -10,7 +10,7 @@ )), no_std )] -#![doc(html_root_url = "https://docs.rs/casper-types/7.0.0")] +#![doc(html_root_url = "https://docs.rs/casper-types/7.1.1")] #![doc( html_favicon_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon_48.png", html_logo_url = "https://raw.githubusercontent.com/casper-network/casper-node/blob/dev/images/Casper_Logo_Favicon.png" diff --git a/types/src/system/auction/bid_addr.rs b/types/src/system/auction/bid_addr.rs index 2bfe2a3ef6..2eeff42fdb 100644 --- a/types/src/system/auction/bid_addr.rs +++ b/types/src/system/auction/bid_addr.rs @@ -315,6 +315,14 @@ impl BidAddr { } } + /// Create a new reservation [`BidAddr`] from a [`DelegatorKind`]. + pub fn new_reservation_kind(validator: &PublicKey, delegator_kind: &DelegatorKind) -> Self { + match delegator_kind { + DelegatorKind::PublicKey(pk) => BidAddr::new_reservation_account(validator, pk), + DelegatorKind::Purse(addr) => BidAddr::new_reservation_purse(validator, *addr), + } + } + /// Create a new instance of a [`BidAddr`]. pub fn new_reservation_account(validator: &PublicKey, delegator: &PublicKey) -> Self { BidAddr::ReservedDelegationAccount { diff --git a/types/src/system/auction/reservation.rs b/types/src/system/auction/reservation.rs index 01850aa139..02c9dd9c10 100644 --- a/types/src/system/auction/reservation.rs +++ b/types/src/system/auction/reservation.rs @@ -57,6 +57,12 @@ impl Reservation { &self.validator_public_key } + /// Replaces the validator public key. + pub fn with_validator_public_key(&mut self, validator_public_key: PublicKey) -> &mut Self { + self.validator_public_key = validator_public_key; + self + } + /// Gets the delegation rate of the provided bid pub fn delegation_rate(&self) -> &DelegationRate { &self.delegation_rate diff --git a/types/src/system/handle_payment/error.rs b/types/src/system/handle_payment/error.rs index 4f1305b3e0..af4b7d4255 100644 --- a/types/src/system/handle_payment/error.rs +++ b/types/src/system/handle_payment/error.rs @@ -268,6 +268,14 @@ pub enum Error { /// assert_eq!(40, Error::AttemptToPersistPaymentPurse as u8); /// ``` AttemptToPersistPaymentPurse = 40, + /// `get_payment_purse` was called outside of `Phase::Payment` (or + /// `Phase::FinalizePayment` / system contexts), so session code cannot use it as a way to + /// strand funds in the shared system payment purse. + /// ``` + /// # use casper_types::system::handle_payment::Error; + /// assert_eq!(41, Error::GetPaymentPurseCalledOutsidePayment as u8); + /// ``` + GetPaymentPurseCalledOutsidePayment = 41, } impl Display for Error { @@ -346,6 +354,9 @@ impl Display for Error { Error::AttemptToPersistPaymentPurse => { formatter.write_str("Attempt to persist payment purse") } + Error::GetPaymentPurseCalledOutsidePayment => { + formatter.write_str("get_payment_purse called outside of payment phase") + } } } } @@ -425,6 +436,9 @@ impl TryFrom for Error { v if v == Error::AttemptToPersistPaymentPurse as u8 => { Error::AttemptToPersistPaymentPurse } + v if v == Error::GetPaymentPurseCalledOutsidePayment as u8 => { + Error::GetPaymentPurseCalledOutsidePayment + } _ => return Err(()), }; Ok(error) diff --git a/types/src/transaction/deploy.rs b/types/src/transaction/deploy.rs index 9b683c584d..9d434c29b5 100644 --- a/types/src/transaction/deploy.rs +++ b/types/src/transaction/deploy.rs @@ -1715,6 +1715,10 @@ fn validate_deploy(deploy: &Deploy) -> Result<(), InvalidDeploy> { return Err(InvalidDeploy::EmptyApprovals); } + if deploy.header().account() == &PublicKey::System { + return Err(InvalidDeploy::InvalidInitiator); + } + deploy.has_valid_hash()?; for (index, approval) in deploy.approvals.iter().enumerate() { diff --git a/types/src/transaction/deploy/error.rs b/types/src/transaction/deploy/error.rs index d81d492f1e..de4d6e4417 100644 --- a/types/src/transaction/deploy/error.rs +++ b/types/src/transaction/deploy/error.rs @@ -161,6 +161,9 @@ pub enum InvalidDeploy { /// Pricing mode not supported PricingModeNotSupported, + + /// Invalid initiator for the deploy + InvalidInitiator, } impl Display for InvalidDeploy { @@ -302,6 +305,7 @@ impl Display for InvalidDeploy { } InvalidDeploy::InvalidPaymentAmount => write!(formatter, "invalid payment amount",), InvalidDeploy::PricingModeNotSupported => write!(formatter, "pricing mode not supported",), + InvalidDeploy::InvalidInitiator => write!(formatter, "invalid initiator") } } } @@ -342,7 +346,8 @@ impl StdError for InvalidDeploy { | InvalidDeploy::NoLaneMatch | InvalidDeploy::ExceededLaneGasLimit { .. } | InvalidDeploy::InvalidPaymentAmount - | InvalidDeploy::PricingModeNotSupported => None, + | InvalidDeploy::PricingModeNotSupported + | InvalidDeploy::InvalidInitiator => None, } } } diff --git a/types/src/transaction/transaction_v1.rs b/types/src/transaction/transaction_v1.rs index 41d4b7ac30..af22bfbdbd 100644 --- a/types/src/transaction/transaction_v1.rs +++ b/types/src/transaction/transaction_v1.rs @@ -50,7 +50,7 @@ use super::{ }; #[cfg(any(feature = "std", feature = "testing", test))] use crate::bytesrepr::Bytes; -use crate::{Digest, DisplayIter, SecretKey, TimeDiff, Timestamp}; +use crate::{Digest, DisplayIter, PublicKey, SecretKey, TimeDiff, Timestamp}; pub use errors_v1::{ DecodeFromJsonErrorV1 as TransactionV1DecodeFromJsonError, ErrorV1 as TransactionV1Error, @@ -202,6 +202,43 @@ impl TransactionV1 { transaction } + #[cfg(any(feature = "std", test, feature = "testing"))] + pub(crate) fn build_with_system_initiator( + chain_name: String, + timestamp: Timestamp, + ttl: TimeDiff, + pricing_mode: PricingMode, + fields: BTreeMap, + should_use_public_key: bool, + initiator_addr_and_secret_key: InitiatorAddrAndSecretKey, + ) -> TransactionV1 { + let initiator_addr = if should_use_public_key { + InitiatorAddr::PublicKey(PublicKey::System) + } else { + InitiatorAddr::AccountHash(PublicKey::System.to_account_hash()) + }; + let transaction_v1_payload = TransactionV1Payload::new( + chain_name, + timestamp, + ttl, + pricing_mode, + initiator_addr, + fields, + ); + let hash = Digest::hash( + transaction_v1_payload + .to_bytes() + .unwrap_or_else(|error| panic!("should serialize body: {}", error)), + ); + let mut transaction = + TransactionV1::new(hash.into(), transaction_v1_payload, BTreeSet::new()); + + if let Some(secret_key) = initiator_addr_and_secret_key.secret_key() { + transaction.sign(secret_key); + } + transaction + } + /// Adds a signature of this transaction's hash to its approvals. pub fn sign(&mut self, secret_key: &SecretKey) { let approval = Approval::create(&self.hash.into(), secret_key); @@ -290,7 +327,7 @@ impl TransactionV1 { let container = FieldsContainer::random(rng); let initiator_addr_and_secret_key = InitiatorAddrAndSecretKey::SecretKey(&secret_key); let pricing_mode = PricingMode::Fixed { - gas_price_tolerance: 5, + gas_price_tolerance: 1, additional_computation_factor: 0, }; TransactionV1::build( @@ -319,7 +356,7 @@ impl TransactionV1 { let container = FieldsContainer::random_of_lane(rng, lane); let initiator_addr_and_secret_key = InitiatorAddrAndSecretKey::SecretKey(&secret_key); let pricing_mode = PricingMode::Fixed { - gas_price_tolerance: 5, + gas_price_tolerance: 1, additional_computation_factor: 0, }; TransactionV1::build( @@ -332,6 +369,37 @@ impl TransactionV1 { ) } + #[cfg(any(all(feature = "std", feature = "testing"), test))] + pub fn random_with_system_initiator( + rng: &mut TestRng, + should_use_public_key: bool, + maybe_timestamp: Option, + ttl: Option, + ) -> Self { + let secret_key = SecretKey::random(rng); + let timestamp = maybe_timestamp.unwrap_or_else(Timestamp::now); + let ttl_millis = ttl.map_or( + rng.gen_range(60_000..TransactionConfig::default().max_ttl.millis()), + |ttl| ttl.millis(), + ); + let container = FieldsContainer::random_of_lane(rng, MINT_LANE_ID); + let initiator_addr_and_secret_key = InitiatorAddrAndSecretKey::SecretKey(&secret_key); + let pricing_mode = PricingMode::PaymentLimited { + payment_amount: 10_000_000_000u64, + gas_price_tolerance: 1, + standard_payment: false, + }; + TransactionV1::build_with_system_initiator( + "casper-example".to_string(), + timestamp, + TimeDiff::from_millis(ttl_millis), + pricing_mode, + container.to_map().unwrap(), + should_use_public_key, + initiator_addr_and_secret_key, + ) + } + #[cfg(any(all(feature = "std", feature = "testing"), test))] pub fn random_with_timestamp_and_ttl( rng: &mut TestRng, @@ -443,6 +511,19 @@ impl TransactionV1 { return Err(InvalidTransactionV1::EmptyApprovals); } + match &self.initiator_addr() { + InitiatorAddr::PublicKey(public_key) => { + if public_key == &PublicKey::System { + return Err(InvalidTransactionV1::InvalidInitiator); + } + } + InitiatorAddr::AccountHash(account_hash) => { + if account_hash == &PublicKey::System.to_account_hash() { + return Err(InvalidTransactionV1::InvalidInitiator); + } + } + } + self.has_valid_hash()?; for (index, approval) in self.approvals.iter().enumerate() { diff --git a/types/src/transaction/transaction_v1/errors_v1.rs b/types/src/transaction/transaction_v1/errors_v1.rs index 814803c168..917c96b873 100644 --- a/types/src/transaction/transaction_v1/errors_v1.rs +++ b/types/src/transaction/transaction_v1/errors_v1.rs @@ -282,6 +282,9 @@ pub enum InvalidTransaction { UnsupportedInvocationTarget { id: Option, }, + + /// Invalid initiator for the transaction. + InvalidInitiator, } impl Display for InvalidTransaction { @@ -527,6 +530,9 @@ impl Display for InvalidTransaction { "the transaction invocation target is unsupported under V2 runtime", ) } + InvalidTransaction::InvalidInitiator => { + write!(formatter, "the transaction has an invalid initiator") + } } } } @@ -589,7 +595,8 @@ impl StdError for InvalidTransaction { | InvalidTransaction::InvalidMaximumDelegationAmount { .. } | InvalidTransaction::InvalidReservedSlots { .. } | InvalidTransaction::InvalidDelegationAmount { .. } - | InvalidTransaction::UnsupportedInvocationTarget { .. } => None, + | InvalidTransaction::UnsupportedInvocationTarget { .. } + | InvalidTransaction::InvalidInitiator => None, } } }