Skip to main content

bittensor_core/codec/
extrinsic.rs

1//! Signature payloads, signed-extrinsic assembly and decode, era birth, and
2//! multisig account derivation — the transaction-shaped primitives on top of
3//! the codec, all pinned by the golden vectors.
4
5// Client-side codec, not runtime code.
6#![allow(clippy::indexing_slicing, clippy::arithmetic_side_effects)]
7
8use codec::{Decode, Encode};
9use scale_info::{form::PortableForm, TypeDef, Variant};
10use sp_core::hashing::blake2_256;
11
12use crate::codec::decode::{compact_u128, Cursor};
13use crate::codec::encode::compact;
14use crate::codec::value::Value;
15use crate::error::CoreError;
16use crate::keys::{is_hashed_crypto, ss58_from_public};
17use crate::runtime::Runtime;
18
19/// Everything one signature payload / signed extrinsic needs beyond the call.
20pub struct TxParams {
21    /// `"00"` (immortal) or `{"period": N, "phase": P}` / `{"period": N,
22    /// "current": M}` — the shapes the SDK has always fed the codec.
23    pub era: Value,
24    pub nonce: u64,
25    pub tip: u128,
26    pub tip_asset_id: Option<u128>,
27    pub genesis_hash: [u8; 32],
28    pub era_block_hash: [u8; 32],
29    /// RFC-0078 metadata digest; flips `CheckMetadataHash` to Enabled.
30    pub metadata_hash: Option<[u8; 32]>,
31}
32
33/// The signature payload is ``call ++ extra ++ additional``: each signed
34/// extension the runtime declares contributes its "extra" bytes (signed
35/// alongside the call, `ty`) and then its "additional" bytes (implied data
36/// both sides must agree on, `additional_signed`). This table IS the payload
37/// wire format — order matters and is pinned by the golden signing-payload
38/// vectors (it mirrors the Python codec's `_PAYLOAD_FIELDS`).
39const PAYLOAD_FIELDS: &[(&str, &str, Slot)] = &[
40    ("era", "CheckMortality", Slot::Extrinsic),
41    ("era", "CheckEra", Slot::Extrinsic),
42    ("nonce", "CheckNonce", Slot::Extrinsic),
43    ("tip", "ChargeTransactionPayment", Slot::Extrinsic),
44    ("asset_id", "ChargeAssetTxPayment", Slot::Extrinsic),
45    ("mode", "CheckMetadataHash", Slot::Extrinsic),
46    ("spec_version", "CheckSpecVersion", Slot::AdditionalSigned),
47    (
48        "transaction_version",
49        "CheckTxVersion",
50        Slot::AdditionalSigned,
51    ),
52    ("genesis_hash", "CheckGenesis", Slot::AdditionalSigned),
53    ("block_hash", "CheckMortality", Slot::AdditionalSigned),
54    ("block_hash", "CheckEra", Slot::AdditionalSigned),
55    ("metadata_hash", "CheckMetadataHash", Slot::AdditionalSigned),
56];
57
58#[derive(Debug, PartialEq, Clone, Copy)]
59enum Slot {
60    Extrinsic,
61    AdditionalSigned,
62}
63
64impl Runtime {
65    /// The fixed byte length a signature variant carries, when it wraps a
66    /// single `[u8; N]` (the MultiSignature shape). `None` for variants whose
67    /// payload is not a fixed byte array, where a length check does not apply.
68    fn variant_fixed_byte_len(&self, variant: &Variant<PortableForm>) -> Option<usize> {
69        let [field] = variant.fields.as_slice() else {
70            return None;
71        };
72        let TypeDef::Array(array) = &self.resolve(field.ty.id).ok()?.type_def else {
73            return None;
74        };
75        matches!(
76            self.resolve(array.type_param.id).ok()?.type_def,
77            TypeDef::Primitive(scale_info::TypeDefPrimitive::U8)
78        )
79        .then_some(array.len as usize)
80    }
81
82    /// Encode the payload fields whose type comes from one extension slot.
83    ///
84    /// Iterates the runtime's declared signed extensions in metadata order
85    /// (the authoritative wire order) rather than a private table, so a
86    /// reordered or newly added extension cannot silently shift the payload.
87    /// `PAYLOAD_FIELDS` supplies only the semantic value for each known
88    /// extension; an extension with no known encoder must be zero-sized in
89    /// this slot, otherwise we fail closed.
90    fn encode_payload_section(&self, slot: Slot, params: &TxParams) -> Result<Vec<u8>, CoreError> {
91        let mut out = Vec::new();
92        for ext in &self.extrinsic.signed_extensions {
93            let ty = match slot {
94                Slot::Extrinsic => ext.ty,
95                Slot::AdditionalSigned => ext.additional_signed,
96            };
97            let field = PAYLOAD_FIELDS
98                .iter()
99                .find(|(_, identifier, field_slot)| {
100                    *identifier == ext.identifier && *field_slot == slot
101                })
102                .map(|(field, _, _)| *field);
103            match field {
104                Some(field) => {
105                    let value = self.payload_field_value(field, ty, params)?;
106                    self.encode_id(ty, &value, &mut out)?;
107                }
108                None => {
109                    if !self.type_is_zero_sized(ty)? {
110                        return Err(CoreError::Codec(format!(
111                            "signed extension {} contributes non-zero {slot:?} data with no known encoder",
112                            ext.identifier
113                        )));
114                    }
115                }
116            }
117        }
118        Ok(out)
119    }
120
121    /// Whether a type encodes to zero bytes (empty composite/tuple, or a
122    /// zero-length array, transitively). Used to confirm an unknown signed
123    /// extension genuinely contributes nothing to the payload.
124    fn type_is_zero_sized(&self, id: u32) -> Result<bool, CoreError> {
125        self.type_is_zero_sized_inner(id, 0)
126    }
127
128    fn type_is_zero_sized_inner(&self, id: u32, depth: usize) -> Result<bool, CoreError> {
129        // Fail closed on pathological nesting: treat as non-zero-sized.
130        if depth > 32 {
131            return Ok(false);
132        }
133        Ok(match &self.resolve(id)?.type_def {
134            TypeDef::Composite(c) => {
135                let mut zero_sized = true;
136                for f in &c.fields {
137                    if !self.type_is_zero_sized_inner(f.ty.id, depth + 1)? {
138                        zero_sized = false;
139                        break;
140                    }
141                }
142                zero_sized
143            }
144            TypeDef::Tuple(t) => {
145                let mut zero_sized = true;
146                for f in &t.fields {
147                    if !self.type_is_zero_sized_inner(f.id, depth + 1)? {
148                        zero_sized = false;
149                        break;
150                    }
151                }
152                zero_sized
153            }
154            TypeDef::Array(a) => {
155                a.len == 0 || self.type_is_zero_sized_inner(a.type_param.id, depth + 1)?
156            }
157            _ => false,
158        })
159    }
160
161    /// The value for one payload field, shaped for its metadata type.
162    fn payload_field_value(
163        &self,
164        field: &str,
165        ty: u32,
166        params: &TxParams,
167    ) -> Result<Value, CoreError> {
168        Ok(match field {
169            "era" => params.era.clone(),
170            "nonce" => Value::Uint(u128::from(params.nonce)),
171            "tip" => Value::Uint(params.tip),
172            "asset_id" => Value::record(vec![
173                ("tip".into(), Value::Uint(params.tip)),
174                (
175                    "asset_id".into(),
176                    match params.tip_asset_id {
177                        Some(id) => Value::Uint(id),
178                        None => Value::Null,
179                    },
180                ),
181            ]),
182            "mode" => {
183                let mode = if params.metadata_hash.is_some() {
184                    "Enabled"
185                } else {
186                    "Disabled"
187                };
188                // The extension type is either the Mode enum itself or the
189                // CheckMetadataHash struct wrapping it in a named field.
190                match &self.resolve(ty)?.type_def {
191                    TypeDef::Composite(c) => {
192                        let name = c
193                            .fields
194                            .first()
195                            .and_then(|f| f.name.clone())
196                            .unwrap_or_else(|| "mode".into());
197                        Value::Dict(vec![(Value::Str(name), Value::str(mode))])
198                    }
199                    _ => Value::str(mode),
200                }
201            }
202            "spec_version" => Value::Uint(u128::from(self.spec_version)),
203            "transaction_version" => Value::Uint(u128::from(self.transaction_version)),
204            "genesis_hash" => Value::Bytes(params.genesis_hash.to_vec()),
205            "block_hash" => Value::Bytes(params.era_block_hash.to_vec()),
206            "metadata_hash" => match params.metadata_hash {
207                Some(hash) => Value::Bytes(hash.to_vec()),
208                None => Value::Null,
209            },
210            other => return Err(CoreError::Codec(format!("unknown payload field {other:?}"))),
211        })
212    }
213
214    /// The signature payload split at its wire seams:
215    /// `(included_in_extrinsic, included_in_signed_data)`. Prepending the raw
216    /// call bytes gives the exact unhashed payload; hardware signers that
217    /// prove the runtime on-device need the parts separately.
218    pub fn signature_payload_parts(
219        &self,
220        params: &TxParams,
221    ) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
222        if params.metadata_hash.is_some()
223            && !self
224                .extrinsic
225                .signed_extensions
226                .iter()
227                .any(|e| e.identifier == "CheckMetadataHash")
228        {
229            return Err(CoreError::Codec(
230                "this runtime does not declare CheckMetadataHash".into(),
231            ));
232        }
233        Ok((
234            self.encode_payload_section(Slot::Extrinsic, params)?,
235            self.encode_payload_section(Slot::AdditionalSigned, params)?,
236        ))
237    }
238
239    /// The exact bytes a signer signs for the given raw call. Payloads longer
240    /// than 256 bytes are blake2b-256 hashed, per the Substrate convention.
241    pub fn signature_payload(
242        &self,
243        call_data: &[u8],
244        params: &TxParams,
245    ) -> Result<Vec<u8>, CoreError> {
246        let (extra, additional) = self.signature_payload_parts(params)?;
247        let mut data = call_data.to_vec();
248        data.extend_from_slice(&extra);
249        data.extend_from_slice(&additional);
250        if data.len() > 256 {
251            return Ok(blake2_256(&data).to_vec());
252        }
253        Ok(data)
254    }
255
256    /// Assemble the full signed extrinsic; returns `(bytes, hash)`.
257    ///
258    /// The metadata-hash *mode* must match what the payload was signed with
259    /// (the digest itself is implied data and never travels in the
260    /// extrinsic — set `params.metadata_hash` accordingly).
261    pub fn encode_signed_extrinsic(
262        &self,
263        call_data: &[u8],
264        public_key: [u8; 32],
265        signature: &[u8],
266        signature_version: u8,
267        params: &TxParams,
268    ) -> Result<(Vec<u8>, [u8; 32]), CoreError> {
269        let pq = if crate::keys::is_pq_crypto(signature_version) {
270            let (account, profile, encoded) = if crate::keys::is_falcon_crypto(signature_version) {
271                let address = subtensor_hashed::falcon::SignerAddress::from_bytes(signature)
272                    .map_err(|_| {
273                        CoreError::Codec("invalid Falcon authorization envelope".into())
274                    })?;
275                (address.account, address.algorithm, address.encode())
276            } else {
277                let address = subtensor_hashed::mldsa::SignerAddress::from_bytes(signature)
278                    .map_err(|_| {
279                        CoreError::Codec("invalid ML-DSA authorization envelope".into())
280                    })?;
281                (address.account, address.algorithm, address.encode())
282            };
283            if !self.supports_mldsa_profile(profile) {
284                return Err(CoreError::Codec(
285                    "runtime does not advertise this post-quantum account profile".into(),
286                ));
287            }
288            if account != public_key || crate::keys::pq_profile(signature_version) != Some(profile)
289            {
290                return Err(CoreError::Codec(
291                    "post-quantum proof account does not match signer".into(),
292                ));
293            }
294            Some(encoded)
295        } else {
296            None
297        };
298        let proof = if is_hashed_crypto(signature_version) {
299            if signature.len() != 136 {
300                return Err(CoreError::Codec(
301                    "hashed authorization proof must be 136 bytes".into(),
302                ));
303            }
304            if !self.pallets.iter().any(|p| p.name == "HashedAccounts") {
305                return Err(CoreError::Codec(
306                    "runtime does not support hashed accounts".into(),
307                ));
308            }
309            Some(
310                subtensor_hashed::Proof::decode(&mut &signature[..])
311                    .map_err(|_| CoreError::Codec("invalid hashed authorization proof".into()))?,
312            )
313        } else {
314            None
315        };
316        let (address, signature, signature_version) = if let Some(address) = &pq {
317            (
318                Value::record(vec![("Raw".into(), Value::Bytes(address.clone()))]),
319                subtensor_hashed::mldsa::CARRIER_SIGNATURE.as_slice(),
320                0,
321            )
322        } else if let Some(proof) = &proof {
323            let scheme = if signature_version == crate::keys::CRYPTO_HASHED {
324                subtensor_hashed::Scheme::Sr25519
325            } else {
326                subtensor_hashed::Scheme::Ed25519
327            };
328            let address = subtensor_hashed::SignerAddress {
329                version: subtensor_hashed::VERSION,
330                scheme,
331                account: public_key,
332                generation: proof.generation,
333                public_key: proof.public_key,
334                next_commitment: proof.next_commitment,
335            };
336            (
337                Value::record(vec![("Raw".into(), Value::Bytes(address.encode()))]),
338                proof.signature.as_slice(),
339                if signature_version == crate::keys::CRYPTO_HASHED {
340                    1
341                } else {
342                    0
343                },
344            )
345        } else {
346            (
347                Value::Bytes(public_key.to_vec()),
348                signature,
349                signature_version,
350            )
351        };
352        if self.extrinsic.version != 4 {
353            return Err(CoreError::Codec(format!(
354                "extrinsic version {} not supported",
355                self.extrinsic.version
356            )));
357        }
358        let mut body = vec![0x80 | self.extrinsic.version];
359        let address_type = self
360            .extrinsic
361            .address_type
362            .ok_or_else(|| CoreError::Codec("runtime metadata has no address type".into()))?;
363        self.encode_id(address_type, &address, &mut body)?;
364        // Multi-crypto chains wrap the signature in an enum carrying the
365        // scheme (the MultiSignature variant byte IS the signature version).
366        // Validate the variant exists and that the raw signature matches the
367        // variant's fixed width, so a bad version/length fails closed here
368        // rather than producing a malformed extrinsic the chain will reject.
369        if let Some(signature_type) = self.extrinsic.signature_type {
370            if let TypeDef::Variant(variant) = &self.resolve(signature_type)?.type_def {
371                let chosen = variant
372                    .variants
373                    .iter()
374                    .find(|v| v.index == signature_version)
375                    .ok_or_else(|| {
376                        CoreError::Codec(format!(
377                            "no signature variant with index {signature_version} in the runtime's signature enum"
378                        ))
379                    })?;
380                if let Some(expected) = self.variant_fixed_byte_len(chosen) {
381                    if signature.len() != expected {
382                        return Err(CoreError::Codec(format!(
383                            "signature is {} bytes but variant {} expects {expected}",
384                            signature.len(),
385                            chosen.name
386                        )));
387                    }
388                }
389                body.push(signature_version);
390            }
391        }
392        body.extend_from_slice(signature);
393        body.extend_from_slice(&self.encode_payload_section(Slot::Extrinsic, params)?);
394        body.extend_from_slice(call_data);
395
396        let mut out = Vec::with_capacity(body.len().saturating_add(4));
397        compact(body.len() as u128, &mut out)?;
398        out.extend_from_slice(&body);
399        Ok((out.clone(), blake2_256(&out)))
400    }
401
402    /// Detect the explicit ML-DSA registration variant on the original call.
403    /// Presence of HashedAccounts alone does not imply ML-DSA support.
404    pub fn supports_mldsa_profile(&self, profile: u8) -> bool {
405        if subtensor_hashed::falcon::is_account_profile(profile) {
406            return self.supports_registration_variant("Falcon")
407                && self
408                    .constant("HashedAccounts", "FalconAccountProfiles")
409                    .is_some_and(|constant| constant.value.contains(&profile));
410        }
411        self.supports_mldsa_accounts()
412            && match profile {
413                subtensor_hashed::mldsa::ALGORITHM => true,
414                subtensor_hashed::mldsa::FIXED_ALGORITHM => self
415                    .constant("HashedAccounts", "MldsaAccountProfiles")
416                    .is_some_and(|constant| constant.value.contains(&profile)),
417                _ => false,
418            }
419    }
420
421    pub fn supports_mldsa_accounts(&self) -> bool {
422        self.supports_registration_variant("MlDsa65")
423    }
424
425    fn supports_registration_variant(&self, variant: &str) -> bool {
426        self.pallets
427            .iter()
428            .filter(|p| p.name == "HashedAccounts")
429            .filter_map(|p| p.calls_type)
430            .any(|id| {
431                let Ok(ty) = self.resolve(id) else {
432                    return false;
433                };
434                let TypeDef::Variant(calls) = &ty.type_def else {
435                    return false;
436                };
437                calls.variants.iter().any(|call| {
438                    let [field] = call.fields.as_slice() else {
439                        return false;
440                    };
441                    if call.name != "register" || field.name.as_deref() != Some("descriptor") {
442                        return false;
443                    }
444                    let Ok(descriptor) = self.resolve(field.ty.id) else {
445                        return false;
446                    };
447                    matches!(&descriptor.type_def, TypeDef::Variant(schemes)
448                        if schemes.variants.iter().any(|scheme|
449                            scheme.name == variant && scheme.fields.len() == 1)
450                        && schemes.variants.iter().any(|scheme|
451                            scheme.name == "Classical" && scheme.fields.len() == 1))
452                })
453            })
454    }
455
456    /// Decode one raw extrinsic into cyscale's plain value dict:
457    /// `{extrinsic_hash, extrinsic_length, [address, signature, era, nonce,
458    /// tip, mode,] call}`.
459    pub fn decode_extrinsic(&self, data: &[u8], strict: bool) -> Result<Value, CoreError> {
460        let hash = blake2_256(data);
461        let mut cursor = Cursor::new(data);
462        cursor.strict = strict;
463        let length = compact_u128(&mut cursor)?;
464        // The compact prefix frames the extrinsic body exactly: decode against
465        // a cursor bounded to those bytes and require it to be fully consumed,
466        // so a wrong framing length can never decode as if it were correct.
467        let body_len = usize::try_from(length)
468            .map_err(|_| CoreError::Codec("extrinsic length does not fit usize".into()))?;
469        let body = cursor.take(body_len)?;
470        let mut body_cursor = Cursor::new(body);
471        body_cursor.strict = strict;
472
473        let version_byte = body_cursor.byte()?;
474        let signed = version_byte & 0x80 != 0;
475        let general = version_byte == 0x45;
476        let version = version_byte & 0x7f;
477        // Bare v4/v5 extrinsics contain a call; signed v4 and General v5
478        // carry distinct authorization/extension envelopes.
479        if signed && version != 4 {
480            return Err(CoreError::Codec(format!(
481                "signed extrinsic version {version} not supported"
482            )));
483        }
484        if !matches!(version_byte, 4 | 5 | 0x84 | 0x45) {
485            return Err(CoreError::Codec(format!(
486                "unsupported extrinsic format {version_byte}"
487            )));
488        }
489        if general && body_cursor.byte()? != 0 {
490            return Err(CoreError::Codec(
491                "unsupported general extension version".into(),
492            ));
493        }
494
495        let mut fields: Vec<(String, Value)> = vec![
496            ("extrinsic_hash".into(), Value::hex(&hash)),
497            (
498                "extrinsic_length".into(),
499                Value::Int(i128::try_from(length).unwrap_or(0)),
500            ),
501        ];
502        if signed {
503            let address_type = self
504                .extrinsic
505                .address_type
506                .ok_or_else(|| CoreError::Codec("runtime metadata has no address type".into()))?;
507            fields.push((
508                "address".into(),
509                self.decode_id(address_type, &mut body_cursor)?,
510            ));
511            let signature_type = self
512                .extrinsic
513                .signature_type
514                .ok_or_else(|| CoreError::Codec("runtime metadata has no signature type".into()))?;
515            fields.push((
516                "signature".into(),
517                self.decode_id(signature_type, &mut body_cursor)?,
518            ));
519        }
520        if signed || general {
521            for ext in &self.extrinsic.signed_extensions {
522                let field = PAYLOAD_FIELDS
523                    .iter()
524                    .find(|(_, identifier, slot)| {
525                        *identifier == ext.identifier && *slot == Slot::Extrinsic
526                    })
527                    .map(|(field, _, _)| (*field).to_string())
528                    .unwrap_or_else(|| ext.identifier.clone());
529                let value = self.decode_id(ext.ty, &mut body_cursor)?;
530                // Zero-sized extras (CheckWeight & co) contribute no field.
531                if matches!(&value, Value::Dict(entries) if entries.is_empty()) {
532                    continue;
533                }
534                fields.push((field, value));
535            }
536        }
537        let call = self.decode_call_value(&mut body_cursor)?;
538        fields.push(("call".into(), call));
539        // The declared body length must match what the layout actually
540        // consumed — otherwise `extrinsic_length` would lie about the bytes.
541        if body_cursor.remaining() != 0 {
542            return Err(CoreError::Codec(format!(
543                "{} undecoded bytes remain inside the extrinsic body",
544                body_cursor.remaining()
545            )));
546        }
547        if strict && cursor.remaining() != 0 {
548            return Err(CoreError::Codec(format!(
549                "{} undecoded bytes remain after the extrinsic",
550                cursor.remaining()
551            )));
552        }
553        Ok(Value::record(fields))
554    }
555}
556
557/// The block at which a mortal era starts (cyscale's `Era.birth`).
558pub fn era_birth(period: u64, current: u64) -> u64 {
559    let calculated = period.next_power_of_two().clamp(4, 1 << 16);
560    let quantize_factor = (calculated >> 12).max(1);
561    let phase = (current % calculated) / quantize_factor * quantize_factor;
562    (current.max(phase) - phase) / calculated * calculated + phase
563}
564
565/// Derive the deterministic M-of-N multisig account for a signer set.
566///
567/// Returns `(account_id, sorted_signatories)` — the pallet requires the
568/// signatory set sorted by raw key bytes, and the derivation is
569/// `blake2_256(b"modlpy/utilisuba" ++ Vec<AccountId> ++ u16)`.
570pub fn multisig_account_id(
571    signatories: &[[u8; 32]],
572    threshold: u16,
573) -> Result<([u8; 32], Vec<[u8; 32]>), CoreError> {
574    if signatories.is_empty() {
575        return Err(CoreError::Codec(
576            "multisig needs at least one signatory".into(),
577        ));
578    }
579    let mut sorted = signatories.to_vec();
580    sorted.sort_unstable();
581    let mut data = b"modlpy/utilisuba".to_vec();
582    compact(sorted.len() as u128, &mut data)?;
583    for signatory in &sorted {
584        data.extend_from_slice(signatory);
585    }
586    data.extend_from_slice(&threshold.to_le_bytes());
587    Ok((blake2_256(&data), sorted))
588}
589
590/// Derive an opt-in multisig account that cannot be a raw Ed25519/Sr25519 key.
591/// The ordinary multisig helper remains unchanged for existing accounts.
592pub fn safe_multisig_account_id(
593    signatories: &[[u8; 32]],
594    threshold: u16,
595) -> Result<([u8; 32], Vec<[u8; 32]>), CoreError> {
596    let max_signatories = subtensor_hashed::safe::MAX_MULTISIG_SIGNATORIES as usize;
597    if !(2..=max_signatories).contains(&signatories.len()) {
598        return Err(CoreError::Codec(format!(
599            "safe multisig requires 2 to {max_signatories} signatories"
600        )));
601    }
602    if threshold == 0 || usize::from(threshold) > signatories.len() {
603        return Err(CoreError::Codec("invalid multisig threshold".into()));
604    }
605    let mut sorted = signatories.to_vec();
606    sorted.sort_unstable();
607    if sorted.windows(2).any(|pair| pair[0] == pair[1]) {
608        return Err(CoreError::Codec("duplicate multisig signatory".into()));
609    }
610    let account = subtensor_hashed::safe::safe_derived_account(
611        subtensor_hashed::safe::MULTISIG_DOMAIN,
612        &(sorted.as_slice(), threshold),
613    )
614    .ok_or_else(|| CoreError::Codec("safe multisig derivation exhausted its search".into()))?;
615    Ok((account, sorted))
616}
617
618/// ss58 rendering for a derived multisig account (convenience for bindings).
619pub fn multisig_ss58(account_id: [u8; 32], ss58_format: u16) -> String {
620    ss58_from_public(account_id, ss58_format)
621}
622
623#[cfg(test)]
624mod safe_multisig_tests {
625    #![allow(clippy::unwrap_used, clippy::expect_used)]
626    use super::{multisig_account_id, safe_multisig_account_id};
627
628    #[test]
629    fn safe_mode_is_order_independent_and_separate_from_legacy() {
630        let members = [[2; 32], [1; 32], [3; 32]];
631        let (account, sorted) = safe_multisig_account_id(&members, 2).expect("valid group");
632        assert_eq!(sorted, vec![[1; 32], [2; 32], [3; 32]]);
633        assert_eq!(
634            safe_multisig_account_id(&sorted, 2)
635                .expect("sorted group")
636                .0,
637            account
638        );
639        assert!(subtensor_hashed::mldsa::rejects_legacy_public_keys(
640            &account
641        ));
642        assert_ne!(
643            multisig_account_id(&members, 2).expect("legacy group").0,
644            account
645        );
646        assert_ne!(
647            safe_multisig_account_id(&members, 1)
648                .expect("other threshold")
649                .0,
650            account
651        );
652    }
653
654    #[test]
655    fn safe_mode_rejects_invalid_member_manifests() {
656        assert!(safe_multisig_account_id(&[], 1).is_err());
657        assert!(safe_multisig_account_id(&[[1; 32]], 1).is_err());
658        assert!(safe_multisig_account_id(&[[1; 32], [1; 32]], 2).is_err());
659        assert!(safe_multisig_account_id(&[[1; 32], [2; 32]], 0).is_err());
660        assert!(safe_multisig_account_id(&[[1; 32], [2; 32]], 3).is_err());
661    }
662
663    #[test]
664    fn safe_mode_enforces_runtime_signer_count_boundaries() {
665        let max = subtensor_hashed::safe::MAX_MULTISIG_SIGNATORIES as usize;
666        let members: Vec<_> = (0..=max)
667            .map(|index| {
668                let mut account = [0u8; 32];
669                account[..8].copy_from_slice(&(index as u64).to_le_bytes());
670                account
671            })
672            .collect();
673        for count in [0, 1, max + 1] {
674            assert!(safe_multisig_account_id(&members[..count], 1).is_err());
675        }
676        for count in [2, max] {
677            for threshold in [1, count as u16] {
678                let (account, sorted) =
679                    safe_multisig_account_id(&members[..count], threshold).expect("valid group");
680                assert_eq!(sorted.len(), count);
681                assert!(subtensor_hashed::mldsa::rejects_legacy_public_keys(
682                    &account
683                ));
684                assert_eq!(
685                    safe_multisig_account_id(&sorted, threshold)
686                        .expect("sorted group")
687                        .0,
688                    account
689                );
690            }
691        }
692    }
693}
694
695#[cfg(test)]
696pub(crate) mod hashed_tests {
697    #![allow(clippy::unwrap_used, clippy::expect_used)]
698    use codec::{Decode, Encode};
699    use frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed};
700
701    use super::*;
702    use crate::keys::Keypair;
703    use crate::keys::{CRYPTO_HASHED, CRYPTO_HASHED_ED25519};
704
705    pub(crate) fn runtime(hashed: bool) -> Runtime {
706        let fixture: serde_json::Value =
707            serde_json::from_str(include_str!("../../../python/tests/fixtures/golden.json"))
708                .unwrap();
709        let encoded = hex::decode(
710            fixture["metadata"]["v15_hex"]
711                .as_str()
712                .unwrap()
713                .trim_start_matches("0x"),
714        )
715        .unwrap();
716        let metadata = Option::<Vec<u8>>::decode(&mut &encoded[..])
717            .unwrap()
718            .unwrap();
719        let mut metadata = RuntimeMetadataPrefixed::decode(&mut &metadata[..]).unwrap();
720        if hashed {
721            let RuntimeMetadata::V15(inner) = &mut metadata.1 else {
722                panic!("v15 fixture")
723            };
724            // Only the advertised availability is added. The existing golden
725            // call and extension metadata stays identical to a legacy runtime.
726            let mut pallet = inner.pallets[0].clone();
727            pallet.name = "HashedAccounts".into();
728            pallet.index = 250;
729            inner.pallets.push(pallet);
730        }
731        Runtime::parse(&metadata.encode(), 1, 1, 42).unwrap()
732    }
733
734    fn params() -> TxParams {
735        TxParams {
736            era: Value::str("00"),
737            nonce: 4,
738            tip: 0,
739            tip_asset_id: None,
740            genesis_hash: [8; 32],
741            era_block_hash: [8; 32],
742            metadata_hash: None,
743        }
744    }
745
746    #[test]
747    fn hashed_v4_roundtrip_has_standard_signature_and_real_call() {
748        use sp_core::Pair;
749        for crypto in [CRYPTO_HASHED, CRYPTO_HASHED_ED25519] {
750            let runtime = runtime(true);
751            let call = runtime
752                .compose_call(
753                    "System",
754                    "remark",
755                    &Value::record(vec![("remark".into(), Value::Bytes(vec![7; 300]))]),
756                )
757                .unwrap();
758            let key = Keypair::from_seed(&[4; 32], crypto)
759                .unwrap()
760                .at_generation(12)
761                .unwrap();
762            let (extra, additional) = runtime.signature_payload_parts(&params()).unwrap();
763            let mut payload = call.clone();
764            payload.extend_from_slice(&extra);
765            payload.extend_from_slice(&additional);
766            let proof_bytes = key.sign_hashed(&payload).unwrap();
767            let proof = subtensor_hashed::Proof::decode(&mut proof_bytes.as_slice()).unwrap();
768            let (encoded, _) = runtime
769                .encode_signed_extrinsic(
770                    &call,
771                    key.public_key_bytes(),
772                    &proof_bytes,
773                    crypto,
774                    &params(),
775                )
776                .unwrap();
777            let address = subtensor_hashed::SignerAddress {
778                version: 1,
779                account: key.public_key_bytes(),
780                scheme: if crypto == CRYPTO_HASHED {
781                    subtensor_hashed::Scheme::Sr25519
782                } else {
783                    subtensor_hashed::Scheme::Ed25519
784                },
785                generation: proof.generation,
786                public_key: proof.public_key,
787                next_commitment: proof.next_commitment,
788            };
789            let payload = address.signing_hash(&payload);
790            let valid = if crypto == CRYPTO_HASHED {
791                sp_core::sr25519::Pair::verify(
792                    &sp_core::sr25519::Signature::from_raw(proof.signature),
793                    payload,
794                    &sp_core::sr25519::Public::from_raw(proof.public_key),
795                )
796            } else {
797                sp_core::ed25519::Pair::verify(
798                    &sp_core::ed25519::Signature::from_raw(proof.signature),
799                    payload,
800                    &sp_core::ed25519::Public::from_raw(proof.public_key),
801                )
802            };
803            assert!(valid);
804            let mut body = Cursor::new(&encoded);
805            let _ = compact_u128(&mut body).unwrap();
806            assert_eq!(body.byte().unwrap(), 0x84);
807            let decoded = runtime.decode_extrinsic(&encoded, true).unwrap();
808            let Value::Dict(fields) = decoded else {
809                panic!("record")
810            };
811            // The direct call survives; the signer carries only fixed bounded metadata.
812            assert!(fields.iter().any(|(name, _)| name == &Value::str("call")));
813            assert_eq!(body.byte().unwrap(), 2); // MultiAddress::Raw
814            assert_eq!(compact_u128(&mut body).unwrap(), 106);
815            assert_eq!(body.take(106).unwrap(), address.encode());
816            assert!(encoded.ends_with(&call));
817            let mut malformed = encoded;
818            malformed.push(0);
819            assert!(runtime.decode_extrinsic(&malformed, true).is_err());
820            assert!(runtime
821                .encode_signed_extrinsic(
822                    &call,
823                    key.public_key_bytes(),
824                    &proof_bytes[..64],
825                    crypto,
826                    &params()
827                )
828                .is_err());
829        }
830    }
831
832    #[test]
833    fn legacy_metadata_cannot_silently_accept_hashed_authorization() {
834        let runtime = runtime(false);
835        assert!(runtime
836            .encode_signed_extrinsic(&[0, 0], [0; 32], &[0; 136], CRYPTO_HASHED, &params())
837            .is_err());
838    }
839
840    pub(crate) fn runtime_mldsa() -> Runtime {
841        let mut runtime = runtime(true);
842        assert!(!runtime.supports_mldsa_accounts());
843        let pallet = runtime
844            .pallets
845            .iter_mut()
846            .find(|p| p.name == "HashedAccounts")
847            .unwrap();
848        let source = pallet.calls_type.unwrap();
849        let mut calls = runtime.types.resolve(source).unwrap().clone();
850        let TypeDef::Variant(variants) = &mut calls.type_def else {
851            panic!("call variants")
852        };
853        // Advertise the exact registration enum names without changing any
854        // golden extrinsic envelope, economic call, or signing extension type.
855        let mut marker = variants.variants[0].clone();
856        marker.name = "register".into();
857        marker.index = 0;
858        marker.fields.truncate(1);
859        marker.fields[0].name = Some("descriptor".into());
860        let mut registration = calls.clone();
861        let TypeDef::Variant(schemes) = &mut registration.type_def else {
862            panic!("registration variants")
863        };
864        let mut scheme = marker.clone();
865        scheme.name = "Classical".into();
866        scheme.fields[0].name = None;
867        schemes.variants = vec![scheme.clone()];
868        scheme.name = "MlDsa65".into();
869        scheme.index = 1;
870        schemes.variants.push(scheme);
871        let registration_id = runtime.types.types.len() as u32;
872        runtime.types.types.push(scale_info::PortableType {
873            id: registration_id,
874            ty: registration,
875        });
876        marker.fields[0].ty = registration_id.into();
877        let TypeDef::Variant(variants) = &mut calls.type_def else {
878            panic!("call variants")
879        };
880        variants.variants = vec![marker];
881        let id = runtime.types.types.len() as u32;
882        runtime
883            .types
884            .types
885            .push(scale_info::PortableType { id, ty: calls });
886        pallet.calls_type = Some(id);
887        pallet.constants.push(crate::runtime::ConstantInfo {
888            name: "MldsaAccountProfiles".into(),
889            ty: 0,
890            value: vec![4, 5],
891            docs: vec![],
892        });
893        assert!(runtime.supports_mldsa_accounts());
894        runtime
895    }
896
897    pub(crate) fn runtime_falcon() -> Runtime {
898        let mut runtime = runtime_mldsa();
899        let pallet = runtime
900            .pallets
901            .iter_mut()
902            .find(|p| p.name == "HashedAccounts")
903            .unwrap();
904        let call_id = pallet.calls_type.unwrap();
905        pallet.constants.push(crate::runtime::ConstantInfo {
906            name: "FalconAccountProfiles".into(),
907            ty: 0,
908            value: vec![6, 7, 8, 9],
909            docs: vec![],
910        });
911        let TypeDef::Variant(calls) = &runtime.types.resolve(call_id).unwrap().type_def else {
912            panic!("calls")
913        };
914        let descriptor_id = calls
915            .variants
916            .iter()
917            .find(|call| call.name == "register")
918            .unwrap()
919            .fields[0]
920            .ty
921            .id;
922        let descriptor = &mut runtime.types.types[descriptor_id as usize].ty;
923        let TypeDef::Variant(variants) = &mut descriptor.type_def else {
924            panic!("descriptor")
925        };
926        let mut falcon = variants.variants[1].clone();
927        falcon.name = "Falcon".into();
928        falcon.index = 2;
929        variants.variants.push(falcon);
930        runtime
931    }
932
933    #[test]
934    fn fixed_profile_requires_advertised_runtime_support_before_encoding() {
935        let mut runtime = runtime_mldsa();
936        assert!(runtime.supports_mldsa_profile(4));
937        assert!(runtime.supports_mldsa_profile(5));
938        runtime
939            .pallets
940            .iter_mut()
941            .find(|p| p.name == "HashedAccounts")
942            .unwrap()
943            .constants
944            .retain(|c| c.name != "MldsaAccountProfiles");
945        assert!(runtime.supports_mldsa_profile(4));
946        assert!(!runtime.supports_mldsa_profile(5));
947        let fixed = Keypair::from_seed(&[23; 32], crate::keys::CRYPTO_MLDSA65_FIXED).unwrap();
948        let proof = fixed.sign_mldsa(b"call").unwrap();
949        assert!(runtime
950            .encode_signed_extrinsic(
951                &[0, 0],
952                fixed.public_key_bytes(),
953                &proof,
954                crate::keys::CRYPTO_MLDSA65_FIXED,
955                &params()
956            )
957            .is_err());
958    }
959
960    #[test]
961    fn mldsa_support_requires_registration_enum_not_a_call_name_marker() {
962        let mut runtime = runtime_mldsa();
963        let call_id = runtime
964            .pallets
965            .iter()
966            .find(|pallet| pallet.name == "HashedAccounts")
967            .unwrap()
968            .calls_type
969            .unwrap();
970        let TypeDef::Variant(calls) = &mut runtime.types.types[call_id as usize].ty.type_def else {
971            panic!("call variants")
972        };
973        let descriptor_id = calls.variants[0].fields[0].ty.id;
974        calls.variants[0].name = "register_unsupported".into();
975        assert!(!runtime.supports_mldsa_accounts());
976        let TypeDef::Variant(calls) = &mut runtime.types.types[call_id as usize].ty.type_def else {
977            panic!("call variants")
978        };
979        calls.variants[0].name = "register".into();
980        assert!(runtime.supports_mldsa_accounts());
981        let TypeDef::Variant(schemes) =
982            &mut runtime.types.types[descriptor_id as usize].ty.type_def
983        else {
984            panic!("registration variants")
985        };
986        schemes.variants.retain(|variant| variant.name != "MlDsa65");
987        assert!(!runtime.supports_mldsa_accounts());
988    }
989
990    #[test]
991    fn mldsa_v4_proof_preserves_outer_types_and_call_bytes() {
992        for crypto in [
993            crate::keys::CRYPTO_MLDSA65,
994            crate::keys::CRYPTO_MLDSA65_FIXED,
995            crate::keys::CRYPTO_FALCON512,
996            crate::keys::CRYPTO_FALCON512_HASHED,
997            crate::keys::CRYPTO_FALCON1024,
998            crate::keys::CRYPTO_FALCON1024_HASHED,
999        ] {
1000            let legacy_runtime = runtime(false);
1001            let runtime = runtime_falcon();
1002            let call = runtime
1003                .compose_call(
1004                    "System",
1005                    "remark",
1006                    &Value::record(vec![("remark".into(), Value::Bytes(vec![7; 300]))]),
1007                )
1008                .unwrap();
1009            let wallet = Keypair::from_seed(&[7; 32], crypto)
1010                .unwrap()
1011                .at_generation(if crate::keys::is_rotating_crypto(crypto) {
1012                    3
1013                } else {
1014                    0
1015                })
1016                .unwrap();
1017            let (extra, additional) = runtime.signature_payload_parts(&params()).unwrap();
1018            let payload = [call.clone(), extra.clone(), additional].concat();
1019            let proof = wallet
1020                .sign_mldsa_with_randomness(&payload, &[0; 32])
1021                .unwrap();
1022            if crate::keys::is_falcon_crypto(crypto) {
1023                assert!(subtensor_hashed::falcon::SignerAddress::from_bytes(&proof)
1024                    .unwrap()
1025                    .verify(&payload));
1026            } else {
1027                assert!(subtensor_hashed::mldsa::SignerAddress::from_bytes(&proof)
1028                    .unwrap()
1029                    .verify(&payload));
1030            }
1031            let (encoded, hash) = runtime
1032                .encode_signed_extrinsic(
1033                    &call,
1034                    wallet.public_key_bytes(),
1035                    &proof,
1036                    crypto,
1037                    &params(),
1038                )
1039                .unwrap();
1040            assert_eq!(hash, blake2_256(&encoded));
1041            runtime.decode_extrinsic(&encoded, true).unwrap();
1042            legacy_runtime.decode_extrinsic(&encoded, true).unwrap();
1043            let mut body = Cursor::new(&encoded);
1044            let _ = compact_u128(&mut body).unwrap();
1045            assert_eq!(body.byte().unwrap(), 0x84);
1046            assert_eq!(body.byte().unwrap(), 2);
1047            assert_eq!(compact_u128(&mut body).unwrap(), proof.len() as u128);
1048            assert_eq!(body.take(proof.len()).unwrap(), proof);
1049            assert_eq!(body.byte().unwrap(), 0);
1050            assert_eq!(
1051                body.take(64).unwrap(),
1052                subtensor_hashed::mldsa::CARRIER_SIGNATURE
1053            );
1054            assert_eq!(body.take(extra.len()).unwrap(), extra);
1055            assert_eq!(body.take(call.len()).unwrap(), call);
1056            assert!(runtime
1057                .encode_signed_extrinsic(&call, [0; 32], &proof, crypto, &params())
1058                .is_err());
1059            assert!(legacy_runtime
1060                .encode_signed_extrinsic(
1061                    &call,
1062                    wallet.public_key_bytes(),
1063                    &proof,
1064                    crypto,
1065                    &params()
1066                )
1067                .is_err());
1068        }
1069    }
1070
1071    #[test]
1072    fn historical_general_zero_decodes_without_hashed_authorization() {
1073        let runtime = runtime(false);
1074        let call = runtime
1075            .compose_call(
1076                "System",
1077                "remark",
1078                &Value::record(vec![("remark".into(), Value::Bytes(vec![]))]),
1079            )
1080            .unwrap();
1081        let (extra, _) = runtime.signature_payload_parts(&params()).unwrap();
1082        let mut body = vec![0x45, 0];
1083        body.extend_from_slice(&extra);
1084        body.extend_from_slice(&call);
1085        let mut encoded = Vec::new();
1086        compact(body.len() as u128, &mut encoded).unwrap();
1087        encoded.extend_from_slice(&body);
1088        let decoded = runtime.decode_extrinsic(&encoded, true).unwrap();
1089        let Value::Dict(fields) = decoded else {
1090            panic!("record expected")
1091        };
1092        assert!(!fields
1093            .iter()
1094            .any(|(name, _)| name == &Value::str("signature")));
1095        assert!(fields
1096            .iter()
1097            .any(|(name, value)| name == &Value::str("nonce")
1098                && matches!(value, Value::Uint(4) | Value::Int(4))));
1099    }
1100}