Skip to main content

cdk_spilman/
balance_update.rs

1//! Balance Update Message
2//!
3//! Represents signed and unsigned balance updates in a Spilman payment channel.
4//!
5//! The typical flow is:
6//! 1. Create an `UnsignedBalanceUpdate` from channel funding data
7//! 2. Sign it using a host/signer (using `message_hex` and `tweak_scalar_hex`)
8//! 3. Call `sign()` to produce a `BalanceUpdateMessage`
9
10use bitcoin::secp256k1::schnorr::Signature;
11use cashu::nuts::nut10::SpendingConditionVerification;
12use cashu::nuts::{P2PKWitness, SwapRequest, Witness};
13use std::str::FromStr;
14
15use super::client_storage::ClientChannelFunding;
16use super::deterministic::CommitmentOutputs;
17use super::established_channel::EstablishedChannel;
18
19const SIG_ALL_COMPAT_BUNDLE_PREFIX: &str = "sigall-compat-v1";
20
21pub(crate) struct SigAllSignatureBundle {
22    pub(crate) current: Signature,
23    pub(crate) nutshell_0_20: Option<Signature>,
24}
25
26/// Extract signatures from a swap request's first proof witness
27pub fn get_signatures_from_swap_request(
28    swap_request: &SwapRequest,
29) -> Result<Vec<Signature>, anyhow::Error> {
30    let first_proof = swap_request
31        .inputs()
32        .first()
33        .ok_or_else(|| anyhow::anyhow!("No inputs in swap request"))?;
34
35    let signatures =
36        if let Some(cashu::nuts::Witness::P2PKWitness(p2pk_witness)) = &first_proof.witness {
37            // Parse all signature strings into Signature objects
38            p2pk_witness
39                .signatures
40                .iter()
41                .filter_map(|sig_str| sig_str.parse::<Signature>().ok())
42                .collect()
43        } else {
44            vec![]
45        };
46
47    Ok(signatures)
48}
49
50pub(crate) fn sig_all_message_hash_hex<T>(value: &T) -> String
51where
52    T: SpendingConditionVerification,
53{
54    message_hash_hex(&value.sig_all_msg_to_sign())
55}
56
57pub(crate) fn nutshell_0_20_sig_all_message(swap_request: &SwapRequest) -> String {
58    let mut message = String::new();
59    for proof in swap_request.inputs() {
60        message.push_str(&proof.secret.to_string());
61    }
62    for output in swap_request.outputs() {
63        message.push_str(&output.blinded_secret.to_hex());
64    }
65    message
66}
67
68pub(crate) fn nutshell_0_20_sig_all_message_hash_hex(swap_request: &SwapRequest) -> String {
69    message_hash_hex(&nutshell_0_20_sig_all_message(swap_request))
70}
71
72fn message_hash_hex(message: &str) -> String {
73    use bitcoin::hashes::{sha256, Hash};
74
75    let hash = sha256::Hash::hash(message.as_bytes());
76
77    cashu::util::hex::encode(hash.to_byte_array())
78}
79
80pub(crate) fn encode_sig_all_signature_bundle(current: &str, nutshell_0_20: &str) -> String {
81    format!("{SIG_ALL_COMPAT_BUNDLE_PREFIX}:{current}:{nutshell_0_20}")
82}
83
84pub(crate) fn parse_sig_all_signature_bundle(value: &str) -> Result<SigAllSignatureBundle, String> {
85    let parse = |signature: &str| {
86        Signature::from_str(signature).map_err(|error| format!("Invalid signature: {error}"))
87    };
88    let Some(encoded) = value.strip_prefix(&format!("{SIG_ALL_COMPAT_BUNDLE_PREFIX}:")) else {
89        return Ok(SigAllSignatureBundle {
90            current: parse(value)?,
91            nutshell_0_20: None,
92        });
93    };
94    let (current, nutshell_0_20) = encoded
95        .split_once(':')
96        .ok_or_else(|| "invalid SIG_ALL compatibility signature bundle".to_string())?;
97    if nutshell_0_20.contains(':') {
98        return Err("invalid SIG_ALL compatibility signature bundle".to_string());
99    }
100    Ok(SigAllSignatureBundle {
101        current: parse(current)?,
102        nutshell_0_20: Some(parse(nutshell_0_20)?),
103    })
104}
105
106pub(crate) fn verify_sender_signature_bundle(
107    channel: &EstablishedChannel,
108    balance: u64,
109    encoded: &str,
110) -> Result<SigAllSignatureBundle, String> {
111    let signatures = parse_sig_all_signature_bundle(encoded)?;
112    BalanceUpdateMessage {
113        channel_id: channel.params.get_channel_id(),
114        amount: balance,
115        signature: signatures.current,
116    }
117    .verify_sender_signature(channel)
118    .map_err(|error| error.to_string())?;
119
120    if let Some(signature) = signatures.nutshell_0_20 {
121        let commitment = CommitmentOutputs::for_balance(balance, &channel.params)
122            .map_err(|error| error.to_string())?;
123        let swap = commitment
124            .create_swap_request(channel.funding_proofs.clone(), None)
125            .map_err(|error| error.to_string())?;
126        channel
127            .params
128            .get_sender_blinded_pubkey_for_stage1()
129            .map_err(|error| error.to_string())?
130            .verify(nutshell_0_20_sig_all_message(&swap).as_bytes(), &signature)
131            .map_err(|_| {
132                "Invalid signature: Alice did not authorize the Nutshell 0.20 balance update"
133                    .to_string()
134            })?;
135    }
136
137    Ok(signatures)
138}
139
140pub(crate) fn attach_signature_to_first_input(
141    swap_request: &mut SwapRequest,
142    sig_hex: &str,
143) -> Result<(), anyhow::Error> {
144    let first_input = swap_request
145        .inputs_mut()
146        .first_mut()
147        .ok_or_else(|| anyhow::anyhow!("Swap request has no inputs"))?;
148
149    match first_input.witness.as_mut() {
150        Some(witness) => witness.add_signatures(vec![sig_hex.to_string()]),
151        None => {
152            let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default());
153            p2pk_witness.add_signatures(vec![sig_hex.to_string()]);
154            first_input.witness = Some(p2pk_witness);
155        }
156    }
157
158    Ok(())
159}
160
161/// A balance update message from Alice to Charlie
162///
163/// This represents a signed commitment to a new channel balance.
164/// Alice signs a swap request that distributes the channel funds according to the new balance.
165#[derive(Debug, Clone)]
166pub struct BalanceUpdateMessage {
167    /// Channel ID to identify which channel this update is for
168    pub channel_id: String,
169    /// New balance for the receiver (Charlie)
170    pub amount: u64,
171    /// Alice's signature over the swap request
172    pub signature: Signature,
173}
174
175impl BalanceUpdateMessage {
176    /// Used by Alice to create a balance update message from a swap request
177    /// which is signed by her. She then sends the resulting message to Charlie.
178    pub fn from_signed_swap_request(
179        channel_id: String,
180        amount: u64,
181        swap_request: &SwapRequest,
182    ) -> Result<Self, anyhow::Error> {
183        // Extract Alice's signature from the swap request
184        let signatures = get_signatures_from_swap_request(swap_request)?;
185
186        // Ensure there is exactly one signature (Alice's only)
187        if signatures.len() != 1 {
188            anyhow::bail!(
189                "Expected exactly 1 signature (Alice's), but found {}",
190                signatures.len()
191            );
192        }
193
194        let signature = signatures[0];
195
196        Ok(Self {
197            channel_id,
198            amount,
199            signature,
200        })
201    }
202
203    /// Verify the signature using the established channel
204    /// Charlie reconstructs the swap request from the amount to verify the signature
205    /// Throws an error if the signature is invalid
206    pub fn verify_sender_signature(
207        &self,
208        channel: &EstablishedChannel,
209    ) -> Result<(), anyhow::Error> {
210        // Reconstruct the commitment outputs for this balance
211        let commitment_outputs = CommitmentOutputs::for_balance(self.amount, &channel.params)?;
212
213        // Reconstruct the unsigned swap request
214        let swap_request =
215            commitment_outputs.create_swap_request(channel.funding_proofs.clone(), None)?;
216
217        // Extract the SIG_ALL message from the swap request
218        let msg_to_sign = swap_request.sig_all_msg_to_sign();
219
220        // Verify the signature using Alice's BLINDED pubkey
221        // Alice signs with her blinded secret key (the funding token uses blinded pubkeys for privacy)
222        let blinded_sender_pubkey = channel.params.get_sender_blinded_pubkey_for_stage1()?;
223        blinded_sender_pubkey
224            .verify(msg_to_sign.as_bytes(), &self.signature)
225            .map_err(|_| {
226                anyhow::anyhow!("Invalid signature: Alice did not authorize this balance update")
227            })?;
228
229        Ok(())
230    }
231}
232
233// ============================================================================
234// UnsignedBalanceUpdate
235// ============================================================================
236
237/// An unsigned balance update, ready for signing.
238///
239/// Contains the precomputed message hash and tweak scalar needed for signing.
240/// Once signed, use `sign()` to produce a `BalanceUpdateMessage`.
241///
242/// # Example
243/// ```ignore
244/// let unsigned = UnsignedBalanceUpdate::new(channel_id, balance, &funding)?;
245/// let signature = host.sign_with_tweaked_key(
246///     &funding.sender_pubkey_hex,
247///     &unsigned.message_hex,
248///     &unsigned.tweak_scalar_hex,
249/// )?;
250/// let balance_update = unsigned.sign(&signature)?;
251/// ```
252#[derive(Debug, Clone)]
253pub struct UnsignedBalanceUpdate {
254    /// Channel ID
255    pub channel_id: String,
256    /// Balance (cumulative amount receiver can claim)
257    pub balance: u64,
258    /// SHA-256 hash of the SIG_ALL message (32 bytes, hex-encoded)
259    pub message_hex: String,
260    /// P2BK blinding scalar for the sender (32 bytes, hex-encoded)
261    pub tweak_scalar_hex: String,
262}
263
264impl UnsignedBalanceUpdate {
265    /// Create an unsigned balance update from channel funding data.
266    ///
267    /// Computes the message hash and tweak scalar needed for signing.
268    pub fn new(
269        channel_id: &str,
270        balance: u64,
271        funding: &ClientChannelFunding,
272    ) -> Result<Self, String> {
273        // Use the existing bindings function (Option B: pragmatic approach)
274        let unsigned_json = super::bindings::create_unsigned_balance_update(
275            &funding.params_json,
276            &funding.keyset_info_json,
277            &funding.channel_secret_hex,
278            &funding.funding_proofs_json,
279            balance,
280        )?;
281
282        let unsigned: serde_json::Value = serde_json::from_str(&unsigned_json)
283            .map_err(|e| format!("Failed to parse unsigned update: {}", e))?;
284
285        let message_hex = unsigned["message_hex"]
286            .as_str()
287            .ok_or("Missing 'message_hex'")?
288            .to_string();
289
290        let tweak_scalar_hex = unsigned["tweak_scalar_hex"]
291            .as_str()
292            .ok_or("Missing 'tweak_scalar_hex'")?
293            .to_string();
294
295        Ok(Self {
296            channel_id: channel_id.to_string(),
297            balance,
298            message_hex,
299            tweak_scalar_hex,
300        })
301    }
302
303    /// Attach a signature and produce a `BalanceUpdateMessage`.
304    ///
305    /// The signature should be a BIP-340 Schnorr signature (64 bytes, hex-encoded)
306    /// produced by signing `message_hex` with the tweaked key.
307    pub fn sign(self, signature_hex: &str) -> Result<BalanceUpdateMessage, String> {
308        let signature =
309            Signature::from_str(signature_hex).map_err(|e| format!("Invalid signature: {}", e))?;
310
311        Ok(BalanceUpdateMessage {
312            channel_id: self.channel_id,
313            amount: self.balance,
314            signature,
315        })
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use cashu::nuts::{Id, Proof, PublicKey};
323    use cashu::secret::Secret;
324    use cashu::Amount;
325
326    fn proof(amount: u64, secret: &str) -> Proof {
327        Proof::new(
328            Amount::from(amount),
329            Id::from_bytes(&[0; 8]).expect("keyset id"),
330            Secret::new(secret.to_string()),
331            PublicKey::from_str(
332                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
333            )
334            .expect("public key"),
335        )
336    }
337
338    #[test]
339    fn compatibility_bundle_round_trips_both_signatures() {
340        let mut swap = SwapRequest::new(
341            vec![proof(4, "funding-4"), proof(16, "funding-16")],
342            Vec::new(),
343        );
344        let current = "0b63f13bf77bb0fcd27e252641258eb9f631aa5b52ef1496671660f410b828a763b9bbed98c00dcb7c4d098ede9b9c4d93f87f7490f7a40fe5a8781e83c40390";
345        let nutshell_0_20 = "a640c4bf20075a3f94ba72a7ef520510f3f86fae0272386be255d35ff9803f4141850de3d13afaf44d1b066bfb00f9bfcfd9f659bd09d8679fe8e99f12cc4fd4";
346
347        let encoded = encode_sig_all_signature_bundle(current, nutshell_0_20);
348        let parsed = parse_sig_all_signature_bundle(&encoded).expect("parse bundle");
349        assert_eq!(parsed.current.to_string(), current);
350        assert_eq!(
351            parsed
352                .nutshell_0_20
353                .expect("compatibility signature")
354                .to_string(),
355            nutshell_0_20
356        );
357
358        attach_signature_to_first_input(&mut swap, current).expect("attach signature");
359        assert!(swap.inputs()[0].witness.is_some());
360        assert!(swap.inputs()[1].witness.is_none());
361    }
362}