mirror of
https://github.com/aljazceru/cdk.git
synced 2026-01-04 05:25:26 +01:00
refactor: wallet into multiple mods
This commit is contained in:
69
crates/cdk/src/wallet/balance.rs
Normal file
69
crates/cdk/src/wallet/balance.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
nuts::{CurrencyUnit, State},
|
||||
Amount, Error, Wallet,
|
||||
};
|
||||
|
||||
impl Wallet {
|
||||
/// Total unspent balance of wallet
|
||||
#[instrument(skip(self))]
|
||||
pub async fn total_balance(&self) -> Result<Amount, Error> {
|
||||
let proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Unspent]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let balance = Amount::try_sum(proofs.iter().map(|p| p.proof.amount))?;
|
||||
|
||||
Ok(balance)
|
||||
}
|
||||
|
||||
/// Total pending balance
|
||||
#[instrument(skip(self))]
|
||||
pub async fn total_pending_balance(&self) -> Result<HashMap<CurrencyUnit, Amount>, Error> {
|
||||
let proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Pending]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| {
|
||||
*acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount;
|
||||
acc
|
||||
});
|
||||
|
||||
Ok(balances)
|
||||
}
|
||||
|
||||
/// Total reserved balance
|
||||
#[instrument(skip(self))]
|
||||
pub async fn total_reserved_balance(&self) -> Result<HashMap<CurrencyUnit, Amount>, Error> {
|
||||
let proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Reserved]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| {
|
||||
*acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount;
|
||||
acc
|
||||
});
|
||||
|
||||
Ok(balances)
|
||||
}
|
||||
}
|
||||
95
crates/cdk/src/wallet/keysets.rs
Normal file
95
crates/cdk/src/wallet/keysets.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::nuts::Id;
|
||||
use crate::nuts::KeySetInfo;
|
||||
use crate::nuts::Keys;
|
||||
use crate::Error;
|
||||
use crate::Wallet;
|
||||
|
||||
impl Wallet {
|
||||
/// Get keys for mint keyset
|
||||
///
|
||||
/// Selected keys from localstore if they are already known
|
||||
/// If they are not known queries mint for keyset id and stores the [`Keys`]
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
|
||||
let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
|
||||
keys
|
||||
} else {
|
||||
let keys = self
|
||||
.client
|
||||
.get_mint_keyset(self.mint_url.clone().try_into()?, keyset_id)
|
||||
.await?;
|
||||
|
||||
self.localstore.add_keys(keys.keys.clone()).await?;
|
||||
|
||||
keys.keys
|
||||
};
|
||||
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Get keysets for mint
|
||||
///
|
||||
/// Queries mint for all keysets
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
|
||||
let keysets = self
|
||||
.client
|
||||
.get_mint_keysets(self.mint_url.clone().try_into()?)
|
||||
.await?;
|
||||
|
||||
self.localstore
|
||||
.add_mint_keysets(self.mint_url.clone(), keysets.keysets.clone())
|
||||
.await?;
|
||||
|
||||
Ok(keysets.keysets)
|
||||
}
|
||||
|
||||
/// Get active keyset for mint
|
||||
///
|
||||
/// Queries mint for current keysets then gets [`Keys`] for any unknown
|
||||
/// keysets
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_active_mint_keyset(&self) -> Result<KeySetInfo, Error> {
|
||||
let keysets = self
|
||||
.client
|
||||
.get_mint_keysets(self.mint_url.clone().try_into()?)
|
||||
.await?;
|
||||
let keysets = keysets.keysets;
|
||||
|
||||
self.localstore
|
||||
.add_mint_keysets(self.mint_url.clone(), keysets.clone())
|
||||
.await?;
|
||||
|
||||
let active_keysets = keysets
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|k| k.active && k.unit == self.unit)
|
||||
.collect::<Vec<KeySetInfo>>();
|
||||
|
||||
match self
|
||||
.localstore
|
||||
.get_mint_keysets(self.mint_url.clone())
|
||||
.await?
|
||||
{
|
||||
Some(known_keysets) => {
|
||||
let unknown_keysets: Vec<&KeySetInfo> = keysets
|
||||
.iter()
|
||||
.filter(|k| known_keysets.contains(k))
|
||||
.collect();
|
||||
|
||||
for keyset in unknown_keysets {
|
||||
self.get_keyset_keys(keyset.id).await?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for keyset in keysets {
|
||||
self.get_keyset_keys(keyset.id).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
active_keysets.first().ok_or(Error::NoActiveKeyset).cloned()
|
||||
}
|
||||
}
|
||||
310
crates/cdk/src/wallet/melt.rs
Normal file
310
crates/cdk/src/wallet/melt.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use lightning_invoice::Bolt11Invoice;
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
dhke::construct_proofs,
|
||||
nuts::{
|
||||
CurrencyUnit, MeltQuoteBolt11Response, MeltQuoteState, PreMintSecrets, Proofs, PublicKey,
|
||||
State,
|
||||
},
|
||||
types::{Melted, ProofInfo},
|
||||
util::unix_time,
|
||||
Amount, Error, Wallet,
|
||||
};
|
||||
|
||||
use super::MeltQuote;
|
||||
|
||||
impl Wallet {
|
||||
/// Melt Quote
|
||||
/// # Synopsis
|
||||
/// ```rust
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// use cdk::cdk_database::WalletMemoryDatabase;
|
||||
/// use cdk::nuts::CurrencyUnit;
|
||||
/// use cdk::wallet::Wallet;
|
||||
/// use rand::Rng;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// 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).unwrap();
|
||||
/// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string();
|
||||
/// let quote = wallet.melt_quote(bolt11, None).await?;
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[instrument(skip(self, request))]
|
||||
pub async fn melt_quote(
|
||||
&self,
|
||||
request: String,
|
||||
mpp: Option<Amount>,
|
||||
) -> Result<MeltQuote, Error> {
|
||||
let invoice = Bolt11Invoice::from_str(&request)?;
|
||||
|
||||
let request_amount = invoice
|
||||
.amount_milli_satoshis()
|
||||
.ok_or(Error::InvoiceAmountUndefined)?;
|
||||
|
||||
let amount = match self.unit {
|
||||
CurrencyUnit::Sat => Amount::from(request_amount / 1000),
|
||||
CurrencyUnit::Msat => Amount::from(request_amount),
|
||||
_ => return Err(Error::UnitUnsupported),
|
||||
};
|
||||
|
||||
let quote_res = self
|
||||
.client
|
||||
.post_melt_quote(self.mint_url.clone().try_into()?, self.unit, invoice, mpp)
|
||||
.await?;
|
||||
|
||||
if quote_res.amount != amount {
|
||||
return Err(Error::IncorrectQuoteAmount);
|
||||
}
|
||||
|
||||
let quote = MeltQuote {
|
||||
id: quote_res.quote,
|
||||
amount,
|
||||
request,
|
||||
unit: self.unit,
|
||||
fee_reserve: quote_res.fee_reserve,
|
||||
state: quote_res.state,
|
||||
expiry: quote_res.expiry,
|
||||
payment_preimage: quote_res.payment_preimage,
|
||||
};
|
||||
|
||||
self.localstore.add_melt_quote(quote.clone()).await?;
|
||||
|
||||
Ok(quote)
|
||||
}
|
||||
|
||||
/// Melt quote status
|
||||
#[instrument(skip(self, quote_id))]
|
||||
pub async fn melt_quote_status(
|
||||
&self,
|
||||
quote_id: &str,
|
||||
) -> Result<MeltQuoteBolt11Response, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get_melt_quote_status(self.mint_url.clone().try_into()?, quote_id)
|
||||
.await?;
|
||||
|
||||
match self.localstore.get_melt_quote(quote_id).await? {
|
||||
Some(quote) => {
|
||||
let mut quote = quote;
|
||||
|
||||
quote.state = response.state;
|
||||
self.localstore.add_melt_quote(quote).await?;
|
||||
}
|
||||
None => {
|
||||
tracing::info!("Quote melt {} unknown", quote_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Melt specific proofs
|
||||
#[instrument(skip(self, proofs))]
|
||||
pub async fn melt_proofs(&self, quote_id: &str, proofs: Proofs) -> Result<Melted, Error> {
|
||||
let quote_info = self.localstore.get_melt_quote(quote_id).await?;
|
||||
let quote_info = if let Some(quote) = quote_info {
|
||||
if quote.expiry.le(&unix_time()) {
|
||||
return Err(Error::ExpiredQuote(quote.expiry, unix_time()));
|
||||
}
|
||||
|
||||
quote.clone()
|
||||
} else {
|
||||
return Err(Error::UnknownQuote);
|
||||
};
|
||||
|
||||
let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?;
|
||||
if proofs_total < quote_info.amount + quote_info.fee_reserve {
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
|
||||
let ys = proofs
|
||||
.iter()
|
||||
.map(|p| p.y())
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?;
|
||||
self.localstore.set_pending_proofs(ys).await?;
|
||||
|
||||
let active_keyset_id = self.get_active_mint_keyset().await?.id;
|
||||
|
||||
let count = self
|
||||
.localstore
|
||||
.get_keyset_counter(&active_keyset_id)
|
||||
.await?;
|
||||
|
||||
let count = count.map_or(0, |c| c + 1);
|
||||
|
||||
let premint_secrets = PreMintSecrets::from_xpriv_blank(
|
||||
active_keyset_id,
|
||||
count,
|
||||
self.xpriv,
|
||||
proofs_total - quote_info.amount,
|
||||
)?;
|
||||
|
||||
let melt_response = self
|
||||
.client
|
||||
.post_melt(
|
||||
self.mint_url.clone().try_into()?,
|
||||
quote_id.to_string(),
|
||||
proofs.clone(),
|
||||
Some(premint_secrets.blinded_messages()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let melt_response = match melt_response {
|
||||
Ok(melt_response) => melt_response,
|
||||
Err(err) => {
|
||||
tracing::error!("Could not melt: {}", err);
|
||||
tracing::info!("Checking status of input proofs.");
|
||||
|
||||
self.reclaim_unspent(proofs).await?;
|
||||
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
|
||||
let active_keys = self
|
||||
.localstore
|
||||
.get_keys(&active_keyset_id)
|
||||
.await?
|
||||
.ok_or(Error::NoActiveKeyset)?;
|
||||
|
||||
let change_proofs = match melt_response.change {
|
||||
Some(change) => {
|
||||
let num_change_proof = change.len();
|
||||
|
||||
let num_change_proof = match (
|
||||
premint_secrets.len() < num_change_proof,
|
||||
premint_secrets.secrets().len() < num_change_proof,
|
||||
) {
|
||||
(true, _) | (_, true) => {
|
||||
tracing::error!("Mismatch in change promises to change");
|
||||
premint_secrets.len()
|
||||
}
|
||||
_ => num_change_proof,
|
||||
};
|
||||
|
||||
Some(construct_proofs(
|
||||
change,
|
||||
premint_secrets.rs()[..num_change_proof].to_vec(),
|
||||
premint_secrets.secrets()[..num_change_proof].to_vec(),
|
||||
&active_keys,
|
||||
)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let state = match melt_response.paid {
|
||||
true => MeltQuoteState::Paid,
|
||||
false => MeltQuoteState::Unpaid,
|
||||
};
|
||||
|
||||
let melted = Melted::from_proofs(
|
||||
state,
|
||||
melt_response.payment_preimage,
|
||||
quote_info.amount,
|
||||
proofs.clone(),
|
||||
change_proofs.clone(),
|
||||
)?;
|
||||
|
||||
let change_proof_infos = match change_proofs {
|
||||
Some(change_proofs) => {
|
||||
tracing::debug!(
|
||||
"Change amount returned from melt: {}",
|
||||
Amount::try_sum(change_proofs.iter().map(|p| p.amount))?
|
||||
);
|
||||
|
||||
// Update counter for keyset
|
||||
self.localstore
|
||||
.increment_keyset_counter(&active_keyset_id, change_proofs.len() as u32)
|
||||
.await?;
|
||||
|
||||
change_proofs
|
||||
.into_iter()
|
||||
.map(|proof| {
|
||||
ProofInfo::new(
|
||||
proof,
|
||||
self.mint_url.clone(),
|
||||
State::Unspent,
|
||||
quote_info.unit,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
self.localstore.remove_melt_quote("e_info.id).await?;
|
||||
|
||||
let deleted_ys = proofs
|
||||
.iter()
|
||||
.map(|p| p.y())
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?;
|
||||
self.localstore
|
||||
.update_proofs(change_proof_infos, deleted_ys)
|
||||
.await?;
|
||||
|
||||
Ok(melted)
|
||||
}
|
||||
|
||||
/// Melt
|
||||
/// # Synopsis
|
||||
/// ```rust, no_run
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// use cdk::cdk_database::WalletMemoryDatabase;
|
||||
/// use cdk::nuts::CurrencyUnit;
|
||||
/// use cdk::wallet::Wallet;
|
||||
/// use rand::Rng;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// 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).unwrap();
|
||||
/// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string();
|
||||
/// let quote = wallet.melt_quote(bolt11, None).await?;
|
||||
/// let quote_id = quote.id;
|
||||
///
|
||||
/// let _ = wallet.melt("e_id).await?;
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
#[instrument(skip(self))]
|
||||
pub async fn melt(&self, quote_id: &str) -> Result<Melted, Error> {
|
||||
let quote_info = self.localstore.get_melt_quote(quote_id).await?;
|
||||
|
||||
let quote_info = if let Some(quote) = quote_info {
|
||||
if quote.expiry.le(&unix_time()) {
|
||||
return Err(Error::ExpiredQuote(quote.expiry, unix_time()));
|
||||
}
|
||||
|
||||
quote.clone()
|
||||
} else {
|
||||
return Err(Error::UnknownQuote);
|
||||
};
|
||||
|
||||
let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve;
|
||||
|
||||
let available_proofs = self.get_proofs().await?;
|
||||
|
||||
let input_proofs = self
|
||||
.select_proofs_to_swap(inputs_needed_amount, available_proofs)
|
||||
.await?;
|
||||
|
||||
self.melt_proofs(quote_id, input_proofs).await
|
||||
}
|
||||
}
|
||||
274
crates/cdk/src/wallet/mint.rs
Normal file
274
crates/cdk/src/wallet/mint.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use tracing::instrument;
|
||||
|
||||
use super::MintQuote;
|
||||
use crate::{
|
||||
amount::SplitTarget,
|
||||
dhke::construct_proofs,
|
||||
nuts::{nut12, MintQuoteBolt11Response, PreMintSecrets, SpendingConditions, State},
|
||||
types::ProofInfo,
|
||||
util::unix_time,
|
||||
wallet::MintQuoteState,
|
||||
Amount, Error, Wallet,
|
||||
};
|
||||
|
||||
impl Wallet {
|
||||
/// Mint Quote
|
||||
/// # Synopsis
|
||||
/// ```rust
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// use cdk::amount::Amount;
|
||||
/// use cdk::cdk_database::WalletMemoryDatabase;
|
||||
/// use cdk::nuts::CurrencyUnit;
|
||||
/// use cdk::wallet::Wallet;
|
||||
/// use rand::Rng;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// 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)?;
|
||||
/// let amount = Amount::from(100);
|
||||
///
|
||||
/// let quote = wallet.mint_quote(amount, None).await?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[instrument(skip(self))]
|
||||
pub async fn mint_quote(
|
||||
&self,
|
||||
amount: Amount,
|
||||
description: Option<String>,
|
||||
) -> Result<MintQuote, Error> {
|
||||
let mint_url = self.mint_url.clone();
|
||||
let unit = self.unit;
|
||||
|
||||
// If we have a description, we check that the mint supports it.
|
||||
// If we have a description, we check that the mint supports it.
|
||||
if description.is_some() {
|
||||
let mint_method_settings = self
|
||||
.localstore
|
||||
.get_mint(mint_url.clone())
|
||||
.await?
|
||||
.ok_or(Error::IncorrectMint)?
|
||||
.nuts
|
||||
.nut04
|
||||
.get_settings(&unit, &crate::nuts::PaymentMethod::Bolt11)
|
||||
.ok_or(Error::UnsupportedUnit)?;
|
||||
|
||||
if !mint_method_settings.description {
|
||||
return Err(Error::InvoiceDescriptionUnsupported);
|
||||
}
|
||||
}
|
||||
|
||||
let quote_res = self
|
||||
.client
|
||||
.post_mint_quote(mint_url.clone().try_into()?, amount, unit, description)
|
||||
.await?;
|
||||
|
||||
let quote = MintQuote {
|
||||
mint_url,
|
||||
id: quote_res.quote.clone(),
|
||||
amount,
|
||||
unit,
|
||||
request: quote_res.request,
|
||||
state: quote_res.state,
|
||||
expiry: quote_res.expiry.unwrap_or(0),
|
||||
};
|
||||
|
||||
self.localstore.add_mint_quote(quote.clone()).await?;
|
||||
|
||||
Ok(quote)
|
||||
}
|
||||
|
||||
/// Check mint quote status
|
||||
#[instrument(skip(self, quote_id))]
|
||||
pub async fn mint_quote_state(&self, quote_id: &str) -> Result<MintQuoteBolt11Response, Error> {
|
||||
let response = self
|
||||
.client
|
||||
.get_mint_quote_status(self.mint_url.clone().try_into()?, quote_id)
|
||||
.await?;
|
||||
|
||||
match self.localstore.get_mint_quote(quote_id).await? {
|
||||
Some(quote) => {
|
||||
let mut quote = quote;
|
||||
|
||||
quote.state = response.state;
|
||||
self.localstore.add_mint_quote(quote).await?;
|
||||
}
|
||||
None => {
|
||||
tracing::info!("Quote mint {} unknown", quote_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Check status of pending mint quotes
|
||||
#[instrument(skip(self))]
|
||||
pub async fn check_all_mint_quotes(&self) -> Result<Amount, Error> {
|
||||
let mint_quotes = self.localstore.get_mint_quotes().await?;
|
||||
let mut total_amount = Amount::ZERO;
|
||||
|
||||
for mint_quote in mint_quotes {
|
||||
let mint_quote_response = self.mint_quote_state(&mint_quote.id).await?;
|
||||
|
||||
if mint_quote_response.state == MintQuoteState::Paid {
|
||||
let amount = self
|
||||
.mint(&mint_quote.id, SplitTarget::default(), None)
|
||||
.await?;
|
||||
total_amount += amount;
|
||||
} else if mint_quote.expiry.le(&unix_time()) {
|
||||
self.localstore.remove_mint_quote(&mint_quote.id).await?;
|
||||
}
|
||||
}
|
||||
Ok(total_amount)
|
||||
}
|
||||
|
||||
/// Mint
|
||||
/// # Synopsis
|
||||
/// ```rust
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// use anyhow::Result;
|
||||
/// use cdk::amount::{Amount, SplitTarget};
|
||||
/// use cdk::cdk_database::WalletMemoryDatabase;
|
||||
/// use cdk::nuts::CurrencyUnit;
|
||||
/// use cdk::wallet::Wallet;
|
||||
/// use rand::Rng;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> Result<()> {
|
||||
/// 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).unwrap();
|
||||
/// let amount = Amount::from(100);
|
||||
///
|
||||
/// let quote = wallet.mint_quote(amount, None).await?;
|
||||
/// let quote_id = quote.id;
|
||||
/// // To be called after quote request is paid
|
||||
/// let amount_minted = wallet.mint("e_id, SplitTarget::default(), None).await?;
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[instrument(skip(self))]
|
||||
pub async fn mint(
|
||||
&self,
|
||||
quote_id: &str,
|
||||
amount_split_target: SplitTarget,
|
||||
spending_conditions: Option<SpendingConditions>,
|
||||
) -> 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 quote_info = self.localstore.get_mint_quote(quote_id).await?;
|
||||
|
||||
let quote_info = if let Some(quote) = quote_info {
|
||||
if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) {
|
||||
return Err(Error::ExpiredQuote(quote.expiry, unix_time()));
|
||||
}
|
||||
|
||||
quote.clone()
|
||||
} else {
|
||||
return Err(Error::UnknownQuote);
|
||||
};
|
||||
|
||||
let active_keyset_id = self.get_active_mint_keyset().await?.id;
|
||||
|
||||
let count = self
|
||||
.localstore
|
||||
.get_keyset_counter(&active_keyset_id)
|
||||
.await?;
|
||||
|
||||
let count = count.map_or(0, |c| c + 1);
|
||||
|
||||
let premint_secrets = match &spending_conditions {
|
||||
Some(spending_conditions) => PreMintSecrets::with_conditions(
|
||||
active_keyset_id,
|
||||
quote_info.amount,
|
||||
&amount_split_target,
|
||||
spending_conditions,
|
||||
)?,
|
||||
None => PreMintSecrets::from_xpriv(
|
||||
active_keyset_id,
|
||||
count,
|
||||
self.xpriv,
|
||||
quote_info.amount,
|
||||
&amount_split_target,
|
||||
)?,
|
||||
};
|
||||
|
||||
let mint_res = self
|
||||
.client
|
||||
.post_mint(
|
||||
self.mint_url.clone().try_into()?,
|
||||
quote_id,
|
||||
premint_secrets.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let keys = self.get_keyset_keys(active_keyset_id).await?;
|
||||
|
||||
// Verify the signature DLEQ is valid
|
||||
{
|
||||
for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) {
|
||||
let keys = self.get_keyset_keys(sig.keyset_id).await?;
|
||||
let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?;
|
||||
match sig.verify_dleq(key, premint.blinded_message.blinded_secret) {
|
||||
Ok(_) | Err(nut12::Error::MissingDleqProof) => (),
|
||||
Err(_) => return Err(Error::CouldNotVerifyDleq),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let proofs = construct_proofs(
|
||||
mint_res.signatures,
|
||||
premint_secrets.rs(),
|
||||
premint_secrets.secrets(),
|
||||
&keys,
|
||||
)?;
|
||||
|
||||
let minted_amount = Amount::try_sum(proofs.iter().map(|p| p.amount))?;
|
||||
|
||||
// Remove filled quote from store
|
||||
self.localstore.remove_mint_quote("e_info.id).await?;
|
||||
|
||||
if spending_conditions.is_none() {
|
||||
// Update counter for keyset
|
||||
self.localstore
|
||||
.increment_keyset_counter(&active_keyset_id, proofs.len() as u32)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let proofs = proofs
|
||||
.into_iter()
|
||||
.map(|proof| {
|
||||
ProofInfo::new(
|
||||
proof,
|
||||
self.mint_url.clone(),
|
||||
State::Unspent,
|
||||
quote_info.unit,
|
||||
)
|
||||
})
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
||||
|
||||
// Add new proofs to store
|
||||
self.localstore.update_proofs(proofs, vec![]).await?;
|
||||
|
||||
Ok(minted_amount)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
277
crates/cdk/src/wallet/proofs.rs
Normal file
277
crates/cdk/src/wallet/proofs.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
amount::SplitTarget,
|
||||
dhke::hash_to_curve,
|
||||
nuts::{Proof, ProofState, Proofs, PublicKey, State},
|
||||
types::ProofInfo,
|
||||
Amount, Error, Wallet,
|
||||
};
|
||||
|
||||
impl Wallet {
|
||||
/// Get unspent proofs for mint
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_proofs(&self) -> Result<Proofs, Error> {
|
||||
Ok(self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Unspent]),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| p.proof)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get pending [`Proofs`]
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_pending_proofs(&self) -> Result<Proofs, Error> {
|
||||
Ok(self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Pending]),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| p.proof)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Get reserved [`Proofs`]
|
||||
#[instrument(skip(self))]
|
||||
pub async fn get_reserved_proofs(&self) -> Result<Proofs, Error> {
|
||||
Ok(self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Reserved]),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|p| p.proof)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Return proofs to unspent allowing them to be selected and spent
|
||||
#[instrument(skip(self))]
|
||||
pub async fn unreserve_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Error> {
|
||||
Ok(self.localstore.set_unspent_proofs(ys).await?)
|
||||
}
|
||||
|
||||
/// Reclaim unspent proofs
|
||||
///
|
||||
/// Checks the stats of [`Proofs`] swapping for a new [`Proof`] if unspent
|
||||
#[instrument(skip(self, proofs))]
|
||||
pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), Error> {
|
||||
let proof_ys = proofs
|
||||
.iter()
|
||||
// Find Y for the secret
|
||||
.map(|p| hash_to_curve(p.secret.as_bytes()))
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?;
|
||||
|
||||
let spendable = self
|
||||
.client
|
||||
.post_check_state(self.mint_url.clone().try_into()?, proof_ys)
|
||||
.await?
|
||||
.states;
|
||||
|
||||
let unspent: Proofs = proofs
|
||||
.into_iter()
|
||||
.zip(spendable)
|
||||
.filter_map(|(p, s)| (s.state == State::Unspent).then_some(p))
|
||||
.collect();
|
||||
|
||||
self.swap(None, SplitTarget::default(), unspent, None, false)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// NUT-07 Check the state of a [`Proof`] with the mint
|
||||
#[instrument(skip(self, proofs))]
|
||||
pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result<Vec<ProofState>, Error> {
|
||||
let spendable = self
|
||||
.client
|
||||
.post_check_state(
|
||||
self.mint_url.clone().try_into()?,
|
||||
proofs
|
||||
.iter()
|
||||
// Find Y for the secret
|
||||
.map(|p| hash_to_curve(p.secret.as_bytes()))
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(spendable.states)
|
||||
}
|
||||
|
||||
/// Checks pending proofs for spent status
|
||||
#[instrument(skip(self))]
|
||||
pub async fn check_all_pending_proofs(&self) -> Result<Amount, Error> {
|
||||
let mut balance = Amount::ZERO;
|
||||
|
||||
let proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Pending, State::Reserved]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if proofs.is_empty() {
|
||||
return Ok(Amount::ZERO);
|
||||
}
|
||||
|
||||
let states = self
|
||||
.check_proofs_spent(proofs.clone().into_iter().map(|p| p.proof).collect())
|
||||
.await?;
|
||||
|
||||
// Both `State::Pending` and `State::Unspent` should be included in the pending
|
||||
// table. This is because a proof that has been crated to send will be
|
||||
// stored in the pending table in order to avoid accidentally double
|
||||
// spending but to allow it to be explicitly reclaimed
|
||||
let pending_states: HashSet<PublicKey> = states
|
||||
.into_iter()
|
||||
.filter(|s| s.state.ne(&State::Spent))
|
||||
.map(|s| s.y)
|
||||
.collect();
|
||||
|
||||
let (pending_proofs, non_pending_proofs): (Vec<ProofInfo>, Vec<ProofInfo>) = proofs
|
||||
.into_iter()
|
||||
.partition(|p| pending_states.contains(&p.y));
|
||||
|
||||
let amount = Amount::try_sum(pending_proofs.iter().map(|p| p.proof.amount))?;
|
||||
|
||||
self.localstore
|
||||
.update_proofs(
|
||||
vec![],
|
||||
non_pending_proofs.into_iter().map(|p| p.y).collect(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
balance += amount;
|
||||
|
||||
Ok(balance)
|
||||
}
|
||||
|
||||
/// Select proofs to send
|
||||
#[instrument(skip_all)]
|
||||
pub async fn select_proofs_to_send(
|
||||
&self,
|
||||
amount: Amount,
|
||||
proofs: Proofs,
|
||||
include_fees: bool,
|
||||
) -> Result<Proofs, Error> {
|
||||
// TODO: Check all proofs are same unit
|
||||
|
||||
if Amount::try_sum(proofs.iter().map(|p| p.amount))? < amount {
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
|
||||
let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) =
|
||||
proofs.into_iter().partition(|p| p.amount > amount);
|
||||
|
||||
let next_bigger_proof = proofs_larger.first().cloned();
|
||||
|
||||
let mut selected_proofs: Proofs = Vec::new();
|
||||
let mut remaining_amount = amount;
|
||||
|
||||
while remaining_amount > Amount::ZERO {
|
||||
proofs_larger.sort();
|
||||
// Sort smaller proofs in descending order
|
||||
proofs_smaller.sort_by(|a: &Proof, b: &Proof| b.cmp(a));
|
||||
|
||||
let selected_proof = if let Some(next_small) = proofs_smaller.clone().first() {
|
||||
next_small.clone()
|
||||
} else if let Some(next_bigger) = proofs_larger.first() {
|
||||
next_bigger.clone()
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
|
||||
let proof_amount = selected_proof.amount;
|
||||
|
||||
selected_proofs.push(selected_proof);
|
||||
|
||||
let fees = match include_fees {
|
||||
true => self.get_proofs_fee(&selected_proofs).await?,
|
||||
false => Amount::ZERO,
|
||||
};
|
||||
|
||||
if proof_amount >= remaining_amount + fees {
|
||||
remaining_amount = Amount::ZERO;
|
||||
break;
|
||||
}
|
||||
|
||||
remaining_amount = amount.checked_add(fees).ok_or(Error::AmountOverflow)?
|
||||
- Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
(proofs_larger, proofs_smaller) = proofs_smaller
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.partition(|p| p.amount > remaining_amount);
|
||||
}
|
||||
|
||||
if remaining_amount > Amount::ZERO {
|
||||
if let Some(next_bigger) = next_bigger_proof {
|
||||
return Ok(vec![next_bigger.clone()]);
|
||||
}
|
||||
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
|
||||
Ok(selected_proofs)
|
||||
}
|
||||
|
||||
/// Select proofs to send
|
||||
#[instrument(skip_all)]
|
||||
pub async fn select_proofs_to_swap(
|
||||
&self,
|
||||
amount: Amount,
|
||||
proofs: Proofs,
|
||||
) -> Result<Proofs, Error> {
|
||||
let active_keyset_id = self.get_active_mint_keyset().await?.id;
|
||||
|
||||
let (mut active_proofs, mut inactive_proofs): (Proofs, Proofs) = proofs
|
||||
.into_iter()
|
||||
.partition(|p| p.keyset_id == active_keyset_id);
|
||||
|
||||
let mut selected_proofs: Proofs = Vec::new();
|
||||
inactive_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a));
|
||||
|
||||
for inactive_proof in inactive_proofs {
|
||||
selected_proofs.push(inactive_proof);
|
||||
let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
let fees = self.get_proofs_fee(&selected_proofs).await?;
|
||||
|
||||
if selected_total >= amount + fees {
|
||||
return Ok(selected_proofs);
|
||||
}
|
||||
}
|
||||
|
||||
active_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a));
|
||||
|
||||
for active_proof in active_proofs {
|
||||
selected_proofs.push(active_proof);
|
||||
let selected_total = Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
let fees = self.get_proofs_fee(&selected_proofs).await?;
|
||||
|
||||
if selected_total >= amount + fees {
|
||||
return Ok(selected_proofs);
|
||||
}
|
||||
}
|
||||
|
||||
Err(Error::InsufficientFunds)
|
||||
}
|
||||
}
|
||||
224
crates/cdk/src/wallet/receive.rs
Normal file
224
crates/cdk/src/wallet/receive.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use bitcoin::hashes::Hash;
|
||||
use bitcoin::{hashes::sha256::Hash as Sha256Hash, XOnlyPublicKey};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::nuts::nut10::Kind;
|
||||
use crate::nuts::{Conditions, Token};
|
||||
use crate::{
|
||||
amount::SplitTarget,
|
||||
dhke::construct_proofs,
|
||||
mint_url::MintUrl,
|
||||
nuts::{Proofs, PublicKey, SecretKey, SigFlag, State},
|
||||
types::ProofInfo,
|
||||
util::hex,
|
||||
Amount, Error, Wallet, SECP256K1,
|
||||
};
|
||||
|
||||
impl Wallet {
|
||||
/// Receive proofs
|
||||
#[instrument(skip_all)]
|
||||
pub async fn receive_proofs(
|
||||
&self,
|
||||
proofs: Proofs,
|
||||
amount_split_target: SplitTarget,
|
||||
p2pk_signing_keys: &[SecretKey],
|
||||
preimages: &[String],
|
||||
) -> Result<Amount, Error> {
|
||||
let mut received_proofs: HashMap<MintUrl, Proofs> = HashMap::new();
|
||||
let mint_url = &self.mint_url;
|
||||
// Add mint if it does not exist in the store
|
||||
if self
|
||||
.localstore
|
||||
.get_mint(self.mint_url.clone())
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
tracing::debug!(
|
||||
"Mint not in localstore fetching info for: {}",
|
||||
self.mint_url
|
||||
);
|
||||
self.get_mint_info().await?;
|
||||
}
|
||||
|
||||
let _ = self.get_active_mint_keyset().await?;
|
||||
|
||||
let active_keyset_id = self.get_active_mint_keyset().await?.id;
|
||||
|
||||
let keys = self.get_keyset_keys(active_keyset_id).await?;
|
||||
|
||||
let mut proofs = proofs;
|
||||
|
||||
let mut sig_flag = SigFlag::SigInputs;
|
||||
|
||||
// Map hash of preimage to preimage
|
||||
let hashed_to_preimage: HashMap<String, &String> = preimages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let hex_bytes = hex::decode(p)?;
|
||||
Ok::<(String, &String), Error>((Sha256Hash::hash(&hex_bytes).to_string(), p))
|
||||
})
|
||||
.collect::<Result<HashMap<String, &String>, _>>()?;
|
||||
|
||||
let p2pk_signing_keys: HashMap<XOnlyPublicKey, &SecretKey> = p2pk_signing_keys
|
||||
.iter()
|
||||
.map(|s| (s.x_only_public_key(&SECP256K1).0, s))
|
||||
.collect();
|
||||
|
||||
for proof in &mut proofs {
|
||||
// Verify that proof DLEQ is valid
|
||||
if proof.dleq.is_some() {
|
||||
let keys = self.get_keyset_keys(proof.keyset_id).await?;
|
||||
let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?;
|
||||
proof.verify_dleq(key)?;
|
||||
}
|
||||
|
||||
if let Ok(secret) =
|
||||
<crate::secret::Secret as TryInto<crate::nuts::nut10::Secret>>::try_into(
|
||||
proof.secret.clone(),
|
||||
)
|
||||
{
|
||||
let conditions: Result<Conditions, _> =
|
||||
secret.secret_data.tags.unwrap_or_default().try_into();
|
||||
if let Ok(conditions) = conditions {
|
||||
let mut pubkeys = conditions.pubkeys.unwrap_or_default();
|
||||
|
||||
match secret.kind {
|
||||
Kind::P2PK => {
|
||||
let data_key = PublicKey::from_str(&secret.secret_data.data)?;
|
||||
|
||||
pubkeys.push(data_key);
|
||||
}
|
||||
Kind::HTLC => {
|
||||
let hashed_preimage = &secret.secret_data.data;
|
||||
let preimage = hashed_to_preimage
|
||||
.get(hashed_preimage)
|
||||
.ok_or(Error::PreimageNotProvided)?;
|
||||
proof.add_preimage(preimage.to_string());
|
||||
}
|
||||
}
|
||||
for pubkey in pubkeys {
|
||||
if let Some(signing) = p2pk_signing_keys.get(&pubkey.x_only_public_key()) {
|
||||
proof.sign_p2pk(signing.to_owned().clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
if conditions.sig_flag.eq(&SigFlag::SigAll) {
|
||||
sig_flag = SigFlag::SigAll;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since the proofs are unknown they need to be added to the database
|
||||
let proofs_info = proofs
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit))
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
||||
self.localstore.update_proofs(proofs_info, vec![]).await?;
|
||||
|
||||
let mut pre_swap = self
|
||||
.create_swap(None, amount_split_target, proofs, None, false)
|
||||
.await?;
|
||||
|
||||
if sig_flag.eq(&SigFlag::SigAll) {
|
||||
for blinded_message in &mut pre_swap.swap_request.outputs {
|
||||
for signing_key in p2pk_signing_keys.values() {
|
||||
blinded_message.sign_p2pk(signing_key.to_owned().clone())?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let swap_response = self
|
||||
.client
|
||||
.post_swap(mint_url.clone().try_into()?, pre_swap.swap_request)
|
||||
.await?;
|
||||
|
||||
// Proof to keep
|
||||
let p = construct_proofs(
|
||||
swap_response.signatures,
|
||||
pre_swap.pre_mint_secrets.rs(),
|
||||
pre_swap.pre_mint_secrets.secrets(),
|
||||
&keys,
|
||||
)?;
|
||||
let mint_proofs = received_proofs.entry(mint_url.clone()).or_default();
|
||||
|
||||
self.localstore
|
||||
.increment_keyset_counter(&active_keyset_id, p.len() as u32)
|
||||
.await?;
|
||||
|
||||
mint_proofs.extend(p);
|
||||
|
||||
let mut total_amount = Amount::ZERO;
|
||||
for (mint, proofs) in received_proofs {
|
||||
total_amount += Amount::try_sum(proofs.iter().map(|p| p.amount))?;
|
||||
let proofs = proofs
|
||||
.into_iter()
|
||||
.map(|proof| ProofInfo::new(proof, mint.clone(), State::Unspent, self.unit))
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
||||
self.localstore.update_proofs(proofs, vec![]).await?;
|
||||
}
|
||||
|
||||
Ok(total_amount)
|
||||
}
|
||||
|
||||
/// Receive
|
||||
/// # Synopsis
|
||||
/// ```rust, no_run
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// use cdk::amount::SplitTarget;
|
||||
/// use cdk::cdk_database::WalletMemoryDatabase;
|
||||
/// use cdk::nuts::CurrencyUnit;
|
||||
/// use cdk::wallet::Wallet;
|
||||
/// use rand::Rng;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() -> anyhow::Result<()> {
|
||||
/// 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).unwrap();
|
||||
/// let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0=";
|
||||
/// let amount_receive = wallet.receive(token, SplitTarget::default(), &[], &[]).await?;
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
#[instrument(skip_all)]
|
||||
pub async fn receive(
|
||||
&self,
|
||||
encoded_token: &str,
|
||||
amount_split_target: SplitTarget,
|
||||
p2pk_signing_keys: &[SecretKey],
|
||||
preimages: &[String],
|
||||
) -> Result<Amount, Error> {
|
||||
let token_data = Token::from_str(encoded_token)?;
|
||||
|
||||
let unit = token_data.unit().unwrap_or_default();
|
||||
|
||||
if unit != self.unit {
|
||||
return Err(Error::UnitUnsupported);
|
||||
}
|
||||
|
||||
let proofs = token_data.proofs();
|
||||
if proofs.len() != 1 {
|
||||
return Err(Error::MultiMintTokenNotSupported);
|
||||
}
|
||||
|
||||
let (mint_url, proofs) = proofs.into_iter().next().expect("Token has proofs");
|
||||
|
||||
if self.mint_url != mint_url {
|
||||
return Err(Error::IncorrectMint);
|
||||
}
|
||||
|
||||
let amount = self
|
||||
.receive_proofs(proofs, amount_split_target, p2pk_signing_keys, preimages)
|
||||
.await?;
|
||||
|
||||
Ok(amount)
|
||||
}
|
||||
}
|
||||
212
crates/cdk/src/wallet/send.rs
Normal file
212
crates/cdk/src/wallet/send.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::{
|
||||
amount::SplitTarget,
|
||||
nuts::{Proofs, PublicKey, SpendingConditions, State, Token},
|
||||
Amount, Error, Wallet,
|
||||
};
|
||||
|
||||
use super::SendKind;
|
||||
|
||||
impl Wallet {
|
||||
/// Send specific proofs
|
||||
#[instrument(skip(self))]
|
||||
pub async fn send_proofs(&self, memo: Option<String>, proofs: Proofs) -> Result<Token, Error> {
|
||||
let ys = proofs
|
||||
.iter()
|
||||
.map(|p| p.y())
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?;
|
||||
self.localstore.reserve_proofs(ys).await?;
|
||||
|
||||
Ok(Token::new(
|
||||
self.mint_url.clone(),
|
||||
proofs,
|
||||
memo,
|
||||
Some(self.unit),
|
||||
))
|
||||
}
|
||||
|
||||
/// Send
|
||||
#[instrument(skip(self))]
|
||||
pub async fn send(
|
||||
&self,
|
||||
amount: Amount,
|
||||
memo: Option<String>,
|
||||
conditions: Option<SpendingConditions>,
|
||||
amount_split_target: &SplitTarget,
|
||||
send_kind: &SendKind,
|
||||
include_fees: bool,
|
||||
) -> Result<Token, Error> {
|
||||
// If online send check mint for current keysets fees
|
||||
if matches!(
|
||||
send_kind,
|
||||
SendKind::OnlineExact | SendKind::OnlineTolerance(_)
|
||||
) {
|
||||
if let Err(e) = self.get_active_mint_keyset().await {
|
||||
tracing::error!(
|
||||
"Error fetching active mint keyset: {:?}. Using stored keysets",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mint_url = &self.mint_url;
|
||||
let unit = &self.unit;
|
||||
let available_proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(mint_url.clone()),
|
||||
Some(*unit),
|
||||
Some(vec![State::Unspent]),
|
||||
conditions.clone().map(|c| vec![c]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold(
|
||||
(Vec::new(), Amount::ZERO),
|
||||
|(mut acc1, mut acc2), p| {
|
||||
acc2 += p.amount;
|
||||
acc1.push(p);
|
||||
(acc1, acc2)
|
||||
},
|
||||
);
|
||||
let available_proofs = if proofs_sum < amount {
|
||||
match &conditions {
|
||||
Some(conditions) => {
|
||||
let available_proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(mint_url.clone()),
|
||||
Some(*unit),
|
||||
Some(vec![State::Unspent]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect();
|
||||
|
||||
let proofs_to_swap =
|
||||
self.select_proofs_to_swap(amount, available_proofs).await?;
|
||||
|
||||
let proofs_with_conditions = self
|
||||
.swap(
|
||||
Some(amount),
|
||||
SplitTarget::default(),
|
||||
proofs_to_swap,
|
||||
Some(conditions.clone()),
|
||||
include_fees,
|
||||
)
|
||||
.await?;
|
||||
proofs_with_conditions.ok_or(Error::InsufficientFunds)?
|
||||
}
|
||||
None => {
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
available_proofs
|
||||
};
|
||||
|
||||
let selected = self
|
||||
.select_proofs_to_send(amount, available_proofs, include_fees)
|
||||
.await;
|
||||
|
||||
let send_proofs: Proofs = match (send_kind, selected, conditions.clone()) {
|
||||
// Handle exact matches offline
|
||||
(SendKind::OfflineExact, Ok(selected_proofs), _) => {
|
||||
let selected_proofs_amount =
|
||||
Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
|
||||
let amount_to_send = match include_fees {
|
||||
true => amount + self.get_proofs_fee(&selected_proofs).await?,
|
||||
false => amount,
|
||||
};
|
||||
|
||||
if selected_proofs_amount == amount_to_send {
|
||||
selected_proofs
|
||||
} else {
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle exact matches
|
||||
(SendKind::OnlineExact, Ok(selected_proofs), _) => {
|
||||
let selected_proofs_amount =
|
||||
Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
|
||||
let amount_to_send = match include_fees {
|
||||
true => amount + self.get_proofs_fee(&selected_proofs).await?,
|
||||
false => amount,
|
||||
};
|
||||
|
||||
if selected_proofs_amount == amount_to_send {
|
||||
selected_proofs
|
||||
} else {
|
||||
tracing::info!("Could not select proofs exact while offline.");
|
||||
tracing::info!("Attempting to select proofs and swapping");
|
||||
|
||||
self.swap_from_unspent(amount, conditions, include_fees)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
// Handle offline tolerance
|
||||
(SendKind::OfflineTolerance(tolerance), Ok(selected_proofs), _) => {
|
||||
let selected_proofs_amount =
|
||||
Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
|
||||
let amount_to_send = match include_fees {
|
||||
true => amount + self.get_proofs_fee(&selected_proofs).await?,
|
||||
false => amount,
|
||||
};
|
||||
if selected_proofs_amount - amount_to_send <= *tolerance {
|
||||
selected_proofs
|
||||
} else {
|
||||
tracing::info!("Selected proofs greater than tolerance. Must swap online");
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle online tolerance when selection fails and conditions are present
|
||||
(SendKind::OnlineTolerance(_), Err(_), Some(_)) => {
|
||||
tracing::info!("Could not select proofs with conditions while offline.");
|
||||
tracing::info!("Attempting to select proofs without conditions and swapping");
|
||||
|
||||
self.swap_from_unspent(amount, conditions, include_fees)
|
||||
.await?
|
||||
}
|
||||
|
||||
// Handle online tolerance with successful selection
|
||||
(SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => {
|
||||
let selected_proofs_amount =
|
||||
Amount::try_sum(selected_proofs.iter().map(|p| p.amount))?;
|
||||
let amount_to_send = match include_fees {
|
||||
true => amount + self.get_proofs_fee(&selected_proofs).await?,
|
||||
false => amount,
|
||||
};
|
||||
if selected_proofs_amount - amount_to_send <= *tolerance {
|
||||
selected_proofs
|
||||
} else {
|
||||
tracing::info!("Could not select proofs while offline. Attempting swap");
|
||||
self.swap_from_unspent(amount, conditions, include_fees)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all other cases where selection fails
|
||||
(
|
||||
SendKind::OfflineExact
|
||||
| SendKind::OnlineExact
|
||||
| SendKind::OfflineTolerance(_)
|
||||
| SendKind::OnlineTolerance(_),
|
||||
Err(_),
|
||||
_,
|
||||
) => {
|
||||
tracing::debug!("Could not select proofs");
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
};
|
||||
|
||||
self.send_proofs(memo, send_proofs).await
|
||||
}
|
||||
}
|
||||
308
crates/cdk/src/wallet/swap.rs
Normal file
308
crates/cdk/src/wallet/swap.rs
Normal file
@@ -0,0 +1,308 @@
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::amount::SplitTarget;
|
||||
use crate::dhke::construct_proofs;
|
||||
use crate::nuts::nut10;
|
||||
use crate::nuts::PreMintSecrets;
|
||||
use crate::nuts::PreSwap;
|
||||
use crate::nuts::Proofs;
|
||||
use crate::nuts::PublicKey;
|
||||
use crate::nuts::SpendingConditions;
|
||||
use crate::nuts::State;
|
||||
use crate::nuts::SwapRequest;
|
||||
use crate::types::ProofInfo;
|
||||
use crate::Amount;
|
||||
use crate::Error;
|
||||
use crate::Wallet;
|
||||
|
||||
impl Wallet {
|
||||
/// Swap
|
||||
#[instrument(skip(self, input_proofs))]
|
||||
pub async fn swap(
|
||||
&self,
|
||||
amount: Option<Amount>,
|
||||
amount_split_target: SplitTarget,
|
||||
input_proofs: Proofs,
|
||||
spending_conditions: Option<SpendingConditions>,
|
||||
include_fees: bool,
|
||||
) -> Result<Option<Proofs>, Error> {
|
||||
let mint_url = &self.mint_url;
|
||||
let unit = &self.unit;
|
||||
|
||||
let pre_swap = self
|
||||
.create_swap(
|
||||
amount,
|
||||
amount_split_target,
|
||||
input_proofs.clone(),
|
||||
spending_conditions.clone(),
|
||||
include_fees,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let swap_response = self
|
||||
.client
|
||||
.post_swap(mint_url.clone().try_into()?, pre_swap.swap_request)
|
||||
.await?;
|
||||
|
||||
let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id;
|
||||
|
||||
let active_keys = self
|
||||
.localstore
|
||||
.get_keys(&active_keyset_id)
|
||||
.await?
|
||||
.ok_or(Error::NoActiveKeyset)?;
|
||||
|
||||
let post_swap_proofs = construct_proofs(
|
||||
swap_response.signatures,
|
||||
pre_swap.pre_mint_secrets.rs(),
|
||||
pre_swap.pre_mint_secrets.secrets(),
|
||||
&active_keys,
|
||||
)?;
|
||||
|
||||
self.localstore
|
||||
.increment_keyset_counter(&active_keyset_id, pre_swap.derived_secret_count)
|
||||
.await?;
|
||||
|
||||
let mut added_proofs = Vec::new();
|
||||
let change_proofs;
|
||||
let send_proofs;
|
||||
match amount {
|
||||
Some(amount) => {
|
||||
let (proofs_with_condition, proofs_without_condition): (Proofs, Proofs) =
|
||||
post_swap_proofs.into_iter().partition(|p| {
|
||||
let nut10_secret: Result<nut10::Secret, _> = p.secret.clone().try_into();
|
||||
|
||||
nut10_secret.is_ok()
|
||||
});
|
||||
|
||||
let (proofs_to_send, proofs_to_keep) = match spending_conditions {
|
||||
Some(_) => (proofs_with_condition, proofs_without_condition),
|
||||
None => {
|
||||
let mut all_proofs = proofs_without_condition;
|
||||
all_proofs.reverse();
|
||||
|
||||
let mut proofs_to_send: Proofs = Vec::new();
|
||||
let mut proofs_to_keep = Vec::new();
|
||||
|
||||
for proof in all_proofs {
|
||||
let proofs_to_send_amount =
|
||||
Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?;
|
||||
if proof.amount + proofs_to_send_amount <= amount + pre_swap.fee {
|
||||
proofs_to_send.push(proof);
|
||||
} else {
|
||||
proofs_to_keep.push(proof);
|
||||
}
|
||||
}
|
||||
|
||||
(proofs_to_send, proofs_to_keep)
|
||||
}
|
||||
};
|
||||
|
||||
let send_amount = Amount::try_sum(proofs_to_send.iter().map(|p| p.amount))?;
|
||||
|
||||
if send_amount.ne(&(amount + pre_swap.fee)) {
|
||||
tracing::warn!(
|
||||
"Send amount proofs is {:?} expected {:?}",
|
||||
send_amount,
|
||||
amount
|
||||
);
|
||||
}
|
||||
|
||||
let send_proofs_info = proofs_to_send
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit))
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
||||
added_proofs = send_proofs_info;
|
||||
|
||||
change_proofs = proofs_to_keep;
|
||||
send_proofs = Some(proofs_to_send);
|
||||
}
|
||||
None => {
|
||||
change_proofs = post_swap_proofs;
|
||||
send_proofs = None;
|
||||
}
|
||||
}
|
||||
|
||||
let keep_proofs = change_proofs
|
||||
.into_iter()
|
||||
.map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit))
|
||||
.collect::<Result<Vec<ProofInfo>, _>>()?;
|
||||
added_proofs.extend(keep_proofs);
|
||||
|
||||
// Remove spent proofs used as inputs
|
||||
let deleted_ys = input_proofs
|
||||
.into_iter()
|
||||
.map(|proof| proof.y())
|
||||
.collect::<Result<Vec<PublicKey>, _>>()?;
|
||||
|
||||
self.localstore
|
||||
.update_proofs(added_proofs, deleted_ys)
|
||||
.await?;
|
||||
Ok(send_proofs)
|
||||
}
|
||||
|
||||
/// Swap from unspent proofs in db
|
||||
#[instrument(skip(self))]
|
||||
pub async fn swap_from_unspent(
|
||||
&self,
|
||||
amount: Amount,
|
||||
conditions: Option<SpendingConditions>,
|
||||
include_fees: bool,
|
||||
) -> Result<Proofs, Error> {
|
||||
let available_proofs = self
|
||||
.localstore
|
||||
.get_proofs(
|
||||
Some(self.mint_url.clone()),
|
||||
Some(self.unit),
|
||||
Some(vec![State::Unspent]),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold(
|
||||
(Vec::new(), Amount::ZERO),
|
||||
|(mut acc1, mut acc2), p| {
|
||||
acc2 += p.amount;
|
||||
acc1.push(p);
|
||||
(acc1, acc2)
|
||||
},
|
||||
);
|
||||
|
||||
if proofs_sum < amount {
|
||||
return Err(Error::InsufficientFunds);
|
||||
}
|
||||
|
||||
let proofs = self.select_proofs_to_swap(amount, available_proofs).await?;
|
||||
|
||||
self.swap(
|
||||
Some(amount),
|
||||
SplitTarget::default(),
|
||||
proofs,
|
||||
conditions,
|
||||
include_fees,
|
||||
)
|
||||
.await?
|
||||
.ok_or(Error::InsufficientFunds)
|
||||
}
|
||||
|
||||
/// Create Swap Payload
|
||||
#[instrument(skip(self, proofs))]
|
||||
pub async fn create_swap(
|
||||
&self,
|
||||
amount: Option<Amount>,
|
||||
amount_split_target: SplitTarget,
|
||||
proofs: Proofs,
|
||||
spending_conditions: Option<SpendingConditions>,
|
||||
include_fees: bool,
|
||||
) -> Result<PreSwap, Error> {
|
||||
let active_keyset_id = self.get_active_mint_keyset().await?.id;
|
||||
|
||||
// Desired amount is either amount passed or value of all proof
|
||||
let proofs_total = Amount::try_sum(proofs.iter().map(|p| p.amount))?;
|
||||
|
||||
let ys: Vec<PublicKey> = proofs.iter().map(|p| p.y()).collect::<Result<_, _>>()?;
|
||||
self.localstore.set_pending_proofs(ys).await?;
|
||||
|
||||
let fee = self.get_proofs_fee(&proofs).await?;
|
||||
|
||||
let change_amount: Amount = proofs_total - amount.unwrap_or(Amount::ZERO) - fee;
|
||||
|
||||
let (send_amount, change_amount) = match include_fees {
|
||||
true => {
|
||||
let split_count = amount
|
||||
.unwrap_or(Amount::ZERO)
|
||||
.split_targeted(&SplitTarget::default())
|
||||
.unwrap()
|
||||
.len();
|
||||
|
||||
let fee_to_redeem = self
|
||||
.get_keyset_count_fee(&active_keyset_id, split_count as u64)
|
||||
.await?;
|
||||
|
||||
(
|
||||
amount.map(|a| a + fee_to_redeem),
|
||||
change_amount - fee_to_redeem,
|
||||
)
|
||||
}
|
||||
false => (amount, change_amount),
|
||||
};
|
||||
|
||||
// If a non None split target is passed use that
|
||||
// else use state refill
|
||||
let change_split_target = match amount_split_target {
|
||||
SplitTarget::None => self.determine_split_target_values(change_amount).await?,
|
||||
s => s,
|
||||
};
|
||||
|
||||
let derived_secret_count;
|
||||
|
||||
let count = self
|
||||
.localstore
|
||||
.get_keyset_counter(&active_keyset_id)
|
||||
.await?;
|
||||
|
||||
let mut count = count.map_or(0, |c| c + 1);
|
||||
|
||||
let (mut desired_messages, change_messages) = match spending_conditions {
|
||||
Some(conditions) => {
|
||||
let change_premint_secrets = PreMintSecrets::from_xpriv(
|
||||
active_keyset_id,
|
||||
count,
|
||||
self.xpriv,
|
||||
change_amount,
|
||||
&change_split_target,
|
||||
)?;
|
||||
|
||||
derived_secret_count = change_premint_secrets.len();
|
||||
|
||||
(
|
||||
PreMintSecrets::with_conditions(
|
||||
active_keyset_id,
|
||||
send_amount.unwrap_or(Amount::ZERO),
|
||||
&SplitTarget::default(),
|
||||
&conditions,
|
||||
)?,
|
||||
change_premint_secrets,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let premint_secrets = PreMintSecrets::from_xpriv(
|
||||
active_keyset_id,
|
||||
count,
|
||||
self.xpriv,
|
||||
send_amount.unwrap_or(Amount::ZERO),
|
||||
&SplitTarget::default(),
|
||||
)?;
|
||||
|
||||
count += premint_secrets.len() as u32;
|
||||
|
||||
let change_premint_secrets = PreMintSecrets::from_xpriv(
|
||||
active_keyset_id,
|
||||
count,
|
||||
self.xpriv,
|
||||
change_amount,
|
||||
&change_split_target,
|
||||
)?;
|
||||
|
||||
derived_secret_count = change_premint_secrets.len() + premint_secrets.len();
|
||||
|
||||
(premint_secrets, change_premint_secrets)
|
||||
}
|
||||
};
|
||||
|
||||
// Combine the BlindedMessages totaling the desired amount with change
|
||||
desired_messages.combine(change_messages);
|
||||
// Sort the premint secrets to avoid finger printing
|
||||
desired_messages.sort_secrets();
|
||||
|
||||
let swap_request = SwapRequest::new(proofs, desired_messages.blinded_messages());
|
||||
|
||||
Ok(PreSwap {
|
||||
pre_mint_secrets: desired_messages,
|
||||
swap_request,
|
||||
derived_secret_count: derived_secret_count as u32,
|
||||
fee,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user