mirror of
https://github.com/aljazceru/cdk.git
synced 2026-01-28 09:16:19 +01:00
526 lines
18 KiB
Rust
526 lines
18 KiB
Rust
#![doc = include_str!("./README.md")]
|
|
|
|
use std::collections::HashMap;
|
|
use std::str::FromStr;
|
|
use std::sync::Arc;
|
|
|
|
use bitcoin::bip32::Xpriv;
|
|
use bitcoin::Network;
|
|
use client::HttpClientMethods;
|
|
use tracing::instrument;
|
|
|
|
use crate::amount::SplitTarget;
|
|
use crate::cdk_database::{self, WalletDatabase};
|
|
use crate::dhke::construct_proofs;
|
|
use crate::error::Error;
|
|
use crate::fees::calculate_fee;
|
|
use crate::mint_url::MintUrl;
|
|
use crate::nuts::nut00::token::Token;
|
|
use crate::nuts::{
|
|
nut10, CurrencyUnit, Id, Keys, MintInfo, MintQuoteState, PreMintSecrets, Proof, Proofs,
|
|
RestoreRequest, SpendingConditions, State,
|
|
};
|
|
use crate::types::ProofInfo;
|
|
use crate::{Amount, HttpClient};
|
|
|
|
mod balance;
|
|
pub mod client;
|
|
mod keysets;
|
|
mod melt;
|
|
mod mint;
|
|
pub mod multi_mint_wallet;
|
|
mod proofs;
|
|
mod receive;
|
|
mod send;
|
|
mod swap;
|
|
pub mod types;
|
|
pub mod util;
|
|
|
|
use crate::nuts::nut00::ProofsMethods;
|
|
pub use multi_mint_wallet::MultiMintWallet;
|
|
pub use types::{MeltQuote, MintQuote, SendKind};
|
|
|
|
/// CDK Wallet
|
|
///
|
|
/// The CDK [`Wallet`] is a high level cashu wallet.
|
|
///
|
|
/// A [`Wallet`] is for a single mint and single unit.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Wallet {
|
|
/// Mint Url
|
|
pub mint_url: MintUrl,
|
|
/// Unit
|
|
pub unit: CurrencyUnit,
|
|
/// Storage backend
|
|
pub localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync>,
|
|
/// The targeted amount of proofs to have at each size
|
|
pub target_proof_count: usize,
|
|
xpriv: Xpriv,
|
|
client: Arc<dyn HttpClientMethods + Send + Sync>,
|
|
}
|
|
|
|
impl Wallet {
|
|
/// Create new [`Wallet`]
|
|
/// # Synopsis
|
|
/// ```rust
|
|
/// use std::sync::Arc;
|
|
///
|
|
/// use cdk::cdk_database::WalletMemoryDatabase;
|
|
/// use cdk::nuts::CurrencyUnit;
|
|
/// use cdk::wallet::Wallet;
|
|
/// use rand::Rng;
|
|
///
|
|
/// let seed = rand::thread_rng().gen::<[u8; 32]>();
|
|
/// let mint_url = "https://testnut.cashu.space";
|
|
/// let unit = CurrencyUnit::Sat;
|
|
///
|
|
/// let localstore = WalletMemoryDatabase::default();
|
|
/// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None);
|
|
/// ```
|
|
pub fn new(
|
|
mint_url: &str,
|
|
unit: CurrencyUnit,
|
|
localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync>,
|
|
seed: &[u8],
|
|
target_proof_count: Option<usize>,
|
|
) -> Result<Self, Error> {
|
|
let xpriv = Xpriv::new_master(Network::Bitcoin, seed).expect("Could not create master key");
|
|
|
|
Ok(Self {
|
|
mint_url: MintUrl::from_str(mint_url)?,
|
|
unit,
|
|
client: Arc::new(HttpClient::new()),
|
|
localstore,
|
|
xpriv,
|
|
target_proof_count: target_proof_count.unwrap_or(3),
|
|
})
|
|
}
|
|
|
|
/// Change HTTP client
|
|
pub fn set_client<C: HttpClientMethods + 'static + Send + Sync>(&mut self, client: C) {
|
|
self.client = Arc::new(client);
|
|
}
|
|
|
|
/// Fee required for proof set
|
|
#[instrument(skip_all)]
|
|
pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result<Amount, Error> {
|
|
let mut proofs_per_keyset = HashMap::new();
|
|
let mut fee_per_keyset = HashMap::new();
|
|
|
|
for proof in proofs {
|
|
if let std::collections::hash_map::Entry::Vacant(e) =
|
|
fee_per_keyset.entry(proof.keyset_id)
|
|
{
|
|
let mint_keyset_info = self
|
|
.localstore
|
|
.get_keyset_by_id(&proof.keyset_id)
|
|
.await?
|
|
.ok_or(Error::UnknownKeySet)?;
|
|
e.insert(mint_keyset_info.input_fee_ppk);
|
|
}
|
|
|
|
proofs_per_keyset
|
|
.entry(proof.keyset_id)
|
|
.and_modify(|count| *count += 1)
|
|
.or_insert(1);
|
|
}
|
|
|
|
let fee = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;
|
|
|
|
Ok(fee)
|
|
}
|
|
|
|
/// Get fee for count of proofs in a keyset
|
|
#[instrument(skip_all)]
|
|
pub async fn get_keyset_count_fee(&self, keyset_id: &Id, count: u64) -> Result<Amount, Error> {
|
|
let input_fee_ppk = self
|
|
.localstore
|
|
.get_keyset_by_id(keyset_id)
|
|
.await?
|
|
.ok_or(Error::UnknownKeySet)?
|
|
.input_fee_ppk;
|
|
|
|
let fee = (input_fee_ppk * count + 999) / 1000;
|
|
|
|
Ok(Amount::from(fee))
|
|
}
|
|
|
|
/// Update Mint information and related entries in the event a mint changes
|
|
/// its URL
|
|
#[instrument(skip(self))]
|
|
pub async fn update_mint_url(&mut self, new_mint_url: MintUrl) -> Result<(), Error> {
|
|
self.mint_url = new_mint_url.clone();
|
|
// Where the mint_url is in the database it must be updated
|
|
self.localstore
|
|
.update_mint_url(self.mint_url.clone(), new_mint_url)
|
|
.await?;
|
|
|
|
self.localstore.remove_mint(self.mint_url.clone()).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Qeury mint for current mint information
|
|
#[instrument(skip(self))]
|
|
pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> {
|
|
let mint_info = match self.client.get_mint_info(self.mint_url.clone()).await {
|
|
Ok(mint_info) => Some(mint_info),
|
|
Err(err) => {
|
|
tracing::warn!("Could not get mint info {}", err);
|
|
None
|
|
}
|
|
};
|
|
|
|
self.localstore
|
|
.add_mint(self.mint_url.clone(), mint_info.clone())
|
|
.await?;
|
|
|
|
tracing::trace!("Mint info updated for {}", self.mint_url);
|
|
|
|
Ok(mint_info)
|
|
}
|
|
|
|
/// Get amounts needed to refill proof state
|
|
#[instrument(skip(self))]
|
|
pub async fn amounts_needed_for_state_target(&self) -> Result<Vec<Amount>, Error> {
|
|
let unspent_proofs = self.get_unspent_proofs().await?;
|
|
|
|
let amounts_count: HashMap<usize, usize> =
|
|
unspent_proofs
|
|
.iter()
|
|
.fold(HashMap::new(), |mut acc, proof| {
|
|
let amount = proof.amount;
|
|
let counter = acc.entry(u64::from(amount) as usize).or_insert(0);
|
|
*counter += 1;
|
|
acc
|
|
});
|
|
|
|
let all_possible_amounts: Vec<usize> = (0..32).map(|i| 2usize.pow(i as u32)).collect();
|
|
|
|
let needed_amounts = all_possible_amounts
|
|
.iter()
|
|
.fold(Vec::new(), |mut acc, amount| {
|
|
let count_needed: usize = self
|
|
.target_proof_count
|
|
.saturating_sub(*amounts_count.get(amount).unwrap_or(&0));
|
|
|
|
for _i in 0..count_needed {
|
|
acc.push(Amount::from(*amount as u64));
|
|
}
|
|
|
|
acc
|
|
});
|
|
Ok(needed_amounts)
|
|
}
|
|
|
|
/// Determine [`SplitTarget`] for amount based on state
|
|
#[instrument(skip(self))]
|
|
async fn determine_split_target_values(
|
|
&self,
|
|
change_amount: Amount,
|
|
) -> Result<SplitTarget, Error> {
|
|
let mut amounts_needed_refill = self.amounts_needed_for_state_target().await?;
|
|
|
|
amounts_needed_refill.sort();
|
|
|
|
let mut values = Vec::new();
|
|
|
|
for amount in amounts_needed_refill {
|
|
let values_sum = Amount::try_sum(values.clone().into_iter())?;
|
|
if values_sum + amount <= change_amount {
|
|
values.push(amount);
|
|
}
|
|
}
|
|
|
|
Ok(SplitTarget::Values(values))
|
|
}
|
|
|
|
/// Restore
|
|
#[instrument(skip(self))]
|
|
pub async fn restore(&self) -> Result<Amount, Error> {
|
|
// Check that mint is in store of mints
|
|
if self
|
|
.localstore
|
|
.get_mint(self.mint_url.clone())
|
|
.await?
|
|
.is_none()
|
|
{
|
|
self.get_mint_info().await?;
|
|
}
|
|
|
|
let keysets = self.get_mint_keysets().await?;
|
|
|
|
let mut restored_value = Amount::ZERO;
|
|
|
|
for keyset in keysets {
|
|
let keys = self.get_keyset_keys(keyset.id).await?;
|
|
let mut empty_batch = 0;
|
|
let mut start_counter = 0;
|
|
|
|
while empty_batch.lt(&3) {
|
|
let premint_secrets = PreMintSecrets::restore_batch(
|
|
keyset.id,
|
|
self.xpriv,
|
|
start_counter,
|
|
start_counter + 100,
|
|
)?;
|
|
|
|
tracing::debug!(
|
|
"Attempting to restore counter {}-{} for mint {} keyset {}",
|
|
start_counter,
|
|
start_counter + 100,
|
|
self.mint_url,
|
|
keyset.id
|
|
);
|
|
|
|
let restore_request = RestoreRequest {
|
|
outputs: premint_secrets.blinded_messages(),
|
|
};
|
|
|
|
let response = self
|
|
.client
|
|
.post_restore(self.mint_url.clone(), restore_request)
|
|
.await?;
|
|
|
|
if response.signatures.is_empty() {
|
|
empty_batch += 1;
|
|
start_counter += 100;
|
|
continue;
|
|
}
|
|
|
|
let premint_secrets: Vec<_> = premint_secrets
|
|
.secrets
|
|
.iter()
|
|
.filter(|p| response.outputs.contains(&p.blinded_message))
|
|
.collect();
|
|
|
|
let premint_secrets: Vec<_> = premint_secrets
|
|
.iter()
|
|
.filter(|p| response.outputs.contains(&p.blinded_message))
|
|
.collect();
|
|
|
|
// the response outputs and premint secrets should be the same after filtering
|
|
// blinded messages the mint did not have signatures for
|
|
assert_eq!(response.outputs.len(), premint_secrets.len());
|
|
|
|
let proofs = construct_proofs(
|
|
response.signatures,
|
|
premint_secrets.iter().map(|p| p.r.clone()).collect(),
|
|
premint_secrets.iter().map(|p| p.secret.clone()).collect(),
|
|
&keys,
|
|
)?;
|
|
|
|
tracing::debug!("Restored {} proofs", proofs.len());
|
|
|
|
self.localstore
|
|
.increment_keyset_counter(&keyset.id, proofs.len() as u32)
|
|
.await?;
|
|
|
|
let states = self.check_proofs_spent(proofs.clone()).await?;
|
|
|
|
let unspent_proofs: Vec<Proof> = proofs
|
|
.iter()
|
|
.zip(states)
|
|
.filter(|(_, state)| !state.state.eq(&State::Spent))
|
|
.map(|(p, _)| p)
|
|
.cloned()
|
|
.collect();
|
|
|
|
restored_value += unspent_proofs.total_amount()?;
|
|
|
|
let unspent_proofs = unspent_proofs
|
|
.into_iter()
|
|
.map(|proof| {
|
|
ProofInfo::new(
|
|
proof,
|
|
self.mint_url.clone(),
|
|
State::Unspent,
|
|
keyset.unit.clone(),
|
|
)
|
|
})
|
|
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
|
|
|
self.localstore
|
|
.update_proofs(unspent_proofs, vec![])
|
|
.await?;
|
|
|
|
empty_batch = 0;
|
|
start_counter += 100;
|
|
}
|
|
}
|
|
Ok(restored_value)
|
|
}
|
|
|
|
/// Verify all proofs in token have meet the required spend
|
|
/// Can be used to allow a wallet to accept payments offline while reducing
|
|
/// the risk of claiming back to the limits let by the spending_conditions
|
|
#[instrument(skip(self, token))]
|
|
pub fn verify_token_p2pk(
|
|
&self,
|
|
token: &Token,
|
|
spending_conditions: SpendingConditions,
|
|
) -> Result<(), Error> {
|
|
let (refund_keys, pubkeys, locktime, num_sigs) = match spending_conditions {
|
|
SpendingConditions::P2PKConditions { data, conditions } => {
|
|
let mut pubkeys = vec![data];
|
|
|
|
match conditions {
|
|
Some(conditions) => {
|
|
pubkeys.extend(conditions.pubkeys.unwrap_or_default());
|
|
|
|
(
|
|
conditions.refund_keys,
|
|
Some(pubkeys),
|
|
conditions.locktime,
|
|
conditions.num_sigs,
|
|
)
|
|
}
|
|
None => (None, Some(pubkeys), None, None),
|
|
}
|
|
}
|
|
SpendingConditions::HTLCConditions {
|
|
conditions,
|
|
data: _,
|
|
} => match conditions {
|
|
Some(conditions) => (
|
|
conditions.refund_keys,
|
|
conditions.pubkeys,
|
|
conditions.locktime,
|
|
conditions.num_sigs,
|
|
),
|
|
None => (None, None, None, None),
|
|
},
|
|
};
|
|
|
|
if refund_keys.is_some() && locktime.is_none() {
|
|
tracing::warn!(
|
|
"Invalid spending conditions set: Locktime must be set if refund keys are allowed"
|
|
);
|
|
return Err(Error::InvalidSpendConditions(
|
|
"Must set locktime".to_string(),
|
|
));
|
|
}
|
|
if token.mint_url()? != self.mint_url {
|
|
return Err(Error::IncorrectWallet(format!(
|
|
"Should be {} not {}",
|
|
self.mint_url,
|
|
token.mint_url()?
|
|
)));
|
|
}
|
|
|
|
let proofs = token.proofs();
|
|
for proof in proofs {
|
|
let secret: nut10::Secret = (&proof.secret).try_into()?;
|
|
|
|
let proof_conditions: SpendingConditions = secret.try_into()?;
|
|
|
|
if num_sigs.ne(&proof_conditions.num_sigs()) {
|
|
tracing::debug!(
|
|
"Spending condition requires: {:?} sigs proof secret specifies: {:?}",
|
|
num_sigs,
|
|
proof_conditions.num_sigs()
|
|
);
|
|
|
|
return Err(Error::P2PKConditionsNotMet(
|
|
"Num sigs did not match spending condition".to_string(),
|
|
));
|
|
}
|
|
|
|
let spending_condition_pubkeys = pubkeys.clone().unwrap_or_default();
|
|
let proof_pubkeys = proof_conditions.pubkeys().unwrap_or_default();
|
|
|
|
// Check the Proof has the required pubkeys
|
|
if proof_pubkeys.len().ne(&spending_condition_pubkeys.len())
|
|
|| !proof_pubkeys
|
|
.iter()
|
|
.all(|pubkey| spending_condition_pubkeys.contains(pubkey))
|
|
{
|
|
tracing::debug!("Proof did not included Publickeys meeting condition");
|
|
tracing::debug!("{:?}", proof_pubkeys);
|
|
tracing::debug!("{:?}", spending_condition_pubkeys);
|
|
return Err(Error::P2PKConditionsNotMet(
|
|
"Pubkeys in proof not allowed by spending condition".to_string(),
|
|
));
|
|
}
|
|
|
|
// If spending condition refund keys is allowed (Some(Empty Vec))
|
|
// If spending conition refund keys is allowed to restricted set of keys check
|
|
// it is one of them Check that proof locktime is > condition
|
|
// locktime
|
|
|
|
if let Some(proof_refund_keys) = proof_conditions.refund_keys() {
|
|
let proof_locktime = proof_conditions
|
|
.locktime()
|
|
.ok_or(Error::LocktimeNotProvided)?;
|
|
|
|
if let (Some(condition_refund_keys), Some(condition_locktime)) =
|
|
(&refund_keys, locktime)
|
|
{
|
|
// Proof locktime must be greater then condition locktime to ensure it
|
|
// cannot be claimed back
|
|
if proof_locktime.lt(&condition_locktime) {
|
|
return Err(Error::P2PKConditionsNotMet(
|
|
"Proof locktime less then required".to_string(),
|
|
));
|
|
}
|
|
|
|
// A non empty condition refund key list is used as a restricted set of keys
|
|
// returns are allowed to An empty list means the
|
|
// proof can be refunded to anykey set in the secret
|
|
if !condition_refund_keys.is_empty()
|
|
&& !proof_refund_keys
|
|
.iter()
|
|
.all(|refund_key| condition_refund_keys.contains(refund_key))
|
|
{
|
|
return Err(Error::P2PKConditionsNotMet(
|
|
"Refund Key not allowed".to_string(),
|
|
));
|
|
}
|
|
} else {
|
|
// Spending conditions does not allow refund keys
|
|
return Err(Error::P2PKConditionsNotMet(
|
|
"Spending condition does not allow refund keys".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify all proofs in token have a valid DLEQ proof
|
|
#[instrument(skip(self, token))]
|
|
pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> {
|
|
let mut keys_cache: HashMap<Id, Keys> = HashMap::new();
|
|
|
|
// TODO: Get mint url
|
|
// if mint_url != &self.mint_url {
|
|
// return Err(Error::IncorrectWallet(format!(
|
|
// "Should be {} not {}",
|
|
// self.mint_url, mint_url
|
|
// )));
|
|
// }
|
|
|
|
let proofs = token.proofs();
|
|
for proof in proofs {
|
|
let mint_pubkey = match keys_cache.get(&proof.keyset_id) {
|
|
Some(keys) => keys.amount_key(proof.amount),
|
|
None => {
|
|
let keys = self.get_keyset_keys(proof.keyset_id).await?;
|
|
|
|
let key = keys.amount_key(proof.amount);
|
|
keys_cache.insert(proof.keyset_id, keys);
|
|
|
|
key
|
|
}
|
|
}
|
|
.ok_or(Error::AmountKey)?;
|
|
|
|
proof
|
|
.verify_dleq(mint_pubkey)
|
|
.map_err(|_| Error::CouldNotVerifyDleq)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|