Skip to main content

host_encoding/
extrinsic.rs

1//! Substrate extrinsic signing payload decoder.
2//!
3//! Decodes the opaque `payload` bytes from a `NeedsSign` outcome (tag 36,
4//! `sign_payload`) into structured fields: genesis hash, block hash,
5//! spec version, transaction version, and optional metadata hash.
6//!
7//! # Layout
8//!
9//! A Substrate V4 extrinsic signing payload is:
10//!
11//! ```text
12//! [call_data ++ signed_extensions_extra] | spec_version(4) | tx_version(4) | genesis_hash(32) | block_hash(32) [| metadata_hash_option(1 or 33)]
13//! ```
14//!
15//! The "additional signed" tail has fixed sizes and is always at the end.
16//! Everything before it (call data concatenated with extensions extra like
17//! era, nonce, tip) is returned as an opaque blob — separating call data
18//! from extensions requires runtime metadata.
19//!
20//! # Limitations
21//!
22//! - **Heuristic, not a security boundary.** A malicious app can craft raw
23//!   bytes where the tail positions produce any `genesis_hash`. Use the
24//!   decoded fields for display/hint purposes only — they are not a
25//!   guarantee of what the signer will actually commit to.
26//!
27//! - **Substrate V4 only.** Future extrinsic format versions (V5+) may
28//!   change the additional-signed layout. If decoding fails, callers should
29//!   fall back to displaying the raw hex payload.
30//!
31//! - **Pre-hashed payloads.** When the original payload exceeds 256 bytes,
32//!   the Substrate signing protocol hashes it (blake2b-256) to 32 bytes
33//!   before signing. If the `payload` field in `NeedsSign` is already the
34//!   32-byte hash, this function returns [`ExtrinsicError::PayloadHashed`].
35//!
36//! No I/O. No threads. WASM-safe.
37
38use thiserror::Error;
39
40/// Errors from extrinsic signing payload decoding.
41#[derive(Debug, Error, PartialEq)]
42pub enum ExtrinsicError {
43    /// The payload is exactly 32 bytes, which strongly suggests it has already
44    /// been blake2b-256 hashed (Substrate hashes payloads > 256 bytes before
45    /// signing). A hash cannot be decoded into structured fields.
46    #[error("signing payload appears to be pre-hashed (32 bytes); cannot decode")]
47    PayloadHashed,
48
49    /// The payload is too short or has an unrecognizable layout. None of the
50    /// three known additional-signed tail interpretations (with metadata hash
51    /// Some, with metadata hash None, or without CheckMetadataHash) produced
52    /// a valid result with a non-empty call-data prefix.
53    #[error("no valid extrinsic layout found ({actual} bytes); ensure it is a Substrate V4 signing payload and is not pre-hashed")]
54    InvalidLayout { actual: usize },
55}
56
57/// Decoded fields from a Substrate extrinsic signing payload.
58///
59/// See [module documentation](self) for layout details and limitations.
60#[derive(Debug, Clone, PartialEq)]
61pub struct DecodedSignPayload {
62    /// Genesis hash of the target chain (32 bytes).
63    pub genesis_hash: [u8; 32],
64    /// Block hash for the mortality window (32 bytes).
65    /// For immortal transactions this equals `genesis_hash`.
66    pub block_hash: [u8; 32],
67    /// Runtime spec version.
68    pub spec_version: u32,
69    /// Transaction format version.
70    pub tx_version: u32,
71    /// Metadata hash from `CheckMetadataHash` extension, if active with mode=1.
72    pub metadata_hash: Option<[u8; 32]>,
73    /// Raw bytes containing call data concatenated with signed-extension extra
74    /// data (era, nonce, tip, and any other extension-specific bytes).
75    ///
76    /// Splitting call data from extensions requires runtime metadata. Without
77    /// metadata, this blob can still be displayed as hex for user confirmation.
78    pub call_data_and_extra: Vec<u8>,
79}
80
81// Additional-signed tail sizes for each interpretation.
82// spec_version(4) + tx_version(4) + genesis_hash(32) + block_hash(32) = 72
83const TAIL_BASE: usize = 4 + 4 + 32 + 32;
84// + Option::None (0x00) = 73
85const TAIL_META_NONE: usize = TAIL_BASE + 1;
86// + Option::Some(0x01) + hash(32) = 105
87const TAIL_META_SOME: usize = TAIL_BASE + 1 + 32;
88
89/// Decode a Substrate extrinsic signing payload into its constituent fields.
90///
91/// Takes the raw `payload` bytes from a `NeedsSign` outcome where
92/// `request_tag == 36` (sign_payload). Returns the decoded additional-signed
93/// fields and the opaque call-data-plus-extra prefix.
94///
95/// # Errors
96///
97/// - [`ExtrinsicError::PayloadHashed`] if the payload is exactly 32 bytes
98///   (likely a blake2b-256 hash).
99/// - [`ExtrinsicError::InvalidLayout`] if the payload is too short or no
100///   valid interpretation of the additional-signed tail succeeds.
101pub fn decode_sign_payload(payload: &[u8]) -> Result<DecodedSignPayload, ExtrinsicError> {
102    let len = payload.len();
103
104    // Detect pre-hashed payloads (Substrate hashes payloads > 256 bytes to 32 bytes).
105    if len == 32 {
106        return Err(ExtrinsicError::PayloadHashed);
107    }
108
109    // Try interpretation 1: CheckMetadataHash with mode=1 (Some(hash)).
110    // Tail = 105 bytes. The Option::Some tag byte is at offset len - 33 - 32 - 32 - 4 - 4 = len - 105 + 72.
111    if len > TAIL_META_SOME {
112        let option_offset = len - TAIL_META_SOME + TAIL_BASE;
113        if payload[option_offset] == 0x01 {
114            let prefix = &payload[..len - TAIL_META_SOME];
115            if !prefix.is_empty() {
116                return Ok(decode_tail(payload, prefix, len - TAIL_META_SOME, true));
117            }
118        }
119    }
120
121    // Try interpretation 2: CheckMetadataHash with mode=0 (None).
122    // Tail = 73 bytes. The Option::None tag byte is at offset len - 1.
123    if len > TAIL_META_NONE {
124        let option_offset = len - TAIL_META_NONE + TAIL_BASE;
125        if payload[option_offset] == 0x00 {
126            let prefix = &payload[..len - TAIL_META_NONE];
127            if !prefix.is_empty() {
128                return Ok(decode_tail(payload, prefix, len - TAIL_META_NONE, false));
129            }
130        }
131    }
132
133    // Try interpretation 3: No CheckMetadataHash extension (legacy, 72-byte tail).
134    if len > TAIL_BASE {
135        let prefix = &payload[..len - TAIL_BASE];
136        if !prefix.is_empty() {
137            let tail_start = len - TAIL_BASE;
138            // SAFETY: slices are exactly 4 bytes; bounds guaranteed by `len > TAIL_BASE`.
139            let spec_version =
140                u32::from_le_bytes(payload[tail_start..tail_start + 4].try_into().unwrap());
141            let tx_version =
142                u32::from_le_bytes(payload[tail_start + 4..tail_start + 8].try_into().unwrap());
143            let mut genesis_hash = [0u8; 32];
144            genesis_hash.copy_from_slice(&payload[tail_start + 8..tail_start + 40]);
145            let mut block_hash = [0u8; 32];
146            block_hash.copy_from_slice(&payload[tail_start + 40..tail_start + 72]);
147
148            return Ok(DecodedSignPayload {
149                genesis_hash,
150                block_hash,
151                spec_version,
152                tx_version,
153                metadata_hash: None,
154                call_data_and_extra: prefix.to_vec(),
155            });
156        }
157    }
158
159    Err(ExtrinsicError::InvalidLayout { actual: len })
160}
161
162/// Extract the base additional-signed fields from the tail and optionally
163/// the metadata hash.
164fn decode_tail(
165    payload: &[u8],
166    prefix: &[u8],
167    tail_start: usize,
168    has_metadata_hash: bool,
169) -> DecodedSignPayload {
170    // SAFETY: slices are exactly 4 bytes; bounds guaranteed by caller's `len > TAIL_*` guard.
171    let spec_version = u32::from_le_bytes(payload[tail_start..tail_start + 4].try_into().unwrap());
172    let tx_version =
173        u32::from_le_bytes(payload[tail_start + 4..tail_start + 8].try_into().unwrap());
174    let mut genesis_hash = [0u8; 32];
175    genesis_hash.copy_from_slice(&payload[tail_start + 8..tail_start + 40]);
176    let mut block_hash = [0u8; 32];
177    block_hash.copy_from_slice(&payload[tail_start + 40..tail_start + 72]);
178
179    let metadata_hash = if has_metadata_hash {
180        // Option::Some tag is at tail_start + 72, hash starts at tail_start + 73
181        let mut h = [0u8; 32];
182        h.copy_from_slice(&payload[tail_start + 73..tail_start + 105]);
183        Some(h)
184    } else {
185        None
186    };
187
188    DecodedSignPayload {
189        genesis_hash,
190        block_hash,
191        spec_version,
192        tx_version,
193        metadata_hash,
194        call_data_and_extra: prefix.to_vec(),
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    // -- Test vector constants --
203
204    const SPEC: u32 = 1_002_004;
205    const TX: u32 = 26;
206    const GENESIS: [u8; 32] = [0x91; 32];
207    const BLOCK: [u8; 32] = [0xAB; 32];
208    const META_HASH: [u8; 32] = [0xCC; 32];
209    // Minimal realistic call prefix (5 bytes).
210    const PREFIX: &[u8] = &[0x05, 0x03, 0x00, 0x00, 0x00];
211
212    /// Build a synthetic signing payload with CheckMetadataHash mode=0 (None).
213    fn make_mode_none(prefix: &[u8]) -> Vec<u8> {
214        let mut v = prefix.to_vec();
215        v.extend_from_slice(&SPEC.to_le_bytes());
216        v.extend_from_slice(&TX.to_le_bytes());
217        v.extend_from_slice(&GENESIS);
218        v.extend_from_slice(&BLOCK);
219        v.push(0x00); // Option::None
220        v
221    }
222
223    /// Build a synthetic signing payload with CheckMetadataHash mode=1 (Some).
224    fn make_mode_some(prefix: &[u8], hash: &[u8; 32]) -> Vec<u8> {
225        let mut v = prefix.to_vec();
226        v.extend_from_slice(&SPEC.to_le_bytes());
227        v.extend_from_slice(&TX.to_le_bytes());
228        v.extend_from_slice(&GENESIS);
229        v.extend_from_slice(&BLOCK);
230        v.push(0x01); // Option::Some
231        v.extend_from_slice(hash);
232        v
233    }
234
235    /// Build a synthetic signing payload without CheckMetadataHash (legacy).
236    fn make_legacy(prefix: &[u8]) -> Vec<u8> {
237        let mut v = prefix.to_vec();
238        v.extend_from_slice(&SPEC.to_le_bytes());
239        v.extend_from_slice(&TX.to_le_bytes());
240        v.extend_from_slice(&GENESIS);
241        v.extend_from_slice(&BLOCK);
242        v
243    }
244
245    // -----------------------------------------------------------------------
246    // Happy paths
247    // -----------------------------------------------------------------------
248
249    #[test]
250    fn test_decodes_payload_with_metadata_hash_some() {
251        let payload = make_mode_some(PREFIX, &META_HASH);
252        let decoded = decode_sign_payload(&payload).unwrap();
253        assert_eq!(decoded.genesis_hash, GENESIS);
254        assert_eq!(decoded.block_hash, BLOCK);
255        assert_eq!(decoded.spec_version, SPEC);
256        assert_eq!(decoded.tx_version, TX);
257        assert_eq!(decoded.metadata_hash, Some(META_HASH));
258        assert_eq!(decoded.call_data_and_extra, PREFIX);
259    }
260
261    #[test]
262    fn test_decodes_payload_with_metadata_hash_none() {
263        let payload = make_mode_none(PREFIX);
264        let decoded = decode_sign_payload(&payload).unwrap();
265        assert_eq!(decoded.genesis_hash, GENESIS);
266        assert_eq!(decoded.block_hash, BLOCK);
267        assert_eq!(decoded.spec_version, SPEC);
268        assert_eq!(decoded.tx_version, TX);
269        assert_eq!(decoded.metadata_hash, None);
270        assert_eq!(decoded.call_data_and_extra, PREFIX);
271    }
272
273    #[test]
274    fn test_decodes_payload_without_checkmetadatahash_extension() {
275        let payload = make_legacy(PREFIX);
276        let decoded = decode_sign_payload(&payload).unwrap();
277        assert_eq!(decoded.genesis_hash, GENESIS);
278        assert_eq!(decoded.block_hash, BLOCK);
279        assert_eq!(decoded.spec_version, SPEC);
280        assert_eq!(decoded.tx_version, TX);
281        assert_eq!(decoded.metadata_hash, None);
282        assert_eq!(decoded.call_data_and_extra, PREFIX);
283    }
284
285    #[test]
286    fn test_genesis_hash_and_block_hash_are_distinct() {
287        let genesis = [0x91; 32];
288        let block = [0xAB; 32];
289        let mut v = PREFIX.to_vec();
290        v.extend_from_slice(&SPEC.to_le_bytes());
291        v.extend_from_slice(&TX.to_le_bytes());
292        v.extend_from_slice(&genesis);
293        v.extend_from_slice(&block);
294        v.push(0x00);
295        let decoded = decode_sign_payload(&v).unwrap();
296        assert_eq!(decoded.genesis_hash, genesis);
297        assert_eq!(decoded.block_hash, block);
298        assert_ne!(decoded.genesis_hash, decoded.block_hash);
299    }
300
301    #[test]
302    fn test_spec_and_tx_version_are_little_endian() {
303        let payload = make_mode_none(PREFIX);
304        let decoded = decode_sign_payload(&payload).unwrap();
305        assert_eq!(decoded.spec_version, 1_002_004);
306        assert_eq!(decoded.tx_version, 26);
307    }
308
309    #[test]
310    fn test_decodes_minimum_valid_payload_mode_none() {
311        // 1-byte prefix + 73-byte tail = 74 bytes
312        let payload = make_mode_none(&[0xFF]);
313        assert_eq!(payload.len(), 74);
314        let decoded = decode_sign_payload(&payload).unwrap();
315        assert_eq!(decoded.call_data_and_extra, vec![0xFF]);
316    }
317
318    #[test]
319    fn test_decodes_minimum_valid_payload_legacy() {
320        // 1-byte prefix + 72-byte tail = 73 bytes
321        let payload = make_legacy(&[0xFF]);
322        assert_eq!(payload.len(), 73);
323        let decoded = decode_sign_payload(&payload).unwrap();
324        assert_eq!(decoded.call_data_and_extra, vec![0xFF]);
325    }
326
327    #[test]
328    fn test_call_data_and_extra_is_exact_prefix() {
329        let prefix = vec![0x01, 0x02, 0x03, 0x04, 0x05];
330        let payload = make_mode_none(&prefix);
331        let decoded = decode_sign_payload(&payload).unwrap();
332        assert_eq!(decoded.call_data_and_extra, prefix);
333    }
334
335    #[test]
336    fn test_decodes_payload_with_large_call_data() {
337        let prefix = vec![0xAA; 300];
338        let payload = make_mode_none(&prefix);
339        assert!(payload.len() > 256);
340        let decoded = decode_sign_payload(&payload).unwrap();
341        assert_eq!(decoded.call_data_and_extra.len(), 300);
342        assert_eq!(decoded.genesis_hash, GENESIS);
343    }
344
345    #[test]
346    fn test_prefers_metadata_hash_some_over_none_when_ambiguous() {
347        // Build a mode=1 payload. The decoder should match mode=1 first
348        // and not fall through to mode=0 or legacy.
349        let payload = make_mode_some(PREFIX, &META_HASH);
350        let decoded = decode_sign_payload(&payload).unwrap();
351        assert_eq!(decoded.metadata_hash, Some(META_HASH));
352    }
353
354    #[test]
355    fn test_heuristic_limitation_mode_none_with_spec_version_lsb_0x01() {
356        // Documented heuristic limitation: if a mode-None payload has a
357        // spec_version whose LSB is 0x01, the decoder may misclassify it as
358        // mode-Some because the 0x01 byte at the option_offset position looks
359        // like an Option::Some tag. We construct such a payload and verify
360        // the decoder still returns a valid result (even if the interpretation
361        // differs from the "true" layout). The important thing is that it does
362        // not error — callers get a best-effort decode either way.
363        let spec_with_lsb_01: u32 = 0x00_00_01_01; // LSB = 0x01
364        let mut v = vec![0x05; 34]; // 34-byte prefix to hit the ambiguity window
365        v.extend_from_slice(&spec_with_lsb_01.to_le_bytes());
366        v.extend_from_slice(&TX.to_le_bytes());
367        v.extend_from_slice(&GENESIS);
368        v.extend_from_slice(&BLOCK);
369        v.push(0x00); // Option::None
370        let result = decode_sign_payload(&v);
371        // The decoder should not error — it produces a valid (possibly
372        // mode-Some) interpretation rather than failing.
373        assert!(
374            result.is_ok(),
375            "heuristic edge case must not error: {result:?}"
376        );
377    }
378
379    // -----------------------------------------------------------------------
380    // Error paths — every ExtrinsicError variant exercised
381    // -----------------------------------------------------------------------
382
383    #[test]
384    fn test_rejects_empty_payload() {
385        let result = decode_sign_payload(&[]);
386        assert_eq!(result, Err(ExtrinsicError::InvalidLayout { actual: 0 }));
387    }
388
389    #[test]
390    fn test_rejects_payload_too_short() {
391        let result = decode_sign_payload(&[0u8; 71]);
392        assert_eq!(result, Err(ExtrinsicError::InvalidLayout { actual: 71 }));
393    }
394
395    #[test]
396    fn test_rejects_hashed_payload() {
397        let result = decode_sign_payload(&[0xAA; 32]);
398        assert_eq!(result, Err(ExtrinsicError::PayloadHashed));
399    }
400
401    #[test]
402    fn test_rejects_payload_with_empty_prefix() {
403        // Exactly 72 bytes (TAIL_BASE) — the legacy tail fills the entire
404        // payload, leaving an empty prefix. All interpretations fail because
405        // we require at least 1 byte of call data.
406        let result = decode_sign_payload(&[0u8; 72]);
407        assert_eq!(result, Err(ExtrinsicError::InvalidLayout { actual: 72 }));
408    }
409
410    // -----------------------------------------------------------------------
411    // Pinned regression vectors
412    // -----------------------------------------------------------------------
413
414    #[test]
415    fn test_golden_polkadot_like_payload_mode_none() {
416        let payload = make_mode_none(PREFIX);
417        let decoded = decode_sign_payload(&payload).unwrap();
418        // Pin every field to exact expected values.
419        assert_eq!(decoded.spec_version, 1_002_004);
420        assert_eq!(decoded.tx_version, 26);
421        assert_eq!(decoded.genesis_hash, [0x91; 32]);
422        assert_eq!(decoded.block_hash, [0xAB; 32]);
423        assert_eq!(decoded.metadata_hash, None);
424        assert_eq!(
425            decoded.call_data_and_extra,
426            vec![0x05, 0x03, 0x00, 0x00, 0x00]
427        );
428    }
429
430    #[test]
431    fn test_golden_polkadot_like_payload_mode_some() {
432        let payload = make_mode_some(PREFIX, &META_HASH);
433        let decoded = decode_sign_payload(&payload).unwrap();
434        assert_eq!(decoded.spec_version, 1_002_004);
435        assert_eq!(decoded.tx_version, 26);
436        assert_eq!(decoded.genesis_hash, [0x91; 32]);
437        assert_eq!(decoded.block_hash, [0xAB; 32]);
438        assert_eq!(decoded.metadata_hash, Some([0xCC; 32]));
439        assert_eq!(
440            decoded.call_data_and_extra,
441            vec![0x05, 0x03, 0x00, 0x00, 0x00]
442        );
443    }
444
445    #[test]
446    fn test_golden_legacy_payload() {
447        let payload = make_legacy(&[0xFF]);
448        let decoded = decode_sign_payload(&payload).unwrap();
449        assert_eq!(decoded.spec_version, SPEC);
450        assert_eq!(decoded.tx_version, TX);
451        assert_eq!(decoded.genesis_hash, GENESIS);
452        assert_eq!(decoded.block_hash, BLOCK);
453        assert_eq!(decoded.metadata_hash, None);
454        assert_eq!(decoded.call_data_and_extra, vec![0xFF]);
455    }
456
457    #[test]
458    fn test_metadata_hash_all_zeros_is_some() {
459        // A metadata hash of all-zeros must be decoded as Some([0x00;32]),
460        // not confused with Option::None.
461        let zero_hash = [0x00u8; 32];
462        let payload = make_mode_some(PREFIX, &zero_hash);
463        let decoded = decode_sign_payload(&payload).unwrap();
464        assert_eq!(decoded.metadata_hash, Some(zero_hash));
465    }
466}