Skip to main content

sui_core/authority/
execution_time_estimator.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    collections::HashMap,
6    hash::{Hash, Hasher},
7    num::NonZeroUsize,
8    sync::{Arc, Weak},
9    time::{Duration, SystemTime},
10};
11
12use serde::{Deserialize, Serialize};
13
14use super::authority_per_epoch_store::AuthorityPerEpochStore;
15use super::weighted_moving_average::WeightedMovingAverage;
16use crate::consensus_adapter::SubmitToConsensus;
17use governor::{Quota, RateLimiter, clock::MonotonicClock};
18use itertools::Itertools;
19use lru::LruCache;
20#[cfg(not(msim))]
21use mysten_common::in_antithesis;
22use mysten_common::{assert_reachable, debug_fatal, in_test_configuration};
23use mysten_metrics::{monitored_scope, spawn_monitored_task};
24use rand::{Rng, SeedableRng, random, rngs, thread_rng};
25use simple_moving_average::{SMA, SingleSumSMA};
26use sui_config::node::ExecutionTimeObserverConfig;
27use sui_protocol_config::{ExecutionTimeEstimateParams, PerObjectCongestionControlMode};
28use sui_types::{
29    base_types::ObjectID,
30    committee::Committee,
31    error::SuiErrorKind,
32    execution::{ExecutionTimeObservationKey, ExecutionTiming},
33    messages_consensus::{AuthorityIndex, ConsensusTransaction, ExecutionTimeObservation},
34    transaction::{
35        Command, ProgrammableTransaction, StoredExecutionTimeObservations, TransactionData,
36        TransactionDataAPI, TransactionKind,
37    },
38};
39use tokio::{sync::mpsc, time::Instant};
40use tracing::{debug, info, trace, warn};
41
42// TODO: Move this into ExecutionTimeObserverConfig, if we switch to a moving average
43// implmentation without the window size in the type.
44const SMA_LOCAL_OBSERVATION_WINDOW_SIZE: usize = 20;
45const OBJECT_UTILIZATION_METRIC_HASH_MODULUS: u8 = 32;
46
47/// Determines whether to inject synthetic execution time in Antithesis environments.
48///
49/// This function checks two conditions:
50/// 1. Whether the code is running in an Antithesis environment
51/// 2. Whether injection is enabled via the `ANTITHESIS_ENABLE_EXECUTION_TIME_INJECTION` env var
52///    (enabled by default)
53#[cfg(not(msim))]
54fn antithesis_enable_injecting_synthetic_execution_time() -> bool {
55    use std::sync::OnceLock;
56    static ENABLE_INJECTION: OnceLock<bool> = OnceLock::new();
57    *ENABLE_INJECTION.get_or_init(|| {
58        if !in_antithesis() {
59            return false;
60        }
61
62        std::env::var("ANTITHESIS_ENABLE_EXECUTION_TIME_INJECTION")
63            .map(|v| v.to_lowercase() == "true" || v == "1")
64            .unwrap_or(true)
65    })
66}
67
68// Collects local execution time estimates to share via consensus.
69pub struct ExecutionTimeObserver {
70    epoch_store: Weak<AuthorityPerEpochStore>,
71    consensus_adapter: Box<dyn SubmitToConsensus>,
72
73    protocol_params: ExecutionTimeEstimateParams,
74    config: ExecutionTimeObserverConfig,
75
76    local_observations: LruCache<ExecutionTimeObservationKey, LocalObservations>,
77
78    // For each object, tracks the amount of time above our utilization target that we spent
79    // executing transactions. This is used to decide which observations should be shared
80    // via consensus.
81    object_utilization_tracker: LruCache<ObjectID, ObjectUtilization>,
82
83    // Sorted list of recently indebted objects, updated by consensus handler.
84    indebted_objects: Vec<ObjectID>,
85
86    sharing_rate_limiter: RateLimiter<
87        governor::state::NotKeyed,
88        governor::state::InMemoryState,
89        governor::clock::MonotonicClock,
90        governor::middleware::NoOpMiddleware<
91            <governor::clock::MonotonicClock as governor::clock::Clock>::Instant,
92        >,
93    >,
94
95    next_generation_number: u64,
96}
97
98#[derive(Debug, Clone)]
99pub struct LocalObservations {
100    moving_average: SingleSumSMA<Duration, u32, SMA_LOCAL_OBSERVATION_WINDOW_SIZE>,
101    weighted_moving_average: WeightedMovingAverage,
102    last_shared: Option<(Duration, Instant)>,
103    config: ExecutionTimeObserverConfig,
104}
105
106impl LocalObservations {
107    fn new(config: ExecutionTimeObserverConfig, default_duration: Duration) -> Self {
108        let window_size = config.weighted_moving_average_window_size();
109        Self {
110            moving_average: SingleSumSMA::from_zero(Duration::ZERO),
111            weighted_moving_average: WeightedMovingAverage::new(
112                default_duration.as_micros() as u64,
113                window_size,
114            ),
115            last_shared: None,
116            config,
117        }
118    }
119
120    fn add_sample(&mut self, duration: Duration, gas_price: u64) {
121        self.moving_average.add_sample(duration);
122        self.weighted_moving_average
123            .add_sample(duration.as_micros() as u64, gas_price);
124    }
125
126    fn get_average(&self) -> Duration {
127        if self.config.enable_gas_price_weighting() {
128            Duration::from_micros(self.weighted_moving_average.get_weighted_average())
129        } else {
130            self.moving_average.get_average()
131        }
132    }
133
134    fn diff_exceeds_threshold(
135        &self,
136        new_average: Duration,
137        threshold: f64,
138        min_interval: Duration,
139    ) -> bool {
140        let Some((last_shared, last_shared_timestamp)) = self.last_shared else {
141            // Diff threshold exceeded by default if we haven't shared anything yet.
142            return true;
143        };
144
145        if last_shared_timestamp.elapsed() < min_interval {
146            return false;
147        }
148
149        if threshold >= 0.0 {
150            // Positive threshold requires upward change.
151            new_average
152                .checked_sub(last_shared)
153                .is_some_and(|diff| diff > last_shared.mul_f64(threshold))
154        } else {
155            // Negative threshold requires downward change.
156            last_shared
157                .checked_sub(new_average)
158                .is_some_and(|diff| diff > last_shared.mul_f64(-threshold))
159        }
160    }
161}
162
163#[derive(Debug, Clone)]
164pub struct ObjectUtilization {
165    excess_execution_time: Duration,
166    last_measured: Option<Instant>,
167    was_overutilized: bool, // true if the object has ever had excess_execution_time
168}
169
170impl ObjectUtilization {
171    pub fn overutilized(&self, config: &ExecutionTimeObserverConfig) -> bool {
172        self.excess_execution_time > config.observation_sharing_object_utilization_threshold()
173    }
174}
175
176// Tracks local execution time observations and shares them via consensus.
177impl ExecutionTimeObserver {
178    pub fn spawn(
179        epoch_store: Arc<AuthorityPerEpochStore>,
180        consensus_adapter: Box<dyn SubmitToConsensus>,
181        config: ExecutionTimeObserverConfig,
182    ) {
183        let PerObjectCongestionControlMode::ExecutionTimeEstimate(protocol_params) = epoch_store
184            .protocol_config()
185            .per_object_congestion_control_mode()
186        else {
187            info!(
188                "ExecutionTimeObserver disabled because per-object congestion control mode is not ExecutionTimeEstimate"
189            );
190            return;
191        };
192
193        let (tx_local_execution_time, mut rx_local_execution_time) =
194            mpsc::channel(config.observation_channel_capacity().into());
195        let (tx_object_debts, mut rx_object_debts) =
196            mpsc::channel(config.object_debt_channel_capacity().into());
197        epoch_store.set_local_execution_time_channels(tx_local_execution_time, tx_object_debts);
198
199        // TODO: pre-populate local observations with stored data from prior epoch.
200        let mut observer = Self {
201            epoch_store: Arc::downgrade(&epoch_store),
202            consensus_adapter,
203            local_observations: LruCache::new(config.observation_cache_size()),
204            object_utilization_tracker: LruCache::new(config.object_utilization_cache_size()),
205            indebted_objects: Vec::new(),
206            sharing_rate_limiter: RateLimiter::direct_with_clock(
207                Quota::per_second(config.observation_sharing_rate_limit())
208                    .allow_burst(config.observation_sharing_burst_limit()),
209                &MonotonicClock,
210            ),
211            protocol_params,
212            config,
213            next_generation_number: SystemTime::now()
214                .duration_since(std::time::UNIX_EPOCH)
215                .expect("Sui did not exist prior to 1970")
216                .as_micros()
217                .try_into()
218                .expect("This build of sui is not supported in the year 500,000"),
219        };
220        spawn_monitored_task!(epoch_store.within_alive_epoch(async move {
221            loop {
222                tokio::select! {
223                    // TODO: add metrics for messages received.
224                    Some(object_debts) = rx_object_debts.recv() => {
225                        observer.update_indebted_objects(object_debts);
226                    }
227                    Some((tx, timings, total_duration, gas_price)) = rx_local_execution_time.recv() => {
228                        observer
229                            .record_local_observations(&tx, &timings, total_duration, gas_price);
230                    }
231                    else => { break }
232                }
233            }
234            info!("shutting down ExecutionTimeObserver");
235        }));
236    }
237
238    #[cfg(test)]
239    fn new_for_testing(
240        epoch_store: Arc<AuthorityPerEpochStore>,
241        consensus_adapter: Box<dyn SubmitToConsensus>,
242        observation_sharing_object_utilization_threshold: Duration,
243        enable_gas_price_weighting: bool,
244    ) -> Self {
245        let PerObjectCongestionControlMode::ExecutionTimeEstimate(protocol_params) = epoch_store
246            .protocol_config()
247            .per_object_congestion_control_mode()
248        else {
249            panic!(
250                "tried to construct test ExecutionTimeObserver when congestion control mode is not ExecutionTimeEstimate"
251            );
252        };
253        Self {
254            epoch_store: Arc::downgrade(&epoch_store),
255            consensus_adapter,
256            protocol_params,
257            config: ExecutionTimeObserverConfig {
258                observation_sharing_object_utilization_threshold: Some(
259                    observation_sharing_object_utilization_threshold,
260                ),
261                enable_gas_price_weighting: Some(enable_gas_price_weighting),
262                ..ExecutionTimeObserverConfig::default()
263            },
264            local_observations: LruCache::new(NonZeroUsize::new(10000).unwrap()),
265            object_utilization_tracker: LruCache::new(NonZeroUsize::new(50000).unwrap()),
266            indebted_objects: Vec::new(),
267            sharing_rate_limiter: RateLimiter::direct_with_clock(
268                Quota::per_hour(std::num::NonZeroU32::MAX),
269                &MonotonicClock,
270            ),
271            next_generation_number: SystemTime::now()
272                .duration_since(std::time::UNIX_EPOCH)
273                .expect("Sui did not exist prior to 1970")
274                .as_micros()
275                .try_into()
276                .expect("This build of sui is not supported in the year 500,000"),
277        }
278    }
279
280    // Used by execution to report observed per-entry-point execution times to the estimator.
281    // Updates moving averages and submits observation to consensus if local observation differs
282    // from consensus median.
283    // TODO: Consider more detailed heuristic to account for overhead outside of commands.
284    fn record_local_observations(
285        &mut self,
286        tx: &ProgrammableTransaction,
287        timings: &[ExecutionTiming],
288        total_duration: Duration,
289        gas_price: u64,
290    ) {
291        let _scope = monitored_scope("ExecutionTimeObserver::record_local_observations");
292
293        // Simulate timing in test contexts to trigger congestion control.
294        #[cfg(msim)]
295        let should_inject = self.config.inject_synthetic_execution_time();
296        #[cfg(not(msim))]
297        let should_inject = antithesis_enable_injecting_synthetic_execution_time();
298
299        if should_inject {
300            let (generated_timings, generated_duration) = self.generate_test_timings(tx, timings);
301            self.record_local_observations_timing(
302                tx,
303                &generated_timings,
304                generated_duration,
305                gas_price,
306            )
307        } else {
308            self.record_local_observations_timing(tx, timings, total_duration, gas_price)
309        }
310    }
311
312    fn record_local_observations_timing(
313        &mut self,
314        tx: &ProgrammableTransaction,
315        timings: &[ExecutionTiming],
316        total_duration: Duration,
317        gas_price: u64,
318    ) {
319        let Some(epoch_store) = self.epoch_store.upgrade() else {
320            debug!("epoch is ending, dropping execution time observation");
321            return;
322        };
323        let timings = if timings.len() > tx.commands.len() {
324            warn!(
325                executed_commands = timings.len(),
326                original_commands = tx.commands.len(),
327                "execution produced more timings than the original PTB commands; using the trailing timings for local execution-time observations"
328            );
329            &timings[timings.len() - tx.commands.len()..]
330        } else {
331            timings
332        };
333
334        let mut uses_indebted_object = false;
335
336        // Update the accumulated excess execution time for shared object
337        // used for exclusive access in this transaction, and determine the max overage.
338        let max_excess_per_object_execution_time = tx
339            .shared_input_objects()
340            .filter_map(|obj| obj.is_accessed_exclusively().then_some(obj.id))
341            .map(|id| {
342                // Mark if any object used in the tx is indebted.
343                if !uses_indebted_object && self.indebted_objects.binary_search(&id).is_ok() {
344                    uses_indebted_object = true;
345                }
346
347                // For each object:
348                // - add the execution time of the current transaction to the tracker
349                // - subtract the maximum amount of time available for execution according
350                //   to our utilization target since the last report was received
351                //   (clamping to zero)
352                //
353                // What remains is the amount of excess time spent executing transactions on
354                // the object above the intended limit. If this value is greater than zero,
355                // it means the object is overutilized.
356                let now = Instant::now();
357                let utilization =
358                    self.object_utilization_tracker
359                        .get_or_insert_mut(id, || ObjectUtilization {
360                            excess_execution_time: Duration::ZERO,
361                            last_measured: None,
362                            was_overutilized: false,
363                        });
364                let overutilized_at_start = utilization.overutilized(&self.config);
365                utilization.excess_execution_time += total_duration;
366                utilization.excess_execution_time =
367                    utilization.excess_execution_time.saturating_sub(
368                        utilization
369                            .last_measured
370                            .map(|last_measured| {
371                                now.duration_since(last_measured)
372                                    .mul_f64(self.protocol_params.target_utilization as f64 / 100.0)
373                            })
374                            .unwrap_or(Duration::MAX),
375                    );
376                utilization.last_measured = Some(now);
377                if utilization.overutilized(&self.config) {
378                    utilization.was_overutilized = true;
379                }
380
381                // Update overutilized objects metrics.
382                if !overutilized_at_start && utilization.overutilized(&self.config) {
383                    trace!("object {id:?} is overutilized");
384                    epoch_store
385                        .metrics
386                        .epoch_execution_time_observer_overutilized_objects
387                        .inc();
388                } else if overutilized_at_start && !utilization.overutilized(&self.config) {
389                    epoch_store
390                        .metrics
391                        .epoch_execution_time_observer_overutilized_objects
392                        .dec();
393                }
394                if utilization.was_overutilized {
395                    let key = if self.config.report_object_utilization_metric_with_full_id() {
396                        id.to_string()
397                    } else {
398                        let key_lsb = id.into_bytes()[ObjectID::LENGTH - 1];
399                        let hash = key_lsb % OBJECT_UTILIZATION_METRIC_HASH_MODULUS;
400                        format!("{:x}", hash)
401                    };
402
403                    epoch_store
404                        .metrics
405                        .epoch_execution_time_observer_object_utilization
406                        .with_label_values(&[key.as_str()])
407                        .inc_by(total_duration.as_secs_f64());
408                }
409
410                utilization.excess_execution_time
411            })
412            .max()
413            .unwrap_or(Duration::ZERO);
414        epoch_store
415            .metrics
416            .epoch_execution_time_observer_utilization_cache_size
417            .set(self.object_utilization_tracker.len() as i64);
418
419        let total_command_duration: Duration = timings.iter().map(|t| t.duration()).sum();
420        let extra_overhead = total_duration - total_command_duration;
421
422        let mut to_share = Vec::with_capacity(tx.commands.len());
423        for (i, timing) in timings.iter().enumerate() {
424            let command = &tx.commands[i];
425
426            // Special-case handling for Publish command: only use hard-coded default estimate.
427            if matches!(command, Command::Publish(_, _)) {
428                continue;
429            }
430
431            // TODO: Consider using failure/success information in computing estimates.
432            let mut command_duration = timing.duration();
433
434            // Distribute overhead proportionally to each command's measured duration.
435            let overhead_factor = if total_command_duration > Duration::ZERO {
436                command_duration.as_secs_f64() / total_command_duration.as_secs_f64()
437            } else {
438                // divisor here must be >0 or this loop would not be running at all
439                1.0 / (tx.commands.len() as f64)
440            };
441            command_duration += extra_overhead.mul_f64(overhead_factor);
442
443            // For native commands, adjust duration by length of command's inputs/outputs.
444            // This is sort of arbitrary, but hopefully works okay as a heuristic.
445            command_duration = command_duration.div_f64(command_length(command).get() as f64);
446
447            // Update gas-weighted moving-average observation for the command.
448            let key = ExecutionTimeObservationKey::from_command(command);
449            let local_observation = self.local_observations.get_or_insert_mut(key.clone(), || {
450                LocalObservations::new(self.config.clone(), Duration::ZERO)
451            });
452            local_observation.add_sample(command_duration, gas_price);
453
454            // Send a new observation through consensus if:
455            // - our current moving average differs too much from the last one we shared, and
456            // - the tx has at least one mutable shared object with utilization that's too high
457            // TODO: Consider only sharing observations that disagree with consensus estimate.
458            let new_average = local_observation.get_average();
459            let mut should_share = false;
460
461            // Share upward adjustments if an object is overutilized.
462            if max_excess_per_object_execution_time
463                >= self
464                    .config
465                    .observation_sharing_object_utilization_threshold()
466                && local_observation.diff_exceeds_threshold(
467                    new_average,
468                    self.config.observation_sharing_diff_threshold(),
469                    self.config.observation_sharing_min_interval(),
470                )
471            {
472                should_share = true;
473                epoch_store
474                    .metrics
475                    .epoch_execution_time_observations_sharing_reason
476                    .with_label_values(&["utilization"])
477                    .inc();
478            };
479
480            // Share downward adjustments if an object is indebted.
481            if uses_indebted_object
482                && local_observation.diff_exceeds_threshold(
483                    new_average,
484                    -self.config.observation_sharing_diff_threshold(),
485                    self.config.observation_sharing_min_interval(),
486                )
487            {
488                should_share = true;
489                epoch_store
490                    .metrics
491                    .epoch_execution_time_observations_sharing_reason
492                    .with_label_values(&["indebted"])
493                    .inc();
494            }
495
496            if should_share {
497                debug!("sharing new execution time observation for {key:?}: {new_average:?}");
498                to_share.push((key, new_average));
499                local_observation.last_shared = Some((new_average, Instant::now()));
500            }
501        }
502
503        // Share new observations.
504        self.share_observations(to_share);
505    }
506
507    fn generate_test_timings(
508        &self,
509        tx: &ProgrammableTransaction,
510        timings: &[ExecutionTiming],
511    ) -> (Vec<ExecutionTiming>, Duration) {
512        #[allow(clippy::disallowed_methods)]
513        let generated_timings: Vec<_> = tx
514            .commands
515            .iter()
516            // TODO: migrate to zip_debug_eq once PR #26125 fixes the timings/commands length mismatch
517            .zip(timings.iter())
518            .map(|(command, timing)| {
519                let key = ExecutionTimeObservationKey::from_command(command);
520                let duration = self.get_test_duration(&key);
521                if timing.is_abort() {
522                    ExecutionTiming::Abort(duration)
523                } else {
524                    ExecutionTiming::Success(duration)
525                }
526            })
527            .collect();
528
529        let total_duration = generated_timings
530            .iter()
531            .map(|t| t.duration())
532            .sum::<Duration>()
533            + thread_rng().gen_range(Duration::from_millis(10)..Duration::from_millis(50));
534
535        (generated_timings, total_duration)
536    }
537
538    fn get_test_duration(&self, key: &ExecutionTimeObservationKey) -> Duration {
539        #[cfg(msim)]
540        let should_inject = self.config.inject_synthetic_execution_time();
541        #[cfg(not(msim))]
542        let should_inject = false;
543
544        if !in_test_configuration() && !should_inject {
545            panic!("get_test_duration called in non-test configuration");
546        }
547
548        static PER_TEST_SEED: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
549
550        let mut hasher = std::collections::hash_map::DefaultHasher::new();
551
552        let checkpoint_digest_used = self
553            .epoch_store
554            .upgrade()
555            .and_then(|store| {
556                store
557                    .get_lowest_non_genesis_checkpoint_summary()
558                    .ok()
559                    .flatten()
560            })
561            .map(|summary| summary.content_digest.hash(&mut hasher))
562            .is_some();
563
564        if !checkpoint_digest_used {
565            PER_TEST_SEED.get_or_init(random::<u64>).hash(&mut hasher);
566        }
567
568        key.hash(&mut hasher);
569        let mut rng = rngs::StdRng::seed_from_u64(hasher.finish());
570        rng.gen_range(Duration::from_millis(100)..Duration::from_millis(600))
571    }
572
573    fn share_observations(&mut self, to_share: Vec<(ExecutionTimeObservationKey, Duration)>) {
574        if to_share.is_empty() {
575            return;
576        }
577        let Some(epoch_store) = self.epoch_store.upgrade() else {
578            debug!("epoch is ending, dropping execution time observation");
579            return;
580        };
581
582        let num_observations = to_share.len() as u64;
583
584        // Enforce global observation-sharing rate limit.
585        if let Err(e) = self.sharing_rate_limiter.check() {
586            epoch_store
587                .metrics
588                .epoch_execution_time_observations_dropped
589                .with_label_values(&["global_rate_limit"])
590                .inc_by(num_observations);
591            debug!("rate limit exceeded, dropping execution time observation; {e:?}");
592            return;
593        }
594
595        let epoch_store = epoch_store.clone();
596        let transaction = ConsensusTransaction::new_execution_time_observation(
597            ExecutionTimeObservation::new(epoch_store.name, self.next_generation_number, to_share),
598        );
599        self.next_generation_number += 1;
600
601        if let Err(e) = self.consensus_adapter.submit_best_effort(
602            &transaction,
603            &epoch_store,
604            Duration::from_secs(5),
605        ) {
606            if !matches!(e.as_inner(), SuiErrorKind::EpochEnded(_)) {
607                epoch_store
608                    .metrics
609                    .epoch_execution_time_observations_dropped
610                    .with_label_values(&["submit_to_consensus"])
611                    .inc_by(num_observations);
612                warn!("failed to submit execution time observation: {e:?}");
613            }
614        } else {
615            // Note: it is not actually guaranteed that the observation has been submitted at this point,
616            // but that is also not true with ConsensusAdapter::submit_to_consensus. The only way to know
617            // for sure is to observe that the message is processed by consensus handler.
618            assert_reachable!("successfully shares execution time observations");
619            epoch_store
620                .metrics
621                .epoch_execution_time_observations_shared
622                .inc_by(num_observations);
623        }
624    }
625
626    fn update_indebted_objects(&mut self, mut object_debts: Vec<ObjectID>) {
627        let _scope = monitored_scope("ExecutionTimeObserver::update_indebted_objects");
628
629        let Some(epoch_store) = self.epoch_store.upgrade() else {
630            debug!("epoch is ending, dropping indebted object update");
631            return;
632        };
633
634        object_debts.sort_unstable();
635        object_debts.dedup();
636        self.indebted_objects = object_debts;
637        epoch_store
638            .metrics
639            .epoch_execution_time_observer_indebted_objects
640            .set(self.indebted_objects.len() as i64);
641    }
642}
643
644// Key used to save StoredExecutionTimeObservations in the Sui system state object's
645// `extra_fields` Bag.
646pub const EXTRA_FIELD_EXECUTION_TIME_ESTIMATES_KEY: u64 = 0;
647
648// Key used to save the chunk count for chunked execution time observations
649pub const EXTRA_FIELD_EXECUTION_TIME_ESTIMATES_CHUNK_COUNT_KEY: u64 = 1;
650
651// Tracks global execution time observations provided by validators from consensus
652// and computes deterministic per-command estimates for use in congestion control.
653pub struct ExecutionTimeEstimator {
654    committee: Arc<Committee>,
655    protocol_params: ExecutionTimeEstimateParams,
656
657    consensus_observations: HashMap<ExecutionTimeObservationKey, ConsensusObservations>,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct ConsensusObservations {
662    observations: Vec<(u64 /* generation */, Option<Duration>)>, // keyed by authority index
663    stake_weighted_median: Option<Duration>,                     // cached value
664}
665
666impl ConsensusObservations {
667    fn update_stake_weighted_median(
668        &mut self,
669        committee: &Committee,
670        config: &ExecutionTimeEstimateParams,
671    ) {
672        let mut stake_with_observations = 0;
673        let sorted_observations: Vec<_> = self
674            .observations
675            .iter()
676            .enumerate()
677            .filter_map(|(i, (_, duration))| {
678                duration.map(|duration| {
679                    let authority_index: AuthorityIndex = i.try_into().unwrap();
680                    stake_with_observations += committee.stake_by_index(authority_index).unwrap();
681                    (duration, authority_index)
682                })
683            })
684            .sorted()
685            .collect();
686
687        // Don't use observations until we have received enough.
688        if stake_with_observations < config.stake_weighted_median_threshold {
689            self.stake_weighted_median = None;
690            return;
691        }
692
693        // Compute stake-weighted median.
694        let median_stake = stake_with_observations / 2;
695        let mut running_stake = 0;
696        for (duration, authority_index) in sorted_observations {
697            running_stake += committee.stake_by_index(authority_index).unwrap();
698            if running_stake > median_stake {
699                self.stake_weighted_median = Some(duration);
700                break;
701            }
702        }
703    }
704}
705
706impl ExecutionTimeEstimator {
707    pub fn new(
708        committee: Arc<Committee>,
709        protocol_params: ExecutionTimeEstimateParams,
710        initial_observations: impl Iterator<
711            Item = (
712                AuthorityIndex,
713                Option<u64>,
714                ExecutionTimeObservationKey,
715                Duration,
716            ),
717        >,
718    ) -> Self {
719        let mut estimator = Self {
720            committee,
721            protocol_params,
722            consensus_observations: HashMap::new(),
723        };
724        for (source, generation, key, duration) in initial_observations {
725            estimator.process_observation_from_consensus(
726                source,
727                generation,
728                key.to_owned(),
729                duration,
730                true,
731            );
732        }
733        for observation in estimator.consensus_observations.values_mut() {
734            observation
735                .update_stake_weighted_median(&estimator.committee, &estimator.protocol_params);
736        }
737        estimator
738    }
739
740    #[cfg(test)]
741    pub fn new_for_testing() -> Self {
742        let (committee, _) = Committee::new_simple_test_committee_of_size(1);
743        Self {
744            committee: Arc::new(committee),
745            protocol_params: ExecutionTimeEstimateParams {
746                target_utilization: 100,
747                max_estimate_us: u64::MAX,
748                ..ExecutionTimeEstimateParams::default()
749            },
750            consensus_observations: HashMap::new(),
751        }
752    }
753
754    pub fn process_observations_from_consensus(
755        &mut self,
756        source: AuthorityIndex,
757        generation: Option<u64>,
758        observations: &[(ExecutionTimeObservationKey, Duration)],
759    ) {
760        for (key, duration) in observations {
761            self.process_observation_from_consensus(
762                source,
763                generation,
764                key.to_owned(),
765                *duration,
766                false,
767            );
768        }
769    }
770
771    fn process_observation_from_consensus(
772        &mut self,
773        source: AuthorityIndex,
774        generation: Option<u64>,
775        observation_key: ExecutionTimeObservationKey,
776        duration: Duration,
777        skip_update: bool,
778    ) {
779        if matches!(observation_key, ExecutionTimeObservationKey::Publish) {
780            // Special-case handling for Publish command: only use hard-coded default estimate.
781            warn!(
782                "dropping Publish observation received from possibly-Byzanitine authority {source}"
783            );
784            return;
785        }
786
787        assert_reachable!("receives some valid execution time observations");
788
789        let observations = self
790            .consensus_observations
791            .entry(observation_key)
792            .or_insert_with(|| {
793                let len = self.committee.num_members();
794                let mut empty_observations = Vec::with_capacity(len);
795                empty_observations.resize(len, (0, None));
796                ConsensusObservations {
797                    observations: empty_observations,
798                    stake_weighted_median: if self
799                        .protocol_params
800                        .default_none_duration_for_new_keys
801                    {
802                        None
803                    } else {
804                        Some(Duration::ZERO)
805                    },
806                }
807            });
808
809        let (obs_generation, obs_duration) =
810            &mut observations.observations[TryInto::<usize>::try_into(source).unwrap()];
811        if generation.is_some_and(|generation| *obs_generation >= generation) {
812            // Ignore outdated observation.
813            return;
814        }
815        *obs_generation = generation.unwrap_or(0);
816        *obs_duration = Some(duration);
817        if !skip_update {
818            observations.update_stake_weighted_median(&self.committee, &self.protocol_params);
819        }
820    }
821
822    pub fn get_estimate(&self, tx: &TransactionData) -> Duration {
823        let TransactionKind::ProgrammableTransaction(tx) = tx.kind() else {
824            debug_fatal!("get_estimate called on non-ProgrammableTransaction");
825            return Duration::ZERO;
826        };
827        tx.commands
828            .iter()
829            .map(|command| {
830                let key = ExecutionTimeObservationKey::from_command(command);
831                self.consensus_observations
832                    .get(&key)
833                    .and_then(|obs| obs.stake_weighted_median)
834                    .unwrap_or_else(|| key.default_duration())
835                    // For native commands, adjust duration by length of command's inputs/outputs.
836                    // This is sort of arbitrary, but hopefully works okay as a heuristic.
837                    .mul_f64(command_length(command).get() as f64)
838            })
839            .sum::<Duration>()
840            .min(Duration::from_micros(self.protocol_params.max_estimate_us))
841    }
842
843    pub fn take_observations(&mut self) -> StoredExecutionTimeObservations {
844        StoredExecutionTimeObservations::V1(
845            self.consensus_observations
846                .drain()
847                .map(|(key, observations)| {
848                    let observations = observations
849                        .observations
850                        .into_iter()
851                        .enumerate()
852                        .filter_map(|(idx, (_, duration))| {
853                            duration.map(|d| {
854                                (
855                                    self.committee
856                                        .authority_by_index(idx.try_into().unwrap())
857                                        .cloned()
858                                        .unwrap(),
859                                    d,
860                                )
861                            })
862                        })
863                        .collect();
864                    (key, observations)
865                })
866                .collect(),
867        )
868    }
869
870    pub fn get_observations(&self) -> Vec<(ExecutionTimeObservationKey, ConsensusObservations)> {
871        self.consensus_observations
872            .iter()
873            .map(|(key, observations)| (key.clone(), observations.clone()))
874            .collect()
875    }
876}
877
878fn command_length(command: &Command) -> NonZeroUsize {
879    // Commands with variable-length inputs/outputs are reported as +1
880    // to account for fixed overhead and prevent divide-by-zero.
881    NonZeroUsize::new(match command {
882        Command::MoveCall(_) => 1,
883        Command::TransferObjects(src, _) => src.len() + 1,
884        Command::SplitCoins(_, amts) => amts.len() + 1,
885        Command::MergeCoins(_, src) => src.len() + 1,
886        Command::Publish(_, _) => 1,
887        Command::MakeMoveVec(_, src) => src.len() + 1,
888        Command::Upgrade(_, _, _, _) => 1,
889    })
890    .unwrap()
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use crate::authority::test_authority_builder::TestAuthorityBuilder;
897    use crate::checkpoints::CheckpointStore;
898    use crate::consensus_adapter::{
899        ConsensusAdapter, ConsensusAdapterMetrics, MockConsensusClient,
900    };
901    use sui_protocol_config::ProtocolConfig;
902    use sui_types::base_types::{ObjectID, SequenceNumber, SuiAddress};
903    use sui_types::transaction::{
904        Argument, CallArg, ObjectArg, ProgrammableMoveCall, SharedObjectMutability,
905    };
906    use {
907        rand::{Rng, SeedableRng},
908        sui_protocol_config::ProtocolVersion,
909        sui_types::supported_protocol_versions::Chain,
910    };
911
912    #[tokio::test]
913    async fn test_record_local_observations() {
914        telemetry_subscribers::init_for_testing();
915
916        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
917            config.set_per_object_congestion_control_mode_for_testing(
918                PerObjectCongestionControlMode::ExecutionTimeEstimate(
919                    ExecutionTimeEstimateParams {
920                        target_utilization: 100,
921                        allowed_txn_cost_overage_burst_limit_us: 0,
922                        randomness_scalar: 100,
923                        max_estimate_us: u64::MAX,
924                        stored_observations_num_included_checkpoints: 10,
925                        stored_observations_limit: u64::MAX,
926                        stake_weighted_median_threshold: 0,
927                        default_none_duration_for_new_keys: true,
928                        observations_chunk_size: Some(18),
929                    },
930                ),
931            );
932            config
933        });
934
935        let mock_consensus_client = MockConsensusClient::new();
936        let authority = TestAuthorityBuilder::new().build().await;
937        let epoch_store = authority.epoch_store_for_testing();
938        let consensus_adapter = Arc::new(ConsensusAdapter::new(
939            Arc::new(mock_consensus_client),
940            CheckpointStore::new_for_tests(),
941            authority.name,
942            100_000,
943            100_000,
944            ConsensusAdapterMetrics::new_test(),
945            Arc::new(tokio::sync::Notify::new()),
946        ));
947        let mut observer = ExecutionTimeObserver::new_for_testing(
948            epoch_store.clone(),
949            Box::new(consensus_adapter.clone()),
950            Duration::ZERO, // disable object utilization thresholds for this test
951            false,          // disable gas price weighting for this test
952        );
953
954        // Create a simple PTB with one move call
955        let package = ObjectID::random();
956        let module = "test_module".to_string();
957        let function = "test_function".to_string();
958        let ptb = ProgrammableTransaction {
959            inputs: vec![],
960            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
961                package,
962                module: module.clone(),
963                function: function.clone(),
964                type_arguments: vec![],
965                arguments: vec![],
966            }))],
967        };
968
969        // Record an observation
970        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
971        let total_duration = Duration::from_millis(110);
972        observer.record_local_observations(&ptb, &timings, total_duration, 1);
973
974        let key = ExecutionTimeObservationKey::MoveEntryPoint {
975            package,
976            module: module.clone(),
977            function: function.clone(),
978            type_arguments: vec![],
979        };
980
981        // Check that local observation was recorded and shared
982        let local_obs = observer.local_observations.get(&key).unwrap();
983        assert_eq!(
984            local_obs.get_average(),
985            // 10ms overhead should be entirely apportioned to the one command in the PTB
986            Duration::from_millis(110)
987        );
988        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
989
990        // Record another observation
991        let timings = vec![ExecutionTiming::Success(Duration::from_millis(110))];
992        let total_duration = Duration::from_millis(120);
993        observer.record_local_observations(&ptb, &timings, total_duration, 1);
994
995        // Check that moving average was updated
996        let local_obs = observer.local_observations.get(&key).unwrap();
997        assert_eq!(
998            local_obs.get_average(),
999            // average of 110ms and 120ms observations
1000            Duration::from_millis(115)
1001        );
1002        // new 115ms average should not be shared; it's <5% different from 110ms
1003        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1004
1005        // Record another observation
1006        let timings = vec![ExecutionTiming::Success(Duration::from_millis(120))];
1007        let total_duration = Duration::from_millis(130);
1008        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1009
1010        // Check that moving average was updated
1011        let local_obs = observer.local_observations.get(&key).unwrap();
1012        assert_eq!(
1013            local_obs.get_average(),
1014            // average of [110ms, 120ms, 130ms]
1015            Duration::from_millis(120)
1016        );
1017        // new 120ms average should not be shared; it's >5% different from 110ms,
1018        // but not enough time has passed
1019        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1020
1021        // Manually update last-shared time to long ago
1022        observer
1023            .local_observations
1024            .get_mut(&key)
1025            .unwrap()
1026            .last_shared = Some((
1027            Duration::from_millis(110),
1028            Instant::now() - Duration::from_secs(60),
1029        ));
1030
1031        // Record last observation
1032        let timings = vec![ExecutionTiming::Success(Duration::from_millis(120))];
1033        let total_duration = Duration::from_millis(160);
1034        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1035
1036        // Verify that moving average is the same and a new observation was shared, as
1037        // enough time has now elapsed
1038        let local_obs = observer.local_observations.get(&key).unwrap();
1039        assert_eq!(
1040            local_obs.get_average(),
1041            // average of [110ms, 120ms, 130ms, 160ms]
1042            Duration::from_millis(130)
1043        );
1044        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(130));
1045    }
1046
1047    #[tokio::test]
1048    async fn test_record_local_observations_with_gas_price_weighting() {
1049        telemetry_subscribers::init_for_testing();
1050
1051        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1052            config.set_per_object_congestion_control_mode_for_testing(
1053                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1054                    ExecutionTimeEstimateParams {
1055                        target_utilization: 100,
1056                        allowed_txn_cost_overage_burst_limit_us: 0,
1057                        randomness_scalar: 100,
1058                        max_estimate_us: u64::MAX,
1059                        stored_observations_num_included_checkpoints: 10,
1060                        stored_observations_limit: u64::MAX,
1061                        stake_weighted_median_threshold: 0,
1062                        default_none_duration_for_new_keys: true,
1063                        observations_chunk_size: Some(18),
1064                    },
1065                ),
1066            );
1067            config
1068        });
1069
1070        let mock_consensus_client = MockConsensusClient::new();
1071        let authority = TestAuthorityBuilder::new().build().await;
1072        let epoch_store = authority.epoch_store_for_testing();
1073        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1074            Arc::new(mock_consensus_client),
1075            CheckpointStore::new_for_tests(),
1076            authority.name,
1077            100_000,
1078            100_000,
1079            ConsensusAdapterMetrics::new_test(),
1080            Arc::new(tokio::sync::Notify::new()),
1081        ));
1082        let mut observer = ExecutionTimeObserver::new_for_testing(
1083            epoch_store.clone(),
1084            Box::new(consensus_adapter.clone()),
1085            Duration::ZERO, // disable object utilization thresholds for this test
1086            true,           // enable gas price weighting for this test
1087        );
1088
1089        // Create a simple PTB with one move call
1090        let package = ObjectID::random();
1091        let module = "test_module".to_string();
1092        let function = "test_function".to_string();
1093        let ptb = ProgrammableTransaction {
1094            inputs: vec![],
1095            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1096                package,
1097                module: module.clone(),
1098                function: function.clone(),
1099                type_arguments: vec![],
1100                arguments: vec![],
1101            }))],
1102        };
1103
1104        // Record an observation
1105        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
1106        let total_duration = Duration::from_millis(110);
1107        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1108
1109        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1110            package,
1111            module: module.clone(),
1112            function: function.clone(),
1113            type_arguments: vec![],
1114        };
1115
1116        // Check that local observation was recorded and shared
1117        let local_obs = observer.local_observations.get(&key).unwrap();
1118        assert_eq!(
1119            local_obs.get_average(),
1120            // 10ms overhead should be entirely apportioned to the one command in the PTB
1121            Duration::from_millis(110)
1122        );
1123        assert_eq!(local_obs.last_shared.unwrap().0, Duration::from_millis(110));
1124
1125        // Record another observation
1126        let timings = vec![ExecutionTiming::Success(Duration::from_millis(110))];
1127        let total_duration = Duration::from_millis(120);
1128        observer.record_local_observations(&ptb, &timings, total_duration, 2);
1129
1130        // Check that weighted moving average was updated
1131        let local_obs = observer.local_observations.get(&key).unwrap();
1132        assert_eq!(
1133            local_obs.get_average(),
1134            // Our local observation averages are weighted by gas price:
1135            // 110ms * 1 + 110ms * 2 / (1 + 2) = 116.666ms
1136            Duration::from_micros(116_666)
1137        );
1138    }
1139
1140    #[tokio::test]
1141    async fn test_record_local_observations_with_multiple_commands() {
1142        telemetry_subscribers::init_for_testing();
1143
1144        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1145            config.set_per_object_congestion_control_mode_for_testing(
1146                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1147                    ExecutionTimeEstimateParams {
1148                        target_utilization: 100,
1149                        allowed_txn_cost_overage_burst_limit_us: 0,
1150                        randomness_scalar: 0,
1151                        max_estimate_us: u64::MAX,
1152                        stored_observations_num_included_checkpoints: 10,
1153                        stored_observations_limit: u64::MAX,
1154                        stake_weighted_median_threshold: 0,
1155                        default_none_duration_for_new_keys: true,
1156                        observations_chunk_size: Some(18),
1157                    },
1158                ),
1159            );
1160            config
1161        });
1162
1163        let mock_consensus_client = MockConsensusClient::new();
1164        let authority = TestAuthorityBuilder::new().build().await;
1165        let epoch_store = authority.epoch_store_for_testing();
1166        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1167            Arc::new(mock_consensus_client),
1168            CheckpointStore::new_for_tests(),
1169            authority.name,
1170            100_000,
1171            100_000,
1172            ConsensusAdapterMetrics::new_test(),
1173            Arc::new(tokio::sync::Notify::new()),
1174        ));
1175        let mut observer = ExecutionTimeObserver::new_for_testing(
1176            epoch_store.clone(),
1177            Box::new(consensus_adapter.clone()),
1178            Duration::ZERO, // disable object utilization thresholds for this test
1179            false,          // disable gas price weighting for this test
1180        );
1181
1182        // Create a PTB with multiple commands.
1183        let package = ObjectID::random();
1184        let module = "test_module".to_string();
1185        let function = "test_function".to_string();
1186        let ptb = ProgrammableTransaction {
1187            inputs: vec![],
1188            commands: vec![
1189                Command::MoveCall(Box::new(ProgrammableMoveCall {
1190                    package,
1191                    module: module.clone(),
1192                    function: function.clone(),
1193                    type_arguments: vec![],
1194                    arguments: vec![],
1195                })),
1196                Command::TransferObjects(
1197                    // Inputs don't exist above, but doesn't matter for this test.
1198                    vec![Argument::Input(1), Argument::Input(2)],
1199                    Argument::Input(0),
1200                ),
1201            ],
1202        };
1203        let timings = vec![
1204            ExecutionTiming::Success(Duration::from_millis(100)),
1205            ExecutionTiming::Success(Duration::from_millis(50)),
1206        ];
1207        let total_duration = Duration::from_millis(180);
1208        observer.record_local_observations(&ptb, &timings, total_duration, 1);
1209
1210        // Check that both commands were recorded
1211        let move_key = ExecutionTimeObservationKey::MoveEntryPoint {
1212            package,
1213            module: module.clone(),
1214            function: function.clone(),
1215            type_arguments: vec![],
1216        };
1217        let move_obs = observer.local_observations.get(&move_key).unwrap();
1218        assert_eq!(
1219            move_obs.get_average(),
1220            // 100/150 == 2/3 of 30ms overhead distributed to Move command
1221            Duration::from_millis(120)
1222        );
1223
1224        let transfer_obs = observer
1225            .local_observations
1226            .get(&ExecutionTimeObservationKey::TransferObjects)
1227            .unwrap();
1228        assert_eq!(
1229            transfer_obs.get_average(),
1230            // 50ms time before adjustments
1231            // 50/150 == 1/3 of 30ms overhead distributed to object xfer
1232            // 60ms adjusetd time / 3 command length == 20ms
1233            Duration::from_millis(20)
1234        );
1235    }
1236
1237    #[tokio::test]
1238    async fn test_record_local_observations_with_object_utilization_threshold() {
1239        telemetry_subscribers::init_for_testing();
1240
1241        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1242            config.set_per_object_congestion_control_mode_for_testing(
1243                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1244                    ExecutionTimeEstimateParams {
1245                        target_utilization: 100,
1246                        allowed_txn_cost_overage_burst_limit_us: 0,
1247                        randomness_scalar: 0,
1248                        max_estimate_us: u64::MAX,
1249                        stored_observations_num_included_checkpoints: 10,
1250                        stored_observations_limit: u64::MAX,
1251                        stake_weighted_median_threshold: 0,
1252                        default_none_duration_for_new_keys: true,
1253                        observations_chunk_size: Some(18),
1254                    },
1255                ),
1256            );
1257            config
1258        });
1259
1260        let mock_consensus_client = MockConsensusClient::new();
1261        let authority = TestAuthorityBuilder::new().build().await;
1262        let epoch_store = authority.epoch_store_for_testing();
1263        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1264            Arc::new(mock_consensus_client),
1265            CheckpointStore::new_for_tests(),
1266            authority.name,
1267            100_000,
1268            100_000,
1269            ConsensusAdapterMetrics::new_test(),
1270            Arc::new(tokio::sync::Notify::new()),
1271        ));
1272        let mut observer = ExecutionTimeObserver::new_for_testing(
1273            epoch_store.clone(),
1274            Box::new(consensus_adapter.clone()),
1275            Duration::from_millis(500), // only share observations with excess utilization >= 500ms
1276            false,                      // disable gas price weighting for this test
1277        );
1278
1279        // Create a simple PTB with one move call and one mutable shared input
1280        let package = ObjectID::random();
1281        let module = "test_module".to_string();
1282        let function = "test_function".to_string();
1283        let shared_object_id = ObjectID::random();
1284        let ptb = ProgrammableTransaction {
1285            inputs: vec![CallArg::Object(ObjectArg::SharedObject {
1286                id: shared_object_id,
1287                initial_shared_version: SequenceNumber::new(),
1288                mutability: SharedObjectMutability::Mutable,
1289            })],
1290            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1291                package,
1292                module: module.clone(),
1293                function: function.clone(),
1294                type_arguments: vec![],
1295                arguments: vec![],
1296            }))],
1297        };
1298        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1299            package,
1300            module: module.clone(),
1301            function: function.clone(),
1302            type_arguments: vec![],
1303        };
1304
1305        tokio::time::pause();
1306
1307        // First observation - should not share due to low utilization
1308        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1309        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1310        assert!(
1311            observer
1312                .local_observations
1313                .get(&key)
1314                .unwrap()
1315                .last_shared
1316                .is_none()
1317        );
1318
1319        // Second observation - no time has passed, so now utilization is high; should share upward change
1320        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1321        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1322        assert_eq!(
1323            observer
1324                .local_observations
1325                .get(&key)
1326                .unwrap()
1327                .last_shared
1328                .unwrap()
1329                .0,
1330            Duration::from_secs(2)
1331        );
1332
1333        // Third execution with significant upward diff and high utilization - should share again
1334        tokio::time::advance(Duration::from_secs(5)).await;
1335        let timings = vec![ExecutionTiming::Success(Duration::from_secs(3))];
1336        observer.record_local_observations(&ptb, &timings, Duration::from_secs(5), 1);
1337        assert_eq!(
1338            observer
1339                .local_observations
1340                .get(&key)
1341                .unwrap()
1342                .last_shared
1343                .unwrap()
1344                .0,
1345            Duration::from_secs(3)
1346        );
1347
1348        // Fourth execution with significant downward diff but still overutilized - should NOT share downward change
1349        // (downward changes are only shared for indebted objects, not overutilized ones)
1350        tokio::time::advance(Duration::from_millis(150)).await;
1351        let timings = vec![ExecutionTiming::Success(Duration::from_millis(100))];
1352        observer.record_local_observations(&ptb, &timings, Duration::from_millis(500), 1);
1353        assert_eq!(
1354            observer
1355                .local_observations
1356                .get(&key)
1357                .unwrap()
1358                .last_shared
1359                .unwrap()
1360                .0,
1361            Duration::from_secs(3) // still the old value, no sharing of downward change
1362        );
1363
1364        // Fifth execution after utilization drops - should not share upward diff since not overutilized
1365        tokio::time::advance(Duration::from_secs(60)).await;
1366        let timings = vec![ExecutionTiming::Success(Duration::from_secs(11))];
1367        observer.record_local_observations(&ptb, &timings, Duration::from_secs(11), 1);
1368        assert_eq!(
1369            observer
1370                .local_observations
1371                .get(&key)
1372                .unwrap()
1373                .last_shared
1374                .unwrap()
1375                .0,
1376            Duration::from_secs(3) // still the old value, no sharing when not overutilized
1377        );
1378    }
1379
1380    #[tokio::test]
1381    async fn test_record_local_observations_with_indebted_objects() {
1382        telemetry_subscribers::init_for_testing();
1383
1384        let _guard = ProtocolConfig::apply_overrides_for_testing(|_, mut config| {
1385            config.set_per_object_congestion_control_mode_for_testing(
1386                PerObjectCongestionControlMode::ExecutionTimeEstimate(
1387                    ExecutionTimeEstimateParams {
1388                        target_utilization: 100,
1389                        allowed_txn_cost_overage_burst_limit_us: 0,
1390                        randomness_scalar: 0,
1391                        max_estimate_us: u64::MAX,
1392                        stored_observations_num_included_checkpoints: 10,
1393                        stored_observations_limit: u64::MAX,
1394                        stake_weighted_median_threshold: 0,
1395                        default_none_duration_for_new_keys: true,
1396                        observations_chunk_size: Some(18),
1397                    },
1398                ),
1399            );
1400            config
1401        });
1402
1403        let mock_consensus_client = MockConsensusClient::new();
1404        let authority = TestAuthorityBuilder::new().build().await;
1405        let epoch_store = authority.epoch_store_for_testing();
1406        let consensus_adapter = Arc::new(ConsensusAdapter::new(
1407            Arc::new(mock_consensus_client),
1408            CheckpointStore::new_for_tests(),
1409            authority.name,
1410            100_000,
1411            100_000,
1412            ConsensusAdapterMetrics::new_test(),
1413            Arc::new(tokio::sync::Notify::new()),
1414        ));
1415        let mut observer = ExecutionTimeObserver::new_for_testing(
1416            epoch_store.clone(),
1417            Box::new(consensus_adapter.clone()),
1418            Duration::from_millis(500), // Low utilization threshold to enable overutilized sharing initially
1419            false,                      // disable gas price weighting for this test
1420        );
1421
1422        // Create a simple PTB with one move call and one mutable shared input
1423        let package = ObjectID::random();
1424        let module = "test_module".to_string();
1425        let function = "test_function".to_string();
1426        let shared_object_id = ObjectID::random();
1427        let ptb = ProgrammableTransaction {
1428            inputs: vec![CallArg::Object(ObjectArg::SharedObject {
1429                id: shared_object_id,
1430                initial_shared_version: SequenceNumber::new(),
1431                mutability: SharedObjectMutability::Mutable,
1432            })],
1433            commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1434                package,
1435                module: module.clone(),
1436                function: function.clone(),
1437                type_arguments: vec![],
1438                arguments: vec![],
1439            }))],
1440        };
1441        let key = ExecutionTimeObservationKey::MoveEntryPoint {
1442            package,
1443            module: module.clone(),
1444            function: function.clone(),
1445            type_arguments: vec![],
1446        };
1447
1448        tokio::time::pause();
1449
1450        // First observation - should not share due to low utilization
1451        let timings = vec![ExecutionTiming::Success(Duration::from_secs(1))];
1452        observer.record_local_observations(&ptb, &timings, Duration::from_secs(1), 1);
1453        assert!(
1454            observer
1455                .local_observations
1456                .get(&key)
1457                .unwrap()
1458                .last_shared
1459                .is_none()
1460        );
1461
1462        // Second observation - no time has passed, so now utilization is high; should share upward change
1463        let timings = vec![ExecutionTiming::Success(Duration::from_secs(2))];
1464        observer.record_local_observations(&ptb, &timings, Duration::from_secs(2), 1);
1465        assert_eq!(
1466            observer
1467                .local_observations
1468                .get(&key)
1469                .unwrap()
1470                .last_shared
1471                .unwrap()
1472                .0,
1473            Duration::from_millis(1500) // (1s + 2s) / 2 = 1.5s
1474        );
1475
1476        // Mark the shared object as indebted and increase utilization threshold to prevent overutilized sharing
1477        observer.update_indebted_objects(vec![shared_object_id]);
1478        observer
1479            .config
1480            .observation_sharing_object_utilization_threshold = Some(Duration::from_secs(1000));
1481
1482        // Wait for min interval and record a significant downward change
1483        // This should share because the object is indebted
1484        tokio::time::advance(Duration::from_secs(60)).await;
1485        let timings = vec![ExecutionTiming::Success(Duration::from_millis(300))];
1486        observer.record_local_observations(&ptb, &timings, Duration::from_millis(300), 1);
1487
1488        // Moving average should be (1s + 2s + 0.3s) / 3 = 1.1s
1489        // This downward change should have been shared for indebted object
1490        assert_eq!(
1491            observer
1492                .local_observations
1493                .get(&key)
1494                .unwrap()
1495                .last_shared
1496                .unwrap()
1497                .0,
1498            Duration::from_millis(1100)
1499        );
1500    }
1501
1502    #[tokio::test]
1503    async fn test_stake_weighted_median() {
1504        telemetry_subscribers::init_for_testing();
1505
1506        let (committee, _) =
1507            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1508
1509        let params = ExecutionTimeEstimateParams {
1510            stake_weighted_median_threshold: 0,
1511            ..Default::default()
1512        };
1513
1514        let mut tracker = ConsensusObservations {
1515            observations: vec![
1516                (0, Some(Duration::from_secs(1))), // 10% stake
1517                (0, Some(Duration::from_secs(2))), // 20% stake
1518                (0, Some(Duration::from_secs(3))), // 30% stake
1519                (0, Some(Duration::from_secs(4))), // 40% stake
1520            ],
1521            stake_weighted_median: None,
1522        };
1523        tracker.update_stake_weighted_median(&committee, &params);
1524        // With stake weights [10,20,30,40]:
1525        // - Duration 1 covers 10% of stake
1526        // - Duration 2 covers 30% of stake (10+20)
1527        // - Duration 3 covers 60% of stake (10+20+30)
1528        // - Duration 4 covers 100% of stake
1529        // Median should be 3 since that's where we cross 50% of stake
1530        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1531
1532        // Test duration sorting
1533        let mut tracker = ConsensusObservations {
1534            observations: vec![
1535                (0, Some(Duration::from_secs(3))), // 10% stake
1536                (0, Some(Duration::from_secs(4))), // 20% stake
1537                (0, Some(Duration::from_secs(1))), // 30% stake
1538                (0, Some(Duration::from_secs(2))), // 40% stake
1539            ],
1540            stake_weighted_median: None,
1541        };
1542        tracker.update_stake_weighted_median(&committee, &params);
1543        // With sorted stake weights [30,40,10,20]:
1544        // - Duration 1 covers 30% of stake
1545        // - Duration 2 covers 70% of stake (30+40)
1546        // - Duration 3 covers 80% of stake (30+40+10)
1547        // - Duration 4 covers 100% of stake
1548        // Median should be 2 since that's where we cross 50% of stake
1549        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(2)));
1550
1551        // Test with one missing observation
1552        let mut tracker = ConsensusObservations {
1553            observations: vec![
1554                (0, Some(Duration::from_secs(1))), // 10% stake
1555                (0, None),                         // 20% stake (missing)
1556                (0, Some(Duration::from_secs(3))), // 30% stake
1557                (0, Some(Duration::from_secs(4))), // 40% stake
1558            ],
1559            stake_weighted_median: None,
1560        };
1561        tracker.update_stake_weighted_median(&committee, &params);
1562        // With missing observation for 20% stake:
1563        // - Duration 1 covers 10% of stake
1564        // - Duration 3 covers 40% of stake (10+30)
1565        // - Duration 4 covers 80% of stake (10+30+40)
1566        // Median should be 4 since that's where we pass half of available stake (80% / 2 == 40%)
1567        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(4)));
1568
1569        // Test with multiple missing observations
1570        let mut tracker = ConsensusObservations {
1571            observations: vec![
1572                (0, Some(Duration::from_secs(1))), // 10% stake
1573                (0, Some(Duration::from_secs(2))), // 20% stake
1574                (0, None),                         // 30% stake (missing)
1575                (0, None),                         // 40% stake (missing)
1576            ],
1577            stake_weighted_median: None,
1578        };
1579        tracker.update_stake_weighted_median(&committee, &params);
1580        // With missing observations:
1581        // - Duration 1 covers 10% of stake
1582        // - Duration 2 covers 30% of stake (10+20)
1583        // Median should be 2 since that's where we cross half of available stake (40% / 2 == 20%)
1584        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(2)));
1585
1586        // Test with one observation
1587        let mut tracker = ConsensusObservations {
1588            observations: vec![
1589                (0, None),                         // 10% stake
1590                (0, None),                         // 20% stake
1591                (0, Some(Duration::from_secs(3))), // 30% stake
1592                (0, None),                         // 40% stake
1593            ],
1594            stake_weighted_median: None,
1595        };
1596        tracker.update_stake_weighted_median(&committee, &params);
1597        // With only one observation, median should be that observation
1598        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1599
1600        // Test with all same durations
1601        let mut tracker = ConsensusObservations {
1602            observations: vec![
1603                (0, Some(Duration::from_secs(5))), // 10% stake
1604                (0, Some(Duration::from_secs(5))), // 20% stake
1605                (0, Some(Duration::from_secs(5))), // 30% stake
1606                (0, Some(Duration::from_secs(5))), // 40% stake
1607            ],
1608            stake_weighted_median: None,
1609        };
1610        tracker.update_stake_weighted_median(&committee, &params);
1611        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(5)));
1612    }
1613
1614    #[tokio::test]
1615    async fn test_stake_weighted_median_threshold() {
1616        telemetry_subscribers::init_for_testing();
1617
1618        let (committee, _) =
1619            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1620
1621        // Test with threshold requiring at least 50% stake
1622        let params = ExecutionTimeEstimateParams {
1623            stake_weighted_median_threshold: 5000,
1624            ..Default::default()
1625        };
1626
1627        // Test with insufficient stake (only 30% have observations)
1628        let mut tracker = ConsensusObservations {
1629            observations: vec![
1630                (0, Some(Duration::from_secs(1))), // 10% stake
1631                (0, Some(Duration::from_secs(2))), // 20% stake
1632                (0, None),                         // 30% stake (missing)
1633                (0, None),                         // 40% stake (missing)
1634            ],
1635            stake_weighted_median: None,
1636        };
1637        tracker.update_stake_weighted_median(&committee, &params);
1638        // Should not compute median since only 30% stake has observations (< 50% threshold)
1639        assert_eq!(tracker.stake_weighted_median, None);
1640
1641        // Test with sufficient stake (60% have observations)
1642        let mut tracker = ConsensusObservations {
1643            observations: vec![
1644                (0, Some(Duration::from_secs(1))), // 10% stake
1645                (0, Some(Duration::from_secs(2))), // 20% stake
1646                (0, Some(Duration::from_secs(3))), // 30% stake
1647                (0, None),                         // 40% stake (missing)
1648            ],
1649            stake_weighted_median: None,
1650        };
1651        tracker.update_stake_weighted_median(&committee, &params);
1652        // Should compute median since 60% stake has observations (>= 50% threshold)
1653        assert_eq!(tracker.stake_weighted_median, Some(Duration::from_secs(3)));
1654    }
1655
1656    #[tokio::test]
1657    async fn test_execution_time_estimator() {
1658        telemetry_subscribers::init_for_testing();
1659
1660        let (committee, _) =
1661            Committee::new_simple_test_committee_with_normalized_voting_power(vec![10, 20, 30, 40]);
1662        let mut estimator = ExecutionTimeEstimator::new(
1663            Arc::new(committee),
1664            ExecutionTimeEstimateParams {
1665                target_utilization: 50,
1666                max_estimate_us: 1_500_000,
1667
1668                // Not used in this test.
1669                allowed_txn_cost_overage_burst_limit_us: 0,
1670                randomness_scalar: 0,
1671                stored_observations_num_included_checkpoints: 10,
1672                stored_observations_limit: u64::MAX,
1673                stake_weighted_median_threshold: 0,
1674                default_none_duration_for_new_keys: true,
1675                observations_chunk_size: Some(18),
1676            },
1677            std::iter::empty(),
1678        );
1679        // Create test keys
1680        let package = ObjectID::random();
1681        let module = "test_module".to_string();
1682        let function = "test_function".to_string();
1683        let move_key = ExecutionTimeObservationKey::MoveEntryPoint {
1684            package,
1685            module: module.clone(),
1686            function: function.clone(),
1687            type_arguments: vec![],
1688        };
1689        let transfer_key = ExecutionTimeObservationKey::TransferObjects;
1690
1691        // Record observations from different validators
1692        // First record some old observations that should be ignored
1693        estimator.process_observation_from_consensus(
1694            0,
1695            Some(1),
1696            move_key.clone(),
1697            Duration::from_millis(1000),
1698            false,
1699        );
1700        estimator.process_observation_from_consensus(
1701            1,
1702            Some(1),
1703            move_key.clone(),
1704            Duration::from_millis(1000),
1705            false,
1706        );
1707        estimator.process_observation_from_consensus(
1708            2,
1709            Some(1),
1710            move_key.clone(),
1711            Duration::from_millis(1000),
1712            false,
1713        );
1714
1715        estimator.process_observation_from_consensus(
1716            0,
1717            Some(1),
1718            transfer_key.clone(),
1719            Duration::from_millis(500),
1720            false,
1721        );
1722        estimator.process_observation_from_consensus(
1723            1,
1724            Some(1),
1725            transfer_key.clone(),
1726            Duration::from_millis(500),
1727            false,
1728        );
1729        estimator.process_observation_from_consensus(
1730            2,
1731            Some(1),
1732            transfer_key.clone(),
1733            Duration::from_millis(500),
1734            false,
1735        );
1736
1737        // Now record newer observations that should be used
1738        estimator.process_observation_from_consensus(
1739            0,
1740            Some(2),
1741            move_key.clone(),
1742            Duration::from_millis(100),
1743            false,
1744        );
1745        estimator.process_observation_from_consensus(
1746            1,
1747            Some(2),
1748            move_key.clone(),
1749            Duration::from_millis(200),
1750            false,
1751        );
1752        estimator.process_observation_from_consensus(
1753            2,
1754            Some(2),
1755            move_key.clone(),
1756            Duration::from_millis(300),
1757            false,
1758        );
1759
1760        estimator.process_observation_from_consensus(
1761            0,
1762            Some(2),
1763            transfer_key.clone(),
1764            Duration::from_millis(50),
1765            false,
1766        );
1767        estimator.process_observation_from_consensus(
1768            1,
1769            Some(2),
1770            transfer_key.clone(),
1771            Duration::from_millis(60),
1772            false,
1773        );
1774        estimator.process_observation_from_consensus(
1775            2,
1776            Some(2),
1777            transfer_key.clone(),
1778            Duration::from_millis(70),
1779            false,
1780        );
1781
1782        // Try to record old observations again - these should be ignored
1783        estimator.process_observation_from_consensus(
1784            0,
1785            Some(1),
1786            move_key.clone(),
1787            Duration::from_millis(1000),
1788            false,
1789        );
1790        estimator.process_observation_from_consensus(
1791            1,
1792            Some(1),
1793            transfer_key.clone(),
1794            Duration::from_millis(500),
1795            false,
1796        );
1797        estimator.process_observation_from_consensus(
1798            2,
1799            Some(1),
1800            move_key.clone(),
1801            Duration::from_millis(1000),
1802            false,
1803        );
1804
1805        // Test single command transaction
1806        let single_move_tx = TransactionData::new_programmable(
1807            SuiAddress::ZERO,
1808            vec![],
1809            ProgrammableTransaction {
1810                inputs: vec![],
1811                commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1812                    package,
1813                    module: module.clone(),
1814                    function: function.clone(),
1815                    type_arguments: vec![],
1816                    arguments: vec![],
1817                }))],
1818            },
1819            100,
1820            100,
1821        );
1822
1823        // Should return median of move call observations (300ms)
1824        assert_eq!(
1825            estimator.get_estimate(&single_move_tx),
1826            Duration::from_millis(300)
1827        );
1828
1829        // Test multi-command transaction
1830        let multi_command_tx = TransactionData::new_programmable(
1831            SuiAddress::ZERO,
1832            vec![],
1833            ProgrammableTransaction {
1834                inputs: vec![],
1835                commands: vec![
1836                    Command::MoveCall(Box::new(ProgrammableMoveCall {
1837                        package,
1838                        module: module.clone(),
1839                        function: function.clone(),
1840                        type_arguments: vec![],
1841                        arguments: vec![],
1842                    })),
1843                    Command::TransferObjects(
1844                        vec![Argument::Input(1), Argument::Input(2)],
1845                        Argument::Input(0),
1846                    ),
1847                ],
1848            },
1849            100,
1850            100,
1851        );
1852
1853        // Should return sum of median move call (300ms)
1854        // plus the median transfer (70ms) * command length (3)
1855        assert_eq!(
1856            estimator.get_estimate(&multi_command_tx),
1857            Duration::from_millis(510)
1858        );
1859    }
1860
1861    #[derive(Debug, Clone, Serialize, Deserialize)]
1862    struct ExecutionTimeObserverSnapshot {
1863        protocol_version: u64,
1864        consensus_observations: Vec<(ExecutionTimeObservationKey, ConsensusObservations)>,
1865        transaction_estimates: Vec<(String, Duration)>, // (transaction_description, estimated_duration)
1866    }
1867
1868    fn generate_test_inputs(
1869        seed: u64,
1870        num_validators: usize,
1871        generation_override: Option<u64>,
1872    ) -> Vec<(
1873        AuthorityIndex,
1874        Option<u64>,
1875        ExecutionTimeObservationKey,
1876        Duration,
1877    )> {
1878        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1879
1880        let observation_keys = [
1881            ExecutionTimeObservationKey::MoveEntryPoint {
1882                package: ObjectID::from_hex_literal("0x1").unwrap(),
1883                module: "coin".to_string(),
1884                function: "transfer".to_string(),
1885                type_arguments: vec![],
1886            },
1887            ExecutionTimeObservationKey::MoveEntryPoint {
1888                package: ObjectID::from_hex_literal("0x2").unwrap(),
1889                module: "nft".to_string(),
1890                function: "mint".to_string(),
1891                type_arguments: vec![],
1892            },
1893            ExecutionTimeObservationKey::TransferObjects,
1894            ExecutionTimeObservationKey::SplitCoins,
1895            ExecutionTimeObservationKey::MergeCoins,
1896            ExecutionTimeObservationKey::MakeMoveVec,
1897            ExecutionTimeObservationKey::Upgrade,
1898        ];
1899
1900        let mut inputs = Vec::new();
1901        let target_samples = 25;
1902
1903        for _ in 0..target_samples {
1904            let key = observation_keys[rng.gen_range(0..observation_keys.len())].clone();
1905            let authority_index =
1906                AuthorityIndex::try_from(rng.gen_range(0..num_validators)).unwrap();
1907
1908            // Use realistic range where newer generations might replace older ones
1909            let generation = generation_override.unwrap_or_else(|| rng.gen_range(1..=10));
1910
1911            // Generate duration based on key type with realistic variance
1912            // Sometimes generate zero values to test corner cases with byzantine validators
1913            let base_duration = if rng.gen_ratio(1, 20) {
1914                // 5% chance of zero duration to test corner cases
1915                0
1916            } else {
1917                match &key {
1918                    ExecutionTimeObservationKey::MoveEntryPoint { .. } => rng.gen_range(50..=500),
1919                    ExecutionTimeObservationKey::TransferObjects => rng.gen_range(10..=100),
1920                    ExecutionTimeObservationKey::SplitCoins => rng.gen_range(20..=80),
1921                    ExecutionTimeObservationKey::MergeCoins => rng.gen_range(15..=70),
1922                    ExecutionTimeObservationKey::MakeMoveVec => rng.gen_range(5..=30),
1923                    ExecutionTimeObservationKey::Upgrade => rng.gen_range(100..=1000),
1924                    ExecutionTimeObservationKey::Publish => rng.gen_range(200..=2000),
1925                }
1926            };
1927
1928            let duration = Duration::from_millis(base_duration);
1929
1930            inputs.push((authority_index, Some(generation), key, duration));
1931        }
1932
1933        inputs
1934    }
1935
1936    fn generate_test_transactions(seed: u64) -> Vec<(String, TransactionData)> {
1937        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1938        let mut transactions = Vec::new();
1939
1940        let package3 = ObjectID::from_hex_literal("0x3").unwrap();
1941        transactions.push((
1942            "coin_transfer_call".to_string(),
1943            TransactionData::new_programmable(
1944                SuiAddress::ZERO,
1945                vec![],
1946                ProgrammableTransaction {
1947                    inputs: vec![],
1948                    commands: vec![Command::MoveCall(Box::new(ProgrammableMoveCall {
1949                        package: ObjectID::from_hex_literal("0x1").unwrap(),
1950                        module: "coin".to_string(),
1951                        function: "transfer".to_string(),
1952                        type_arguments: vec![],
1953                        arguments: vec![],
1954                    }))],
1955                },
1956                rng.gen_range(100..1000),
1957                rng.gen_range(100..1000),
1958            ),
1959        ));
1960
1961        transactions.push((
1962            "mixed_move_calls".to_string(),
1963            TransactionData::new_programmable(
1964                SuiAddress::ZERO,
1965                vec![],
1966                ProgrammableTransaction {
1967                    inputs: vec![],
1968                    commands: vec![
1969                        Command::MoveCall(Box::new(ProgrammableMoveCall {
1970                            package: ObjectID::from_hex_literal("0x1").unwrap(),
1971                            module: "coin".to_string(),
1972                            function: "transfer".to_string(),
1973                            type_arguments: vec![],
1974                            arguments: vec![],
1975                        })),
1976                        Command::MoveCall(Box::new(ProgrammableMoveCall {
1977                            package: ObjectID::from_hex_literal("0x2").unwrap(),
1978                            module: "nft".to_string(),
1979                            function: "mint".to_string(),
1980                            type_arguments: vec![],
1981                            arguments: vec![],
1982                        })),
1983                    ],
1984                },
1985                rng.gen_range(100..1000),
1986                rng.gen_range(100..1000),
1987            ),
1988        ));
1989
1990        transactions.push((
1991            "native_commands_with_observations".to_string(),
1992            TransactionData::new_programmable(
1993                SuiAddress::ZERO,
1994                vec![],
1995                ProgrammableTransaction {
1996                    inputs: vec![],
1997                    commands: vec![
1998                        Command::TransferObjects(vec![Argument::Input(0)], Argument::Input(1)),
1999                        Command::SplitCoins(Argument::Input(2), vec![Argument::Input(3)]),
2000                        Command::MergeCoins(Argument::Input(4), vec![Argument::Input(5)]),
2001                        Command::MakeMoveVec(None, vec![Argument::Input(6)]),
2002                    ],
2003                },
2004                rng.gen_range(100..1000),
2005                rng.gen_range(100..1000),
2006            ),
2007        ));
2008
2009        let num_objects = rng.gen_range(1..=5);
2010        transactions.push((
2011            format!("transfer_objects_{}_items", num_objects),
2012            TransactionData::new_programmable(
2013                SuiAddress::ZERO,
2014                vec![],
2015                ProgrammableTransaction {
2016                    inputs: vec![],
2017                    commands: vec![Command::TransferObjects(
2018                        (0..num_objects).map(Argument::Input).collect(),
2019                        Argument::Input(num_objects),
2020                    )],
2021                },
2022                rng.gen_range(100..1000),
2023                rng.gen_range(100..1000),
2024            ),
2025        ));
2026
2027        let num_amounts = rng.gen_range(1..=4);
2028        transactions.push((
2029            format!("split_coins_{}_amounts", num_amounts),
2030            TransactionData::new_programmable(
2031                SuiAddress::ZERO,
2032                vec![],
2033                ProgrammableTransaction {
2034                    inputs: vec![],
2035                    commands: vec![Command::SplitCoins(
2036                        Argument::Input(0),
2037                        (1..=num_amounts).map(Argument::Input).collect(),
2038                    )],
2039                },
2040                rng.gen_range(100..1000),
2041                rng.gen_range(100..1000),
2042            ),
2043        ));
2044
2045        let num_sources = rng.gen_range(1..=3);
2046        transactions.push((
2047            format!("merge_coins_{}_sources", num_sources),
2048            TransactionData::new_programmable(
2049                SuiAddress::ZERO,
2050                vec![],
2051                ProgrammableTransaction {
2052                    inputs: vec![],
2053                    commands: vec![Command::MergeCoins(
2054                        Argument::Input(0),
2055                        (1..=num_sources).map(Argument::Input).collect(),
2056                    )],
2057                },
2058                rng.gen_range(100..1000),
2059                rng.gen_range(100..1000),
2060            ),
2061        ));
2062
2063        let num_elements = rng.gen_range(0..=6);
2064        transactions.push((
2065            format!("make_move_vec_{}_elements", num_elements),
2066            TransactionData::new_programmable(
2067                SuiAddress::ZERO,
2068                vec![],
2069                ProgrammableTransaction {
2070                    inputs: vec![],
2071                    commands: vec![Command::MakeMoveVec(
2072                        None,
2073                        (0..num_elements).map(Argument::Input).collect(),
2074                    )],
2075                },
2076                rng.gen_range(100..1000),
2077                rng.gen_range(100..1000),
2078            ),
2079        ));
2080
2081        transactions.push((
2082            "mixed_commands".to_string(),
2083            TransactionData::new_programmable(
2084                SuiAddress::ZERO,
2085                vec![],
2086                ProgrammableTransaction {
2087                    inputs: vec![],
2088                    commands: vec![
2089                        Command::MoveCall(Box::new(ProgrammableMoveCall {
2090                            package: package3,
2091                            module: "game".to_string(),
2092                            function: "play".to_string(),
2093                            type_arguments: vec![],
2094                            arguments: vec![],
2095                        })),
2096                        Command::TransferObjects(
2097                            vec![Argument::Input(1), Argument::Input(2)],
2098                            Argument::Input(0),
2099                        ),
2100                        Command::SplitCoins(Argument::Input(3), vec![Argument::Input(4)]),
2101                    ],
2102                },
2103                rng.gen_range(100..1000),
2104                rng.gen_range(100..1000),
2105            ),
2106        ));
2107
2108        transactions.push((
2109            "upgrade_package".to_string(),
2110            TransactionData::new_programmable(
2111                SuiAddress::ZERO,
2112                vec![],
2113                ProgrammableTransaction {
2114                    inputs: vec![],
2115                    commands: vec![Command::Upgrade(
2116                        vec![],
2117                        vec![],
2118                        package3,
2119                        Argument::Input(0),
2120                    )],
2121                },
2122                rng.gen_range(100..1000),
2123                rng.gen_range(100..1000),
2124            ),
2125        ));
2126
2127        transactions
2128    }
2129
2130    // Safeguard against forking because of changes to the execution time estimator.
2131    //
2132    // Within an epoch, each estimator must reach the same conclusion about the observations and
2133    // stake_weighted_median from the observations shared by other validators, as this is used
2134    // for transaction ordering.
2135    //
2136    // Therefore; any change in the calculation of the observations or stake_weighted_median
2137    // not accompanied by a protocol version change may fork.
2138    //
2139    // This test uses snapshots of computed stake weighted median at particular protocol versions
2140    // to attempt to discover regressions that might fork.
2141    #[test]
2142    fn snapshot_tests() {
2143        println!("\n============================================================================");
2144        println!("!                                                                          !");
2145        println!("! IMPORTANT: never update snapshots from this test. only add new versions! !");
2146        println!("!                                                                          !");
2147        println!("============================================================================\n");
2148
2149        let max_version = ProtocolVersion::MAX.as_u64();
2150
2151        let test_versions: Vec<u64> = (max_version.saturating_sub(9)..=max_version).collect();
2152
2153        for version in test_versions {
2154            let protocol_version = ProtocolVersion::new(version);
2155            let protocol_config = ProtocolConfig::get_for_version(protocol_version, Chain::Unknown);
2156            let (committee, _) = Committee::new_simple_test_committee_of_size(4);
2157            let committee = Arc::new(committee);
2158
2159            let initial_generation =
2160                if let PerObjectCongestionControlMode::ExecutionTimeEstimate(params) =
2161                    protocol_config.per_object_congestion_control_mode()
2162                {
2163                    if params.default_none_duration_for_new_keys {
2164                        None
2165                    } else {
2166                        Some(0)
2167                    }
2168                } else {
2169                    Some(0) // fallback for versions without execution time estimate mode
2170                };
2171
2172            let initial_observations =
2173                generate_test_inputs(0, committee.num_members(), initial_generation);
2174            let mut estimator = ExecutionTimeEstimator::new(
2175                committee.clone(),
2176                ExecutionTimeEstimateParams {
2177                    max_estimate_us: u64::MAX, // Allow unlimited estimates for testing
2178                    ..ExecutionTimeEstimateParams::default()
2179                },
2180                initial_observations.into_iter(),
2181            );
2182
2183            let test_inputs = generate_test_inputs(version, committee.num_members(), None);
2184
2185            for (source, generation, observation_key, duration) in test_inputs {
2186                estimator.process_observation_from_consensus(
2187                    source,
2188                    generation,
2189                    observation_key,
2190                    duration,
2191                    false,
2192                );
2193            }
2194
2195            let mut final_observations = estimator.get_observations();
2196            final_observations.sort_by_key(|a| a.0.to_string());
2197
2198            let test_transactions = generate_test_transactions(version);
2199            let mut transaction_estimates = Vec::new();
2200            for (description, tx_data) in test_transactions {
2201                let estimate = estimator.get_estimate(&tx_data);
2202                transaction_estimates.push((description, estimate));
2203            }
2204
2205            let snapshot_data = ExecutionTimeObserverSnapshot {
2206                protocol_version: version,
2207                consensus_observations: final_observations.clone(),
2208                transaction_estimates,
2209            };
2210            insta::assert_yaml_snapshot!(
2211                format!("execution_time_observer_v{}", version),
2212                snapshot_data
2213            );
2214        }
2215    }
2216}