From 279119ecfe415a8b3adfbad7d427ccb81daec2d1 Mon Sep 17 00:00:00 2001 From: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:00:31 +0200 Subject: [PATCH 1/5] feat(iota-core, iota-types)!: introduce gas price feedback mechanism for transactions cancelled due to congestion (#6280) This PR adds a gas price feedback mechanism for transactions cancelled due to shared object congestion. For more detail, see the corresponding issue. Closes #6206 Breaking change (fix or feature that would cause existing functionality to not work as expected). The most crucial changes, I believe, that require thorough reviews from the code owners include: - Embedding the suggested gas price for cancelled transactions into the sequence number of congested shared objects: https://github.com/iotaledger/iota/blob/9bd3ea8f0c4b306d79e12b25cdaa07729f17038a/crates/iota-types/src/base_types.rs#L1209-L1220 - Adding a new execution failure status: https://github.com/iotaledger/iota/blob/467bfae0bb8c2e1f2d28911dd366142d468b762b/crates/iota-types/src/execution_status.rs#L200-L210 I did not run all the existing tests locally, but those that required changes were modified to pass: ```console cargo simtest -p iota-core unit_tests congestion_control_tests::test_congestion_control_execution_cancellation cargo simtest -p iota-core test_consensus_handler_congestion_control_transaction_cancellation cargo simtest -p iota-benchmark test_simulated_load_large_consensus_commit_prologue_size cargo test -p iota-core authority::shared_object_congestion_tracker::object_cost_tests cargo test -p iota-core authority::shared_object_version_manager::tests::test_assign_versions_from_consensus_with_cancellation cargo test -p iota-core transaction_manager_with_cancelled_transactions ``` Additionally, run ```console UPDATE=1 cargo test -p iota-rest-api test::openapi_spec cargo -q run --example generate-format -- print > crates/iota-core/tests/staged/iota.yaml ``` - [x] I have followed the contribution guidelines for this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have checked that new and existing unit tests pass locally with my changes --------- Co-authored-by: Andrew Cullen <45826600+cyberphysic4l@users.noreply.github.com> Co-authored-by: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> Co-authored-by: Vlad Semenov Signed-off-by: Roman Overko --- Cargo.lock | 59 +++++--- Cargo.toml | 2 +- .../src/workloads/batch_payment.rs | 6 +- crates/iota-benchmark/tests/simtest.rs | 5 +- .../authority/authority_per_epoch_store.rs | 32 ++++- .../src/authority/authority_store.rs | 6 +- .../src/authority/authority_store_pruner.rs | 15 +- .../src/authority/authority_store_tables.rs | 2 +- .../shared_object_congestion_tracker.rs | 6 + .../shared_object_version_manager.rs | 52 +++++-- .../src/unit_tests/authority_tests.rs | 70 +++++++--- .../unit_tests/congestion_control_tests.rs | 21 ++- .../unit_tests/transaction_manager_tests.rs | 5 +- .../unit_tests/transfer_to_object_tests.rs | 2 +- crates/iota-core/tests/staged/iota.yaml | 6 + crates/iota-genesis-builder/src/lib.rs | 4 +- .../src/stardust/migration/migration.rs | 10 +- .../iota-indexer/tests/rpc-tests/read_api.rs | 2 +- .../tests/transaction_builder_api.rs | 2 +- crates/iota-open-rpc/spec/openrpc.json | 1 + crates/iota-protocol-config/src/lib.rs | 21 +++ ...ota_protocol_config__test__version_10.snap | 1 + crates/iota-rest-api/openapi/openapi.json | 28 ++++ crates/iota-transaction-checks/src/lib.rs | 6 +- crates/iota-types/src/base_types.rs | 130 ++++++++++++++++-- crates/iota-types/src/execution_status.rs | 14 +- .../src/iota_sdk_types_conversions.rs | 16 +++ crates/iota-types/src/storage/mod.rs | 6 +- crates/iota-types/src/transaction.rs | 4 +- .../src/unit_tests/base_types_tests.rs | 2 +- .../tests/staged/exec_failure_status.yaml | 1 + iota-execution/cut/Cargo.toml | 2 +- .../iota-adapter/src/execution_engine.rs | 18 ++- .../iota-adapter/src/temporary_store.rs | 2 +- .../src/object_runtime/object_store.rs | 9 +- 35 files changed, 461 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c331e4bba7..6581c162001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6105,7 +6105,7 @@ dependencies = [ "tempfile", "thiserror 1.0.64", "toml 0.7.8", - "toml_edit 0.22.22", + "toml_edit 0.22.27", ] [[package]] @@ -7374,7 +7374,7 @@ dependencies = [ [[package]] name = "iota-rust-sdk" version = "0.0.0" -source = "git+https://github.com/iotaledger/iota-rust-sdk.git?rev=6bb6e45fea2c266f9c15dd89f26f68631c2d37a8#6bb6e45fea2c266f9c15dd89f26f68631c2d37a8" +source = "git+https://github.com/iotaledger/iota-rust-sdk.git?rev=cd97687c9316045351d7db8b389ea381a5c335e1#cd97687c9316045351d7db8b389ea381a5c335e1" dependencies = [ "base64ct", "bcs", @@ -7388,7 +7388,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_with", - "winnow 0.6.20", + "winnow 0.6.26", ] [[package]] @@ -8921,7 +8921,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd01039851e82f8799046eabbb354056283fb265c8ec0996af940f4e85a380ff" dependencies = [ "serde", - "toml 0.8.19", + "toml 0.8.9", ] [[package]] @@ -9670,7 +9670,7 @@ dependencies = [ "socket2", "tap", "tokio-util 0.7.13", - "toml 0.8.19", + "toml 0.8.9", "tracing", "tracing-subscriber", ] @@ -11197,7 +11197,7 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ - "toml_edit 0.22.22", + "toml_edit 0.22.27", ] [[package]] @@ -14312,21 +14312,21 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.19" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "c6a4b9e8023eb94392d3dca65d717c53abc5dad49c07cb65bb8fcd87115fa325" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.22", + "toml_edit 0.21.1", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] @@ -14358,17 +14358,35 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" dependencies = [ "indexmap 2.5.0", "serde", "serde_spanned", "toml_datetime", - "winnow 0.6.20", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.5.0", + "toml_datetime", + "toml_write", + "winnow 0.7.11", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tonic" version = "0.12.3" @@ -15750,9 +15768,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.20" +version = "0.6.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index 98213f23d72..d054cd3db4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -435,7 +435,7 @@ iota-rosetta = { path = "crates/iota-rosetta" } iota-rpc-loadgen = { path = "crates/iota-rpc-loadgen" } iota-sdk = { path = "crates/iota-sdk" } # core-types with json format for REST API -iota-sdk2 = { package = "iota-rust-sdk", git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "6bb6e45fea2c266f9c15dd89f26f68631c2d37a8", features = ["hash", "serde", "schemars"] } +iota-sdk2 = { package = "iota-rust-sdk", git = "https://github.com/iotaledger/iota-rust-sdk.git", rev = "cd97687c9316045351d7db8b389ea381a5c335e1", features = ["hash", "serde", "schemars"] } iota-simulator = { path = "crates/iota-simulator" } iota-snapshot = { path = "crates/iota-snapshot" } iota-source-validation = { path = "crates/iota-source-validation" } diff --git a/crates/iota-benchmark/src/workloads/batch_payment.rs b/crates/iota-benchmark/src/workloads/batch_payment.rs index f69afc6231d..11c97905333 100644 --- a/crates/iota-benchmark/src/workloads/batch_payment.rs +++ b/crates/iota-benchmark/src/workloads/batch_payment.rs @@ -39,7 +39,11 @@ const PRIMARY_COIN_VALUE: u64 = 100 * NANOS_PER_IOTA; /// Number of nanos sent to each address on each batch transfer const BATCH_TRANSFER_AMOUNT: u64 = 1; -const DUMMY_GAS: ObjectRef = (ObjectID::ZERO, SequenceNumber::MIN, ObjectDigest::MIN); +const DUMMY_GAS: ObjectRef = ( + ObjectID::ZERO, + SequenceNumber::MIN_VALID_INCL, + ObjectDigest::MIN, +); #[derive(Debug)] pub struct BatchPaymentTestPayload { diff --git a/crates/iota-benchmark/tests/simtest.rs b/crates/iota-benchmark/tests/simtest.rs index 32f3ee8f2b6..a6caf86cf71 100644 --- a/crates/iota-benchmark/tests/simtest.rs +++ b/crates/iota-benchmark/tests/simtest.rs @@ -676,7 +676,10 @@ mod test { let num_objs = thread_rng().gen_range(1..15); let mut assigned_object_versions = Vec::new(); for _ in 0..num_objs { - assigned_object_versions.push((ObjectID::random(), SequenceNumber::CONGESTED)); + assigned_object_versions.push(( + ObjectID::random(), + SequenceNumber::new_congested_with_suggested_gas_price(1_000), + )); } additional_cancelled_txns.push((TransactionDigest::random(), assigned_object_versions)); } diff --git a/crates/iota-core/src/authority/authority_per_epoch_store.rs b/crates/iota-core/src/authority/authority_per_epoch_store.rs index b2feafb6878..a95940133b5 100644 --- a/crates/iota-core/src/authority/authority_per_epoch_store.rs +++ b/crates/iota-core/src/authority/authority_per_epoch_store.rs @@ -167,7 +167,10 @@ enum SchedulingResult { } pub enum CancelConsensusCertificateReason { - CongestionOnObjects(Vec), + CongestionOnObjects { + congested_objects: Vec, + suggested_gas_price: u64, + }, DkgFailed, } @@ -2885,12 +2888,14 @@ impl AuthorityPerEpochStore { let mut shared_input_next_version = HashMap::new(); for txn in transactions.iter() { match cancelled_txns.get(txn.digest()) { - Some(CancelConsensusCertificateReason::CongestionOnObjects(_)) + Some(CancelConsensusCertificateReason::CongestionOnObjects { .. }) | Some(CancelConsensusCertificateReason::DkgFailed) => { let assigned_versions = SharedObjVerManager::assign_versions_for_certificate( txn, &mut shared_input_next_version, cancelled_txns, + self.protocol_config + .congested_objects_gas_price_feedback_mechanism(), ); version_assignment.push((*txn.digest(), assigned_versions)); } @@ -3475,17 +3480,30 @@ impl AuthorityPerEpochStore { ConsensusCertificateResult::Deferred(deferral_key) } else { // Cancel the transaction that has been deferred for too long. + + let suggested_gas_price = shared_object_congestion_tracker + .compute_suggested_gas_price(&certificate) + .expect( + "cancelled transaction must have at least one shared \ + object and calculated suggested gas price", + ); + debug!( - "Cancelling consensus certificate for transaction {:?} with deferral key {:?} due to congestion on objects {:?}", + "Cancelling consensus certificate for transaction {:?} with \ + deferral key {deferral_key:?} due to congestion on \ + objects {congested_objects:?}: actual gas price: \ + {}, suggested gas price: \ + {suggested_gas_price}", certificate.digest(), - deferral_key, - congested_objects + certificate.transaction_data().gas_price(), ); + ConsensusCertificateResult::Cancelled(( certificate, - CancelConsensusCertificateReason::CongestionOnObjects( + CancelConsensusCertificateReason::CongestionOnObjects { congested_objects, - ), + suggested_gas_price, + }, )) } } diff --git a/crates/iota-core/src/authority/authority_store.rs b/crates/iota-core/src/authority/authority_store.rs index b5e2353aeef..53d9dbfef8c 100644 --- a/crates/iota-core/src/authority/authority_store.rs +++ b/crates/iota-core/src/authority/authority_store.rs @@ -1149,7 +1149,7 @@ impl AuthorityStore { .live_owned_object_markers .unbounded_iter() // Make the max possible entry for this object ID. - .skip_prior_to(&(object_id, SequenceNumber::MAX, ObjectDigest::MAX))?; + .skip_prior_to(&(object_id, SequenceNumber::MAX_VALID_EXCL, ObjectDigest::MAX))?; Ok(iterator .next() .and_then(|value| { @@ -1799,8 +1799,8 @@ impl AuthorityStore { self.perpetual_tables .objects .safe_iter_with_bounds( - Some(ObjectKey(object_id, VersionNumber::MIN)), - Some(ObjectKey(object_id, VersionNumber::MAX)), + Some(ObjectKey(object_id, VersionNumber::MIN_VALID_INCL)), + Some(ObjectKey(object_id, VersionNumber::MAX_VALID_EXCL)), ) .collect::, _>>() .unwrap() diff --git a/crates/iota-core/src/authority/authority_store_pruner.rs b/crates/iota-core/src/authority/authority_store_pruner.rs index f55a7e5a429..1ec416e84ad 100644 --- a/crates/iota-core/src/authority/authority_store_pruner.rs +++ b/crates/iota-core/src/authority/authority_store_pruner.rs @@ -216,7 +216,7 @@ impl AuthorityStorePruner { let mut object_keys_to_delete = vec![]; for ObjectKey(object_id, seq_number) in object_tombstones_to_prune { for result in perpetual_db.objects.safe_iter_with_bounds( - Some(ObjectKey(object_id, VersionNumber::MIN)), + Some(ObjectKey(object_id, VersionNumber::MIN_VALID_INCL)), Some(ObjectKey(object_id, seq_number.next())), ) { let (object_key, _) = result?; @@ -786,8 +786,8 @@ impl AuthorityStorePruner { /// invoking a range compaction on the database. pub fn compact(perpetual_db: &Arc) -> Result<(), TypedStoreError> { perpetual_db.objects.compact_range( - &ObjectKey(ObjectID::ZERO, SequenceNumber::MIN), - &ObjectKey(ObjectID::MAX, SequenceNumber::MAX), + &ObjectKey(ObjectID::ZERO, SequenceNumber::MIN_VALID_INCL), + &ObjectKey(ObjectID::MAX, SequenceNumber::MAX_VALID_EXCL), ) } } @@ -1070,8 +1070,8 @@ mod tests { } let db_path = primary_path.clone().join("perpetual"); - let start = ObjectKey(ObjectID::ZERO, SequenceNumber::MIN); - let end = ObjectKey(ObjectID::MAX, SequenceNumber::MAX); + let start = ObjectKey(ObjectID::ZERO, SequenceNumber::MIN_VALID_INCL); + let end = ObjectKey(ObjectID::MAX, SequenceNumber::MAX_VALID_EXCL); perpetual_db.objects.rocksdb.flush()?; perpetual_db.objects.compact_range_to_bottom(&start, &end)?; @@ -1171,7 +1171,10 @@ mod pprof_tests { ) -> Result<(), anyhow::Error> { let mut i = 0; while i < num_reads { - let _res = objects.get(&ObjectKey(ObjectID::random(), VersionNumber::MAX))?; + let _res = objects.get(&ObjectKey( + ObjectID::random(), + VersionNumber::MAX_VALID_EXCL, + ))?; i += 1; } Ok(()) diff --git a/crates/iota-core/src/authority/authority_store_tables.rs b/crates/iota-core/src/authority/authority_store_tables.rs index ee30f11e28e..2b9f4e6e2f8 100644 --- a/crates/iota-core/src/authority/authority_store_tables.rs +++ b/crates/iota-core/src/authority/authority_store_tables.rs @@ -411,7 +411,7 @@ impl AuthorityPerpetualTables { let mut objects = vec![]; for result in self.objects.safe_iter_with_bounds( Some(ObjectKey(object.0, object.1.next())), - Some(ObjectKey(object.0, VersionNumber::MAX)), + Some(ObjectKey(object.0, VersionNumber::MAX_VALID_EXCL)), ) { let (key, _) = result?; objects.push(key); diff --git a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs index 120cee261a1..04e0315ca19 100644 --- a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs +++ b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs @@ -477,6 +477,12 @@ impl SharedObjectCongestionTracker { .max() .unwrap_or(0) } + + // NOTE: this function will be rewritten anyway in the new sequencer + // (see PR #6490), so we simple return the certificate's gas price here. + pub fn compute_suggested_gas_price(&self, cert: &VerifiedExecutableTransaction) -> Option { + Some(cert.transaction_data().gas_price()) + } } #[cfg(test)] diff --git a/crates/iota-core/src/authority/shared_object_version_manager.rs b/crates/iota-core/src/authority/shared_object_version_manager.rs index 8a4f650086c..74c910e2eba 100644 --- a/crates/iota-core/src/authority/shared_object_version_manager.rs +++ b/crates/iota-core/src/authority/shared_object_version_manager.rs @@ -80,6 +80,9 @@ impl SharedObjVerManager { cert, &mut shared_input_next_versions, cancelled_txns, + epoch_store + .protocol_config() + .congested_objects_gas_price_feedback_mechanism(), ); assigned_versions.push((cert.key(), cert_assigned_versions)); } @@ -132,14 +135,17 @@ impl SharedObjVerManager { cert: &VerifiedExecutableTransaction, shared_input_next_versions: &mut HashMap, cancelled_txns: &BTreeMap, + enable_gas_price_feedback: bool, ) -> Vec<(ObjectID, SequenceNumber)> { let tx_digest = cert.digest(); // Check if the transaction is cancelled due to congestion. let cancellation_info = cancelled_txns.get(tx_digest); let congested_objects_info: Option> = - if let Some(CancelConsensusCertificateReason::CongestionOnObjects(congested_objects)) = - &cancellation_info + if let Some(CancelConsensusCertificateReason::CongestionOnObjects { + congested_objects, + suggested_gas_price: _, + }) = &cancellation_info { Some(congested_objects.iter().cloned().collect()) } else { @@ -164,12 +170,25 @@ impl SharedObjVerManager { // any shared objects. for SharedInputObject { id, .. } in shared_input_objects.iter() { let assigned_version = match cancellation_info { - Some(CancelConsensusCertificateReason::CongestionOnObjects(_)) => { + Some(CancelConsensusCertificateReason::CongestionOnObjects { + congested_objects: _, + suggested_gas_price, + }) => { if congested_objects_info .as_ref() .is_some_and(|info| info.contains(id)) { - SequenceNumber::CONGESTED + if enable_gas_price_feedback { + SequenceNumber::new_congested_with_suggested_gas_price( + *suggested_gas_price, + ) + } else { + // WARN: do not remove this `else` branch even after + // `congested_objects_gas_price_feedback_mechanism` is enabled + // on the mainnet. It must be kept to be able to replay old + // transaction data. + SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK + } } else { SequenceNumber::CANCELLED_READ } @@ -518,14 +537,21 @@ mod tests { let epoch_store = authority.epoch_store_for_testing(); // Cancel transactions 2 and 4 due to congestion. + let suggested_gas_price = 1_000; let cancelled_txns: BTreeMap = [ ( *certs[1].digest(), - CancelConsensusCertificateReason::CongestionOnObjects(vec![id1]), + CancelConsensusCertificateReason::CongestionOnObjects { + congested_objects: vec![id1], + suggested_gas_price, + }, ), ( *certs[3].digest(), - CancelConsensusCertificateReason::CongestionOnObjects(vec![id2]), + CancelConsensusCertificateReason::CongestionOnObjects { + congested_objects: vec![id2], + suggested_gas_price, + }, ), ( *certs[4].digest(), @@ -571,7 +597,12 @@ mod tests { ( certs[1].key(), vec![ - (id1, SequenceNumber::CONGESTED), + ( + id1, + SequenceNumber::new_congested_with_suggested_gas_price( + suggested_gas_price + ) + ), (id2, SequenceNumber::CANCELLED_READ), ] ), @@ -580,7 +611,12 @@ mod tests { certs[3].key(), vec![ (id1, SequenceNumber::CANCELLED_READ), - (id2, SequenceNumber::CONGESTED) + ( + id2, + SequenceNumber::new_congested_with_suggested_gas_price( + suggested_gas_price + ) + ) ] ), ( diff --git a/crates/iota-core/src/unit_tests/authority_tests.rs b/crates/iota-core/src/unit_tests/authority_tests.rs index 228094ec6c5..b51889da5b9 100644 --- a/crates/iota-core/src/unit_tests/authority_tests.rs +++ b/crates/iota-core/src/unit_tests/authority_tests.rs @@ -1089,7 +1089,7 @@ async fn test_dry_run_dev_inspect_max_gas_version() { let (fullnode, _object_basics) = publish_object_basics(fullnode).await; let gas_object = Object::with_id_owner_version_for_testing( gas_object_id, - SequenceNumber::from_u64(SequenceNumber::MAX.value() - 1), + SequenceNumber::from_u64(SequenceNumber::MAX_VALID_EXCL.value() - 1), sender, ); let gas_object_ref = gas_object.compute_object_reference(); @@ -1222,7 +1222,7 @@ async fn test_handle_transfer_transaction_with_max_sequence_number() { let gas_object_id = ObjectID::random(); let recipient = dbg_addr(2); let authority_state = init_state_with_ids_and_versions(vec![ - (sender, object_id, SequenceNumber::MAX), + (sender, object_id, SequenceNumber::MAX_VALID_EXCL), (sender, gas_object_id, SequenceNumber::new()), ]) .await; @@ -1253,7 +1253,10 @@ async fn test_handle_transfer_transaction_with_max_sequence_number() { #[tokio::test] async fn test_handle_shared_object_with_max_sequence_number() { let (authority, _fullnode, transaction, _, _) = - construct_shared_object_transaction_with_sequence_number(Some(SequenceNumber::MAX)).await; + construct_shared_object_transaction_with_sequence_number(Some( + SequenceNumber::MAX_VALID_EXCL, + )) + .await; let epoch_store = authority.load_epoch_store_one_call_per_task(); // Submit the transaction and assemble a certificate. let response = authority @@ -6155,6 +6158,8 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { authority.insert_genesis_objects(&genesis_objects).await; let mut certificates: Vec = vec![]; + let gas_price_of_non_cancelled_txs = 2_000; + let gas_price_of_cancelled_txs = 1_000; // Create 3 transactions that operate on shared_objects[0]. These transactions // will go through eventually. @@ -6167,7 +6172,7 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { &gas_object.compute_object_reference(), &[&authority], 12345, - Some(2000), + Some(gas_price_of_non_cancelled_txs), Some(100_000_000), ) .await; @@ -6189,7 +6194,7 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { &gas_objects_cancelled_txn[0].compute_object_reference(), &[&authority], 12345, - Some(1000), + Some(gas_price_of_cancelled_txs), Some(100_000_000), ) .await; @@ -6209,7 +6214,9 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { scheduled_txns[0].data().transaction_data().kind(), TransactionKind::ConsensusCommitPrologueV1(..) )); - assert!(scheduled_txns[1].data().transaction_data().gas_price() == 2000); + assert!( + scheduled_txns[1].data().transaction_data().gas_price() == gas_price_of_non_cancelled_txs + ); let scheduled_txns = send_batch_consensus_no_execution(&authority, &[], false).await; assert_eq!(scheduled_txns.len(), 2); @@ -6217,7 +6224,9 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { scheduled_txns[0].data().transaction_data().kind(), TransactionKind::ConsensusCommitPrologueV1(..) )); - assert!(scheduled_txns[1].data().transaction_data().gas_price() == 2000); + assert!( + scheduled_txns[1].data().transaction_data().gas_price() == gas_price_of_non_cancelled_txs + ); // Run consensus round 3. 2 user transactions will come out with 1 transaction // being cancelled. @@ -6241,8 +6250,14 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { .collect::>(); assert_eq!( [ - (shared_objects[0].id(), SequenceNumber::CONGESTED), - (shared_objects[1].id(), SequenceNumber::CONGESTED) + ( + shared_objects[0].id(), + SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + ), + ( + shared_objects[1].id(), + SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + ) ] .into_iter() .collect::>(), @@ -6273,8 +6288,14 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { assert_eq!( shared_inputs, vec![ - SharedInput::Cancelled((shared_objects[0].id(), SequenceNumber::CONGESTED)), - SharedInput::Cancelled((shared_objects[1].id(), SequenceNumber::CONGESTED)) + SharedInput::Cancelled(( + shared_objects[0].id(), + SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + )), + SharedInput::Cancelled(( + shared_objects[1].id(), + SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + )) ] ); @@ -6284,7 +6305,10 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { cancelled_objects, vec![shared_objects[0].id(), shared_objects[1].id()] ); - assert_eq!(cancellation_reason, SequenceNumber::CONGESTED); + assert_eq!( + cancellation_reason, + SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + ); // Consensus commit prologue contains cancelled txn shared object version // assignment. @@ -6295,12 +6319,22 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { &prologue_txn.consensus_determined_version_assignments, ConsensusDeterminedVersionAssignments::CancelledTransactions(assignment) if assignment == &vec![( - *cancelled_txn.digest(), - vec![ - (shared_objects[0].id(), SequenceNumber::CONGESTED), - (shared_objects[1].id(), SequenceNumber::CONGESTED) - ] - )] + *cancelled_txn.digest(), + vec![ + ( + shared_objects[0].id(), + SequenceNumber::new_congested_with_suggested_gas_price( + gas_price_of_cancelled_txs + ), + ), + ( + shared_objects[1].id(), + SequenceNumber::new_congested_with_suggested_gas_price( + gas_price_of_cancelled_txs + ), + ) + ] + )] )); } else { panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); diff --git a/crates/iota-core/src/unit_tests/congestion_control_tests.rs b/crates/iota-core/src/unit_tests/congestion_control_tests.rs index a8f28cc9c87..a020bb07e8a 100644 --- a/crates/iota-core/src/unit_tests/congestion_control_tests.rs +++ b/crates/iota-core/src/unit_tests/congestion_control_tests.rs @@ -331,13 +331,15 @@ async fn test_congestion_control_execution_cancellation() { ) .await; - // Transaction should be cancelled with `shared_object_1` as the congested - // object. + // Transaction should be cancelled with `shared_object_1` and `shared_object_2` + // as the congested objects, and the suggested gas price should be + // `TEST_ONLY_GAS_PRICE`. assert_eq!( effects.status(), &ExecutionStatus::Failure { - error: ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestion { + error: ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { congested_objects: CongestedObjects(vec![shared_object_1.0, shared_object_2.0]), + suggested_gas_price: TEST_ONLY_GAS_PRICE, }, command: None } @@ -347,8 +349,14 @@ async fn test_congestion_control_execution_cancellation() { assert_eq!( effects.input_shared_objects(), vec![ - InputSharedObject::Cancelled(shared_object_1.0, SequenceNumber::CONGESTED), - InputSharedObject::Cancelled(shared_object_2.0, SequenceNumber::CONGESTED) + InputSharedObject::Cancelled( + shared_object_1.0, + SequenceNumber::new_congested_with_suggested_gas_price(TEST_ONLY_GAS_PRICE) + ), + InputSharedObject::Cancelled( + shared_object_2.0, + SequenceNumber::new_congested_with_suggested_gas_price(TEST_ONLY_GAS_PRICE) + ) ] ); @@ -371,8 +379,9 @@ async fn test_congestion_control_execution_cancellation() { // Should result in the same cancellation. assert_eq!( execution_error.unwrap().to_execution_status().0, - ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestion { + ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { congested_objects: CongestedObjects(vec![shared_object_1.0, shared_object_2.0]), + suggested_gas_price: TEST_ONLY_GAS_PRICE, } ); assert_eq!(&effects, effects_2.data()) diff --git a/crates/iota-core/src/unit_tests/transaction_manager_tests.rs b/crates/iota-core/src/unit_tests/transaction_manager_tests.rs index bb46a775e3b..79fcfb34ebb 100644 --- a/crates/iota-core/src/unit_tests/transaction_manager_tests.rs +++ b/crates/iota-core/src/unit_tests/transaction_manager_tests.rs @@ -788,7 +788,10 @@ async fn transaction_manager_with_cancelled_transactions() { cancelled_transaction.digest(), &vec![ (shared_object_1.id(), SequenceNumber::CANCELLED_READ), - (shared_object_2.id(), SequenceNumber::CONGESTED), + ( + shared_object_2.id(), + SequenceNumber::new_congested_with_suggested_gas_price(101), + ), ], ) .unwrap(); diff --git a/crates/iota-core/src/unit_tests/transfer_to_object_tests.rs b/crates/iota-core/src/unit_tests/transfer_to_object_tests.rs index 0cfebc2b31e..28340f3c26a 100644 --- a/crates/iota-core/src/unit_tests/transfer_to_object_tests.rs +++ b/crates/iota-core/src/unit_tests/transfer_to_object_tests.rs @@ -431,7 +431,7 @@ async fn test_tto_invalid_receiving_arguments() { Box bool>, )> = vec![ ( - Box::new(|x: ObjectRef| (x.0, SequenceNumber::MAX, x.2)), + Box::new(|x: ObjectRef| (x.0, SequenceNumber::MAX_VALID_EXCL, x.2)), Box::new(|err| matches!(err, UserInputError::InvalidSequenceNumber)), ), ( diff --git a/crates/iota-core/tests/staged/iota.yaml b/crates/iota-core/tests/staged/iota.yaml index 7396a9673b2..0abfc760660 100644 --- a/crates/iota-core/tests/staged/iota.yaml +++ b/crates/iota-core/tests/staged/iota.yaml @@ -513,6 +513,12 @@ ExecutionFailureStatus: - coin_type: STR 36: ExecutionCancelledDueToRandomnessUnavailable: UNIT + 37: + ExecutionCancelledDueToSharedObjectCongestionV2: + STRUCT: + - congested_objects: + TYPENAME: CongestedObjects + - suggested_gas_price: U64 ExecutionStatus: ENUM: 0: diff --git a/crates/iota-genesis-builder/src/lib.rs b/crates/iota-genesis-builder/src/lib.rs index a8ad134a950..363604534bd 100644 --- a/crates/iota-genesis-builder/src/lib.rs +++ b/crates/iota-genesis-builder/src/lib.rs @@ -1306,14 +1306,14 @@ fn create_genesis_transaction( .into_iter() .map(|mut object| { if let Some(o) = object.data.try_as_move_mut() { - o.decrement_version_to(SequenceNumber::MIN); + o.decrement_version_to(SequenceNumber::MIN_VALID_INCL); } if let Owner::Shared { initial_shared_version, } = &mut object.owner { - *initial_shared_version = SequenceNumber::MIN; + *initial_shared_version = SequenceNumber::MIN_VALID_INCL; } let object = object.into_inner(); diff --git a/crates/iota-genesis-builder/src/stardust/migration/migration.rs b/crates/iota-genesis-builder/src/stardust/migration/migration.rs index 58f2a8b99b8..120810acd23 100644 --- a/crates/iota-genesis-builder/src/stardust/migration/migration.rs +++ b/crates/iota-genesis-builder/src/stardust/migration/migration.rs @@ -481,7 +481,7 @@ mod tests { owner, &ProtocolConfig::get_for_min_version(), &tx_context, - SequenceNumber::MIN, + SequenceNumber::MIN_VALID_INCL, ) .unwrap() }) @@ -494,12 +494,12 @@ mod tests { address, &ProtocolConfig::get_for_min_version(), &tx_context, - SequenceNumber::MIN, + SequenceNumber::MIN_VALID_INCL, ) .unwrap() }); let non_matching_objects = (0..8) - .map(|_| GasCoin::new_for_testing(0).to_object(SequenceNumber::MIN)) + .map(|_| GasCoin::new_for_testing(0).to_object(SequenceNumber::MIN_VALID_INCL)) .map(|move_object| { Object::new_from_genesis( Data::Move(move_object), @@ -538,12 +538,12 @@ mod tests { address, &ProtocolConfig::get_for_min_version(), &tx_context, - SequenceNumber::MIN, + SequenceNumber::MIN_VALID_INCL, ) .unwrap() }); let expected_gas_coins = (0..8) - .map(|_| GasCoin::new_for_testing(0).to_object(SequenceNumber::MIN)) + .map(|_| GasCoin::new_for_testing(0).to_object(SequenceNumber::MIN_VALID_INCL)) .map(|move_object| { Object::new_from_genesis( Data::Move(move_object), diff --git a/crates/iota-indexer/tests/rpc-tests/read_api.rs b/crates/iota-indexer/tests/rpc-tests/read_api.rs index 23ce6f2b303..c8a37649325 100644 --- a/crates/iota-indexer/tests/rpc-tests/read_api.rs +++ b/crates/iota-indexer/tests/rpc-tests/read_api.rs @@ -1448,7 +1448,7 @@ fn try_get_past_object_object_deleted() { let deleted_version = nft_object_ref.1.next(); let result = client - .try_get_object_before_version(nft_object_id, SequenceNumber::MAX) + .try_get_object_before_version(nft_object_id, SequenceNumber::MAX_VALID_EXCL) .await .expect("RPC call should succeed"); diff --git a/crates/iota-json-rpc-tests/tests/transaction_builder_api.rs b/crates/iota-json-rpc-tests/tests/transaction_builder_api.rs index 135c58eb733..24b9aaf6261 100644 --- a/crates/iota-json-rpc-tests/tests/transaction_builder_api.rs +++ b/crates/iota-json-rpc-tests/tests/transaction_builder_api.rs @@ -39,7 +39,7 @@ fn assert_same_object_changes_ignoring_version_and_digest( .map(|mut change| { let object_id = change.object_id(); // ignore the version and digest for comparison - change.mask_for_test(SequenceNumber::MAX, ObjectDigest::MAX); + change.mask_for_test(SequenceNumber::MAX_VALID_EXCL, ObjectDigest::MAX); (object_id, change) }) .collect() diff --git a/crates/iota-open-rpc/spec/openrpc.json b/crates/iota-open-rpc/spec/openrpc.json index f681653f8eb..fb35801c5cd 100644 --- a/crates/iota-open-rpc/spec/openrpc.json +++ b/crates/iota-open-rpc/spec/openrpc.json @@ -1306,6 +1306,7 @@ "featureFlags": { "accept_passkey_in_multisig": false, "accept_zklogin_in_multisig": false, + "congested_objects_gas_price_feedback_mechanism": false, "congestion_control_min_free_execution_slot": false, "consensus_batched_block_sync": false, "consensus_distributed_vote_scoring_strategy": false, diff --git a/crates/iota-protocol-config/src/lib.rs b/crates/iota-protocol-config/src/lib.rs index 02259f02bcb..85768862f43 100644 --- a/crates/iota-protocol-config/src/lib.rs +++ b/crates/iota-protocol-config/src/lib.rs @@ -65,6 +65,7 @@ pub const MAX_PROTOCOL_VERSION: u64 = 10; // Enable consensus garbage collection for mainnet with GC depth set // to 60 rounds // Enable batching in synchronizer for testnet +// Enable the gas price feedback mechanism in devnet. #[derive(Copy, Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct ProtocolVersion(u64); @@ -275,6 +276,11 @@ struct FeatureFlags { #[serde(skip_serializing_if = "is_false")] consensus_zstd_compression: bool, + // To enable/disable the gas price feedback mechanism used for transactions + // cancelled due to shared object congestion + #[serde(skip_serializing_if = "is_false")] + congested_objects_gas_price_feedback_mechanism: bool, + // Use the minimum free execution slot to schedule execution of a transaction in the shared // object congestion tracker. #[serde(skip_serializing_if = "is_false")] @@ -1251,6 +1257,13 @@ impl ProtocolConfig { self.feature_flags.consensus_zstd_compression } + /// Check if the gas price feedback mechanism (which is used for + /// transactions cancelled due to shared object congestion) is enabled + pub fn congested_objects_gas_price_feedback_mechanism(&self) -> bool { + self.feature_flags + .congested_objects_gas_price_feedback_mechanism + } + pub fn congestion_control_min_free_execution_slot(&self) -> bool { self.feature_flags .congestion_control_min_free_execution_slot @@ -1994,6 +2007,7 @@ impl ProtocolConfig { // to be included before be considered garbage collected. cfg.consensus_gc_depth = Some(60); } + // Enable min_free_execution_slot for the shared object congestion tracker in // devnet. if chain != Chain::Testnet && chain != Chain::Mainnet { @@ -2046,6 +2060,13 @@ impl ProtocolConfig { // Enable batched block sync in devnet and testnet. cfg.feature_flags.consensus_batched_block_sync = true; } + + if chain != Chain::Testnet && chain != Chain::Mainnet { + // Enable the gas price feedback mechanism (which is used for + // transactions cancelled due to shared object congestion) in devnet + cfg.feature_flags + .congested_objects_gas_price_feedback_mechanism = true; + } } // Use this template when making changes: // diff --git a/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap b/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap index 5ea0bf0aff9..e5eb6ae8af6 100644 --- a/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap +++ b/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap @@ -26,6 +26,7 @@ feature_flags: congestion_control_min_free_execution_slot: true accept_passkey_in_multisig: true consensus_batched_block_sync: true + congested_objects_gas_price_feedback_mechanism: true max_tx_size_bytes: 131072 max_input_objects: 2048 max_size_written_objects: 5000000 diff --git a/crates/iota-rest-api/openapi/openapi.json b/crates/iota-rest-api/openapi/openapi.json index b0d7a3ac87e..1836e1a933b 100644 --- a/crates/iota-rest-api/openapi/openapi.json +++ b/crates/iota-rest-api/openapi/openapi.json @@ -3068,6 +3068,34 @@ } } }, + { + "description": "Certificate is cancelled due to congestion on shared objects; suggested gas price can be used to give this certificate more priority.", + "type": "object", + "required": [ + "congested_objects", + "error", + "suggested_gas_price" + ], + "properties": { + "congested_objects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ObjectId" + } + }, + "error": { + "type": "string", + "enum": [ + "execution_cancelled_due_to_shared_object_congestion_v2" + ] + }, + "suggested_gas_price": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + } + }, { "description": "Address is denied for this coin type", "type": "object", diff --git a/crates/iota-transaction-checks/src/lib.rs b/crates/iota-transaction-checks/src/lib.rs index 8d63f7b51ba..275ef7c8235 100644 --- a/crates/iota-transaction-checks/src/lib.rs +++ b/crates/iota-transaction-checks/src/lib.rs @@ -241,7 +241,7 @@ mod checked { } in receiving_objects.iter() { fp_ensure!( - *version < SequenceNumber::MAX, + *version < SequenceNumber::MAX_VALID_EXCL, UserInputError::InvalidSequenceNumber.into() ); @@ -448,7 +448,7 @@ mod checked { UserInputError::MovePackageAsObject { object_id } ); fp_ensure!( - sequence_number < SequenceNumber::MAX, + sequence_number < SequenceNumber::MAX_VALID_EXCL, UserInputError::InvalidSequenceNumber ); @@ -547,7 +547,7 @@ mod checked { .. } => { fp_ensure!( - object.version() < SequenceNumber::MAX, + object.version() < SequenceNumber::MAX_VALID_EXCL, UserInputError::InvalidSequenceNumber ); diff --git a/crates/iota-types/src/base_types.rs b/crates/iota-types/src/base_types.rs index 9fe70c5c4c2..aab8c739bac 100644 --- a/crates/iota-types/src/base_types.rs +++ b/crates/iota-types/src/base_types.rs @@ -1137,12 +1137,67 @@ impl TxContext { // TODO: rename to version impl SequenceNumber { - pub const MIN: SequenceNumber = SequenceNumber(u64::MIN); - pub const MAX: SequenceNumber = SequenceNumber(0x7fff_ffff_ffff_ffff); - pub const CANCELLED_READ: SequenceNumber = SequenceNumber(SequenceNumber::MAX.value() + 1); - pub const CONGESTED: SequenceNumber = SequenceNumber(SequenceNumber::MAX.value() + 2); + /// An inclusive lower limit on a valid sequence number. + /// + /// A valid sequence number means an object, which this sequence number + /// is assigned to, does not appear in a cancelled transaction. + pub const MIN_VALID_INCL: SequenceNumber = SequenceNumber(u64::MIN); + + /// An exclusive upper limit on a valid sequence number: sequence numbers + /// strictly smaller than this limit are valid sequence numbers. + /// + /// A valid sequence number means an object, which this sequence number + /// is assigned to, does not appear in a cancelled transaction. + /// Sequence numbers larger than this value are "special" and + /// assigned to objects that appear in cancelled transactions. + pub const MAX_VALID_EXCL: SequenceNumber = SequenceNumber(0x7fff_ffff_ffff_ffff); + + /// Special sequence number that is assigned to objects which are accessed + /// immutably in a cancelled transaction. + pub const CANCELLED_READ: SequenceNumber = + SequenceNumber(SequenceNumber::MAX_VALID_EXCL.value() + 1); + + /// Special sequence number that was assigned to congested objects which + /// cause transaction cancellations. Note that this special sequence + /// number was only used prior to the introduction of a gas price feedback + /// mechanism, but it is kept for backward compatibility. + pub const CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK: SequenceNumber = + SequenceNumber(SequenceNumber::MAX_VALID_EXCL.value() + 2); + + /// Special sequence number that is assigned the randomness state object + /// if randomness is unavailable. pub const RANDOMNESS_UNAVAILABLE: SequenceNumber = - SequenceNumber(SequenceNumber::MAX.value() + 3); + SequenceNumber(SequenceNumber::MAX_VALID_EXCL.value() + 3); + + // NOTE: if you want to add new SequenceNumber constants used for cancellation + // reasons different than those used for cancellations due to shared object + // congestion, please make sure their offset is less than + // CONGESTED_BASE_OFFSET_FOR_GAS_PRICE_FEEDBACK + + /// The meaning of this constant is as follows: + /// + /// In the gas price feedback mechanism, sequence numbers >= + /// `SequenceNumber::MAX_VALID_EXCL` + + /// `CONGESTED_BASE_OFFSET_FOR_GAS_PRICE_FEEDBACK` are assigned to + /// objects that cause transactions cancellations due to congestion. + /// + /// Sequence numbers larger than `SequenceNumber::MAX_VALID_EXCL` but + /// smaller than `SequenceNumber::MAX_VALID_EXCL` + + /// `CONGESTED_BASE_OFFSET_FOR_GAS_PRICE_FEEDBACK` are + /// intended for other transaction cancellation reasons. + /// + /// There unlikely will be more than 1000 non-congestion cancellation + /// reasons, but this offset can be increased if needed, as long as + /// (`SequenceNumber::MIN_CONGESTED.value()` + maximum gas price) does not + /// overflow `u64::MAX`. + const CONGESTED_BASE_OFFSET_FOR_GAS_PRICE_FEEDBACK: u64 = 1_000; + + /// Minimum congested sequence number used in the gas price feedback + /// mechanism. A congested sequence number is assigned to objects that + /// cause transaction cancellations. + const MIN_CONGESTED_FOR_GAS_PRICE_FEEDBACK: SequenceNumber = SequenceNumber( + SequenceNumber::MAX_VALID_EXCL.value() + Self::CONGESTED_BASE_OFFSET_FOR_GAS_PRICE_FEEDBACK, + ); pub const fn new() -> Self { SequenceNumber(0) @@ -1156,8 +1211,50 @@ impl SequenceNumber { SequenceNumber(u) } + /// Returns a special sequence number used for congested shared objects: + /// `SequenceNumber::MIN_CONGESTED.value()` + `suggested_gas_price`, + /// where `suggested_gas_price` is embedded into a congested sequence + /// number to facilitate a gas price feedback mechanism for transactions + /// cancelled due to shared object congestion. + pub fn new_congested_with_suggested_gas_price(suggested_gas_price: u64) -> Self { + let (version, overflows) = Self::MIN_CONGESTED_FOR_GAS_PRICE_FEEDBACK + .value() + .overflowing_add(suggested_gas_price); + debug_assert!( + !overflows, + "the calculated version for a congested shared objects overflows" + ); + + Self(version) + } + + /// Check if this sequence number is congested, i.e., the corresponding + /// object is the reason for transaction cancellation. + pub fn is_congested(&self) -> bool { + *self == Self::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK + || self >= &Self::MIN_CONGESTED_FOR_GAS_PRICE_FEEDBACK + } + + /// Returns the `suggested_gas_price` embedded in this congested shared + /// object sequence number. The `suggested_gas_price` here is used for a + /// gas price feedback mechanism for transactions cancelled due to + /// shared object congestion. + pub fn get_congested_version_suggested_gas_price(&self) -> u64 { + assert!( + *self >= Self::MIN_CONGESTED_FOR_GAS_PRICE_FEEDBACK, + "this is not a version used for congested shared objects in the gas price feedback \ + mechanism" + ); + + self.value() - Self::MIN_CONGESTED_FOR_GAS_PRICE_FEEDBACK.value() + } + pub fn increment(&mut self) { - assert_ne!(self.0, u64::MAX); + assert!( + self.is_valid(), + "cannot increment a sequence number: \ + maximum valid sequence number has already been reached" + ); self.0 += 1; } @@ -1167,7 +1264,12 @@ impl SequenceNumber { } pub fn decrement(&mut self) { - assert_ne!(self.0, 0); + assert_ne!( + *self, + Self::MIN_VALID_INCL, + "cannot decrement a sequence number: \ + minimum valid sequence number has already been reached" + ); self.0 -= 1; } @@ -1186,19 +1288,27 @@ impl SequenceNumber { // Option 1: Freeze the object when sequence number reaches MAX. // Option 2: Reject tx with MAX sequence number. // Issue #182. - assert_ne!(max_input.0, u64::MAX); + assert!( + max_input.is_valid(), + "cannot increment a sequence number: \ + maximum valid sequence number has already been reached" + ); SequenceNumber(max_input.0 + 1) } + /// Checks if this sequence number is cancelled, i.e., the corresponding + /// object appears in a cancelled transaction. pub fn is_cancelled(&self) -> bool { self == &SequenceNumber::CANCELLED_READ - || self == &SequenceNumber::CONGESTED || self == &SequenceNumber::RANDOMNESS_UNAVAILABLE + || self.is_congested() } + /// Checks if this sequence number is valid, i.e., the corresponding + /// object does not appear in a cancelled transaction. pub fn is_valid(&self) -> bool { - self < &SequenceNumber::MAX + self < &SequenceNumber::MAX_VALID_EXCL } } diff --git a/crates/iota-types/src/execution_status.rs b/crates/iota-types/src/execution_status.rs index 3ec761d5b14..05fe98c66a0 100644 --- a/crates/iota-types/src/execution_status.rs +++ b/crates/iota-types/src/execution_status.rs @@ -194,7 +194,7 @@ pub enum ExecutionFailureStatus { #[error("Certificate cannot be executed due to a dependency on a deleted shared object")] InputObjectDeleted, - #[error("Certificate is cancelled due to congestion on shared objects: {congested_objects}")] + #[error("Certificate is cancelled due to congestion on shared objects: {congested_objects}.")] ExecutionCancelledDueToSharedObjectCongestion { congested_objects: CongestedObjects }, #[error("Address {address:?} is denied for coin {coin_type}")] @@ -208,6 +208,18 @@ pub enum ExecutionFailureStatus { #[error("Certificate is cancelled because randomness could not be generated this epoch")] ExecutionCancelledDueToRandomnessUnavailable, + + // Certificate is cancelled due to congestion on shared objects; + // suggested gas price can be used to give this certificate more priority. + #[error( + "Certificate is cancelled due to congestion on shared objects: {congested_objects}. \ + To give this certificate more priority to be executed, its gas price can be increased \ + to at least {suggested_gas_price}." + )] + ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects: CongestedObjects, + suggested_gas_price: u64, + }, // NOTE: if you want to add a new enum, // please add it at the end for Rust SDK backward compatibility. } diff --git a/crates/iota-types/src/iota_sdk_types_conversions.rs b/crates/iota-types/src/iota_sdk_types_conversions.rs index 85db2e1ad34..2bb8f5d663d 100644 --- a/crates/iota-types/src/iota_sdk_types_conversions.rs +++ b/crates/iota-types/src/iota_sdk_types_conversions.rs @@ -1236,6 +1236,13 @@ impl From for ExecutionError { ExecutionFailureStatus::ExecutionCancelledDueToRandomnessUnavailable => { Self::ExecutionCancelledDueToRandomnessUnavailable } + ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } => Self::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects: congested_objects.0.into_iter().map(Into::into).collect(), + suggested_gas_price, + }, } } } @@ -1430,6 +1437,15 @@ impl From for crate::execution_status::ExecutionFailureStatus { ExecutionError::ExecutionCancelledDueToRandomnessUnavailable => { Self::ExecutionCancelledDueToRandomnessUnavailable } + ExecutionError::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } => Self::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects: crate::execution_status::CongestedObjects( + congested_objects.into_iter().map(Into::into).collect(), + ), + suggested_gas_price, + }, } } } diff --git a/crates/iota-types/src/storage/mod.rs b/crates/iota-types/src/storage/mod.rs index f2bded23594..9206a76a0d2 100644 --- a/crates/iota-types/src/storage/mod.rs +++ b/crates/iota-types/src/storage/mod.rs @@ -474,14 +474,14 @@ impl ChildObjectResolver for &mut S { pub struct ObjectKey(pub ObjectID, pub VersionNumber); impl ObjectKey { - pub const ZERO: ObjectKey = ObjectKey(ObjectID::ZERO, VersionNumber::MIN); + pub const ZERO: ObjectKey = ObjectKey(ObjectID::ZERO, VersionNumber::MIN_VALID_INCL); pub fn max_for_id(id: &ObjectID) -> Self { - Self(*id, VersionNumber::MAX) + Self(*id, VersionNumber::MAX_VALID_EXCL) } pub fn min_for_id(id: &ObjectID) -> Self { - Self(*id, VersionNumber::MIN) + Self(*id, VersionNumber::MIN_VALID_INCL) } } diff --git a/crates/iota-types/src/transaction.rs b/crates/iota-types/src/transaction.rs index 881221772c5..225dc555038 100644 --- a/crates/iota-types/src/transaction.rs +++ b/crates/iota-types/src/transaction.rs @@ -2929,9 +2929,7 @@ impl InputObjects { for obj in &self.objects { if let ObjectReadResultKind::CancelledTransactionSharedObject(version) = obj.object { contains_cancelled = true; - if version == SequenceNumber::CONGESTED - || version == SequenceNumber::RANDOMNESS_UNAVAILABLE - { + if version.is_congested() || version == SequenceNumber::RANDOMNESS_UNAVAILABLE { // Verify we don't have multiple cancellation reasons. assert!(cancel_reason.is_none() || cancel_reason == Some(version)); cancel_reason = Some(version); diff --git a/crates/iota-types/src/unit_tests/base_types_tests.rs b/crates/iota-types/src/unit_tests/base_types_tests.rs index 35dbe644f21..54f19df33e1 100644 --- a/crates/iota-types/src/unit_tests/base_types_tests.rs +++ b/crates/iota-types/src/unit_tests/base_types_tests.rs @@ -99,7 +99,7 @@ fn test_signatures_serde() { #[test] fn test_max_sequence_number() { - let max = SequenceNumber::MAX; + let max = SequenceNumber::MAX_VALID_EXCL; assert_eq!(max.0 * 2 + 1, u64::MAX); } diff --git a/crates/iota-types/tests/staged/exec_failure_status.yaml b/crates/iota-types/tests/staged/exec_failure_status.yaml index 00e684c3fa7..8d1a713694e 100644 --- a/crates/iota-types/tests/staged/exec_failure_status.yaml +++ b/crates/iota-types/tests/staged/exec_failure_status.yaml @@ -36,3 +36,4 @@ 34: AddressDeniedForCoin 35: CoinTypeGlobalPause 36: ExecutionCancelledDueToRandomnessUnavailable +37: ExecutionCancelledDueToSharedObjectCongestionV2 diff --git a/iota-execution/cut/Cargo.toml b/iota-execution/cut/Cargo.toml index 5de96d061b7..0f260053639 100644 --- a/iota-execution/cut/Cargo.toml +++ b/iota-execution/cut/Cargo.toml @@ -11,7 +11,7 @@ anyhow.workspace = true clap.workspace = true thiserror.workspace = true toml.workspace = true -toml_edit = "0.22" +toml_edit = "0.22.27" [dev-dependencies] expect-test.workspace = true diff --git a/iota-execution/latest/iota-adapter/src/execution_engine.rs b/iota-execution/latest/iota-adapter/src/execution_engine.rs index bdaa3960925..44029a35b41 100644 --- a/iota-execution/latest/iota-adapter/src/execution_engine.rs +++ b/iota-execution/latest/iota-adapter/src/execution_engine.rs @@ -334,9 +334,21 @@ mod checked { )) } else if let Some((cancelled_objects, reason)) = cancelled_objects { match reason { - SequenceNumber::CONGESTED => Err(ExecutionError::new( - ExecutionErrorKind::ExecutionCancelledDueToSharedObjectCongestion { - congested_objects: CongestedObjects(cancelled_objects), + version if version.is_congested() => Err(ExecutionError::new( + if protocol_config.congested_objects_gas_price_feedback_mechanism() { + ExecutionErrorKind::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects: CongestedObjects(cancelled_objects), + suggested_gas_price: version + .get_congested_version_suggested_gas_price(), + } + } else { + // WARN: do not remove this `else` branch even after + // `congested_objects_gas_price_feedback_mechanism` is enabled + // on the mainnet. It must be kept to be able to replay old + // transaction data. + ExecutionErrorKind::ExecutionCancelledDueToSharedObjectCongestion { + congested_objects: CongestedObjects(cancelled_objects), + } }, None, )), diff --git a/iota-execution/latest/iota-adapter/src/temporary_store.rs b/iota-execution/latest/iota-adapter/src/temporary_store.rs index 4448c6272ea..239705a7d9f 100644 --- a/iota-execution/latest/iota-adapter/src/temporary_store.rs +++ b/iota-execution/latest/iota-adapter/src/temporary_store.rs @@ -367,7 +367,7 @@ impl<'backing> TemporaryStore<'backing> { // transaction's lamport timestamp is strictly greater than all versions // witnessed by the transaction). debug_assert!( - object.is_immutable() || object.version() == SequenceNumber::MIN, + object.is_immutable() || object.version() == SequenceNumber::MIN_VALID_INCL, "Created mutable objects should not have a version set", ); let id = object.id(); diff --git a/iota-execution/latest/iota-move-natives/src/object_runtime/object_store.rs b/iota-execution/latest/iota-move-natives/src/object_runtime/object_store.rs index 6e7d9619d1d..f74d3baf363 100644 --- a/iota-execution/latest/iota-move-natives/src/object_runtime/object_store.rs +++ b/iota-execution/latest/iota-move-natives/src/object_runtime/object_store.rs @@ -615,8 +615,13 @@ impl<'a> ChildObjectStore<'a> { btree_map::Entry::Vacant(e) => { let child_move_type = field_setting_object_type; let inner = &self.inner; - let obj_opt = - fetch_child_object_unbounded!(inner, parent, child, SequenceNumber::MAX, true); + let obj_opt = fetch_child_object_unbounded!( + inner, + parent, + child, + SequenceNumber::MAX_VALID_EXCL, + true + ); let Some(move_obj) = obj_opt.as_ref().map(|obj| obj.data.try_as_move().unwrap()) else { return Ok(ObjectResult::Loaded(None)); From e5f8f975bc45a03fb4c7a61383b872a2779ba79a Mon Sep 17 00:00:00 2001 From: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:39:26 +0200 Subject: [PATCH 2/5] fix(iota-core), chore(iota-execution): fix order of gas price feedback feature flag; downgrade toml_edit version (#7501) - `iota-core`: Place the new feature flag `congested_objects_gas_price_feedback_mechanism` at the bottom. - `iota-execution`: Downgrade `toml_edit` to `0.22`. It seems it was unnecessary bumped to `0.22.27` on the feature branch. See first three comments in https://github.com/iotaledger/iota/pull/7456. - [x] Basic tests (linting, compilation, formatting, unit/integration tests) - [ ] Patch-specific tests (correctness, functionality coverage) - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have checked that new and existing unit tests pass locally with my changes - [x] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] Indexer: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] REST API: Signed-off-by: Roman Overko --- crates/iota-protocol-config/src/lib.rs | 24 ++++++++++++------------ iota-execution/cut/Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/iota-protocol-config/src/lib.rs b/crates/iota-protocol-config/src/lib.rs index 85768862f43..40186fbc393 100644 --- a/crates/iota-protocol-config/src/lib.rs +++ b/crates/iota-protocol-config/src/lib.rs @@ -276,11 +276,6 @@ struct FeatureFlags { #[serde(skip_serializing_if = "is_false")] consensus_zstd_compression: bool, - // To enable/disable the gas price feedback mechanism used for transactions - // cancelled due to shared object congestion - #[serde(skip_serializing_if = "is_false")] - congested_objects_gas_price_feedback_mechanism: bool, - // Use the minimum free execution slot to schedule execution of a transaction in the shared // object congestion tracker. #[serde(skip_serializing_if = "is_false")] @@ -293,6 +288,11 @@ struct FeatureFlags { // If true, enabled batched block sync in consensus. #[serde(skip_serializing_if = "is_false")] consensus_batched_block_sync: bool, + + // To enable/disable the gas price feedback mechanism used for transactions + // cancelled due to shared object congestion + #[serde(skip_serializing_if = "is_false")] + congested_objects_gas_price_feedback_mechanism: bool, } fn is_true(b: &bool) -> bool { @@ -1257,13 +1257,6 @@ impl ProtocolConfig { self.feature_flags.consensus_zstd_compression } - /// Check if the gas price feedback mechanism (which is used for - /// transactions cancelled due to shared object congestion) is enabled - pub fn congested_objects_gas_price_feedback_mechanism(&self) -> bool { - self.feature_flags - .congested_objects_gas_price_feedback_mechanism - } - pub fn congestion_control_min_free_execution_slot(&self) -> bool { self.feature_flags .congestion_control_min_free_execution_slot @@ -1276,6 +1269,13 @@ impl ProtocolConfig { pub fn consensus_batched_block_sync(&self) -> bool { self.feature_flags.consensus_batched_block_sync } + + /// Check if the gas price feedback mechanism (which is used for + /// transactions cancelled due to shared object congestion) is enabled + pub fn congested_objects_gas_price_feedback_mechanism(&self) -> bool { + self.feature_flags + .congested_objects_gas_price_feedback_mechanism + } } #[cfg(not(msim))] diff --git a/iota-execution/cut/Cargo.toml b/iota-execution/cut/Cargo.toml index 0f260053639..5de96d061b7 100644 --- a/iota-execution/cut/Cargo.toml +++ b/iota-execution/cut/Cargo.toml @@ -11,7 +11,7 @@ anyhow.workspace = true clap.workspace = true thiserror.workspace = true toml.workspace = true -toml_edit = "0.22.27" +toml_edit = "0.22" [dev-dependencies] expect-test.workspace = true From 371f9a2e6bbcb0590652faa340198187b01492a7 Mon Sep 17 00:00:00 2001 From: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:47:23 +0200 Subject: [PATCH 3/5] feat(iota-core): extend gas price feedback for multiple consensus commits (#7624) # Description of change This PR extend the gas price feedback mechanism to use the data from multiple consensus commit rounds for the suggested gas price calculations. Instead of calculating the suggested gas price using the data from a single commit round (the one in which the transaction is cancelled), in this PR, we calculate the suggested gas price at each commit a transaction is deferred, save the suggested gas price to the deferred transactions table, and update it every time a lower suggested gas price is found. ## Links to any relevant issues Closes #6353. ## How the change has been tested - [x] Basic tests (linting, compilation, formatting, unit/integration tests) - [ ] Patch-specific tests (correctness, functionality coverage) - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have checked that new and existing unit tests pass locally with my changes ### Release Notes - [x] Protocol: - [x] Nodes (Validators and Full nodes): - [ ] Indexer: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] REST API: --------- Co-authored-by: muXxer Signed-off-by: Roman Overko --- .../authority/authority_per_epoch_store.rs | 310 +++++++++++++----- .../shared_object_congestion_tracker.rs | 79 +++-- .../shared_object_version_manager.rs | 10 +- 3 files changed, 279 insertions(+), 120 deletions(-) diff --git a/crates/iota-core/src/authority/authority_per_epoch_store.rs b/crates/iota-core/src/authority/authority_per_epoch_store.rs index a95940133b5..c2cab6381c5 100644 --- a/crates/iota-core/src/authority/authority_per_epoch_store.rs +++ b/crates/iota-core/src/authority/authority_per_epoch_store.rs @@ -154,6 +154,42 @@ impl CertLockGuard { type JwkAggregator = GenericMultiStakeAggregator<(JwkId, JWK), true>; +/// An alias type for a collection used to hold previously deferred +/// transactions, where `Option` is used to hold suggested gas +/// price for transactions deferred due to shared object congestion +/// (`None` for transactions deferred due to "randomness not available"). +pub(crate) type PreviouslyDeferredTransactions = + HashMap)>; + +/// Holds a verified sequenced consensus transaction that is deferred +/// and optionally a suggested gas price for that transaction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeferredTransaction { + /// Deferred verified sequenced consensus transaction. + transaction: VerifiedSequencedConsensusTransaction, + + /// Suggested gas price is `Some(u64)` for transactions deferred due + /// to shared object congestion if gas price feedback is enabled, and + /// it is `None` otherwise and for transactions deferred due to + /// "randomness not available". + suggested_gas_price: Option, +} + +impl DeferredTransaction { + /// Construct a new `DeferredTransaction` instance from a deferred + /// verified sequenced consensus transaction and optionally a suggested + /// gas price for that transaction. + pub fn new( + transaction: VerifiedSequencedConsensusTransaction, + suggested_gas_price: Option, + ) -> Self { + Self { + transaction, + suggested_gas_price, + } + } +} + /// Represents a scheduling result: a transaction can be either scheduled /// for execution, or deferred for some reason. Scheduling result is /// returned by the `try_schedule` method of `AuthorityPerEpochStore`. @@ -169,7 +205,7 @@ enum SchedulingResult { pub enum CancelConsensusCertificateReason { CongestionOnObjects { congested_objects: Vec, - suggested_gas_price: u64, + suggested_gas_price: Option, }, DkgFailed, } @@ -192,8 +228,16 @@ pub enum ConsensusCertificateResult { start_time: ExecutionTime, }, /// The transaction should be re-processed at a future commit, specified by - /// the DeferralKey - Deferred(DeferralKey), + /// `deferral_key`. If the gas price feedback is enabled, + /// `suggested_gas_price` is `Some(...)` and indicates a gas price that + /// the certificate would need to pay to be scheduled in a consensus + /// commit. If the feedback mechanism is not enabled and for + /// certificates deferred due to "randomness not available", + /// the `suggested_gas_price` price field will be set to `None`. + Deferred { + deferral_key: DeferralKey, + suggested_gas_price: Option, + }, /// A message was processed which updates randomness state. RandomnessConsensusMessage, /// Everything else, e.g. AuthorityCapabilities, CheckpointSignatures, etc. @@ -617,6 +661,11 @@ pub struct AuthorityEpochTables { /// Transactions that are being deferred until some future time deferred_transactions: DBMap>, + /// Transactions that are being deferred until some future time. + /// V2 additionally includes suggested gas price for transactions + /// deferred due to congestion. + deferred_transactions_v2: DBMap>, + // Tables for recording state for RandomnessManager. // @@ -1671,7 +1720,7 @@ impl AuthorityPerEpochStore { fn load_deferred_transactions_for_randomness( &self, output: &mut ConsensusCommitOutput, - ) -> IotaResult)>> { + ) -> IotaResult)>> { let (min, max) = DeferralKey::full_range_for_randomness(); self.load_deferred_transactions(output, min, max) } @@ -1679,7 +1728,7 @@ impl AuthorityPerEpochStore { fn load_and_process_deferred_transactions_for_randomness( &self, output: &mut ConsensusCommitOutput, - previously_deferred_tx_digests: &mut HashMap, + previously_deferred_tx_digests: &mut PreviouslyDeferredTransactions, sequenced_randomness_transactions: &mut Vec, ) -> IotaResult { let deferred_randomness_txs = self.load_deferred_transactions_for_randomness(output)?; @@ -1689,18 +1738,24 @@ impl AuthorityPerEpochStore { ); previously_deferred_tx_digests.extend(deferred_randomness_txs.iter().flat_map( |(deferral_key, txs)| { - txs.iter().map(|tx| match tx.0.transaction.key() { - SequencedConsensusTransactionKey::External( - ConsensusTransactionKey::Certificate(digest), - ) => (digest, *deferral_key), - _ => { - panic!("deferred randomness transaction was not a user certificate: {tx:?}") - } - }) + txs.iter() + .map(|tx| match tx.transaction.0.transaction.key() { + SequencedConsensusTransactionKey::External( + ConsensusTransactionKey::Certificate(digest), + ) => (digest, (*deferral_key, tx.suggested_gas_price)), + _ => { + panic!( + "deferred randomness transaction was not a user certificate: {tx:?}" + ) + } + }) }, )); - sequenced_randomness_transactions - .extend(deferred_randomness_txs.into_iter().flat_map(|(_, txs)| txs)); + sequenced_randomness_transactions.extend( + deferred_randomness_txs + .into_iter() + .flat_map(|(_, txs)| txs.into_iter().map(|tx| tx.transaction).collect::>()), + ); Ok(()) } @@ -1708,7 +1763,7 @@ impl AuthorityPerEpochStore { &self, output: &mut ConsensusCommitOutput, consensus_round: u64, - ) -> IotaResult)>> { + ) -> IotaResult)>> { let (min, max) = DeferralKey::range_for_up_to_consensus_round(consensus_round); self.load_deferred_transactions(output, min, max) } @@ -1719,26 +1774,54 @@ impl AuthorityPerEpochStore { output: &mut ConsensusCommitOutput, min: DeferralKey, max: DeferralKey, - ) -> IotaResult)>> { + ) -> IotaResult)>> { debug!("Query epoch store to load deferred txn {:?} {:?}", min, max); let mut keys = Vec::new(); let mut txns = Vec::new(); - self.tables()? - .deferred_transactions - .safe_iter_with_bounds(Some(min), Some(max)) - .try_for_each(|result| match result { - Ok((key, txs)) => { - debug!( - "Loaded {:?} deferred txn with deferral key {:?}", - txs.len(), - key - ); - keys.push(key); - txns.push((key, txs)); - Ok(()) - } - Err(err) => Err(err), - })?; + + if self + .protocol_config + .congested_objects_gas_price_feedback_mechanism() + { + self.tables()? + .deferred_transactions_v2 + .safe_iter_with_bounds(Some(min), Some(max)) + .try_for_each(|result| match result { + Ok((key, txs)) => { + debug!( + "Loaded {:?} deferred txn with deferral key {:?}", + txs.len(), + key + ); + keys.push(key); + txns.push((key, txs)); + Ok(()) + } + Err(err) => Err(err), + })?; + } else { + self.tables()? + .deferred_transactions + .safe_iter_with_bounds(Some(min), Some(max)) + .try_for_each(|result| match result { + Ok((key, txs)) => { + debug!( + "Loaded {:?} deferred txn with deferral key {:?}", + txs.len(), + key + ); + keys.push(key); + txns.push(( + key, + txs.into_iter() + .map(|tx| DeferredTransaction::new(tx, None)) + .collect(), + )); + Ok(()) + } + Err(err) => Err(err), + })?; + } // verify that there are no duplicates - should be impossible due to // is_consensus_message_processed @@ -1747,7 +1830,7 @@ impl AuthorityPerEpochStore { let mut seen = HashSet::new(); for deferred_txn_batch in &txns { for txn in &deferred_txn_batch.1 { - assert!(seen.insert(txn.0.key())); + assert!(seen.insert(txn.transaction.0.key())); } } } @@ -1759,10 +1842,10 @@ impl AuthorityPerEpochStore { pub fn get_all_deferred_transactions_for_test( &self, - ) -> IotaResult)>> { + ) -> IotaResult)>> { Ok(self .tables()? - .deferred_transactions + .deferred_transactions_v2 .safe_iter() .collect::, _>>()?) } @@ -1781,7 +1864,7 @@ impl AuthorityPerEpochStore { commit_round: CommitRound, dkg_failed: bool, generating_randomness: bool, - previously_deferred_tx_digests: &HashMap, + previously_deferred_tx_digests: &PreviouslyDeferredTransactions, shared_object_congestion_tracker: &mut SharedObjectCongestionTracker, ) -> SchedulingResult { // Defer transaction if it uses randomness but we aren't generating any this @@ -1790,7 +1873,11 @@ impl AuthorityPerEpochStore { if !dkg_failed && !generating_randomness && cert.transaction_data().uses_randomness() { let deferred_from_round = previously_deferred_tx_digests .get(cert.digest()) - .map(|previous_key| previous_key.deferred_from_round()) + .map(|previous_key_suggested_gas_price_pair| { + previous_key_suggested_gas_price_pair + .0 + .deferred_from_round() + }) .unwrap_or(commit_round); return SchedulingResult::Defer( DeferralKey::new_for_randomness(deferred_from_round), @@ -1917,10 +2004,20 @@ impl AuthorityPerEpochStore { } pub fn deferred_transactions_empty(&self) -> bool { - self.tables() - .expect("deferred transactions should not be read past end of epoch") - .deferred_transactions - .is_empty() + if self + .protocol_config + .congested_objects_gas_price_feedback_mechanism() + { + self.tables() + .expect("deferred transactions should not be read past end of epoch") + .deferred_transactions_v2 + .is_empty() + } else { + self.tables() + .expect("deferred transactions should not be read past end of epoch") + .deferred_transactions + .is_empty() + } } /// Check whether any certificates were processed by consensus. @@ -2596,25 +2693,25 @@ impl AuthorityPerEpochStore { let mut output = ConsensusCommitOutput::new(); // Load transactions deferred from previous commits. - let deferred_txs: Vec<(DeferralKey, Vec)> = self + let deferred_txs: Vec<(DeferralKey, Vec)> = self .load_deferred_transactions_for_up_to_consensus_round( &mut output, consensus_commit_info.round, )? .into_iter() .collect(); - let mut previously_deferred_tx_digests: HashMap = - deferred_txs - .iter() - .flat_map(|(deferral_key, txs)| { - txs.iter().map(|tx| match tx.0.transaction.key() { + let mut previously_deferred_tx_digests: PreviouslyDeferredTransactions = deferred_txs + .iter() + .flat_map(|(deferral_key, txs)| { + txs.iter() + .map(|tx| match tx.transaction.0.transaction.key() { SequencedConsensusTransactionKey::External( ConsensusTransactionKey::Certificate(digest), - ) => (digest, *deferral_key), + ) => (digest, (*deferral_key, tx.suggested_gas_price)), _ => panic!("deferred transaction was not a user certificate: {tx:?}"), }) - }) - .collect(); + }) + .collect(); // Sequenced_transactions and sequenced_randomness_transactions store all // transactions that will be sent to process_consensus_transactions. We @@ -2684,10 +2781,10 @@ impl AuthorityPerEpochStore { .into_iter() .flat_map(|(_, txs)| txs.into_iter()) { - if tx.0.is_user_tx_with_randomness() { - sequenced_randomness_transactions.push(tx); + if tx.transaction.0.is_user_tx_with_randomness() { + sequenced_randomness_transactions.push(tx.transaction); } else { - sequenced_transactions.push(tx); + sequenced_transactions.push(tx.transaction); } } sequenced_transactions.extend(current_commit_sequenced_consensus_transactions); @@ -3053,7 +3150,7 @@ impl AuthorityPerEpochStore { consensus_commit_info: &ConsensusCommitInfo, roots: &mut BTreeSet, randomness_roots: &mut BTreeSet, - previously_deferred_tx_digests: HashMap, + previously_deferred_tx_digests: PreviouslyDeferredTransactions, mut randomness_manager: Option<&mut RandomnessManager>, dkg_failed: bool, randomness_round: Option, @@ -3073,8 +3170,7 @@ impl AuthorityPerEpochStore { let mut verified_certificates = VecDeque::with_capacity(transactions.len() + 1); let mut notifications = Vec::with_capacity(transactions.len()); - let mut deferred_txns: BTreeMap> = - BTreeMap::new(); + let mut deferred_txns: BTreeMap> = BTreeMap::new(); let mut cancelled_txns: BTreeMap = BTreeMap::new(); @@ -3136,13 +3232,16 @@ impl AuthorityPerEpochStore { notifications.push(key.clone()); sequenced_transactions.push((transaction, start_time)); } - ConsensusCertificateResult::Deferred(deferral_key) => { + ConsensusCertificateResult::Deferred { + deferral_key, + suggested_gas_price, + } => { // Note: record_consensus_message_processed() must be called for this // cert even though we are not processing it now! deferred_txns .entry(deferral_key) .or_default() - .push(tx.clone()); + .push(DeferredTransaction::new(tx.clone(), suggested_gas_price)); filter_roots = true; if tx.0.transaction.is_executable_transaction() { // Notify consensus adapter that the consensus handler has received the @@ -3376,7 +3475,7 @@ impl AuthorityPerEpochStore { transaction: &VerifiedSequencedConsensusTransaction, checkpoint_service: &Arc, commit_round: CommitRound, - previously_deferred_tx_digests: &HashMap, + previously_deferred_tx_digests: &PreviouslyDeferredTransactions, mut randomness_manager: Option<&mut RandomnessManager>, dkg_failed: bool, generating_randomness: bool, @@ -3466,34 +3565,62 @@ impl AuthorityPerEpochStore { let deferral_result = match deferral_reason { DeferralReason::RandomnessNotReady => { // Always defer transaction due to randomness not ready. - ConsensusCertificateResult::Deferred(deferral_key) + ConsensusCertificateResult::Deferred { + deferral_key, + suggested_gas_price: None, + } } DeferralReason::SharedObjectCongestion(congested_objects) => { authority_metrics .consensus_handler_congested_transactions .inc(); + + let suggested_gas_price = if self + .protocol_config + .congested_objects_gas_price_feedback_mechanism() + { + let current_commit_suggested_gas_price = + self.reference_gas_price(); + let suggested_gas_price = previously_deferred_tx_digests + .get(certificate.digest()) + .map_or_else( + || current_commit_suggested_gas_price, + |deferral_key_suggested_gas_price_pair| { + deferral_key_suggested_gas_price_pair + .1 + .expect( + "Suggested gas price for transactions \ + previously deferred due to congestion must \ + not be None if the gas price feedback is \ + enabled.", + ) + .min(current_commit_suggested_gas_price) + }, + ); + + Some(suggested_gas_price) + } else { + None + }; + if transaction_deferral_within_limit( &deferral_key, self.protocol_config() .max_deferral_rounds_for_congestion_control(), ) { - ConsensusCertificateResult::Deferred(deferral_key) + ConsensusCertificateResult::Deferred { + deferral_key, + suggested_gas_price, + } } else { // Cancel the transaction that has been deferred for too long. - let suggested_gas_price = shared_object_congestion_tracker - .compute_suggested_gas_price(&certificate) - .expect( - "cancelled transaction must have at least one shared \ - object and calculated suggested gas price", - ); - debug!( - "Cancelling consensus certificate for transaction {:?} with \ - deferral key {deferral_key:?} due to congestion on \ - objects {congested_objects:?}: actual gas price: \ - {}, suggested gas price: \ - {suggested_gas_price}", + "Cancelling consensus certificate for transaction {:?} \ + with deferral key {deferral_key:?} due to congestion \ + on objects {congested_objects:?}: actual gas price: \ + {}, suggested gas price: \ + {suggested_gas_price:?}", certificate.digest(), certificate.transaction_data().gas_price(), ); @@ -4017,7 +4144,7 @@ pub(crate) struct ConsensusCommitOutput { // transaction scheduling state shared_object_versions: Option<(AssignedTxAndVersions, HashMap)>, - deferred_txns: Vec<(DeferralKey, Vec)>, + deferred_txns: Vec<(DeferralKey, Vec)>, // deferred txns that have been loaded and can be removed deleted_deferred_txns: BTreeSet, @@ -4084,11 +4211,7 @@ impl ConsensusCommitOutput { self.shared_object_versions = Some((versions, next_versions)); } - fn defer_transactions( - &mut self, - key: DeferralKey, - transactions: Vec, - ) { + fn defer_transactions(&mut self, key: DeferralKey, transactions: Vec) { self.deferred_txns.push((key, transactions)); } @@ -4180,8 +4303,31 @@ impl ConsensusCommitOutput { batch.insert_batch(&tables.next_shared_object_versions, next_versions)?; } - batch.delete_batch(&tables.deferred_transactions, self.deleted_deferred_txns)?; - batch.insert_batch(&tables.deferred_transactions, self.deferred_txns)?; + if epoch_store + .protocol_config + .congested_objects_gas_price_feedback_mechanism() + { + batch.delete_batch(&tables.deferred_transactions_v2, self.deleted_deferred_txns)?; + batch.insert_batch(&tables.deferred_transactions_v2, self.deferred_txns)?; + } else { + batch.delete_batch(&tables.deferred_transactions, self.deleted_deferred_txns)?; + batch.insert_batch( + &tables.deferred_transactions, + self.deferred_txns + .into_iter() + .map(|entry| { + ( + entry.0, + entry + .1 + .into_iter() + .map(|tx| tx.transaction) + .collect::>(), + ) + }) + .collect::>(), + )?; + } batch.insert_batch( &tables.user_signatures_for_checkpoints, diff --git a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs index 04e0315ca19..331e74593e1 100644 --- a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs +++ b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs @@ -6,12 +6,14 @@ use std::{cmp::Ordering, collections::HashMap}; use iota_protocol_config::PerObjectCongestionControlMode; use iota_types::{ - base_types::{CommitRound, ObjectID, TransactionDigest}, + base_types::{CommitRound, ObjectID}, executable_transaction::VerifiedExecutableTransaction, transaction::{SharedInputObject, TransactionDataAPI}, }; -use super::transaction_deferral::DeferralKey; +use super::{ + authority_per_epoch_store::PreviouslyDeferredTransactions, transaction_deferral::DeferralKey, +}; /// Represents execution slot boundaries pub(crate) type ExecutionTime = u64; @@ -374,7 +376,7 @@ impl SharedObjectCongestionTracker { &self, cert: &VerifiedExecutableTransaction, max_execution_duration_per_commit: u64, - previously_deferred_tx_digests: &HashMap, + previously_deferred_tx_digests: &PreviouslyDeferredTransactions, commit_round: CommitRound, ) -> SequencingResult { let tx_duration = self.get_estimated_execution_duration(cert); @@ -428,19 +430,22 @@ impl SharedObjectCongestionTracker { }; assert!(!congested_objects.is_empty()); - let deferral_key = - if let Some(previous_key) = previously_deferred_tx_digests.get(cert.digest()) { - // This transaction has been deferred in previous consensus commit. Use its - // previous deferred_from_round. - DeferralKey::new_for_consensus_round( - commit_round + 1, - previous_key.deferred_from_round(), - ) - } else { - // This transaction has not been deferred before. Use the current commit round - // as the deferred_from_round. - DeferralKey::new_for_consensus_round(commit_round + 1, commit_round) - }; + let deferral_key = if let Some(previous_key_suggested_gas_price_pair) = + previously_deferred_tx_digests.get(cert.digest()) + { + // This transaction has been deferred in previous consensus commit. Use its + // previous deferred_from_round. + DeferralKey::new_for_consensus_round( + commit_round + 1, + previous_key_suggested_gas_price_pair + .0 + .deferred_from_round(), + ) + } else { + // This transaction has not been deferred before. Use the current commit round + // as the deferred_from_round. + DeferralKey::new_for_consensus_round(commit_round + 1, commit_round) + }; SequencingResult::Defer(deferral_key, congested_objects) } @@ -477,12 +482,6 @@ impl SharedObjectCongestionTracker { .max() .unwrap_or(0) } - - // NOTE: this function will be rewritten anyway in the new sequencer - // (see PR #6490), so we simple return the certificate's gas price here. - pub fn compute_suggested_gas_price(&self, cert: &VerifiedExecutableTransaction) -> Option { - Some(cert.transaction_data().gas_price()) - } } #[cfg(test)] @@ -672,7 +671,7 @@ pub mod shared_object_test_utils { shared_object_congestion_tracker: &mut SharedObjectCongestionTracker, cert: &VerifiedExecutableTransaction, max_execution_duration_per_commit: u64, - previously_deferred_tx_digests: &HashMap, + previously_deferred_tx_digests: &PreviouslyDeferredTransactions, commit_round: CommitRound, ) -> SequencingResult { shared_object_congestion_tracker.initialize_object_execution_slots( @@ -735,6 +734,7 @@ pub mod shared_object_test_utils { #[cfg(test)] mod object_cost_tests { + use iota_types::digests::TransactionDigest; use rstest::rstest; use super::{shared_object_test_utils::*, *}; @@ -1068,13 +1068,16 @@ mod object_cost_tests { let mut shared_object_congestion_tracker = SharedObjectCongestionTracker::new(mode, false); // Insert a random pre-existing transaction. - let mut previously_deferred_tx_digests = HashMap::new(); + let mut previously_deferred_tx_digests = PreviouslyDeferredTransactions::new(); previously_deferred_tx_digests.insert( TransactionDigest::random(), - DeferralKey::ConsensusRound { - future_round: 10, - deferred_from_round: 5, - }, + ( + DeferralKey::ConsensusRound { + future_round: 10, + deferred_from_round: 5, + }, + Some(1_000), + ), ); // Test deferral key for a transaction that has not been deferred before. @@ -1100,9 +1103,12 @@ mod object_cost_tests { // Insert `tx`` as previously deferred transaction due to randomness. previously_deferred_tx_digests.insert( *tx.digest(), - DeferralKey::Randomness { - deferred_from_round: 4, - }, + ( + DeferralKey::Randomness { + deferred_from_round: 4, + }, + None, + ), ); // New deferral key should have deferred_from_round equal to the deferred @@ -1129,10 +1135,13 @@ mod object_cost_tests { // Insert `tx`` as previously deferred consensus transaction. previously_deferred_tx_digests.insert( *tx.digest(), - DeferralKey::ConsensusRound { - future_round: 10, - deferred_from_round: 5, - }, + ( + DeferralKey::ConsensusRound { + future_round: 10, + deferred_from_round: 5, + }, + Some(1_000), + ), ); // New deferral key should have deferred_from_round equal to the one in the old diff --git a/crates/iota-core/src/authority/shared_object_version_manager.rs b/crates/iota-core/src/authority/shared_object_version_manager.rs index 74c910e2eba..3c6633a658a 100644 --- a/crates/iota-core/src/authority/shared_object_version_manager.rs +++ b/crates/iota-core/src/authority/shared_object_version_manager.rs @@ -180,7 +180,11 @@ impl SharedObjVerManager { { if enable_gas_price_feedback { SequenceNumber::new_congested_with_suggested_gas_price( - *suggested_gas_price, + suggested_gas_price.expect( + "Suggested gas price for transactions cancelled due \ + to congestion must not be None if the gas price \ + feedback is enabled.", + ), ) } else { // WARN: do not remove this `else` branch even after @@ -543,14 +547,14 @@ mod tests { *certs[1].digest(), CancelConsensusCertificateReason::CongestionOnObjects { congested_objects: vec![id1], - suggested_gas_price, + suggested_gas_price: Some(suggested_gas_price), }, ), ( *certs[3].digest(), CancelConsensusCertificateReason::CongestionOnObjects { congested_objects: vec![id2], - suggested_gas_price, + suggested_gas_price: Some(suggested_gas_price), }, ), ( From 870cd6533951d3e14d8575065ada925f146e1656 Mon Sep 17 00:00:00 2001 From: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:42:07 +0200 Subject: [PATCH 4/5] feat(iota-core): calculate suggested gas price in the new sequencer for gas price feedback mechanism (#6490) This PR add a `SuggestedGasPriceCalculator` component that collects congestion data from a single commit in order to calculate a suggested gas price for transactions deferred/cancelled due to shared object congestion. The component works similarly to `SharedObjectCongestionTracker` in the sense that a new instance of `SuggestedGasPriceCalculator` is created for each consensus commit round. The calculator supports suggested gas price calculations for both new (#5763) and old `SharedObjectCongestionTracker` (configured by the `congestion_control_min_free_execution_slot` feature flag), as well as for any currently available `PerObjectCongestionControlMode` mode. Calculated suggested gas prices are intended for use in the gas price feedback mechanism #6280 for transactions cancelled due to shared object congestion. Closes #6351. ```console cargo test -p iota-core authority::suggested_gas_price_calculator ``` - [x] Basic tests (linting, compilation, formatting, unit/integration tests) - [ ] Patch-specific tests (correctness, functionality coverage) - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have checked that new and existing unit tests pass locally with my changes - [x] Protocol: - [x] Nodes (Validators and Full nodes): - [ ] Indexer: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] REST API: --------- Co-authored-by: Andrew Co-authored-by: cuileri Co-authored-by: Andrew Cullen <45826600+cyberphysic4l@users.noreply.github.com> Signed-off-by: Roman Overko --- crates/iota-core/src/authority.rs | 1 + .../authority/authority_per_epoch_store.rs | 49 +- .../shared_object_congestion_tracker.rs | 96 +- .../suggested_gas_price_calculator.rs | 1389 +++++++++++++++++ .../src/unit_tests/authority_tests.rs | 15 +- .../unit_tests/congestion_control_tests.rs | 35 +- 6 files changed, 1548 insertions(+), 37 deletions(-) create mode 100644 crates/iota-core/src/authority/suggested_gas_price_calculator.rs diff --git a/crates/iota-core/src/authority.rs b/crates/iota-core/src/authority.rs index e188e53a628..4516f2fac67 100644 --- a/crates/iota-core/src/authority.rs +++ b/crates/iota-core/src/authority.rs @@ -207,6 +207,7 @@ pub mod authority_store_types; pub mod epoch_start_configuration; pub mod shared_object_congestion_tracker; pub mod shared_object_version_manager; +pub mod suggested_gas_price_calculator; pub mod test_authority_builder; pub mod transaction_deferral; diff --git a/crates/iota-core/src/authority/authority_per_epoch_store.rs b/crates/iota-core/src/authority/authority_per_epoch_store.rs index c2cab6381c5..a4c2b19e591 100644 --- a/crates/iota-core/src/authority/authority_per_epoch_store.rs +++ b/crates/iota-core/src/authority/authority_per_epoch_store.rs @@ -94,6 +94,7 @@ use crate::{ shared_object_version_manager::{ AssignedTxAndVersions, ConsensusSharedObjVerAssignment, SharedObjVerManager, }, + suggested_gas_price_calculator::SuggestedGasPriceCalculator, }, checkpoints::{ BuilderCheckpointSummary, CheckpointHeight, CheckpointServiceNotify, EpochStats, @@ -193,7 +194,7 @@ impl DeferredTransaction { /// Represents a scheduling result: a transaction can be either scheduled /// for execution, or deferred for some reason. Scheduling result is /// returned by the `try_schedule` method of `AuthorityPerEpochStore`. -enum SchedulingResult { +pub(crate) enum SchedulingResult { /// Scheduling result indicating that a transaction is scheduled to be /// executed at start time Schedule(/* start_time */ ExecutionTime), @@ -3200,6 +3201,23 @@ impl AuthorityPerEpochStore { } ); + let mut suggested_gas_price_calculator = SuggestedGasPriceCalculator::new( + self.get_max_execution_duration_per_commit(), + self.reference_gas_price(), + self.protocol_config().max_gas_price(), + ); + + fail_point_arg!( + "initial_suggested_gas_price_calculator", + |calculator: SuggestedGasPriceCalculator| { + info!( + "Initialize suggested_gas_price_calculator to {:?}", + calculator + ); + suggested_gas_price_calculator = calculator; + } + ); + let mut randomness_state_updated = false; for tx in transactions { let key = tx.0.transaction.key(); @@ -3221,6 +3239,7 @@ impl AuthorityPerEpochStore { dkg_failed, randomness_round.is_some(), congestion_tracker, + &mut suggested_gas_price_calculator, authority_metrics, ) .await? @@ -3480,6 +3499,7 @@ impl AuthorityPerEpochStore { dkg_failed: bool, generating_randomness: bool, shared_object_congestion_tracker: &mut SharedObjectCongestionTracker, + suggested_gas_price_calculator: &mut SuggestedGasPriceCalculator, authority_metrics: &Arc, ) -> IotaResult { let _scope = monitored_scope("HandleConsensusTransaction"); @@ -3516,7 +3536,8 @@ impl AuthorityPerEpochStore { // certificate here it means authority is byzantine and sent certificate after // EndOfPublish (or we have some bug in ConsensusAdapter) warn!( - "[Byzantine authority] Authority {:?} sent a new, previously unseen certificate {:?} after it sent EndOfPublish message to consensus", + "[Byzantine authority] Authority {:?} sent a new, previously unseen + certificate {:?} after it sent EndOfPublish message to consensus", certificate_author.concise(), certificate.digest() ); @@ -3553,6 +3574,8 @@ impl AuthorityPerEpochStore { previously_deferred_tx_digests, shared_object_congestion_tracker, ); + let estimated_execution_duration = + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate); match scheduling_result { SchedulingResult::Defer(deferral_key, deferral_reason) => { @@ -3580,7 +3603,12 @@ impl AuthorityPerEpochStore { .congested_objects_gas_price_feedback_mechanism() { let current_commit_suggested_gas_price = - self.reference_gas_price(); + suggested_gas_price_calculator + .calculate_suggested_gas_price( + &certificate, + estimated_execution_duration, + ); + let suggested_gas_price = previously_deferred_tx_digests .get(certificate.digest()) .map_or_else( @@ -3635,7 +3663,8 @@ impl AuthorityPerEpochStore { } } }; - return Ok(deferral_result); + + Ok(deferral_result) } SchedulingResult::Schedule(start_time) => { if dkg_failed && certificate.transaction_data().uses_randomness() { @@ -3643,13 +3672,17 @@ impl AuthorityPerEpochStore { "Canceling randomness-using certificate for transaction {:?} because DKG failed", certificate.digest(), ); + return Ok(ConsensusCertificateResult::Cancelled(( certificate, CancelConsensusCertificateReason::DkgFailed, ))); } - // This certificate will be scheduled. Update object execution slots. + // This certificate will be scheduled. If it contains shared object(s), + // we have to update the following: + // - shared object execution slots (for congestion tracker); + // - shared object congestion info (for suggested gas price calculator). // We only need to do this if `max_execution_duration_per_commit` is // `Some`, since otherwise this bumping will panic as object // execution slots are only initialized if @@ -3659,6 +3692,12 @@ impl AuthorityPerEpochStore { { shared_object_congestion_tracker .bump_object_execution_slots(&certificate, start_time); + + suggested_gas_price_calculator.update_congestion_info( + &certificate, + start_time, + estimated_execution_duration, + ); } Ok(ConsensusCertificateResult::Scheduled { diff --git a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs index 331e74593e1..86d442474d3 100644 --- a/crates/iota-core/src/authority/shared_object_congestion_tracker.rs +++ b/crates/iota-core/src/authority/shared_object_congestion_tracker.rs @@ -248,7 +248,7 @@ impl SharedObjectCongestionTracker { /// /// Before calling this function, the caller should ensure that the tracker /// is initialized for all objects in the transaction by first calling - /// `initialize_for_shared_objects`. + /// `initialize_object_execution_slots`. pub fn compute_tx_start_time( &self, shared_input_objects: &[SharedInputObject], @@ -428,6 +428,7 @@ impl SharedObjectCongestionTracker { .map(|obj| obj.id) .collect() }; + assert!(!congested_objects.is_empty()); let deferral_key = if let Some(previous_key_suggested_gas_price_pair) = @@ -623,19 +624,22 @@ pub mod shared_object_test_utils { use super::*; + pub const TEST_ONLY_GAS_PRICE: u64 = 1_000; + // Builds a certificate with a list of shared objects and their mutability. The // certificate is only used to test the SharedObjectCongestionTracker - // functions, therefore the content other than shared inputs and gas budget - // are not important. + // functions, therefore the content other than shared inputs, gas budget + // and gas price are not important. pub fn build_transaction( objects: &[(ObjectID, bool)], gas_budget: u64, + gas_price: u64, ) -> VerifiedExecutableTransaction { let (sender, keypair): (_, AccountKeyPair) = get_key_pair(); let gas_object = random_object_ref(); VerifiedExecutableTransaction::new_system( VerifiedTransaction::new_unchecked( - TestTransactionBuilder::new(sender, gas_object, 1000) + TestTransactionBuilder::new(sender, gas_object, gas_price) .with_gas_budget(gas_budget) .move_call( ObjectID::random(), @@ -702,15 +706,43 @@ pub mod shared_object_test_utils { match mode { PerObjectCongestionControlMode::None => {} PerObjectCongestionControlMode::TotalGasBudget => { - let transaction = build_transaction(&[(*object_id, true)], *duration); - let start_time = initialize_tracker_and_compute_tx_start_time(&mut shared_object_congestion_tracker, &transaction.data().inner().intent_message().value.shared_input_objects(), *duration).expect("initial value should be fit within the available range of slots in the tracker"); + let transaction = + build_transaction(&[(*object_id, true)], *duration, TEST_ONLY_GAS_PRICE); + let start_time = initialize_tracker_and_compute_tx_start_time( + &mut shared_object_congestion_tracker, + &transaction + .data() + .inner() + .intent_message() + .value + .shared_input_objects(), + *duration, + ) + .expect( + "initial value should be fit within the available range of slots \ + in the tracker", + ); shared_object_congestion_tracker .bump_object_execution_slots(&transaction, start_time); } PerObjectCongestionControlMode::TotalTxCount => { for _ in 0..*duration { - let transaction = build_transaction(&[(*object_id, true)], 1); - let start_time = initialize_tracker_and_compute_tx_start_time(&mut shared_object_congestion_tracker, &transaction.data().inner().intent_message().value.shared_input_objects(), 1).expect("initial value should be fit within the available range of slots in the tracker"); + let transaction = + build_transaction(&[(*object_id, true)], 1, TEST_ONLY_GAS_PRICE); + let start_time = initialize_tracker_and_compute_tx_start_time( + &mut shared_object_congestion_tracker, + &transaction + .data() + .inner() + .intent_message() + .value + .shared_input_objects(), + 1, + ) + .expect( + "initial value should be fit within the available range of \ + slots in the tracker", + ); shared_object_congestion_tracker .bump_object_execution_slots(&transaction, start_time); } @@ -784,7 +816,7 @@ mod object_cost_tests { Some(9) ); // now add this transaction to the tracker. - let tx = build_transaction(objects, 1); + let tx = build_transaction(objects, 1, TEST_ONLY_GAS_PRICE); shared_object_congestion_tracker.bump_object_execution_slots(&tx, 9); // That tracker now has the following object execution slots: @@ -969,7 +1001,11 @@ mod object_cost_tests { } }; // add a transaction that writes to object 0 and 1. - let tx = build_transaction(&[(shared_obj_0, true), (shared_obj_1, true)], 1); + let tx = build_transaction( + &[(shared_obj_0, true), (shared_obj_1, true)], + 1, + TEST_ONLY_GAS_PRICE, + ); shared_object_congestion_tracker.bump_object_execution_slots( &tx, match mode { @@ -993,7 +1029,11 @@ mod object_cost_tests { // Read/write to object 0 should be deferred. for mutable in [true, false].iter() { - let tx = build_transaction(&[(shared_obj_0, *mutable)], tx_gas_budget); + let tx = build_transaction( + &[(shared_obj_0, *mutable)], + tx_gas_budget, + TEST_ONLY_GAS_PRICE, + ); if let SequencingResult::Defer(_, congested_objects) = shared_object_congestion_tracker .try_schedule(&tx, max_execution_duration_per_commit, &HashMap::new(), 0) { @@ -1007,7 +1047,11 @@ mod object_cost_tests { // Read/write to object 1 should be scheduled with start_time 1 with // `assign_min_free_execution_slot` and deferred otherwise. for mutable in [true, false].iter() { - let tx = build_transaction(&[(shared_obj_1, *mutable)], tx_gas_budget); + let tx = build_transaction( + &[(shared_obj_1, *mutable)], + tx_gas_budget, + TEST_ONLY_GAS_PRICE, + ); let sequencing_result = initialize_tracker_and_try_schedule( &mut shared_object_congestion_tracker, &tx, @@ -1032,6 +1076,7 @@ mod object_cost_tests { let tx = build_transaction( &[(shared_obj_0, *mutable_0), (shared_obj_1, *mutable_1)], tx_gas_budget, + TEST_ONLY_GAS_PRICE, ); if let SequencingResult::Defer(_, congested_objects) = initialize_tracker_and_try_schedule( @@ -1062,7 +1107,7 @@ mod object_cost_tests { mode: PerObjectCongestionControlMode, ) { let shared_obj_0 = ObjectID::random(); - let tx = build_transaction(&[(shared_obj_0, true)], 100); + let tx = build_transaction(&[(shared_obj_0, true)], 100, TEST_ONLY_GAS_PRICE); // Make try_schedule always defers transactions. let max_execution_duration_per_commit = 0; let mut shared_object_congestion_tracker = SharedObjectCongestionTracker::new(mode, false); @@ -1191,7 +1236,11 @@ mod object_cost_tests { ); // Read two objects should not change the object execution slots. - let cert = build_transaction(&[(object_id_0, false), (object_id_1, false)], 10); + let cert = build_transaction( + &[(object_id_0, false), (object_id_1, false)], + 10, + TEST_ONLY_GAS_PRICE, + ); let cert_duration = shared_object_congestion_tracker.get_estimated_execution_duration(&cert); let start_time = initialize_tracker_and_compute_tx_start_time( @@ -1222,7 +1271,11 @@ mod object_cost_tests { // Write to object 0 should only bump object 0's execution slots. The start time // should be object 1's duration. - let cert = build_transaction(&[(object_id_0, true), (object_id_1, false)], 10); + let cert = build_transaction( + &[(object_id_0, true), (object_id_1, false)], + 10, + TEST_ONLY_GAS_PRICE, + ); let cert_duration = shared_object_congestion_tracker.get_estimated_execution_duration(&cert); let start_time = initialize_tracker_and_compute_tx_start_time( @@ -1272,6 +1325,7 @@ mod object_cost_tests { (object_id_2, true), ], 10, + TEST_ONLY_GAS_PRICE, ); let expected_object_duration = match mode { PerObjectCongestionControlMode::None => unreachable!(), @@ -1346,7 +1400,7 @@ mod object_cost_tests { assign_min_free_execution_slot, ); - let tx = build_transaction(&[(object_id_0, true)], 1); + let tx = build_transaction(&[(object_id_0, true)], 1, TEST_ONLY_GAS_PRICE); if let SequencingResult::Schedule(start_time) = initialize_tracker_and_try_schedule( &mut shared_object_congestion_tracker, &tx, @@ -1383,7 +1437,11 @@ mod object_cost_tests { panic!("transaction is not congesting, should not defer"); } - let tx = build_transaction(&[(object_id_0, true), (object_id_1, true)], 1); + let tx = build_transaction( + &[(object_id_0, true), (object_id_1, true)], + 1, + TEST_ONLY_GAS_PRICE, + ); if let SequencingResult::Defer(_, congested_objects) = initialize_tracker_and_try_schedule( &mut shared_object_congestion_tracker, &tx, @@ -1402,6 +1460,7 @@ mod object_cost_tests { } else { panic!("transaction is congesting, should defer"); } + let cert_duration = shared_object_congestion_tracker.get_estimated_execution_duration(&tx); assert!( initialize_tracker_and_compute_tx_start_time( @@ -1436,6 +1495,7 @@ mod object_cost_tests { (object_id_2, true), ], MAX_EXECUTION_TIME - 1, + TEST_ONLY_GAS_PRICE, ); if let SequencingResult::Defer(_, congested_objects) = initialize_tracker_and_try_schedule( &mut shared_object_congestion_tracker, @@ -1487,7 +1547,7 @@ mod object_cost_tests { assign_min_free_execution_slot, ); - let tx = build_transaction(&[(object_id_0, true)], u64::MAX); + let tx = build_transaction(&[(object_id_0, true)], u64::MAX, TEST_ONLY_GAS_PRICE); if let SequencingResult::Defer(_, congested_objects) = initialize_tracker_and_try_schedule( &mut shared_object_congestion_tracker, &tx, diff --git a/crates/iota-core/src/authority/suggested_gas_price_calculator.rs b/crates/iota-core/src/authority/suggested_gas_price_calculator.rs new file mode 100644 index 00000000000..eaaaa87511a --- /dev/null +++ b/crates/iota-core/src/authority/suggested_gas_price_calculator.rs @@ -0,0 +1,1389 @@ +// Copyright (c) 2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{BTreeMap, HashMap}; + +use iota_types::{ + base_types::ObjectID, executable_transaction::VerifiedExecutableTransaction, + transaction::TransactionDataAPI, +}; +use tracing::instrument; + +use super::shared_object_congestion_tracker::ExecutionTime; + +/// Holds shared object congestion info for a single scheduled shared-object +/// transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ScheduledTransactionCongestionInfo { + /// Gas price of a scheduled shared-object transaction. + gas_price: u64, + + /// Estimated execution duration of a scheduled shared-object transaction. + estimated_execution_duration: ExecutionTime, +} + +impl ScheduledTransactionCongestionInfo { + /// Create a new congestion info for scheduled shared-object transaction + /// with `gas_price` and `estimated_execution_duration`. + fn new(gas_price: u64, estimated_execution_duration: ExecutionTime) -> Self { + Self { + gas_price, + estimated_execution_duration, + } + } +} + +/// Holds shared object congestion info for a single shared object, +/// keyed by transaction execution start time. +type PerObjectCongestionInfo = BTreeMap; + +/// Holds shared object congestion data for a single consensus commit round. +type PerCommitCongestionInfo = HashMap; + +/// `SuggestedGasPriceCalculator` calculates suggested gas prices for +/// deferred/cancelled shared-object transactions, using congestion +/// info from a single consensus commit. +/// +/// The congestion info stored by the calculator should only be updated +/// for scheduled certificates. In contrast, calculations of the suggested +/// gas price should only be invoked for deferred/cancelled certificates. +/// +/// Roughly speaking, the suggested gas price calculator works as follows: +/// 1. For every scheduled certificate, obtain its reference gas price, +/// execution start time and estimated execution duration. +/// 2. For every input shared object accessed mutably by the scheduled +/// transaction, keep and update a map, ordered by execution start time +/// (key), whose values store scheduled certificate's gas price and estimated +/// execution duration. +/// 3. For every deferred/cancelled certificate, obtain its estimated execution +/// duration, as well as all input shared objects. +/// 4. Calculate a suggested gas price for the deferred/cancelled certificate as +/// follows: +/// - compute its (imaginary) execution start time as +/// `max_execution_duration_per_commit` minus its estimated execution +/// duration; +/// - for each input shared object, get the maximum gas price over scheduled +/// certificates whose end execution time is larger than our imaginary +/// start time; +/// - take the maximum over the values obtained in the previous step; +/// - the suggested gas price equals the maximum value obtained in the +/// previous step plus 1, but such that it does not become larger than the +/// maximum gas price set in the protocol. +/// +/// Note that if `max_execution_duration_per_commit` is set to `None`, +/// which means there is no shared object congestion control mechanism, +/// the calculator will suggest the reference gas price. +#[derive(Debug)] +pub(crate) struct SuggestedGasPriceCalculator { + /// Per-commit congestion info + congestion_info: PerCommitCongestionInfo, + + /// Maximum execution duration per shared object per commit. + max_execution_duration_per_commit: Option, + + /// The reference gas price, which will be suggested if + /// `max_execution_duration_per_commit` is set to `None`. + reference_gas_price: u64, + + /// Maximum gas price that can be set in transactions. This is + /// used to prevent suggesting feedback gas price larger than + /// this maximum value set in the protocol config. + max_gas_price: u64, +} + +impl SuggestedGasPriceCalculator { + /// Create a new `SuggestedGasPriceCalculator` with empty shared + /// object congestion data. + pub fn new( + max_execution_duration_per_commit: Option, + reference_gas_price: u64, + max_gas_price: u64, + ) -> Self { + Self { + congestion_info: PerCommitCongestionInfo::new(), + max_execution_duration_per_commit, + reference_gas_price, + max_gas_price, + } + } + + /// Update per-commit congestion info for a single certificate. This should + /// only be called for scheduled certificates that contain shared object(s); + /// otherwise, the calculator might wrongly calculate suggested gas price. + /// The `execution_start_time` and `estimated_execution_duration` parameters + /// are the outcomes of the shared object congestion tracker (sequencer). + pub fn update_congestion_info( + &mut self, + certificate: &VerifiedExecutableTransaction, + execution_start_time: ExecutionTime, + estimated_execution_duration: ExecutionTime, + ) { + // If we don't have a max execution duration, we don't need to update + // the congestion info since the reference gas price will be suggested. + if self.max_execution_duration_per_commit.is_none() { + return; + } + + let scheduled_transaction_congestion_info = ScheduledTransactionCongestionInfo::new( + certificate.transaction_data().gas_price(), + estimated_execution_duration, + ); + + certificate + .shared_input_objects() + // Only consider shared objects accessed mutably as objects accessed immutably + // do not change object's execution slots in the sequencer. + .filter(|object| object.mutable) + .for_each(|object| { + self.congestion_info + .entry(object.id) + .and_modify(|per_object_congestion_info| { + per_object_congestion_info + .insert(execution_start_time, scheduled_transaction_congestion_info); + }) + .or_insert(PerObjectCongestionInfo::from([( + execution_start_time, + scheduled_transaction_congestion_info, + )])); + }); + } + + /// Calculate a suggested gas price for a deferred/cancelled `certificate` + /// using the single-commit congestion info held by the calculator. This + /// should only be called for certificates deferred/cancelled due to + /// shared object congestion; otherwise, there is a risk of panic. + #[instrument(level = "trace", skip_all)] + pub fn calculate_suggested_gas_price( + &self, + certificate: &VerifiedExecutableTransaction, + estimated_execution_duration: ExecutionTime, + ) -> u64 { + if let Some(max_execution_duration_per_commit) = self.max_execution_duration_per_commit { + debug_assert!( + estimated_execution_duration <= max_execution_duration_per_commit, + "This certificate alone has estimated execution duration of \ + {estimated_execution_duration}, which is larger than the maximum execution \ + duration per commit {max_execution_duration_per_commit}, so the certificate \ + cannot be scheduled regardless of suggested gas price. It is likely that \ + {max_execution_duration_per_commit} was set too low in the protocol config, \ + such that a commit cannot accommodate a single certificate." + ); + + let clearing_gas_price = self.find_clearing_gas_price( + certificate, + estimated_execution_duration, + max_execution_duration_per_commit, + ); + + // Suggested gas price equals `clearing_gas_price + 1`. We add 1 to make this + // transaction would be scheduled if the same commit structure was repeated. + let suggested_gas_price = clearing_gas_price + 1; + + // Make sure suggested gas price is not larger than the maximum possible gas + // price. + suggested_gas_price.min(self.max_gas_price) + } else { + // ^ If we don't have a max execution duration, suggest the reference gas price. + + self.reference_gas_price + } + } + + /// Find the gas price for which a deferred/scheduled certificate would be + /// scheduled if that gas price was paid and if exactly the same set of + /// transactions appeared in a commit. + fn find_clearing_gas_price( + &self, + certificate: &VerifiedExecutableTransaction, + estimated_execution_duration: ExecutionTime, + max_execution_duration_per_commit: ExecutionTime, + ) -> u64 { + // Imaginary start time of the deferred/cancelled certificate. We consider + // only the highest possible (but sufficient for scheduling) start time as + // it is very likely that scheduled certificates with lower gas prices + // appear have higher start times. + let start_time_of_deferred_cert = + max_execution_duration_per_commit - estimated_execution_duration; + + certificate + .shared_input_objects() + .filter_map(|object| { + self.congestion_info + .get(&object.id) + .map(|per_object_congestion_info| { + per_object_congestion_info + .iter() + .filter_map(|(execution_start_time, tx_congestion_info)| { + let end_time_of_scheduled_cert = execution_start_time + + tx_congestion_info.estimated_execution_duration; + + if end_time_of_scheduled_cert > start_time_of_deferred_cert + { + // Store gas price of that scheduled certificate + Some(tx_congestion_info.gas_price) + } else { + None + } + }) + // Take the maximum over all found gas prices of scheduled certificates + // whose execution end time is larger than the imaginary start time + // of the deferred/cancelled transaction. It has to be maximum here + // since otherwise the suggested gas price will be insufficient to + // guarantee scheduling if the same set of certificates was repeated + // again in a commit. + .max() + }) + }) + // Take the maximum over all input shared objects, as we need to consider the + // "worst-case" (most congested) object; otherwise, the suggested gas price + // will be insufficient to guarantee scheduling if the same set of certificates + // was repeated again in a commit. + .max() + .flatten() + .unwrap_or_else(|| { + panic!( + "At least one of the shared input objects should have appeared in between \ + execution start time of {start_time_of_deferred_cert} and execution end time of \ + {max_execution_duration_per_commit}; otherwise, this deferred certificate \ + would be scheduled by the sequencer." + ); + }) + } +} + +#[cfg(test)] +pub mod suggested_gas_price_calculator_test_utils { + use iota_protocol_config::PerObjectCongestionControlMode; + use iota_types::base_types::ObjectID; + + use super::SuggestedGasPriceCalculator; + use crate::authority::shared_object_congestion_tracker::{ + ExecutionTime, SharedObjectCongestionTracker, + shared_object_test_utils::{ + build_transaction, initialize_tracker_and_compute_tx_start_time, + }, + }; + + pub(crate) fn new_suggested_gas_price_calculator_with_initial_values_for_test( + init_values: &[(ObjectID, ExecutionTime, u64)], + per_object_congestion_control_mode: PerObjectCongestionControlMode, + max_execution_duration_per_commit: Option, + min_free_execution_slot_assigned: bool, + reference_gas_price: u64, + max_gas_price: u64, + ) -> SuggestedGasPriceCalculator { + let mut suggested_gas_price_calculator = SuggestedGasPriceCalculator::new( + max_execution_duration_per_commit, + reference_gas_price, + max_gas_price, + ); + + let mut shared_object_congestion_tracker = SharedObjectCongestionTracker::new( + per_object_congestion_control_mode, + min_free_execution_slot_assigned, + ); + + for (object_id, duration, gas_price) in init_values { + match per_object_congestion_control_mode { + PerObjectCongestionControlMode::None => {} + PerObjectCongestionControlMode::TotalGasBudget => { + let certificate = + build_transaction(&[(*object_id, true)], *duration, *gas_price); + + let execution_start_time = initialize_tracker_and_compute_tx_start_time( + &mut shared_object_congestion_tracker, + &certificate.shared_input_objects().collect::>(), + *duration, + ) + .expect( + "initial value should be fit within the available range of slots \ + in the tracker", + ); + + shared_object_congestion_tracker + .bump_object_execution_slots(&certificate, execution_start_time); + + suggested_gas_price_calculator.update_congestion_info( + &certificate, + execution_start_time, + *duration, + ); + } + PerObjectCongestionControlMode::TotalTxCount => { + for _ in 0..*duration { + let certificate = build_transaction(&[(*object_id, true)], 1, *gas_price); + + let execution_start_time = initialize_tracker_and_compute_tx_start_time( + &mut shared_object_congestion_tracker, + &certificate.shared_input_objects().collect::>(), + *duration, + ) + .expect( + "initial value should be fit within the available range of slots \ + in the tracker", + ); + + shared_object_congestion_tracker + .bump_object_execution_slots(&certificate, execution_start_time); + + suggested_gas_price_calculator.update_congestion_info( + &certificate, + execution_start_time, + 1, + ); + } + } + } + } + + suggested_gas_price_calculator + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use iota_protocol_config::{PerObjectCongestionControlMode, ProtocolConfig}; + use iota_types::{base_types::ObjectID, executable_transaction::VerifiedExecutableTransaction}; + use rstest::rstest; + + use super::SuggestedGasPriceCalculator; + use crate::authority::{ + shared_object_congestion_tracker::{ + ExecutionTime, SequencingResult, SharedObjectCongestionTracker, + shared_object_test_utils::build_transaction, + }, + suggested_gas_price_calculator::{ + PerCommitCongestionInfo, PerObjectCongestionInfo, ScheduledTransactionCongestionInfo, + }, + }; + + const REFERENCE_GAS_PRICE: u64 = 1_000; + + #[derive(Copy, Clone)] + struct TxGasData { + global_ordering_index: usize, + gas_price: u64, + gas_budget: u64, + } + + fn build_and_try_sequencing_certificate( + input_shared_objects: &[(ObjectID, bool)], + tx_gas_data: TxGasData, + max_execution_duration_per_commit: ExecutionTime, + shared_object_congestion_tracker: &mut SharedObjectCongestionTracker, + ) -> (VerifiedExecutableTransaction, SequencingResult) { + let certificate = build_transaction( + input_shared_objects, + tx_gas_data.gas_budget, + tx_gas_data.gas_price, + ); + let shared_input_objects: Vec<_> = certificate.shared_input_objects().collect(); + shared_object_congestion_tracker.initialize_object_execution_slots(&shared_input_objects); + + let sequencing_result = shared_object_congestion_tracker.try_schedule( + &certificate, + max_execution_duration_per_commit, + // The next two inputs are not important for testing. + &HashMap::new(), + 0, + ); + + (certificate, sequencing_result) + } + + fn update_data_for_scheduled_certificate( + certificate: &VerifiedExecutableTransaction, + execution_start_time: ExecutionTime, + shared_object_congestion_tracker: &mut SharedObjectCongestionTracker, + suggested_gas_price_calculator: &mut SuggestedGasPriceCalculator, + ) { + shared_object_congestion_tracker + .bump_object_execution_slots(certificate, execution_start_time); + suggested_gas_price_calculator.update_congestion_info( + certificate, + execution_start_time, + shared_object_congestion_tracker.get_estimated_execution_duration(certificate), + ); + } + + #[rstest] + fn update_congestion_info( + #[values( + None, + Some(10), // the value is not important in this test + )] + max_execution_duration_per_commit: Option, + ) { + let max_gas_price = ProtocolConfig::get_for_max_version_UNSAFE().max_gas_price(); + let mut suggested_gas_price_calculator = SuggestedGasPriceCalculator::new( + max_execution_duration_per_commit, + REFERENCE_GAS_PRICE, + max_gas_price, + ); + + let object_1 = ObjectID::random(); + let object_2 = ObjectID::random(); + let object_3 = ObjectID::random(); + let object_4 = ObjectID::random(); + let object_5 = ObjectID::random(); + + // Construct the first certificate that touches shared objects: + // - `object_1` by mutable reference, + // - `object_2` by immutable reference. + let objects_1 = vec![(object_1, true), (object_2, false)]; + let gas_budget_1 = 1_003_000; // not important in this test + let gas_price_1 = 1_003; + let certificate_1 = build_transaction(&objects_1, gas_budget_1, gas_price_1); + let execution_start_time_1 = 0; + let estimated_execution_duration_1 = 3; + // Update the calculator's congestion info for this certificate. + suggested_gas_price_calculator.update_congestion_info( + &certificate_1, + execution_start_time_1, + estimated_execution_duration_1, + ); + // + if let Some(_max_execution_duration_per_commit) = max_execution_duration_per_commit { + // Note that `object_2` should not appear because it is accessed immutably. + let object_1_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_1, + ScheduledTransactionCongestionInfo::new( + gas_price_1, + estimated_execution_duration_1, + ), + )]); + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::from([(object_1, object_1_expected_congestion_info)]), + ); + } else { + // We don't have max execution duration per commit, so there is no need + // in updating the calculator's congestion info. + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::new() + ); + } + + // Construct the second certificate that touches shared objects: + // - `object_2` by mutable reference, + // - `object_3` by immutable reference, + // - `object_4` by mutable reference. + let objects_2 = vec![(object_2, true), (object_3, false), (object_4, true)]; + let gas_budget_2 = 1_002_000; // not important in this test + let gas_price_2 = 1_002; + let certificate_2 = build_transaction(&objects_2, gas_budget_2, gas_price_2); + let execution_start_time_2 = 1; + let estimated_execution_duration_2 = 2; + // Update the calculator's congestion info for this certificate. + suggested_gas_price_calculator.update_congestion_info( + &certificate_2, + execution_start_time_2, + estimated_execution_duration_2, + ); + // + if let Some(_max_execution_duration_per_commit) = max_execution_duration_per_commit { + // Note that `object_3` should not appear because it is accessed immutably. + let object_1_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_1, + ScheduledTransactionCongestionInfo::new( + gas_price_1, + estimated_execution_duration_1, + ), + )]); + let object_2_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_2, + ScheduledTransactionCongestionInfo::new( + gas_price_2, + estimated_execution_duration_2, + ), + )]); + let object_4_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_2, + ScheduledTransactionCongestionInfo::new( + gas_price_2, + estimated_execution_duration_2, + ), + )]); + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::from([ + (object_1, object_1_expected_congestion_info), + (object_2, object_2_expected_congestion_info), + (object_4, object_4_expected_congestion_info), + ]), + ); + } else { + // We don't have max execution duration per commit, so there is no need + // in updating the calculator's congestion info. + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::new() + ); + } + + // Construct the third certificate that touches shared objects: + // - `object_4` by immutable reference, + // - `object_5` by mutable reference. + let objects_3 = vec![(object_4, false), (object_5, true)]; + let gas_budget_3 = 1_001_000; // not important in this test + let gas_price_3 = 1_001; + let certificate_3 = build_transaction(&objects_3, gas_budget_3, gas_price_3); + let execution_start_time_3 = 2; + let estimated_execution_duration_3 = 1; + // Update the calculator's congestion info for this certificate. + suggested_gas_price_calculator.update_congestion_info( + &certificate_3, + execution_start_time_3, + estimated_execution_duration_3, + ); + // + if let Some(_max_execution_duration_per_commit) = max_execution_duration_per_commit { + // Note that `object_3` should not appear because it is accessed immutably. + let object_1_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_1, + ScheduledTransactionCongestionInfo::new( + gas_price_1, + estimated_execution_duration_1, + ), + )]); + let object_2_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_2, + ScheduledTransactionCongestionInfo::new( + gas_price_2, + estimated_execution_duration_2, + ), + )]); + let object_4_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_2, + ScheduledTransactionCongestionInfo::new( + gas_price_2, + estimated_execution_duration_2, + ), + )]); + let object_5_expected_congestion_info = PerObjectCongestionInfo::from([( + execution_start_time_3, + ScheduledTransactionCongestionInfo::new( + gas_price_3, + estimated_execution_duration_3, + ), + )]); + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::from([ + (object_1, object_1_expected_congestion_info), + (object_2, object_2_expected_congestion_info), + (object_4, object_4_expected_congestion_info), + (object_5, object_5_expected_congestion_info), + ]), + ); + } else { + // We don't have max execution duration per commit, so there is no need + // in updating the calculator's congestion info. + assert_eq!( + suggested_gas_price_calculator.congestion_info, + PerCommitCongestionInfo::new() + ); + } + } + + #[rstest] + fn calculate_suggested_gas_price( + #[values( + PerObjectCongestionControlMode::TotalTxCount, + PerObjectCongestionControlMode::TotalGasBudget + )] + mode: PerObjectCongestionControlMode, + #[values(false, true)] min_free_execution_slot_assigned: bool, + ) { + // Allow only two transactions per shared object per commit. In the + // `TotalGasBudget` mode, gas budget of transactions will be set + // accordingly. + let max_execution_duration_per_commit = match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => 3, + PerObjectCongestionControlMode::TotalGasBudget => 9_000_000, + }; + + let max_gas_price = ProtocolConfig::get_for_max_version_UNSAFE().max_gas_price(); + + let mut shared_object_congestion_tracker = + SharedObjectCongestionTracker::new(mode, min_free_execution_slot_assigned); + + let mut suggested_gas_price_calculator = SuggestedGasPriceCalculator::new( + Some(max_execution_duration_per_commit), + REFERENCE_GAS_PRICE, + max_gas_price, + ); + + let object_1 = ObjectID::random(); + let object_2 = ObjectID::random(); + + // Gas prices (sorted in descending order) and gas budget to build transactions + let txs_gas_data = [ + (max_gas_price, 3_000_000), // 0 + (9_000, 1_000_000), // 1 + (8_000, 4_000_000), // 2 + (7_000, 2_000_000), // 3 + (7_000, 1_000_001), // 4 + (7_000, 5_000_000), // 5 + (7_000, 5_000_001), // 6 + (7_000, 8_000_000), // 7 + (6_000, 4_000_000), // 8 + (5_000, 2_000_000), // 9 + (5_000, 1_000_001), // 10 + (5_000, 5_000_001), // 11 + (5_000, 9_000_000), // 12 + ] + .into_iter() + .enumerate() + .map(|(index, (gas_price, gas_budget))| TxGasData { + global_ordering_index: index, + gas_price, + gas_budget, + }) + .collect::>(); + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_1, true), (object_2, false)], + txs_gas_data[0], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // Allocations of mutably accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // |::::::::::::::::::::::::|::::::::::::::::::::::::|::::::::::::| + // | | | | + // |------------------------| |---- 3M | + // | | | | + // | | |---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled", + txs_gas_data[0].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_1, false), (object_2, true)], + txs_gas_data[1], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // Allocations of mutably accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // |::::::::::::::::::::::::|::::::::::::::::::::::::|::::::::::::| + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | | |---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // Certificate 1 cannot be scheduled at start time 0 because it touches + // object 1, even though immutably. + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled", + txs_gas_data[1].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_1, false), (object_2, true)], + txs_gas_data[2], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // Allocations of mutably accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | | |---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled", + txs_gas_data[2].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_2, true)], + txs_gas_data[3], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // If `min_free_execution_slot_assigned = false` (old sequencer), this + // certificate must be deferred. + if min_free_execution_slot_assigned { + // ^ This corresponds the new sequencer's logic + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled in the new sequencer", + txs_gas_data[3].global_ordering_index + ); + } + } else { + // ^ This corresponds the old sequencer's logic + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + assert_eq!(congested_objects, vec![object_2]); + + let suggested_gas_price = suggested_gas_price_calculator + .calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker + .get_estimated_execution_duration(&certificate), + ); + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } else { + panic!( + "Certificate {} must be deferred in the old sequencer", + txs_gas_data[3].global_ordering_index + ); + } + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_2, false)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[4], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + if min_free_execution_slot_assigned { + // ^ this corresponds the new sequencer's logic + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + } else { + // ^ this corresponds the old sequencer's logic + assert_eq!(congested_objects, vec![object_2]); + } + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[4].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_2, true)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[5], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + if min_free_execution_slot_assigned { + // ^ this corresponds the new sequencer's logic + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + } else { + // ^ this corresponds the old sequencer's logic + assert_eq!(congested_objects, vec![object_2]); + } + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[5].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_1, true), (object_2, true)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[6], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + if min_free_execution_slot_assigned { + // ^ this corresponds the new sequencer's logic + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + } else { + // ^ this corresponds the old sequencer's logic + assert_eq!(congested_objects, vec![object_2]); + } + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => { + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } + PerObjectCongestionControlMode::TotalGasBudget => { + assert_eq!(suggested_gas_price, txs_gas_data[1].gas_price + 1); + } + } + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[6].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_1, true), (object_2, true)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[7], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // | | |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + + if min_free_execution_slot_assigned { + // ^ this corresponds the new sequencer's logic + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + } else { + // ^ this corresponds the old sequencer's logic + match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => { + assert_eq!(congested_objects, vec![object_2]); + } + PerObjectCongestionControlMode::TotalGasBudget => { + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + } + } + } + + match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => { + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } + PerObjectCongestionControlMode::TotalGasBudget => { + assert_eq!(suggested_gas_price, max_gas_price); + } + } + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[7].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_1, true)], + txs_gas_data[8], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | |------------------------|---- 8M | + // | | | | + // |------------------------| |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | cert. 8 (g=6000, d=4M) | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled", + txs_gas_data[8].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &[(object_1, true)], + txs_gas_data[9], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | cert. 9 (g=5000, d=2M) |------------------------|---- 8M | + // | | | | + // |------------------------| |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | cert. 8 (g=6000, d=4M) | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + if let SequencingResult::Schedule(execution_start_time) = sequencing_result { + update_data_for_scheduled_certificate( + &certificate, + execution_start_time, + &mut shared_object_congestion_tracker, + &mut suggested_gas_price_calculator, + ); + } else { + panic!( + "Certificate {} must be scheduled", + txs_gas_data[9].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_1, false), (object_2, false)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[10], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | cert. 9 (g=5000, d=2M) |------------------------|---- 8M | + // | | | | + // |------------------------| |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | cert. 8 (g=6000, d=4M) | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[10].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_1, true), (object_2, false)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[11], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | cert. 9 (g=5000, d=2M) |------------------------|---- 8M | + // | | | | + // |------------------------| |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | cert. 8 (g=6000, d=4M) | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => { + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } + PerObjectCongestionControlMode::TotalGasBudget => { + assert_eq!(suggested_gas_price, txs_gas_data[1].gas_price + 1); + } + } + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[11].global_ordering_index + ); + } + + // Construct a certificate with some shared objects (note mutability), + // and try scheduling it. + let input_shared_objects = vec![(object_1, false), (object_2, true)]; + let (certificate, sequencing_result) = build_and_try_sequencing_certificate( + &input_shared_objects, + txs_gas_data[12], + max_execution_duration_per_commit, + &mut shared_object_congestion_tracker, + ); + // If `min_free_execution_slot_assigned = true`, allocations of mutably + // accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9M | + // | | | | + // | cert. 9 (g=5000, d=2M) |------------------------|---- 8M | + // | | | | + // |------------------------| |---- 7M | + // | | | | + // | | cert. 2 (g=8000, d=4M) |---- 6M | + // | | | | + // | cert. 8 (g=6000, d=4M) | |---- 5M | + // | | | | + // | |------------------------|---- 4M | + // | | cert. 1 (g=9000, d=1M) | | + // |------------------------|------------------------|---- 3M | + // | | | | + // | |------------------------|---- 2M | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2M) |---- 1M | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, this certificate must be deferred in both new and old sequencers. + if let SequencingResult::Defer(_key, congested_objects) = sequencing_result { + assert_eq!( + congested_objects, + input_shared_objects + .into_iter() + .map(|(id, _)| id) + .collect::>() + ); + + let suggested_gas_price = suggested_gas_price_calculator.calculate_suggested_gas_price( + &certificate, + shared_object_congestion_tracker.get_estimated_execution_duration(&certificate), + ); + match mode { + PerObjectCongestionControlMode::None => unreachable!(), + PerObjectCongestionControlMode::TotalTxCount => { + assert_eq!(suggested_gas_price, txs_gas_data[2].gas_price + 1); + } + PerObjectCongestionControlMode::TotalGasBudget => { + assert_eq!(suggested_gas_price, max_gas_price); + } + } + } else { + panic!( + "Certificate {} must be deferred", + txs_gas_data[12].global_ordering_index + ); + } + } +} diff --git a/crates/iota-core/src/unit_tests/authority_tests.rs b/crates/iota-core/src/unit_tests/authority_tests.rs index b51889da5b9..7c03b9bd8fd 100644 --- a/crates/iota-core/src/unit_tests/authority_tests.rs +++ b/crates/iota-core/src/unit_tests/authority_tests.rs @@ -6160,6 +6160,7 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { let mut certificates: Vec = vec![]; let gas_price_of_non_cancelled_txs = 2_000; let gas_price_of_cancelled_txs = 1_000; + let suggested_gas_price = gas_price_of_non_cancelled_txs + 1; // Create 3 transactions that operate on shared_objects[0]. These transactions // will go through eventually. @@ -6252,11 +6253,11 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { [ ( shared_objects[0].id(), - SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) ), ( shared_objects[1].id(), - SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) ) ] .into_iter() @@ -6290,11 +6291,11 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { vec![ SharedInput::Cancelled(( shared_objects[0].id(), - SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) )), SharedInput::Cancelled(( shared_objects[1].id(), - SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) )) ] ); @@ -6307,7 +6308,7 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { ); assert_eq!( cancellation_reason, - SequenceNumber::new_congested_with_suggested_gas_price(gas_price_of_cancelled_txs) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) ); // Consensus commit prologue contains cancelled txn shared object version @@ -6324,13 +6325,13 @@ async fn test_consensus_handler_congestion_control_transaction_cancellation() { ( shared_objects[0].id(), SequenceNumber::new_congested_with_suggested_gas_price( - gas_price_of_cancelled_txs + suggested_gas_price ), ), ( shared_objects[1].id(), SequenceNumber::new_congested_with_suggested_gas_price( - gas_price_of_cancelled_txs + suggested_gas_price ), ) ] diff --git a/crates/iota-core/src/unit_tests/congestion_control_tests.rs b/crates/iota-core/src/unit_tests/congestion_control_tests.rs index a020bb07e8a..038976f90a5 100644 --- a/crates/iota-core/src/unit_tests/congestion_control_tests.rs +++ b/crates/iota-core/src/unit_tests/congestion_control_tests.rs @@ -31,6 +31,7 @@ use crate::{ }, move_integration_tests::build_and_publish_test_package, shared_object_congestion_tracker::shared_object_test_utils::new_congestion_tracker_with_initial_value_for_test, + suggested_gas_price_calculator::suggested_gas_price_calculator_test_utils::new_suggested_gas_price_calculator_with_initial_values_for_test, test_authority_builder::TestAuthorityBuilder, }, move_call, @@ -303,16 +304,34 @@ async fn test_congestion_control_execution_cancellation() { // Initialize shared object queue so that any transaction touches // shared_object_1 should result in congestion and cancellation. + let congestion_control_min_free_execution_slot = test_setup + .protocol_config + .congestion_control_min_free_execution_slot(); register_fail_point_arg("initial_congestion_tracker", move || { Some(new_congestion_tracker_with_initial_value_for_test( &[(shared_object_1.0, 10)], PerObjectCongestionControlMode::TotalGasBudget, - test_setup - .protocol_config - .congestion_control_min_free_execution_slot(), + congestion_control_min_free_execution_slot, )) }); + register_fail_point_arg("initial_suggested_gas_price_calculator", move || { + Some( + new_suggested_gas_price_calculator_with_initial_values_for_test( + &[(shared_object_1.0, 10, TEST_ONLY_GAS_PRICE)], + PerObjectCongestionControlMode::TotalGasBudget, + test_setup + .protocol_config + .max_accumulated_txn_cost_per_object_in_mysticeti_commit_as_option(), + test_setup + .protocol_config + .congestion_control_min_free_execution_slot(), + TEST_ONLY_GAS_PRICE, + test_setup.protocol_config.max_gas_price(), + ), + ) + }); + // Runs a transaction that touches shared_object_1, shared_object_2 and a owned // object. let (congested_tx, effects) = update_objects( @@ -331,6 +350,8 @@ async fn test_congestion_control_execution_cancellation() { ) .await; + let suggested_gas_price = TEST_ONLY_GAS_PRICE + 1; + // Transaction should be cancelled with `shared_object_1` and `shared_object_2` // as the congested objects, and the suggested gas price should be // `TEST_ONLY_GAS_PRICE`. @@ -339,7 +360,7 @@ async fn test_congestion_control_execution_cancellation() { &ExecutionStatus::Failure { error: ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { congested_objects: CongestedObjects(vec![shared_object_1.0, shared_object_2.0]), - suggested_gas_price: TEST_ONLY_GAS_PRICE, + suggested_gas_price, }, command: None } @@ -351,11 +372,11 @@ async fn test_congestion_control_execution_cancellation() { vec![ InputSharedObject::Cancelled( shared_object_1.0, - SequenceNumber::new_congested_with_suggested_gas_price(TEST_ONLY_GAS_PRICE) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) ), InputSharedObject::Cancelled( shared_object_2.0, - SequenceNumber::new_congested_with_suggested_gas_price(TEST_ONLY_GAS_PRICE) + SequenceNumber::new_congested_with_suggested_gas_price(suggested_gas_price) ) ] ); @@ -381,7 +402,7 @@ async fn test_congestion_control_execution_cancellation() { execution_error.unwrap().to_execution_status().0, ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { congested_objects: CongestedObjects(vec![shared_object_1.0, shared_object_2.0]), - suggested_gas_price: TEST_ONLY_GAS_PRICE, + suggested_gas_price, } ); assert_eq!(&effects, effects_2.data()) From ccadcd2a657c30fa05e066384bbaf058a3673c58 Mon Sep 17 00:00:00 2001 From: Roman Overko <63564739+roman1e2f5p8s@users.noreply.github.com> Date: Mon, 28 Jul 2025 11:33:38 +0200 Subject: [PATCH 5/5] feat(iota-core): add unit tests for the gas price feedback mechanism (#7906) - This PR adds unit tests to test the gas price feedback mechanism. The aim is to test all the components: the mechanism itself, calculations of suggested gas price and its propagation across multiple commits. - Instead of panic, the calculator will now suggest reference gas price (or max clearing gas price) if transaction duration exceeds `max_execution_duration_per_commit`. - Renamed `congested_objects_gas_price_feedback_mechanism` feature flag to `congestion_control_gas_price_feedback_mechanism`. ```console cargo simtest -p iota-core unit_tests gas_price_feedback_tests ``` - [x] Basic tests (linting, compilation, formatting, unit/integration tests) - [x] Patch-specific tests (correctness, functionality coverage) - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have checked that new and existing unit tests pass locally with my changes - [ ] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] Indexer: - [ ] JSON-RPC: - [ ] GraphQL: - [ ] CLI: - [ ] Rust SDK: - [ ] REST API: Signed-off-by: Roman Overko --- .../authority/authority_per_epoch_store.rs | 32 +- .../shared_object_version_manager.rs | 4 +- .../suggested_gas_price_calculator.rs | 42 +- crates/iota-core/src/lib.rs | 3 + .../data/gas_price_feedback/Move.toml | 10 + .../sources/gas_price_feedback.move | 50 + .../unit_tests/gas_price_feedback_tests.rs | 1971 +++++++++++++++++ crates/iota-open-rpc/spec/openrpc.json | 2 +- crates/iota-protocol-config/src/lib.rs | 18 +- ...ota_protocol_config__test__version_10.snap | 2 +- .../iota-adapter/src/execution_engine.rs | 4 +- 11 files changed, 2085 insertions(+), 53 deletions(-) create mode 100644 crates/iota-core/src/unit_tests/data/gas_price_feedback/Move.toml create mode 100644 crates/iota-core/src/unit_tests/data/gas_price_feedback/sources/gas_price_feedback.move create mode 100644 crates/iota-core/src/unit_tests/gas_price_feedback_tests.rs diff --git a/crates/iota-core/src/authority/authority_per_epoch_store.rs b/crates/iota-core/src/authority/authority_per_epoch_store.rs index a4c2b19e591..d5d92c1f39b 100644 --- a/crates/iota-core/src/authority/authority_per_epoch_store.rs +++ b/crates/iota-core/src/authority/authority_per_epoch_store.rs @@ -189,6 +189,10 @@ impl DeferredTransaction { suggested_gas_price, } } + + pub fn suggested_gas_price(&self) -> Option { + self.suggested_gas_price + } } /// Represents a scheduling result: a transaction can be either scheduled @@ -1782,7 +1786,7 @@ impl AuthorityPerEpochStore { if self .protocol_config - .congested_objects_gas_price_feedback_mechanism() + .congestion_control_gas_price_feedback_mechanism() { self.tables()? .deferred_transactions_v2 @@ -2007,7 +2011,7 @@ impl AuthorityPerEpochStore { pub fn deferred_transactions_empty(&self) -> bool { if self .protocol_config - .congested_objects_gas_price_feedback_mechanism() + .congestion_control_gas_price_feedback_mechanism() { self.tables() .expect("deferred transactions should not be read past end of epoch") @@ -2993,7 +2997,7 @@ impl AuthorityPerEpochStore { &mut shared_input_next_version, cancelled_txns, self.protocol_config - .congested_objects_gas_price_feedback_mechanism(), + .congestion_control_gas_price_feedback_mechanism(), ); version_assignment.push((*txn.digest(), assigned_versions)); } @@ -3600,7 +3604,7 @@ impl AuthorityPerEpochStore { let suggested_gas_price = if self .protocol_config - .congested_objects_gas_price_feedback_mechanism() + .congestion_control_gas_price_feedback_mechanism() { let current_commit_suggested_gas_price = suggested_gas_price_calculator @@ -3683,15 +3687,15 @@ impl AuthorityPerEpochStore { // we have to update the following: // - shared object execution slots (for congestion tracker); // - shared object congestion info (for suggested gas price calculator). - // We only need to do this if `max_execution_duration_per_commit` is - // `Some`, since otherwise this bumping will panic as object - // execution slots are only initialized if - // `max_execution_duration_per_commit` is not `None`. - if certificate.contains_shared_object() - && self.get_max_execution_duration_per_commit().is_some() - { - shared_object_congestion_tracker - .bump_object_execution_slots(&certificate, start_time); + if certificate.contains_shared_object() { + if self.get_max_execution_duration_per_commit().is_some() { + // We only need to do this if `max_execution_duration_per_commit` + // is `Some`, since otherwise this bumping will panic as object + // execution slots are only initialized if + // `max_execution_duration_per_commit` is not `None`. + shared_object_congestion_tracker + .bump_object_execution_slots(&certificate, start_time); + } suggested_gas_price_calculator.update_congestion_info( &certificate, @@ -4344,7 +4348,7 @@ impl ConsensusCommitOutput { if epoch_store .protocol_config - .congested_objects_gas_price_feedback_mechanism() + .congestion_control_gas_price_feedback_mechanism() { batch.delete_batch(&tables.deferred_transactions_v2, self.deleted_deferred_txns)?; batch.insert_batch(&tables.deferred_transactions_v2, self.deferred_txns)?; diff --git a/crates/iota-core/src/authority/shared_object_version_manager.rs b/crates/iota-core/src/authority/shared_object_version_manager.rs index 3c6633a658a..a2c628616cb 100644 --- a/crates/iota-core/src/authority/shared_object_version_manager.rs +++ b/crates/iota-core/src/authority/shared_object_version_manager.rs @@ -82,7 +82,7 @@ impl SharedObjVerManager { cancelled_txns, epoch_store .protocol_config() - .congested_objects_gas_price_feedback_mechanism(), + .congestion_control_gas_price_feedback_mechanism(), ); assigned_versions.push((cert.key(), cert_assigned_versions)); } @@ -188,7 +188,7 @@ impl SharedObjVerManager { ) } else { // WARN: do not remove this `else` branch even after - // `congested_objects_gas_price_feedback_mechanism` is enabled + // `congestion_control_gas_price_feedback_mechanism` is enabled // on the mainnet. It must be kept to be able to replay old // transaction data. SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK diff --git a/crates/iota-core/src/authority/suggested_gas_price_calculator.rs b/crates/iota-core/src/authority/suggested_gas_price_calculator.rs index eaaaa87511a..eb800876328 100644 --- a/crates/iota-core/src/authority/suggested_gas_price_calculator.rs +++ b/crates/iota-core/src/authority/suggested_gas_price_calculator.rs @@ -1,4 +1,4 @@ -// Copyright (c) 2024 IOTA Stiftung +// Copyright (c) 2025 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 use std::collections::{BTreeMap, HashMap}; @@ -159,16 +159,6 @@ impl SuggestedGasPriceCalculator { estimated_execution_duration: ExecutionTime, ) -> u64 { if let Some(max_execution_duration_per_commit) = self.max_execution_duration_per_commit { - debug_assert!( - estimated_execution_duration <= max_execution_duration_per_commit, - "This certificate alone has estimated execution duration of \ - {estimated_execution_duration}, which is larger than the maximum execution \ - duration per commit {max_execution_duration_per_commit}, so the certificate \ - cannot be scheduled regardless of suggested gas price. It is likely that \ - {max_execution_duration_per_commit} was set too low in the protocol config, \ - such that a commit cannot accommodate a single certificate." - ); - let clearing_gas_price = self.find_clearing_gas_price( certificate, estimated_execution_duration, @@ -177,7 +167,8 @@ impl SuggestedGasPriceCalculator { // Suggested gas price equals `clearing_gas_price + 1`. We add 1 to make this // transaction would be scheduled if the same commit structure was repeated. - let suggested_gas_price = clearing_gas_price + 1; + let suggested_gas_price = + clearing_gas_price.map_or(self.reference_gas_price, |p| p + 1); // Make sure suggested gas price is not larger than the maximum possible gas // price. @@ -197,13 +188,15 @@ impl SuggestedGasPriceCalculator { certificate: &VerifiedExecutableTransaction, estimated_execution_duration: ExecutionTime, max_execution_duration_per_commit: ExecutionTime, - ) -> u64 { + ) -> Option { // Imaginary start time of the deferred/cancelled certificate. We consider // only the highest possible (but sufficient for scheduling) start time as // it is very likely that scheduled certificates with lower gas prices - // appear have higher start times. + // appear have higher start times. If a transaction with its + // `estimated_execution_duration` cannot fit within + // `max_execution_duration_per_commit`, set its imaginary start time to 0. let start_time_of_deferred_cert = - max_execution_duration_per_commit - estimated_execution_duration; + max_execution_duration_per_commit.saturating_sub(estimated_execution_duration); certificate .shared_input_objects() @@ -217,8 +210,7 @@ impl SuggestedGasPriceCalculator { let end_time_of_scheduled_cert = execution_start_time + tx_congestion_info.estimated_execution_duration; - if end_time_of_scheduled_cert > start_time_of_deferred_cert - { + if end_time_of_scheduled_cert > start_time_of_deferred_cert { // Store gas price of that scheduled certificate Some(tx_congestion_info.gas_price) } else { @@ -240,14 +232,6 @@ impl SuggestedGasPriceCalculator { // was repeated again in a commit. .max() .flatten() - .unwrap_or_else(|| { - panic!( - "At least one of the shared input objects should have appeared in between \ - execution start time of {start_time_of_deferred_cert} and execution end time of \ - {max_execution_duration_per_commit}; otherwise, this deferred certificate \ - would be scheduled by the sequencer." - ); - }) } } @@ -296,8 +280,8 @@ pub mod suggested_gas_price_calculator_test_utils { *duration, ) .expect( - "initial value should be fit within the available range of slots \ - in the tracker", + "initial value should fit within the available range of slots in the \ + tracker", ); shared_object_congestion_tracker @@ -319,8 +303,8 @@ pub mod suggested_gas_price_calculator_test_utils { *duration, ) .expect( - "initial value should be fit within the available range of slots \ - in the tracker", + "initial value should fit within the available range of slots in \ + the tracker", ); shared_object_congestion_tracker diff --git a/crates/iota-core/src/lib.rs b/crates/iota-core/src/lib.rs index 879432cdebd..ecfce013323 100644 --- a/crates/iota-core/src/lib.rs +++ b/crates/iota-core/src/lib.rs @@ -50,6 +50,9 @@ pub mod verify_indexes; #[path = "unit_tests/congestion_control_tests.rs"] mod congestion_control_tests; #[cfg(test)] +#[path = "unit_tests/gas_price_feedback_tests.rs"] +mod gas_price_feedback_tests; +#[cfg(test)] #[path = "unit_tests/move_package_management_tests.rs"] mod move_package_management_tests; #[cfg(test)] diff --git a/crates/iota-core/src/unit_tests/data/gas_price_feedback/Move.toml b/crates/iota-core/src/unit_tests/data/gas_price_feedback/Move.toml new file mode 100644 index 00000000000..a90895f0a0a --- /dev/null +++ b/crates/iota-core/src/unit_tests/data/gas_price_feedback/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "gas_price_feedback" +version = "0.0.1" +edition = "2024.beta" + +[dependencies] +Iota = { local = "../../../../../iota-framework/packages/iota-framework" } + +[addresses] +gas_price_feedback = "0x0" diff --git a/crates/iota-core/src/unit_tests/data/gas_price_feedback/sources/gas_price_feedback.move b/crates/iota-core/src/unit_tests/data/gas_price_feedback/sources/gas_price_feedback.move new file mode 100644 index 00000000000..e4136661df5 --- /dev/null +++ b/crates/iota-core/src/unit_tests/data/gas_price_feedback/sources/gas_price_feedback.move @@ -0,0 +1,50 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +module gas_price_feedback::gas_price_feedback { + public struct Counter has key, store { + id: UID, + value: u64, + } + + public entry fun create_shared_counter(ctx: &mut TxContext) { + transfer::public_share_object(Counter { id: object::new(ctx), value: 0 }) + } + + public entry fun increment_both(counter_1: &mut Counter, counter_2: &mut Counter) { + let value = counter_1.value + 1; + counter_1.value = value; + + let value = counter_2.value + 1; + counter_2.value = value; + } + + public entry fun increment_first_read_second(counter_1: &mut Counter, counter_2: &Counter) { + let value = counter_1.value + 1; + counter_1.value = value; + + let value = counter_2.value; + } + + public entry fun read_first_increment_second(counter_1: &Counter, counter_2: &mut Counter) { + let value = counter_1.value; + + let value = counter_2.value + 1; + counter_2.value = value; + } + + public entry fun read_both(counter_1: &Counter, counter_2: &Counter) { + let value = counter_1.value; + + let value = counter_2.value; + } + + public entry fun increment_one(counter: &mut Counter) { + let value = counter.value + 1; + counter.value = value; + } + + public entry fun read_one(counter: &Counter) { + let value = counter.value; + } +} diff --git a/crates/iota-core/src/unit_tests/gas_price_feedback_tests.rs b/crates/iota-core/src/unit_tests/gas_price_feedback_tests.rs new file mode 100644 index 00000000000..a09533493e6 --- /dev/null +++ b/crates/iota-core/src/unit_tests/gas_price_feedback_tests.rs @@ -0,0 +1,1971 @@ +// Copyright (c) 2025 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::{panic, sync::Arc}; + +use iota_macros::sim_test; +use iota_protocol_config::{ + Chain, PerObjectCongestionControlMode, ProtocolConfig, ProtocolVersion, +}; +use iota_types::{ + base_types::{IotaAddress, ObjectID, ObjectRef, SequenceNumber}, + crypto::{AccountKeyPair, get_key_pair}, + effects::{TransactionEffects, TransactionEffectsAPI, UnchangedSharedKind}, + executable_transaction::VerifiedExecutableTransaction, + execution_status::{CongestedObjects, ExecutionFailureStatus, ExecutionStatus}, + messages_consensus::ConsensusDeterminedVersionAssignments, + object::Object, + programmable_transaction_builder::ProgrammableTransactionBuilder, + transaction::{ + ObjectArg, ProgrammableTransaction, Transaction, TransactionData, TransactionDataAPI, + TransactionKind, VerifiedCertificate, + }, + utils::to_sender_signed_transaction, +}; +use move_core_types::ident_str; +use rand::seq::SliceRandom; + +use crate::{ + authority::{ + AuthorityState, + authority_tests::{ + certify_transaction, send_and_confirm_transaction_, send_batch_consensus_no_execution, + }, + move_integration_tests::build_and_publish_test_package, + test_authority_builder::TestAuthorityBuilder, + transaction_deferral::DeferralKey, + }, + move_call, +}; + +/// Reference gas price used in gas price feedback mechanism tests. +const REFERENCE_GAS_PRICE_FOR_TESTS: u64 = 1_000; + +/// Default gas units used in gas price feedback mechanism tests. +const DEFAULT_GAS_UNITS_FOR_TESTS: u64 = 10_000; + +/// Container holding gas object ID, gas price, and gas budget. +struct GasDataForTests { + gas_object_id: ObjectID, + gas_price: u64, + gas_budget: u64, +} + +impl GasDataForTests { + fn new(gas_object_id: ObjectID, gas_price: u64, gas_budget: u64) -> Self { + Self { + gas_object_id, + gas_price, + gas_budget, + } + } +} + +struct GasPriceFeedbackTester { + authority_state: Arc, + protocol_config: ProtocolConfig, + sender: IotaAddress, + sender_key: AccountKeyPair, + gas_object_ids: Vec, + package: ObjectRef, + shared_counter_1: ObjectRef, + shared_counter_2: ObjectRef, +} + +impl GasPriceFeedbackTester { + /// Create a new `GasPriceFeedbackTester`. Under the hood, this builds + /// a new `AuthorityState` with protocol config parameters related to + /// shared object congestion. This will also deploy a number of gas + /// objects needed to send test transactions, and deploy a package with + /// two shared counters and simple Move calls operating on those counters. + async fn new( + max_deferral_rounds_for_congestion_control: u64, + per_object_congestion_control_mode: PerObjectCongestionControlMode, + max_execution_duration_per_commit: Option, + assign_min_free_execution_slot: bool, + enable_gas_price_feedback_mechanism: bool, + num_gas_objects: usize, + ) -> Self { + let (sender, sender_key): (IotaAddress, AccountKeyPair) = get_key_pair(); + + let mut protocol_config = + ProtocolConfig::get_for_version(ProtocolVersion::max(), Chain::Unknown); + protocol_config.set_max_deferral_rounds_for_congestion_control_for_testing( + max_deferral_rounds_for_congestion_control, + ); + protocol_config + .set_per_object_congestion_control_mode_for_testing(per_object_congestion_control_mode); + if let Some(max_execution_duration_per_commit) = max_execution_duration_per_commit { + protocol_config + .set_max_accumulated_txn_cost_per_object_in_mysticeti_commit_for_testing( + max_execution_duration_per_commit, + ); + } else { + protocol_config + .disable_max_accumulated_txn_cost_per_object_in_mysticeti_commit_for_testing(); + } + protocol_config.set_congestion_control_min_free_execution_slot_for_testing( + assign_min_free_execution_slot, + ); + protocol_config.set_congestion_control_gas_price_feedback_mechanism_for_testing( + enable_gas_price_feedback_mechanism, + ); + + let authority_state = TestAuthorityBuilder::new() + .with_reference_gas_price(REFERENCE_GAS_PRICE_FOR_TESTS) + .with_protocol_config(protocol_config.clone()) + .build() + .await; + + let gas_object_ids = (0..num_gas_objects) + .map(|_| ObjectID::random()) + .collect::>(); + let gas_objects = gas_object_ids + .iter() + .map(|gas_object_id| Object::with_id_owner_for_testing(*gas_object_id, sender)) + .collect::>(); + authority_state.insert_genesis_objects(&gas_objects).await; + + let gas_object_id = gas_object_ids.first().unwrap(); + + let package = build_and_publish_test_package( + &authority_state, + &sender, + &sender_key, + gas_object_id, + "gas_price_feedback", + false, + ) + .await; + + let shared_counter_1 = Self::create_shared_counter( + &authority_state, + &package.0, + gas_object_id, + &sender, + &sender_key, + ) + .await; + + let shared_counter_2 = Self::create_shared_counter( + &authority_state, + &package.0, + gas_object_id, + &sender, + &sender_key, + ) + .await; + + Self { + authority_state, + protocol_config, + sender, + sender_key, + gas_object_ids, + package, + shared_counter_1, + shared_counter_2, + } + } + + /// Build and execute a transaction that creates a shared counter. + async fn create_shared_counter( + authority_state: &AuthorityState, + package_id: &ObjectID, + gas_object_id: &ObjectID, + sender: &IotaAddress, + sender_key: &AccountKeyPair, + ) -> ObjectRef { + let mut builder = ProgrammableTransactionBuilder::new(); + + move_call! { + builder, + (*package_id)::gas_price_feedback::create_shared_counter() + }; + + let pt = builder.finish(); + + let gas_object_ref = authority_state + .get_object(gas_object_id) + .await + .unwrap() + .compute_object_reference(); + + let transaction_data = TransactionData::new_programmable( + *sender, + vec![gas_object_ref], + pt, + REFERENCE_GAS_PRICE_FOR_TESTS * DEFAULT_GAS_UNITS_FOR_TESTS, + REFERENCE_GAS_PRICE_FOR_TESTS, + ); + + let transaction = to_sender_signed_transaction(transaction_data, sender_key); + + let effects = send_and_confirm_transaction_(authority_state, None, transaction, false) + .await + .unwrap() + .1 + .into_data(); + + assert!( + effects.status().is_ok(), + "Execution error {:?}", + effects.status() + ); + assert_eq!(effects.created().len(), 1); + + effects.created()[0].0 + } + + /// Build and sign a programmable transaction. + async fn build_programmable_transaction( + &self, + pt: ProgrammableTransaction, + gas_data: GasDataForTests, + ) -> Transaction { + let gas_object_ref = self + .authority_state + .get_object(&gas_data.gas_object_id) + .await + .unwrap() + .compute_object_reference(); + + let transaction_data = TransactionData::new_programmable( + self.sender, + vec![gas_object_ref], + pt, + gas_data.gas_budget, + gas_data.gas_price, + ); + + to_sender_signed_transaction(transaction_data, &self.sender_key) + } + + /// Certify a transaction signed by the user. + async fn certify_transaction(&self, transaction: Transaction) -> VerifiedCertificate { + certify_transaction(&self.authority_state, transaction) + .await + .unwrap() + } + + /// Send certificates to consensus for scheduling. + async fn send_certificates_to_consensus_for_scheduling( + &self, + certificates: &[VerifiedCertificate], + ) -> Vec { + send_batch_consensus_no_execution(&self.authority_state, certificates, false).await + } + + /// Enqueue scheduled transactions and execute them to effects. + async fn enqueue_and_execute_scheduled_transactions( + &self, + transactions: Vec, + ) -> Vec { + let transaction_digests = transactions + .iter() + .map(|tx| *tx.digest()) + .collect::>(); + + self.authority_state.transaction_manager().enqueue( + transactions, + &self.authority_state.epoch_store_for_testing(), + ); + + self.authority_state + .get_transaction_cache_reader() + .notify_read_executed_effects(&transaction_digests) + .await + } + + /// Build and sign a programmable transaction that accesses both counters. + /// `counter_1_mutable` and `counter_2_mutable` flags control how the + /// counters are accessed: mutably or immutably. + async fn build_access_both_counters_transaction( + &self, + gas_data: GasDataForTests, + counter_1_mutable: bool, + counter_2_mutable: bool, + ) -> Transaction { + let mut txn_builder = ProgrammableTransactionBuilder::new(); + + let arg1 = txn_builder + .obj(ObjectArg::SharedObject { + id: self.shared_counter_1.0, + initial_shared_version: self.shared_counter_1.1, + mutable: counter_1_mutable, + }) + .unwrap(); + + let arg2 = txn_builder + .obj(ObjectArg::SharedObject { + id: self.shared_counter_2.0, + initial_shared_version: self.shared_counter_2.1, + mutable: counter_2_mutable, + }) + .unwrap(); + + if counter_1_mutable && counter_2_mutable { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::increment_both(arg1, arg2) + }; + } else if counter_1_mutable && !counter_2_mutable { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::increment_first_read_second(arg1, arg2) + }; + } else if !counter_1_mutable && counter_2_mutable { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::read_first_increment_second(arg1, arg2) + }; + } else { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::read_both(arg1, arg2) + }; + } + + let pt = txn_builder.finish(); + + self.build_programmable_transaction(pt, gas_data).await + } + + /// Build and sign a programmable transaction that accesses one counter. + /// The `mutable` flag control how the counter is accessed: mutably or + /// immutably. The `first` flag control whether the first or the second + /// counter is accessed. + async fn build_access_one_counter_transaction( + &self, + gas_data: GasDataForTests, + mutable: bool, + first: bool, + ) -> Transaction { + let mut txn_builder = ProgrammableTransactionBuilder::new(); + + let counter = if first { + self.shared_counter_1 + } else { + self.shared_counter_2 + }; + + let arg = txn_builder + .obj(ObjectArg::SharedObject { + id: counter.0, + initial_shared_version: counter.1, + mutable, + }) + .unwrap(); + + if mutable { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::increment_one(arg) + }; + } else { + move_call! { + txn_builder, + (self.package.0)::gas_price_feedback::read_one(arg) + }; + } + + let pt = txn_builder.finish(); + + self.build_programmable_transaction(pt, gas_data).await + } + + async fn create_certificates_for_non_trivial_case(&self) -> Vec { + let max_gp = self.protocol_config.max_gas_price(); + // (gas price, gas budget, counter_1_mutable, counter_2_mutable) + let data = [ + (max_gp, 3_000_000_000, Some(true), Some(false)), // 0 + (1_011, 1_000_000_000, Some(false), Some(true)), // 1 + (1_010, 4_000_000_000, Some(false), Some(true)), // 2 + (1_009, 2_000_000_000, None, Some(true)), // 3 + (1_008, 1_000_000_001, None, Some(false)), // 4 + (1_007, 5_000_000_000, None, Some(true)), // 5 + (1_006, 5_000_000_001, Some(true), Some(true)), // 6 + (1_005, 8_000_000_000, Some(true), Some(true)), // 7 + (1_004, 4_000_000_000, Some(true), None), // 8 + (1_003, 2_000_000_000, Some(true), None), // 9 + (1_002, 1_000_000_001, Some(false), Some(false)), // 10 + (1_001, 5_000_000_001, Some(true), Some(false)), // 11 + (1_000, 9_000_000_000, Some(false), Some(true)), // 12 + ]; + + let mut certificates = vec![]; + for (index, data) in data.into_iter().enumerate() { + let gas_data = GasDataForTests::new(self.gas_object_ids[index], data.0, data.1); + + let transaction = if data.2.is_some() && data.3.is_some() { + self.build_access_both_counters_transaction( + gas_data, + data.2.unwrap(), + data.3.unwrap(), + ) + .await + } else if data.2.is_some() && data.3.is_none() { + self.build_access_one_counter_transaction(gas_data, data.2.unwrap(), true) + .await + } else if data.2.is_none() && data.3.is_some() { + self.build_access_one_counter_transaction(gas_data, data.3.unwrap(), false) + .await + } else { + panic!("At least one counter must be accessed in transactions."); + }; + + certificates.push(self.certify_transaction(transaction).await); + } + + certificates + } +} + +// Test that everything goes well (i.e., no transactions are deferred or +// cancelled) if per-object congestion control mode is None. +#[sim_test] +async fn per_object_congestion_control_mode_is_none() { + let num_gas_objects = 10; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::None, // per_object_congestion_control_mode + Some(1), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let mut certificates = vec![]; + for (i, gas_object_id) in tester.gas_object_ids.iter().enumerate() { + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS + i as u64; + let gas_data = GasDataForTests::new( + *gas_object_id, + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + let certificate = tester.certify_transaction(transaction).await; + + certificates.push(certificate); + } + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), num_gas_objects); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + assert!(matches!( + scheduled_transactions[0].data().transaction_data().kind(), + TransactionKind::ConsensusCommitPrologueV1(..) + )); + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // All transactions should be successfully executed. + for effects in effects_vec { + assert!(effects.status().is_ok()); + } +} + +// Test that everything goes well (i.e., no transactions are deferred or +// cancelled) if `max_execution_duration_per_commit` is set None. +#[sim_test] +async fn max_execution_duration_per_commit_is_none() { + let num_gas_objects = 10; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalTxCount, // per_object_congestion_control_mode + None, // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let mut certificates = vec![]; + for (i, gas_object_id) in tester.gas_object_ids.iter().enumerate() { + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS + i as u64; + let gas_data = GasDataForTests::new( + *gas_object_id, + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + let certificate = tester.certify_transaction(transaction).await; + + certificates.push(certificate); + } + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), num_gas_objects); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + assert!(matches!( + scheduled_transactions[0].data().transaction_data().kind(), + TransactionKind::ConsensusCommitPrologueV1(..) + )); + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // All transactions should be successfully executed. + for effects in effects_vec { + assert!(effects.status().is_ok()); + } +} + +// Test that the suggested gas price calculator return the correct gas price +// if there are transactions with estimated execution duration larger than +// `max_execution_duration_per_commit`, that is such, transactions cannot +// be scheduled. +#[sim_test] +async fn transaction_duration_exceeds_max_execution_duration_per_commit() { + let num_gas_objects = 3; + let gas_budget_of_scheduled_tx = + (REFERENCE_GAS_PRICE_FOR_TESTS + 2) * DEFAULT_GAS_UNITS_FOR_TESTS; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalGasBudget, // per_object_congestion_control_mode + Some(gas_budget_of_scheduled_tx), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let mut certificates = vec![]; + // Should be cancelled as it does not fit, suggested gas price must be equal + // the reference gas price as there are no congested objects. + let gas_data = GasDataForTests::new( + tester.gas_object_ids[0], + REFERENCE_GAS_PRICE_FOR_TESTS + 2, + tester.protocol_config.max_tx_gas(), + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + certificates.push(tester.certify_transaction(transaction).await); + // Should be scheduled. + let gas_data = GasDataForTests::new( + tester.gas_object_ids[1], + REFERENCE_GAS_PRICE_FOR_TESTS + 2, + gas_budget_of_scheduled_tx, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + certificates.push(tester.certify_transaction(transaction).await); + // Should be cancelled as it does not fit, suggested gas price must be equal + // gas price of the scheduled transaction + 1. + let gas_data = GasDataForTests::new( + tester.gas_object_ids[2], + REFERENCE_GAS_PRICE_FOR_TESTS + 1, + tester.protocol_config.max_tx_gas(), + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + certificates.push(tester.certify_transaction(transaction).await); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + let expected_suggested_gas_price_2 = scheduled_transactions[2] + .data() + .transaction_data() + .gas_price() + + 1; + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![ + ( + *scheduled_transactions[1].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + REFERENCE_GAS_PRICE_FOR_TESTS, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + REFERENCE_GAS_PRICE_FOR_TESTS, + ), + ), + ], + ), + ( + *scheduled_transactions[3].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_2, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_2, + ), + ), + ], + ), + ]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // `ConsensusCommitPrologueV1` should be successfully executed + assert!(effects_vec[0].status().is_ok()); + // The second transaction should be scheduled. + assert!(effects_vec[2].status().is_ok()); + + // The first transaction should be cancelled + if let ExecutionStatus::Failure { error, command } = effects_vec[1].status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, REFERENCE_GAS_PRICE_FOR_TESTS); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("The transaction must be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects_vec[1].unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + REFERENCE_GAS_PRICE_FOR_TESTS + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + REFERENCE_GAS_PRICE_FOR_TESTS + ) + ) + ), + ] + ); + + // The third transaction should be cancelled + if let ExecutionStatus::Failure { error, command } = effects_vec[3].status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, expected_suggested_gas_price_2); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("The transaction must be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects_vec[3].unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_2 + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_2 + ) + ) + ), + ] + ); +} + +// Test that everything works well if the gas price feedback mechanism is +// turned off: specifically, old `ExecutionCancelledDueToSharedObjectCongestion` +// and `SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK` should appear. +#[sim_test] +async fn gas_price_feedback_mechanism_is_turned_off() { + let num_gas_objects = 2; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalTxCount, // per_object_congestion_control_mode + Some(1), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + false, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let mut certificates = vec![]; + for (i, gas_object_id) in tester.gas_object_ids.iter().enumerate() { + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS + i as u64; + let gas_data = GasDataForTests::new( + *gas_object_id, + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + let certificate = tester.certify_transaction(transaction).await; + + certificates.push(certificate); + } + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), num_gas_objects); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![( + *scheduled_transactions[2].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK, + ), + ( + tester.shared_counter_2.0, + SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK, + ), + ], + )]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + + // Confirm that gas price order of scheduled transactions is descending + assert_eq!( + scheduled_transactions[1] + .data() + .transaction_data() + .gas_price(), + REFERENCE_GAS_PRICE_FOR_TESTS + 1 + ); + assert_eq!( + scheduled_transactions[2] + .data() + .transaction_data() + .gas_price(), + REFERENCE_GAS_PRICE_FOR_TESTS + ); + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // `ConsensusCommitPrologueV1` should be successfully executed + assert!(effects_vec[0].status().is_ok()); + // The first transaction should be successfully executed + assert!(effects_vec[1].status().is_ok()); + + // The second transaction should be cancelled + if let ExecutionStatus::Failure { error, command } = effects_vec[2].status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestion { + congested_objects, + } = error + { + // Check is returned congested_objects are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + } else { + panic!("ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestion."); + } + } else { + panic!("The second transaction must be cancelled.") + } + + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects_vec[2].unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::CONGESTED_PRIOR_TO_GAS_PRICE_FEEDBACK + ) + ), + ] + ); +} + +// Test that suggested gas price does not exceed the max gas price set in +// the protocol. +#[sim_test] +async fn gas_price_feedback_mechanism_with_max_gas_price() { + let max_gas_price = 100_000; + let num_gas_objects = 2; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalGasBudget, // per_object_congestion_control_mode + Some(max_gas_price * DEFAULT_GAS_UNITS_FOR_TESTS), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + assert_eq!(max_gas_price, tester.protocol_config.max_gas_price()); + + // Prepare certificates + let mut certificates = vec![]; + for gas_object_id in tester.gas_object_ids.iter() { + let gas_data = GasDataForTests::new( + *gas_object_id, + max_gas_price, + max_gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, false) + .await; + let certificate = tester.certify_transaction(transaction).await; + + certificates.push(certificate); + } + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), num_gas_objects); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + let expected_suggested_gas_price = tester.protocol_config.max_gas_price(); + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![( + *scheduled_transactions[2].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price, + ), + ), + ], + )]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // `ConsensusCommitPrologueV1` should be successfully executed + assert!(effects_vec[0].status().is_ok()); + // The first transaction should be successfully executed + assert!(effects_vec[1].status().is_ok()); + + // The second transaction should be cancelled + if let ExecutionStatus::Failure { error, command } = effects_vec[2].status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, expected_suggested_gas_price); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("The second transaction must be cancelled.") + } + + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects_vec[2].unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ] + ); +} + +// Test that suggested gas price for a cancelled transactions is the +// lowest suggested gas price over multiple commits in which the +// transaction was deferred. +#[sim_test] +async fn gas_price_feedback_mechanism_for_multiple_commits() { + let max_execution_duration_per_commit = 1; + let num_gas_objects = 2; + let tester = GasPriceFeedbackTester::new( + 1, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalTxCount, // per_object_congestion_control_mode + Some(max_execution_duration_per_commit), + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates for consensus commit round 1 + let mut certificates = vec![]; + // Create a certificate that should be deferred + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS; + let gas_data = GasDataForTests::new( + tester.gas_object_ids[0], + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, true, true) + .await; + let should_defer_certificate = tester.certify_transaction(transaction).await; + certificates.push(should_defer_certificate.clone()); + // Create a certificate that should be scheduled + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS + 5; + let gas_data = GasDataForTests::new( + tester.gas_object_ids[1], + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, false, true) + .await; + let should_schedule_certificate_1 = tester.certify_transaction(transaction).await; + certificates.push(should_schedule_certificate_1.clone()); + + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), num_gas_objects); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len() as u64, + // +1 because of consensus commit prologue transaction + max_execution_duration_per_commit + 1, + ); + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(vec![]) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + // The second scheduled transaction should be one paying higher gas price + assert_eq!( + scheduled_transactions[1].digest(), + should_schedule_certificate_1.digest() + ); + + // Checks that deferred transactions are formed correctly + let deferred_transactions = tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap(); + assert_eq!(deferred_transactions.len(), 1); + assert_eq!(deferred_transactions[0].1.len(), 1); + assert!(matches!( + deferred_transactions[0].0, + DeferralKey::ConsensusRound { .. } + )); + assert_eq!( + deferred_transactions[0].1[0].suggested_gas_price(), + Some(should_schedule_certificate_1.gas_price() + 1) + ); + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len() as u64, + // +1 because of consensus commit prologue transaction + max_execution_duration_per_commit + 1, + ); + + // Both scheduled transactions should be successfully executed + for effects in effects_vec { + assert!(effects.status().is_ok()); + } + + // Prepare certificates for consensus commit round 2 + let mut certificates = vec![]; + // Create a certificate that should be scheduled + let gas_price = REFERENCE_GAS_PRICE_FOR_TESTS + 10; + let gas_data = GasDataForTests::new( + tester.gas_object_ids[1], + gas_price, + gas_price * DEFAULT_GAS_UNITS_FOR_TESTS, + ); + let transaction = tester + .build_access_both_counters_transaction(gas_data, false, true) + .await; + let should_schedule_certificate_2 = tester.certify_transaction(transaction).await; + certificates.push(should_schedule_certificate_2.clone()); + + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + certificates.shuffle(&mut rand::thread_rng()); + assert_eq!(certificates.len(), 1); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +2 because one consensus commit prologue transaction and one cancelled transaction + certificates.len() + 2, + ); + + // Suggested gas price must be gas price of the scheduled certificate in the + // first commit round (not the current commit round) plus one + let expected_suggested_gas_price = should_schedule_certificate_1.gas_price() + 1; + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![( + *scheduled_transactions[2].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price, + ), + ), + ], + )]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + // The second scheduled transaction should be one paying higher gas price + assert_eq!( + scheduled_transactions[1].digest(), + should_schedule_certificate_2.digest() + ); + // The third scheduled transaction should be the canceled transaction + assert_eq!( + scheduled_transactions[2].digest(), + should_defer_certificate.digest() + ); + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +2 because one consensus commit prologue transaction and one cancelled transaction + certificates.len() + 2, + ); + + // `ConsensusCommitPrologueV1` should be successfully executed + assert!(effects_vec[0].status().is_ok()); + // The first scheduled transaction should be successfully executed + assert!(effects_vec[1].status().is_ok()); + + // The second scheduled transaction should be cancelled + if let ExecutionStatus::Failure { error, command } = effects_vec[2].status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, expected_suggested_gas_price); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("The second transaction must be cancelled.") + } + + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects_vec[2].unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ] + ); +} + +// Test gas price feedback mechanism in `TotalTxCount` mode in non-trivial case. +#[sim_test] +async fn gas_price_feedback_mechanism_non_trivial_case_total_tx_count_mode() { + let num_gas_objects = 13; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalTxCount, // per_object_congestion_control_mode + Some(3), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let certificates = tester.create_certificates_for_non_trivial_case().await; + assert_eq!(certificates.len(), num_gas_objects); + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + let mut shuffled_certificates = certificates.clone(); + shuffled_certificates.shuffle(&mut rand::thread_rng()); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&shuffled_certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // Recall the structure of these certificates: + // (gas price, gas budget, counter_1_mutable, counter_2_mutable) + // [ + // (100K, 3_000_000_000, Some(true), Some(false)), // 0 + // (1011, 1_000_000_000, Some(false), Some(true)), // 1 + // (1010, 4_000_000_000, Some(false), Some(true)), // 2 + // (1009, 2_000_000_000, None, Some(true)), // 3 + // (1008, 1_000_000_001, None, Some(false)), // 4 + // (1007, 5_000_000_000, None, Some(true)), // 5 + // (1006, 5_000_000_001, Some(true), Some(true)), // 6 + // (1005, 8_000_000_000, Some(true), Some(true)), // 7 + // (1004, 4_000_000_000, Some(true), None), // 8 + // (1003, 2_000_000_000, Some(true), None), // 9 + // (1002, 1_000_000_001, Some(false), Some(false)), // 10 + // (1001, 5_000_000_001, Some(true), Some(false)), // 11 + // (1000, 9_000_000_000, Some(false), Some(true)), // 12 + // ]; + + // Allocations of mutably accessed shared objects should look as follows: + // |-------------------------------------|------------| + // | object_1 | object_2 | start time | + // |__________________|__________________|____________| + // |------------------|------------------|---- 3 | + // | cert. 9 (g=1003) | cert. 2 (g=1010) | | + // |------------------|------------------|---- 2 | + // | cert. 8 (g=1004) | cert. 1 (g=1011) | | + // |------------------|------------------|---- 1 | + // | cert. 0 (g=100K) | cert. 3 (g=1009) | | + // |-------------------------------------|---- 0 -----| + // That is, certificates 4, 5, 6, 7, 10, 11, 12 should be cancelled. + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + // As can be seen from the illustration above: + let expected_suggested_gas_price_for_object_1 = certificates[9].gas_price() + 1; + let expected_suggested_gas_price_for_object_2 = certificates[2].gas_price() + 1; + let expected_suggested_gas_price_for_both_objects = + expected_suggested_gas_price_for_object_1.max(expected_suggested_gas_price_for_object_2); + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![ + ( + *certificates[4].digest(), + vec![( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_object_2, + ), + )], + ), + ( + *certificates[5].digest(), + vec![( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_object_2, + ), + )], + ), + ( + *certificates[6].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ], + ), + ( + *certificates[7].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ], + ), + ( + *certificates[10].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ], + ), + ( + *certificates[11].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ], + ), + ( + *certificates[12].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects, + ), + ), + ], + ), + ]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // `ConsensusCommitPrologueV1` and first 6 scheduled transactions should be + // successfully executed + for effects in effects_vec.iter().take(7) { + assert!(effects.status().is_ok()); + } + + // The rest of transactions should be cancelled: + // + // Transactions that touch shared counter 2: + for effects in effects_vec.iter().skip(7).take(2) { + if let ExecutionStatus::Failure { error, command } = effects.status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_2.0]) + ); + assert_eq!( + *suggested_gas_price, + expected_suggested_gas_price_for_object_2 + ); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("Transaction should have been be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects.unchanged_shared_objects(), + vec![( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_object_2 + ) + ) + ),] + ); + } + // Transactions that touch both shared counters: + for effects in effects_vec.iter().skip(9).take(5) { + if let ExecutionStatus::Failure { error, command } = effects.status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!( + *suggested_gas_price, + expected_suggested_gas_price_for_both_objects + ); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("Transaction should have been be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects.unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price_for_both_objects + ) + ) + ), + ] + ); + } +} + +// Test gas price feedback mechanism in `TotalGasBudget` mode in non-trivial +// case. +#[sim_test] +async fn gas_price_feedback_mechanism_non_trivial_case_total_gas_budget_mode() { + let num_gas_objects = 13; + let tester = GasPriceFeedbackTester::new( + 0, // max_deferral_rounds_for_congestion_control + PerObjectCongestionControlMode::TotalGasBudget, // per_object_congestion_control_mode + Some(9_000_000_000), // max_execution_duration_per_commit + true, // assign_min_free_execution_slot + true, // enable_gas_price_feedback_mechanism + num_gas_objects, + ) + .await; + + // Prepare certificates + let certificates = tester.create_certificates_for_non_trivial_case().await; + assert_eq!(certificates.len(), num_gas_objects); + // Shuffle certificates so that they do not have any specific order in + // terms of gas price. + let mut shuffled_certificates = certificates.clone(); + shuffled_certificates.shuffle(&mut rand::thread_rng()); + + let scheduled_transactions = tester + .send_certificates_to_consensus_for_scheduling(&shuffled_certificates) + .await; + assert_eq!( + scheduled_transactions.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // Recall the structure of these certificates: + // (gas price, gas budget, counter_1_mutable, counter_2_mutable) + // [ + // (100K, 3_000_000_000, Some(true), Some(false)), // 0 + // (1011, 1_000_000_000, Some(false), Some(true)), // 1 + // (1010, 4_000_000_000, Some(false), Some(true)), // 2 + // (1009, 2_000_000_000, None, Some(true)), // 3 + // (1008, 1_000_000_001, None, Some(false)), // 4 + // (1007, 5_000_000_000, None, Some(true)), // 5 + // (1006, 5_000_000_001, Some(true), Some(true)), // 6 + // (1005, 8_000_000_000, Some(true), Some(true)), // 7 + // (1004, 4_000_000_000, Some(true), None), // 8 + // (1003, 2_000_000_000, Some(true), None), // 9 + // (1002, 1_000_000_001, Some(false), Some(false)), // 10 + // (1001, 5_000_000_001, Some(true), Some(false)), // 11 + // (1000, 9_000_000_000, Some(false), Some(true)), // 12 + // ]; + + // Allocations of mutably accessed shared objects should look as follows: + // |-------------------------------------------------|------------| + // | object_1 | object_2 | start time | + // |________________________|________________________|____________| + // |------------------------|------------------------|---- 9B | + // | | | | + // | cert. 9 (g=5000, d=2B) |------------------------|---- 8B | + // | | | | + // |------------------------| |---- 7B | + // | | | | + // | | cert. 2 (g=8000, d=4B) |---- 6B | + // | | | | + // | cert. 8 (g=6000, d=4B) | |---- 5B | + // | | | | + // | |------------------------|---- 4B | + // | | cert. 1 (g=9000, d=1B) | | + // |------------------------|------------------------|---- 3B | + // | | | | + // | |------------------------|---- 2B | + // | cert. 0 (g=100K, d=3M) | | | + // | | cert. 3 (g=7000, d=2B) |---- 1B | + // | | | | + // |-------------------------------------------------|---- 0 -----| + // That is, certificates 4, 5, 6, 7, 10, 11, 12 should be cancelled. + + // Checks that there are no deferred transactions + assert!( + tester + .authority_state + .epoch_store_for_testing() + .get_all_deferred_transactions_for_test() + .unwrap() + .is_empty() + ); + + // The first scheduled transaction should be `ConsensusCommitPrologueV1` + if let TransactionKind::ConsensusCommitPrologueV1(prologue_tx) = + scheduled_transactions[0].data().transaction_data().kind() + { + // Check if `ConsensusDeterminedVersionAssignments` are correct. + let cancelled_txs = vec![ + ( + *certificates[4].digest(), + vec![( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[2].gas_price() + 1, + ), + )], + ), + ( + *certificates[5].digest(), + vec![( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[2].gas_price() + 1, + ), + )], + ), + ( + *certificates[6].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[1].gas_price() + 1, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[1].gas_price() + 1, + ), + ), + ], + ), + ( + *certificates[7].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[0].gas_price(), + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[0].gas_price(), + ), + ), + ], + ), + ( + *certificates[10].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[2].gas_price() + 1, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[2].gas_price() + 1, + ), + ), + ], + ), + ( + *certificates[11].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[1].gas_price() + 1, + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[1].gas_price() + 1, + ), + ), + ], + ), + ( + *certificates[12].digest(), + vec![ + ( + tester.shared_counter_1.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[0].gas_price(), + ), + ), + ( + tester.shared_counter_2.0, + SequenceNumber::new_congested_with_suggested_gas_price( + certificates[0].gas_price(), + ), + ), + ], + ), + ]; + assert_eq!( + prologue_tx.consensus_determined_version_assignments, + ConsensusDeterminedVersionAssignments::CancelledTransactions(cancelled_txs) + ); + } else { + panic!("First scheduled transaction must be a ConsensusCommitPrologueV1 transaction."); + } + + let effects_vec = tester + .enqueue_and_execute_scheduled_transactions(scheduled_transactions) + .await; + assert_eq!( + effects_vec.len(), + // +1 because of consensus commit prologue transaction + certificates.len() + 1, + ); + + // `ConsensusCommitPrologueV1` and first 6 scheduled transactions should be + // successfully executed + for effects in effects_vec.iter().take(7) { + assert!(effects.status().is_ok()); + } + + // The rest of transactions should be cancelled: + // + // Transactions that touch shared counter 2: + let expected_suggested_gas_price = certificates[2].gas_price() + 1; + for effects in effects_vec.iter().skip(7).take(2) { + if let ExecutionStatus::Failure { error, command } = effects.status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, expected_suggested_gas_price); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("Transaction should have been be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects.unchanged_shared_objects(), + vec![( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ),] + ); + } + // Transactions that touch both shared counters: + for (i, effects) in effects_vec.iter().skip(9).take(5).enumerate() { + let expected_suggested_gas_price = if i == 0 || i == 3 { + certificates[1].gas_price() + 1 + } else if i == 1 || i == 4 { + certificates[0].gas_price() + } else if i == 2 { + certificates[2].gas_price() + 1 + } else { + panic!("Expected only 5 effects to iterate.") + }; + + if let ExecutionStatus::Failure { error, command } = effects.status() { + assert!(command.is_none()); + if let ExecutionFailureStatus::ExecutionCancelledDueToSharedObjectCongestionV2 { + congested_objects, + suggested_gas_price, + } = error + { + // Check is returned congested_objects and suggested_gas_price are correct. + assert_eq!( + *congested_objects, + CongestedObjects(vec![tester.shared_counter_1.0, tester.shared_counter_2.0]) + ); + assert_eq!(*suggested_gas_price, expected_suggested_gas_price); + } else { + panic!( + "ExecutionFailureStatus must be ExecutionCancelledDueToSharedObjectCongestionV2." + ); + } + } else { + panic!("Transaction should have been be cancelled.") + } + // Check if unchanged_shared_objects in effects of the cancelled transaction + // are correct + assert_eq!( + effects.unchanged_shared_objects(), + vec![ + ( + tester.shared_counter_1.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ( + tester.shared_counter_2.0, + UnchangedSharedKind::Cancelled( + SequenceNumber::new_congested_with_suggested_gas_price( + expected_suggested_gas_price + ) + ) + ), + ] + ); + } +} diff --git a/crates/iota-open-rpc/spec/openrpc.json b/crates/iota-open-rpc/spec/openrpc.json index fb35801c5cd..0ca16d497ab 100644 --- a/crates/iota-open-rpc/spec/openrpc.json +++ b/crates/iota-open-rpc/spec/openrpc.json @@ -1306,7 +1306,7 @@ "featureFlags": { "accept_passkey_in_multisig": false, "accept_zklogin_in_multisig": false, - "congested_objects_gas_price_feedback_mechanism": false, + "congestion_control_gas_price_feedback_mechanism": false, "congestion_control_min_free_execution_slot": false, "consensus_batched_block_sync": false, "consensus_distributed_vote_scoring_strategy": false, diff --git a/crates/iota-protocol-config/src/lib.rs b/crates/iota-protocol-config/src/lib.rs index 40186fbc393..899d906929b 100644 --- a/crates/iota-protocol-config/src/lib.rs +++ b/crates/iota-protocol-config/src/lib.rs @@ -292,7 +292,7 @@ struct FeatureFlags { // To enable/disable the gas price feedback mechanism used for transactions // cancelled due to shared object congestion #[serde(skip_serializing_if = "is_false")] - congested_objects_gas_price_feedback_mechanism: bool, + congestion_control_gas_price_feedback_mechanism: bool, } fn is_true(b: &bool) -> bool { @@ -1272,9 +1272,9 @@ impl ProtocolConfig { /// Check if the gas price feedback mechanism (which is used for /// transactions cancelled due to shared object congestion) is enabled - pub fn congested_objects_gas_price_feedback_mechanism(&self) -> bool { + pub fn congestion_control_gas_price_feedback_mechanism(&self) -> bool { self.feature_flags - .congested_objects_gas_price_feedback_mechanism + .congestion_control_gas_price_feedback_mechanism } } @@ -2065,7 +2065,7 @@ impl ProtocolConfig { // Enable the gas price feedback mechanism (which is used for // transactions cancelled due to shared object congestion) in devnet cfg.feature_flags - .congested_objects_gas_price_feedback_mechanism = true; + .congestion_control_gas_price_feedback_mechanism = true; } } // Use this template when making changes: @@ -2218,6 +2218,16 @@ impl ProtocolConfig { pub fn set_consensus_batched_block_sync_for_testing(&mut self, val: bool) { self.feature_flags.consensus_batched_block_sync = val; } + + pub fn set_congestion_control_min_free_execution_slot_for_testing(&mut self, val: bool) { + self.feature_flags + .congestion_control_min_free_execution_slot = val; + } + + pub fn set_congestion_control_gas_price_feedback_mechanism_for_testing(&mut self, val: bool) { + self.feature_flags + .congestion_control_gas_price_feedback_mechanism = val; + } } type OverrideFn = dyn Fn(ProtocolVersion, ProtocolConfig) -> ProtocolConfig + Send + Sync; diff --git a/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap b/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap index e5eb6ae8af6..e6ac046862b 100644 --- a/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap +++ b/crates/iota-protocol-config/src/snapshots/iota_protocol_config__test__version_10.snap @@ -26,7 +26,7 @@ feature_flags: congestion_control_min_free_execution_slot: true accept_passkey_in_multisig: true consensus_batched_block_sync: true - congested_objects_gas_price_feedback_mechanism: true + congestion_control_gas_price_feedback_mechanism: true max_tx_size_bytes: 131072 max_input_objects: 2048 max_size_written_objects: 5000000 diff --git a/iota-execution/latest/iota-adapter/src/execution_engine.rs b/iota-execution/latest/iota-adapter/src/execution_engine.rs index 44029a35b41..6386bfcfa87 100644 --- a/iota-execution/latest/iota-adapter/src/execution_engine.rs +++ b/iota-execution/latest/iota-adapter/src/execution_engine.rs @@ -335,7 +335,7 @@ mod checked { } else if let Some((cancelled_objects, reason)) = cancelled_objects { match reason { version if version.is_congested() => Err(ExecutionError::new( - if protocol_config.congested_objects_gas_price_feedback_mechanism() { + if protocol_config.congestion_control_gas_price_feedback_mechanism() { ExecutionErrorKind::ExecutionCancelledDueToSharedObjectCongestionV2 { congested_objects: CongestedObjects(cancelled_objects), suggested_gas_price: version @@ -343,7 +343,7 @@ mod checked { } } else { // WARN: do not remove this `else` branch even after - // `congested_objects_gas_price_feedback_mechanism` is enabled + // `congestion_control_gas_price_feedback_mechanism` is enabled // on the mainnet. It must be kept to be able to replay old // transaction data. ExecutionErrorKind::ExecutionCancelledDueToSharedObjectCongestion {