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