Skip to main content

bittensor_core/
transaction.rs

1//! Semantic transaction planning and policy for the native Rust SDK.
2//!
3//! Domain packages can construct [`IntentCall`] values without knowing pallet
4//! indices. The executor resolves calls against live metadata, simulates fees,
5//! enforces one policy choke point, signs, and submits through [`Client`].
6
7#![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/// Which wallet key signs an intent.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum SignerRole {
19    Coldkey,
20    Hotkey,
21}
22
23/// The key material used by semantic intents.
24pub 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/// Native-currency movement declared by an intent.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Spend {
48    None,
49    Bounded(u128),
50    /// The exact outgoing amount depends on live state (`transfer_all`, dynamic registration, …).
51    Unbounded,
52}
53
54/// Policy semantics proven by a trusted constructor, or the conservative
55/// classification assigned to an arbitrary metadata call.
56#[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            // An arbitrary call may inspect or mutate any subnet. Treat its
70            // scope as unknown/all rather than letting an empty list bypass an
71            // allowlist.
72            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/// A metadata-resolved call plus immutable product-level transaction semantics.
95///
96/// The call target, parameters, signer, and policy classification are private
97/// so callers cannot change the encoded call after a trusted constructor has
98/// attached its spend/subnet semantics. Use [`IntentCall::new`] only for an
99/// arbitrary call: it is deliberately classified as raw, unbounded, and
100/// unknown/all-subnet scope. Narrower semantics are available only through
101/// fixed operation-specific constructors in this module.
102#[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    /// Construct an arbitrary metadata call.
115    ///
116    /// Arbitrary calls are always `raw`, `Spend::Unbounded`, and treated as
117    /// affecting every subnet. This fail-closed classification means they
118    /// require explicit `allow_raw_calls`, cannot pass a spend cap, and cannot
119    /// pass an explicit subnet allowlist. Callers cannot downgrade that
120    /// classification after construction.
121    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    /// Explicitly named alias for [`IntentCall::new`].
139    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    /// Stable operation name used in plans and diagnostics.
191    pub fn op(&self) -> &str {
192        &self.op
193    }
194
195    /// Signer selected by the constructor. Read-only: callers cannot change it.
196    pub fn signer_role(&self) -> SignerRole {
197        self.signer
198    }
199
200    /// Metadata call target. Read-only: callers cannot replace the trusted call.
201    pub fn pallet(&self) -> &str {
202        &self.pallet
203    }
204
205    /// Metadata function target. Read-only: callers cannot replace the trusted call.
206    pub fn function(&self) -> &str {
207        &self.function
208    }
209
210    /// SCALE input value. Read-only: callers cannot replace trusted parameters.
211    pub fn params(&self) -> &Value {
212        &self.params
213    }
214
215    /// Replace presentation text only. This cannot alter policy semantics.
216    pub fn summary(mut self, summary: impl Into<String>) -> Self {
217        self.summary = summary.into();
218        self
219    }
220
221    /// Compatibility policy floor for older call sites. This can only make a
222    /// trusted classification stricter. Arbitrary calls remain unbounded.
223    pub fn spend(mut self, spend: Spend) -> Self {
224        self.security.spend = strictest_spend(self.security.spend, spend);
225        self
226    }
227
228    /// Compatibility policy floor for older call sites. Supplied netuids are
229    /// unioned with constructor-derived scope; they can never remove scope.
230    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    /// Broaden this call's subnet scope. There is intentionally no inverse.
238    pub fn affects_all_subnets(mut self) -> Self {
239        self.security.affects_all_subnets = true;
240        self
241    }
242
243    /// Force the arbitrary-call classification. There is intentionally no
244    /// inverse that can turn a raw call into a trusted call.
245    pub fn raw(mut self) -> Self {
246        self.security = SecuritySemantics::arbitrary();
247        self
248    }
249
250    /// A bounded keep-alive TAO transfer.
251    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    /// Fund the native mirror of an EVM address with a bounded TAO amount.
268    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    /// A bounded TAO transfer that may reap the sender.
285    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    /// Transfer the account's full transferable balance.
302    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    /// Stake a bounded amount of TAO on one subnet.
319    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    /// Stake a bounded amount of TAO with a limit price.
337    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    /// Remove alpha stake from one subnet. This moves value back to the signer,
363    /// so it is subnet-scoped but not an outgoing spend.
364    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    /// Remove alpha stake from one subnet with a limit price.
382    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    /// Register a subnet. The live registration cost is not known locally.
408    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    /// Activate one owned subnet.
422    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    /// Register a hotkey by burning the subnet's live registration cost.
436    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    /// Register a hotkey on root.
453    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    /// Move stake between hotkeys/subnets without transferring ownership.
467    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    /// Move stake between hotkeys/subnets with a relative cross-subnet price limit.
499    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    /// Swap stake between two subnets on one hotkey.
535    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    /// Transfer stake ownership to another coldkey. The alpha value is not
565    /// cheaply TAO-bounded, so a spend cap must reject it.
566    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    /// Transfer stake ownership to another coldkey, landing it on a different
601    /// hotkey. Like `transfer_stake`, the alpha value is not cheaply
602    /// TAO-bounded, so a spend cap must reject it.
603    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    /// Unstake from every subnet on one hotkey.
640    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    /// Move all alpha stake to root across every subnet on one hotkey.
654    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    /// Construct an owner-settable subnet hyperparameter call while rejecting
668    /// root-only or compound parameters before signing.
669    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        // AdminUtils owner setters are all two positional fields `(netuid,
704        // value)`. Positional input deliberately avoids baking generated Rust
705        // field names into the semantic layer; live metadata remains the
706        // authority for both order and SCALE types.
707        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    /// Construct and validate the runtime's root-claim enum.
720    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    /// Set a delegate take to an absolute value, selecting the runtime's
776    /// directional call from the current on-chain take. This mirrors the
777    /// semantic `set_take` intent without hard-coding a direction at callers.
778    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(&current_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    /// Build an all-or-nothing `Utility.batch_all`, aggregating signer, spend,
833    /// and subnet policy metadata across every child.
834    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/// Transaction guardrails enforced before any signature is created.
928#[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/// Dry-run output. The exact call bytes in this plan are the bytes later signed.
979#[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
997/// The one execution choke point for plans, policy, signing, and submission.
998pub 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}