1use 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
26pub 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 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#[derive(Debug, Clone)]
166pub struct BalanceUpdateMessage {
167 pub channel_id: String,
169 pub amount: u64,
171 pub signature: Signature,
173}
174
175impl BalanceUpdateMessage {
176 pub fn from_signed_swap_request(
179 channel_id: String,
180 amount: u64,
181 swap_request: &SwapRequest,
182 ) -> Result<Self, anyhow::Error> {
183 let signatures = get_signatures_from_swap_request(swap_request)?;
185
186 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 pub fn verify_sender_signature(
207 &self,
208 channel: &EstablishedChannel,
209 ) -> Result<(), anyhow::Error> {
210 let commitment_outputs = CommitmentOutputs::for_balance(self.amount, &channel.params)?;
212
213 let swap_request =
215 commitment_outputs.create_swap_request(channel.funding_proofs.clone(), None)?;
216
217 let msg_to_sign = swap_request.sig_all_msg_to_sign();
219
220 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#[derive(Debug, Clone)]
253pub struct UnsignedBalanceUpdate {
254 pub channel_id: String,
256 pub balance: u64,
258 pub message_hex: String,
260 pub tweak_scalar_hex: String,
262}
263
264impl UnsignedBalanceUpdate {
265 pub fn new(
269 channel_id: &str,
270 balance: u64,
271 funding: &ClientChannelFunding,
272 ) -> Result<Self, String> {
273 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 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}