Skip to main content

dpp/shielded/
mod.rs

1#[cfg(feature = "shielded-client")]
2pub mod builder;
3
4mod compute_minimum_shielded_fee;
5pub mod memo;
6#[cfg(feature = "state-transitions")]
7mod sighash;
8
9use crate::util::hash::hash_single;
10pub use memo::{ShieldedMemo, MEMO_PAYLOAD_SIZE, MEMO_SIZE};
11
12use bincode::{Decode, DecodeUntrusted, Encode};
13#[cfg(feature = "serde-conversion")]
14use serde::{Deserialize, Serialize};
15
16// Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the
17// module and the function share a name but live in different namespaces).
18pub use compute_minimum_shielded_fee::{
19    compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee,
20    compute_shielded_identity_create_fee, compute_shielded_identity_top_up_fee,
21    compute_shielded_unshield_fee, compute_shielded_verification_fee,
22    compute_shielded_withdrawal_fee, compute_token_pool_paid_shielded_fee,
23    compute_token_purchase_from_shielded_pool_fee,
24    compute_token_shielded_transfer_with_shielded_fee_fee,
25    compute_token_unshield_with_shielded_fee_fee,
26};
27
28// Re-exported so the public paths stay `dpp::shielded::<name>` after moving the sighash preimage
29// builders into their own file. Both the version-dispatching wrappers and their `_v0` impls are
30// re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls).
31#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
32use crate::serialization::JsonConvertible;
33#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
34use crate::serialization::ValueConvertible;
35/// A digest of serialized Orchard actions in wire order: every field of every action, hashed
36/// once. A group action stores it so every signer commits to exactly the same notes, and a
37/// pool mint or burn folds it into its group action id.
38///
39/// FROZEN once a protocol version ships it. This is a hash preimage, not a serialization
40/// format, so there is nothing to decode and no version byte to carry — but a group action
41/// stores the digest and a later block re-derives it to compare, and the comparison has no way
42/// to learn which version the action was proposed at. Changing the layout here would leave every
43/// pending group action permanently unconfirmable, and versioning the function on the *current*
44/// protocol version would not help, because that is not the version the stored digest came from.
45/// A new layout needs a new function and a new transition generation, the way
46/// `calculate_action_id_with_fields` is handled.
47///
48/// The concatenation carries no length prefixes, so it is only unambiguous because every field is
49/// fixed-width in practice: `encrypted_note` is a `Vec<u8>` that bundle reconstruction refuses
50/// unless it is exactly the Orchard ciphertext length, on every path where this digest matters.
51pub fn serialized_actions_digest(actions: &[SerializedAction]) -> [u8; 32] {
52    let mut bytes = Vec::new();
53    for action in actions {
54        bytes.extend_from_slice(&action.nullifier);
55        bytes.extend_from_slice(&action.rk);
56        bytes.extend_from_slice(&action.cmx);
57        bytes.extend_from_slice(&action.encrypted_note);
58        bytes.extend_from_slice(&action.cv_net);
59        bytes.extend_from_slice(&action.spend_auth_sig);
60    }
61    hash_single(bytes)
62}
63
64#[cfg(feature = "state-transitions")]
65pub use sighash::{
66    compute_platform_sighash, credit_pool_output_only_extra_sighash_data_v0,
67    document_token_payment_extra_sighash_data, document_token_payment_extra_sighash_data_v0,
68    identity_create_from_shielded_extra_sighash_data,
69    identity_create_from_shielded_extra_sighash_data_v0,
70    identity_top_up_from_shielded_extra_sighash_data,
71    identity_top_up_from_shielded_extra_sighash_data_v0, shield_extra_sighash_data,
72    shield_from_asset_lock_extra_sighash_data, shield_from_identity_extra_sighash_data,
73    shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0,
74    token_burn_from_pool_extra_sighash_data, token_burn_from_pool_extra_sighash_data_v0,
75    token_pool_fee_bundle_extra_sighash_data, token_pool_fee_bundle_extra_sighash_data_v0,
76    token_pool_output_only_extra_sighash_data, token_pool_output_only_extra_sighash_data_v0,
77    token_purchase_from_shielded_pool_extra_sighash_data,
78    token_purchase_from_shielded_pool_extra_sighash_data_v0,
79    token_shielded_transfer_extra_sighash_data, token_shielded_transfer_extra_sighash_data_v0,
80    token_shielded_transfer_with_shielded_fee_extra_sighash_data,
81    token_shielded_transfer_with_shielded_fee_extra_sighash_data_v0,
82    token_unshield_extra_sighash_data, token_unshield_extra_sighash_data_v0,
83    token_unshield_with_shielded_fee_extra_sighash_data,
84    token_unshield_with_shielded_fee_extra_sighash_data_v0, unshield_extra_sighash_data,
85    unshield_extra_sighash_data_v0, SHIELD_BUNDLE_TAG, SHIELD_FROM_ASSET_LOCK_BUNDLE_TAG,
86    SHIELD_FROM_IDENTITY_BUNDLE_TAG, TOKEN_CLAIM_TO_POOL_BUNDLE_TAG,
87    TOKEN_DIRECT_PURCHASE_TO_POOL_BUNDLE_TAG, TOKEN_MINT_TO_POOL_BUNDLE_TAG,
88    TOKEN_PURCHASE_FROM_SHIELDED_POOL_TYPE, TOKEN_SHIELDED_TRANSFER_WITH_SHIELDED_FEE_TYPE,
89    TOKEN_SHIELD_BUNDLE_TAG, TOKEN_UNSHIELD_WITH_SHIELDED_FEE_TYPE,
90};
91
92/// Calibrated effective storage-byte cost of the Core withdrawal document a
93/// `ShieldedWithdrawal` creates.
94///
95/// A `ShieldedWithdrawal` does not only write notes/nullifiers like the other pool-paid
96/// transitions — it ALSO inserts a Core withdrawal document into the withdrawals contract
97/// (`AddWithdrawalDocument`), which writes the document plus its withdrawals-contract index
98/// entries. That insert has a real, GroveDB-metered cost of ≈110,085,900 credits, which is
99/// ~98% storage and is FLAT regardless of the bundle's action count (the document and its
100/// indexes are the same size whether the withdrawal spends one note or sixteen).
101///
102/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
103/// per-bundle ZK compute, so it does NOT cover this document insert. We therefore add the
104/// document cost to the ShieldedWithdrawal fee as a flat BYTE-BASED component, sized at
105/// `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES` effective bytes priced at the SAME per-byte
106/// storage rate the per-action note storage uses (`disk + processing` credits/byte). The
107/// measured ≈110M cost corresponds to ≈4017 effective bytes at that rate; 4100 covers it with
108/// a small (~2%) margin, and — because it is priced off the same rate — it tracks the storage
109/// rate as it evolves, exactly like the per-action note storage does. See
110/// [`compute_minimum_shielded_fee::compute_shielded_withdrawal_fee`].
111pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100;
112
113/// Calibrated effective storage-byte cost of the single `AddBalanceToAddress` write an `Unshield`
114/// performs, crediting the net (`unshielding_amount − fee`) to the output platform address.
115///
116/// Like the other pool-paid transitions, an `Unshield` writes its change notes and nullifiers — but
117/// it ALSO credits a transparent platform address with `AddBalanceToAddress`. In the new-address
118/// worst case that write touches the address subtree (the address path plus its balance/nonce
119/// entries), a real, GroveDB-metered cost of ≈6,239,100 credits (≈222 of those bytes are storage)
120/// that is FLAT regardless of the bundle's action count (the address write is the same size whether
121/// the unshield spends one note or sixteen).
122///
123/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
124/// per-bundle ZK compute, so it does NOT cover this address write. We therefore add the address
125/// cost to the Unshield fee as a flat BYTE-BASED component, sized at
126/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` effective bytes priced at the SAME per-byte storage
127/// rate the per-action note storage uses (`disk + processing` credits/byte).
128///
129/// The constant is the **storage** portion of the address write: the metered `AddBalanceToAddress`
130/// op costs ≈6,239,100 credits total, of which the *storage* part is ≈6,075,000 ≈ **222 effective
131/// bytes** at the storage rate. We size the component to that storage figure — because it is a
132/// `bytes × per_byte_rate` term it is booked as storage, so it should match the address write's
133/// storage cost, not its total. The small remaining op-processing (~164K) is already covered by the
134/// per-action processing fee. Pricing it off the same rate means it tracks the storage rate as it
135/// evolves, exactly like the per-action note storage does. See
136/// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`].
137pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222;
138
139/// Flat component (in effective bytes at the per-byte storage rate) for the identity-side write an
140/// `IdentityTopUpFromShieldedPool` performs on top of its per-action nullifier and note writes:
141/// the single `AddToIdentityBalance` operation, charged as part of the pool-paid flat fee (built
142/// like `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`).
143///
144/// What the write does: the identity must already exist, so it adds no storage. It rewrites the
145/// balance element and every Merk node on the path to the root (replaced bytes, charged at the
146/// per-byte processing rate), loads the path, seeks, and rehashes the nodes. Measured at protocol
147/// version 14: 320 replaced bytes, 886 loaded bytes, 12 seeks and 14 hash calls for 175,320
148/// credits of processing. Like every other flat shielded component, that variable tree work is
149/// folded into one flat effective-byte figure priced at the full storage rate so it tracks the
150/// rate as it evolves, rather than modelled per replaced byte: 175,320 credits is 6.4 effective
151/// bytes at 27,400 credits/byte, and 8 leaves headroom for the path growing by about a node
152/// (roughly 0.7 effective bytes) each time the identity count doubles. The pool-total update is
153/// not priced separately, exactly as for the other pool-paid transitions. See
154/// [`compute_minimum_shielded_fee::compute_shielded_identity_top_up_fee`].
155pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 8;
156
157/// Effective storage bytes for crediting the contract owner's existing identity
158/// balance when tokens are bought from a shielded pool.
159pub const SHIELDED_TOKEN_PURCHASE_OWNER_BALANCE_STORAGE_BYTES: u64 = 20;
160
161/// Flat component (in effective bytes at the per-byte storage rate) for the recipient's token
162/// balance item a `TokenUnshieldWithShieldedFee` writes on top of its per-action nullifier and
163/// note writes.
164///
165/// It prices the write as an INSERT, not a rewrite. A recipient who already holds the token has a
166/// balance sum item to replace, which adds no storage; a recipient who has never held it has no
167/// item, so the write creates one and it is real new storage. Measured at protocol version 14:
168/// 6,102,000 credits of storage, the same for the smallest balance and the widest, because the
169/// item is fixed-width — 223 effective bytes at 27,400 credits/byte, against 8 for the rewrite.
170/// 230 leaves headroom for the node layout gaining a few bytes without a fresh calibration.
171///
172/// The expensive case is priced unconditionally, and there are two independent reasons for that.
173///
174/// The first is that the component may never fall below what the write really costs.
175/// `execute_event` books a pool-paid transition as `storage = min(real_storage, carved_fee)` and
176/// pays the proposer only the remainder, so a component under the real cost comes out of the
177/// proposer's reward for the proof it verified and leaves the storage pool short of an item the
178/// chain then carries forever. Recipient state is not reachable where the number is needed in any
179/// case: the builder that fixes the fee takes no drive and no transaction, and the stateless
180/// `validate_minimum_shielded_fee` gate that re-derives it takes neither either, so no balance is
181/// reachable from where the number is decided. (That builder has no caller outside tests yet; the
182/// argument is about what it can read, not about who calls it.)
183///
184/// The second reason is decisive even where that state IS reachable, and it is why the cheap case
185/// must not be split out later as an optimisation: `credit_amount` is public and must equal this
186/// fee EXACTLY, so a fee that varied with the recipient's holdings would publish whether the
187/// recipient holds this token for the first time. That is precisely the fee fingerprint the
188/// shielded design exists to deny.
189///
190/// Pricing the worst case is the standing choice here, not an exception:
191/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` sizes an `AddBalanceToAddress` to its new-address
192/// worst case, and the estimation branch of `add_to_identity_token_balance_operations` assumes
193/// the insert for the same reason. See
194/// [`compute_minimum_shielded_fee::compute_token_unshield_with_shielded_fee_fee`].
195pub const SHIELDED_TOKEN_BALANCE_INSERT_STORAGE_BYTES: u64 = 230;
196
197/// Creating a balance item costs more than rewriting one, so the unshield's allowance has to
198/// exceed the replace-only allowance the identity top-up keeps. Checked when the crate is built
199/// rather than when a test runs, because both sides are constants and a change to either should
200/// stop the build rather than wait for a test to notice.
201const _: () = assert!(
202    SHIELDED_TOKEN_BALANCE_INSERT_STORAGE_BYTES > SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES
203);
204
205/// Common Orchard bundle parameters shared across all shielded transition types.
206///
207/// Groups the fields that every shielded transition carries identically:
208/// the serialized actions, Sinsemilla anchor, Halo 2 proof, and RedPallas
209/// binding signature. Using this struct reduces parameter counts in SDK
210/// helper functions from 10-12 down to 5-8.
211#[derive(Debug, Clone, PartialEq)]
212pub struct OrchardBundleParams {
213    /// The serialized Orchard actions (spends + outputs).
214    pub actions: Vec<SerializedAction>,
215    /// Sinsemilla root of the note commitment tree at bundle creation time (32 bytes).
216    /// This is the Orchard Anchor — the root of the depth-32 Sinsemilla Merkle
217    /// tree over extracted note commitments (cmx values), NOT the GroveDB
218    /// commitment tree state root.
219    pub anchor: [u8; 32],
220    /// Halo 2 zero-knowledge proof bytes.
221    pub proof: Vec<u8>,
222    /// RedPallas binding signature (64 bytes) over the bundle's value balance.
223    pub binding_signature: [u8; 64],
224}
225
226/// A serialized Orchard action extracted from a bundle.
227///
228/// Each Orchard action structurally contains one spend and one output. The spend
229/// consumes a previously created note (revealing its nullifier), while the output
230/// creates a new note (publishing its commitment). Although paired in the same struct,
231/// observers cannot link which prior note was spent or what value the new note holds —
232/// the zero-knowledge proof ensures privacy.
233///
234/// These fields are raw bytes suitable for network serialization. During validation,
235/// they are parsed back into typed Orchard structs and verified via `BatchValidator`
236/// (Halo 2 proof + RedPallas signatures).
237///
238/// All fields except `spend_auth_sig` are covered by the Orchard bundle commitment
239/// (BLAKE2b-256 per ZIP-244), which feeds into the platform sighash. The signatures
240/// and proof are verified separately and are not part of the commitment.
241/// `#[json_safe_fields]` auto-injects `#[serde(with = ...)]` on the byte fields:
242/// every `[u8; N]` → `serde_bytes` (const-generic), `Vec<u8>` → `serde_bytes_var`.
243/// Keeps the wire shape (Uint8Array in binary, base64 string in JSON) without
244/// per-field annotations.
245#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)]
246#[derive(Debug, Clone, Encode, Decode, PartialEq, DecodeUntrusted)]
247#[cfg_attr(
248    feature = "serde-conversion",
249    derive(Serialize, Deserialize),
250    serde(rename_all = "camelCase")
251)]
252pub struct SerializedAction {
253    /// Unique tag derived from the spent note's position and spending key.
254    /// Published on-chain to prevent double-spends: if this nullifier already
255    /// exists in the nullifier set, the transaction is rejected. The nullifier
256    /// is deterministic for a given note but unlinkable to the note's commitment,
257    /// preserving sender privacy.
258    pub nullifier: [u8; 32],
259
260    /// Randomized spend validating key (RedPallas verification key).
261    /// Derived from the spender's full viewing key with per-action randomness.
262    /// Used to verify `spend_auth_sig`, proving the spender controls the spending
263    /// key for the consumed note without revealing which key it is.
264    pub rk: [u8; 32],
265
266    /// Extracted note commitment for the newly created output note.
267    /// This is added to the commitment tree after the transition is applied,
268    /// allowing the recipient to later spend it. The commitment hides the note's
269    /// value, recipient, and randomness — only the recipient (who knows the
270    /// decryption key) can identify and spend this note.
271    pub cmx: [u8; 32],
272
273    /// Encrypted note ciphertext (216 bytes = epk 32 + enc_ciphertext 104 + out_ciphertext 80).
274    /// Contains the `TransmittedNoteCiphertext` fields packed contiguously:
275    /// - `epk`: ephemeral public key for Diffie-Hellman key agreement (32 bytes)
276    /// - `enc_ciphertext`: note plaintext encrypted to the recipient (104 bytes = 52 compact + 36 memo + 16 AEAD tag)
277    /// - `out_ciphertext`: encrypted to the sender for wallet recovery (80 bytes)
278    ///
279    /// Stored on-chain so recipients can scan and decrypt notes addressed to them.
280    /// Only the intended recipient (or sender) can decrypt; all others see random bytes.
281    pub encrypted_note: Vec<u8>,
282
283    /// Value commitment (Pedersen commitment to the note's value).
284    /// Commits to the value flowing through this action without revealing it.
285    /// The binding signature later proves that the sum of all `cv_net` commitments
286    /// across actions is consistent with the declared `value_balance`, ensuring
287    /// no credits are created or destroyed.
288    pub cv_net: [u8; 32],
289
290    /// RedPallas spend authorization signature over the platform sighash.
291    /// Proves the spender authorized this specific bundle (including all actions,
292    /// value_balance, anchor, and any bound transparent fields). Verified against
293    /// `rk` during batch validation. This prevents replay attacks — a valid
294    /// signature from one transition cannot be reused in another.
295    // `#[json_safe_fields]` gives this field `serde_bytes` when `json-conversion` is on; serde
296    // has no impl for arrays longer than 32, so a serde build without it needs the same here.
297    #[cfg_attr(
298        all(feature = "serde-conversion", not(feature = "json-conversion")),
299        serde(with = "crate::serialization::serde_bytes")
300    )]
301    pub spend_auth_sig: [u8; 64],
302}
303
304#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
305impl JsonConvertible for SerializedAction {}
306
307#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
308impl ValueConvertible for SerializedAction {}
309
310#[cfg(all(
311    test,
312    feature = "json-conversion",
313    feature = "value-conversion",
314    feature = "serde-conversion"
315))]
316mod json_convertible_tests {
317    use super::*;
318    use serde_json::json;
319
320    fn fixture() -> SerializedAction {
321        SerializedAction {
322            nullifier: [0x11; 32],
323            rk: [0x22; 32],
324            cmx: [0x33; 32],
325            // Encrypted note is variable-length (216 bytes per the field doc); a
326            // shorter payload still exercises the `serde_bytes_var` path.
327            encrypted_note: vec![0x44, 0x55, 0x66, 0x77],
328            cv_net: [0x88; 32],
329            spend_auth_sig: [0x99; 64],
330        }
331    }
332
333    // `SerializedAction` is a struct with `serde(rename_all = "camelCase")`.
334    // `#[json_safe_fields]` auto-injects `#[serde(with = ...)]` on the byte
335    // fields: `[u8; N]` → `serde_bytes` (const-generic), `Vec<u8>` →
336    // `serde_bytes_var`. The wire shape is base64 strings in JSON HR and
337    // raw bytes in non-HR.
338
339    #[test]
340    fn json_round_trip_with_full_wire_shape() {
341        use crate::serialization::JsonConvertible;
342        use base64::{engine::general_purpose::STANDARD, Engine};
343        let original = fixture();
344        let json = original.to_json().expect("to_json");
345        // Each byte field is base64-encoded in HR.
346        assert_eq!(
347            json,
348            json!({
349                "nullifier": STANDARD.encode([0x11; 32]),
350                "rk": STANDARD.encode([0x22; 32]),
351                "cmx": STANDARD.encode([0x33; 32]),
352                "encryptedNote": STANDARD.encode([0x44, 0x55, 0x66, 0x77]),
353                "cvNet": STANDARD.encode([0x88; 32]),
354                "spendAuthSig": STANDARD.encode([0x99; 64]),
355            })
356        );
357        let recovered = SerializedAction::from_json(json).expect("from_json");
358        assert_eq!(original, recovered);
359    }
360
361    #[test]
362    fn value_round_trip_with_full_wire_shape() {
363        use crate::serialization::ValueConvertible;
364        use platform_value::Value;
365        let original = fixture();
366        let value = original.to_object().expect("to_object");
367        // `[u8; 32]` → `Value::Bytes32`, `[u8; 64]` and `Vec<u8>` (via
368        // `serde_bytes_var`) → `Value::Bytes(Vec<u8>)`.
369        assert_eq!(
370            value,
371            Value::Map(vec![
372                (Value::Text("nullifier".into()), Value::Bytes32([0x11; 32])),
373                (Value::Text("rk".into()), Value::Bytes32([0x22; 32])),
374                (Value::Text("cmx".into()), Value::Bytes32([0x33; 32])),
375                (
376                    Value::Text("encryptedNote".into()),
377                    Value::Bytes(vec![0x44, 0x55, 0x66, 0x77]),
378                ),
379                (Value::Text("cvNet".into()), Value::Bytes32([0x88; 32])),
380                (
381                    Value::Text("spendAuthSig".into()),
382                    Value::Bytes(vec![0x99; 64]),
383                ),
384            ])
385        );
386        let recovered = SerializedAction::from_object(value).expect("from_object");
387        assert_eq!(original, recovered);
388    }
389}