Skip to main content

sui_core/
transaction_deny_config_manager.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Runtime manager for the `TransactionDenyConfig` used at voting time.
5//!
6//! Holds the operator's local configuration plus the latest deny-rule proposals
7//! received from committee members over consensus (via
8//! `ConsensusTransactionKind::UpdateTransactionDenyConfig`). The effective config is
9//! recomputed by [`evaluate_deny_configs`]:
10//!
11//! - The operator's local `transaction_deny_config` is always applied unconditionally.
12//! - Each operator-defined `SharedDenyRuleset` ("pre-listed" ruleset) activates only
13//!   when validators holding at least its stake threshold of the *eligible* stake have
14//!   each proposed a superset of its rules.
15//! - A "default" bucket threshold-gates each individual proposed rule *element*
16//!   (deny-list entry or boolean kill switch) that peers have proposed.
17
18use crate::authority::AuthorityState;
19use crate::authority::authority_per_epoch_store::AuthorityPerEpochStore;
20use crate::authority::authority_store_tables::AuthorityPerpetualTables;
21use crate::consensus_adapter::ConsensusAdapter;
22use arc_swap::ArcSwap;
23use itertools::Itertools;
24use parking_lot::Mutex;
25use prometheus::{
26    IntCounterVec, IntGauge, IntGaugeVec, Registry, register_int_counter_vec_with_registry,
27    register_int_gauge_vec_with_registry, register_int_gauge_with_registry,
28};
29use std::collections::{BTreeMap, BTreeSet};
30use std::sync::Arc;
31use sui_config::transaction_deny_config::{
32    PeerDenySyncConfig, TransactionDenyConfig, ValidatorEligibility,
33};
34use sui_types::base_types::AuthorityName;
35use sui_types::base_types::ConciseableName;
36use sui_types::committee::{Committee, StakeUnit, TOTAL_VOTING_POWER};
37use sui_types::error::{SuiError, SuiResult};
38use sui_types::messages_consensus::{
39    ConsensusTransaction, SharedTransactionDenyConfig, SharedTransactionDenyConfigV1,
40};
41use sui_types::transaction_deny_rules::{DenyElement, DenyElementKind, TransactionDenyRules};
42use tracing::{debug, info, warn};
43use typed_store::Map;
44
45pub struct TransactionDenyConfigManager {
46    self_authority: AuthorityName,
47    /// The operator's local configuration at startup. Always applied unconditionally.
48    local_config: Arc<TransactionDenyConfig>,
49    /// Operator-defined pre-listed rulesets and the "default" bucket settings.
50    sync_config: PeerDenySyncConfig,
51    /// Latest accepted proposal per committee member. `BTreeMap` for deterministic
52    /// iteration order during evaluation.
53    peer_configs: Mutex<BTreeMap<AuthorityName, SharedTransactionDenyConfig>>,
54    /// Current committee, used for per-validator stake and membership. Replaced on
55    /// epoch change via `update_for_committee`.
56    committee: ArcSwap<Committee>,
57    /// Snapshot of the current effective config used by the voting hot path. Replaced
58    /// atomically whenever evaluation produces different rules.
59    effective_config: ArcSwap<TransactionDenyConfig>,
60    /// Backing store for cross-restart durability of `peer_configs`.
61    perpetual: Arc<AuthorityPerpetualTables>,
62    /// Serializes the read-modify-write in `allocate_next_broadcast_generation` so
63    /// concurrent callers cannot read the same prior generation and hand out duplicates.
64    broadcast_generation_lock: Mutex<()>,
65    metrics: TransactionDenyConfigMetrics,
66}
67
68impl TransactionDenyConfigManager {
69    pub fn new(
70        self_authority: AuthorityName,
71        local_config: TransactionDenyConfig,
72        sync_config: PeerDenySyncConfig,
73        committee: Arc<Committee>,
74        perpetual: Arc<AuthorityPerpetualTables>,
75        registry: &Registry,
76    ) -> SuiResult<Arc<Self>> {
77        sync_config.validate().map_err(SuiError::from)?;
78        let local_config = Arc::new(local_config);
79
80        // Seed peer_configs from the perpetual store. Skip our own persisted
81        // broadcast: after a restart the operator may have edited the local
82        // transaction_deny_config, so a pre-restart self-broadcast could be stale.
83        // Startup reconciliation (in sui-node) either re-broadcasts the current local
84        // config or withdraws; until that lands we simply don't vote for ourselves
85        // rather than vote a possibly-stale snapshot. Entries from validators no longer
86        // in the committee are also skipped (and pruned from the DB by the next
87        // `update_for_committee`).
88        let mut peer_configs = BTreeMap::new();
89        for entry in perpetual.shared_transaction_deny_configs.safe_iter() {
90            let (authority, msg) = entry.expect("db error reading shared_transaction_deny_configs");
91            if authority != self_authority && committee.authority_exists(&authority) {
92                peer_configs.insert(authority, msg);
93            }
94        }
95
96        let metrics = TransactionDenyConfigMetrics::new(registry);
97        let evaluation = evaluate(&local_config, &sync_config, &peer_configs, &committee);
98        let effective = local_config.with_rules(evaluation.effective_rules.clone());
99        metrics.record(
100            &local_config,
101            active_proposal_count(&peer_configs),
102            &evaluation,
103        );
104
105        info!(
106            rulesets = sync_config.rulesets.len(),
107            default_buckets = sync_config.default_buckets.len(),
108            seeded_proposals = peer_configs.len(),
109            "TransactionDenyConfigManager initialized",
110        );
111
112        Ok(Arc::new(Self {
113            self_authority,
114            local_config,
115            sync_config,
116            peer_configs: Mutex::new(peer_configs),
117            committee: ArcSwap::from(committee),
118            effective_config: ArcSwap::from_pointee(effective),
119            perpetual,
120            broadcast_generation_lock: Mutex::new(()),
121            metrics,
122        }))
123    }
124
125    /// Returns the local (operator-configured) deny config. Tests and the admin dump
126    /// endpoint use this when they want the unmerged view.
127    pub fn local(&self) -> &Arc<TransactionDenyConfig> {
128        &self.local_config
129    }
130
131    /// Returns the merged effective deny config (local + threshold-gated peer rules).
132    pub fn effective_config(&self) -> &ArcSwap<TransactionDenyConfig> {
133        &self.effective_config
134    }
135
136    /// Snapshot of the currently-cached per-peer proposals.
137    pub fn peer_configs_snapshot(&self) -> BTreeMap<AuthorityName, SharedTransactionDenyConfig> {
138        self.peer_configs.lock().clone()
139    }
140
141    /// Evaluate the current voting state without mutating anything. Used by the admin
142    /// dump endpoint for operator visibility.
143    pub fn evaluate_status(&self) -> DenyConfigEvaluation {
144        let committee = self.committee.load();
145        let peer_configs = self.peer_configs.lock();
146        evaluate(
147            &self.local_config,
148            &self.sync_config,
149            &peer_configs,
150            &committee,
151        )
152    }
153
154    /// Returns true if this node has ever allocated a broadcast generation — a durable
155    /// signal that a pre-restart vote may be outstanding on the network.
156    ///
157    /// Deliberately not based on our own `shared_transaction_deny_configs` entry: that
158    /// entry is written only when the ConsensusHandler processes the commit carrying
159    /// our broadcast, which races startup reconciliation — a vote committed but not
160    /// yet processed at crash time would be missed. The generation counter is persisted
161    /// *before* submission, so it can never miss a broadcast; it can only
162    /// over-report (e.g. when the last action was itself a withdrawal), which at
163    /// worst costs one redundant withdrawal message.
164    pub fn may_have_outstanding_broadcast(&self) -> bool {
165        self.perpetual
166            .last_broadcast_deny_generation
167            .get(&())
168            .expect("db error")
169            .is_some()
170    }
171
172    /// Apply a batch of proposals from a single authenticated sender: `from` must be
173    /// the verified origin of `msgs` — the author of the consensus block that carried
174    /// them, or this node itself for a just-submitted broadcast.
175    pub fn apply_updates(&self, from: AuthorityName, msgs: Vec<SharedTransactionDenyConfig>) {
176        if msgs.is_empty() {
177            return;
178        }
179        // Cap on accepted generations: receivers persist the per-authority generation
180        // high-water mark until the authority leaves the committee, so accepting a
181        // far-future generation (e.g. from a peer's clock excursion) would permanently
182        // block that peer's subsequent updates. Ignoring such messages caps the
183        // high-water mark at roughly now + drift, making recovery automatic within the
184        // same margin. This wall-clock check must live here, on local state only — in
185        // the consensus validator it would make block validity nondeterministic across
186        // honest validators, and vote-tracker recovery panics if a locally stored block
187        // fails re-validation after a backward clock step.
188        let max_generation = AuthorityState::unixtime_now_ms()
189            .saturating_add(SharedTransactionDenyConfig::MAX_GENERATION_FUTURE_DRIFT_MS);
190        let (evaluation, active_proposals) = {
191            let mut peer_configs = self.peer_configs.lock();
192            // Load the committee inside the critical section so this batch is validated
193            // and evaluated against a single committee snapshot — even if
194            // `update_for_committee` is concurrently swapping the committee.
195            let committee = self.committee.load();
196            let mut accepted = false;
197            for msg in msgs {
198                let authority = msg.authority();
199                let generation = msg.generation();
200                if authority != from {
201                    warn!(
202                        authority = %authority.concise(),
203                        from = %from.concise(),
204                        "Dropping UpdateTransactionDenyConfig: claimed authority does not match sender",
205                    );
206                    self.metrics
207                        .dropped_updates
208                        .with_label_values(&["author_mismatch"])
209                        .inc();
210                    continue;
211                }
212                if !committee.authority_exists(&authority) {
213                    info!(
214                        authority = %authority.concise(),
215                        "Dropping UpdateTransactionDenyConfig: sender not in committee",
216                    );
217                    self.metrics
218                        .dropped_updates
219                        .with_label_values(&["sender_not_in_committee"])
220                        .inc();
221                    continue;
222                }
223                if generation > max_generation {
224                    warn!(
225                        authority = %authority.concise(),
226                        generation,
227                        "Dropping UpdateTransactionDenyConfig: generation too far in the future",
228                    );
229                    self.metrics
230                        .dropped_updates
231                        .with_label_values(&["future_generation"])
232                        .inc();
233                    continue;
234                }
235                if let Some(existing) = peer_configs.get(&authority)
236                    && existing.generation() >= generation
237                {
238                    debug!(
239                        authority = %authority.concise(),
240                        new_generation = generation,
241                        existing_generation = existing.generation(),
242                        "Dropping UpdateTransactionDenyConfig: stale generation",
243                    );
244                    self.metrics
245                        .dropped_updates
246                        .with_label_values(&["stale_generation"])
247                        .inc();
248                    continue;
249                }
250                // Persist before swapping in-memory so a crash leaves state consistent.
251                self.perpetual
252                    .shared_transaction_deny_configs
253                    .insert(&authority, &msg)
254                    .expect("db error");
255                peer_configs.insert(authority, msg);
256                accepted = true;
257                info!(
258                    authority = %authority.concise(),
259                    generation,
260                    "Accepted UpdateTransactionDenyConfig from committee member",
261                );
262            }
263            if !accepted {
264                return;
265            }
266            (
267                evaluate(
268                    &self.local_config,
269                    &self.sync_config,
270                    &peer_configs,
271                    &committee,
272                ),
273                active_proposal_count(&peer_configs),
274            )
275        };
276        self.apply_evaluation(evaluation, active_proposals);
277    }
278
279    /// Update the stored committee and prune proposals (in-memory + DB) from any
280    /// authority that is no longer a member. Always recomputes the effective config,
281    /// since the stake distribution may have shifted even with no departures. Called at
282    /// epoch transitions and at startup.
283    pub fn update_for_committee(&self, committee: Arc<Committee>) -> SuiResult<()> {
284        let (evaluation, active_proposals) = {
285            let mut peer_configs = self.peer_configs.lock();
286            // Swap the committee inside the critical section so any concurrent
287            // `apply_updates` either sees the old committee with the old peer set or
288            // the new committee with the pruned peer set — never a mismatched pair.
289            self.committee.store(committee.clone());
290            let to_remove: Vec<AuthorityName> = peer_configs
291                .keys()
292                .filter(|name| !committee.authority_exists(name))
293                .copied()
294                .collect();
295            for name in &to_remove {
296                self.perpetual
297                    .shared_transaction_deny_configs
298                    .remove(name)?;
299                peer_configs.remove(name);
300            }
301            if !to_remove.is_empty() {
302                info!(
303                    pruned = to_remove.len(),
304                    "Pruned UpdateTransactionDenyConfig entries for peers no longer in committee",
305                );
306            }
307            (
308                evaluate(
309                    &self.local_config,
310                    &self.sync_config,
311                    &peer_configs,
312                    &committee,
313                ),
314                active_proposal_count(&peer_configs),
315            )
316        };
317        self.apply_evaluation(evaluation, active_proposals);
318        Ok(())
319    }
320
321    /// Allocate the next monotonic generation for an outgoing broadcast. Persists the
322    /// returned value before any submission so a crash between allocate-and-send cannot
323    /// reuse the generation.
324    pub fn allocate_next_broadcast_generation(&self) -> SuiResult<u64> {
325        // Hold the lock across the whole read-modify-write: without it, two callers can
326        // read the same `last` and each return `last + 1`, colliding on a generation.
327        let _guard = self.broadcast_generation_lock.lock();
328        let now_ms = AuthorityState::unixtime_now_ms();
329        let last = self
330            .perpetual
331            .last_broadcast_deny_generation
332            .get(&())?
333            .unwrap_or(0);
334        let mut generation = now_ms.max(last.saturating_add(1));
335        // A past clock excursion can leave a far-future `last` behind, and peers ignore
336        // generations more than MAX_GENERATION_FUTURE_DRIFT_MS ahead of their own
337        // clocks (see `apply_updates`) — continuing from it would make every future
338        // broadcast a no-op. No correct-clock peer can have accepted a generation past
339        // that bound, so restarting from the current clock is safe (at worst briefly
340        // stale to peers that accepted a slightly-ahead generation).
341        if generation > now_ms + SharedTransactionDenyConfig::MAX_GENERATION_FUTURE_DRIFT_MS {
342            warn!(
343                last,
344                new_generation = now_ms,
345                "Persisted broadcast generation is too far in the future; resetting to current time",
346            );
347            generation = now_ms;
348        }
349        self.perpetual
350            .last_broadcast_deny_generation
351            .insert(&(), &generation)?;
352        Ok(generation)
353    }
354
355    /// Allocate a generation and build the broadcast message.
356    fn build_share_message(
357        &self,
358        rules: Option<TransactionDenyRules>,
359    ) -> SuiResult<SharedTransactionDenyConfig> {
360        if let Some(rules) = &rules {
361            rules.check_share_limits().map_err(SuiError::from)?;
362        }
363        let generation = self.allocate_next_broadcast_generation()?;
364        Ok(SharedTransactionDenyConfig::V1(
365            SharedTransactionDenyConfigV1 {
366                authority: self.self_authority,
367                generation,
368                rules,
369            },
370        ))
371    }
372
373    /// Publish `Some(rules)` as our proposal (or `None` to withdraw) to the network,
374    /// returning the allocated generation.
375    pub fn submit_broadcast(
376        &self,
377        rules: Option<TransactionDenyRules>,
378        consensus_adapter: &Arc<ConsensusAdapter>,
379        epoch_store: &Arc<AuthorityPerEpochStore>,
380    ) -> SuiResult<u64> {
381        let msg = self.build_share_message(rules)?;
382        let generation = msg.generation();
383        let tx = ConsensusTransaction::new_update_transaction_deny_config(msg.clone());
384        info!(?tx, "Updating transaction deny config vote");
385        consensus_adapter.submit(tx, None, epoch_store, None, None)?;
386        // Apply locally right away: validators do not verify their own blocks, so the
387        // verification-time path in SuiTxValidator never sees this message, and waiting
388        // for the commit handler to apply it would leave our own vote inactive until
389        // the message is sequenced.
390        self.apply_updates(self.self_authority, vec![msg]);
391        Ok(generation)
392    }
393
394    /// Republish metrics and atomically swap in the new effective config.
395    fn apply_evaluation(&self, evaluation: DenyConfigEvaluation, active_proposals: usize) {
396        self.metrics
397            .record(&self.local_config, active_proposals, &evaluation);
398        let prev = self.effective_config.load();
399        if &evaluation.effective_rules != prev.rules() {
400            let new_effective = self.local_config.with_rules(evaluation.effective_rules);
401            self.effective_config.store(Arc::new(new_effective));
402        }
403    }
404}
405
406/// Evaluate the current voting state from a manager's fields. A free function so `new`
407/// (which has no `self` yet) and the instance methods share one wiring.
408fn evaluate(
409    local_config: &TransactionDenyConfig,
410    sync_config: &PeerDenySyncConfig,
411    peer_configs: &BTreeMap<AuthorityName, SharedTransactionDenyConfig>,
412    committee: &Committee,
413) -> DenyConfigEvaluation {
414    let votes: BTreeMap<AuthorityName, &TransactionDenyRules> = peer_configs
415        .iter()
416        .filter(|(name, _)| committee.authority_exists(name))
417        .filter_map(|(name, msg)| msg.rules().map(|rules| (*name, rules)))
418        .collect();
419    let stakes: BTreeMap<AuthorityName, StakeUnit> = committee
420        .members()
421        .map(|(name, stake)| (*name, *stake))
422        .collect();
423    evaluate_deny_configs(local_config.rules(), sync_config, &votes, &stakes)
424}
425
426/// Count of committee members with an accepted (`Some`) proposal.
427fn active_proposal_count(
428    peer_configs: &BTreeMap<AuthorityName, SharedTransactionDenyConfig>,
429) -> usize {
430    peer_configs
431        .values()
432        .filter(|m| m.rules().is_some())
433        .count()
434}
435
436pub struct PrelistedRulesetStatus {
437    pub name: String,
438    pub stake_threshold_percent: u16,
439    pub eligible_stake: StakeUnit,
440    pub voted_stake: StakeUnit,
441    pub voters: Vec<AuthorityName>,
442    pub active: bool,
443}
444
445pub struct DefaultBucketStatus {
446    pub name: String,
447    pub element_kinds: BTreeSet<DenyElementKind>,
448    pub stake_threshold_percent: u16,
449    pub eligible_stake: StakeUnit,
450    pub applied_elements: Vec<DenyElement>,
451}
452
453/// Result of evaluating the deny-config voting state.
454pub struct DenyConfigEvaluation {
455    /// The merged result — already includes the always-on local rules as its base, so
456    /// this is the config to enforce, not a delta.
457    pub effective_rules: TransactionDenyRules,
458    pub prelisted: Vec<PrelistedRulesetStatus>,
459    pub defaults: Vec<DefaultBucketStatus>,
460}
461
462/// Returns true if `voted` is at least `percent`% of `eligible` stake.
463fn meets_threshold(voted: StakeUnit, eligible: StakeUnit, percent: u16) -> bool {
464    if eligible == 0 {
465        // Zero eligible stake never meets a threshold.
466        return false;
467    }
468    // `voted / eligible >= percent / 100`, cross-multiplied to stay in integer math.
469    // Both sides are bounded by `TOTAL_VOTING_POWER * 100` (1e6), well within `u64`.
470    let voted_share = voted * 100;
471    let required_share = StakeUnit::from(percent) * eligible;
472    voted_share >= required_share
473}
474
475/// Total eligible stake under `eligibility`, the denominator for its threshold.
476fn eligible_stake(
477    eligibility: &ValidatorEligibility,
478    stakes: &BTreeMap<AuthorityName, StakeUnit>,
479) -> StakeUnit {
480    match eligibility {
481        ValidatorEligibility::Allowlist(set) => {
482            set.iter().filter_map(|name| stakes.get(name)).sum()
483        }
484        ValidatorEligibility::Denylist(set) => {
485            TOTAL_VOTING_POWER
486                - set
487                    .iter()
488                    .filter_map(|name| stakes.get(name))
489                    .sum::<StakeUnit>()
490        }
491    }
492}
493
494/// Pure evaluation of the deny-config voting state.
495///
496/// Each pre-listed ruleset is evaluated independently — a proposal counts as a vote for
497/// *every* pre-listed ruleset whose rules it is a superset of (including
498/// nested/overlapping rulesets). Each default bucket considers proposed elements
499/// whose `DenyElementKind` it claims, threshold-gating them individually. Element kinds
500/// not claimed by any bucket cannot be activated through the default path.
501pub fn evaluate_deny_configs(
502    local_rules: &TransactionDenyRules,
503    sync_config: &PeerDenySyncConfig,
504    votes: &BTreeMap<AuthorityName, &TransactionDenyRules>,
505    stakes: &BTreeMap<AuthorityName, StakeUnit>,
506) -> DenyConfigEvaluation {
507    let prelisted: Vec<PrelistedRulesetStatus> = sync_config
508        .rulesets
509        .iter()
510        .map(|ruleset| {
511            let eligible_stake = eligible_stake(&ruleset.threshold.eligibility, stakes);
512            // Collect the voter names and sum their stake in a single pass.
513            let (voters, voted_stake): (Vec<AuthorityName>, StakeUnit) = votes
514                .iter()
515                .filter_map(|(name, rules)| {
516                    let stake = stakes.get(name)?;
517                    (ruleset.threshold.eligibility.is_eligible(name)
518                        && rules.is_superset_of(&ruleset.rules))
519                    .then_some((*name, *stake))
520                })
521                .fold((Vec::new(), 0), |(mut voters, total), (name, stake)| {
522                    voters.push(name);
523                    (voters, total + stake)
524                });
525            PrelistedRulesetStatus {
526                name: ruleset.name.clone(),
527                stake_threshold_percent: ruleset.threshold.stake_threshold_percent,
528                eligible_stake,
529                voted_stake,
530                active: meets_threshold(
531                    voted_stake,
532                    eligible_stake,
533                    ruleset.threshold.stake_threshold_percent,
534                ),
535                voters,
536            }
537        })
538        .collect();
539
540    // Build a kind → bucket-index lookup once. `validate()` guarantees each kind
541    // appears in at most one bucket.
542    let kind_to_bucket: BTreeMap<DenyElementKind, usize> = sync_config
543        .default_buckets
544        .iter()
545        .enumerate()
546        .flat_map(|(idx, bucket)| bucket.element_kinds.iter().map(move |k| (*k, idx)))
547        .collect();
548
549    // For each bucket, accumulate per-element stake from voters eligible for that
550    // bucket. Initialize one entry per bucket so the parallel index lookup is safe.
551    let mut per_bucket_element_stake: Vec<BTreeMap<DenyElement, StakeUnit>> =
552        vec![BTreeMap::new(); sync_config.default_buckets.len()];
553    for (name, rules) in votes {
554        let Some(stake) = stakes.get(name) else {
555            continue;
556        };
557        for element in rules.elements() {
558            let Some(&bucket_idx) = kind_to_bucket.get(&element.kind()) else {
559                continue;
560            };
561            let bucket = &sync_config.default_buckets[bucket_idx];
562            if !bucket.threshold.eligibility.is_eligible(name) {
563                continue;
564            }
565            *per_bucket_element_stake[bucket_idx]
566                .entry(element)
567                .or_default() += *stake;
568        }
569    }
570
571    let defaults: Vec<DefaultBucketStatus> = sync_config
572        .default_buckets
573        .iter()
574        .zip_eq(per_bucket_element_stake)
575        .map(|(bucket, element_stake)| {
576            let eligible_stake = eligible_stake(&bucket.threshold.eligibility, stakes);
577            let applied_elements: Vec<DenyElement> = element_stake
578                .into_iter()
579                .filter(|(_, stake)| {
580                    meets_threshold(
581                        *stake,
582                        eligible_stake,
583                        bucket.threshold.stake_threshold_percent,
584                    )
585                })
586                .map(|(element, _)| element)
587                .collect();
588            DefaultBucketStatus {
589                name: bucket.name.clone(),
590                element_kinds: bucket.element_kinds.clone(),
591                stake_threshold_percent: bucket.threshold.stake_threshold_percent,
592                eligible_stake,
593                applied_elements,
594            }
595        })
596        .collect();
597
598    let mut effective_rules = local_rules.clone();
599    for (ruleset, status) in sync_config.rulesets.iter().zip_eq(&prelisted) {
600        if status.active {
601            effective_rules.merge(&ruleset.rules);
602        }
603    }
604    for default in &defaults {
605        for element in &default.applied_elements {
606            effective_rules.apply_element(element);
607        }
608    }
609
610    DenyConfigEvaluation {
611        effective_rules,
612        prelisted,
613        defaults,
614    }
615}
616
617/// Gauges describing one view of a `TransactionDenyRules` — either the operator's local
618/// rules or the post-merge effective rules. Both views publish the same metric shape.
619struct DenyRulesGauges {
620    user_transaction_disabled: IntGauge,
621    shared_object_disabled: IntGauge,
622    package_publish_disabled: IntGauge,
623    package_upgrade_disabled: IntGauge,
624    num_denied_objects: IntGauge,
625    num_denied_packages: IntGauge,
626    num_denied_addresses: IntGauge,
627}
628
629impl DenyRulesGauges {
630    fn new(registry: &Registry, prefix: &str, layer_help: &str) -> Self {
631        let gauge = |name: &str, help: String| {
632            let g = IntGauge::new(format!("{prefix}_{name}"), help).unwrap();
633            registry.register(Box::new(g.clone())).unwrap();
634            g
635        };
636        Self {
637            user_transaction_disabled: gauge(
638                "user_transaction_disabled",
639                format!("1 if user_transaction_disabled is set in the {layer_help}"),
640            ),
641            shared_object_disabled: gauge(
642                "shared_object_disabled",
643                format!("1 if shared_object_disabled is set in the {layer_help}"),
644            ),
645            package_publish_disabled: gauge(
646                "package_publish_disabled",
647                format!("1 if package_publish_disabled is set in the {layer_help}"),
648            ),
649            package_upgrade_disabled: gauge(
650                "package_upgrade_disabled",
651                format!("1 if package_upgrade_disabled is set in the {layer_help}"),
652            ),
653            num_denied_objects: gauge(
654                "num_denied_objects",
655                format!("Number of objects in the {layer_help} object_deny_list"),
656            ),
657            num_denied_packages: gauge(
658                "num_denied_packages",
659                format!("Number of packages in the {layer_help} package_deny_list"),
660            ),
661            num_denied_addresses: gauge(
662                "num_denied_addresses",
663                format!("Number of addresses in the {layer_help} address_deny_list"),
664            ),
665        }
666    }
667
668    fn set_from(&self, rules: &TransactionDenyRules) {
669        self.user_transaction_disabled
670            .set(rules.user_transaction_disabled as i64);
671        self.shared_object_disabled
672            .set(rules.shared_object_disabled as i64);
673        self.package_publish_disabled
674            .set(rules.package_publish_disabled as i64);
675        self.package_upgrade_disabled
676            .set(rules.package_upgrade_disabled as i64);
677        self.num_denied_objects
678            .set(rules.object_deny_list.len() as i64);
679        self.num_denied_packages
680            .set(rules.package_deny_list.len() as i64);
681        self.num_denied_addresses
682            .set(rules.address_deny_list.len() as i64);
683    }
684}
685
686/// Prometheus metrics for the deny config manager. Distinguishes the operator's local
687/// rules from the post-merge effective rules, and exposes per-pre-listed-config voting
688/// status so dashboards can see "config X is one validator away from activating."
689pub struct TransactionDenyConfigMetrics {
690    local: DenyRulesGauges,
691    effective: DenyRulesGauges,
692    active_proposals: IntGauge,
693    dropped_updates: IntCounterVec,
694    default_bucket_applied_elements: IntGaugeVec,
695    default_bucket_eligible_stake: IntGaugeVec,
696    shared_config_active: IntGaugeVec,
697    shared_config_voted_bps: IntGaugeVec,
698    shared_config_eligible_stake: IntGaugeVec,
699}
700
701impl TransactionDenyConfigMetrics {
702    pub fn new(registry: &Registry) -> Self {
703        Self {
704            // The `tx_deny_config_*` prefix matches the legacy gauges that lived in
705            // `sui_config::node_config_metrics`, so existing dashboards keep working.
706            local: DenyRulesGauges::new(registry, "tx_deny_config", "local config"),
707            effective: DenyRulesGauges::new(
708                registry,
709                "tx_deny_effective",
710                "effective config (local + threshold-gated peer rules)",
711            ),
712            active_proposals: register_int_gauge_with_registry!(
713                "tx_deny_active_proposals",
714                "Number of committee members with an accepted (Some) deny-rule proposal",
715                registry,
716            )
717            .unwrap(),
718            dropped_updates: register_int_counter_vec_with_registry!(
719                "tx_deny_dropped_updates",
720                "Number of UpdateTransactionDenyConfig messages dropped without effect, by reason",
721                &["reason"],
722                registry,
723            )
724            .unwrap(),
725            default_bucket_applied_elements: register_int_gauge_vec_with_registry!(
726                "tx_deny_default_bucket_applied_elements",
727                "Number of rule elements activated via this default bucket",
728                &["bucket"],
729                registry,
730            )
731            .unwrap(),
732            default_bucket_eligible_stake: register_int_gauge_vec_with_registry!(
733                "tx_deny_default_bucket_eligible_stake",
734                "Total eligible voting stake (denominator) for this default bucket",
735                &["bucket"],
736                registry,
737            )
738            .unwrap(),
739            shared_config_active: register_int_gauge_vec_with_registry!(
740                "tx_deny_ruleset_active",
741                "1 if this pre-listed ruleset currently meets its stake threshold",
742                &["ruleset"],
743                registry,
744            )
745            .unwrap(),
746            shared_config_voted_bps: register_int_gauge_vec_with_registry!(
747                "tx_deny_ruleset_voted_bps",
748                "Voted stake as basis points of eligible stake for this pre-listed ruleset",
749                &["ruleset"],
750                registry,
751            )
752            .unwrap(),
753            shared_config_eligible_stake: register_int_gauge_vec_with_registry!(
754                "tx_deny_ruleset_eligible_stake",
755                "Total eligible voting stake for this pre-listed ruleset",
756                &["ruleset"],
757                registry,
758            )
759            .unwrap(),
760        }
761    }
762
763    pub fn record(
764        &self,
765        local: &TransactionDenyConfig,
766        active_proposals: usize,
767        evaluation: &DenyConfigEvaluation,
768    ) {
769        self.local.set_from(local.rules());
770        self.effective.set_from(&evaluation.effective_rules);
771        self.active_proposals.set(active_proposals as i64);
772
773        for status in &evaluation.prelisted {
774            let labels = &[status.name.as_str()];
775            self.shared_config_active
776                .with_label_values(labels)
777                .set(status.active as i64);
778            self.shared_config_voted_bps
779                .with_label_values(labels)
780                .set(stake_bps(status.voted_stake, status.eligible_stake));
781            self.shared_config_eligible_stake
782                .with_label_values(labels)
783                .set(status.eligible_stake as i64);
784        }
785
786        for default in &evaluation.defaults {
787            let labels = &[default.name.as_str()];
788            self.default_bucket_applied_elements
789                .with_label_values(labels)
790                .set(default.applied_elements.len() as i64);
791            self.default_bucket_eligible_stake
792                .with_label_values(labels)
793                .set(default.eligible_stake as i64);
794        }
795    }
796}
797
798fn stake_bps(voted: StakeUnit, eligible: StakeUnit) -> i64 {
799    if eligible == 0 {
800        0
801    } else {
802        ((voted as u128 * 10_000) / eligible as u128) as i64
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809    use fastcrypto::traits::VerifyingKey;
810    use sui_config::transaction_deny_config::{
811        DefaultDenyBucket, SharedDenyRuleThreshold, SharedDenyRuleset,
812        TransactionDenyConfigBuilder, ValidatorEligibility,
813    };
814    use sui_types::base_types::{ObjectID, dbg_addr};
815
816    fn fake_name(byte: u8) -> AuthorityName {
817        AuthorityName::new([byte; sui_types::crypto::AuthorityPublicKey::LENGTH])
818    }
819
820    /// A `TransactionDenyRules` denying the objects identified by `bytes` — the common
821    /// proposal/ruleset fixture across these tests.
822    fn rules_with_objects(bytes: &[u8]) -> TransactionDenyRules {
823        TransactionDenyRules {
824            object_deny_list: bytes
825                .iter()
826                .map(|b| ObjectID::from_single_byte(*b))
827                .collect(),
828            ..Default::default()
829        }
830    }
831
832    fn prelisted(
833        name: &str,
834        rules: TransactionDenyRules,
835        eligibility: ValidatorEligibility,
836        threshold: u16,
837    ) -> SharedDenyRuleset {
838        SharedDenyRuleset {
839            name: name.to_string(),
840            rules,
841            threshold: SharedDenyRuleThreshold {
842                eligibility,
843                stake_threshold_percent: threshold,
844            },
845        }
846    }
847
848    fn default_bucket(
849        name: &str,
850        kinds: &[DenyElementKind],
851        eligibility: ValidatorEligibility,
852        threshold: u16,
853    ) -> DefaultDenyBucket {
854        DefaultDenyBucket {
855            name: name.to_string(),
856            element_kinds: kinds.iter().copied().collect(),
857            threshold: SharedDenyRuleThreshold {
858                eligibility,
859                stake_threshold_percent: threshold,
860            },
861        }
862    }
863
864    // ===== Pure-evaluator tests (synthetic names + stakes, no committee) =====
865
866    fn equal_stakes(names: &[AuthorityName]) -> BTreeMap<AuthorityName, StakeUnit> {
867        names.iter().map(|n| (*n, 2500)).collect()
868    }
869
870    fn vote_refs(
871        owned: &BTreeMap<AuthorityName, TransactionDenyRules>,
872    ) -> BTreeMap<AuthorityName, &TransactionDenyRules> {
873        owned.iter().map(|(name, rules)| (*name, rules)).collect()
874    }
875
876    #[test]
877    fn meets_threshold_is_inclusive_and_handles_zero_eligible() {
878        // Inclusive: exactly at the threshold counts.
879        assert!(meets_threshold(5000, 10000, 50));
880        assert!(!meets_threshold(4999, 10000, 50));
881        // Zero eligible stake never meets a threshold.
882        assert!(!meets_threshold(0, 0, 0));
883    }
884
885    #[test]
886    fn evaluate_prelisted_threshold() {
887        let names: Vec<_> = (1..=4).map(fake_name).collect();
888        let stakes = equal_stakes(&names);
889        let local = TransactionDenyConfigBuilder::new().build();
890        let sync = PeerDenySyncConfig {
891            rulesets: vec![prelisted(
892                "c",
893                rules_with_objects(&[1]),
894                ValidatorEligibility::Allowlist(names.iter().copied().collect()),
895                60,
896            )],
897            ..Default::default()
898        };
899
900        // 2/4 voters = 5000 stake = 50%, below the 60% threshold -> inactive.
901        let votes: BTreeMap<_, _> = names[..2]
902            .iter()
903            .map(|n| (*n, rules_with_objects(&[1])))
904            .collect();
905        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
906        assert!(!eval.prelisted[0].active);
907        assert!(
908            !eval
909                .effective_rules
910                .object_deny_list
911                .contains(&ObjectID::from_single_byte(1))
912        );
913
914        // 3/4 voters = 7500 stake = 75% >= 60% -> active.
915        let votes: BTreeMap<_, _> = names[..3]
916            .iter()
917            .map(|n| (*n, rules_with_objects(&[1])))
918            .collect();
919        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
920        assert!(eval.prelisted[0].active);
921        assert!(
922            eval.effective_rules
923                .object_deny_list
924                .contains(&ObjectID::from_single_byte(1))
925        );
926    }
927
928    #[test]
929    fn evaluate_superset_votes_for_overlapping_and_nested_configs() {
930        let names: Vec<_> = (1..=4).map(fake_name).collect();
931        let stakes = equal_stakes(&names);
932        let local = TransactionDenyConfigBuilder::new().build();
933        let all = ValidatorEligibility::Allowlist(names.iter().copied().collect());
934        let sync = PeerDenySyncConfig {
935            rulesets: vec![
936                // Partial overlap with `y`, and a subset of `z`.
937                prelisted("x", rules_with_objects(&[1, 2]), all.clone(), 50),
938                prelisted("y", rules_with_objects(&[2, 3]), all.clone(), 50),
939                // Nesting: superset of `x`.
940                prelisted("z", rules_with_objects(&[1, 2, 3]), all, 50),
941            ],
942            ..Default::default()
943        };
944
945        // 3/4 validators each propose {1,2,3} — a superset of all three configs.
946        let votes: BTreeMap<_, _> = names[..3]
947            .iter()
948            .map(|n| (*n, rules_with_objects(&[1, 2, 3])))
949            .collect();
950        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
951        assert!(eval.prelisted.iter().all(|p| p.active));
952
953        // A proposal of just {1,2} votes for `x` only (superset of x, not y or z).
954        let votes: BTreeMap<_, _> = names[..3]
955            .iter()
956            .map(|n| (*n, rules_with_objects(&[1, 2])))
957            .collect();
958        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
959        assert!(eval.prelisted[0].active); // x
960        assert!(!eval.prelisted[1].active); // y
961        assert!(!eval.prelisted[2].active); // z
962    }
963
964    #[test]
965    fn evaluate_allowlist_vs_denylist_eligibility() {
966        let names: Vec<_> = (1..=4).map(fake_name).collect();
967        let stakes = equal_stakes(&names);
968        let local = TransactionDenyConfigBuilder::new().build();
969
970        // Allowlist of only names[0..2]: eligible stake is 5000. Only names[1] votes
971        // among the eligible (2500 = 50%), which is below the 60% threshold.
972        let allow = PeerDenySyncConfig {
973            rulesets: vec![prelisted(
974                "c",
975                rules_with_objects(&[1]),
976                ValidatorEligibility::Allowlist(names[..2].iter().copied().collect()),
977                60,
978            )],
979            ..Default::default()
980        };
981        // names[2] and names[3] vote but are not eligible -> no effect.
982        let votes: BTreeMap<_, _> = names[1..]
983            .iter()
984            .map(|n| (*n, rules_with_objects(&[1])))
985            .collect();
986        let eval = evaluate_deny_configs(local.rules(), &allow, &vote_refs(&votes), &stakes);
987        assert_eq!(eval.prelisted[0].eligible_stake, 5000);
988        assert_eq!(eval.prelisted[0].voted_stake, 2500); // only names[1] is eligible
989        assert!(!eval.prelisted[0].active);
990
991        // Denylist of names[0]: eligible stake is 7500 (names[1..4]).
992        let deny = PeerDenySyncConfig {
993            rulesets: vec![prelisted(
994                "c",
995                rules_with_objects(&[1]),
996                ValidatorEligibility::Denylist([names[0]].into_iter().collect()),
997                50,
998            )],
999            ..Default::default()
1000        };
1001        let eval = evaluate_deny_configs(local.rules(), &deny, &vote_refs(&votes), &stakes);
1002        assert_eq!(eval.prelisted[0].eligible_stake, 7500);
1003        assert_eq!(eval.prelisted[0].voted_stake, 7500);
1004        assert!(eval.prelisted[0].active);
1005    }
1006
1007    #[test]
1008    fn evaluate_default_per_element() {
1009        let names: Vec<_> = (1..=4).map(fake_name).collect();
1010        let stakes = equal_stakes(&names);
1011        let local = TransactionDenyConfigBuilder::new().build();
1012        let sync = PeerDenySyncConfig {
1013            default_buckets: vec![default_bucket(
1014                "objs",
1015                &[DenyElementKind::Object],
1016                ValidatorEligibility::default(),
1017                50,
1018            )],
1019            ..Default::default()
1020        };
1021
1022        // Object 1 proposed by 3 validators (active); object 2 by only 1 (inactive).
1023        let mut votes = BTreeMap::new();
1024        votes.insert(names[0], rules_with_objects(&[1, 2]));
1025        votes.insert(names[1], rules_with_objects(&[1]));
1026        votes.insert(names[2], rules_with_objects(&[1]));
1027        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
1028        assert!(
1029            eval.effective_rules
1030                .object_deny_list
1031                .contains(&ObjectID::from_single_byte(1))
1032        );
1033        assert!(
1034            !eval
1035                .effective_rules
1036                .object_deny_list
1037                .contains(&ObjectID::from_single_byte(2))
1038        );
1039        assert_eq!(eval.defaults.len(), 1);
1040        assert_eq!(
1041            eval.defaults[0].applied_elements,
1042            vec![DenyElement::Object(ObjectID::from_single_byte(1))]
1043        );
1044    }
1045
1046    #[test]
1047    fn evaluate_element_counts_for_both_prelisted_and_default() {
1048        let names: Vec<_> = (1..=4).map(fake_name).collect();
1049        let stakes = equal_stakes(&names);
1050        let local = TransactionDenyConfigBuilder::new().build();
1051        let all = ValidatorEligibility::Allowlist(names.iter().copied().collect());
1052        // Pre-listed ruleset `c` requires 90% (unreachable here); default requires 50%.
1053        let sync = PeerDenySyncConfig {
1054            rulesets: vec![prelisted("c", rules_with_objects(&[1]), all, 90)],
1055            default_buckets: vec![default_bucket(
1056                "objs",
1057                &[DenyElementKind::Object],
1058                ValidatorEligibility::default(),
1059                50,
1060            )],
1061            ..Default::default()
1062        };
1063        // 3/4 propose object 1: pre-listed ruleset `c` stays inactive (below 90%),
1064        // but the default bucket still applies it, counting it there independently.
1065        let votes: BTreeMap<_, _> = names[..3]
1066            .iter()
1067            .map(|n| (*n, rules_with_objects(&[1])))
1068            .collect();
1069        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
1070        assert!(!eval.prelisted[0].active);
1071        assert!(
1072            eval.effective_rules
1073                .object_deny_list
1074                .contains(&ObjectID::from_single_byte(1))
1075        );
1076    }
1077
1078    #[test]
1079    fn evaluate_default_buckets_segregate_by_kind() {
1080        // Two buckets with different thresholds. All four validators vote both an
1081        // object and `UserTransactionDisabled`. The object kind is in bucket A at
1082        // 50% (≤ 75% achieved, activates); the kill-switch kind is in bucket B at
1083        // 90% (> 75% achieved, does not activate).
1084        let names: Vec<_> = (1..=4).map(fake_name).collect();
1085        let stakes = equal_stakes(&names);
1086        let local = TransactionDenyConfigBuilder::new().build();
1087        let sync = PeerDenySyncConfig {
1088            default_buckets: vec![
1089                default_bucket(
1090                    "objs",
1091                    &[DenyElementKind::Object],
1092                    ValidatorEligibility::default(),
1093                    50,
1094                ),
1095                default_bucket(
1096                    "kill-switches",
1097                    &[DenyElementKind::UserTransactionDisabled],
1098                    ValidatorEligibility::default(),
1099                    90,
1100                ),
1101            ],
1102            ..Default::default()
1103        };
1104
1105        let proposal = TransactionDenyRules {
1106            object_deny_list: [ObjectID::from_single_byte(1)].into_iter().collect(),
1107            user_transaction_disabled: true,
1108            ..Default::default()
1109        };
1110        let votes: BTreeMap<_, _> = names[..3].iter().map(|n| (*n, proposal.clone())).collect();
1111        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
1112
1113        assert!(
1114            eval.effective_rules
1115                .object_deny_list
1116                .contains(&ObjectID::from_single_byte(1))
1117        );
1118        assert!(!eval.effective_rules.user_transaction_disabled);
1119        assert_eq!(eval.defaults.len(), 2);
1120        assert_eq!(
1121            eval.defaults[0].applied_elements,
1122            vec![DenyElement::Object(ObjectID::from_single_byte(1))],
1123        );
1124        assert!(eval.defaults[1].applied_elements.is_empty());
1125    }
1126
1127    #[test]
1128    fn evaluate_default_unconfigured_kind_never_applies() {
1129        // The lone default bucket covers `Object` only. `UserTransactionDisabled` is
1130        // claimed by no bucket, so even unanimous votes leave it inactive.
1131        let names: Vec<_> = (1..=4).map(fake_name).collect();
1132        let stakes = equal_stakes(&names);
1133        let local = TransactionDenyConfigBuilder::new().build();
1134        let sync = PeerDenySyncConfig {
1135            default_buckets: vec![default_bucket(
1136                "objs",
1137                &[DenyElementKind::Object],
1138                ValidatorEligibility::default(),
1139                50,
1140            )],
1141            ..Default::default()
1142        };
1143
1144        let proposal = TransactionDenyRules {
1145            user_transaction_disabled: true,
1146            ..Default::default()
1147        };
1148        let votes: BTreeMap<_, _> = names.iter().map(|n| (*n, proposal.clone())).collect();
1149        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
1150
1151        assert!(!eval.effective_rules.user_transaction_disabled);
1152        assert!(eval.defaults[0].applied_elements.is_empty());
1153    }
1154
1155    #[test]
1156    fn evaluate_default_bucket_eligibility_is_per_bucket() {
1157        // Two buckets covering disjoint kinds with disjoint eligibility sets.
1158        // names[0..2] are eligible for `objs`; names[2..4] for `kill-switches`.
1159        // A voter eligible for one bucket but not the other contributes only to the
1160        // bucket that lists them.
1161        let names: Vec<_> = (1..=4).map(fake_name).collect();
1162        let stakes = equal_stakes(&names);
1163        let local = TransactionDenyConfigBuilder::new().build();
1164        let objs_allowlist = ValidatorEligibility::Allowlist(names[..2].iter().copied().collect());
1165        let kill_allowlist = ValidatorEligibility::Allowlist(names[2..].iter().copied().collect());
1166        let sync = PeerDenySyncConfig {
1167            default_buckets: vec![
1168                default_bucket("objs", &[DenyElementKind::Object], objs_allowlist, 50),
1169                default_bucket(
1170                    "kill-switches",
1171                    &[DenyElementKind::UserTransactionDisabled],
1172                    kill_allowlist,
1173                    50,
1174                ),
1175            ],
1176            ..Default::default()
1177        };
1178
1179        // Every validator proposes both an object and `UserTransactionDisabled`.
1180        let proposal = TransactionDenyRules {
1181            object_deny_list: [ObjectID::from_single_byte(1)].into_iter().collect(),
1182            user_transaction_disabled: true,
1183            ..Default::default()
1184        };
1185        let votes: BTreeMap<_, _> = names.iter().map(|n| (*n, proposal.clone())).collect();
1186        let eval = evaluate_deny_configs(local.rules(), &sync, &vote_refs(&votes), &stakes);
1187
1188        // Bucket A: only names[0..2] (5000 stake = 100% of eligible) vote for the
1189        // object — activates.
1190        assert_eq!(eval.defaults[0].eligible_stake, 5000);
1191        assert_eq!(
1192            eval.defaults[0].applied_elements,
1193            vec![DenyElement::Object(ObjectID::from_single_byte(1))],
1194        );
1195        // Bucket B: only names[2..4] (5000 stake = 100% of eligible) vote for the
1196        // kill switch — activates.
1197        assert_eq!(eval.defaults[1].eligible_stake, 5000);
1198        assert_eq!(
1199            eval.defaults[1].applied_elements,
1200            vec![DenyElement::UserTransactionDisabled],
1201        );
1202        assert!(
1203            eval.effective_rules
1204                .object_deny_list
1205                .contains(&ObjectID::from_single_byte(1))
1206        );
1207        assert!(eval.effective_rules.user_transaction_disabled);
1208    }
1209
1210    #[test]
1211    fn evaluate_local_rules_always_applied() {
1212        let names: Vec<_> = (1..=4).map(fake_name).collect();
1213        let stakes = equal_stakes(&names);
1214        let local = TransactionDenyConfigBuilder::new()
1215            .add_denied_address(dbg_addr(9))
1216            .build();
1217        let sync = PeerDenySyncConfig::default();
1218        let eval = evaluate_deny_configs(local.rules(), &sync, &BTreeMap::new(), &stakes);
1219        assert!(
1220            eval.effective_rules
1221                .address_deny_list
1222                .contains(&dbg_addr(9))
1223        );
1224    }
1225
1226    // ===== Manager tests (real committee so authority names are valid) =====
1227
1228    fn test_committee(size: usize) -> (Arc<Committee>, Vec<AuthorityName>) {
1229        let (committee, _kps) =
1230            Committee::new_simple_test_committee_with_normalized_voting_power(vec![1; size]);
1231        let names: Vec<AuthorityName> = committee.names().copied().collect();
1232        (Arc::new(committee), names)
1233    }
1234
1235    fn open_perpetual() -> (tempfile::TempDir, Arc<AuthorityPerpetualTables>) {
1236        let dir = tempfile::tempdir().unwrap();
1237        let perpetual = Arc::new(AuthorityPerpetualTables::open(dir.path(), None, None));
1238        (dir, perpetual)
1239    }
1240
1241    fn manager_with(
1242        self_authority: AuthorityName,
1243        local: TransactionDenyConfig,
1244        sync_config: PeerDenySyncConfig,
1245        committee: Arc<Committee>,
1246    ) -> (Arc<TransactionDenyConfigManager>, tempfile::TempDir) {
1247        let (dir, perpetual) = open_perpetual();
1248        let registry = Registry::new();
1249        let manager = TransactionDenyConfigManager::new(
1250            self_authority,
1251            local,
1252            sync_config,
1253            committee,
1254            perpetual,
1255            &registry,
1256        )
1257        .unwrap();
1258        (manager, dir)
1259    }
1260
1261    fn make_msg(
1262        authority: AuthorityName,
1263        generation: u64,
1264        rules: Option<TransactionDenyRules>,
1265    ) -> SharedTransactionDenyConfig {
1266        SharedTransactionDenyConfig::V1(SharedTransactionDenyConfigV1 {
1267            authority,
1268            generation,
1269            rules,
1270        })
1271    }
1272
1273    /// A pre-listed ruleset eligible for the whole committee at a 50% threshold.
1274    fn sync_with_prelisted(committee: &Committee) -> PeerDenySyncConfig {
1275        PeerDenySyncConfig {
1276            rulesets: vec![prelisted(
1277                "c",
1278                rules_with_objects(&[1]),
1279                ValidatorEligibility::Allowlist(committee.names().copied().collect()),
1280                50,
1281            )],
1282            ..Default::default()
1283        }
1284    }
1285
1286    #[tokio::test(flavor = "multi_thread")]
1287    async fn apply_updates_rejects_non_committee_sender() {
1288        let (committee, names) = test_committee(4);
1289        let local = TransactionDenyConfigBuilder::new().build();
1290        let (manager, _dir) =
1291            manager_with(names[0], local, sync_with_prelisted(&committee), committee);
1292
1293        let outsider = fake_name(200);
1294        manager.apply_updates(
1295            outsider,
1296            vec![make_msg(outsider, 1, Some(rules_with_objects(&[1])))],
1297        );
1298        assert!(manager.peer_configs_snapshot().is_empty());
1299    }
1300
1301    /// A message claiming an authority other than the authenticated sender is dropped,
1302    /// even when both are committee members.
1303    #[tokio::test(flavor = "multi_thread")]
1304    async fn apply_updates_drops_author_mismatch() {
1305        let (committee, names) = test_committee(4);
1306        let local = TransactionDenyConfigBuilder::new().build();
1307        let (manager, _dir) =
1308            manager_with(names[0], local, sync_with_prelisted(&committee), committee);
1309
1310        manager.apply_updates(
1311            names[2],
1312            vec![make_msg(names[1], 1, Some(rules_with_objects(&[1])))],
1313        );
1314        assert!(manager.peer_configs_snapshot().is_empty());
1315    }
1316
1317    /// A self-originated message (as applied by `submit_broadcast`) is accepted like
1318    /// any peer's — it is not dropped as a self-loopback.
1319    #[tokio::test(flavor = "multi_thread")]
1320    async fn apply_updates_accepts_self_broadcast() {
1321        let (committee, names) = test_committee(4);
1322        let local = TransactionDenyConfigBuilder::new().build();
1323        let (manager, _dir) =
1324            manager_with(names[0], local, sync_with_prelisted(&committee), committee);
1325
1326        manager.apply_updates(
1327            names[0],
1328            vec![make_msg(names[0], 1, Some(rules_with_objects(&[1])))],
1329        );
1330        let snapshot = manager.peer_configs_snapshot();
1331        assert!(
1332            snapshot.contains_key(&names[0]),
1333            "self-broadcast should be accepted into peer_configs",
1334        );
1335        // It also counts as a vote: self is one voter for ruleset `c`.
1336        let status = manager.evaluate_status();
1337        assert_eq!(status.prelisted[0].voters, vec![names[0]]);
1338    }
1339
1340    #[tokio::test(flavor = "multi_thread")]
1341    async fn apply_updates_ignores_stale_generations() {
1342        let (committee, names) = test_committee(4);
1343        let local = TransactionDenyConfigBuilder::new().build();
1344        let (manager, _dir) =
1345            manager_with(names[0], local, sync_with_prelisted(&committee), committee);
1346
1347        manager.apply_updates(
1348            names[1],
1349            vec![make_msg(names[1], 100, Some(rules_with_objects(&[1])))],
1350        );
1351        // Older generation must be dropped even though it carries different rules.
1352        manager.apply_updates(
1353            names[1],
1354            vec![make_msg(names[1], 50, Some(rules_with_objects(&[2])))],
1355        );
1356        let snapshot = manager.peer_configs_snapshot();
1357        assert_eq!(snapshot.get(&names[1]).unwrap().generation(), 100);
1358    }
1359
1360    /// A far-future generation must be ignored entirely — in particular it must not
1361    /// advance the persisted high-water mark, or it would permanently block the
1362    /// sender's subsequent (sane) updates.
1363    #[tokio::test(flavor = "multi_thread")]
1364    async fn apply_updates_ignores_far_future_generations() {
1365        let (committee, names) = test_committee(4);
1366        let local = TransactionDenyConfigBuilder::new().build();
1367        let (manager, _dir) =
1368            manager_with(names[0], local, sync_with_prelisted(&committee), committee);
1369
1370        let now_ms = AuthorityState::unixtime_now_ms();
1371        let far_future =
1372            now_ms + SharedTransactionDenyConfig::MAX_GENERATION_FUTURE_DRIFT_MS + 600_000;
1373        manager.apply_updates(
1374            names[1],
1375            vec![make_msg(
1376                names[1],
1377                far_future,
1378                Some(rules_with_objects(&[1])),
1379            )],
1380        );
1381        assert!(manager.peer_configs_snapshot().is_empty());
1382
1383        // A sane generation from the same sender still lands afterwards.
1384        manager.apply_updates(
1385            names[1],
1386            vec![make_msg(names[1], now_ms, Some(rules_with_objects(&[2])))],
1387        );
1388        assert_eq!(
1389            manager
1390                .peer_configs_snapshot()
1391                .get(&names[1])
1392                .unwrap()
1393                .generation(),
1394            now_ms
1395        );
1396    }
1397
1398    /// On construction the manager seeds `peer_configs` from the perpetual store,
1399    /// keeping committee peers but skipping (a) its own persisted broadcast — which a
1400    /// since-edited local config could have made stale — and (b) authorities no longer
1401    /// in the committee.
1402    #[tokio::test(flavor = "multi_thread")]
1403    async fn new_seeds_committee_peers_only() {
1404        let (committee, names) = test_committee(4);
1405        let (dir, perpetual) = open_perpetual();
1406        // Self, a committee peer, and an outsider all have a persisted broadcast.
1407        for authority in [names[0], names[1], fake_name(200)] {
1408            perpetual
1409                .shared_transaction_deny_configs
1410                .insert(
1411                    &authority,
1412                    &make_msg(authority, 5, Some(rules_with_objects(&[1]))),
1413                )
1414                .unwrap();
1415        }
1416
1417        let registry = Registry::new();
1418        let manager = TransactionDenyConfigManager::new(
1419            names[0],
1420            TransactionDenyConfigBuilder::new().build(),
1421            sync_with_prelisted(&committee),
1422            committee,
1423            perpetual,
1424            &registry,
1425        )
1426        .unwrap();
1427
1428        let snapshot = manager.peer_configs_snapshot();
1429        assert!(
1430            snapshot.contains_key(&names[1]),
1431            "committee peer should be seeded",
1432        );
1433        assert!(
1434            !snapshot.contains_key(&names[0]),
1435            "own persisted broadcast must not be seeded",
1436        );
1437        assert!(
1438            !snapshot.contains_key(&fake_name(200)),
1439            "non-committee authority must not be seeded",
1440        );
1441        drop(dir);
1442    }
1443
1444    #[tokio::test(flavor = "multi_thread")]
1445    async fn update_for_committee_prunes_departed_peer() {
1446        let (committee, names) = test_committee(4);
1447        let local = TransactionDenyConfigBuilder::new().build();
1448        let (manager, _dir) = manager_with(
1449            names[0],
1450            local,
1451            sync_with_prelisted(&committee),
1452            committee.clone(),
1453        );
1454
1455        manager.apply_updates(
1456            names[1],
1457            vec![make_msg(names[1], 1, Some(rules_with_objects(&[1])))],
1458        );
1459        assert!(manager.peer_configs_snapshot().contains_key(&names[1]));
1460
1461        // New committee drops names[1]; the manager prunes its cached entry.
1462        let remaining: BTreeMap<AuthorityName, StakeUnit> = committee
1463            .members()
1464            .filter(|&&(name, _)| name != names[1])
1465            .map(|(name, _)| (*name, 1))
1466            .collect();
1467        let new_committee = Arc::new(Committee::new_for_testing_with_normalized_voting_power(
1468            committee.epoch(),
1469            remaining,
1470        ));
1471        manager.update_for_committee(new_committee).unwrap();
1472        assert!(!manager.peer_configs_snapshot().contains_key(&names[1]));
1473    }
1474
1475    #[tokio::test(flavor = "multi_thread")]
1476    async fn withdrawal_clears_contribution_but_keeps_row() {
1477        let (committee, names) = test_committee(4);
1478        let local = TransactionDenyConfigBuilder::new().build();
1479        let sync = PeerDenySyncConfig {
1480            default_buckets: vec![default_bucket(
1481                "objs",
1482                &[DenyElementKind::Object],
1483                ValidatorEligibility::default(),
1484                10,
1485            )],
1486            ..Default::default()
1487        };
1488        let (manager, _dir) = manager_with(names[0], local, sync, committee);
1489
1490        manager.apply_updates(
1491            names[1],
1492            vec![make_msg(names[1], 10, Some(rules_with_objects(&[1])))],
1493        );
1494        assert!(
1495            manager
1496                .effective_config()
1497                .load()
1498                .get_object_deny_set()
1499                .contains(&ObjectID::from_single_byte(1))
1500        );
1501
1502        manager.apply_updates(names[1], vec![make_msg(names[1], 11, None)]);
1503        assert!(
1504            manager
1505                .effective_config()
1506                .load()
1507                .get_object_deny_set()
1508                .is_empty()
1509        );
1510        // Row kept (rules=None) so a delayed older `Some` can't resurrect it.
1511        let snapshot = manager.peer_configs_snapshot();
1512        let entry = snapshot.get(&names[1]).unwrap();
1513        assert_eq!(entry.generation(), 11);
1514        assert!(entry.rules().is_none());
1515    }
1516
1517    #[tokio::test(flavor = "multi_thread")]
1518    async fn allocate_next_broadcast_generation_is_monotonic() {
1519        let (committee, names) = test_committee(4);
1520        let local = TransactionDenyConfigBuilder::new().build();
1521        let (manager, _dir) =
1522            manager_with(names[0], local, PeerDenySyncConfig::default(), committee);
1523
1524        let g1 = manager.allocate_next_broadcast_generation().unwrap();
1525        let g2 = manager.allocate_next_broadcast_generation().unwrap();
1526        let g3 = manager.allocate_next_broadcast_generation().unwrap();
1527        assert!(g2 > g1);
1528        assert!(g3 > g2);
1529    }
1530
1531    /// A persisted counter beyond the network's future-drift bound (e.g. from a past
1532    /// clock excursion) would make every broadcast invalid at consensus voting, so
1533    /// allocation must reset it to the current wall clock instead of continuing from it.
1534    #[tokio::test(flavor = "multi_thread")]
1535    async fn allocate_next_broadcast_generation_resets_far_future_counter() {
1536        let (committee, names) = test_committee(4);
1537        let local = TransactionDenyConfigBuilder::new().build();
1538        let (manager, _dir) =
1539            manager_with(names[0], local, PeerDenySyncConfig::default(), committee);
1540
1541        let poisoned = u64::MAX - 1;
1542        manager
1543            .perpetual
1544            .last_broadcast_deny_generation
1545            .insert(&(), &poisoned)
1546            .unwrap();
1547
1548        let generation = manager.allocate_next_broadcast_generation().unwrap();
1549        let now_ms = AuthorityState::unixtime_now_ms();
1550        assert!(
1551            generation <= now_ms + SharedTransactionDenyConfig::MAX_GENERATION_FUTURE_DRIFT_MS,
1552            "generation {generation} not reset below the future-drift bound",
1553        );
1554        // Monotonicity resumes from the reset value.
1555        assert!(manager.allocate_next_broadcast_generation().unwrap() > generation);
1556    }
1557
1558    /// `build_share_message` is the single chokepoint for outgoing broadcasts: it
1559    /// rejects rules the consensus validator would otherwise reject downstream.
1560    #[tokio::test(flavor = "multi_thread")]
1561    async fn build_share_message_enforces_share_limit() {
1562        let (committee, names) = test_committee(4);
1563        let (manager, _dir) = manager_with(
1564            names[0],
1565            TransactionDenyConfigBuilder::new().build(),
1566            PeerDenySyncConfig::default(),
1567            committee,
1568        );
1569
1570        // Within-limit rules and a withdrawal both build fine.
1571        assert!(
1572            manager
1573                .build_share_message(Some(rules_with_objects(&[1])))
1574                .is_ok()
1575        );
1576        assert!(manager.build_share_message(None).is_ok());
1577
1578        // A zkLogin provider name past the per-string limit makes the rules
1579        // unshareable, so the build is rejected.
1580        let oversized = TransactionDenyRules {
1581            zklogin_disabled_providers: std::iter::once(
1582                "x".repeat(TransactionDenyRules::MAX_ZKLOGIN_PROVIDER_LENGTH + 1),
1583            )
1584            .collect(),
1585            ..Default::default()
1586        };
1587        assert!(manager.build_share_message(Some(oversized)).is_err());
1588    }
1589
1590    #[tokio::test(flavor = "multi_thread")]
1591    async fn invalid_sync_config_is_rejected() {
1592        let (committee, names) = test_committee(4);
1593        let (_dir, perpetual) = open_perpetual();
1594        let registry = Registry::new();
1595        // Threshold out of range.
1596        let bad = PeerDenySyncConfig {
1597            rulesets: vec![prelisted(
1598                "c",
1599                rules_with_objects(&[1]),
1600                ValidatorEligibility::default(),
1601                150,
1602            )],
1603            ..Default::default()
1604        };
1605        assert!(
1606            TransactionDenyConfigManager::new(
1607                names[0],
1608                TransactionDenyConfigBuilder::new().build(),
1609                bad,
1610                committee,
1611                perpetual,
1612                &registry,
1613            )
1614            .is_err()
1615        );
1616    }
1617}