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;
6mod sighash;
7
8pub use memo::{ShieldedMemo, MEMO_PAYLOAD_SIZE, MEMO_SIZE};
9
10use bincode::{Decode, DecodeUntrusted, Encode};
11#[cfg(feature = "serde-conversion")]
12use serde::{Deserialize, Serialize};
13
14// Re-exported so the public path stays `dpp::shielded::compute_minimum_shielded_fee` (the
15// module and the function share a name but live in different namespaces).
16pub use compute_minimum_shielded_fee::{
17    compute_minimum_shielded_fee, compute_shielded_identity_balance_write_fee,
18    compute_shielded_identity_create_fee, compute_shielded_identity_top_up_fee,
19    compute_shielded_unshield_fee, compute_shielded_verification_fee,
20    compute_shielded_withdrawal_fee,
21};
22
23// Re-exported so the public paths stay `dpp::shielded::<name>` after moving the sighash preimage
24// builders into their own file. Both the version-dispatching wrappers and their `_v0` impls are
25// re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls).
26pub use sighash::{
27    compute_platform_sighash, identity_create_from_shielded_extra_sighash_data,
28    identity_create_from_shielded_extra_sighash_data_v0,
29    identity_top_up_from_shielded_extra_sighash_data,
30    identity_top_up_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data,
31    shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data,
32    unshield_extra_sighash_data_v0,
33};
34
35/// Calibrated effective storage-byte cost of the Core withdrawal document a
36/// `ShieldedWithdrawal` creates.
37///
38/// A `ShieldedWithdrawal` does not only write notes/nullifiers like the other pool-paid
39/// transitions — it ALSO inserts a Core withdrawal document into the withdrawals contract
40/// (`AddWithdrawalDocument`), which writes the document plus its withdrawals-contract index
41/// entries. That insert has a real, GroveDB-metered cost of ≈110,085,900 credits, which is
42/// ~98% storage and is FLAT regardless of the bundle's action count (the document and its
43/// indexes are the same size whether the withdrawal spends one note or sixteen).
44///
45/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
46/// per-bundle ZK compute, so it does NOT cover this document insert. We therefore add the
47/// document cost to the ShieldedWithdrawal fee as a flat BYTE-BASED component, sized at
48/// `SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES` effective bytes priced at the SAME per-byte
49/// storage rate the per-action note storage uses (`disk + processing` credits/byte). The
50/// measured ≈110M cost corresponds to ≈4017 effective bytes at that rate; 4100 covers it with
51/// a small (~2%) margin, and — because it is priced off the same rate — it tracks the storage
52/// rate as it evolves, exactly like the per-action note storage does. See
53/// [`compute_minimum_shielded_fee::compute_shielded_withdrawal_fee`].
54pub const SHIELDED_WITHDRAWAL_DOCUMENT_STORAGE_BYTES: u64 = 4100;
55
56/// Calibrated effective storage-byte cost of the single `AddBalanceToAddress` write an `Unshield`
57/// performs, crediting the net (`unshielding_amount − fee`) to the output platform address.
58///
59/// Like the other pool-paid transitions, an `Unshield` writes its change notes and nullifiers — but
60/// it ALSO credits a transparent platform address with `AddBalanceToAddress`. In the new-address
61/// worst case that write touches the address subtree (the address path plus its balance/nonce
62/// entries), a real, GroveDB-metered cost of ≈6,239,100 credits (≈222 of those bytes are storage)
63/// that is FLAT regardless of the bundle's action count (the address write is the same size whether
64/// the unshield spends one note or sixteen).
65///
66/// `compute_minimum_shielded_fee` prices only the per-action note/nullifier storage and the
67/// per-bundle ZK compute, so it does NOT cover this address write. We therefore add the address
68/// cost to the Unshield fee as a flat BYTE-BASED component, sized at
69/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES` effective bytes priced at the SAME per-byte storage
70/// rate the per-action note storage uses (`disk + processing` credits/byte).
71///
72/// The constant is the **storage** portion of the address write: the metered `AddBalanceToAddress`
73/// op costs ≈6,239,100 credits total, of which the *storage* part is ≈6,075,000 ≈ **222 effective
74/// bytes** at the storage rate. We size the component to that storage figure — because it is a
75/// `bytes × per_byte_rate` term it is booked as storage, so it should match the address write's
76/// storage cost, not its total. The small remaining op-processing (~164K) is already covered by the
77/// per-action processing fee. Pricing it off the same rate means it tracks the storage rate as it
78/// evolves, exactly like the per-action note storage does. See
79/// [`compute_minimum_shielded_fee::compute_shielded_unshield_fee`].
80pub const SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES: u64 = 222;
81
82/// Flat component (in effective bytes at the per-byte storage rate) for the identity-side write an
83/// `IdentityTopUpFromShieldedPool` performs on top of its per-action nullifier and note writes:
84/// the single `AddToIdentityBalance` operation, charged as part of the pool-paid flat fee (built
85/// like `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`).
86///
87/// What the write does: the identity must already exist, so it adds no storage. It rewrites the
88/// balance element and every Merk node on the path to the root (replaced bytes, charged at the
89/// per-byte processing rate), loads the path, seeks, and rehashes the nodes. Measured at protocol
90/// version 14: 320 replaced bytes, 886 loaded bytes, 12 seeks and 14 hash calls for 175,320
91/// credits of processing. Like every other flat shielded component, that variable tree work is
92/// folded into one flat effective-byte figure priced at the full storage rate so it tracks the
93/// rate as it evolves, rather than modelled per replaced byte: 175,320 credits is 6.4 effective
94/// bytes at 27,400 credits/byte, and 8 leaves headroom for the path growing by about a node
95/// (roughly 0.7 effective bytes) each time the identity count doubles. The pool-total update is
96/// not priced separately, exactly as for the other pool-paid transitions. See
97/// [`compute_minimum_shielded_fee::compute_shielded_identity_top_up_fee`].
98pub const SHIELDED_IDENTITY_TOP_UP_BALANCE_STORAGE_BYTES: u64 = 8;
99
100/// Flat component (in effective bytes at the per-byte storage rate) for the identity-side writes a
101/// `ShieldFromIdentity` performs on top of its per-action note inserts: the `UpdateIdentityNonce`
102/// and `RemoveFromIdentityBalance` operations.
103///
104/// The transition's real fee is metered and only known at execution, so its stateless admission
105/// floor needs a conservative stand-in for the metered part: [`compute_minimum_shielded_fee`]
106/// (compute plus the per-action note allowance, which covers the note inserts with headroom)
107/// plus this component. Admission below `amount + floor` is refused BEFORE the Orchard proof is
108/// verified, so a short identity never occupies a proof-verification slot.
109///
110/// What the two writes do: the identity already exists, so neither adds storage. Each rewrites
111/// its element and every Merk node on the path to the root (replaced bytes, charged at the
112/// per-byte processing rate), loads the path, seeks, and rehashes the nodes. Measured at protocol
113/// version 14: the nonce update replaces 563 bytes and the balance debit 320 bytes (883 in
114/// total) for 466,760 credits of processing applied one at a time, 424,400 when batched together
115/// (shared path work). Like every other flat shielded component (`shielded_storage_bytes_per_action`,
116/// `SHIELDED_UNSHIELD_ADDRESS_STORAGE_BYTES`), that variable tree work is folded into ONE flat
117/// effective-byte figure priced at the full storage rate so it tracks the rate as it evolves,
118/// rather than modelled per replaced byte: 466,760 credits is 17.0 effective bytes at 27,400
119/// credits/byte, and 20 leaves headroom for the path growing by about a node (roughly 0.7
120/// effective bytes) each time the identity count doubles. The pool-total update is not priced
121/// separately, exactly as for the other pool-paid transitions. See
122/// [`compute_minimum_shielded_fee::compute_shielded_identity_balance_write_fee`].
123pub const SHIELDED_IDENTITY_BALANCE_WRITE_STORAGE_BYTES: u64 = 20;
124
125/// Common Orchard bundle parameters shared across all shielded transition types.
126///
127/// Groups the fields that every shielded transition carries identically:
128/// the serialized actions, Sinsemilla anchor, Halo 2 proof, and RedPallas
129/// binding signature. Using this struct reduces parameter counts in SDK
130/// helper functions from 10-12 down to 5-8.
131pub struct OrchardBundleParams {
132    /// The serialized Orchard actions (spends + outputs).
133    pub actions: Vec<SerializedAction>,
134    /// Sinsemilla root of the note commitment tree at bundle creation time (32 bytes).
135    /// This is the Orchard Anchor — the root of the depth-32 Sinsemilla Merkle
136    /// tree over extracted note commitments (cmx values), NOT the GroveDB
137    /// commitment tree state root.
138    pub anchor: [u8; 32],
139    /// Halo 2 zero-knowledge proof bytes.
140    pub proof: Vec<u8>,
141    /// RedPallas binding signature (64 bytes) over the bundle's value balance.
142    pub binding_signature: [u8; 64],
143}
144
145/// A serialized Orchard action extracted from a bundle.
146///
147/// Each Orchard action structurally contains one spend and one output. The spend
148/// consumes a previously created note (revealing its nullifier), while the output
149/// creates a new note (publishing its commitment). Although paired in the same struct,
150/// observers cannot link which prior note was spent or what value the new note holds —
151/// the zero-knowledge proof ensures privacy.
152///
153/// These fields are raw bytes suitable for network serialization. During validation,
154/// they are parsed back into typed Orchard structs and verified via `BatchValidator`
155/// (Halo 2 proof + RedPallas signatures).
156///
157/// All fields except `spend_auth_sig` are covered by the Orchard bundle commitment
158/// (BLAKE2b-256 per ZIP-244), which feeds into the platform sighash. The signatures
159/// and proof are verified separately and are not part of the commitment.
160/// `#[json_safe_fields]` auto-injects `#[serde(with = ...)]` on the byte fields:
161/// every `[u8; N]` → `serde_bytes` (const-generic), `Vec<u8>` → `serde_bytes_var`.
162/// Keeps the wire shape (Uint8Array in binary, base64 string in JSON) without
163/// per-field annotations.
164#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)]
165#[derive(Debug, Clone, Encode, Decode, PartialEq, DecodeUntrusted)]
166#[cfg_attr(
167    feature = "serde-conversion",
168    derive(Serialize, Deserialize),
169    serde(rename_all = "camelCase")
170)]
171pub struct SerializedAction {
172    /// Unique tag derived from the spent note's position and spending key.
173    /// Published on-chain to prevent double-spends: if this nullifier already
174    /// exists in the nullifier set, the transaction is rejected. The nullifier
175    /// is deterministic for a given note but unlinkable to the note's commitment,
176    /// preserving sender privacy.
177    pub nullifier: [u8; 32],
178
179    /// Randomized spend validating key (RedPallas verification key).
180    /// Derived from the spender's full viewing key with per-action randomness.
181    /// Used to verify `spend_auth_sig`, proving the spender controls the spending
182    /// key for the consumed note without revealing which key it is.
183    pub rk: [u8; 32],
184
185    /// Extracted note commitment for the newly created output note.
186    /// This is added to the commitment tree after the transition is applied,
187    /// allowing the recipient to later spend it. The commitment hides the note's
188    /// value, recipient, and randomness — only the recipient (who knows the
189    /// decryption key) can identify and spend this note.
190    pub cmx: [u8; 32],
191
192    /// Encrypted note ciphertext (216 bytes = epk 32 + enc_ciphertext 104 + out_ciphertext 80).
193    /// Contains the `TransmittedNoteCiphertext` fields packed contiguously:
194    /// - `epk`: ephemeral public key for Diffie-Hellman key agreement (32 bytes)
195    /// - `enc_ciphertext`: note plaintext encrypted to the recipient (104 bytes = 52 compact + 36 memo + 16 AEAD tag)
196    /// - `out_ciphertext`: encrypted to the sender for wallet recovery (80 bytes)
197    ///
198    /// Stored on-chain so recipients can scan and decrypt notes addressed to them.
199    /// Only the intended recipient (or sender) can decrypt; all others see random bytes.
200    pub encrypted_note: Vec<u8>,
201
202    /// Value commitment (Pedersen commitment to the note's value).
203    /// Commits to the value flowing through this action without revealing it.
204    /// The binding signature later proves that the sum of all `cv_net` commitments
205    /// across actions is consistent with the declared `value_balance`, ensuring
206    /// no credits are created or destroyed.
207    pub cv_net: [u8; 32],
208
209    /// RedPallas spend authorization signature over the platform sighash.
210    /// Proves the spender authorized this specific bundle (including all actions,
211    /// value_balance, anchor, and any bound transparent fields). Verified against
212    /// `rk` during batch validation. This prevents replay attacks — a valid
213    /// signature from one transition cannot be reused in another.
214    pub spend_auth_sig: [u8; 64],
215}
216
217#[cfg(all(feature = "json-conversion", feature = "serde-conversion"))]
218impl crate::serialization::JsonConvertible for SerializedAction {}
219
220#[cfg(all(feature = "value-conversion", feature = "serde-conversion"))]
221impl crate::serialization::ValueConvertible for SerializedAction {}
222
223#[cfg(all(
224    test,
225    feature = "json-conversion",
226    feature = "value-conversion",
227    feature = "serde-conversion"
228))]
229mod json_convertible_tests {
230    use super::*;
231    use serde_json::json;
232
233    fn fixture() -> SerializedAction {
234        SerializedAction {
235            nullifier: [0x11; 32],
236            rk: [0x22; 32],
237            cmx: [0x33; 32],
238            // Encrypted note is variable-length (216 bytes per the field doc); a
239            // shorter payload still exercises the `serde_bytes_var` path.
240            encrypted_note: vec![0x44, 0x55, 0x66, 0x77],
241            cv_net: [0x88; 32],
242            spend_auth_sig: [0x99; 64],
243        }
244    }
245
246    // `SerializedAction` is a struct with `serde(rename_all = "camelCase")`.
247    // `#[json_safe_fields]` auto-injects `#[serde(with = ...)]` on the byte
248    // fields: `[u8; N]` → `serde_bytes` (const-generic), `Vec<u8>` →
249    // `serde_bytes_var`. The wire shape is base64 strings in JSON HR and
250    // raw bytes in non-HR.
251
252    #[test]
253    fn json_round_trip_with_full_wire_shape() {
254        use crate::serialization::JsonConvertible;
255        use base64::{engine::general_purpose::STANDARD, Engine};
256        let original = fixture();
257        let json = original.to_json().expect("to_json");
258        // Each byte field is base64-encoded in HR.
259        assert_eq!(
260            json,
261            json!({
262                "nullifier": STANDARD.encode([0x11; 32]),
263                "rk": STANDARD.encode([0x22; 32]),
264                "cmx": STANDARD.encode([0x33; 32]),
265                "encryptedNote": STANDARD.encode([0x44, 0x55, 0x66, 0x77]),
266                "cvNet": STANDARD.encode([0x88; 32]),
267                "spendAuthSig": STANDARD.encode([0x99; 64]),
268            })
269        );
270        let recovered = SerializedAction::from_json(json).expect("from_json");
271        assert_eq!(original, recovered);
272    }
273
274    #[test]
275    fn value_round_trip_with_full_wire_shape() {
276        use crate::serialization::ValueConvertible;
277        use platform_value::Value;
278        let original = fixture();
279        let value = original.to_object().expect("to_object");
280        // `[u8; 32]` → `Value::Bytes32`, `[u8; 64]` and `Vec<u8>` (via
281        // `serde_bytes_var`) → `Value::Bytes(Vec<u8>)`.
282        assert_eq!(
283            value,
284            Value::Map(vec![
285                (Value::Text("nullifier".into()), Value::Bytes32([0x11; 32])),
286                (Value::Text("rk".into()), Value::Bytes32([0x22; 32])),
287                (Value::Text("cmx".into()), Value::Bytes32([0x33; 32])),
288                (
289                    Value::Text("encryptedNote".into()),
290                    Value::Bytes(vec![0x44, 0x55, 0x66, 0x77]),
291                ),
292                (Value::Text("cvNet".into()), Value::Bytes32([0x88; 32])),
293                (
294                    Value::Text("spendAuthSig".into()),
295                    Value::Bytes(vec![0x99; 64]),
296                ),
297            ])
298        );
299        let recovered = SerializedAction::from_object(value).expect("from_object");
300        assert_eq!(original, recovered);
301    }
302}