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}