Skip to main content

dpp/shielded/
mod.rs

1#[cfg(feature = "shielded-client")]
2pub mod builder;
3
4mod compute_minimum_shielded_fee;
5
6use bincode::{Decode, Encode};
7#[cfg(feature = "serde-conversion")]
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::withdrawal::Pooling;
12
13// Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the
14// module and the function share a name but live in different namespaces).
15pub use compute_minimum_shielded_fee::{
16    compute_minimum_shielded_fee, compute_shielded_unshield_fee, compute_shielded_verification_fee,
17    compute_shielded_withdrawal_fee,
18};
19
20/// Permanent storage bytes per shielded action: 312 bytes total.
21///
22/// - 280 bytes in the BulkAppendTree: 32 (`cmx`, the note commitment) + 32
23///   (`rho`) + 216 (the encrypted note ciphertext).
24/// - 32 bytes in the nullifier tree.
25///
26/// The 216-byte encrypted note is Orchard's `TransmittedNoteCiphertext`, laid
27/// out as `epk(32) || enc_ciphertext(104) || out_ciphertext(80)`:
28///
29/// - `epk` (32): the note's ephemeral public key, published in the clear. The
30///   recipient combines it with their incoming viewing key (Diffie–Hellman) to
31///   derive the AEAD key.
32/// - `enc_ciphertext` (104): the note encrypted to the recipient (opened with
33///   the incoming viewing key) — ChaCha20-Poly1305 over the note plaintext. It
34///   holds the compact note (52 = version 1 + diversifier `d` 11 + value 8 +
35///   `rseed` 32), the memo (36), and the AEAD tag (16); the 52-byte compact
36///   prefix is what wallets trial-decrypt during sync to detect their own notes.
37/// - `out_ciphertext` (80): the note encrypted to the sender for wallet
38///   recovery (opened with the outgoing viewing key): out plaintext
39///   (64 = `pk_d` 32 + `esk` 32) + AEAD tag (16).
40///
41/// This is the standard Orchard layout except the memo is 36 bytes (`DashMemo`)
42/// instead of Zcash's 512 — the dashpay `orchard` fork makes the memo size a
43/// type parameter (`MemoSize`) — which is why each note is 216 bytes
44/// (`ENCRYPTED_NOTE_SIZE`) rather than Zcash Orchard's ~692.
45pub const SHIELDED_STORAGE_BYTES_PER_ACTION: u64 = 312;
46
47/// Calibrated effective storage-byte cost of the Core withdrawal document a
48/// `ShieldedWithdrawal` creates.
49///
50/// A `ShieldedWithdrawal` does not only write notes/nullifiers like the other pool-paid
51/// transitions — it ALSO inserts a Core withdrawal document into the withdrawals contract
52/// (`AddWithdrawalDocument`), which writes the document plus its withdrawals-contract index
53/// entries. That insert has a real, GroveDB-metered cost of ≈110,085,900 credits, which is
54/// ~98% storage and is FLAT regardless of the bundle's action count (the document and its
55/// indexes are the same size whether the withdrawal spends one note or sixteen).
56///
57/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
58/// per-bundle ZK compute, so it does NOT cover this document insert. We therefore add the
59/// document cost to the ShieldedWithdrawal fee as a flat BYTE-BASED component, sized at
60/// `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES` effective bytes priced at the SAME per-byte
61/// storage rate the per-action note storage uses (`disk + processing` credits/byte). The
62/// measured ≈110M cost corresponds to ≈4017 effective bytes at that rate; 4100 covers it with
63/// a small (~2%) margin, and — because it is priced off the same rate — it tracks the storage
64/// rate as it evolves, exactly like the per-action note storage does. See
65/// [`compute_minimum_shielded_fee::compute_shielded_withdrawal_fee`].
66pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100;
67
68/// Calibrated effective storage-byte cost of the single `AddBalanceToAddress` write an `Unshield`
69/// performs, crediting the net (`unshielding_amount − fee`) to the output platform address.
70///
71/// Like the other pool-paid transitions, an `Unshield` writes its change notes and nullifiers — but
72/// it ALSO credits a transparent platform address with `AddBalanceToAddress`. In the new-address
73/// worst case that write touches the address subtree (the address path plus its balance/nonce
74/// entries), a real, GroveDB-metered cost of ≈6,239,100 credits (≈222 of those bytes are storage)
75/// that is FLAT regardless of the bundle's action count (the address write is the same size whether
76/// the unshield spends one note or sixteen).
77///
78/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
79/// per-bundle ZK compute, so it does NOT cover this address write. We therefore add the address
80/// cost to the Unshield fee as a flat BYTE-BASED component, sized at
81/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` effective bytes priced at the SAME per-byte storage
82/// rate the per-action note storage uses (`disk + processing` credits/byte).
83///
84/// The constant is the **storage** portion of the address write: the metered `AddBalanceToAddress`
85/// op costs ≈6,239,100 credits total, of which the *storage* part is ≈6,075,000 ≈ **222 effective
86/// bytes** at the storage rate. We size the component to that storage figure — because it is a
87/// `bytes × per_byte_rate` term it is booked as storage, so it should match the address write's
88/// storage cost, not its total. The small remaining op-processing (~164K) is already covered by the
89/// per-action processing fee. Pricing it off the same rate means it tracks the storage rate as it
90/// evolves, exactly like the per-action note storage does. See
91/// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`].
92pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222;
93
94/// Domain separator for Platform sighash computation.
95const SIGHASH_DOMAIN: &[u8] = b"DashPlatformSighash";
96
97/// Computes the platform sighash from an Orchard bundle commitment and optional
98/// transparent field data.
99///
100/// The sighash is computed as:
101///   `SHA-256(SIGHASH_DOMAIN || bundle_commitment || extra_data)`
102///
103/// This binds transparent state transition fields (like `output_address` in unshield
104/// or `output_script` in shielded withdrawal) to the Orchard signatures, preventing
105/// replay attacks where an attacker substitutes transparent fields while reusing a
106/// valid Orchard bundle.
107///
108/// The same computation must be used on both the signing (client) and verification
109/// (platform) sides. For transitions without transparent fields (shield and
110/// shielded_transfer), `extra_data` is empty.
111pub fn compute_platform_sighash(bundle_commitment: &[u8; 32], extra_data: &[u8]) -> [u8; 32] {
112    let mut hasher = Sha256::new();
113    hasher.update(SIGHASH_DOMAIN);
114    hasher.update(bundle_commitment);
115    hasher.update(extra_data);
116    hasher.finalize().into()
117}
118
119/// Builds the transparent `extra_data` bound into a ShieldedWithdrawal's platform
120/// sighash, with the byte layout
121/// `output_script || unshielding_amount (u64 LE) || core_fee_per_byte (u32 LE) || pooling (u8)`.
122///
123/// Every field here is written verbatim by the transformer into the queued withdrawal
124/// document that constructs the Core asset-unlock TxOut. Binding all of them into the
125/// Orchard sighash means the binding signature authorizes them: since ShieldedWithdrawal
126/// has no identity-key signature and no address-witness check, the Orchard signature is
127/// the only authorization boundary, so a relay or block proposer cannot malleate
128/// `core_fee_per_byte` (or `pooling`, were it ever unpinned from `Never`) — e.g. flip a
129/// user's `core_fee_per_byte = 1` to a much larger Fibonacci value to redirect the
130/// withdrawn amount into L1 miner fees — without invalidating the proof.
131///
132/// The signing (client/builder) and verifying (consensus) sides MUST produce identical
133/// bytes, so both call this single function.
134///
135/// The layout places the variable-length `output_script` first with no length prefix. This
136/// is unambiguous only because `validate_structure` runs before proof verification and pins
137/// `output_script` to a canonical, fixed-length P2PKH (25 bytes) or P2SH (23 bytes); the
138/// remaining fields are fixed-width, so the preimage is well-defined for every accepted
139/// transition. If that script-shape restriction is ever relaxed, add a length prefix here.
140pub fn shielded_withdrawal_extra_sighash_data(
141    output_script: &[u8],
142    unshielding_amount: u64,
143    core_fee_per_byte: u32,
144    pooling: Pooling,
145) -> Vec<u8> {
146    let mut data = Vec::with_capacity(output_script.len() + 8 + 4 + 1);
147    data.extend_from_slice(output_script);
148    data.extend_from_slice(&unshielding_amount.to_le_bytes());
149    data.extend_from_slice(&core_fee_per_byte.to_le_bytes());
150    data.push(pooling as u8);
151    data
152}
153
154/// Builds the transparent `extra_data` bound into an Unshield's platform sighash, with the
155/// byte layout `output_address || unshielding_amount (u64 LE)`.
156///
157/// As with [`shielded_withdrawal_extra_sighash_data`], the signing (client/builder) and
158/// verifying (consensus) sides MUST produce identical bytes, so both call this single
159/// function. Unshield credits a transparent platform address (not a Core asset-unlock
160/// `TxOut`), so it carries no `core_fee_per_byte`/`pooling` to bind.
161pub fn unshield_extra_sighash_data(output_address: &[u8], unshielding_amount: u64) -> Vec<u8> {
162    let mut data = Vec::with_capacity(output_address.len() + 8);
163    data.extend_from_slice(output_address);
164    data.extend_from_slice(&unshielding_amount.to_le_bytes());
165    data
166}
167
168/// Common Orchard bundle parameters shared across all shielded transition types.
169///
170/// Groups the fields that every shielded transition carries identically:
171/// the serialized actions, Sinsemilla anchor, Halo 2 proof, and RedPallas
172/// binding signature. Using this struct reduces parameter counts in SDK
173/// helper functions from 10-12 down to 5-8.
174pub struct OrchardBundleParams {
175    /// The serialized Orchard actions (spends + outputs).
176    pub actions: Vec<SerializedAction>,
177    /// Sinsemilla root of the note commitment tree at bundle creation time (32 bytes).
178    /// This is the Orchard Anchor — the root of the depth-32 Sinsemilla Merkle
179    /// tree over extracted note commitments (cmx values), NOT the GroveDB
180    /// commitment tree state root.
181    pub anchor: [u8; 32],
182    /// Halo 2 zero-knowledge proof bytes.
183    pub proof: Vec<u8>,
184    /// RedPallas binding signature (64 bytes) over the bundle's value balance.
185    pub binding_signature: [u8; 64],
186}
187
188/// A serialized Orchard action extracted from a bundle.
189///
190/// Each Orchard action structurally contains one spend and one output. The spend
191/// consumes a previously created note (revealing its nullifier), while the output
192/// creates a new note (publishing its commitment). Although paired in the same struct,
193/// observers cannot link which prior note was spent or what value the new note holds —
194/// the zero-knowledge proof ensures privacy.
195///
196/// These fields are raw bytes suitable for network serialization. During validation,
197/// they are parsed back into typed Orchard structs and verified via `BatchValidator`
198/// (Halo 2 proof + RedPallas signatures).
199///
200/// All fields except `spend_auth_sig` are covered by the Orchard bundle commitment
201/// (BLAKE2b-256 per ZIP-244), which feeds into the platform sighash. The signatures
202/// and proof are verified separately and are not part of the commitment.
203/// `#[json_safe_fields]` auto-injects `#[serde(with = ...)]` on the byte fields:
204/// every `[u8; N]` → `serde_bytes` (const-generic), `Vec<u8>` → `serde_bytes_var`.
205/// Keeps the wire shape (Uint8Array in binary, base64 string in JSON) without
206/// per-field annotations.
207#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)]
208#[derive(Debug, Clone, Encode, Decode, PartialEq)]
209#[cfg_attr(
210    feature = "serde-conversion",
211    derive(Serialize, Deserialize),
212    serde(rename_all = "camelCase")
213)]
214pub struct SerializedAction {
215    /// Unique tag derived from the spent note's position and spending key.
216    /// Published on-chain to prevent double-spends: if this nullifier already
217    /// exists in the nullifier set, the transaction is rejected. The nullifier
218    /// is deterministic for a given note but unlinkable to the note's commitment,
219    /// preserving sender privacy.
220    pub nullifier: [u8; 32],
221
222    /// Randomized spend validating key (RedPallas verification key).
223    /// Derived from the spender's full viewing key with per-action randomness.
224    /// Used to verify `spend_auth_sig`, proving the spender controls the spending
225    /// key for the consumed note without revealing which key it is.
226    pub rk: [u8; 32],
227
228    /// Extracted note commitment for the newly created output note.
229    /// This is added to the commitment tree after the transition is applied,
230    /// allowing the recipient to later spend it. The commitment hides the note's
231    /// value, recipient, and randomness — only the recipient (who knows the
232    /// decryption key) can identify and spend this note.
233    pub cmx: [u8; 32],
234
235    /// Encrypted note ciphertext (216 bytes = epk 32 + enc_ciphertext 104 + out_ciphertext 80).
236    /// Contains the `TransmittedNoteCiphertext` fields packed contiguously:
237    /// - `epk`: ephemeral public key for Diffie-Hellman key agreement (32 bytes)
238    /// - `enc_ciphertext`: note plaintext encrypted to the recipient (104 bytes = 52 compact + 36 memo + 16 AEAD tag)
239    /// - `out_ciphertext`: encrypted to the sender for wallet recovery (80 bytes)
240    ///
241    /// Stored on-chain so recipients can scan and decrypt notes addressed to them.
242    /// Only the intended recipient (or sender) can decrypt; all others see random bytes.
243    pub encrypted_note: Vec<u8>,
244
245    /// Value commitment (Pedersen commitment to the note's value).
246    /// Commits to the value flowing through this action without revealing it.
247    /// The binding signature later proves that the sum of all `cv_net` commitments
248    /// across actions is consistent with the declared `value_balance`, ensuring
249    /// no credits are created or destroyed.
250    pub cv_net: [u8; 32],
251
252    /// RedPallas spend authorization signature over the platform sighash.
253    /// Proves the spender authorized this specific bundle (including all actions,
254    /// value_balance, anchor, and any bound transparent fields). Verified against
255    /// `rk` during batch validation. This prevents replay attacks — a valid
256    /// signature from one transition cannot be reused in another.
257    pub spend_auth_sig: [u8; 64],
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use crate::identity::core_script::CoreScript;
264    use crate::withdrawal::Pooling;
265
266    #[test]
267    fn withdrawal_sighash_data_binds_core_fee_per_byte() {
268        let script = CoreScript::new_p2pkh([1u8; 20]);
269        let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never);
270        let b = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 2, Pooling::Never);
271        assert_ne!(
272            a, b,
273            "changing core_fee_per_byte must change the sighash preimage"
274        );
275    }
276
277    #[test]
278    fn withdrawal_sighash_data_binds_pooling() {
279        // `pooling` is pinned to `Never` by `validate_structure`, so this binding is currently
280        // dead defense-in-depth; assert it is nonetheless mixed into the preimage so a future
281        // unpinning would still be authorized by the Orchard binding signature.
282        let script = CoreScript::new_p2pkh([1u8; 20]);
283        let a = shielded_withdrawal_extra_sighash_data(script.as_bytes(), 1000, 1, Pooling::Never);
284        let b = shielded_withdrawal_extra_sighash_data(
285            script.as_bytes(),
286            1000,
287            1,
288            Pooling::IfAvailable,
289        );
290        assert_ne!(a, b, "changing pooling must change the sighash preimage");
291    }
292
293    #[test]
294    fn withdrawal_sighash_data_layout() {
295        // output_script(2) || unshielding_amount(8) || core_fee_per_byte(4) || pooling(1)
296        let d = shielded_withdrawal_extra_sighash_data(&[0xAA, 0xBB], 1, 2, Pooling::Never);
297        assert_eq!(d.len(), 2 + 8 + 4 + 1);
298        assert_eq!(&d[0..2], &[0xAA, 0xBB]);
299        assert_eq!(&d[2..10], &1u64.to_le_bytes());
300        assert_eq!(&d[10..14], &2u32.to_le_bytes());
301        assert_eq!(d[14], Pooling::Never as u8);
302    }
303
304    #[test]
305    fn unshield_sighash_data_layout() {
306        // output_address || unshielding_amount(8)
307        let d = unshield_extra_sighash_data(&[0xAA, 0xBB, 0xCC], 5);
308        assert_eq!(d.len(), 3 + 8);
309        assert_eq!(&d[0..3], &[0xAA, 0xBB, 0xCC]);
310        assert_eq!(&d[3..11], &5u64.to_le_bytes());
311    }
312}