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