use bitcoin::secp256k1::schnorr::Signature;
use cashu::nuts::nut10::SpendingConditionVerification;
use cashu::nuts::{P2PKWitness, SwapRequest, Witness};
use std::str::FromStr;
use super::client_storage::ClientChannelFunding;
use super::deterministic::CommitmentOutputs;
use super::established_channel::EstablishedChannel;
const SIG_ALL_COMPAT_BUNDLE_PREFIX: &str = "sigall-compat-v1";
pub(crate) struct SigAllSignatureBundle {
pub(crate) current: Signature,
pub(crate) nutshell_0_20: Option<Signature>,
}
pub fn get_signatures_from_swap_request(
swap_request: &SwapRequest,
) -> Result<Vec<Signature>, anyhow::Error> {
let first_proof = swap_request
.inputs()
.first()
.ok_or_else(|| anyhow::anyhow!("No inputs in swap request"))?;
let signatures =
if let Some(cashu::nuts::Witness::P2PKWitness(p2pk_witness)) = &first_proof.witness {
p2pk_witness
.signatures
.iter()
.filter_map(|sig_str| sig_str.parse::<Signature>().ok())
.collect()
} else {
vec![]
};
Ok(signatures)
}
pub(crate) fn sig_all_message_hash_hex<T>(value: &T) -> String
where
T: SpendingConditionVerification,
{
message_hash_hex(&value.sig_all_msg_to_sign())
}
pub(crate) fn nutshell_0_20_sig_all_message(swap_request: &SwapRequest) -> String {
let mut message = String::new();
for proof in swap_request.inputs() {
message.push_str(&proof.secret.to_string());
}
for output in swap_request.outputs() {
message.push_str(&output.blinded_secret.to_hex());
}
message
}
pub(crate) fn nutshell_0_20_sig_all_message_hash_hex(swap_request: &SwapRequest) -> String {
message_hash_hex(&nutshell_0_20_sig_all_message(swap_request))
}
fn message_hash_hex(message: &str) -> String {
use bitcoin::hashes::{sha256, Hash};
let hash = sha256::Hash::hash(message.as_bytes());
cashu::util::hex::encode(hash.to_byte_array())
}
pub(crate) fn encode_sig_all_signature_bundle(current: &str, nutshell_0_20: &str) -> String {
format!("{SIG_ALL_COMPAT_BUNDLE_PREFIX}:{current}:{nutshell_0_20}")
}
pub(crate) fn parse_sig_all_signature_bundle(value: &str) -> Result<SigAllSignatureBundle, String> {
let parse = |signature: &str| {
Signature::from_str(signature).map_err(|error| format!("Invalid signature: {error}"))
};
let Some(encoded) = value.strip_prefix(&format!("{SIG_ALL_COMPAT_BUNDLE_PREFIX}:")) else {
return Ok(SigAllSignatureBundle {
current: parse(value)?,
nutshell_0_20: None,
});
};
let (current, nutshell_0_20) = encoded
.split_once(':')
.ok_or_else(|| "invalid SIG_ALL compatibility signature bundle".to_string())?;
if nutshell_0_20.contains(':') {
return Err("invalid SIG_ALL compatibility signature bundle".to_string());
}
Ok(SigAllSignatureBundle {
current: parse(current)?,
nutshell_0_20: Some(parse(nutshell_0_20)?),
})
}
pub(crate) fn verify_sender_signature_bundle(
channel: &EstablishedChannel,
balance: u64,
encoded: &str,
) -> Result<SigAllSignatureBundle, String> {
let signatures = parse_sig_all_signature_bundle(encoded)?;
BalanceUpdateMessage {
channel_id: channel.params.get_channel_id(),
amount: balance,
signature: signatures.current,
}
.verify_sender_signature(channel)
.map_err(|error| error.to_string())?;
if let Some(signature) = signatures.nutshell_0_20 {
let commitment = CommitmentOutputs::for_balance(balance, &channel.params)
.map_err(|error| error.to_string())?;
let swap = commitment
.create_swap_request(channel.funding_proofs.clone(), None)
.map_err(|error| error.to_string())?;
channel
.params
.get_sender_blinded_pubkey_for_stage1()
.map_err(|error| error.to_string())?
.verify(nutshell_0_20_sig_all_message(&swap).as_bytes(), &signature)
.map_err(|_| {
"Invalid signature: Alice did not authorize the Nutshell 0.20 balance update"
.to_string()
})?;
}
Ok(signatures)
}
pub(crate) fn attach_signature_to_first_input(
swap_request: &mut SwapRequest,
sig_hex: &str,
) -> Result<(), anyhow::Error> {
let first_input = swap_request
.inputs_mut()
.first_mut()
.ok_or_else(|| anyhow::anyhow!("Swap request has no inputs"))?;
match first_input.witness.as_mut() {
Some(witness) => witness.add_signatures(vec![sig_hex.to_string()]),
None => {
let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default());
p2pk_witness.add_signatures(vec![sig_hex.to_string()]);
first_input.witness = Some(p2pk_witness);
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct BalanceUpdateMessage {
pub channel_id: String,
pub amount: u64,
pub signature: Signature,
}
impl BalanceUpdateMessage {
pub fn from_signed_swap_request(
channel_id: String,
amount: u64,
swap_request: &SwapRequest,
) -> Result<Self, anyhow::Error> {
let signatures = get_signatures_from_swap_request(swap_request)?;
if signatures.len() != 1 {
anyhow::bail!(
"Expected exactly 1 signature (Alice's), but found {}",
signatures.len()
);
}
let signature = signatures[0];
Ok(Self {
channel_id,
amount,
signature,
})
}
pub fn verify_sender_signature(
&self,
channel: &EstablishedChannel,
) -> Result<(), anyhow::Error> {
let commitment_outputs = CommitmentOutputs::for_balance(self.amount, &channel.params)?;
let swap_request =
commitment_outputs.create_swap_request(channel.funding_proofs.clone(), None)?;
let msg_to_sign = swap_request.sig_all_msg_to_sign();
let blinded_sender_pubkey = channel.params.get_sender_blinded_pubkey_for_stage1()?;
blinded_sender_pubkey
.verify(msg_to_sign.as_bytes(), &self.signature)
.map_err(|_| {
anyhow::anyhow!("Invalid signature: Alice did not authorize this balance update")
})?;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct UnsignedBalanceUpdate {
pub channel_id: String,
pub balance: u64,
pub message_hex: String,
pub tweak_scalar_hex: String,
}
impl UnsignedBalanceUpdate {
pub fn new(
channel_id: &str,
balance: u64,
funding: &ClientChannelFunding,
) -> Result<Self, String> {
let unsigned_json = super::bindings::create_unsigned_balance_update(
&funding.params_json,
&funding.keyset_info_json,
&funding.channel_secret_hex,
&funding.funding_proofs_json,
balance,
)?;
let unsigned: serde_json::Value = serde_json::from_str(&unsigned_json)
.map_err(|e| format!("Failed to parse unsigned update: {}", e))?;
let message_hex = unsigned["message_hex"]
.as_str()
.ok_or("Missing 'message_hex'")?
.to_string();
let tweak_scalar_hex = unsigned["tweak_scalar_hex"]
.as_str()
.ok_or("Missing 'tweak_scalar_hex'")?
.to_string();
Ok(Self {
channel_id: channel_id.to_string(),
balance,
message_hex,
tweak_scalar_hex,
})
}
pub fn sign(self, signature_hex: &str) -> Result<BalanceUpdateMessage, String> {
let signature =
Signature::from_str(signature_hex).map_err(|e| format!("Invalid signature: {}", e))?;
Ok(BalanceUpdateMessage {
channel_id: self.channel_id,
amount: self.balance,
signature,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use cashu::nuts::{Id, Proof, PublicKey};
use cashu::secret::Secret;
use cashu::Amount;
fn proof(amount: u64, secret: &str) -> Proof {
Proof::new(
Amount::from(amount),
Id::from_bytes(&[0; 8]).expect("keyset id"),
Secret::new(secret.to_string()),
PublicKey::from_str(
"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
)
.expect("public key"),
)
}
#[test]
fn compatibility_bundle_round_trips_both_signatures() {
let mut swap = SwapRequest::new(
vec![proof(4, "funding-4"), proof(16, "funding-16")],
Vec::new(),
);
let current = "0b63f13bf77bb0fcd27e252641258eb9f631aa5b52ef1496671660f410b828a763b9bbed98c00dcb7c4d098ede9b9c4d93f87f7490f7a40fe5a8781e83c40390";
let nutshell_0_20 = "a640c4bf20075a3f94ba72a7ef520510f3f86fae0272386be255d35ff9803f4141850de3d13afaf44d1b066bfb00f9bfcfd9f659bd09d8679fe8e99f12cc4fd4";
let encoded = encode_sig_all_signature_bundle(current, nutshell_0_20);
let parsed = parse_sig_all_signature_bundle(&encoded).expect("parse bundle");
assert_eq!(parsed.current.to_string(), current);
assert_eq!(
parsed
.nutshell_0_20
.expect("compatibility signature")
.to_string(),
nutshell_0_20
);
attach_signature_to_first_input(&mut swap, current).expect("attach signature");
assert!(swap.inputs()[0].witness.is_some());
assert!(swap.inputs()[1].witness.is_none());
}
}