Skip to main content

dpp/shielded/
sighash.rs

1//! Platform sighash preimage construction for shielded transitions.
2//!
3//! Shielded transitions carry NO platform identity signature — authorization is the Orchard proof +
4//! per-action spend-auth signatures + the RedPallas binding signature over the platform sighash.
5//! These helpers build the transparent `extra_data` each transition binds into that sighash so the
6//! signing (client/builder) and verifying (consensus) sides commit to identical bytes. The byte
7//! layouts are consensus-critical and versioned via `dpp.methods.shielded_extra_sighash_data`.
8
9use crate::address_funds::PlatformAddress;
10use crate::identity::identity_public_key::contract_bounds::ContractBounds;
11use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters;
12use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation;
13use crate::withdrawal::Pooling;
14use crate::ProtocolError;
15use platform_version::version::PlatformVersion;
16use sha2::{Digest, Sha256};
17
18/// Domain separator for Platform sighash computation.
19const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash";
20
21/// Computes the platform sighash from an Orchard bundle commitment and optional
22/// transparent field data.
23///
24/// The sighash is computed as:
25///   `SHA-256(SIGHASH_DOMAIN || bundle_commitment || extra_data)`
26///
27/// This binds transparent state transition fields (like `output_address` in unshield
28/// or `output_script` in shielded withdrawal) to the Orchard signatures, preventing
29/// replay attacks where an attacker substitutes transparent fields while reusing a
30/// valid Orchard bundle.
31///
32/// The same computation must be used on both the signing (client) and verification
33/// (platform) sides. For transitions without transparent fields (shield and
34/// shielded_transfer), `extra_data` is empty.
35pub fn compute_platform_sighash(bundle_commitment: &[u8; 32], extra_data: &[u8]) -> [u8; 32] {
36    let mut hasher = Sha256::new();
37    hasher.update(SIGHASH_DOMAIN);
38    hasher.update(bundle_commitment);
39    hasher.update(extra_data);
40    hasher.finalize().into()
41}
42
43/// Builds the transparent `extra_data` bound into a ShieldedWithdrawal's platform
44/// sighash, with the byte layout
45/// `output_script || unshielding_amount (u64 LE) || core_fee_per_byte (u32 LE) || pooling (u8)`.
46///
47/// Every field here is written verbatim by the transformer into the queued withdrawal
48/// document that constructs the Core asset-unlock TxOut. Binding all of them into the
49/// Orchard sighash means the binding signature authorizes them: since ShieldedWithdrawal
50/// has no identity-key signature and no address-witness check, the Orchard signature is
51/// the only authorization boundary, so a relay or block proposer cannot malleate
52/// `core_fee_per_byte` (or `pooling`, were it ever unpinned from `Never`) — e.g. flip a
53/// user's `core_fee_per_byte = 1` to a much larger Fibonacci value to redirect the
54/// withdrawn amount into L1 miner fees — without invalidating the proof.
55///
56/// The signing (client/builder) and verifying (consensus) sides MUST produce identical
57/// bytes, so both call this single function.
58///
59/// The layout places the variable-length `output_script` first with no length prefix. This
60/// is unambiguous only because `validate_structure` runs before proof verification and pins
61/// `output_script` to a canonical, fixed-length P2PKH (25 bytes) or P2SH (23 bytes); the
62/// remaining fields are fixed-width, so the preimage is well-defined for every accepted
63/// transition. If that script-shape restriction is ever relaxed, add a length prefix here.
64/// Dispatches on the platform-versioned `dpp.methods.shielded_extra_sighash_data` so the
65/// consensus-critical byte layout can evolve across protocol versions without breaking older
66/// transitions — the same versioning the sibling shielded fee methods use. The signing
67/// (client/builder) and verifying (consensus) sides both call this single function with the same
68/// `platform_version`, so they can never produce divergent preimages.
69pub fn shielded_withdrawal_extra_sighash_data(
70    output_script: &[u8],
71    unshielding_amount: u64,
72    core_fee_per_byte: u32,
73    pooling: Pooling,
74    platform_version: &PlatformVersion,
75) -> Result<Vec<u8>, ProtocolError> {
76    match platform_version.dpp.methods.shielded_extra_sighash_data {
77        0 => Ok(shielded_withdrawal_extra_sighash_data_v0(
78            output_script,
79            unshielding_amount,
80            core_fee_per_byte,
81            pooling,
82        )),
83        version => Err(ProtocolError::UnknownVersionMismatch {
84            method: "shielded_withdrawal_extra_sighash_data".to_string(),
85            known_versions: vec![0],
86            received: version,
87        }),
88    }
89}
90
91/// v0 byte layout of [`shielded_withdrawal_extra_sighash_data`] (see that function's doc comment for
92/// the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version.
93pub fn shielded_withdrawal_extra_sighash_data_v0(
94    output_script: &[u8],
95    unshielding_amount: u64,
96    core_fee_per_byte: u32,
97    pooling: Pooling,
98) -> Vec<u8> {
99    let mut data = Vec::with_capacity(output_script.len() + 8 + 4 + 1);
100    data.extend_from_slice(output_script);
101    data.extend_from_slice(&unshielding_amount.to_le_bytes());
102    data.extend_from_slice(&core_fee_per_byte.to_le_bytes());
103    data.push(pooling as u8);
104    data
105}
106
107/// Builds the transparent `extra_data` bound into an Unshield's platform sighash, with the
108/// byte layout `output_address || unshielding_amount (u64 LE)`.
109///
110/// As with [`shielded_withdrawal_extra_sighash_data`], the signing (client/builder) and
111/// verifying (consensus) sides MUST produce identical bytes, so both call this single
112/// function. Unshield credits a transparent platform address (not a Core asset-unlock
113/// `TxOut`), so it carries no `core_fee_per_byte`/`pooling` to bind.
114pub fn unshield_extra_sighash_data(
115    output_address: &[u8],
116    unshielding_amount: u64,
117    platform_version: &PlatformVersion,
118) -> Result<Vec<u8>, ProtocolError> {
119    match platform_version.dpp.methods.shielded_extra_sighash_data {
120        0 => Ok(unshield_extra_sighash_data_v0(
121            output_address,
122            unshielding_amount,
123        )),
124        version => Err(ProtocolError::UnknownVersionMismatch {
125            method: "unshield_extra_sighash_data".to_string(),
126            known_versions: vec![0],
127            received: version,
128        }),
129    }
130}
131
132/// v0 byte layout of [`unshield_extra_sighash_data`] (see that function's doc comment for the layout
133/// and rationale). Frozen: never mutate; a layout change requires a new `_v1` + version bump.
134pub fn unshield_extra_sighash_data_v0(output_address: &[u8], unshielding_amount: u64) -> Vec<u8> {
135    let mut data = Vec::with_capacity(output_address.len() + 8);
136    data.extend_from_slice(output_address);
137    data.extend_from_slice(&unshielding_amount.to_le_bytes());
138    data
139}
140
141/// Builds the transparent `extra_data` bound into an `IdentityCreateFromShieldedPool`'s platform
142/// sighash, with the byte layout
143/// `identity_id (32) || denomination (u64 LE)
144///   || send_to_address_on_creation_failure (tag u8: 0=P2pkh, 1=P2sh || hash 20)
145///   || num_keys (u16 LE)
146///   || for each key in supplied order: key_id (u32 LE) || purpose (u8) || security_level (u8)
147///   || key_type (u8) || key_data_len (u16 LE) || key_data || read_only (u8)
148///   || contract_bounds (tag u8: 0=None, 1=SingleContract id(32), 2=SingleContractDocumentType
149///   id(32) name_len(u16 LE) name)`.
150///
151/// `IdentityCreateFromShieldedPool` carries NO platform identity signature: authorization is 100%
152/// the Orchard proof + per-action spend-auth signatures + binding signature over this sighash. The
153/// transparent, state-determining fields — the new identity id, the exit denomination, and the
154/// FULL public-key set — must therefore be committed into the Orchard sighash, exactly as the
155/// `surplus_output` field is committed into `ShieldFromAssetLock`'s ECDSA signature. Without this
156/// binding a relay or block proposer could take a valid bundle exiting a denomination and re-point
157/// it at a DIFFERENT identity id, or swap in DIFFERENT keys they control, stealing the credited
158/// balance (the per-key proofs-of-possession alone do NOT prevent this — a relayer keeps valid PoP
159/// sigs for their own keys while swapping the bundle). Binding `(this spend → these exact keys →
160/// this id → this denomination)` here makes the redirection atomic-or-invalid.
161///
162/// The signing (client/builder) and verifying (consensus) sides MUST produce identical bytes, so
163/// both call this single function. Unlike the fixed-length withdrawal/unshield helpers, the
164/// variable-length key list is fully length-prefixed (both the key count and each key's data) so
165/// the preimage is unambiguous for any key set.
166pub fn identity_create_from_shielded_extra_sighash_data(
167    identity_id: &[u8; 32],
168    denomination: u64,
169    send_to_address_on_creation_failure: &PlatformAddress,
170    public_keys: &[IdentityPublicKeyInCreation],
171    platform_version: &PlatformVersion,
172) -> Result<Vec<u8>, ProtocolError> {
173    match platform_version.dpp.methods.shielded_extra_sighash_data {
174        0 => Ok(identity_create_from_shielded_extra_sighash_data_v0(
175            identity_id,
176            denomination,
177            send_to_address_on_creation_failure,
178            public_keys,
179        )),
180        version => Err(ProtocolError::UnknownVersionMismatch {
181            method: "identity_create_from_shielded_extra_sighash_data".to_string(),
182            known_versions: vec![0],
183            received: version,
184        }),
185    }
186}
187
188/// v0 byte layout of [`identity_create_from_shielded_extra_sighash_data`] (see that function's doc
189/// comment for the layout and rationale). Frozen: never mutate; a layout change requires a new `_v1`
190/// + version bump.
191pub fn identity_create_from_shielded_extra_sighash_data_v0(
192    identity_id: &[u8; 32],
193    denomination: u64,
194    send_to_address_on_creation_failure: &PlatformAddress,
195    public_keys: &[IdentityPublicKeyInCreation],
196) -> Vec<u8> {
197    let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44);
198    data.extend_from_slice(identity_id);
199    data.extend_from_slice(&denomination.to_le_bytes());
200    // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the
201    // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address.
202    match send_to_address_on_creation_failure {
203        PlatformAddress::P2pkh(hash) => {
204            data.push(0u8);
205            data.extend_from_slice(hash);
206        }
207        PlatformAddress::P2sh(hash) => {
208            data.push(1u8);
209            data.extend_from_slice(hash);
210        }
211    }
212    data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes());
213    for key in public_keys {
214        data.extend_from_slice(&key.id().to_le_bytes());
215        data.push(key.purpose() as u8);
216        data.push(key.security_level() as u8);
217        data.push(key.key_type() as u8);
218        let key_data = key.data().as_slice();
219        data.extend_from_slice(&(key_data.len() as u16).to_le_bytes());
220        data.extend_from_slice(key_data);
221        // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that
222        // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind
223        // them for hash-based key types (which accept an empty signature). Committing them into the
224        // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer
225        // cannot flip `read_only` or alter `contract_bounds` on an observed transition.
226        data.push(key.read_only() as u8);
227        match key.contract_bounds() {
228            None => data.push(0u8),
229            Some(ContractBounds::SingleContract { id }) => {
230                data.push(1u8);
231                data.extend_from_slice(id.as_bytes());
232            }
233            Some(ContractBounds::SingleContractDocumentType {
234                id,
235                document_type_name,
236            }) => {
237                data.push(2u8);
238                data.extend_from_slice(id.as_bytes());
239                let name = document_type_name.as_bytes();
240                data.extend_from_slice(&(name.len() as u16).to_le_bytes());
241                data.extend_from_slice(name);
242            }
243        }
244    }
245    data
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::identity::core_script::CoreScript;
252    use crate::withdrawal::Pooling;
253    // These tests pin the v0 preimage directly (they assert exact bytes), so resolve the bare helper
254    // names to the `_v0` impls rather than the version-dispatching public wrappers.
255    use crate::shielded::shielded_withdrawal_extra_sighash_data_v0 as shielded_withdrawal_extra_sighash_data;
256    use crate::shielded::unshield_extra_sighash_data_v0 as unshield_extra_sighash_data;
257
258    #[test]
259    fn withdrawal_sighash_data_binds_core_fee_per_byte() {
260        let script = CoreScript::new_p2pkh([1u8; 20]);
261        let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never);
262        let b = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 2, Pooling::Never);
263        assert_ne!(
264            a, b,
265            "changing core_fee_per_byte must change the sighash preimage"
266        );
267    }
268
269    #[test]
270    fn withdrawal_sighash_data_binds_pooling() {
271        // `pooling` is pinned to `Never` by `validate_structure`, so this binding is currently
272        // dead defense-in-depth; assert it is nonetheless mixed into the preimage so a future
273        // unpinning would still be authorized by the Orchard binding signature.
274        let script = CoreScript::new_p2pkh([1u8; 20]);
275        let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never);
276        let b = shielded_withdrawal_extra_sighash_data(
277            script.as_bytes(),
278            1000,
279            1,
280            Pooling::IfAvailable,
281        );
282        assert_ne!(a, b, "changing pooling must change the sighash preimage");
283    }
284
285    #[test]
286    fn withdrawal_sighash_data_layout() {
287        // output_script(2) || unshielding_amount(8) || core_fee_per_byte(4) || pooling(1)
288        let d = shielded_withdrawal_extra_sighash_data(&[0xAA, 0xBB], 1, 2, Pooling::Never);
289        assert_eq!(d.len(), 2 + 8 + 4 + 1);
290        assert_eq!(&d[0..2], &[0xAA, 0xBB]);
291        assert_eq!(&d[2..10], &1u64.to_le_bytes());
292        assert_eq!(&d[10..14], &2u32.to_le_bytes());
293        assert_eq!(d[14], Pooling::Never as u8);
294    }
295
296    #[test]
297    fn unshield_sighash_data_layout() {
298        // output_address || unshielding_amount(8)
299        let d = unshield_extra_sighash_data(&[0xAA, 0xBB, 0xCC], 5);
300        assert_eq!(d.len(), 3 + 8);
301        assert_eq!(&d[0..3], &[0xAA, 0xBB, 0xCC]);
302        assert_eq!(&d[3..11], &5u64.to_le_bytes());
303    }
304
305    mod identity_create_sighash {
306        use super::*;
307        // Pin the v0 preimage directly (see the note in the parent test module).
308        use crate::identity::{KeyType, Purpose, SecurityLevel};
309        use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data;
310        use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0;
311        use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation;
312        use platform_value::BinaryData;
313
314        fn mk_key(id: u32, data_byte: u8) -> IdentityPublicKeyInCreation {
315            IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 {
316                id,
317                key_type: KeyType::ECDSA_SECP256K1,
318                purpose: Purpose::AUTHENTICATION,
319                security_level: SecurityLevel::MASTER,
320                contract_bounds: None,
321                read_only: false,
322                data: BinaryData::new(vec![data_byte; 33]),
323                signature: BinaryData::new(vec![]),
324            })
325        }
326
327        #[test]
328        fn layout_is_length_prefixed() {
329            // identity_id(32) || denomination(8)
330            //   || send_to_address_on_creation_failure (tag(1) || hash(20))
331            //   || num_keys(2)
332            //   || [key_id(4)|purpose|sec|type|len(2)|data|read_only(1)|contract_bounds_tag(1)]
333            let id = [0x11u8; 32];
334            let keys = vec![mk_key(7, 0xAB)];
335            let fallback = PlatformAddress::P2pkh([0x5Cu8; 20]);
336            let d = identity_create_from_shielded_extra_sighash_data(
337                &id,
338                10_000_000_000,
339                &fallback,
340                &keys,
341            );
342            assert_eq!(&d[0..32], &id);
343            assert_eq!(&d[32..40], &10_000_000_000u64.to_le_bytes());
344            // Fallback address: tag(0=P2pkh) at offset 40, 20-byte hash at 41..61.
345            assert_eq!(d[40], 0u8, "fallback address P2pkh tag");
346            assert_eq!(&d[41..61], &[0x5Cu8; 20], "fallback address hash");
347            assert_eq!(&d[61..63], &1u16.to_le_bytes());
348            assert_eq!(&d[63..67], &7u32.to_le_bytes());
349            assert_eq!(d[67], Purpose::AUTHENTICATION as u8);
350            assert_eq!(d[68], SecurityLevel::MASTER as u8);
351            assert_eq!(d[69], KeyType::ECDSA_SECP256K1 as u8);
352            assert_eq!(&d[70..72], &33u16.to_le_bytes());
353            assert_eq!(&d[72..105], &[0xAB; 33]);
354            assert_eq!(d[105], 0u8, "read_only=false");
355            assert_eq!(d[106], 0u8, "contract_bounds=None tag");
356            assert_eq!(d.len(), 32 + 8 + 21 + 2 + (4 + 1 + 1 + 1 + 2 + 33 + 1 + 1));
357        }
358
359        #[test]
360        fn binds_identity_id_denomination_and_keys() {
361            let id_a = [0x11u8; 32];
362            let id_b = [0x22u8; 32];
363            let keys = vec![mk_key(0, 0xAA)];
364            let fallback = PlatformAddress::P2pkh([0x01u8; 20]);
365            let base = identity_create_from_shielded_extra_sighash_data(
366                &id_a,
367                10_000_000_000,
368                &fallback,
369                &keys,
370            );
371
372            // Changing the identity id changes the preimage (anti-redirection to a different id).
373            assert_ne!(
374                base,
375                identity_create_from_shielded_extra_sighash_data(
376                    &id_b,
377                    10_000_000_000,
378                    &fallback,
379                    &keys
380                ),
381                "identity id must be bound"
382            );
383            // Changing the denomination changes the preimage.
384            assert_ne!(
385                base,
386                identity_create_from_shielded_extra_sighash_data(
387                    &id_a,
388                    30_000_000_000,
389                    &fallback,
390                    &keys
391                ),
392                "denomination must be bound"
393            );
394            // Changing the fallback failure address changes the preimage (anti-redirection of the
395            // failure credit: a relayer cannot point the penalty-charged spend at a different
396            // address than the one each key's proof-of-possession signed).
397            assert_ne!(
398                base,
399                identity_create_from_shielded_extra_sighash_data(
400                    &id_a,
401                    10_000_000_000,
402                    &PlatformAddress::P2pkh([0x02u8; 20]),
403                    &keys
404                ),
405                "fallback failure address hash must be bound"
406            );
407            // Changing only the fallback address TYPE (P2pkh -> P2sh, same hash) changes the
408            // preimage too (the type tag is bound, not just the hash).
409            assert_ne!(
410                base,
411                identity_create_from_shielded_extra_sighash_data(
412                    &id_a,
413                    10_000_000_000,
414                    &PlatformAddress::P2sh([0x01u8; 20]),
415                    &keys
416                ),
417                "fallback failure address type tag must be bound"
418            );
419            // Swapping in a different key changes the preimage (anti-key-swap).
420            assert_ne!(
421                base,
422                identity_create_from_shielded_extra_sighash_data(
423                    &id_a,
424                    10_000_000_000,
425                    &fallback,
426                    &[mk_key(0, 0xBB)]
427                ),
428                "key data must be bound"
429            );
430            // Adding a key changes the preimage (the full set is bound, not just the count).
431            assert_ne!(
432                base,
433                identity_create_from_shielded_extra_sighash_data(
434                    &id_a,
435                    10_000_000_000,
436                    &fallback,
437                    &[mk_key(0, 0xAA), mk_key(1, 0xCC)]
438                ),
439                "the full key set must be bound"
440            );
441        }
442
443        #[test]
444        fn binds_read_only_and_contract_bounds() {
445            use crate::identity::identity_public_key::contract_bounds::ContractBounds;
446            use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters;
447            let id = [0x11u8; 32];
448            let fallback = PlatformAddress::P2pkh([0x01u8; 20]);
449            let base = identity_create_from_shielded_extra_sighash_data(
450                &id,
451                10_000_000_000,
452                &fallback,
453                &[mk_key(0, 0xAA)],
454            );
455
456            // Flipping read_only changes the preimage (un-malleable for every key type).
457            let mut ro_key = mk_key(0, 0xAA);
458            ro_key.set_read_only(true);
459            assert_ne!(
460                base,
461                identity_create_from_shielded_extra_sighash_data(
462                    &id,
463                    10_000_000_000,
464                    &fallback,
465                    &[ro_key]
466                ),
467                "read_only must be bound"
468            );
469
470            // Attaching contract_bounds changes the preimage.
471            let mut cb_key = mk_key(0, 0xAA);
472            cb_key.set_contract_bounds(Some(ContractBounds::SingleContract {
473                id: platform_value::Identifier::new([0x33; 32]),
474            }));
475            assert_ne!(
476                base,
477                identity_create_from_shielded_extra_sighash_data(
478                    &id,
479                    10_000_000_000,
480                    &fallback,
481                    &[cb_key]
482                ),
483                "contract_bounds must be bound"
484            );
485        }
486    }
487}