Skip to main content

cdk_spilman/
params.rs

1//! Spilman Channel Parameters
2//!
3//! Contains the protocol parameters for a Spilman payment channel
4
5/// Type alias for channel identifiers (hex-encoded).
6pub type ChannelId = String;
7
8use serde::{Deserialize, Serialize};
9
10use bitcoin::hashes::{sha256, Hash};
11use bitcoin::secp256k1::ecdh::SharedSecret;
12use bitcoin::secp256k1::{Parity, Scalar};
13use cashu::nuts::{CurrencyUnit, SecretKey};
14#[cfg(test)]
15use cashu::nuts::{Id, Keys, PublicKey};
16use cashu::util::hex;
17#[cfg(test)]
18use cashu::Amount;
19use cashu::SECP256K1;
20#[cfg(test)]
21use std::collections::BTreeMap;
22#[cfg(test)]
23use std::str::FromStr;
24
25use super::deterministic::DeterministicSecretWithBlinding;
26use super::keysets_and_amounts::KeysetInfo;
27
28pub(crate) struct Stage2P2bkTweakInfo {
29    #[allow(dead_code)]
30    pub(crate) ephemeral_secret: SecretKey,
31    #[allow(dead_code)]
32    pub(crate) ephemeral_pubkey: cashu::nuts::PublicKey,
33    #[allow(dead_code)]
34    pub(crate) ephemeral_shared_secret_x: [u8; 32],
35    #[allow(dead_code)]
36    pub(crate) stage2_tweak_scalar: Scalar,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum Stage2Role {
41    Sender,
42    Receiver,
43}
44
45impl Stage2Role {
46    fn stage2_context(self) -> &'static str {
47        match self {
48            Self::Sender => "sender_stage2",
49            Self::Receiver => "receiver_stage2",
50        }
51    }
52
53    fn pubkey(self, params: &ChannelParameters) -> &cashu::nuts::PublicKey {
54        match self {
55            Self::Sender => &params.sender_pubkey,
56            Self::Receiver => &params.receiver_pubkey,
57        }
58    }
59}
60
61/// Parameters for a Spilman payment channel
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ChannelParameters {
64    /// Alice's public key (sender)
65    pub sender_pubkey: cashu::nuts::PublicKey,
66    /// Charlie's public key (receiver)
67    pub receiver_pubkey: cashu::nuts::PublicKey,
68    /// Mint URL (or "local" for in-process mint)
69    pub mint: String,
70    /// Currency unit for the channel
71    pub unit: CurrencyUnit,
72    /// Channel capacity: maximum final value (after both fee stages) that Charlie can receive
73    pub capacity: u64,
74    /// Total nominal value of the funding token (must satisfy: capacity <= forward(forward(funding_token_amount)))
75    pub funding_token_amount: u64,
76    /// Expiry timestamp after which Alice can reclaim funds (unix timestamp)
77    pub expiry_timestamp: u64,
78    /// Setup timestamp (unix timestamp when channel was created)
79    pub setup_timestamp: u64,
80    /// Keyset information (ID, keys, amounts, fees)
81    pub keyset_info: KeysetInfo,
82    /// Maximum amount for one output (amounts larger than this are filtered out)
83    pub maximum_amount_for_one_output: u64,
84    /// Channel secret: a domain-separated hash of the ECDH shared secret between Alice and Charlie
85    pub channel_secret: [u8; 32],
86}
87
88/// Compute the channel secret from a secret key and counterparty's public key
89///
90/// Performs ECDH and then hashes the result with a domain separator so that
91/// the raw Diffie-Hellman shared secret never leaves this function.
92///
93/// Returns: SHA256("Cashu_Spilman_channel_secret_v1" || ECDH(my_secret, their_pubkey))
94pub fn compute_channel_secret(
95    my_secret: &cashu::nuts::SecretKey,
96    their_pubkey: &cashu::nuts::PublicKey,
97) -> [u8; 32] {
98    let raw_ecdh = SharedSecret::new(their_pubkey, my_secret).secret_bytes();
99    let mut input = Vec::new();
100    input.extend_from_slice(b"Cashu_Spilman_channel_secret_v1");
101    input.extend_from_slice(&raw_ecdh);
102    sha256::Hash::hash(&input).to_byte_array()
103}
104
105/// Helper to create a simple KeysetInfo for testing
106#[cfg(test)]
107pub(crate) fn mock_keyset_info(amounts: Vec<u64>, input_fee_ppk: u64) -> KeysetInfo {
108    let mut keys_map = BTreeMap::new();
109    let dummy_pubkey =
110        PublicKey::from_str("02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2")
111            .unwrap();
112    for &amt in &amounts {
113        keys_map.insert(Amount::from(amt), dummy_pubkey);
114    }
115
116    let mut amounts_largest_first = amounts;
117    amounts_largest_first.sort_by(|a, b| b.cmp(a));
118
119    let active_keys = Keys::new(keys_map);
120    let keyset_id = Id::v1_from_keys(&active_keys);
121
122    KeysetInfo::new(
123        keyset_id,
124        CurrencyUnit::Sat,
125        active_keys,
126        input_fee_ppk,
127        None,
128    )
129}
130
131/// Derive a blinded secret key for P2BK signing
132///
133/// Computes k = p + r (mod n), handling BIP-340 parity.
134/// If the pubkey has odd Y, we use k = -p + r instead.
135///
136/// This ensures that signing with k produces a valid signature for the blinded pubkey P' = P + r*G.
137fn derive_blinded_secret_key(secret: &SecretKey, r: &Scalar) -> anyhow::Result<SecretKey> {
138    // Get parity of the public key by accessing the underlying secp256k1 pubkey
139    // Our wrapper's x_only_public_key() only returns XOnlyPublicKey, but the inner
140    // secp256k1::PublicKey::x_only_public_key() returns (XOnlyPublicKey, Parity)
141    let pubkey = secret.public_key();
142    let inner_pubkey: &bitcoin::secp256k1::PublicKey = &pubkey;
143    let (_, parity) = inner_pubkey.x_only_public_key();
144
145    // Get the underlying secp256k1 secret key
146    // We need to clone because negate() consumes self
147    let inner_secret: bitcoin::secp256k1::SecretKey = **secret;
148
149    // If parity is odd, negate the secret key before adding the tweak
150    // This is because BIP-340 signing will use the negated key for odd-Y pubkeys
151    let effective_secret = if parity == Parity::Odd {
152        inner_secret.negate()
153    } else {
154        inner_secret
155    };
156
157    // Add the blinding scalar: k = p + r (or k = -p + r if odd parity)
158    let blinded = effective_secret
159        .add_tweak(r)
160        .map_err(|e| anyhow::anyhow!("Failed to add blinding tweak: {}", e))?;
161
162    Ok(blinded.into())
163}
164
165/// Derive a blinded pubkey for P2BK verification
166///
167/// This is the pubkey-side counterpart to `derive_blinded_secret_key`.
168/// It computes the pubkey that corresponds to the blinded secret key.
169///
170/// For BIP-340 compatibility:
171/// - If pubkey has even Y: P' = P + r*G
172/// - If pubkey has odd Y:  P' = -P + r*G
173///
174/// This ensures that `k*G = P'` where `k` is the blinded secret key.
175fn derive_blinded_pubkey(
176    pubkey: &cashu::nuts::PublicKey,
177    r: &Scalar,
178) -> anyhow::Result<cashu::nuts::PublicKey> {
179    // Get parity of the public key
180    let inner_pubkey: &bitcoin::secp256k1::PublicKey = pubkey;
181    let (_, parity) = inner_pubkey.x_only_public_key();
182
183    // If parity is odd, negate the pubkey before adding the tweak
184    // This matches what derive_blinded_secret_key does with the secret key
185    let effective_pubkey = if parity == Parity::Odd {
186        inner_pubkey.negate(&SECP256K1)
187    } else {
188        *inner_pubkey
189    };
190
191    // Add the tweak: P' = P + r*G (or P' = -P + r*G if odd parity)
192    let blinded = effective_pubkey
193        .add_exp_tweak(&SECP256K1, r)
194        .map_err(|e| anyhow::anyhow!("Failed to blind pubkey: {}", e))?;
195
196    Ok(blinded.into())
197}
198
199impl ChannelParameters {
200    /// Create new channel parameters with a pre-computed channel secret
201    #[allow(clippy::too_many_arguments)]
202    pub fn new(
203        sender_pubkey: cashu::nuts::PublicKey,
204        receiver_pubkey: cashu::nuts::PublicKey,
205        mint: String,
206        unit: CurrencyUnit,
207        capacity: u64,
208        funding_token_amount: u64,
209        expiry_timestamp: u64,
210        setup_timestamp: u64,
211        keyset_info: KeysetInfo,
212        maximum_amount_for_one_output: u64,
213        channel_secret: [u8; 32],
214    ) -> anyhow::Result<Self> {
215        // Validate input_fee_ppk is in valid range
216        if keyset_info.input_fee_ppk > 999 {
217            anyhow::bail!(
218                "input_fee_ppk must be between 0 and 999 (inclusive), got {}",
219                keyset_info.input_fee_ppk
220            );
221        }
222
223        // Validate capacity <= forward(forward(funding_token_amount))
224        let max_capacity = {
225            let after_stage1 = keyset_info.deterministic_value_after_fees(
226                funding_token_amount,
227                maximum_amount_for_one_output,
228            )?;
229            keyset_info
230                .deterministic_value_after_fees(after_stage1, maximum_amount_for_one_output)?
231        };
232        if capacity > max_capacity {
233            anyhow::bail!(
234                "capacity {} exceeds maximum achievable capacity {} for funding_token_amount {} \
235                 (capacity must be <= forward(forward(funding_token_amount)))",
236                capacity,
237                max_capacity,
238                funding_token_amount
239            );
240        }
241
242        Ok(Self {
243            sender_pubkey,
244            receiver_pubkey,
245            mint,
246            unit,
247            capacity,
248            funding_token_amount,
249            expiry_timestamp,
250            setup_timestamp,
251            keyset_info,
252            maximum_amount_for_one_output,
253            channel_secret,
254        })
255    }
256
257    /// Create new channel parameters by computing the channel secret from a secret key
258    ///
259    /// This constructor computes the channel secret (hashed ECDH) automatically.
260    /// It auto-detects whether the provided secret key belongs to Alice or Charlie by checking
261    /// if its public key matches either party, then uses the counterparty's public key for ECDH.
262    ///
263    /// # Arguments
264    /// * `my_secret` - Either Alice's or Charlie's secret key
265    /// * All other arguments are the same as `new`
266    ///
267    /// # Errors
268    /// Returns an error if the secret key's public key doesn't match either sender_pubkey or receiver_pubkey
269    #[allow(clippy::too_many_arguments)]
270    pub fn new_with_secret_key(
271        sender_pubkey: cashu::nuts::PublicKey,
272        receiver_pubkey: cashu::nuts::PublicKey,
273        mint: String,
274        unit: CurrencyUnit,
275        capacity: u64,
276        funding_token_amount: u64,
277        expiry_timestamp: u64,
278        setup_timestamp: u64,
279        keyset_info: KeysetInfo,
280        maximum_amount_for_one_output: u64,
281        my_secret: &SecretKey,
282    ) -> anyhow::Result<Self> {
283        let my_pubkey = my_secret.public_key();
284
285        // Determine which party we are and get the counterparty's pubkey
286        let their_pubkey = if my_pubkey == sender_pubkey {
287            // We are Alice, use Charlie's pubkey
288            &receiver_pubkey
289        } else if my_pubkey == receiver_pubkey {
290            // We are Charlie, use Alice's pubkey
291            &sender_pubkey
292        } else {
293            anyhow::bail!(
294                "Secret key's public key doesn't match either sender_pubkey or receiver_pubkey"
295            );
296        };
297
298        // Compute channel secret (hashed ECDH)
299        let channel_secret = compute_channel_secret(my_secret, their_pubkey);
300
301        Self::new(
302            sender_pubkey,
303            receiver_pubkey,
304            mint,
305            unit,
306            capacity,
307            funding_token_amount,
308            expiry_timestamp,
309            setup_timestamp,
310            keyset_info,
311            maximum_amount_for_one_output,
312            channel_secret,
313        )
314    }
315
316    /// Create channel parameters from a JSON string and a secret key
317    ///
318    /// The JSON should contain: mint, unit, capacity, keyset_id, input_fee_ppk,
319    /// maximum_amount, setup_timestamp, sender_pubkey, receiver_pubkey, expiry_timestamp
320    /// (as produced by `get_channel_id_params_json`)
321    ///
322    /// Additional parameters needed:
323    /// * `keyset_info` - Keyset information from the mint (keyset_id and input_fee_ppk must match JSON)
324    /// * `my_secret` - Either Alice's or Charlie's secret key for ECDH
325    pub fn from_json_with_secret_key(
326        json_str: &str,
327        keyset_info: KeysetInfo,
328        my_secret: &SecretKey,
329    ) -> anyhow::Result<Self> {
330        // Parse JSON to get pubkeys for ECDH
331        let json: serde_json::Value =
332            serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;
333
334        let sender_pubkey_hex = json["sender_pubkey"]
335            .as_str()
336            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'sender_pubkey' field"))?;
337        let sender_pubkey: cashu::nuts::PublicKey = sender_pubkey_hex
338            .parse()
339            .map_err(|e| anyhow::anyhow!("Invalid sender_pubkey: {}", e))?;
340
341        let receiver_pubkey_hex = json["receiver_pubkey"]
342            .as_str()
343            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'receiver_pubkey' field"))?;
344        let receiver_pubkey: cashu::nuts::PublicKey = receiver_pubkey_hex
345            .parse()
346            .map_err(|e| anyhow::anyhow!("Invalid receiver_pubkey: {}", e))?;
347
348        // Determine counterparty and compute channel secret
349        let my_pubkey = my_secret.public_key();
350        let their_pubkey = if my_pubkey == sender_pubkey {
351            &receiver_pubkey
352        } else if my_pubkey == receiver_pubkey {
353            &sender_pubkey
354        } else {
355            anyhow::bail!(
356                "Secret key's public key doesn't match either sender_pubkey or receiver_pubkey"
357            );
358        };
359
360        let channel_secret = compute_channel_secret(my_secret, their_pubkey);
361
362        Self::from_json_with_channel_secret(json_str, keyset_info, channel_secret)
363    }
364
365    /// Create channel parameters from a JSON string with a pre-computed channel secret
366    ///
367    /// Same as `from_json` but takes the channel secret directly instead of computing it.
368    pub fn from_json_with_channel_secret(
369        json_str: &str,
370        keyset_info: KeysetInfo,
371        channel_secret: [u8; 32],
372    ) -> anyhow::Result<Self> {
373        let json: serde_json::Value =
374            serde_json::from_str(json_str).map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;
375
376        // Parse keyset_id and input_fee_ppk first to validate against keyset_info
377        let keyset_id_str = json["keyset_id"]
378            .as_str()
379            .or_else(|| json["keysetId"].as_str())
380            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'keyset_id' field"))?;
381        let json_keyset_id: cashu::nuts::Id = keyset_id_str
382            .parse()
383            .map_err(|e| anyhow::anyhow!("Invalid keyset_id: {}", e))?;
384
385        let json_input_fee_ppk = json["input_fee_ppk"]
386            .as_u64()
387            .or_else(|| json["inputFeePpk"].as_u64())
388            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'input_fee_ppk' field"))?;
389
390        // Validate keyset_info matches JSON
391        if keyset_info.keyset_id != json_keyset_id {
392            anyhow::bail!(
393                "keyset_id mismatch: JSON has {}, KeysetInfo has {}",
394                json_keyset_id,
395                keyset_info.keyset_id
396            );
397        }
398        if keyset_info.input_fee_ppk != json_input_fee_ppk {
399            anyhow::bail!(
400                "input_fee_ppk mismatch: JSON has {}, KeysetInfo has {}",
401                json_input_fee_ppk,
402                keyset_info.input_fee_ppk
403            );
404        }
405
406        // Parse remaining fields
407        let mint = json["mint"]
408            .as_str()
409            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'mint' field"))?
410            .to_string();
411
412        let unit_str = json["unit"]
413            .as_str()
414            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'unit' field"))?;
415        let unit = match unit_str {
416            "sat" => CurrencyUnit::Sat,
417            "msat" => CurrencyUnit::Msat,
418            "usd" => CurrencyUnit::Usd,
419            "eur" => CurrencyUnit::Eur,
420            _ => anyhow::bail!("Unknown unit: {}", unit_str),
421        };
422
423        let capacity = json["capacity"]
424            .as_u64()
425            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'capacity' field"))?;
426
427        let funding_token_amount = json["funding_token_amount"]
428            .as_u64()
429            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'funding_token_amount' field"))?;
430
431        let maximum_amount_for_one_output = json["maximum_amount"]
432            .as_u64()
433            .or_else(|| json["maximum_amount_for_one_output"].as_u64())
434            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'maximum_amount' field"))?;
435
436        let setup_timestamp = json["setup_timestamp"]
437            .as_u64()
438            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'setup_timestamp' field"))?;
439
440        let sender_pubkey_hex = json["sender_pubkey"]
441            .as_str()
442            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'sender_pubkey' field"))?;
443        let sender_pubkey: cashu::nuts::PublicKey = sender_pubkey_hex
444            .parse()
445            .map_err(|e| anyhow::anyhow!("Invalid sender_pubkey: {}", e))?;
446
447        let receiver_pubkey_hex = json["receiver_pubkey"]
448            .as_str()
449            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'receiver_pubkey' field"))?;
450        let receiver_pubkey: cashu::nuts::PublicKey = receiver_pubkey_hex
451            .parse()
452            .map_err(|e| anyhow::anyhow!("Invalid receiver_pubkey: {}", e))?;
453
454        let expiry_timestamp = json["expiry_timestamp"]
455            .as_u64()
456            .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'expiry_timestamp' field"))?;
457
458        Self::new(
459            sender_pubkey,
460            receiver_pubkey,
461            mint,
462            unit,
463            capacity,
464            funding_token_amount,
465            expiry_timestamp,
466            setup_timestamp,
467            keyset_info,
468            maximum_amount_for_one_output,
469            channel_secret,
470        )
471    }
472
473    /// Get channel capacity
474    /// Returns the maximum final value (after both fee stages) that Charlie can receive
475    pub fn get_capacity(&self) -> u64 {
476        self.capacity
477    }
478
479    /// Get channel ID as raw bytes (32-byte SHA256 hash)
480    /// The hash is computed over: mint|unit|capacity|funding_token_amount|keyset_id|input_fee_ppk|maximum_amount|setup_timestamp|sender_pubkey|receiver_pubkey|expiry_timestamp|channel_secret
481    ///
482    /// The channel_secret (channel_secret) is included implicitly — it does not
483    /// appear in `get_channel_id_params_json()`. This means the channel ID can
484    /// only be computed by the two parties who know the channel secret.
485    pub fn get_channel_id_bytes(&self) -> [u8; 32] {
486        let params_string = format!(
487            "{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}|{}",
488            self.mint,
489            self.unit_name(),
490            self.capacity,
491            self.funding_token_amount,
492            self.keyset_info.keyset_id,
493            self.keyset_info.input_fee_ppk,
494            self.maximum_amount_for_one_output,
495            self.setup_timestamp,
496            self.sender_pubkey.to_hex(),
497            self.receiver_pubkey.to_hex(),
498            self.expiry_timestamp,
499            hex::encode(self.channel_secret)
500        );
501        sha256::Hash::hash(params_string.as_bytes()).to_byte_array()
502    }
503
504    /// Get channel ID as a hex string
505    pub fn get_channel_id(&self) -> String {
506        hex::encode(self.get_channel_id_bytes())
507    }
508
509    /// Get a JSON string representation of the data that contributes to the channel ID
510    /// This includes all parameters that define the channel unique identity.
511    pub fn get_channel_id_params_json(&self) -> String {
512        serde_json::json!({
513            "mint": self.mint,
514            "unit": self.unit_name(),
515            "capacity": self.capacity,
516            "funding_token_amount": self.funding_token_amount,
517            "keyset_id": self.keyset_info.keyset_id.to_string(),
518            "input_fee_ppk": self.keyset_info.input_fee_ppk,
519            "maximum_amount": self.maximum_amount_for_one_output,
520            "setup_timestamp": self.setup_timestamp,
521            "sender_pubkey": self.sender_pubkey.to_hex(),
522            "receiver_pubkey": self.receiver_pubkey.to_hex(),
523            "expiry_timestamp": self.expiry_timestamp
524        })
525        .to_string()
526    }
527}
528
529mod blinding;
530
531#[cfg(test)]
532mod tests;