diff --git a/linera-execution/src/execution_state_actor.rs b/linera-execution/src/execution_state_actor.rs index 466892fa2183..c28bfd3230ad 100644 --- a/linera-execution/src/execution_state_actor.rs +++ b/linera-execution/src/execution_state_actor.rs @@ -1007,6 +1007,8 @@ where .execution_runtime_config() .allow_application_logs; + let transaction_index = self.txn_tracker.transaction_index(); + let contract_runtime_task = self .state .context() @@ -1016,6 +1018,7 @@ where let runtime = ContractSyncRuntime::new( execution_state_sender, chain_id, + transaction_index, refund_grant_to, controller, &action, diff --git a/linera-execution/src/lib.rs b/linera-execution/src/lib.rs index 0a4a9318d104..c5009b4bf263 100644 --- a/linera-execution/src/lib.rs +++ b/linera-execution/src/lib.rs @@ -942,6 +942,9 @@ pub trait ContractRuntime: BaseRuntime { /// The authenticated owner for this execution, if there is one. fn authenticated_owner(&mut self) -> Result, ExecutionError>; + /// The index of the current transaction within its block. + fn transaction_index(&mut self) -> Result; + /// If the current message (if there is one) was rejected by its destination and is now /// bouncing back. fn message_is_bouncing(&mut self) -> Result, ExecutionError>; diff --git a/linera-execution/src/runtime.rs b/linera-execution/src/runtime.rs index 3c44d705e2e5..bbef3a458c0f 100644 --- a/linera-execution/src/runtime.rs +++ b/linera-execution/src/runtime.rs @@ -89,6 +89,8 @@ pub struct SyncRuntimeInternal { /// The height of the next block that will be added to this chain. During operations /// and messages, this is the current block height. height: BlockHeight, + /// The index of the current transaction within its block. + transaction_index: u32, /// The current consensus round. Only available during block validation in multi-leader rounds. round: Option, /// The current message being executed, if there is one. @@ -313,6 +315,7 @@ impl SyncRuntimeInternal { fn new( chain_id: ChainId, height: BlockHeight, + transaction_index: u32, round: Option, executing_message: Option, execution_state_sender: ExecutionStateSender, @@ -325,6 +328,7 @@ impl SyncRuntimeInternal { Self { chain_id, height, + transaction_index, round, executing_message, execution_state_sender, @@ -1045,6 +1049,7 @@ impl ContractSyncRuntime { pub(crate) fn new( execution_state_sender: ExecutionStateSender, chain_id: ChainId, + transaction_index: u32, refund_grant_to: Option, resource_controller: ResourceController, action: &UserAction, @@ -1054,6 +1059,7 @@ impl ContractSyncRuntime { SyncRuntimeInternal::new( chain_id, action.height(), + transaction_index, action.round(), if let UserAction::Message(context, _) = action { Some(context.into()) @@ -1214,6 +1220,10 @@ impl ContractRuntime for ContractSyncRuntimeHandle { Ok(this.current_application().signer) } + fn transaction_index(&mut self) -> Result { + Ok(self.inner().transaction_index) + } + fn message_is_bouncing(&mut self) -> Result, ExecutionError> { Ok(self .inner() @@ -1747,6 +1757,7 @@ impl ServiceSyncRuntime { SyncRuntimeInternal::new( context.chain_id, context.next_block_height, + 0, None, None, execution_state_sender, diff --git a/linera-execution/src/unit_tests/runtime_tests.rs b/linera-execution/src/unit_tests/runtime_tests.rs index e675a265f27c..f72619a85c3e 100644 --- a/linera-execution/src/unit_tests/runtime_tests.rs +++ b/linera-execution/src/unit_tests/runtime_tests.rs @@ -178,6 +178,7 @@ where let runtime = SyncRuntimeInternal::new( chain_id, BlockHeight(0), + 0, Some(0), None, execution_state_sender, diff --git a/linera-execution/src/wasm/runtime_api.rs b/linera-execution/src/wasm/runtime_api.rs index 939a4462a289..0cf91c6c8196 100644 --- a/linera-execution/src/wasm/runtime_api.rs +++ b/linera-execution/src/wasm/runtime_api.rs @@ -470,6 +470,15 @@ where .map_err(|error| RuntimeError::Custom(error.into())) } + /// Returns the index of the current transaction within its block. + fn transaction_index(caller: &mut Caller) -> Result { + caller + .user_data_mut() + .runtime + .transaction_index() + .map_err(|error| RuntimeError::Custom(error.into())) + } + /// Returns `Some(true)` if the incoming message was rejected from the original destination and /// is now bouncing back, `Some(false)` if the message is being currently being delivered to /// its original destination, or [`None`] if not executing an incoming message. diff --git a/linera-sdk/src/contract/runtime.rs b/linera-sdk/src/contract/runtime.rs index aa2e0d4c26ce..b652610d857c 100644 --- a/linera-sdk/src/contract/runtime.rs +++ b/linera-sdk/src/contract/runtime.rs @@ -196,6 +196,15 @@ where contract_wit::authenticated_owner().map(AccountOwner::from) } + /// Returns the index of the current transaction within its block. + /// + /// Combined with the chain ID, application ID, and block height, this can be used by + /// smart contracts to derive a deterministic seed (e.g. for pseudo-random number + /// generation). + pub fn transaction_index(&mut self) -> u32 { + contract_wit::transaction_index() + } + /// Returns [`true`] if the incoming message was rejected from the original destination and is /// now bouncing back, or [`None`] if not executing an incoming message. pub fn message_is_bouncing(&mut self) -> Option { diff --git a/linera-sdk/src/contract/test_runtime.rs b/linera-sdk/src/contract/test_runtime.rs index 0731b340b610..df1d4dba367b 100644 --- a/linera-sdk/src/contract/test_runtime.rs +++ b/linera-sdk/src/contract/test_runtime.rs @@ -57,6 +57,7 @@ where application_descriptions: HashMap, chain_id: Option, authenticated_owner: Option>, + transaction_index: Option, block_height: Option, round: Option, message_is_bouncing: Option>, @@ -110,6 +111,7 @@ where application_descriptions: HashMap::new(), chain_id: None, authenticated_owner: None, + transaction_index: None, block_height: None, round: None, message_is_bouncing: None, @@ -305,6 +307,26 @@ where ) } + /// Configures the transaction index to return during the test. + pub fn with_transaction_index(mut self, transaction_index: u32) -> Self { + self.transaction_index = Some(transaction_index); + self + } + + /// Configures the transaction index to return during the test. + pub fn set_transaction_index(&mut self, transaction_index: u32) -> &mut Self { + self.transaction_index = Some(transaction_index); + self + } + + /// Returns the index of the current transaction within its block. + pub fn transaction_index(&mut self) -> u32 { + self.transaction_index.expect( + "Transaction index has not been mocked, \ + please call `MockContractRuntime::set_transaction_index` first", + ) + } + /// Configures the block height to return during the test. pub fn with_block_height(mut self, block_height: BlockHeight) -> Self { self.block_height = Some(block_height); diff --git a/linera-sdk/tests/fixtures/Cargo.lock b/linera-sdk/tests/fixtures/Cargo.lock index dfcb3d1b2e9c..d52bee60f801 100644 --- a/linera-sdk/tests/fixtures/Cargo.lock +++ b/linera-sdk/tests/fixtures/Cargo.lock @@ -3414,6 +3414,16 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "random-source" +version = "0.1.0" +dependencies = [ + "linera-sdk", + "rand 0.8.5", + "serde", + "tokio", +] + [[package]] name = "rayon" version = "1.11.0" diff --git a/linera-sdk/tests/fixtures/Cargo.toml b/linera-sdk/tests/fixtures/Cargo.toml index 432d55cebc9f..1a964f162bc7 100644 --- a/linera-sdk/tests/fixtures/Cargo.toml +++ b/linera-sdk/tests/fixtures/Cargo.toml @@ -8,6 +8,7 @@ members = [ "event-subscriber", "meta-counter", "publish-read-data-blob", + "random-source", "time-expiry", "track-instantiation", ] @@ -16,6 +17,7 @@ members = [ async-graphql = { version = "=7.0.17", default-features = false } linera-sdk = { path = "../../../linera-sdk" } log = "0.4.20" +rand = { version = "0.8.5", default-features = false, features = ["std_rng"] } serde = { version = "1.0.152", features = ["derive"] } tokio = { version = "1.25.0", features = ["macros", "rt-multi-thread"] } @@ -27,6 +29,7 @@ create-and-call = { path = "./create-and-call" } event-emitter = { path = "./event-emitter" } meta-counter = { path = "./meta-counter" } publish-read-data-blob = { path = "./publish-read-data-blob" } +random-source = { path = "./random-source" } time-expiry = { path = "./time-expiry" } track-instantiation = { path = "./track-instantiation" } diff --git a/linera-sdk/tests/fixtures/random-source/Cargo.toml b/linera-sdk/tests/fixtures/random-source/Cargo.toml new file mode 100644 index 000000000000..89319fe8dbfc --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "random-source" +version = "0.1.0" +authors = ["Linera "] +edition = "2021" + +[dependencies] +linera-sdk.workspace = true +rand.workspace = true +serde.workspace = true + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +linera-sdk = { workspace = true, features = ["test", "wasmer"] } +tokio.workspace = true + +[[bin]] +name = "random_source_contract" +path = "src/contract.rs" + +[[bin]] +name = "random_source_service" +path = "src/service.rs" diff --git a/linera-sdk/tests/fixtures/random-source/src/contract.rs b/linera-sdk/tests/fixtures/random-source/src/contract.rs new file mode 100644 index 000000000000..1d34f8a93294 --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/src/contract.rs @@ -0,0 +1,110 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(target_arch = "wasm32", no_main)] + +mod state; + +use linera_sdk::{ + linera_base_types::{ + ApplicationId, BcsHashable, BlockHeight, ChainId, CryptoHash, WithContractAbi, + }, + views::{RootView, View}, + Contract, ContractRuntime, +}; +use rand::{rngs::StdRng, RngCore, SeedableRng}; +use random_source::RandomSourceAbi; +use serde::{Deserialize, Serialize}; + +use self::state::RandomSourceState; + +pub struct RandomSourceContract { + state: RandomSourceState, + runtime: ContractRuntime, +} + +linera_sdk::contract!(RandomSourceContract); + +impl WithContractAbi for RandomSourceContract { + type Abi = RandomSourceAbi; +} + +/// Inputs combined into the seed for the deterministic RNG. Only the +/// `transaction_index` makes the seed unique across consecutive operations +/// within the same block; the other fields make it unique across applications, +/// chains, and blocks. +#[derive(Serialize, Deserialize)] +struct RandomSourceSeed { + chain_id: ChainId, + application_id: ApplicationId, + block_height: BlockHeight, + transaction_index: u32, +} + +impl BcsHashable<'_> for RandomSourceSeed {} + +impl Contract for RandomSourceContract { + type Message = (); + type InstantiationArgument = (); + type Parameters = (); + type EventValue = (); + + async fn load(runtime: ContractRuntime) -> Self { + let state = RandomSourceState::load(runtime.root_view_storage_context()) + .await + .expect("Failed to load state"); + RandomSourceContract { state, runtime } + } + + async fn instantiate(&mut self, _argument: ()) {} + + async fn execute_operation(&mut self, _operation: ()) { + // Asserting that the operation runs as the first transaction in the + // block exercises the fix for issue #2411: previously, the operation + // would have been preceded by an implicit system transaction. + let transaction_index = self.runtime.transaction_index(); + assert_eq!( + transaction_index, 0, + "Expected operation to be the first transaction in the block, \ + got transaction index {transaction_index}", + ); + + let seed_input = RandomSourceSeed { + chain_id: self.runtime.chain_id(), + application_id: self.runtime.application_id().forget_abi(), + block_height: self.runtime.block_height(), + transaction_index, + }; + let hash_bytes = CryptoHash::new(&seed_input).as_bytes().0; + let seed = u64::from_le_bytes([ + hash_bytes[0], + hash_bytes[1], + hash_bytes[2], + hash_bytes[3], + hash_bytes[4], + hash_bytes[5], + hash_bytes[6], + hash_bytes[7], + ]); + + let mut rng = StdRng::seed_from_u64(seed); + let sample1 = rng.next_u64(); + let sample2 = rng.next_u64(); + assert_ne!( + sample1, sample2, + "Two consecutive samples from the same RNG must differ", + ); + + self.state.seed.set(seed); + self.state.sample1.set(sample1); + self.state.sample2.set(sample2); + } + + async fn execute_message(&mut self, _message: ()) { + panic!("RandomSource application doesn't support cross-chain messages"); + } + + async fn store(mut self) { + self.state.save().await.expect("Failed to save state"); + } +} diff --git a/linera-sdk/tests/fixtures/random-source/src/lib.rs b/linera-sdk/tests/fixtures/random-source/src/lib.rs new file mode 100644 index 000000000000..3a20c5d61620 --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/src/lib.rs @@ -0,0 +1,43 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +/*! ABI of the Random Source Test Application. + +This fixture demonstrates how a smart contract can derive a deterministic +pseudo-random number from the runtime's `transaction_index`, combined with the +chain ID, application ID and block height. It also asserts that the operation +runs as the first transaction in the block, which exercises the fix for +. */ + +use linera_sdk::linera_base_types::{ContractAbi, ServiceAbi}; +use serde::{Deserialize, Serialize}; + +pub struct RandomSourceAbi; + +impl ContractAbi for RandomSourceAbi { + type Operation = (); + type Response = (); +} + +impl ServiceAbi for RandomSourceAbi { + type Query = Query; + type QueryResponse = QueryResponse; +} + +/// Query exposed by the random source service. +#[derive(Debug, Deserialize, Serialize)] +pub enum Query { + /// Returns the seed and the random samples that the contract derived from it. + GetSamples, +} + +/// Response from the random source service. +#[derive(Debug, Deserialize, Serialize)] +pub enum QueryResponse { + /// The seed and the two distinct random `u64` values it produced. + Samples { + seed: u64, + sample1: u64, + sample2: u64, + }, +} diff --git a/linera-sdk/tests/fixtures/random-source/src/service.rs b/linera-sdk/tests/fixtures/random-source/src/service.rs new file mode 100644 index 000000000000..f6293d8dabab --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/src/service.rs @@ -0,0 +1,42 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(target_arch = "wasm32", no_main)] + +mod state; + +use linera_sdk::{linera_base_types::WithServiceAbi, views::View, Service, ServiceRuntime}; +use random_source::{Query, QueryResponse, RandomSourceAbi}; + +use self::state::RandomSourceState; + +pub struct RandomSourceService { + state: RandomSourceState, +} + +linera_sdk::service!(RandomSourceService); + +impl WithServiceAbi for RandomSourceService { + type Abi = RandomSourceAbi; +} + +impl Service for RandomSourceService { + type Parameters = (); + + async fn new(runtime: ServiceRuntime) -> Self { + let state = RandomSourceState::load(runtime.root_view_storage_context()) + .await + .expect("Failed to load state"); + RandomSourceService { state } + } + + async fn handle_query(&self, query: Query) -> QueryResponse { + match query { + Query::GetSamples => QueryResponse::Samples { + seed: *self.state.seed.get(), + sample1: *self.state.sample1.get(), + sample2: *self.state.sample2.get(), + }, + } + } +} diff --git a/linera-sdk/tests/fixtures/random-source/src/state.rs b/linera-sdk/tests/fixtures/random-source/src/state.rs new file mode 100644 index 000000000000..ad142f881712 --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/src/state.rs @@ -0,0 +1,14 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use linera_sdk::views::{linera_views, RegisterView, RootView, ViewStorageContext}; + +/// Stores the seed derived from the runtime context and a couple of samples +/// drawn from the seeded RNG. +#[derive(RootView)] +#[view(context = ViewStorageContext)] +pub struct RandomSourceState { + pub seed: RegisterView, + pub sample1: RegisterView, + pub sample2: RegisterView, +} diff --git a/linera-sdk/tests/fixtures/random-source/tests/integration.rs b/linera-sdk/tests/fixtures/random-source/tests/integration.rs new file mode 100644 index 000000000000..4b20e7c0a720 --- /dev/null +++ b/linera-sdk/tests/fixtures/random-source/tests/integration.rs @@ -0,0 +1,45 @@ +// Copyright (c) Zefchain Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Integration tests for the Random Source application. + +#![cfg(not(target_arch = "wasm32"))] + +use linera_sdk::test::TestValidator; +use random_source::{Query, QueryResponse, RandomSourceAbi}; + +/// Runs the operation that derives a deterministic seed from the runtime +/// context (chain ID, application ID, block height, transaction index) and +/// stores two RNG samples; then queries them back. +/// +/// The operation also asserts inside the contract that its `transaction_index` +/// equals `0`, demonstrating that issue #2411 has been addressed. +#[tokio::test] +async fn test_random_source_seed_from_transaction_index() { + let (validator, module_id) = + TestValidator::with_current_module::().await; + let mut chain = validator.new_chain().await; + + let application_id = chain.create_application(module_id, (), (), vec![]).await; + + chain + .add_block(|block| { + block.with_operation(application_id, ()); + }) + .await; + + let response = chain + .query(application_id, Query::GetSamples) + .await + .response; + let QueryResponse::Samples { + seed, + sample1, + sample2, + } = response; + assert_ne!(seed, 0, "Seed derived from the runtime must be non-trivial"); + assert_ne!( + sample1, sample2, + "Two consecutive RNG samples must be distinct", + ); +} diff --git a/linera-sdk/wit/contract-runtime-api.wit b/linera-sdk/wit/contract-runtime-api.wit index aa6e408fcb2d..7ef7ceadad61 100644 --- a/linera-sdk/wit/contract-runtime-api.wit +++ b/linera-sdk/wit/contract-runtime-api.wit @@ -2,6 +2,7 @@ package linera:app; interface contract-runtime-api { authenticated-owner: func() -> option; + transaction-index: func() -> u32; message-is-bouncing: func() -> option; message-origin-chain-id: func() -> option; authenticated-caller-id: func() -> option;