Skip to main content

bittensor_core/digest/
mod.rs

1//! RFC-0078 merkleized-metadata: the digest signed via `CheckMetadataHash`
2//! and the type proof hardware wallets need to decode a transaction on-device.
3//!
4//! Binds Parity's `merkleized-metadata` crate (the reference implementation
5//! whose fixtures match polkadot-js `merkleizeMetadata`) to raw
6//! `MetadataVersioned` bytes — the same blob the transport already downloads
7//! and caches. Two entry points:
8//!
9//! - [`metadata_digest`]: the 32-byte hash a `MetadataVerifyingSigner` returns
10//!   from its `metadata_digest` hook; the transport signs it into the payload
11//!   and flips the mode byte.
12//! - [`generate_extrinsic_proof`]: the SCALE-encoded proof blob the Ledger
13//!   generic app expects appended to the signature payload (the same
14//!   `MetadataProof { proof, extrinsic, extra_info }` wire shape Zondax's
15//!   `ledger-polkadot-generic-api` service produces).
16
17use codec::{Decode, Encode};
18use frame_metadata::{RuntimeMetadata, RuntimeMetadataPrefixed};
19use merkleized_metadata::{
20    generate_metadata_digest, generate_proof_for_extrinsic_parts, types::ExtrinsicMetadata,
21    ExtraInfo, FrameMetadataPrepared, Proof, SignedExtrinsicData,
22};
23use subtensor_macros::freeze_struct;
24
25use crate::error::CoreError;
26
27/// The chain constants that go into the digest alongside the metadata.
28///
29/// They must match what the runtime baked into its `RUNTIME_METADATA_HASH`
30/// (`substrate-wasm-builder`'s `enable_metadata_hash(token_symbol, decimals)`
31/// plus the runtime's spec name/version and ss58 prefix) or `CheckMetadataHash`
32/// rejects the extrinsic with `BadProof`.
33#[derive(Debug, Clone)]
34pub struct ChainInfo {
35    pub spec_version: u32,
36    pub spec_name: String,
37    pub base58_prefix: u16,
38    pub decimals: u8,
39    pub token_symbol: String,
40}
41
42impl ChainInfo {
43    fn extra_info(&self) -> ExtraInfo {
44        ExtraInfo {
45            spec_version: self.spec_version,
46            spec_name: self.spec_name.clone(),
47            base58_prefix: self.base58_prefix,
48            decimals: self.decimals,
49            token_symbol: self.token_symbol.clone(),
50        }
51    }
52}
53
54/// `ExtraInfo` in the SCALE wire shape the Ledger generic app decodes.
55///
56/// The upstream crate's `ExtraInfo` doesn't derive `Encode`; the field order
57/// here is pinned by the app's parser (spec_version, spec_name, base58_prefix,
58/// decimals, token_symbol — the RFC-0078 `MetadataDigest::V1` tail order).
59#[freeze_struct("edb2a68250293f5")]
60#[derive(Encode)]
61struct ExtraInfoEncoded {
62    spec_version: u32,
63    spec_name: String,
64    base58_prefix: u16,
65    decimals: u8,
66    token_symbol: String,
67}
68
69impl From<ExtraInfo> for ExtraInfoEncoded {
70    fn from(info: ExtraInfo) -> Self {
71        Self {
72            spec_version: info.spec_version,
73            spec_name: info.spec_name,
74            base58_prefix: info.base58_prefix,
75            decimals: info.decimals,
76            token_symbol: info.token_symbol,
77        }
78    }
79}
80
81/// The proof blob wire shape the Ledger generic app expects: the merkle proof
82/// of the types the extrinsic touches, the extrinsic metadata, and the chain
83/// constants — SCALE-encoded in this order.
84#[freeze_struct("e1038cfe3b767760")]
85#[derive(Encode)]
86struct MetadataProof {
87    proof: Proof,
88    extrinsic: ExtrinsicMetadata,
89    extra_info: ExtraInfoEncoded,
90}
91
92/// Decode a raw `MetadataVersioned` blob (magic `meta` + version byte +
93/// V14/V15 payload — exactly what `state_getMetadata` /
94/// `Metadata_metadata_at_version` return, unwrapped).
95fn decode_metadata(metadata_bytes: &[u8]) -> Result<RuntimeMetadata, CoreError> {
96    let prefixed = RuntimeMetadataPrefixed::decode(&mut &metadata_bytes[..])
97        .map_err(|e| CoreError::Codec(format!("cannot decode runtime metadata: {e}")))?;
98    Ok(prefixed.1)
99}
100
101/// The RFC-0078 metadata digest: the 32-byte hash `CheckMetadataHash` signs.
102pub fn metadata_digest(metadata_bytes: &[u8], info: &ChainInfo) -> Result<[u8; 32], CoreError> {
103    let metadata = decode_metadata(metadata_bytes)?;
104    let digest =
105        generate_metadata_digest(&metadata, info.extra_info()).map_err(CoreError::Codec)?;
106    Ok(digest.hash())
107}
108
109/// The SCALE-encoded metadata proof for one extrinsic, in the wire shape the
110/// Ledger generic app decodes on-device (appended after the signature payload).
111///
112/// - `call_data`: the SCALE-encoded call.
113/// - `included_in_extrinsic`: the signed-extension bytes that travel in the
114///   extrinsic (era, nonce, tip, mode byte...), in declaration order.
115/// - `included_in_signed_data`: the implied bytes both sides agree on
116///   (spec/tx version, genesis hash, era block hash, metadata hash...).
117pub fn generate_extrinsic_proof(
118    call_data: &[u8],
119    included_in_extrinsic: &[u8],
120    included_in_signed_data: &[u8],
121    metadata_bytes: &[u8],
122    info: &ChainInfo,
123) -> Result<Vec<u8>, CoreError> {
124    let metadata = decode_metadata(metadata_bytes)?;
125    let signed_ext_data = SignedExtrinsicData {
126        included_in_extrinsic,
127        included_in_signed_data,
128    };
129    let proof = generate_proof_for_extrinsic_parts(call_data, Some(signed_ext_data), &metadata)
130        .map_err(CoreError::Codec)?;
131    let extrinsic = FrameMetadataPrepared::prepare(&metadata)
132        .map_err(CoreError::Codec)?
133        .as_type_information()
134        .map_err(CoreError::Codec)?
135        .extrinsic_metadata;
136    let metadata_proof = MetadataProof {
137        proof,
138        extrinsic,
139        extra_info: info.extra_info().into(),
140    };
141    Ok(metadata_proof.encode())
142}
143
144#[cfg(test)]
145mod tests {
146    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
147
148    use super::*;
149
150    /// The Bittensor mainnet/localnet chain constants (runtime/build.rs bakes
151    /// TAO / 9 decimals; ss58 prefix 42; spec name "node-subtensor").
152    fn chain_info(spec_version: u32) -> ChainInfo {
153        ChainInfo {
154            spec_version,
155            spec_name: "node-subtensor".into(),
156            base58_prefix: 42,
157            decimals: 9,
158            token_symbol: "TAO".into(),
159        }
160    }
161
162    /// The golden fixture recorded from localnet by the Python test suite.
163    fn golden() -> serde_json::Value {
164        let path = concat!(
165            env!("CARGO_MANIFEST_DIR"),
166            "/../python/tests/fixtures/golden.json"
167        );
168        let raw = std::fs::read_to_string(path).expect("golden.json fixture exists");
169        serde_json::from_str(&raw).unwrap()
170    }
171
172    fn golden_metadata_v15() -> Vec<u8> {
173        let golden = golden();
174        let hex_str = golden["metadata"]["v15_hex"]
175            .as_str()
176            .expect("golden.json has metadata.v15_hex");
177        let raw = hex::decode(hex_str.trim_start_matches("0x")).unwrap();
178        // The fixture stores the raw `Metadata_metadata_at_version` response:
179        // an SCALE `Option<OpaqueMetadata>` wrapping the MetadataVersioned blob.
180        Option::<Vec<u8>>::decode(&mut &raw[..])
181            .unwrap()
182            .expect("fixture metadata is Some")
183    }
184
185    /// Cross-implementation vector: the same metadata + chain info fed to
186    /// polkadot-api's `merkleizeMetadata` (the JS implementation wallets use)
187    /// produces this digest. The JS library validates the extra info against
188    /// the metadata's own System.Version, so this pins spec name, version,
189    /// prefix, decimals, and symbol all at once.
190    #[test]
191    fn digest_matches_polkadot_js_merkleize_metadata() {
192        let golden = golden();
193        let spec_version = golden["network"]["spec_version"]
194            .as_u64()
195            .and_then(|value| u32::try_from(value).ok())
196            .expect("golden.json network.spec_version fits u32");
197        let metadata = golden_metadata_v15();
198        let digest = metadata_digest(&metadata, &chain_info(spec_version)).unwrap();
199        assert_eq!(
200            hex::encode(digest),
201            "affbcc25b1cb6847bc94dcf3a7f35fb8d6a5c59d9d47da92063a9399a5fc11c0"
202        );
203    }
204
205    #[test]
206    fn digest_is_deterministic_and_spec_version_sensitive() {
207        let metadata = golden_metadata_v15();
208        let digest_a = metadata_digest(&metadata, &chain_info(1)).unwrap();
209        let digest_b = metadata_digest(&metadata, &chain_info(1)).unwrap();
210        assert_eq!(digest_a, digest_b);
211        // The spec version is part of the digest: a different runtime version
212        // must produce a different hash.
213        let digest_c = metadata_digest(&metadata, &chain_info(2)).unwrap();
214        assert_ne!(digest_a, digest_c);
215        assert_ne!(digest_a, [0u8; 32]);
216    }
217
218    #[test]
219    fn rejects_garbage_metadata() {
220        let result = metadata_digest(&[0u8; 16], &chain_info(1));
221        assert!(matches!(result, Err(CoreError::Codec(_))));
222    }
223
224    /// The Ledger proof blob, cross-checked two ways.
225    ///
226    /// Byte-exact against the pinned vector (recorded from this crate — the
227    /// same `generate_proof_for_extrinsic_parts` call Zondax's
228    /// `ledger-polkadot-generic-api` service ships to devices), and
229    /// envelope-compatible with polkadot-api's `getProofForExtrinsicParts`:
230    /// the JS proof carries fewer leaves (it omits the address/signature
231    /// types that only appear in the full extrinsic), so the merkle sections
232    /// differ, but both must agree on the trailing
233    /// `ExtrinsicMetadata ++ ExtraInfo` wire encoding the device parses. The
234    /// tree contents themselves are cross-checked by the digest vector above.
235    #[test]
236    fn extrinsic_proof_matches_pinned_vectors() {
237        let path = concat!(
238            env!("CARGO_MANIFEST_DIR"),
239            "/fixtures/ledger_proof_vector.json"
240        );
241        let raw = std::fs::read_to_string(path).expect("ledger proof vector fixture exists");
242        let vector: serde_json::Value = serde_json::from_str(&raw).unwrap();
243        let field =
244            |name: &str| -> Vec<u8> { hex::decode(vector[name].as_str().unwrap()).unwrap() };
245        let metadata = golden_metadata_v15();
246        let spec_version = match &vector["spec_version"] {
247            serde_json::Value::Number(number) => number
248                .to_string()
249                .parse()
250                .expect("fixture spec_version fits u32"),
251            value => panic!("fixture spec_version {value} is not an integer"),
252        };
253        let proof = generate_extrinsic_proof(
254            &field("call_data_hex"),
255            &field("included_in_extrinsic_hex"),
256            &field("included_in_signed_data_hex"),
257            &metadata,
258            &chain_info(spec_version),
259        )
260        .unwrap();
261        assert_eq!(
262            hex::encode(&proof),
263            vector["rust_proof_hex"].as_str().unwrap()
264        );
265        // ExtrinsicMetadata ++ ExtraInfo: everything after the merkle proof.
266        // ExtraInfo alone is spec_version(4) + spec_name + prefix(2) +
267        // decimals(1) + symbol; the shared tail across both implementations
268        // must cover at least that plus the signed-extension list.
269        let js_proof = field("polkadot_js_proof_hex");
270        let tail_len = 512.min(js_proof.len()).min(proof.len());
271        assert_eq!(
272            proof.get(proof.len() - tail_len..),
273            js_proof.get(js_proof.len() - tail_len..),
274        );
275    }
276}