1#![allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)]
8
9use std::collections::BTreeSet;
10
11use crate::client::{as_u128, Client, TxOutcome};
12use crate::codec::Value;
13use crate::error::CoreError;
14use crate::keys::{Keypair, CRYPTO_SR25519};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum SignerRole {
19 Coldkey,
20 Hotkey,
21}
22
23pub struct Wallet {
25 pub coldkey: Keypair,
26 pub hotkey: Keypair,
27}
28
29impl Wallet {
30 pub fn from_uris(coldkey_uri: &str, hotkey_uri: &str) -> Result<Self, CoreError> {
31 Ok(Self {
32 coldkey: Keypair::from_uri(coldkey_uri, CRYPTO_SR25519)?,
33 hotkey: Keypair::from_uri(hotkey_uri, CRYPTO_SR25519)?,
34 })
35 }
36
37 pub fn signer(&self, role: SignerRole) -> &Keypair {
38 match role {
39 SignerRole::Coldkey => &self.coldkey,
40 SignerRole::Hotkey => &self.hotkey,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Spend {
48 None,
49 Bounded(u128),
50 Unbounded,
52}
53
54#[derive(Debug, Clone)]
57struct SecuritySemantics {
58 spend: Spend,
59 netuids: Vec<u16>,
60 affects_all_subnets: bool,
61 raw: bool,
62}
63
64impl SecuritySemantics {
65 fn arbitrary() -> Self {
66 Self {
67 spend: Spend::Unbounded,
68 netuids: Vec::new(),
69 affects_all_subnets: true,
73 raw: true,
74 }
75 }
76
77 fn trusted(
78 spend: Spend,
79 netuids: impl IntoIterator<Item = u16>,
80 affects_all_subnets: bool,
81 ) -> Self {
82 let mut netuids: Vec<u16> = netuids.into_iter().collect();
83 netuids.sort_unstable();
84 netuids.dedup();
85 Self {
86 spend,
87 netuids,
88 affects_all_subnets,
89 raw: false,
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
103pub struct IntentCall {
104 pub op: String,
105 pub summary: String,
106 signer: SignerRole,
107 pallet: String,
108 function: String,
109 params: Value,
110 security: SecuritySemantics,
111}
112
113impl IntentCall {
114 pub fn new(
122 op: impl Into<String>,
123 signer: SignerRole,
124 pallet: impl Into<String>,
125 function: impl Into<String>,
126 params: Value,
127 ) -> Self {
128 Self::from_parts(
129 op,
130 signer,
131 pallet,
132 function,
133 params,
134 SecuritySemantics::arbitrary(),
135 )
136 }
137
138 pub fn raw_call(
140 op: impl Into<String>,
141 signer: SignerRole,
142 pallet: impl Into<String>,
143 function: impl Into<String>,
144 params: Value,
145 ) -> Self {
146 Self::new(op, signer, pallet, function, params)
147 }
148
149 #[allow(clippy::too_many_arguments)]
150 fn trusted(
151 op: impl Into<String>,
152 signer: SignerRole,
153 pallet: impl Into<String>,
154 function: impl Into<String>,
155 params: Value,
156 spend: Spend,
157 netuids: impl IntoIterator<Item = u16>,
158 affects_all_subnets: bool,
159 ) -> Self {
160 Self::from_parts(
161 op,
162 signer,
163 pallet,
164 function,
165 params,
166 SecuritySemantics::trusted(spend, netuids, affects_all_subnets),
167 )
168 }
169
170 fn from_parts(
171 op: impl Into<String>,
172 signer: SignerRole,
173 pallet: impl Into<String>,
174 function: impl Into<String>,
175 params: Value,
176 security: SecuritySemantics,
177 ) -> Self {
178 let op = op.into();
179 Self {
180 summary: op.replace('_', " "),
181 op,
182 signer,
183 pallet: pallet.into(),
184 function: function.into(),
185 params,
186 security,
187 }
188 }
189
190 pub fn op(&self) -> &str {
192 &self.op
193 }
194
195 pub fn signer_role(&self) -> SignerRole {
197 self.signer
198 }
199
200 pub fn pallet(&self) -> &str {
202 &self.pallet
203 }
204
205 pub fn function(&self) -> &str {
207 &self.function
208 }
209
210 pub fn params(&self) -> &Value {
212 &self.params
213 }
214
215 pub fn summary(mut self, summary: impl Into<String>) -> Self {
217 self.summary = summary.into();
218 self
219 }
220
221 pub fn spend(mut self, spend: Spend) -> Self {
224 self.security.spend = strictest_spend(self.security.spend, spend);
225 self
226 }
227
228 pub fn touches(mut self, netuids: impl IntoIterator<Item = u16>) -> Self {
231 self.security.netuids.extend(netuids);
232 self.security.netuids.sort_unstable();
233 self.security.netuids.dedup();
234 self
235 }
236
237 pub fn affects_all_subnets(mut self) -> Self {
239 self.security.affects_all_subnets = true;
240 self
241 }
242
243 pub fn raw(mut self) -> Self {
246 self.security = SecuritySemantics::arbitrary();
247 self
248 }
249
250 pub fn transfer(dest: impl Into<String>, amount_rao: u128) -> Self {
252 Self::trusted(
253 "transfer",
254 SignerRole::Coldkey,
255 "Balances",
256 "transfer_keep_alive",
257 Value::record(vec![
258 ("dest".into(), Value::str(dest)),
259 ("value".into(), Value::Uint(amount_rao)),
260 ]),
261 Spend::Bounded(amount_rao),
262 [],
263 false,
264 )
265 }
266
267 pub fn fund_evm_key(mirror: impl Into<String>, amount_rao: u128) -> Self {
269 Self::trusted(
270 "fund_evm_key",
271 SignerRole::Coldkey,
272 "Balances",
273 "transfer_keep_alive",
274 Value::record(vec![
275 ("dest".into(), Value::str(mirror)),
276 ("value".into(), Value::Uint(amount_rao)),
277 ]),
278 Spend::Bounded(amount_rao),
279 [],
280 false,
281 )
282 }
283
284 pub fn transfer_allow_death(dest: impl Into<String>, amount_rao: u128) -> Self {
286 Self::trusted(
287 "transfer",
288 SignerRole::Coldkey,
289 "Balances",
290 "transfer_allow_death",
291 Value::record(vec![
292 ("dest".into(), Value::str(dest)),
293 ("value".into(), Value::Uint(amount_rao)),
294 ]),
295 Spend::Bounded(amount_rao),
296 [],
297 false,
298 )
299 }
300
301 pub fn transfer_all(dest: impl Into<String>, keep_alive: bool) -> Self {
303 Self::trusted(
304 "transfer_all",
305 SignerRole::Coldkey,
306 "Balances",
307 "transfer_all",
308 Value::record(vec![
309 ("dest".into(), Value::str(dest)),
310 ("keep_alive".into(), Value::Bool(keep_alive)),
311 ]),
312 Spend::Unbounded,
313 [],
314 false,
315 )
316 }
317
318 pub fn add_stake(hotkey: impl Into<String>, netuid: u16, amount_rao: u128) -> Self {
320 Self::trusted(
321 "add_stake",
322 SignerRole::Coldkey,
323 "SubtensorModule",
324 "add_stake",
325 Value::record(vec![
326 ("hotkey".into(), Value::str(hotkey)),
327 ("netuid".into(), Value::Uint(u128::from(netuid))),
328 ("amount_staked".into(), Value::Uint(amount_rao)),
329 ]),
330 Spend::Bounded(amount_rao),
331 [netuid],
332 false,
333 )
334 }
335
336 pub fn add_stake_limit(
338 hotkey: impl Into<String>,
339 netuid: u16,
340 amount_rao: u128,
341 limit_price_rao: u128,
342 allow_partial: bool,
343 ) -> Self {
344 Self::trusted(
345 "add_stake_limit",
346 SignerRole::Coldkey,
347 "SubtensorModule",
348 "add_stake_limit",
349 Value::record(vec![
350 ("hotkey".into(), Value::str(hotkey)),
351 ("netuid".into(), Value::Uint(u128::from(netuid))),
352 ("amount_staked".into(), Value::Uint(amount_rao)),
353 ("limit_price".into(), Value::Uint(limit_price_rao)),
354 ("allow_partial".into(), Value::Bool(allow_partial)),
355 ]),
356 Spend::Bounded(amount_rao),
357 [netuid],
358 false,
359 )
360 }
361
362 pub fn remove_stake(hotkey: impl Into<String>, netuid: u16, amount_alpha_rao: u128) -> Self {
365 Self::trusted(
366 "remove_stake",
367 SignerRole::Coldkey,
368 "SubtensorModule",
369 "remove_stake",
370 Value::record(vec![
371 ("hotkey".into(), Value::str(hotkey)),
372 ("netuid".into(), Value::Uint(u128::from(netuid))),
373 ("amount_unstaked".into(), Value::Uint(amount_alpha_rao)),
374 ]),
375 Spend::None,
376 [netuid],
377 false,
378 )
379 }
380
381 pub fn remove_stake_limit(
383 hotkey: impl Into<String>,
384 netuid: u16,
385 amount_alpha_rao: u128,
386 limit_price_rao: u128,
387 allow_partial: bool,
388 ) -> Self {
389 Self::trusted(
390 "remove_stake_limit",
391 SignerRole::Coldkey,
392 "SubtensorModule",
393 "remove_stake_limit",
394 Value::record(vec![
395 ("hotkey".into(), Value::str(hotkey)),
396 ("netuid".into(), Value::Uint(u128::from(netuid))),
397 ("amount_unstaked".into(), Value::Uint(amount_alpha_rao)),
398 ("limit_price".into(), Value::Uint(limit_price_rao)),
399 ("allow_partial".into(), Value::Bool(allow_partial)),
400 ]),
401 Spend::None,
402 [netuid],
403 false,
404 )
405 }
406
407 pub fn register_subnet(hotkey: impl Into<String>) -> Self {
409 Self::trusted(
410 "register_subnet",
411 SignerRole::Coldkey,
412 "SubtensorModule",
413 "register_network",
414 Value::record(vec![("hotkey".into(), Value::str(hotkey))]),
415 Spend::Unbounded,
416 [],
417 false,
418 )
419 }
420
421 pub fn start_call(netuid: u16) -> Self {
423 Self::trusted(
424 "start_call",
425 SignerRole::Coldkey,
426 "SubtensorModule",
427 "start_call",
428 Value::record(vec![("netuid".into(), Value::Uint(u128::from(netuid)))]),
429 Spend::None,
430 [netuid],
431 false,
432 )
433 }
434
435 pub fn burned_register(netuid: u16, hotkey: impl Into<String>) -> Self {
437 Self::trusted(
438 "burned_register",
439 SignerRole::Coldkey,
440 "SubtensorModule",
441 "burned_register",
442 Value::record(vec![
443 ("netuid".into(), Value::Uint(u128::from(netuid))),
444 ("hotkey".into(), Value::str(hotkey)),
445 ]),
446 Spend::Unbounded,
447 [netuid],
448 false,
449 )
450 }
451
452 pub fn root_register(hotkey: impl Into<String>) -> Self {
454 Self::trusted(
455 "root_register",
456 SignerRole::Coldkey,
457 "SubtensorModule",
458 "root_register",
459 Value::record(vec![("hotkey".into(), Value::str(hotkey))]),
460 Spend::None,
461 [0],
462 false,
463 )
464 }
465
466 pub fn move_stake(
468 origin_hotkey: impl Into<String>,
469 origin_netuid: u16,
470 destination_hotkey: impl Into<String>,
471 destination_netuid: u16,
472 amount_alpha_rao: u128,
473 ) -> Self {
474 Self::trusted(
475 "move_stake",
476 SignerRole::Coldkey,
477 "SubtensorModule",
478 "move_stake",
479 Value::record(vec![
480 ("origin_hotkey".into(), Value::str(origin_hotkey)),
481 ("destination_hotkey".into(), Value::str(destination_hotkey)),
482 (
483 "origin_netuid".into(),
484 Value::Uint(u128::from(origin_netuid)),
485 ),
486 (
487 "destination_netuid".into(),
488 Value::Uint(u128::from(destination_netuid)),
489 ),
490 ("alpha_amount".into(), Value::Uint(amount_alpha_rao)),
491 ]),
492 Spend::None,
493 [origin_netuid, destination_netuid],
494 false,
495 )
496 }
497
498 pub fn move_stake_limit(
500 origin_hotkey: impl Into<String>,
501 origin_netuid: u16,
502 destination_hotkey: impl Into<String>,
503 destination_netuid: u16,
504 amount_alpha_rao: u128,
505 limit_price: u128,
506 allow_partial: bool,
507 ) -> Self {
508 Self::trusted(
509 "move_stake_limit",
510 SignerRole::Coldkey,
511 "SubtensorModule",
512 "move_stake_limit",
513 Value::record(vec![
514 ("origin_hotkey".into(), Value::str(origin_hotkey)),
515 ("destination_hotkey".into(), Value::str(destination_hotkey)),
516 (
517 "origin_netuid".into(),
518 Value::Uint(u128::from(origin_netuid)),
519 ),
520 (
521 "destination_netuid".into(),
522 Value::Uint(u128::from(destination_netuid)),
523 ),
524 ("alpha_amount".into(), Value::Uint(amount_alpha_rao)),
525 ("limit_price".into(), Value::Uint(limit_price)),
526 ("allow_partial".into(), Value::Bool(allow_partial)),
527 ]),
528 Spend::None,
529 [origin_netuid, destination_netuid],
530 false,
531 )
532 }
533
534 pub fn swap_stake(
536 hotkey: impl Into<String>,
537 origin_netuid: u16,
538 destination_netuid: u16,
539 amount_alpha_rao: u128,
540 ) -> Self {
541 Self::trusted(
542 "swap_stake",
543 SignerRole::Coldkey,
544 "SubtensorModule",
545 "swap_stake",
546 Value::record(vec![
547 ("hotkey".into(), Value::str(hotkey)),
548 (
549 "origin_netuid".into(),
550 Value::Uint(u128::from(origin_netuid)),
551 ),
552 (
553 "destination_netuid".into(),
554 Value::Uint(u128::from(destination_netuid)),
555 ),
556 ("alpha_amount".into(), Value::Uint(amount_alpha_rao)),
557 ]),
558 Spend::None,
559 [origin_netuid, destination_netuid],
560 false,
561 )
562 }
563
564 pub fn transfer_stake(
567 destination_coldkey: impl Into<String>,
568 hotkey: impl Into<String>,
569 origin_netuid: u16,
570 destination_netuid: u16,
571 amount_alpha_rao: u128,
572 ) -> Self {
573 Self::trusted(
574 "transfer_stake",
575 SignerRole::Coldkey,
576 "SubtensorModule",
577 "transfer_stake",
578 Value::record(vec![
579 (
580 "destination_coldkey".into(),
581 Value::str(destination_coldkey),
582 ),
583 ("hotkey".into(), Value::str(hotkey)),
584 (
585 "origin_netuid".into(),
586 Value::Uint(u128::from(origin_netuid)),
587 ),
588 (
589 "destination_netuid".into(),
590 Value::Uint(u128::from(destination_netuid)),
591 ),
592 ("alpha_amount".into(), Value::Uint(amount_alpha_rao)),
593 ]),
594 Spend::Unbounded,
595 [origin_netuid, destination_netuid],
596 false,
597 )
598 }
599
600 pub fn transfer_stake_and_hotkey(
604 destination_coldkey: impl Into<String>,
605 origin_hotkey: impl Into<String>,
606 destination_hotkey: impl Into<String>,
607 origin_netuid: u16,
608 destination_netuid: u16,
609 amount_alpha_rao: u128,
610 ) -> Self {
611 Self::trusted(
612 "transfer_stake_and_hotkey",
613 SignerRole::Coldkey,
614 "SubtensorModule",
615 "transfer_stake_and_hotkey",
616 Value::record(vec![
617 (
618 "destination_coldkey".into(),
619 Value::str(destination_coldkey),
620 ),
621 ("origin_hotkey".into(), Value::str(origin_hotkey)),
622 ("destination_hotkey".into(), Value::str(destination_hotkey)),
623 (
624 "origin_netuid".into(),
625 Value::Uint(u128::from(origin_netuid)),
626 ),
627 (
628 "destination_netuid".into(),
629 Value::Uint(u128::from(destination_netuid)),
630 ),
631 ("alpha_amount".into(), Value::Uint(amount_alpha_rao)),
632 ]),
633 Spend::Unbounded,
634 [origin_netuid, destination_netuid],
635 false,
636 )
637 }
638
639 pub fn unstake_all(hotkey: impl Into<String>) -> Self {
641 Self::trusted(
642 "unstake_all",
643 SignerRole::Coldkey,
644 "SubtensorModule",
645 "unstake_all",
646 Value::record(vec![("hotkey".into(), Value::str(hotkey))]),
647 Spend::None,
648 [],
649 true,
650 )
651 }
652
653 pub fn unstake_all_alpha(hotkey: impl Into<String>) -> Self {
655 Self::trusted(
656 "unstake_all_alpha",
657 SignerRole::Coldkey,
658 "SubtensorModule",
659 "unstake_all_alpha",
660 Value::record(vec![("hotkey".into(), Value::str(hotkey))]),
661 Spend::None,
662 [],
663 true,
664 )
665 }
666
667 pub fn set_hyperparameter(netuid: u16, name: &str, value: Value) -> Result<Self, CoreError> {
670 let (function, is_bool) = match name {
671 "immunity_period" => ("sudo_set_immunity_period", false),
672 "min_allowed_weights" => ("sudo_set_min_allowed_weights", false),
673 "weights_version" => ("sudo_set_weights_version_key", false),
674 "activity_cutoff" => ("sudo_set_activity_cutoff", false),
675 "min_burn" => ("sudo_set_min_burn", false),
676 "bonds_moving_avg" => ("sudo_set_bonds_moving_average", false),
677 "serving_rate_limit" => ("sudo_set_serving_rate_limit", false),
678 "commit_reveal_period" => ("sudo_set_commit_reveal_weights_interval", false),
679 "max_allowed_uids" => ("sudo_set_max_allowed_uids", false),
680 "burn_increase_mult" => ("sudo_set_burn_increase_mult", false),
681 "burn_half_life" => ("sudo_set_burn_half_life", false),
682 "commit_reveal_weights_enabled" => ("sudo_set_commit_reveal_weights_enabled", true),
683 "liquid_alpha_enabled" => ("sudo_set_liquid_alpha_enabled", true),
684 "network_pow_registration_allowed" => {
685 ("sudo_set_network_pow_registration_allowed", true)
686 }
687 "yuma3_enabled" => ("sudo_set_yuma3_enabled", true),
688 "bonds_reset_enabled" => ("sudo_set_bonds_reset_enabled", true),
689 "transfers_enabled" => ("sudo_set_toggle_transfer", true),
690 "owner_cut_enabled" => ("sudo_set_owner_cut_enabled", true),
691 "owner_cut_auto_lock_enabled" => ("sudo_set_owner_cut_auto_lock_enabled", true),
692 other => {
693 return Err(CoreError::Policy(format!(
694 "unknown or owner-unsettable hyperparameter {other}"
695 )))
696 }
697 };
698 if is_bool && !matches!(value, Value::Bool(_)) {
699 return Err(CoreError::Policy(format!(
700 "hyperparameter {name} requires a boolean value"
701 )));
702 }
703 Ok(Self::trusted(
708 "set_hyperparameter",
709 SignerRole::Coldkey,
710 "AdminUtils",
711 function,
712 Value::Tuple(vec![Value::Uint(u128::from(netuid)), value]),
713 Spend::None,
714 [netuid],
715 false,
716 ))
717 }
718
719 pub fn set_root_claim_type(
721 claim_type: &str,
722 subnets: Option<Vec<u16>>,
723 ) -> Result<Self, CoreError> {
724 let value = match (claim_type, subnets) {
725 ("Swap" | "Keep", None) => Value::str(claim_type),
726 ("KeepSubnets", Some(mut subnets)) if !subnets.is_empty() => {
727 subnets.sort_unstable();
728 subnets.dedup();
729 Value::Dict(vec![(
730 Value::str("KeepSubnets"),
731 Value::record(vec![(
732 "subnets".into(),
733 Value::List(
734 subnets
735 .iter()
736 .copied()
737 .map(|netuid| Value::Uint(u128::from(netuid)))
738 .collect(),
739 ),
740 )]),
741 )])
742 }
743 ("KeepSubnets", _) => {
744 return Err(CoreError::Policy(
745 "claim type KeepSubnets requires a non-empty subnets list".into(),
746 ))
747 }
748 ("Swap" | "Keep", Some(_)) => {
749 return Err(CoreError::Policy(format!(
750 "subnets are only valid for KeepSubnets, not {claim_type}"
751 )))
752 }
753 (other, _) => {
754 return Err(CoreError::Policy(format!(
755 "unknown root claim type {other}"
756 )))
757 }
758 };
759 let netuids = match &value {
760 Value::Dict(_) => subnets_from_root_claim(&value),
761 _ => Vec::new(),
762 };
763 Ok(Self::trusted(
764 "set_root_claim_type",
765 SignerRole::Coldkey,
766 "SubtensorModule",
767 "set_root_claim_type",
768 Value::record(vec![("new_root_claim_type".into(), value)]),
769 Spend::None,
770 netuids,
771 false,
772 ))
773 }
774
775 pub fn set_take(
779 client: &Client,
780 hotkey: impl Into<String>,
781 take: u16,
782 ) -> Result<Self, CoreError> {
783 let hotkey = hotkey.into();
784 let current_value = client.query(
785 "SubtensorModule",
786 "Delegates",
787 &[Value::str(hotkey.clone())],
788 None,
789 )?;
790 let current = if matches!(current_value, Value::Null) {
791 0
792 } else {
793 as_u128(¤t_value).ok_or_else(|| {
794 CoreError::Codec("SubtensorModule.Delegates is not an integer".into())
795 })?
796 };
797 let target = u128::from(take);
798 if target == current {
799 return Err(CoreError::Policy(format!(
800 "delegate take is already {take}/{}; nothing to change",
801 u16::MAX
802 )));
803 }
804 let function = if target < current {
805 "decrease_take"
806 } else {
807 "increase_take"
808 };
809 Ok(Self::trusted(
810 "set_take",
811 SignerRole::Coldkey,
812 "SubtensorModule",
813 function,
814 Value::record(vec![
815 ("hotkey".into(), Value::str(hotkey)),
816 ("take".into(), Value::Uint(target)),
817 ]),
818 Spend::None,
819 [],
820 false,
821 )
822 .summary(format!(
823 "set delegate take to {:.2}% ({take} as u16)",
824 f64::from(take) * 100.0 / f64::from(u16::MAX)
825 )))
826 }
827
828 pub fn encode(&self, client: &Client) -> Result<Vec<u8>, CoreError> {
829 client.compose_call(&self.pallet, &self.function, &self.params)
830 }
831
832 pub fn batch(client: &Client, children: Vec<Self>) -> Result<Self, CoreError> {
835 let Some(first) = children.first() else {
836 return Err(CoreError::Policy(
837 "batch requires at least one child".into(),
838 ));
839 };
840 if children.iter().any(|child| child.op == "batch") {
841 return Err(CoreError::Policy("nested batches are not supported".into()));
842 }
843 if children.iter().any(|child| child.signer != first.signer) {
844 return Err(CoreError::Policy(
845 "all batched intents must share one signer".into(),
846 ));
847 }
848 let mut calls = Vec::with_capacity(children.len());
849 let mut spend = Spend::None;
850 let mut netuids = BTreeSet::new();
851 let mut affects_all_subnets = false;
852 let mut summaries = Vec::with_capacity(children.len());
853 let mut raw = false;
854 for child in &children {
855 calls.push(Value::Bytes(child.encode(client)?));
856 spend = aggregate_spend(spend, child.security.spend);
857 netuids.extend(child.security.netuids.iter().copied());
858 affects_all_subnets |= child.security.affects_all_subnets;
859 raw |= child.security.raw;
860 summaries.push(child.summary.clone());
861 }
862 Ok(Self {
863 op: "batch".into(),
864 summary: format!(
865 "atomic batch of {}: {}",
866 children.len(),
867 summaries.join("; ")
868 ),
869 signer: first.signer,
870 pallet: "Utility".into(),
871 function: "batch_all".into(),
872 params: Value::record(vec![("calls".into(), Value::List(calls))]),
873 security: SecuritySemantics {
874 spend,
875 netuids: netuids.into_iter().collect(),
876 affects_all_subnets,
877 raw,
878 },
879 })
880 }
881}
882
883fn subnets_from_root_claim(value: &Value) -> Vec<u16> {
884 let Value::Dict(variants) = value else {
885 return Vec::new();
886 };
887 let Some((_, Value::Dict(fields))) = variants.first() else {
888 return Vec::new();
889 };
890 let Some((_, Value::List(subnets))) = fields
891 .iter()
892 .find(|(key, _)| matches!(key, Value::Str(name) if name == "subnets"))
893 else {
894 return Vec::new();
895 };
896 subnets
897 .iter()
898 .filter_map(|value| match value {
899 Value::Uint(value) => u16::try_from(*value).ok(),
900 Value::Int(value) if *value >= 0 => u16::try_from(*value).ok(),
901 _ => None,
902 })
903 .collect()
904}
905
906fn strictest_spend(left: Spend, right: Spend) -> Spend {
907 match (left, right) {
908 (Spend::Unbounded, _) | (_, Spend::Unbounded) => Spend::Unbounded,
909 (Spend::Bounded(left), Spend::Bounded(right)) => Spend::Bounded(left.max(right)),
910 (Spend::Bounded(value), Spend::None) | (Spend::None, Spend::Bounded(value)) => {
911 Spend::Bounded(value)
912 }
913 (Spend::None, Spend::None) => Spend::None,
914 }
915}
916
917fn aggregate_spend(left: Spend, right: Spend) -> Spend {
918 match (left, right) {
919 (Spend::Unbounded, _) | (_, Spend::Unbounded) => Spend::Unbounded,
920 (Spend::None, other) | (other, Spend::None) => other,
921 (Spend::Bounded(left), Spend::Bounded(right)) => left
922 .checked_add(right)
923 .map_or(Spend::Unbounded, Spend::Bounded),
924 }
925}
926
927#[derive(Debug, Clone, Default)]
929pub struct Policy {
930 pub max_fee_rao: Option<u128>,
931 pub max_spend_rao: Option<u128>,
932 pub allowed_netuids: Option<BTreeSet<u16>>,
933 pub allow_raw_calls: bool,
934}
935
936impl Policy {
937 pub fn check(&self, intent: &IntentCall, fee_rao: Option<u128>) -> Vec<String> {
938 let mut violations = Vec::new();
939 if intent.security.raw && !self.allow_raw_calls {
940 violations.push("raw calls are disabled by policy".into());
941 }
942 if let Some(max_fee) = self.max_fee_rao {
943 match fee_rao {
944 Some(fee) if fee > max_fee => violations.push(format!(
945 "estimated fee {fee} rao exceeds max_fee_rao {max_fee}"
946 )),
947 None => violations
948 .push("fee estimation failed, so max_fee_rao cannot be enforced safely".into()),
949 _ => {}
950 }
951 }
952 if let Some(max_spend) = self.max_spend_rao {
953 match intent.security.spend {
954 Spend::Bounded(spend) if spend > max_spend => violations.push(format!(
955 "spend {spend} rao exceeds max_spend_rao {max_spend}"
956 )),
957 Spend::Unbounded => violations
958 .push("unbounded spend is not allowed while max_spend_rao is set".into()),
959 _ => {}
960 }
961 }
962 if let Some(allowed) = &self.allowed_netuids {
963 if intent.security.affects_all_subnets {
964 violations.push(
965 "intent affects every subnet but policy only allows an explicit subset".into(),
966 );
967 }
968 for netuid in &intent.security.netuids {
969 if !allowed.contains(netuid) {
970 violations.push(format!("netuid {netuid} is not allowed by policy"));
971 }
972 }
973 }
974 violations
975 }
976}
977
978#[derive(Debug, Clone)]
980pub struct Plan {
981 pub op: String,
982 pub summary: String,
983 pub signer: SignerRole,
984 pub signer_address: String,
985 pub fee_rao: Option<u128>,
986 pub warnings: Vec<String>,
987 pub violations: Vec<String>,
988 pub call_data: Vec<u8>,
989}
990
991impl Plan {
992 pub fn ok(&self) -> bool {
993 self.violations.is_empty()
994 }
995}
996
997pub struct Executor<'a> {
999 client: &'a Client,
1000 policy: Option<Policy>,
1001}
1002
1003impl<'a> Executor<'a> {
1004 pub fn new(client: &'a Client) -> Self {
1005 Self {
1006 client,
1007 policy: None,
1008 }
1009 }
1010
1011 pub fn with_policy(client: &'a Client, policy: Policy) -> Self {
1012 Self {
1013 client,
1014 policy: Some(policy),
1015 }
1016 }
1017
1018 pub fn plan(&self, intent: &IntentCall, wallet: &Wallet) -> Result<Plan, CoreError> {
1019 self.plan_with_proxy(intent, wallet, None, None, None)
1020 }
1021
1022 pub fn plan_with_policy(
1023 &self,
1024 intent: &IntentCall,
1025 wallet: &Wallet,
1026 policy: &Policy,
1027 ) -> Result<Plan, CoreError> {
1028 self.plan_with_proxy(intent, wallet, Some(policy), None, None)
1029 }
1030
1031 pub fn plan_with_proxy(
1032 &self,
1033 intent: &IntentCall,
1034 wallet: &Wallet,
1035 policy: Option<&Policy>,
1036 proxy_for: Option<&str>,
1037 proxy_type: Option<&str>,
1038 ) -> Result<Plan, CoreError> {
1039 let mut call_data = intent.encode(self.client)?;
1040 if let Some(real) = proxy_for {
1041 call_data = self.client.compose_call(
1042 "Proxy",
1043 "proxy",
1044 &Value::record(vec![
1045 ("real".into(), Value::str(real)),
1046 (
1047 "force_proxy_type".into(),
1048 proxy_type.map_or(Value::Null, Value::str),
1049 ),
1050 ("call".into(), Value::Bytes(call_data)),
1051 ]),
1052 )?;
1053 }
1054 let signer = wallet.signer(intent.signer);
1055 let mut warnings = Vec::new();
1056 let fee_rao = match self.client.estimate_fee(&call_data, signer) {
1057 Ok(fee) => Some(fee),
1058 Err(error) => {
1059 warnings.push(format!("could not estimate fee: {error}"));
1060 None
1061 }
1062 };
1063 let active = policy.or(self.policy.as_ref());
1064 let violations = active.map_or_else(Vec::new, |policy| policy.check(intent, fee_rao));
1065 Ok(Plan {
1066 op: intent.op.clone(),
1067 summary: intent.summary.clone(),
1068 signer: intent.signer,
1069 signer_address: signer.ss58_address(),
1070 fee_rao,
1071 warnings,
1072 violations,
1073 call_data,
1074 })
1075 }
1076
1077 pub fn execute(&self, intent: &IntentCall, wallet: &Wallet) -> Result<TxOutcome, CoreError> {
1078 self.execute_with(intent, wallet, None, None, None, true)
1079 }
1080
1081 pub fn execute_with(
1082 &self,
1083 intent: &IntentCall,
1084 wallet: &Wallet,
1085 policy: Option<&Policy>,
1086 proxy_for: Option<&str>,
1087 proxy_type: Option<&str>,
1088 wait_for_finalization: bool,
1089 ) -> Result<TxOutcome, CoreError> {
1090 let plan = self.plan_with_proxy(intent, wallet, policy, proxy_for, proxy_type)?;
1091 if !plan.ok() {
1092 return Err(CoreError::Policy(plan.violations.join("; ")));
1093 }
1094 self.client.submit(
1095 &plan.call_data,
1096 wallet.signer(intent.signer),
1097 None,
1098 Some(64),
1099 wait_for_finalization,
1100 )
1101 }
1102
1103 pub fn submit_shielded(
1104 &self,
1105 intent: &IntentCall,
1106 wallet: &Wallet,
1107 policy: Option<&Policy>,
1108 ) -> Result<TxOutcome, CoreError> {
1109 let plan = self.plan_with_proxy(intent, wallet, policy, None, None)?;
1110 if !plan.ok() {
1111 return Err(CoreError::Policy(plan.violations.join("; ")));
1112 }
1113 self.client
1114 .submit_shielded(&plan.call_data, wallet.signer(intent.signer), false)
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121
1122 #[test]
1123 fn arbitrary_call_fails_closed_for_every_policy_dimension() {
1124 let intent = IntentCall::new(
1125 "caller_supplied",
1126 SignerRole::Coldkey,
1127 "Balances",
1128 "transfer_keep_alive",
1129 Value::record(vec![
1130 ("dest".into(), Value::str("5caller-controlled")),
1131 ("value".into(), Value::Uint(1)),
1132 ]),
1133 );
1134 let policy = Policy {
1135 max_spend_rao: Some(u128::MAX),
1136 allowed_netuids: Some(BTreeSet::from([1])),
1137 allow_raw_calls: false,
1138 ..Policy::default()
1139 };
1140
1141 let violations = policy.check(&intent, Some(0));
1142 assert!(violations
1143 .iter()
1144 .any(|violation| violation == "raw calls are disabled by policy"));
1145 assert!(violations
1146 .iter()
1147 .any(|violation| violation
1148 == "unbounded spend is not allowed while max_spend_rao is set"));
1149 assert!(violations.iter().any(|violation| violation
1150 == "intent affects every subnet but policy only allows an explicit subset"));
1151 }
1152
1153 #[test]
1154 fn allowing_raw_does_not_disable_spend_or_subnet_guardrails() {
1155 let intent = IntentCall::raw_call(
1156 "caller_supplied",
1157 SignerRole::Coldkey,
1158 "Sudo",
1159 "sudo",
1160 Value::Null,
1161 );
1162 let policy = Policy {
1163 max_spend_rao: Some(u128::MAX),
1164 allowed_netuids: Some(BTreeSet::from([1])),
1165 allow_raw_calls: true,
1166 ..Policy::default()
1167 };
1168
1169 assert_eq!(
1170 policy.check(&intent, Some(0)),
1171 vec![
1172 String::from("unbounded spend is not allowed while max_spend_rao is set"),
1173 String::from(
1174 "intent affects every subnet but policy only allows an explicit subset",
1175 ),
1176 ]
1177 );
1178 }
1179
1180 #[test]
1181 fn trusted_transfer_uses_constructor_derived_bounded_spend() {
1182 let intent = IntentCall::transfer("5trusted-destination", 10);
1183 let exact = Policy {
1184 max_spend_rao: Some(10),
1185 ..Policy::default()
1186 };
1187 assert!(exact.check(&intent, Some(0)).is_empty());
1188
1189 let too_small = Policy {
1190 max_spend_rao: Some(9),
1191 ..Policy::default()
1192 };
1193 assert_eq!(
1194 too_small.check(&intent, Some(0)),
1195 vec![String::from("spend 10 rao exceeds max_spend_rao 9")]
1196 );
1197 }
1198
1199 #[test]
1200 fn trusted_move_stake_uses_constructor_derived_netuids() {
1201 let intent = IntentCall::move_stake("5origin", 1, "5destination", 2, 10);
1202 let policy = Policy {
1203 allowed_netuids: Some(BTreeSet::from([1])),
1204 ..Policy::default()
1205 };
1206 assert_eq!(
1207 policy.check(&intent, Some(0)),
1208 vec![String::from("netuid 2 is not allowed by policy")]
1209 );
1210 }
1211
1212 #[test]
1213 fn trusted_move_stake_limit_uses_constructor_derived_netuids() {
1214 let intent =
1215 IntentCall::move_stake_limit("5origin", 1, "5destination", 2, 10, 900_000_000, false);
1216 let policy = Policy {
1217 allowed_netuids: Some(BTreeSet::from([1])),
1218 ..Policy::default()
1219 };
1220 assert_eq!(
1221 policy.check(&intent, Some(0)),
1222 vec![String::from("netuid 2 is not allowed by policy")]
1223 );
1224 }
1225
1226 #[test]
1227 fn compatibility_builders_cannot_downgrade_arbitrary_calls() {
1228 let intent = IntentCall::new(
1229 "caller_supplied",
1230 SignerRole::Coldkey,
1231 "Balances",
1232 "transfer_keep_alive",
1233 Value::record(vec![("value".into(), Value::Uint(1))]),
1234 )
1235 .spend(Spend::None)
1236 .touches([]);
1237 let policy = Policy {
1238 max_spend_rao: Some(u128::MAX),
1239 allowed_netuids: Some(BTreeSet::from([1])),
1240 allow_raw_calls: true,
1241 ..Policy::default()
1242 };
1243
1244 assert_eq!(
1245 policy.check(&intent, Some(0)),
1246 vec![
1247 String::from("unbounded spend is not allowed while max_spend_rao is set"),
1248 String::from(
1249 "intent affects every subnet but policy only allows an explicit subset",
1250 ),
1251 ]
1252 );
1253 }
1254
1255 #[test]
1256 fn compatibility_builders_cannot_lower_trusted_spend_or_scope() {
1257 let intent = IntentCall::transfer("5trusted-destination", 10)
1258 .spend(Spend::None)
1259 .touches([2]);
1260 let policy = Policy {
1261 max_spend_rao: Some(9),
1262 allowed_netuids: Some(BTreeSet::from([1])),
1263 ..Policy::default()
1264 };
1265
1266 assert_eq!(
1267 policy.check(&intent, Some(0)),
1268 vec![
1269 String::from("spend 10 rao exceeds max_spend_rao 9"),
1270 String::from("netuid 2 is not allowed by policy"),
1271 ]
1272 );
1273 }
1274
1275 #[test]
1276 fn set_root_claim_type_requires_subnets_exactly_for_keep_subnets() {
1277 assert!(IntentCall::set_root_claim_type("KeepSubnets", None).is_err());
1278 assert!(IntentCall::set_root_claim_type("Swap", None).is_ok());
1279 assert!(IntentCall::set_root_claim_type("Keep", None).is_ok());
1280 assert!(IntentCall::set_root_claim_type("KeepSubnets", Some(vec![1])).is_ok());
1281 }
1282}