1use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use sui_types::base_types::{AuthorityName, ObjectID, SuiAddress};
8pub use sui_types::transaction_deny_rules::{DenyElementKind, TransactionDenyRules};
9
10use crate::dynamic_transaction_signing_checks::{
11 DynamicCheckRunnerContext, DynamicCheckRunnerError,
12};
13
14#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
19#[serde(rename_all = "kebab-case")]
20pub struct PeerDenySyncConfig {
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub rulesets: Vec<SharedDenyRuleset>,
25
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub default_buckets: Vec<DefaultDenyBucket>,
31
32 #[serde(default)]
33 pub broadcast_on_startup: bool,
34
35 #[serde(default)]
36 pub broadcast_on_epoch_change: bool,
37}
38
39#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
43#[serde(rename_all = "kebab-case")]
44pub struct SharedDenyRuleset {
45 pub name: String,
47 pub rules: TransactionDenyRules,
48 #[serde(flatten)]
49 pub threshold: SharedDenyRuleThreshold,
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
54#[serde(rename_all = "kebab-case")]
55pub struct SharedDenyRuleThreshold {
56 pub eligibility: ValidatorEligibility,
57 pub stake_threshold_percent: u16,
59}
60
61#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
65#[serde(rename_all = "kebab-case")]
66pub struct DefaultDenyBucket {
67 pub name: String,
69 pub element_kinds: BTreeSet<DenyElementKind>,
72 #[serde(flatten)]
73 pub threshold: SharedDenyRuleThreshold,
74}
75
76#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
78#[serde(rename_all = "kebab-case")]
79pub enum ValidatorEligibility {
80 Allowlist(BTreeSet<AuthorityName>),
82 Denylist(BTreeSet<AuthorityName>),
84}
85
86impl ValidatorEligibility {
87 pub fn is_eligible(&self, name: &AuthorityName) -> bool {
88 match self {
89 ValidatorEligibility::Allowlist(set) => set.contains(name),
90 ValidatorEligibility::Denylist(set) => !set.contains(name),
91 }
92 }
93}
94
95impl Default for ValidatorEligibility {
96 fn default() -> Self {
97 ValidatorEligibility::Denylist(BTreeSet::new())
99 }
100}
101
102impl PeerDenySyncConfig {
103 pub fn validate(&self) -> Result<(), String> {
105 let mut names = BTreeSet::new();
106 for ruleset in &self.rulesets {
107 if ruleset.name.is_empty() {
108 return Err("rulesets entry has an empty name".to_string());
109 }
110 if !names.insert(ruleset.name.as_str()) {
111 return Err(format!("duplicate rulesets name: {}", ruleset.name));
112 }
113 if ruleset.rules.is_empty() {
114 return Err(format!("rulesets entry {} has empty rules", ruleset.name));
115 }
116 ruleset.threshold.validate(&ruleset.name)?;
117 }
118 let mut seen_kinds = BTreeSet::new();
119 for bucket in &self.default_buckets {
120 if bucket.name.is_empty() {
121 return Err("default_buckets entry has an empty name".to_string());
122 }
123 if !names.insert(bucket.name.as_str()) {
124 return Err(format!(
125 "default_buckets name collides with another ruleset or bucket: {}",
126 bucket.name,
127 ));
128 }
129 if bucket.element_kinds.is_empty() {
130 return Err(format!(
131 "default_buckets entry {} has empty element_kinds",
132 bucket.name,
133 ));
134 }
135 for kind in &bucket.element_kinds {
136 if !seen_kinds.insert(*kind) {
137 return Err(format!(
138 "default_buckets entry {} claims element kind {:?} already \
139 claimed by another bucket",
140 bucket.name, kind,
141 ));
142 }
143 }
144 bucket.threshold.validate(&bucket.name)?;
145 }
146 Ok(())
147 }
148}
149
150impl SharedDenyRuleThreshold {
151 pub fn validate(&self, label: &str) -> Result<(), String> {
155 if self.stake_threshold_percent == 0 || self.stake_threshold_percent > 100 {
156 return Err(format!(
157 "{label}: stake_threshold_percent must be 1..=100, got {}",
158 self.stake_threshold_percent,
159 ));
160 }
161 Ok(())
162 }
163}
164
165#[derive(Clone, Debug, Default, Deserialize, Serialize)]
166#[serde(rename_all = "kebab-case")]
167pub struct TransactionDenyConfig {
168 #[serde(flatten)]
170 rules: TransactionDenyRules,
171
172 #[serde(
177 default,
178 skip_serializing_if = "Option::is_none",
179 serialize_with = "crate::dynamic_transaction_signing_checks::serialize_dynamic_transaction_checks",
180 deserialize_with = "crate::dynamic_transaction_signing_checks::deserialize_dynamic_transaction_checks"
181 )]
182 dynamic_transaction_checks: Option<DynamicCheckRunnerContext>,
183}
184
185impl TransactionDenyConfig {
186 pub fn rules(&self) -> &TransactionDenyRules {
187 &self.rules
188 }
189
190 pub fn get_object_deny_set(&self) -> &BTreeSet<ObjectID> {
191 &self.rules.object_deny_list
192 }
193
194 pub fn get_package_deny_set(&self) -> &BTreeSet<ObjectID> {
195 &self.rules.package_deny_list
196 }
197
198 pub fn get_address_deny_set(&self) -> &BTreeSet<SuiAddress> {
199 &self.rules.address_deny_list
200 }
201
202 pub fn package_publish_disabled(&self) -> bool {
203 self.rules.package_publish_disabled
204 }
205
206 pub fn package_upgrade_disabled(&self) -> bool {
207 self.rules.package_upgrade_disabled
208 }
209
210 pub fn shared_object_disabled(&self) -> bool {
211 self.rules.shared_object_disabled
212 }
213
214 pub fn user_transaction_disabled(&self) -> bool {
215 self.rules.user_transaction_disabled
216 }
217
218 pub fn gasless_disabled(&self) -> bool {
219 self.rules.gasless_disabled
220 }
221
222 pub fn receiving_objects_disabled(&self) -> bool {
223 self.rules.receiving_objects_disabled
224 }
225
226 pub fn zklogin_sig_disabled(&self) -> bool {
227 self.rules.zklogin_sig_disabled
228 }
229
230 pub fn zklogin_disabled_providers(&self) -> &BTreeSet<String> {
231 &self.rules.zklogin_disabled_providers
232 }
233
234 pub fn dynamic_transaction_checks(&self) -> &Option<DynamicCheckRunnerContext> {
235 &self.dynamic_transaction_checks
236 }
237
238 pub fn has_dynamic_transaction_checks(&self) -> bool {
239 self.dynamic_transaction_checks.is_some()
240 }
241
242 pub fn with_rules(&self, rules: TransactionDenyRules) -> Self {
245 Self {
246 rules,
247 dynamic_transaction_checks: self.dynamic_transaction_checks.clone(),
248 }
249 }
250}
251
252#[derive(Default)]
253pub struct TransactionDenyConfigBuilder {
254 config: TransactionDenyConfig,
255}
256
257impl TransactionDenyConfigBuilder {
258 pub fn new() -> Self {
259 Self::default()
260 }
261
262 pub fn build(self) -> TransactionDenyConfig {
263 self.config
264 }
265
266 pub fn disable_user_transaction(mut self) -> Self {
267 self.config.rules.user_transaction_disabled = true;
268 self
269 }
270
271 pub fn disable_gasless(mut self) -> Self {
272 self.config.rules.gasless_disabled = true;
273 self
274 }
275
276 pub fn disable_shared_object_transaction(mut self) -> Self {
277 self.config.rules.shared_object_disabled = true;
278 self
279 }
280
281 pub fn disable_package_publish(mut self) -> Self {
282 self.config.rules.package_publish_disabled = true;
283 self
284 }
285
286 pub fn disable_package_upgrade(mut self) -> Self {
287 self.config.rules.package_upgrade_disabled = true;
288 self
289 }
290
291 pub fn disable_receiving_objects(mut self) -> Self {
292 self.config.rules.receiving_objects_disabled = true;
293 self
294 }
295
296 pub fn add_denied_object(mut self, id: ObjectID) -> Self {
297 self.config.rules.object_deny_list.insert(id);
298 self
299 }
300
301 pub fn add_denied_address(mut self, address: SuiAddress) -> Self {
302 self.config.rules.address_deny_list.insert(address);
303 self
304 }
305
306 pub fn add_denied_package(mut self, id: ObjectID) -> Self {
307 self.config.rules.package_deny_list.insert(id);
308 self
309 }
310
311 pub fn disable_zklogin_sig(mut self) -> Self {
312 self.config.rules.zklogin_sig_disabled = true;
313 self
314 }
315
316 pub fn add_zklogin_disabled_provider(mut self, provider: String) -> Self {
317 self.config
318 .rules
319 .zklogin_disabled_providers
320 .insert(provider);
321 self
322 }
323
324 pub fn add_dynamic_transaction_checks(
325 mut self,
326 checks: String,
327 ) -> Result<Self, DynamicCheckRunnerError> {
328 self.config.dynamic_transaction_checks = Some(DynamicCheckRunnerContext::new(checks)?);
329 Ok(self)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use sui_types::base_types::dbg_addr;
337
338 #[test]
339 fn with_rules_replaces_rules_and_keeps_dynamic_checks() {
340 let starlark =
341 "def predicate(tx_data, tx_signatures, input_objects, receiving_objects):\n pass\n"
342 .to_string();
343 let local = TransactionDenyConfigBuilder::new()
344 .add_denied_object(ObjectID::from_single_byte(1))
345 .add_dynamic_transaction_checks(starlark)
346 .expect("starlark should parse")
347 .build();
348
349 let mut new_rules = TransactionDenyRules::default();
350 new_rules
351 .object_deny_list
352 .insert(ObjectID::from_single_byte(2));
353 new_rules.user_transaction_disabled = true;
354
355 let updated = local.with_rules(new_rules);
356
357 assert!(
358 !updated
359 .get_object_deny_set()
360 .contains(&ObjectID::from_single_byte(1))
361 );
362 assert!(
363 updated
364 .get_object_deny_set()
365 .contains(&ObjectID::from_single_byte(2))
366 );
367 assert!(updated.user_transaction_disabled());
368 assert!(updated.has_dynamic_transaction_checks());
370 }
371
372 #[test]
373 fn transaction_deny_config_yaml_preserves_existing_schema() {
374 let yaml = r#"
377 package-publish-disabled: true
378 user-transaction-disabled: false
379 object-deny-list:
380 - "0x0101010101010101010101010101010101010101010101010101010101010101"
381 "#;
382 let cfg: TransactionDenyConfig = serde_yaml::from_str(yaml).unwrap();
383 assert!(cfg.package_publish_disabled());
384 assert!(!cfg.user_transaction_disabled());
385 assert_eq!(cfg.get_object_deny_set().len(), 1);
386 }
387
388 #[test]
393 fn transaction_deny_config_yaml_round_trip() {
394 let cfg = TransactionDenyConfigBuilder::new()
395 .add_denied_object(ObjectID::from_single_byte(1))
396 .add_denied_object(ObjectID::from_single_byte(2))
397 .add_denied_package(ObjectID::from_single_byte(3))
398 .add_denied_address(dbg_addr(4))
399 .disable_user_transaction()
400 .disable_gasless()
401 .disable_shared_object_transaction()
402 .disable_package_publish()
403 .disable_package_upgrade()
404 .disable_receiving_objects()
405 .disable_zklogin_sig()
406 .add_zklogin_disabled_provider("Google".to_string())
407 .add_zklogin_disabled_provider("Apple".to_string())
408 .build();
409
410 let yaml = serde_yaml::to_string(&cfg).expect("serialize");
411 let parsed: TransactionDenyConfig = serde_yaml::from_str(&yaml).expect("deserialize");
412
413 assert_eq!(cfg.rules(), parsed.rules());
416 assert_eq!(
417 cfg.has_dynamic_transaction_checks(),
418 parsed.has_dynamic_transaction_checks(),
419 );
420 }
421
422 #[test]
427 fn transaction_deny_config_yaml_pre_refactor_schema_parses() {
428 let yaml = r#"
432 object-deny-list:
433 - "0x0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a"
434 - "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b"
435 package-deny-list:
436 - "0x0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c"
437 address-deny-list:
438 - "0x0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d0d"
439 package-publish-disabled: true
440 package-upgrade-disabled: true
441 shared-object-disabled: true
442 user-transaction-disabled: true
443 gasless-disabled: true
444 receiving-objects-disabled: true
445 zklogin-sig-disabled: true
446 zklogin-disabled-providers:
447 - Google
448 - Apple
449 "#;
450 let cfg: TransactionDenyConfig = serde_yaml::from_str(yaml).unwrap();
451 assert_eq!(cfg.get_object_deny_set().len(), 2);
452 assert_eq!(cfg.get_package_deny_set().len(), 1);
453 assert_eq!(cfg.get_address_deny_set().len(), 1);
454 assert_eq!(cfg.zklogin_disabled_providers().len(), 2);
455 assert!(cfg.user_transaction_disabled());
456 assert!(cfg.zklogin_sig_disabled());
457 }
458
459 #[test]
464 fn transaction_deny_config_yaml_round_trip_with_dynamic_checks() {
465 let starlark =
467 "def predicate(tx_data, tx_signatures, input_objects, receiving_objects):\n pass\n"
468 .to_string();
469 let cfg = TransactionDenyConfigBuilder::new()
470 .add_denied_object(ObjectID::from_single_byte(1))
471 .disable_package_publish()
472 .add_dynamic_transaction_checks(starlark)
473 .expect("starlark should parse");
474 let cfg = cfg.build();
475
476 let yaml = serde_yaml::to_string(&cfg).expect("serialize");
477 let parsed: TransactionDenyConfig = serde_yaml::from_str(&yaml).expect("deserialize");
478
479 assert_eq!(cfg.rules(), parsed.rules());
481 assert!(parsed.has_dynamic_transaction_checks());
482 }
483
484 #[test]
489 fn peer_deny_sync_config_yaml_round_trip() {
490 let rules = TransactionDenyRules {
491 package_publish_disabled: true,
492 object_deny_list: std::iter::once(ObjectID::from_single_byte(7)).collect(),
493 ..Default::default()
494 };
495 let config = PeerDenySyncConfig {
496 rulesets: vec![SharedDenyRuleset {
497 name: "incident".to_string(),
498 rules,
499 threshold: SharedDenyRuleThreshold {
500 eligibility: ValidatorEligibility::Denylist(BTreeSet::new()),
501 stake_threshold_percent: 67,
502 },
503 }],
504 default_buckets: vec![
505 DefaultDenyBucket {
506 name: "deny-list-entries".to_string(),
507 element_kinds: [
508 DenyElementKind::Object,
509 DenyElementKind::Package,
510 DenyElementKind::Address,
511 ]
512 .into_iter()
513 .collect(),
514 threshold: SharedDenyRuleThreshold {
515 eligibility: ValidatorEligibility::Allowlist(BTreeSet::new()),
516 stake_threshold_percent: 50,
517 },
518 },
519 DefaultDenyBucket {
520 name: "kill-switches".to_string(),
521 element_kinds: [DenyElementKind::SharedObjectDisabled]
522 .into_iter()
523 .collect(),
524 threshold: SharedDenyRuleThreshold {
525 eligibility: ValidatorEligibility::Denylist(BTreeSet::new()),
526 stake_threshold_percent: 90,
527 },
528 },
529 ],
530 broadcast_on_startup: true,
531 broadcast_on_epoch_change: false,
532 };
533
534 let yaml = serde_yaml::to_string(&config).expect("serialize");
535 let parsed: PeerDenySyncConfig = serde_yaml::from_str(&yaml).expect("deserialize");
536 assert_eq!(config, parsed);
537 }
538
539 #[test]
540 fn validate_rejects_malformed_rulesets() {
541 let nonempty = || TransactionDenyRules {
542 package_publish_disabled: true,
543 ..Default::default()
544 };
545 let ruleset = |name: &str, rules: TransactionDenyRules, percent: u16| SharedDenyRuleset {
546 name: name.to_string(),
547 rules,
548 threshold: SharedDenyRuleThreshold {
549 eligibility: ValidatorEligibility::default(),
550 stake_threshold_percent: percent,
551 },
552 };
553 let bucket = |name: &str, kinds: &[DenyElementKind], percent: u16| DefaultDenyBucket {
554 name: name.to_string(),
555 element_kinds: kinds.iter().copied().collect(),
556 threshold: SharedDenyRuleThreshold {
557 eligibility: ValidatorEligibility::default(),
558 stake_threshold_percent: percent,
559 },
560 };
561 let config = |rulesets, default_buckets| PeerDenySyncConfig {
562 rulesets,
563 default_buckets,
564 ..Default::default()
565 };
566
567 assert!(
569 config(vec![ruleset("a", nonempty(), 50)], vec![])
570 .validate()
571 .is_ok()
572 );
573 assert!(
575 config(
576 vec![],
577 vec![
578 bucket("objs", &[DenyElementKind::Object], 50),
579 bucket("kill", &[DenyElementKind::UserTransactionDisabled], 90),
580 ],
581 )
582 .validate()
583 .is_ok()
584 );
585 assert!(
587 config(vec![ruleset("", nonempty(), 50)], vec![])
588 .validate()
589 .is_err()
590 );
591 assert!(
593 config(
594 vec![
595 ruleset("dup", nonempty(), 50),
596 ruleset("dup", nonempty(), 50)
597 ],
598 vec![],
599 )
600 .validate()
601 .is_err()
602 );
603 assert!(
605 config(
606 vec![ruleset("a", TransactionDenyRules::default(), 50)],
607 vec![],
608 )
609 .validate()
610 .is_err()
611 );
612 assert!(
614 config(vec![ruleset("a", nonempty(), 101)], vec![])
615 .validate()
616 .is_err()
617 );
618 assert!(
620 config(vec![], vec![bucket("b", &[DenyElementKind::Object], 101)],)
621 .validate()
622 .is_err()
623 );
624 assert!(
626 config(vec![ruleset("a", nonempty(), 0)], vec![])
627 .validate()
628 .is_err()
629 );
630 assert!(
632 config(vec![], vec![bucket("b", &[DenyElementKind::Object], 0)])
633 .validate()
634 .is_err()
635 );
636 assert!(
638 config(vec![], vec![bucket("", &[DenyElementKind::Object], 50)])
639 .validate()
640 .is_err()
641 );
642 assert!(
644 config(
645 vec![],
646 vec![
647 bucket("dup", &[DenyElementKind::Object], 50),
648 bucket("dup", &[DenyElementKind::Address], 50),
649 ],
650 )
651 .validate()
652 .is_err()
653 );
654 assert!(
656 config(
657 vec![ruleset("shared", nonempty(), 50)],
658 vec![bucket("shared", &[DenyElementKind::Object], 50)],
659 )
660 .validate()
661 .is_err()
662 );
663 assert!(
665 config(vec![], vec![bucket("b", &[], 50)])
666 .validate()
667 .is_err()
668 );
669 assert!(
671 config(
672 vec![],
673 vec![
674 bucket("a", &[DenyElementKind::Object], 50),
675 bucket("b", &[DenyElementKind::Object], 50),
676 ],
677 )
678 .validate()
679 .is_err()
680 );
681 }
682}