cdk_spilman/params/blinding.rs
1use super::*;
2
3impl ChannelParameters {
4 /// Derive a blinding scalar for P2BK
5 ///
6 /// The `context` parameter specifies which blinded key to derive:
7 /// - "sender_stage1" / "receiver_stage1" - for funding token 2-of-2
8 /// - "sender_stage1_refund" - for funding token expiry refund
9 ///
10 /// Computes: SHA256("Cashu_Spilman_P2BK_v1" || channel_secret || "{channel_id}|{context}|{retry_counter}")
11 /// Retries with incrementing retry_counter until a valid scalar in [1, n-1] is found.
12 ///
13 /// Note: This produces a SHARED blinding scalar for all proofs with the same context.
14 /// For per-proof blinding (stage2), use `stage2_tweak_info_for_role()` instead.
15 fn derive_blinding_scalar(&self, context: &str) -> anyhow::Result<Scalar> {
16 let channel_id = self.get_channel_id();
17
18 for retry_counter in 0u8..=255 {
19 let text = format!("{}|{}|{}", channel_id, context, retry_counter);
20 let mut input = Vec::new();
21 input.extend_from_slice(b"Cashu_Spilman_P2BK_v1");
22 input.extend_from_slice(&self.channel_secret);
23 input.extend_from_slice(text.as_bytes());
24
25 let hash = sha256::Hash::hash(&input);
26 let bytes: [u8; 32] = hash.to_byte_array();
27
28 // Try to create a valid scalar (must be in range [1, n-1])
29 if let Ok(scalar) = Scalar::from_be_bytes(bytes) {
30 // Scalar::from_be_bytes rejects values >= n, and we also reject zero
31 if scalar != Scalar::ZERO {
32 return Ok(scalar);
33 }
34 }
35 }
36
37 anyhow::bail!("Failed to derive valid blinding scalar after 256 attempts")
38 }
39
40 /// Derive stage 2 P2BK tweak info for a specific output
41 ///
42 /// Uses the per-output ephemeral secret to compute a NUT-28 shared-secret tweak
43 /// alongside the deterministic ephemeral key material for later metadata use.
44 pub(crate) fn stage2_tweak_info_for_role(
45 &self,
46 role: Stage2Role,
47 amount: u64,
48 index: usize,
49 ) -> anyhow::Result<Stage2P2bkTweakInfo> {
50 let role_pubkey = role.pubkey(self);
51 let ephemeral_secret = self.derive_stage2_p2bk_ephemeral_secret_for_output(
52 role.stage2_context(),
53 amount,
54 index,
55 )?;
56 let ephemeral_pubkey = ephemeral_secret.public_key();
57 let ephemeral_shared_secret_x =
58 Self::derive_nut28_shared_secret_x(role_pubkey, &ephemeral_secret)?;
59 let stage2_tweak_scalar =
60 Self::derive__nut28_P2KB_shared_secret_scalar(&ephemeral_shared_secret_x, 0x00)?;
61
62 Ok(Stage2P2bkTweakInfo {
63 ephemeral_secret,
64 ephemeral_pubkey,
65 ephemeral_shared_secret_x,
66 stage2_tweak_scalar,
67 })
68 }
69
70 /// Derive a per-output ephemeral secret for stage 2 contexts
71 ///
72 /// Computes: SHA256("Cashu_Spilman_P2BK_ephemeral_v1" || channel_secret || "{channel_id}|{context}|{amount}|{index}|{retry_counter}")
73 /// Retries with incrementing retry_counter until a valid secret key is found.
74 fn derive_stage2_p2bk_ephemeral_secret_for_output(
75 &self,
76 context: &str,
77 amount: u64,
78 index: usize,
79 ) -> anyhow::Result<SecretKey> {
80 let channel_id = self.get_channel_id();
81
82 for retry_counter in 0u8..=255 {
83 let text = format!(
84 "{}|{}|{}|{}|{}",
85 channel_id, context, amount, index, retry_counter
86 );
87 let mut input = Vec::new();
88 input.extend_from_slice(b"Cashu_Spilman_P2BK_ephemeral_v1");
89 input.extend_from_slice(&self.channel_secret);
90 input.extend_from_slice(text.as_bytes());
91
92 let hash = sha256::Hash::hash(&input);
93 let bytes: [u8; 32] = hash.to_byte_array();
94
95 if let Ok(secret) = SecretKey::from_slice(&bytes) {
96 return Ok(secret);
97 }
98 }
99
100 anyhow::bail!("Failed to derive valid ephemeral secret for output after 256 attempts")
101 }
102
103 /// Derive the raw x-coordinate used by NUT-28 before the KDF step.
104 pub(super) fn derive_nut28_shared_secret_x(
105 pubkey: &cashu::nuts::PublicKey,
106 secret: &SecretKey,
107 ) -> anyhow::Result<[u8; 32]> {
108 let shared_point = pubkey.mul_tweak(&SECP256K1, &secret.as_scalar())?;
109 Ok(shared_point.x_only_public_key().0.serialize())
110 }
111
112 /// Derive NUT-28 P2BK scalar from ephemeral shared secret x-coordinate.
113 ///
114 /// Spec: https://raw.githubusercontent.com/cashubtc/nuts/refs/heads/main/28.md
115 #[allow(non_snake_case)]
116 fn derive__nut28_P2KB_shared_secret_scalar(
117 zx: &[u8; 32],
118 i_byte: u8,
119 ) -> anyhow::Result<Scalar> {
120 let mut input = Vec::new();
121 input.extend_from_slice(b"Cashu_P2BK_v1");
122 input.extend_from_slice(zx);
123 input.push(i_byte);
124
125 let hash = sha256::Hash::hash(&input);
126 let bytes: [u8; 32] = hash.to_byte_array();
127 if let Ok(scalar) = Scalar::from_be_bytes(bytes) {
128 if scalar != Scalar::ZERO {
129 return Ok(scalar);
130 }
131 }
132
133 input.push(0xff);
134 let hash = sha256::Hash::hash(&input);
135 let bytes: [u8; 32] = hash.to_byte_array();
136 if let Ok(scalar) = Scalar::from_be_bytes(bytes) {
137 if scalar != Scalar::ZERO {
138 return Ok(scalar);
139 }
140 }
141
142 anyhow::bail!("Failed to derive valid P2BK scalar")
143 }
144
145 /// Get the blinded sender (Alice) pubkey for stage 1 P2BK
146 ///
147 /// Computes the blinded pubkey that corresponds to Alice's blinded secret key.
148 /// This handles BIP-340 parity: if Alice's pubkey has odd Y, we negate it first.
149 ///
150 /// The formula matches `derive_blinded_secret_key`:
151 /// - If even Y: P' = P + r*G (matches k = p + r)
152 /// - If odd Y: P' = -P + r*G (matches k = -p + r)
153 pub fn get_sender_blinded_pubkey_for_stage1(&self) -> anyhow::Result<cashu::nuts::PublicKey> {
154 let r = self.derive_blinding_scalar("sender_stage1")?;
155 derive_blinded_pubkey(&self.sender_pubkey, &r)
156 }
157
158 /// Get the blinded receiver (Charlie) pubkey for stage 1 P2BK
159 ///
160 /// Computes the blinded pubkey that corresponds to Charlie's blinded secret key.
161 /// This handles BIP-340 parity: if Charlie's pubkey has odd Y, we negate it first.
162 ///
163 /// The formula matches `derive_blinded_secret_key`:
164 /// - If even Y: P' = P + r*G (matches k = p + r)
165 /// - If odd Y: P' = -P + r*G (matches k = -p + r)
166 pub fn get_receiver_blinded_pubkey_for_stage1(&self) -> anyhow::Result<cashu::nuts::PublicKey> {
167 let r = self.derive_blinding_scalar("receiver_stage1")?;
168 derive_blinded_pubkey(&self.receiver_pubkey, &r)
169 }
170
171 /// Derive the blinded sender secret key for stage 1 signing
172 ///
173 /// For P2BK, Alice must sign with a blinded private key k such that k*G = P'.
174 /// This handles BIP-340 parity: if Alice's pubkey has odd Y, we negate her
175 /// private key before adding the blinding scalar.
176 pub fn get_sender_blinded_secret_key_for_stage1(
177 &self,
178 alice_secret: &SecretKey,
179 ) -> anyhow::Result<SecretKey> {
180 let r = self.derive_blinding_scalar("sender_stage1")?;
181 derive_blinded_secret_key(alice_secret, &r)
182 }
183
184 /// Get the sender's P2BK blinding scalar for stage 1 signing.
185 ///
186 /// This is the tweak scalar that must be added to Alice's secret key
187 /// (with BIP-340 parity handling) to produce the blinded signing key.
188 /// Used by the external signer flow in SpilmanClientBridge.
189 pub fn derive_sender_blinding_scalar_for_stage1(&self) -> anyhow::Result<Scalar> {
190 self.derive_blinding_scalar("sender_stage1")
191 }
192
193 /// Get the receiver's P2BK blinding scalar for stage 1 signing.
194 ///
195 /// This is the tweak scalar that must be added to Charlie's secret key
196 /// (with BIP-340 parity handling) to produce the blinded signing key.
197 /// Used by the external signer flow in SpilmanBridge.
198 pub fn derive_receiver_blinding_scalar_for_stage1(&self) -> anyhow::Result<Scalar> {
199 self.derive_blinding_scalar("receiver_stage1")
200 }
201
202 /// Get the blinded sender (Alice) pubkey for stage 1 expiry refund
203 ///
204 /// Uses a DIFFERENT blinding tweak than the 2-of-2 spending path, so the mint
205 /// cannot correlate Alice's refund to the normal channel close.
206 pub fn get_sender_blinded_pubkey_for_stage1_refund(
207 &self,
208 ) -> anyhow::Result<cashu::nuts::PublicKey> {
209 let r = self.derive_blinding_scalar("sender_stage1_refund")?;
210 derive_blinded_pubkey(&self.sender_pubkey, &r)
211 }
212
213 /// Derive the blinded sender secret key for stage 1 expiry refund
214 ///
215 /// Uses a DIFFERENT blinding tweak than the 2-of-2 spending path.
216 /// Alice uses this to sign when reclaiming funds after expiry.
217 pub fn get_sender_blinded_secret_key_for_stage1_refund(
218 &self,
219 alice_secret: &SecretKey,
220 ) -> anyhow::Result<SecretKey> {
221 let r = self.derive_blinding_scalar("sender_stage1_refund")?;
222 derive_blinded_secret_key(alice_secret, &r)
223 }
224
225 /// Derive the blinded receiver secret key for stage 1 signing
226 ///
227 /// For P2BK, Charlie must sign with a blinded private key k such that k*G = P'.
228 /// This handles BIP-340 parity: if Charlie's pubkey has odd Y, we negate his
229 /// private key before adding the blinding scalar.
230 pub fn get_receiver_blinded_secret_key_for_stage1(
231 &self,
232 charlie_secret: &SecretKey,
233 ) -> anyhow::Result<SecretKey> {
234 let r = self.derive_blinding_scalar("receiver_stage1")?;
235 derive_blinded_secret_key(charlie_secret, &r)
236 }
237
238 /// Get the blinded sender (Alice) pubkey for a specific stage 2 output
239 ///
240 /// Used for stage 1 outputs - each of Alice's proofs is locked to a UNIQUE
241 /// blinded pubkey derived from (amount, index). She'll need to sign with
242 /// the corresponding secret key in stage 2.
243 ///
244 /// This provides better privacy than a shared pubkey - the mint cannot
245 /// trivially link proofs from the same channel closure.
246 pub fn get_sender_blinded_pubkey_for_stage2_output(
247 &self,
248 amount: u64,
249 index: usize,
250 ) -> anyhow::Result<cashu::nuts::PublicKey> {
251 let tweak_info = self.stage2_tweak_info_for_role(Stage2Role::Sender, amount, index)?;
252 derive_blinded_pubkey(&self.sender_pubkey, &tweak_info.stage2_tweak_scalar)
253 }
254
255 /// Get the blinded receiver (Charlie) pubkey for a specific stage 2 output
256 ///
257 /// Used for stage 1 outputs - each of Charlie's proofs is locked to a UNIQUE
258 /// blinded pubkey derived from (amount, index). He'll need to sign with
259 /// the corresponding secret key in stage 2.
260 ///
261 /// This provides better privacy than a shared pubkey - the mint cannot
262 /// trivially link proofs from the same channel closure.
263 pub fn get_receiver_blinded_pubkey_for_stage2_output(
264 &self,
265 amount: u64,
266 index: usize,
267 ) -> anyhow::Result<cashu::nuts::PublicKey> {
268 let tweak_info = self.stage2_tweak_info_for_role(Stage2Role::Receiver, amount, index)?;
269 derive_blinded_pubkey(&self.receiver_pubkey, &tweak_info.stage2_tweak_scalar)
270 }
271
272 /// Derive the blinded sender secret key for a specific stage 2 output
273 ///
274 /// Alice uses this to sign when spending a specific stage 1 proof in stage 2.
275 /// Each proof has a unique blinded secret key derived from (amount, index).
276 pub fn get_sender_blinded_secret_key_for_stage2_output(
277 &self,
278 alice_secret: &SecretKey,
279 amount: u64,
280 index: usize,
281 ) -> anyhow::Result<SecretKey> {
282 let tweak_info = self.stage2_tweak_info_for_role(Stage2Role::Sender, amount, index)?;
283 derive_blinded_secret_key(alice_secret, &tweak_info.stage2_tweak_scalar)
284 }
285
286 /// Derive the blinded receiver secret key for a specific stage 2 output
287 ///
288 /// Charlie uses this to sign when spending a specific stage 1 proof in stage 2.
289 /// Each proof has a unique blinded secret key derived from (amount, index).
290 pub fn get_receiver_blinded_secret_key_for_stage2_output(
291 &self,
292 charlie_secret: &SecretKey,
293 amount: u64,
294 index: usize,
295 ) -> anyhow::Result<SecretKey> {
296 let tweak_info = self.stage2_tweak_info_for_role(Stage2Role::Receiver, amount, index)?;
297 derive_blinded_secret_key(charlie_secret, &tweak_info.stage2_tweak_scalar)
298 }
299
300 /// Get a string representation of the unit
301 pub fn unit_name(&self) -> &str {
302 match self.unit {
303 CurrencyUnit::Sat => "sat",
304 CurrencyUnit::Msat => "msat",
305 CurrencyUnit::Usd => "usd",
306 CurrencyUnit::Eur => "eur",
307 _ => "units",
308 }
309 }
310
311 /// Get the STAGE2 blinded pubkey for a stage 1 output context ("sender" or "receiver")
312 ///
313 /// Returns the stage2 blinded pubkey for use in stage 1 commitment outputs:
314 /// - "receiver" → Charlie's per-proof blinded pubkey (stage2 context)
315 /// - "sender" → Alice's per-proof blinded pubkey (stage2 context)
316 /// - "funding" → error (funding uses 2-of-2 with stage1 blinded pubkeys)
317 ///
318 /// Uses "stage2" blinding context because these are the keys needed to sign in stage 2.
319 /// Each proof gets a UNIQUE blinded pubkey derived from (amount, index) for better privacy.
320 pub fn get_stage2_blinded_pubkey_for_stage1_output(
321 &self,
322 context: &str,
323 amount: u64,
324 index: usize,
325 ) -> Result<cashu::nuts::PublicKey, anyhow::Error> {
326 match context {
327 "receiver" => self.get_receiver_blinded_pubkey_for_stage2_output(amount, index),
328 "sender" => self.get_sender_blinded_pubkey_for_stage2_output(amount, index),
329 "funding" => anyhow::bail!(
330 "Funding context requires 2-of-2 blinded pubkeys, use new_funding() instead"
331 ),
332 _ => anyhow::bail!("Unknown context: {}", context),
333 }
334 }
335
336 pub(crate) fn stage2_p2pk_e_for_role(
337 &self,
338 role: Stage2Role,
339 amount: u64,
340 index: usize,
341 ) -> Result<cashu::nuts::PublicKey, anyhow::Error> {
342 let tweak_info = self.stage2_tweak_info_for_role(role, amount, index)?;
343
344 Ok(tweak_info.ephemeral_pubkey)
345 }
346
347 pub(crate) fn attach_stage2_p2pk_e(
348 &self,
349 proof: &mut cashu::nuts::Proof,
350 role: Stage2Role,
351 amount: u64,
352 index: usize,
353 ) -> Result<(), anyhow::Error> {
354 proof.p2pk_e = Some(self.stage2_p2pk_e_for_role(role, amount, index)?);
355 Ok(())
356 }
357
358 /// Create a deterministic output with blinding using the channel ID and channel secret
359 /// Uses channel_secret, channel_id, context, amount, and index in the derivation per NUT-XX spec
360 ///
361 /// The context parameter specifies the role: "sender", "receiver", or "funding"
362 /// - "sender"/"receiver" create simple P2PK outputs for commitments using stage2 blinded pubkeys
363 /// - "funding" creates P2PK outputs with 2-of-2 multisig + expiry conditions
364 pub fn create_deterministic_output_with_blinding(
365 &self,
366 context: &str,
367 amount: u64,
368 index: usize,
369 ) -> Result<DeterministicSecretWithBlinding, anyhow::Error> {
370 let channel_id = self.get_channel_id();
371
372 // Derive deterministic nonce: SHA256(channel_secret || "{channel_id}|{context}|{amount}|nonce|{index}")
373 let nonce_text = format!("{}|{}|{}|nonce|{}", channel_id, context, amount, index);
374 let mut nonce_input = Vec::new();
375 nonce_input.extend_from_slice(&self.channel_secret);
376 nonce_input.extend_from_slice(nonce_text.as_bytes());
377
378 let hash = sha256::Hash::hash(&nonce_input);
379 let nonce = hex::encode(hash.to_byte_array());
380
381 // Derive deterministic blinding factor: SHA256(channel_secret || "{channel_id}|{context}|{amount}|blinding|{index}")
382 let blinding_text = format!("{}|{}|{}|blinding|{}", channel_id, context, amount, index);
383 let mut blinding_input = Vec::new();
384 blinding_input.extend_from_slice(&self.channel_secret);
385 blinding_input.extend_from_slice(blinding_text.as_bytes());
386
387 let hash = sha256::Hash::hash(&blinding_input);
388 let blinding_factor = SecretKey::from_slice(hash.as_byte_array())?;
389
390 // Handle funding context separately (requires 2-of-2 blinded pubkeys + expiry)
391 if context == "funding" {
392 DeterministicSecretWithBlinding::new_funding(
393 self,
394 nonce,
395 blinding_factor,
396 amount,
397 index,
398 )
399 } else {
400 // For sender/receiver contexts, create simple P2PK outputs with BLINDED pubkeys
401 // Each proof gets a UNIQUE blinded pubkey derived from (amount, index)
402 let pubkey =
403 self.get_stage2_blinded_pubkey_for_stage1_output(context, amount, index)?;
404 DeterministicSecretWithBlinding::new_p2pk(
405 &pubkey,
406 nonce,
407 blinding_factor,
408 amount,
409 index,
410 )
411 }
412 }
413
414 /// Get the minimum funding token amount for a given capacity using double inverse
415 ///
416 /// This computes the minimum funding_token_amount needed to achieve at least
417 /// the specified capacity after both fee stages, using the given keyset.
418 ///
419 /// Applies the inverse fee calculation twice to the capacity:
420 /// 1. capacity → post-stage-1 nominal (accounting for stage 2 fees)
421 /// 2. post-stage-1 nominal → funding token nominal (accounting for stage 1 fees)
422 pub fn get_minimum_funding_token_amount(
423 capacity: u64,
424 keyset_info: &KeysetInfo,
425 maximum_amount_for_one_output: u64,
426 ) -> anyhow::Result<u64> {
427 let max_amt = maximum_amount_for_one_output;
428
429 // First inverse: capacity → post-stage-1 nominal (accounting for stage 2 fees)
430 let first_inverse =
431 keyset_info.inverse_deterministic_value_after_fees(capacity, max_amt)?;
432 let post_stage1_nominal = first_inverse.nominal_value;
433
434 // Second inverse: post-stage-1 nominal → funding token nominal (accounting for stage 1 fees)
435 let second_inverse =
436 keyset_info.inverse_deterministic_value_after_fees(post_stage1_nominal, max_amt)?;
437 let funding_token_nominal = second_inverse.nominal_value;
438
439 Ok(funding_token_nominal)
440 }
441
442 /// Get the total funding token amount
443 ///
444 /// Returns the explicit funding_token_amount field.
445 pub fn get_total_funding_token_amount(&self) -> anyhow::Result<u64> {
446 Ok(self.funding_token_amount)
447 }
448
449 /// Get the value available after stage 1 fees with a specific keyset
450 pub fn get_value_after_stage1_with_keyset(
451 &self,
452 keyset_info: &KeysetInfo,
453 ) -> anyhow::Result<u64> {
454 // Apply forward to get actual value after stage 1 fees (spending the funding token)
455 // using the provided keyset for the outputs
456 let value_after_stage1 = keyset_info.deterministic_value_after_fees(
457 self.funding_token_amount,
458 self.maximum_amount_for_one_output,
459 )?;
460
461 Ok(value_after_stage1)
462 }
463
464 /// Get the value available after stage 1 fees
465 ///
466 /// Takes the funding token amount and applies the forward fee calculation
467 /// to determine the actual amount available after the swap transaction (stage 1).
468 ///
469 /// This represents the total amount that will be distributed between Alice and Charlie
470 /// in the commitment transaction outputs.
471 ///
472 /// Returns the actual value after stage 1 fees
473 pub fn get_value_after_stage1(&self) -> anyhow::Result<u64> {
474 self.get_value_after_stage1_with_keyset(&self.keyset_info)
475 }
476
477 /// Compute the actual de facto balance from an intended balance
478 ///
479 /// Due to output denomination constraints and fee rounding, the actual balance
480 /// that can be created may differ slightly from the intended balance.
481 ///
482 /// This method:
483 /// 1. Applies inverse to find the nominal value needed for the intended balance
484 /// 2. Applies deterministic_value to that nominal to get the actual de facto balance
485 ///
486 /// Returns the actual balance that will be created
487 pub fn get_de_facto_balance(&self, intended_balance: u64) -> anyhow::Result<u64> {
488 let max_amt = self.maximum_amount_for_one_output;
489
490 // Apply inverse to get nominal value needed
491 let inverse_result = self
492 .keyset_info
493 .inverse_deterministic_value_after_fees(intended_balance, max_amt)?;
494 let nominal_value = inverse_result.nominal_value;
495
496 // Apply deterministic_value to get actual balance
497 let actual_balance = self
498 .keyset_info
499 .deterministic_value_after_fees(nominal_value, max_amt)?;
500
501 Ok(actual_balance)
502 }
503}