use bitcoin::secp256k1::schnorr::Signature;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use super::nut01::Error as Nut01Error;
use super::nut20::{legacy_mint_quote_msg_to_sign, mint_quote_msg_to_sign};
use super::{PublicKey, SecretKey};
use crate::{Amount, BlindedMessage};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Signature(#[from] super::nut20::Error),
#[error(transparent)]
Nut01(#[from] Nut01Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct Settings {
#[serde(skip_serializing_if = "Option::is_none")]
pub max_batch_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<String>>,
}
impl Settings {
pub fn new(max_batch_size: Option<u64>, methods: Option<Vec<String>>) -> Self {
Self {
max_batch_size,
methods,
}
}
pub fn is_empty(&self) -> bool {
self.max_batch_size.is_none() && self.methods.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound = "Q: Serialize + DeserializeOwned")]
pub struct BatchCheckMintQuoteRequest<Q> {
pub quotes: Vec<Q>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(bound = "Q: Serialize + DeserializeOwned")]
pub struct BatchMintRequest<Q> {
pub quotes: Vec<Q>,
pub quote_amounts: Option<Vec<Amount>>,
pub outputs: Vec<BlindedMessage>,
pub signatures: Option<Vec<Option<String>>>,
}
impl<Q> BatchMintRequest<Q>
where
Q: ToString,
{
pub fn msg_to_sign(&self, quote: &Q) -> Vec<u8> {
let quote_id = quote.to_string();
mint_quote_msg_to_sign("e_id, &self.outputs)
}
fn legacy_msg_to_sign(&self, quote: &Q) -> Vec<u8> {
let quote_id = quote.to_string();
legacy_mint_quote_msg_to_sign("e_id, &self.outputs)
}
pub fn sign_quote(&self, quote: &Q, secret_key: &SecretKey) -> Result<String, Error> {
let msg = self.msg_to_sign(quote);
let signature: Signature = secret_key.sign(&msg)?;
Ok(signature.to_string())
}
pub fn sign_quote_legacy(&self, quote: &Q, secret_key: &SecretKey) -> Result<String, Error> {
let msg = self.legacy_msg_to_sign(quote);
let signature: Signature = secret_key.sign(&msg)?;
Ok(signature.to_string())
}
pub fn verify_quote_signature(
&self,
quote: &Q,
signature: &str,
pubkey: &PublicKey,
) -> Result<(), Error> {
let signature = signature
.parse::<Signature>()
.map_err(|_| super::nut20::Error::InvalidSignature)?;
let quote_id = quote.to_string();
let msg = mint_quote_msg_to_sign("e_id, &self.outputs);
match pubkey.verify(&msg, &signature) {
Ok(()) => return Ok(()),
Err(_) => {
let legacy_msg = legacy_mint_quote_msg_to_sign("e_id, &self.outputs);
pubkey
.verify(&legacy_msg, &signature)
.map_err(|_| super::nut20::Error::InvalidSignature)?;
tracing::warn!(
quote_id = %quote_id,
output_count = self.outputs.len(),
"Accepted legacy batch mint quote signature format"
);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::*;
use crate::Id;
fn dummy_blinded_message() -> BlindedMessage {
let secret_key = SecretKey::generate();
BlindedMessage {
amount: Amount::from(1),
keyset_id: Id::from_str("009a1f293253e41e").unwrap(),
blinded_secret: secret_key.public_key(),
witness: None,
}
}
fn blinded_message(amount: u64, blinded_secret: &str) -> BlindedMessage {
BlindedMessage {
amount: Amount::from(amount),
keyset_id: Id::from_str("009a1f293253e41e").expect("valid keyset id"),
blinded_secret: PublicKey::from_hex(blinded_secret).expect("valid blinded secret"),
witness: None,
}
}
fn test_vector_outputs() -> Vec<BlindedMessage> {
vec![
blinded_message(
1,
"036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2",
),
blinded_message(
1,
"021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59",
),
]
}
#[test]
fn test_batch_msg_to_sign_matches_nut29_vector() {
let quote_id = "019e6d5a-2347-7000-8c81-a1e0dbf3299f".to_string();
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs: test_vector_outputs(),
signatures: None,
};
let msg = request.msg_to_sign("e_id);
assert_eq!(
crate::util::hex::encode(&msg),
"43617368755f4d696e7451756f74655369675f76310000002430313965366435612d323334372d373030302d386338312d613165306462663332393966000000010100000021036d6caac248af96f6afa7f904f550253a0f3ef3f5aa2fe6838a95b216691468e2000000010100000021021f8a566c205633d029094747d2e18f44e05993dda7a5f88f496078205f656e59"
);
use bitcoin::hashes::sha256::Hash as Sha256Hash;
use bitcoin::hashes::Hash;
assert_eq!(
Sha256Hash::hash(&msg).to_string(),
"dad25acc587637206d73398894d337f983a0ca644746e8673727eaa0b29fa9b4"
);
}
#[test]
fn test_batch_signature_matches_nut29_vector() {
let quote_id = "019e6d5a-2347-7000-8c81-a1e0dbf3299f".to_string();
let pubkey = PublicKey::from_hex(
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
)
.expect("valid pubkey");
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs: test_vector_outputs(),
signatures: Some(vec![Some(
"0c39431338a0202568b9a1d4215c99f179cbb8ee5472ac5ae7133fbb8f99cafbb9e425ad33c60224c96b8f9f984f004379a18e9558468d129b6b03f0da6de162".to_string(),
)]),
};
request
.verify_quote_signature(
"e_id,
"0c39431338a0202568b9a1d4215c99f179cbb8ee5472ac5ae7133fbb8f99cafbb9e425ad33c60224c96b8f9f984f004379a18e9558468d129b6b03f0da6de162",
&pubkey,
)
.expect("verification should succeed");
}
#[test]
fn test_sign_and_verify_batch_quote_roundtrip() {
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let quote_id = "test-quote-id-123".to_string();
let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let signature = request
.sign_quote("e_id, &secret_key)
.expect("signing should succeed");
request
.verify_quote_signature("e_id, &signature, &pubkey)
.expect("verification should succeed with correct key");
}
#[test]
fn test_sign_and_verify_batch_quote_legacy_roundtrip() {
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let quote_id = "test-quote-id-legacy".to_string();
let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let signature = request
.sign_quote_legacy("e_id, &secret_key)
.expect("legacy signing should succeed");
request
.verify_quote_signature("e_id, &signature, &pubkey)
.expect("verification should fall back to the legacy message format");
}
#[test]
fn test_verify_batch_quote_wrong_key() {
let signing_key = SecretKey::generate();
let wrong_key = SecretKey::generate();
let quote_id = "test-quote-wrong-key".to_string();
let outputs = vec![dummy_blinded_message()];
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let signature = request
.sign_quote("e_id, &signing_key)
.expect("signing should succeed");
let result = request.verify_quote_signature("e_id, &signature, &wrong_key.public_key());
assert!(result.is_err(), "verification should fail with wrong key");
}
#[test]
fn test_verify_batch_quote_tampered_outputs() {
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let quote_id = "test-quote-tampered".to_string();
let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let signature = request
.sign_quote("e_id, &secret_key)
.expect("signing should succeed");
let tampered_request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs: vec![dummy_blinded_message(), dummy_blinded_message()],
signatures: None,
};
let result = tampered_request.verify_quote_signature("e_id, &signature, &pubkey);
assert!(
result.is_err(),
"verification should fail with tampered outputs"
);
}
#[test]
fn test_verify_batch_quote_wrong_quote_id() {
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let quote_id = "original-quote-id".to_string();
let outputs = vec![dummy_blinded_message()];
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let signature = request
.sign_quote("e_id, &secret_key)
.expect("signing should succeed");
let different_quote_id = "different-quote-id".to_string();
let result = request.verify_quote_signature(&different_quote_id, &signature, &pubkey);
assert!(
result.is_err(),
"verification should fail with different quote ID"
);
}
#[test]
fn test_sign_batch_quote_empty_outputs() {
let secret_key = SecretKey::generate();
let pubkey = secret_key.public_key();
let quote_id = "test-empty-outputs".to_string();
let request = BatchMintRequest {
quotes: vec![quote_id.clone()],
quote_amounts: None,
outputs: vec![],
signatures: None,
};
let signature = request
.sign_quote("e_id, &secret_key)
.expect("signing should succeed");
request
.verify_quote_signature("e_id, &signature, &pubkey)
.expect("verification should succeed even with empty outputs");
}
#[test]
fn test_sign_batch_quote_multiple_quotes_different_sigs() {
let secret_key = SecretKey::generate();
let outputs = vec![dummy_blinded_message(), dummy_blinded_message()];
let quote_1 = "quote-1".to_string();
let quote_2 = "quote-2".to_string();
let request = BatchMintRequest {
quotes: vec![quote_1.clone(), quote_2.clone()],
quote_amounts: None,
outputs,
signatures: None,
};
let sig1 = request
.sign_quote("e_1, &secret_key)
.expect("signing should succeed");
let sig2 = request
.sign_quote("e_2, &secret_key)
.expect("signing should succeed");
assert_ne!(
sig1, sig2,
"signatures for different quote IDs should differ"
);
}
#[test]
fn settings_is_empty_requires_both_optional_fields_to_be_absent() {
assert!(Settings::new(None, None).is_empty());
assert!(!Settings::new(Some(10), None).is_empty());
assert!(!Settings::new(None, Some(vec!["bolt11".to_string()])).is_empty());
assert!(!Settings::new(Some(10), Some(vec!["bolt11".to_string()])).is_empty());
}
}