#![allow(missing_docs)]
use serde::Serialize;
use cashu::nuts::{Proof, PublicKey, RestoreRequest, SecretKey, SwapRequest};
use cashu::Amount;
use super::balance_update::BalanceUpdateMessage;
use super::deterministic::{CommitmentOutputs, DeterministicOutputsForOneContext, MintConnection};
use super::established_channel::EstablishedChannel;
use super::keysets_and_amounts::KeysetInfo;
use super::params::{ChannelParameters, Stage2Role};
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
pub enum ChannelVerificationError {
MissingDleq { proof_index: usize, amount: u64 },
InvalidDleq {
proof_index: usize,
amount: u64,
reason: String,
},
MissingMintKey { proof_index: usize, amount: u64 },
InvalidKeysetId { expected: String, computed: String },
ValueMismatch { expected: u64, actual: u64 },
CountMismatch { expected: usize, actual: usize },
SecretMismatch {
proof_index: usize,
expected: String,
actual: String,
},
AmountMismatch {
proof_index: usize,
expected: u64,
actual: u64,
},
InternalError(String),
}
#[derive(Debug, Serialize)]
pub struct ChannelVerificationResult {
pub valid: bool,
pub errors: Vec<ChannelVerificationError>,
}
impl ChannelVerificationResult {
pub fn ok() -> Self {
Self {
valid: true,
errors: Vec::new(),
}
}
pub fn failed(errors: Vec<ChannelVerificationError>) -> Self {
Self {
valid: false,
errors,
}
}
pub fn is_ok(&self) -> bool {
self.valid
}
}
pub fn verify_valid_channel(
funding_proofs: &[Proof],
params: &ChannelParameters,
) -> ChannelVerificationResult {
use cashu::nuts::Id;
let mut errors = Vec::new();
let expected_keyset_id = params.keyset_info.keyset_id;
let computed_keyset_id = match expected_keyset_id.get_version() {
cashu::nuts::nut02::KeySetVersion::Version00 => {
Id::v1_from_keys(¶ms.keyset_info.active_keys)
}
cashu::nuts::nut02::KeySetVersion::Version01 => Id::v2_from_data(
¶ms.keyset_info.active_keys,
¶ms.keyset_info.unit,
params.keyset_info.input_fee_ppk,
params.keyset_info.final_expiry,
),
};
if expected_keyset_id != computed_keyset_id {
errors.push(ChannelVerificationError::InvalidKeysetId {
expected: expected_keyset_id.to_string(),
computed: computed_keyset_id.to_string(),
});
}
for (i, proof) in funding_proofs.iter().enumerate() {
let amount = u64::from(proof.amount);
if proof.dleq.is_none() {
errors.push(ChannelVerificationError::MissingDleq {
proof_index: i,
amount,
});
continue;
}
let mint_pubkey: Option<PublicKey> =
params.keyset_info.active_keys.amount_key(proof.amount);
let mint_pubkey = match mint_pubkey {
Some(key) => key,
None => {
errors.push(ChannelVerificationError::MissingMintKey {
proof_index: i,
amount,
});
continue;
}
};
if let Err(e) = proof.verify_dleq(mint_pubkey) {
errors.push(ChannelVerificationError::InvalidDleq {
proof_index: i,
amount,
reason: e.to_string(),
});
}
}
let total_value: u64 = funding_proofs.iter().map(|p| u64::from(p.amount)).sum();
if total_value != params.funding_token_amount {
errors.push(ChannelVerificationError::ValueMismatch {
expected: params.funding_token_amount,
actual: total_value,
});
}
let expected_outputs = match DeterministicOutputsForOneContext::new(
"funding".to_string(),
params.funding_token_amount,
params.clone(),
) {
Ok(outputs) => match outputs.get_secrets_with_blinding() {
Ok(secrets) => secrets,
Err(e) => {
errors.push(ChannelVerificationError::InternalError(e.to_string()));
return ChannelVerificationResult::failed(errors);
}
},
Err(e) => {
errors.push(ChannelVerificationError::InternalError(e.to_string()));
return ChannelVerificationResult::failed(errors);
}
};
if funding_proofs.len() != expected_outputs.len() {
errors.push(ChannelVerificationError::CountMismatch {
expected: expected_outputs.len(),
actual: funding_proofs.len(),
});
} else {
for (i, (proof, expected)) in funding_proofs
.iter()
.zip(expected_outputs.iter())
.enumerate()
{
if proof.secret != expected.secret {
errors.push(ChannelVerificationError::SecretMismatch {
proof_index: i,
expected: expected.secret.to_string(),
actual: proof.secret.to_string(),
});
}
if u64::from(proof.amount) != expected.amount {
errors.push(ChannelVerificationError::AmountMismatch {
proof_index: i,
expected: expected.amount,
actual: u64::from(proof.amount),
});
}
}
}
if errors.is_empty() {
ChannelVerificationResult::ok()
} else {
ChannelVerificationResult::failed(errors)
}
}
#[derive(Debug)]
pub struct SpilmanChannelSender {
pub alice_secret: SecretKey,
pub channel: EstablishedChannel,
}
impl SpilmanChannelSender {
pub fn new(alice_secret: SecretKey, channel: EstablishedChannel) -> Self {
Self {
alice_secret,
channel,
}
}
pub fn create_signed_balance_update(
&self,
charlie_balance: u64,
) -> anyhow::Result<(BalanceUpdateMessage, SwapRequest)> {
let commitment_outputs =
CommitmentOutputs::for_balance(charlie_balance, &self.channel.params)?;
let mut swap_request =
commitment_outputs.create_swap_request(self.channel.funding_proofs.clone(), None)?;
let blinded_secret = self
.channel
.params
.get_sender_blinded_secret_key_for_stage1(&self.alice_secret)?;
swap_request.sign_sig_all(blinded_secret)?;
let balance_update = BalanceUpdateMessage::from_signed_swap_request(
self.channel.params.get_channel_id(),
charlie_balance,
&swap_request,
)?;
Ok((balance_update, swap_request))
}
pub fn get_de_facto_balance(&self, intended_balance: u64) -> anyhow::Result<u64> {
self.channel.params.get_de_facto_balance(intended_balance)
}
pub fn capacity(&self) -> u64 {
self.channel.params.capacity
}
pub fn channel_id(&self) -> String {
self.channel.params.get_channel_id()
}
pub fn get_channel_secret(&self) -> &[u8; 32] {
&self.channel.params.channel_secret
}
pub async fn restore_sender_proofs<M: MintConnection + ?Sized>(
&self,
mint_connection: &M,
) -> anyhow::Result<Vec<Proof>> {
self.restore_sender_proofs_with_keyset(mint_connection, &self.channel.params.keyset_info)
.await
}
pub async fn restore_sender_proofs_with_keyset<M: MintConnection + ?Sized>(
&self,
mint_connection: &M,
output_keyset_info: &KeysetInfo,
) -> anyhow::Result<Vec<Proof>> {
let params = &self.channel.params;
let keyset_id = output_keyset_info.keyset_id;
let max_amount = params.maximum_amount_for_one_output;
let mut amounts: Vec<u64> = params
.keyset_info
.amounts_largest_first
.iter()
.copied()
.filter(|&amt| max_amount == 0 || amt <= max_amount)
.collect();
amounts.reverse();
let mut recovered_proofs = Vec::new();
for amount in amounts {
let mut index = 0usize;
loop {
let det_output =
params.create_deterministic_output_with_blinding("sender", amount, index)?;
let blinded_message =
det_output.to_blinded_message(Amount::from(amount), keyset_id)?;
let restore_request = RestoreRequest {
outputs: vec![blinded_message],
};
let restore_response = mint_connection.post_restore(restore_request).await;
match restore_response {
Ok(response) if !response.signatures.is_empty() => {
let blind_signature =
response.signatures.into_iter().next().ok_or_else(|| {
anyhow::anyhow!("mint restore response had no signatures")
})?;
let mut proofs = cashu::dhke::construct_proofs(
vec![blind_signature],
vec![det_output.blinding_factor.clone()],
vec![det_output.secret.clone()],
&output_keyset_info.active_keys,
)?;
let mut proof = proofs.pop().ok_or_else(|| {
anyhow::anyhow!("construct_proofs returned no proofs")
})?;
params.attach_stage2_p2pk_e(
&mut proof,
Stage2Role::Sender,
amount,
index,
)?;
recovered_proofs.push(proof);
index += 1;
}
Ok(_) => {
break;
}
Err(error) => return Err(error),
}
}
}
Ok(recovered_proofs)
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use async_trait::async_trait;
use super::*;
use crate::params::mock_keyset_info;
use cashu::nuts::{CheckStateResponse, CurrencyUnit, RestoreResponse, SwapResponse};
struct RecordingMintConnection {
attempted_amounts: Mutex<Vec<u64>>,
attempted_keysets: Mutex<Vec<cashu::nuts::Id>>,
}
impl RecordingMintConnection {
fn new() -> Self {
Self {
attempted_amounts: Mutex::new(Vec::new()),
attempted_keysets: Mutex::new(Vec::new()),
}
}
fn attempted_amounts(&self) -> Vec<u64> {
self.attempted_amounts.lock().unwrap().clone()
}
fn attempted_keysets(&self) -> Vec<cashu::nuts::Id> {
self.attempted_keysets.lock().unwrap().clone()
}
}
#[async_trait]
impl MintConnection for RecordingMintConnection {
async fn process_swap(
&self,
_request: cashu::nuts::SwapRequest,
) -> anyhow::Result<SwapResponse> {
unreachable!("process_swap is not used in these tests")
}
async fn post_restore(&self, request: RestoreRequest) -> anyhow::Result<RestoreResponse> {
let amount = request
.outputs
.first()
.map(|output| u64::from(output.amount))
.expect("restore request should contain one output");
self.attempted_amounts.lock().unwrap().push(amount);
self.attempted_keysets
.lock()
.unwrap()
.push(request.outputs[0].keyset_id);
Ok(RestoreResponse {
outputs: request.outputs,
signatures: vec![],
})
}
async fn check_state(
&self,
_ys: Vec<cashu::nuts::PublicKey>,
) -> anyhow::Result<CheckStateResponse> {
unreachable!("check_state is not used in these tests")
}
}
struct FailingRestoreMintConnection;
#[async_trait]
impl MintConnection for FailingRestoreMintConnection {
async fn process_swap(
&self,
_request: cashu::nuts::SwapRequest,
) -> anyhow::Result<SwapResponse> {
unreachable!("process_swap is not used in these tests")
}
async fn post_restore(&self, _request: RestoreRequest) -> anyhow::Result<RestoreResponse> {
anyhow::bail!("restore transport failed")
}
async fn check_state(
&self,
_ys: Vec<cashu::nuts::PublicKey>,
) -> anyhow::Result<CheckStateResponse> {
unreachable!("check_state is not used in these tests")
}
}
fn create_test_sender(maximum_amount_for_one_output: u64) -> SpilmanChannelSender {
let alice_secret = SecretKey::generate();
let sender_pubkey = alice_secret.public_key();
let charlie_secret = SecretKey::generate();
let receiver_pubkey = charlie_secret.public_key();
let keyset_info = mock_keyset_info(vec![1, 2, 4, 8], 0);
let capacity = 8;
let funding_token_amount = ChannelParameters::get_minimum_funding_token_amount(
capacity,
&keyset_info,
maximum_amount_for_one_output,
)
.unwrap();
let params = ChannelParameters::new_with_secret_key(
sender_pubkey,
receiver_pubkey,
"local".to_string(),
CurrencyUnit::Sat,
capacity,
funding_token_amount,
0,
0,
keyset_info,
maximum_amount_for_one_output,
&alice_secret,
)
.unwrap();
let channel = EstablishedChannel {
params,
funding_proofs: vec![],
};
SpilmanChannelSender::new(alice_secret, channel)
}
#[tokio::test]
async fn test_restore_sender_proofs_max_amount_zero_means_no_filtering() {
let sender = create_test_sender(0);
let mint = RecordingMintConnection::new();
let proofs = sender.restore_sender_proofs(&mint).await.unwrap();
assert!(proofs.is_empty(), "mock restore returns no proofs");
assert_eq!(mint.attempted_amounts(), vec![1, 2, 4, 8]);
}
#[tokio::test]
async fn test_restore_sender_proofs_respects_nonzero_max_amount() {
let sender = create_test_sender(4);
let mint = RecordingMintConnection::new();
let proofs = sender.restore_sender_proofs(&mint).await.unwrap();
assert!(proofs.is_empty(), "mock restore returns no proofs");
assert_eq!(mint.attempted_amounts(), vec![1, 2, 4]);
}
#[tokio::test]
async fn test_restore_sender_proofs_uses_close_output_keyset() {
let sender = create_test_sender(4);
let mint = RecordingMintConnection::new();
let mut close_output_keyset = sender.channel.params.keyset_info.clone();
close_output_keyset.keyset_id = "00107937db0cc865".parse().unwrap();
let proofs = sender
.restore_sender_proofs_with_keyset(&mint, &close_output_keyset)
.await
.unwrap();
assert!(proofs.is_empty(), "mock restore returns no proofs");
assert_eq!(
mint.attempted_keysets(),
vec![close_output_keyset.keyset_id; 3]
);
}
#[tokio::test]
async fn test_restore_sender_proofs_propagates_mint_failures() {
let sender = create_test_sender(4);
let error = sender
.restore_sender_proofs(&FailingRestoreMintConnection)
.await
.expect_err("mint failures must remain retryable");
assert!(error.to_string().contains("restore transport failed"));
}
}