From 17263b07f5112a2dfd168c4629d9a96c70ba81d5 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Thu, 30 May 2024 00:19:19 +0100 Subject: [PATCH] feat(NUT02): add input_fee_ppk chore: instrument log on mint fns --- CHANGELOG.md | 4 +- Cargo.toml | 1 + bindings/cdk-js/src/wallet.rs | 29 +- crates/cdk-cli/Cargo.toml | 2 +- crates/cdk-cli/src/main.rs | 14 +- crates/cdk-cli/src/sub_commands/mint.rs | 8 +- crates/cdk-cli/src/sub_commands/receive.rs | 3 +- crates/cdk-cli/src/sub_commands/send.rs | 19 + crates/cdk-mintd/Cargo.toml | 2 +- crates/cdk-mintd/example.config.toml | 1 + crates/cdk-mintd/src/config.rs | 1 + crates/cdk-mintd/src/main.rs | 14 +- crates/cdk-redb/src/wallet/mod.rs | 10 + .../migrations/20240710145043_input_fee.sql | 1 + crates/cdk-sqlite/src/mint/mod.rs | 9 +- .../migrations/20240710144711_input_fee.sql | 1 + crates/cdk-sqlite/src/wallet/mod.rs | 7 +- crates/cdk/Cargo.toml | 4 + crates/cdk/examples/mint-token.rs | 12 +- crates/cdk/examples/p2pk.rs | 14 +- crates/cdk/examples/proof_selection.rs | 61 ++ crates/cdk/examples/wallet.rs | 12 +- crates/cdk/src/amount.rs | 76 +- crates/cdk/src/error.rs | 3 + crates/cdk/src/mint/error.rs | 3 + crates/cdk/src/mint/mod.rs | 129 ++- crates/cdk/src/nuts/nut00/mod.rs | 36 +- crates/cdk/src/nuts/nut02.rs | 24 +- crates/cdk/src/nuts/nut03.rs | 2 + crates/cdk/src/nuts/nut13.rs | 15 +- crates/cdk/src/wallet/mod.rs | 945 ++++++++++-------- crates/cdk/src/wallet/multi_mint_wallet.rs | 19 +- crates/cdk/src/wallet/types.rs | 14 + 33 files changed, 995 insertions(+), 500 deletions(-) create mode 100644 crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql create mode 100644 crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql create mode 100644 crates/cdk/examples/proof_selection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fb19bdcc..ca341b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ### Changed cdk(wallet): `wallet:receive` will not claim `proofs` from a mint other then the wallet's mint ([thesimplekid]). -cdk(NUT00): `Token` is changed from a struct to enum of either `TokenV4` or `Tokenv3` ([thesimplekid]). +cdk(NUT00): `Token` is changed from a `struct` to `enum` of either `TokenV4` or `Tokenv3` ([thesimplekid]). cdk(NUT00): Rename `MintProofs` to `TokenV3Token` ([thesimplekid]). @@ -40,6 +40,8 @@ cdk-mintd: Mint binary ([thesimplekid]). cdk-cln: cln backend for mint ([thesimplekid]). cdk-axum: Mint axum server ([thesimplekid]). cdk: NUT06 `MintInfo` and `NUTs` builder ([thesimplekid]). +cdk: NUT00 `PreMintSecret` added Keyset id ([thesimplekid]) +cdk: NUT02 Support fees ([thesimplekid]) ### Fixed cdk: NUT06 deseralize `MintInfo` ([thesimplekid]). diff --git a/Cargo.toml b/Cargo.toml index 3fe4fad2..279a8d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ cdk-axum = { version = "0.1", path = "./crates/cdk-axum", default-features = fal tokio = { version = "1", default-features = false } thiserror = "1" tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } serde = { version = "1", default-features = false, features = ["derive"] } serde_json = "1" serde-wasm-bindgen = "0.6.5" diff --git a/bindings/cdk-js/src/wallet.rs b/bindings/cdk-js/src/wallet.rs index 182bce07..d591c96e 100644 --- a/bindings/cdk-js/src/wallet.rs +++ b/bindings/cdk-js/src/wallet.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use cdk::amount::SplitTarget; use cdk::nuts::{Proofs, SecretKey}; -use cdk::wallet::Wallet; +use cdk::wallet::{SendKind, Wallet}; use cdk::Amount; use cdk_rexie::WalletRexieDatabase; use wasm_bindgen::prelude::*; @@ -44,7 +44,7 @@ impl JsWallet { pub async fn new(mints_url: String, unit: JsCurrencyUnit, seed: Vec) -> Self { let db = WalletRexieDatabase::new().await.unwrap(); - Wallet::new(&mints_url, unit.into(), Arc::new(db), &seed).into() + Wallet::new(&mints_url, unit.into(), Arc::new(db), &seed, None).into() } #[wasm_bindgen(js_name = totalBalance)] @@ -81,12 +81,6 @@ impl JsWallet { .map(|i| i.into())) } - #[wasm_bindgen(js_name = refreshMint)] - pub async fn refresh_mint_keys(&self) -> Result<()> { - self.inner.refresh_mint_keys().await.map_err(into_err)?; - Ok(()) - } - #[wasm_bindgen(js_name = mintQuote)] pub async fn mint_quote(&mut self, amount: u64) -> Result { let quote = self @@ -200,7 +194,7 @@ impl JsWallet { .inner .receive( &encoded_token, - &SplitTarget::default(), + SplitTarget::default(), &signing_keys, &preimages, ) @@ -234,7 +228,14 @@ impl JsWallet { .map(|a| SplitTarget::Value(*a.deref())) .unwrap_or_default(); self.inner - .send(Amount::from(amount), memo, conditions, &target) + .send( + Amount::from(amount), + memo, + conditions, + &target, + &SendKind::default(), + false, + ) .await .map_err(into_err) } @@ -267,7 +268,13 @@ impl JsWallet { .unwrap_or_default(); let post_swap_proofs = self .inner - .swap(Some(Amount::from(amount)), &target, proofs, conditions) + .swap( + Some(Amount::from(amount)), + target, + proofs, + conditions, + false, + ) .await .map_err(into_err)?; diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index 3d771d2f..eb8d5b7c 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -22,7 +22,7 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true tokio.workspace = true tracing.workspace = true -tracing-subscriber = "0.3.18" +tracing-subscriber.workspace = true rand = "0.8.5" home.workspace = true nostr-sdk = { version = "0.32.0", default-features = false, features = [ diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 7ae1319c..655d17d0 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -14,6 +14,7 @@ use cdk_sqlite::WalletSqliteDatabase; use clap::{Parser, Subcommand}; use rand::Rng; use tracing::Level; +use tracing_subscriber::EnvFilter; mod sub_commands; @@ -69,11 +70,15 @@ enum Commands { #[tokio::main] async fn main() -> Result<()> { - // Parse input let args: Cli = Cli::parse(); - tracing_subscriber::fmt() - .with_max_level(args.log_level) - .init(); + let default_filter = args.log_level; + + let sqlx_filter = "sqlx=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + // Parse input + tracing_subscriber::fmt().with_env_filter(env_filter).init(); let work_dir = match &args.work_dir { Some(work_dir) => work_dir.clone(), @@ -131,6 +136,7 @@ async fn main() -> Result<()> { cdk::nuts::CurrencyUnit::Sat, localstore.clone(), &mnemonic.to_seed_normalized(""), + None, ); wallets.insert(mint, wallet); diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 4366155e..9f56f8ed 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -32,7 +32,13 @@ pub async fn mint( let mint_url = sub_command_args.mint_url.clone(); let wallet = match wallets.get(&mint_url) { Some(wallet) => wallet.clone(), - None => Wallet::new(&mint_url.to_string(), CurrencyUnit::Sat, localstore, seed), + None => Wallet::new( + &mint_url.to_string(), + CurrencyUnit::Sat, + localstore, + seed, + None, + ), }; let quote = wallet diff --git a/crates/cdk-cli/src/sub_commands/receive.rs b/crates/cdk-cli/src/sub_commands/receive.rs index a0f2d7aa..d7f2e2b3 100644 --- a/crates/cdk-cli/src/sub_commands/receive.rs +++ b/crates/cdk-cli/src/sub_commands/receive.rs @@ -136,11 +136,12 @@ async fn receive_token( CurrencyUnit::Sat, Arc::clone(localstore), seed, + None, ), }; let amount = wallet - .receive(token_str, &SplitTarget::default(), signing_keys, preimage) + .receive(token_str, SplitTarget::default(), signing_keys, preimage) .await?; Ok(amount) } diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index 12c841c6..5d78a497 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use anyhow::{bail, Result}; use cdk::amount::SplitTarget; use cdk::nuts::{Conditions, PublicKey, SpendingConditions, Token}; +use cdk::wallet::types::SendKind; use cdk::wallet::Wallet; use cdk::{Amount, UncheckedUrl}; use clap::Args; @@ -35,6 +36,15 @@ pub struct SendSubCommand { /// Token as V3 token #[arg(short, long)] v3: bool, + /// Should the send be offline only + #[arg(short, long)] + offline: bool, + /// Include fee to redeam in token + #[arg(short, long)] + include_fee: bool, + /// Amount willing to overpay to avoid a swap + #[arg(short, long)] + tolerance: Option, } pub async fn send( @@ -146,12 +156,21 @@ pub async fn send( let wallet = mints_amounts[mint_number].0.clone(); + let send_kind = match (sub_command_args.offline, sub_command_args.tolerance) { + (true, Some(amount)) => SendKind::OfflineTolerance(Amount::from(amount)), + (true, None) => SendKind::OfflineExact, + (false, Some(amount)) => SendKind::OnlineTolerance(Amount::from(amount)), + (false, None) => SendKind::OnlineExact, + }; + let token = wallet .send( token_amount, sub_command_args.memo.clone(), conditions, &SplitTarget::default(), + &send_kind, + sub_command_args.include_fee, ) .await?; diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 262092ec..3a9a34e5 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -21,7 +21,7 @@ config = { version = "0.13.3", features = ["toml"] } clap = { version = "4.4.8", features = ["derive", "env", "default"] } tokio.workspace = true tracing.workspace = true -tracing-subscriber = "0.3.18" +tracing-subscriber.workspace = true futures = "0.3.28" serde.workspace = true bip39.workspace = true diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 2b238449..db1f0c00 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -3,6 +3,7 @@ url = "https://mint.thesimplekid.dev/" listen_host = "127.0.0.1" listen_port = 8085 mnemonic = "" +# input_fee_ppk = 0 diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index b5282c97..b1270baa 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -13,6 +13,7 @@ pub struct Info { pub listen_port: u16, pub mnemonic: String, pub seconds_quote_is_valid_for: Option, + pub input_fee_ppk: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 76e08987..f1e75243 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -28,6 +28,7 @@ use cli::CLIArgs; use config::{DatabaseEngine, LnBackend}; use futures::StreamExt; use tower_http::cors::CorsLayer; +use tracing_subscriber::EnvFilter; mod cli; mod config; @@ -37,9 +38,13 @@ const DEFAULT_QUOTE_TTL_SECS: u64 = 1800; #[tokio::main] async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) - .init(); + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + tracing_subscriber::fmt().with_env_filter(env_filter).init(); let args = CLIArgs::parse(); @@ -206,6 +211,8 @@ async fn main() -> anyhow::Result<()> { let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?; + let input_fee_ppk = settings.info.input_fee_ppk.unwrap_or(0); + let mint = Mint::new( &settings.info.url, &mnemonic.to_seed_normalized(""), @@ -213,6 +220,7 @@ async fn main() -> anyhow::Result<()> { localstore, absolute_ln_fee_reserve, relative_ln_fee, + input_fee_ppk, ) .await?; diff --git a/crates/cdk-redb/src/wallet/mod.rs b/crates/cdk-redb/src/wallet/mod.rs index b00ccda7..b4afc7d6 100644 --- a/crates/cdk-redb/src/wallet/mod.rs +++ b/crates/cdk-redb/src/wallet/mod.rs @@ -288,6 +288,7 @@ impl WalletDatabase for WalletRedbDatabase { let mut table = write_txn .open_multimap_table(MINT_KEYSETS_TABLE) .map_err(Error::from)?; + let mut keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; for keyset in keysets { table @@ -296,6 +297,15 @@ impl WalletDatabase for WalletRedbDatabase { keyset.id.to_bytes().as_slice(), ) .map_err(Error::from)?; + + keysets_table + .insert( + keyset.id.to_bytes().as_slice(), + serde_json::to_string(&keyset) + .map_err(Error::from)? + .as_str(), + ) + .map_err(Error::from)?; } } write_txn.commit().map_err(Error::from)?; diff --git a/crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql b/crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql new file mode 100644 index 00000000..6bdd8cd0 --- /dev/null +++ b/crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql @@ -0,0 +1 @@ +ALTER TABLE keyset ADD input_fee_ppk INTEGER; diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index da82f406..21ed5230 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -407,9 +407,9 @@ WHERE id=? async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err> { sqlx::query( r#" -INSERT INTO keyset -(id, unit, active, valid_from, valid_to, derivation_path, max_order) -VALUES (?, ?, ?, ?, ?, ?, ?); +INSERT OR REPLACE INTO keyset +(id, unit, active, valid_from, valid_to, derivation_path, max_order, input_fee_ppk) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); "#, ) .bind(keyset.id.to_string()) @@ -419,6 +419,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?); .bind(keyset.valid_to.map(|v| v as i64)) .bind(keyset.derivation_path.to_string()) .bind(keyset.max_order) + .bind(keyset.input_fee_ppk as i64) .execute(&self.pool) .await .map_err(Error::from)?; @@ -714,6 +715,7 @@ fn sqlite_row_to_keyset_info(row: SqliteRow) -> Result { let row_valid_to: Option = row.try_get("valid_to").map_err(Error::from)?; let row_derivation_path: String = row.try_get("derivation_path").map_err(Error::from)?; let row_max_order: u8 = row.try_get("max_order").map_err(Error::from)?; + let row_keyset_ppk: Option = row.try_get("input_fee_ppk").map_err(Error::from)?; Ok(MintKeySetInfo { id: Id::from_str(&row_id).map_err(Error::from)?, @@ -723,6 +725,7 @@ fn sqlite_row_to_keyset_info(row: SqliteRow) -> Result { valid_to: row_valid_to.map(|v| v as u64), derivation_path: DerivationPath::from_str(&row_derivation_path).map_err(Error::from)?, max_order: row_max_order, + input_fee_ppk: row_keyset_ppk.unwrap_or(0) as u64, }) } diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql b/crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql new file mode 100644 index 00000000..6bdd8cd0 --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql @@ -0,0 +1 @@ +ALTER TABLE keyset ADD input_fee_ppk INTEGER; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index c3762127..ed5c638d 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -211,14 +211,15 @@ FROM mint sqlx::query( r#" INSERT OR REPLACE INTO keyset -(mint_url, id, unit, active) -VALUES (?, ?, ?, ?); +(mint_url, id, unit, active, input_fee_ppk) +VALUES (?, ?, ?, ?, ?); "#, ) .bind(mint_url.to_string()) .bind(keyset.id.to_string()) .bind(keyset.unit.to_string()) .bind(keyset.active) + .bind(keyset.input_fee_ppk as i64) .execute(&self.pool) .await .map_err(Error::from)?; @@ -708,11 +709,13 @@ fn sqlite_row_to_keyset(row: &SqliteRow) -> Result { let row_id: String = row.try_get("id").map_err(Error::from)?; let row_unit: String = row.try_get("unit").map_err(Error::from)?; let active: bool = row.try_get("active").map_err(Error::from)?; + let row_keyset_ppk: Option = row.try_get("input_fee_ppk").map_err(Error::from)?; Ok(KeySetInfo { id: Id::from_str(&row_id)?, unit: CurrencyUnit::from_str(&row_unit).map_err(Error::from)?, active, + input_fee_ppk: row_keyset_ppk.unwrap_or(0) as u64, }) } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 6730dd08..e2be5160 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -69,6 +69,10 @@ required-features = ["wallet"] name = "wallet" required-features = ["wallet"] +[[example]] +name = "proof_selection" +required-features = ["wallet"] + [dev-dependencies] rand = "0.8.5" bip39.workspace = true diff --git a/crates/cdk/examples/mint-token.rs b/crates/cdk/examples/mint-token.rs index 95a393df..cb13bc28 100644 --- a/crates/cdk/examples/mint-token.rs +++ b/crates/cdk/examples/mint-token.rs @@ -5,6 +5,7 @@ use cdk::amount::SplitTarget; use cdk::cdk_database::WalletMemoryDatabase; use cdk::error::Error; use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::wallet::types::SendKind; use cdk::wallet::Wallet; use cdk::Amount; use rand::Rng; @@ -19,7 +20,7 @@ async fn main() -> Result<(), Error> { let unit = CurrencyUnit::Sat; let amount = Amount::from(10); - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); let quote = wallet.mint_quote(amount).await.unwrap(); @@ -45,7 +46,14 @@ async fn main() -> Result<(), Error> { println!("Received {receive_amount} from mint {mint_url}"); let token = wallet - .send(amount, None, None, &SplitTarget::default()) + .send( + amount, + None, + None, + &SplitTarget::default(), + &SendKind::OnlineExact, + false, + ) .await .unwrap(); diff --git a/crates/cdk/examples/p2pk.rs b/crates/cdk/examples/p2pk.rs index 1f5e1401..e1d7b8b8 100644 --- a/crates/cdk/examples/p2pk.rs +++ b/crates/cdk/examples/p2pk.rs @@ -5,6 +5,7 @@ use cdk::amount::SplitTarget; use cdk::cdk_database::WalletMemoryDatabase; use cdk::error::Error; use cdk::nuts::{CurrencyUnit, MintQuoteState, SecretKey, SpendingConditions}; +use cdk::wallet::types::SendKind; use cdk::wallet::Wallet; use cdk::Amount; use rand::Rng; @@ -19,7 +20,7 @@ async fn main() -> Result<(), Error> { let unit = CurrencyUnit::Sat; let amount = Amount::from(10); - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); let quote = wallet.mint_quote(amount).await.unwrap(); @@ -47,7 +48,14 @@ async fn main() -> Result<(), Error> { let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None); let token = wallet - .send(amount, None, Some(spending_conditions), &SplitTarget::None) + .send( + amount, + None, + Some(spending_conditions), + &SplitTarget::None, + &SendKind::default(), + false, + ) .await .unwrap(); @@ -55,7 +63,7 @@ async fn main() -> Result<(), Error> { println!("{}", token); let amount = wallet - .receive(&token, &SplitTarget::default(), &[secret], &[]) + .receive(&token, SplitTarget::default(), &[secret], &[]) .await .unwrap(); diff --git a/crates/cdk/examples/proof_selection.rs b/crates/cdk/examples/proof_selection.rs new file mode 100644 index 00000000..3c7f7b05 --- /dev/null +++ b/crates/cdk/examples/proof_selection.rs @@ -0,0 +1,61 @@ +//! Wallet example with memory store + +use std::sync::Arc; +use std::time::Duration; + +use cdk::amount::SplitTarget; +use cdk::cdk_database::WalletMemoryDatabase; +use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::wallet::Wallet; +use cdk::Amount; +use rand::Rng; +use tokio::time::sleep; + +#[tokio::main] +async fn main() { + 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); + + for amount in [64] { + let amount = Amount::from(amount); + let quote = wallet.mint_quote(amount).await.unwrap(); + + println!("Pay request: {}", quote.request); + + loop { + let status = wallet.mint_quote_state("e.id).await.unwrap(); + + if status.state == MintQuoteState::Paid { + break; + } + + println!("Quote state: {}", status.state); + + sleep(Duration::from_secs(5)).await; + } + + let receive_amount = wallet + .mint("e.id, SplitTarget::default(), None) + .await + .unwrap(); + + println!("Minted {}", receive_amount); + } + + let proofs = wallet.get_proofs().await.unwrap(); + + let selected = wallet + .select_proofs_to_send(Amount::from(65), proofs, false) + .await + .unwrap(); + + for (i, proof) in selected.iter().enumerate() { + println!("{}: {}", i, proof.amount); + } +} diff --git a/crates/cdk/examples/wallet.rs b/crates/cdk/examples/wallet.rs index 388dcfa5..e2cd24fa 100644 --- a/crates/cdk/examples/wallet.rs +++ b/crates/cdk/examples/wallet.rs @@ -6,6 +6,7 @@ use std::time::Duration; use cdk::amount::SplitTarget; use cdk::cdk_database::WalletMemoryDatabase; use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::wallet::types::SendKind; use cdk::wallet::Wallet; use cdk::Amount; use rand::Rng; @@ -21,7 +22,7 @@ async fn main() { let localstore = WalletMemoryDatabase::default(); - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); let quote = wallet.mint_quote(amount).await.unwrap(); @@ -47,7 +48,14 @@ async fn main() { println!("Minted {}", receive_amount); let token = wallet - .send(amount, None, None, &SplitTarget::None) + .send( + amount, + None, + None, + &SplitTarget::None, + &SendKind::default(), + false, + ) .await .unwrap(); diff --git a/crates/cdk/src/amount.rs b/crates/cdk/src/amount.rs index 0ccdad97..396ec01d 100644 --- a/crates/cdk/src/amount.rs +++ b/crates/cdk/src/amount.rs @@ -2,10 +2,13 @@ //! //! Is any unit and will be treated as the unit of the wallet +use std::cmp::Ordering; use std::fmt; use serde::{Deserialize, Serialize}; +use crate::error::Error; + /// Amount can be any unit #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(transparent)] @@ -28,12 +31,12 @@ impl Amount { } /// Split into parts that are powers of two by target - pub fn split_targeted(&self, target: &SplitTarget) -> Vec { - let mut parts = match *target { + pub fn split_targeted(&self, target: &SplitTarget) -> Result, Error> { + let mut parts = match target { SplitTarget::None => self.split(), SplitTarget::Value(amount) => { - if self.le(&amount) { - return self.split(); + if self.le(amount) { + return Ok(self.split()); } let mut parts_total = Amount::ZERO; @@ -61,10 +64,28 @@ impl Amount { parts } + SplitTarget::Values(values) => { + let values_total: Amount = values.clone().into_iter().sum(); + + match self.cmp(&values_total) { + Ordering::Equal => values.clone(), + Ordering::Less => { + return Err(Error::SplitValuesGreater); + } + Ordering::Greater => { + let extra = *self - values_total; + let mut extra_amount = extra.split(); + let mut values = values.clone(); + + values.append(&mut extra_amount); + values + } + } + } }; parts.sort(); - parts + Ok(parts) } } @@ -162,15 +183,15 @@ impl core::iter::Sum for Amount { } /// Kinds of targeting that are supported -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize, -)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)] pub enum SplitTarget { /// Default target; least amount of proofs #[default] None, /// Target amount for wallet to have most proofs that add up to value Value(Amount), + /// Specific amounts to split into **must** equal amount being split + Values(Vec), } #[cfg(test)] @@ -198,12 +219,16 @@ mod tests { fn test_split_target_amount() { let amount = Amount(65); - let split = amount.split_targeted(&SplitTarget::Value(Amount(32))); + let split = amount + .split_targeted(&SplitTarget::Value(Amount(32))) + .unwrap(); assert_eq!(vec![Amount(1), Amount(32), Amount(32)], split); let amount = Amount(150); - let split = amount.split_targeted(&SplitTarget::Value(Amount::from(50))); + let split = amount + .split_targeted(&SplitTarget::Value(Amount::from(50))) + .unwrap(); assert_eq!( vec![ Amount(2), @@ -221,7 +246,9 @@ mod tests { let amount = Amount::from(63); - let split = amount.split_targeted(&SplitTarget::Value(Amount::from(32))); + let split = amount + .split_targeted(&SplitTarget::Value(Amount::from(32))) + .unwrap(); assert_eq!( vec![ Amount(1), @@ -234,4 +261,31 @@ mod tests { split ); } + + #[test] + fn test_split_values() { + let amount = Amount(10); + + let target = vec![Amount(2), Amount(4), Amount(4)]; + + let split_target = SplitTarget::Values(target.clone()); + + let values = amount.split_targeted(&split_target).unwrap(); + + assert_eq!(target, values); + + let target = vec![Amount(2), Amount(4), Amount(4)]; + + let split_target = SplitTarget::Values(vec![Amount(2), Amount(4)]); + + let values = amount.split_targeted(&split_target).unwrap(); + + assert_eq!(target, values); + + let split_target = SplitTarget::Values(vec![Amount(2), Amount(10)]); + + let values = amount.split_targeted(&split_target); + + assert!(values.is_err()) + } } diff --git a/crates/cdk/src/error.rs b/crates/cdk/src/error.rs index 335cacd3..3dd8cf1f 100644 --- a/crates/cdk/src/error.rs +++ b/crates/cdk/src/error.rs @@ -54,6 +54,9 @@ pub enum Error { /// No valid point on curve #[error("No valid point found")] NoValidPoint, + /// Split Values must be less then or equal to amount + #[error("Split Values must be less then or equal to amount")] + SplitValuesGreater, /// Secp256k1 error #[error(transparent)] Secp256k1(#[from] bitcoin::secp256k1::Error), diff --git a/crates/cdk/src/mint/error.rs b/crates/cdk/src/mint/error.rs index 9b89936e..dee98670 100644 --- a/crates/cdk/src/mint/error.rs +++ b/crates/cdk/src/mint/error.rs @@ -20,6 +20,9 @@ pub enum Error { /// Amount is not what is expected #[error("Amount")] Amount, + /// Not engough inputs provided + #[error("Inputs: `{0}`, Outputs: `{0}`, Fee: `{0}`")] + InsufficientInputs(u64, u64, u64), /// Duplicate proofs provided #[error("Duplicate proofs")] DuplicateProofs, diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index d583ef9e..7b59eeee 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -8,6 +8,7 @@ use bitcoin::secp256k1::{self, Secp256k1}; use error::Error; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; +use tracing::instrument; use self::nut05::QuoteState; use crate::cdk_database::{self, MintDatabase}; @@ -47,6 +48,7 @@ impl Mint { localstore: Arc + Send + Sync>, min_fee_reserve: Amount, percent_fee_reserve: f32, + input_fee_ppk: u64, ) -> Result { let secp_ctx = Secp256k1::new(); let xpriv = @@ -58,6 +60,9 @@ impl Mint { match keysets_infos.is_empty() { false => { for keyset_info in keysets_infos { + let mut keyset_info = keyset_info; + keyset_info.input_fee_ppk = input_fee_ppk; + localstore.add_keyset_info(keyset_info.clone()).await?; if keyset_info.active { let id = keyset_info.id; let keyset = MintKeySet::generate_from_xpriv(&secp_ctx, xpriv, keyset_info); @@ -69,8 +74,14 @@ impl Mint { let derivation_path = DerivationPath::from(vec![ ChildNumber::from_hardened_idx(0).expect("0 is a valid index") ]); - let (keyset, keyset_info) = - create_new_keyset(&secp_ctx, xpriv, derivation_path, CurrencyUnit::Sat, 64); + let (keyset, keyset_info) = create_new_keyset( + &secp_ctx, + xpriv, + derivation_path, + CurrencyUnit::Sat, + 64, + input_fee_ppk, + ); let id = keyset_info.id; localstore.add_keyset_info(keyset_info).await?; localstore.add_active_keyset(CurrencyUnit::Sat, id).await?; @@ -95,26 +106,31 @@ impl Mint { } /// Set Mint Url + #[instrument(skip_all)] pub fn set_mint_url(&mut self, mint_url: UncheckedUrl) { self.mint_url = mint_url; } /// Get Mint Url + #[instrument(skip_all)] pub fn get_mint_url(&self) -> &UncheckedUrl { &self.mint_url } /// Set Mint Info + #[instrument(skip_all)] pub fn set_mint_info(&mut self, mint_info: MintInfo) { self.mint_info = mint_info; } /// Get Mint Info + #[instrument(skip_all)] pub fn mint_info(&self) -> &MintInfo { &self.mint_info } /// New mint quote + #[instrument(skip_all)] pub async fn new_mint_quote( &self, mint_url: UncheckedUrl, @@ -139,6 +155,7 @@ impl Mint { } /// Check mint quote + #[instrument(skip(self))] pub async fn check_mint_quote(&self, quote_id: &str) -> Result { let quote = self .localstore @@ -165,18 +182,21 @@ impl Mint { } /// Update mint quote + #[instrument(skip_all)] pub async fn update_mint_quote(&self, quote: MintQuote) -> Result<(), Error> { self.localstore.add_mint_quote(quote).await?; Ok(()) } /// Get mint quotes + #[instrument(skip_all)] pub async fn mint_quotes(&self) -> Result, Error> { let quotes = self.localstore.get_mint_quotes().await?; Ok(quotes) } /// Get pending mint quotes + #[instrument(skip_all)] pub async fn get_pending_mint_quotes(&self) -> Result, Error> { let mint_quotes = self.localstore.get_mint_quotes().await?; @@ -187,6 +207,7 @@ impl Mint { } /// Remove mint quote + #[instrument(skip_all)] pub async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Error> { self.localstore.remove_mint_quote(quote_id).await?; @@ -194,6 +215,7 @@ impl Mint { } /// New melt quote + #[instrument(skip_all)] pub async fn new_melt_quote( &self, request: String, @@ -217,7 +239,28 @@ impl Mint { Ok(quote) } + /// Fee required for proof set + #[instrument(skip_all)] + pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result { + let mut sum_fee = 0; + + for proof in proofs { + let input_fee_ppk = self + .localstore + .get_keyset_info(&proof.keyset_id) + .await? + .ok_or(Error::UnknownKeySet)?; + + sum_fee += input_fee_ppk.input_fee_ppk; + } + + let fee = (sum_fee + 999) / 1000; + + Ok(Amount::from(fee)) + } + /// Check melt quote status + #[instrument(skip(self))] pub async fn check_melt_quote(&self, quote_id: &str) -> Result { let quote = self .localstore @@ -238,26 +281,29 @@ impl Mint { } /// Update melt quote + #[instrument(skip_all)] pub async fn update_melt_quote(&self, quote: MeltQuote) -> Result<(), Error> { self.localstore.add_melt_quote(quote).await?; Ok(()) } /// Get melt quotes + #[instrument(skip_all)] pub async fn melt_quotes(&self) -> Result, Error> { let quotes = self.localstore.get_melt_quotes().await?; Ok(quotes) } /// Remove melt quote + #[instrument(skip(self))] pub async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Error> { self.localstore.remove_melt_quote(quote_id).await?; Ok(()) } - /// Retrieve the public keys of the active keyset for distribution to - /// wallet clients + /// Retrieve the public keys of the active keyset for distribution to wallet clients + #[instrument(skip(self))] pub async fn keyset_pubkeys(&self, keyset_id: &Id) -> Result { self.ensure_keyset_loaded(keyset_id).await?; let keysets = self.keysets.read().await; @@ -267,8 +313,8 @@ impl Mint { }) } - /// Retrieve the public keys of the active keyset for distribution to - /// wallet clients + /// Retrieve the public keys of the active keyset for distribution to wallet clients + #[instrument(skip_all)] pub async fn pubkeys(&self) -> Result { let keyset_infos = self.localstore.get_keyset_infos().await?; for keyset_info in keyset_infos { @@ -281,6 +327,7 @@ impl Mint { } /// Return a list of all supported keysets + #[instrument(skip_all)] pub async fn keysets(&self) -> Result { let keysets = self.localstore.get_keyset_infos().await?; let active_keysets: HashSet = self @@ -297,6 +344,7 @@ impl Mint { id: k.id, unit: k.unit, active: active_keysets.contains(&k.id), + input_fee_ppk: k.input_fee_ppk, }) .collect(); @@ -304,6 +352,7 @@ impl Mint { } /// Get keysets + #[instrument(skip(self))] pub async fn keyset(&self, id: &Id) -> Result, Error> { self.ensure_keyset_loaded(id).await?; let keysets = self.keysets.read().await; @@ -313,14 +362,22 @@ impl Mint { /// Add current keyset to inactive keysets /// Generate new keyset + #[instrument(skip(self))] pub async fn rotate_keyset( &self, unit: CurrencyUnit, derivation_path: DerivationPath, max_order: u8, + input_fee_ppk: u64, ) -> Result<(), Error> { - let (keyset, keyset_info) = - create_new_keyset(&self.secp_ctx, self.xpriv, derivation_path, unit, max_order); + let (keyset, keyset_info) = create_new_keyset( + &self.secp_ctx, + self.xpriv, + derivation_path, + unit, + max_order, + input_fee_ppk, + ); let id = keyset_info.id; self.localstore.add_keyset_info(keyset_info).await?; self.localstore.add_active_keyset(unit, id).await?; @@ -332,6 +389,7 @@ impl Mint { } /// Process mint request + #[instrument(skip_all)] pub async fn process_mint_request( &self, mint_request: nut04::MintBolt11Request, @@ -397,6 +455,7 @@ impl Mint { } /// Blind Sign + #[instrument(skip_all)] pub async fn blind_sign( &self, blinded_message: &BlindedMessage, @@ -447,6 +506,7 @@ impl Mint { } /// Process Swap + #[instrument(skip_all)] pub async fn process_swap_request( &self, swap_request: SwapRequest, @@ -470,8 +530,20 @@ impl Mint { let output_total = swap_request.output_amount(); - if proofs_total != output_total { - return Err(Error::Amount); + let fee = self.get_proofs_fee(&swap_request.inputs).await?; + + if proofs_total < output_total + fee { + tracing::info!( + "Swap request without enough inputs: {}, outputs {}, fee {}", + proofs_total, + output_total, + fee + ); + return Err(Error::InsufficientInputs( + proofs_total.into(), + output_total.into(), + fee.into(), + )); } let proof_count = swap_request.inputs.len(); @@ -554,6 +626,7 @@ impl Mint { } /// Verify [`Proof`] meets conditions and is signed + #[instrument(skip_all)] pub async fn verify_proof(&self, proof: &Proof) -> Result<(), Error> { // Check if secret is a nut10 secret with conditions if let Ok(secret) = @@ -597,6 +670,7 @@ impl Mint { } /// Check state + #[instrument(skip_all)] pub async fn check_state( &self, check_state: &CheckStateRequest, @@ -622,6 +696,7 @@ impl Mint { } /// Verify melt request is valid + #[instrument(skip_all)] pub async fn verify_melt_request( &self, melt_request: &MeltBolt11Request, @@ -653,15 +728,23 @@ impl Mint { let proofs_total = melt_request.proofs_amount(); - let required_total = quote.amount + quote.fee_reserve; + let fee = self.get_proofs_fee(&melt_request.inputs).await?; + + let required_total = quote.amount + quote.fee_reserve + fee; if proofs_total < required_total { - tracing::debug!( - "Insufficient Proofs: Got: {}, Required: {}", + tracing::info!( + "Swap request without enough inputs: {}, quote amount {}, fee_reserve: {} fee {}", proofs_total, - required_total + quote.amount, + quote.fee_reserve, + fee ); - return Err(Error::Amount); + return Err(Error::InsufficientInputs( + proofs_total.into(), + (quote.amount + quote.fee_reserve).into(), + fee.into(), + )); } let input_keyset_ids: HashSet = @@ -740,6 +823,7 @@ impl Mint { /// Process unpaid melt request /// In the event that a melt request fails and the lighthing payment is not made /// The [`Proofs`] should be returned to an unspent state and the quote should be unpaid + #[instrument(skip_all)] pub async fn process_unpaid_melt(&self, melt_request: &MeltBolt11Request) -> Result<(), Error> { self.localstore .remove_pending_proofs(melt_request.inputs.iter().map(|p| &p.secret).collect()) @@ -754,6 +838,7 @@ impl Mint { /// Process melt request marking [`Proofs`] as spent /// The melt request must be verifyed using [`Self::verify_melt_request`] before calling [`Self::process_melt_request`] + #[instrument(skip_all)] pub async fn process_melt_request( &self, melt_request: &MeltBolt11Request, @@ -851,6 +936,7 @@ impl Mint { } /// Restore + #[instrument(skip_all)] pub async fn restore(&self, request: RestoreRequest) -> Result { let output_len = request.outputs.len(); @@ -883,6 +969,7 @@ impl Mint { } /// Ensure Keyset is loaded in mint + #[instrument(skip(self))] pub async fn ensure_keyset_loaded(&self, id: &Id) -> Result<(), Error> { let keysets = self.keysets.read().await; if keysets.contains_key(id) { @@ -902,6 +989,7 @@ impl Mint { } /// Generate [`MintKeySet`] from [`MintKeySetInfo`] + #[instrument(skip_all)] pub fn generate_keyset(&self, keyset_info: MintKeySetInfo) -> MintKeySet { MintKeySet::generate_from_xpriv(&self.secp_ctx, self.xpriv, keyset_info) } @@ -935,6 +1023,13 @@ pub struct MintKeySetInfo { pub derivation_path: DerivationPath, /// Max order of keyset pub max_order: u8, + /// Input Fee ppk + #[serde(default = "default_fee")] + pub input_fee_ppk: u64, +} + +fn default_fee() -> u64 { + 0 } impl From for KeySetInfo { @@ -943,17 +1038,20 @@ impl From for KeySetInfo { id: keyset_info.id, unit: keyset_info.unit, active: keyset_info.active, + input_fee_ppk: keyset_info.input_fee_ppk, } } } /// Generate new [`MintKeySetInfo`] from path +#[instrument(skip_all)] fn create_new_keyset( secp: &secp256k1::Secp256k1, xpriv: ExtendedPrivKey, derivation_path: DerivationPath, unit: CurrencyUnit, max_order: u8, + input_fee_ppk: u64, ) -> (MintKeySet, MintKeySetInfo) { let keyset = MintKeySet::generate( secp, @@ -971,6 +1069,7 @@ fn create_new_keyset( valid_to: None, derivation_path, max_order, + input_fee_ppk, }; (keyset, keyset_info) } diff --git a/crates/cdk/src/nuts/nut00/mod.rs b/crates/cdk/src/nuts/nut00/mod.rs index 3fa259fc..0aa35f3c 100644 --- a/crates/cdk/src/nuts/nut00/mod.rs +++ b/crates/cdk/src/nuts/nut00/mod.rs @@ -444,20 +444,30 @@ impl PartialOrd for PreMint { } /// Premint Secrets -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct PreMintSecrets { /// Secrets pub secrets: Vec, + /// Keyset Id + pub keyset_id: Id, } impl PreMintSecrets { + /// Create new [`PreMintSecrets`] + pub fn new(keyset_id: Id) -> Self { + Self { + secrets: Vec::new(), + keyset_id, + } + } + /// Outputs for speceifed amount with random secret pub fn random( keyset_id: Id, amount: Amount, amount_split_target: &SplitTarget, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target); + let amount_split = amount.split_targeted(amount_split_target)?; let mut output = Vec::with_capacity(amount_split.len()); @@ -475,7 +485,10 @@ impl PreMintSecrets { }); } - Ok(PreMintSecrets { secrets: output }) + Ok(PreMintSecrets { + secrets: output, + keyset_id, + }) } /// Outputs from pre defined secrets @@ -499,7 +512,10 @@ impl PreMintSecrets { }); } - Ok(PreMintSecrets { secrets: output }) + Ok(PreMintSecrets { + secrets: output, + keyset_id, + }) } /// Blank Outputs used for NUT-08 change @@ -522,7 +538,10 @@ impl PreMintSecrets { }) } - Ok(PreMintSecrets { secrets: output }) + Ok(PreMintSecrets { + secrets: output, + keyset_id, + }) } /// Outputs with specific spending conditions @@ -532,7 +551,7 @@ impl PreMintSecrets { amount_split_target: &SplitTarget, conditions: &SpendingConditions, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target); + let amount_split = amount.split_targeted(amount_split_target)?; let mut output = Vec::with_capacity(amount_split.len()); @@ -552,7 +571,10 @@ impl PreMintSecrets { }); } - Ok(PreMintSecrets { secrets: output }) + Ok(PreMintSecrets { + secrets: output, + keyset_id, + }) } /// Iterate over secrets diff --git a/crates/cdk/src/nuts/nut02.rs b/crates/cdk/src/nuts/nut02.rs index 6824346e..04146218 100644 --- a/crates/cdk/src/nuts/nut02.rs +++ b/crates/cdk/src/nuts/nut02.rs @@ -230,15 +230,6 @@ pub struct KeysetResponse { pub keysets: Vec, } -impl KeysetResponse { - /// Create new [`KeysetResponse`] - pub fn new(keysets: Vec) -> Self { - Self { - keysets: keysets.into_iter().map(|keyset| keyset.into()).collect(), - } - } -} - /// Keyset #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct KeySet { @@ -271,16 +262,13 @@ pub struct KeySetInfo { /// Keyset state /// Mint will only sign from an active keyset pub active: bool, + /// Input Fee PPK + #[serde(default = "default_input_fee_ppk")] + pub input_fee_ppk: u64, } -impl From for KeySetInfo { - fn from(keyset: KeySet) -> KeySetInfo { - Self { - id: keyset.id, - unit: keyset.unit, - active: false, - } - } +fn default_input_fee_ppk() -> u64 { + 0 } /// MintKeyset @@ -504,7 +492,7 @@ mod test { #[test] fn test_deserialization_of_keyset_response() { - let h = r#"{"keysets":[{"id":"009a1f293253e41e","unit":"sat","active":true},{"id":"eGnEWtdJ0PIM","unit":"sat","active":true},{"id":"003dfdf4e5e35487","unit":"sat","active":true},{"id":"0066ad1a4b6fc57c","unit":"sat","active":true},{"id":"00f7ca24d44c3e5e","unit":"sat","active":true},{"id":"001fcea2931f2d85","unit":"sat","active":true},{"id":"00d095959d940edb","unit":"sat","active":true},{"id":"000d7f730d657125","unit":"sat","active":true},{"id":"0007208d861d7295","unit":"sat","active":true},{"id":"00bfdf8889b719dd","unit":"sat","active":true},{"id":"00ca9b17da045f21","unit":"sat","active":true}]}"#; + let h = r#"{"keysets":[{"id":"009a1f293253e41e","unit":"sat","active":true, "input_fee_ppk": 100},{"id":"eGnEWtdJ0PIM","unit":"sat","active":true},{"id":"003dfdf4e5e35487","unit":"sat","active":true},{"id":"0066ad1a4b6fc57c","unit":"sat","active":true},{"id":"00f7ca24d44c3e5e","unit":"sat","active":true},{"id":"001fcea2931f2d85","unit":"sat","active":true},{"id":"00d095959d940edb","unit":"sat","active":true},{"id":"000d7f730d657125","unit":"sat","active":true},{"id":"0007208d861d7295","unit":"sat","active":true},{"id":"00bfdf8889b719dd","unit":"sat","active":true},{"id":"00ca9b17da045f21","unit":"sat","active":true}]}"#; let _keyset_response: KeysetResponse = serde_json::from_str(h).unwrap(); } diff --git a/crates/cdk/src/nuts/nut03.rs b/crates/cdk/src/nuts/nut03.rs index 8c4c5b96..21a91302 100644 --- a/crates/cdk/src/nuts/nut03.rs +++ b/crates/cdk/src/nuts/nut03.rs @@ -16,6 +16,8 @@ pub struct PreSwap { pub swap_request: SwapRequest, /// Amount to increment keyset counter by pub derived_secret_count: u32, + /// Fee amount + pub fee: Amount, } /// Split Request [NUT-06] diff --git a/crates/cdk/src/nuts/nut13.rs b/crates/cdk/src/nuts/nut13.rs index a33d7c19..386a7523 100644 --- a/crates/cdk/src/nuts/nut13.rs +++ b/crates/cdk/src/nuts/nut13.rs @@ -3,6 +3,7 @@ //! use bitcoin::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey}; +use tracing::instrument; use super::nut00::{BlindedMessage, PreMint, PreMintSecrets}; use super::nut01::SecretKey; @@ -43,6 +44,7 @@ impl SecretKey { impl PreMintSecrets { /// Generate blinded messages from predetermined secrets and blindings /// factor + #[instrument(skip(xpriv))] pub fn from_xpriv( keyset_id: Id, counter: u32, @@ -50,11 +52,11 @@ impl PreMintSecrets { amount: Amount, amount_split_target: &SplitTarget, ) -> Result { - let mut pre_mint_secrets = PreMintSecrets::default(); + let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); let mut counter = counter; - for amount in amount.split_targeted(amount_split_target) { + for amount in amount.split_targeted(amount_split_target)? { let secret = Secret::from_xpriv(xpriv, keyset_id, counter)?; let blinding_factor = SecretKey::from_xpriv(xpriv, keyset_id, counter)?; @@ -84,10 +86,10 @@ impl PreMintSecrets { amount: Amount, ) -> Result { if amount <= Amount::ZERO { - return Ok(PreMintSecrets::default()); + return Ok(PreMintSecrets::new(keyset_id)); } let count = ((u64::from(amount) as f64).log2().ceil() as u64).max(1); - let mut pre_mint_secrets = PreMintSecrets::default(); + let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); let mut counter = counter; @@ -115,15 +117,14 @@ impl PreMintSecrets { Ok(pre_mint_secrets) } - /// Generate blinded messages from predetermined secrets and blindings - /// factor + /// Generate blinded messages from predetermined secrets and blindings factor pub fn restore_batch( keyset_id: Id, xpriv: ExtendedPrivKey, start_count: u32, end_count: u32, ) -> Result { - let mut pre_mint_secrets = PreMintSecrets::default(); + let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); for i in start_count..=end_count { let secret = Secret::from_xpriv(xpriv, keyset_id, i)?; diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 114a857e..ec56c309 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -13,17 +13,16 @@ use bitcoin::key::XOnlyPublicKey; use bitcoin::Network; use error::Error; use tracing::instrument; -use url::Url; use crate::amount::SplitTarget; use crate::cdk_database::{self, WalletDatabase}; use crate::dhke::{construct_proofs, hash_to_curve}; use crate::nuts::nut00::token::Token; use crate::nuts::{ - nut10, nut12, Conditions, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, Kind, - MeltQuoteBolt11Response, MeltQuoteState, MintInfo, MintQuoteBolt11Response, MintQuoteState, - PreMintSecrets, PreSwap, Proof, ProofState, Proofs, PublicKey, RestoreRequest, SecretKey, - SigFlag, SpendingConditions, State, SwapRequest, + nut10, nut12, Conditions, CurrencyUnit, Id, KeySetInfo, Keys, Kind, MeltQuoteBolt11Response, + MeltQuoteState, MintInfo, MintQuoteBolt11Response, MintQuoteState, PreMintSecrets, PreSwap, + Proof, ProofState, Proofs, PublicKey, RestoreRequest, SecretKey, SigFlag, SpendingConditions, + State, SwapRequest, }; use crate::types::{Melted, ProofInfo}; use crate::url::UncheckedUrl; @@ -36,7 +35,7 @@ pub mod multi_mint_wallet; pub mod types; pub mod util; -pub use types::{MeltQuote, MintQuote}; +pub use types::{MeltQuote, MintQuote, SendKind}; /// CDK Wallet #[derive(Debug, Clone)] @@ -45,10 +44,12 @@ pub struct Wallet { pub mint_url: UncheckedUrl, /// Unit pub unit: CurrencyUnit, - client: HttpClient, /// Storage backend pub localstore: Arc + Send + Sync>, + /// The targeted amount of proofs to have at each size + pub target_proof_count: usize, xpriv: ExtendedPrivKey, + client: HttpClient, } impl Wallet { @@ -58,6 +59,7 @@ impl Wallet { unit: CurrencyUnit, localstore: Arc + Send + Sync>, seed: &[u8], + target_proof_count: Option, ) -> Self { let xpriv = ExtendedPrivKey::new_master(Network::Bitcoin, seed) .expect("Could not create master key"); @@ -68,10 +70,46 @@ impl Wallet { client: HttpClient::new(), localstore, xpriv, + target_proof_count: target_proof_count.unwrap_or(3), } } - /// Total Balance of wallet + /// Fee required for proof set + #[instrument(skip_all)] + pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result { + let mut sum_fee = 0; + + for proof in proofs { + let input_fee_ppk = self + .localstore + .get_keyset_by_id(&proof.keyset_id) + .await? + .ok_or(Error::UnknownKey)?; + + sum_fee += input_fee_ppk.input_fee_ppk; + } + + let fee = (sum_fee + 999) / 1000; + + Ok(Amount::from(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 { + let input_fee_ppk = self + .localstore + .get_keyset_by_id(keyset_id) + .await? + .ok_or(Error::UnknownKey)? + .input_fee_ppk; + + let fee = (input_fee_ppk * count + 999) / 1000; + + Ok(Amount::from(fee)) + } + + /// Total unspent balance of wallet #[instrument(skip(self))] pub async fn total_balance(&self) -> Result { if let Some(proofs) = self @@ -92,7 +130,7 @@ impl Wallet { Ok(Amount::ZERO) } - /// Total balance of pending proofs + /// Total pending balance #[instrument(skip(self))] pub async fn total_pending_balance(&self) -> Result, Error> { let mut balances = HashMap::new(); @@ -118,7 +156,7 @@ impl Wallet { Ok(balances) } - /// Total balance of reserved proofs + /// Total reserved balance #[instrument(skip(self))] pub async fn total_reserved_balance(&self) -> Result, Error> { let mut balances = HashMap::new(); @@ -159,7 +197,7 @@ impl Wallet { /// Get unspent proofs for mint #[instrument(skip(self))] - pub async fn get_proofs(&self) -> Result, Error> { + pub async fn get_proofs(&self) -> Result { Ok(self .localstore .get_proofs( @@ -169,7 +207,8 @@ impl Wallet { None, ) .await? - .map(|p| p.into_iter().map(|p| p.proof).collect())) + .map(|p| p.into_iter().map(|p| p.proof).collect()) + .unwrap_or_default()) } /// Get pending [`Proofs`] @@ -270,115 +309,49 @@ impl Wallet { Ok(keysets.keysets) } - /// Get active mint keyset + /// Get active keyset for mint + /// Quieries mint for current keysets then gets Keys for any unknown keysets #[instrument(skip(self))] - pub async fn get_active_mint_keys(&self) -> Result, Error> { - let mint_url: Url = self.mint_url.clone().try_into()?; - let keysets = self.client.get_mint_keys(mint_url.clone()).await?; - - for keyset in keysets.clone() { - self.localstore.add_keys(keyset.keys).await?; - } - - let k = self.client.get_mint_keysets(mint_url).await?; + pub async fn get_active_mint_keyset(&self) -> Result { + 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(), k.keysets) + .add_mint_keysets(self.mint_url.clone(), keysets.clone()) .await?; - Ok(keysets) - } + let active_keysets = keysets + .clone() + .into_iter() + .filter(|k| k.active && k.unit == self.unit) + .collect::>(); - /// Refresh Mint keys - #[instrument(skip(self))] - pub async fn refresh_mint_keys(&self) -> Result<(), Error> { - let mint_url = &self.mint_url.clone(); - let current_mint_keysets_info = self - .client - .get_mint_keysets(mint_url.try_into()?) + match self + .localstore + .get_mint_keysets(self.mint_url.clone()) .await? - .keysets; - - match self.localstore.get_mint_keysets(mint_url.clone()).await? { - Some(stored_keysets) => { - let mut unseen_keysets = current_mint_keysets_info.clone(); - unseen_keysets.retain(|ks| !stored_keysets.contains(ks)); - - for keyset in unseen_keysets { - let keys = self - .client - .get_mint_keyset(mint_url.try_into()?, keyset.id) - .await?; + { + Some(known_keysets) => { + let unknown_keysets: Vec<&KeySetInfo> = keysets + .iter() + .filter(|k| known_keysets.contains(k)) + .collect(); - self.localstore.add_keys(keys.keys).await?; + for keyset in unknown_keysets { + self.get_keyset_keys(keyset.id).await?; } } None => { - let mint_keys = self.client.get_mint_keys(mint_url.try_into()?).await?; - - for keys in mint_keys { - self.localstore.add_keys(keys.keys).await?; + for keyset in keysets { + self.get_keyset_keys(keyset.id).await?; } } } - self.localstore - .add_mint_keysets(mint_url.clone(), current_mint_keysets_info) - .await?; - - Ok(()) - } - - /// Get Active mint keyset id - #[instrument(skip(self))] - pub async fn active_mint_keyset(&self) -> Result { - let mint_url = &self.mint_url; - let unit = &self.unit; - if let Some(keysets) = self.localstore.get_mint_keysets(mint_url.clone()).await? { - for keyset in keysets { - if keyset.unit.eq(unit) && keyset.active { - return Ok(keyset.id); - } - } - } - - let keysets = self.client.get_mint_keysets(mint_url.try_into()?).await?; - - self.localstore - .add_mint_keysets( - mint_url.clone(), - keysets.keysets.clone().into_iter().collect(), - ) - .await?; - for keyset in &keysets.keysets { - if keyset.unit.eq(unit) && keyset.active { - return Ok(keyset.id); - } - } - - Err(Error::NoActiveKeyset) - } - - /// Get active mint keys - #[instrument(skip(self))] - pub async fn active_keys(&self) -> Result, Error> { - let active_keyset_id = self.active_mint_keyset().await?; - - let keys; - - if let Some(k) = self.localstore.get_keys(&active_keyset_id).await? { - keys = Some(k.clone()) - } else { - let keyset = self - .client - .get_mint_keyset(self.mint_url.clone().try_into()?, active_keyset_id) - .await?; - - self.localstore.add_keys(keyset.keys.clone()).await?; - keys = Some(keyset.keys); - } - - Ok(keys) + active_keysets.first().ok_or(Error::NoActiveKeyset).cloned() } /// Reclaim unspent proofs @@ -402,7 +375,7 @@ impl Wallet { .filter_map(|(p, s)| (s.state == State::Unspent).then_some(p)) .collect(); - self.swap(None, &SplitTarget::default(), unspent, None) + self.swap(None, SplitTarget::default(), unspent, None, false) .await?; Ok(()) @@ -569,7 +542,7 @@ impl Wallet { return Err(Error::QuoteUnknown); }; - let active_keyset_id = self.active_mint_keyset().await?; + let active_keyset_id = self.get_active_mint_keyset().await?.id; let count = self .localstore @@ -654,124 +627,59 @@ impl Wallet { Ok(minted_amount) } - /// Swap - #[instrument(skip(self, input_proofs))] - pub async fn swap( - &self, - amount: Option, - amount_split_target: &SplitTarget, - input_proofs: Proofs, - spending_conditions: Option, - ) -> Result, 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, - ) - .await?; - - let swap_response = self - .client - .post_swap(mint_url.clone().try_into()?, pre_swap.swap_request) - .await?; - - let active_keys = self.active_keys().await?.unwrap(); - - let mut post_swap_proofs = construct_proofs( - swap_response.signatures, - pre_swap.pre_mint_secrets.rs(), - pre_swap.pre_mint_secrets.secrets(), - &active_keys, - )?; - - let active_keyset_id = self.active_mint_keyset().await?; - - self.localstore - .increment_keyset_counter(&active_keyset_id, pre_swap.derived_secret_count) - .await?; - - let mut keep_proofs = Proofs::new(); - let proofs_to_send; - - match amount { - Some(amount) => { - post_swap_proofs.reverse(); + /// Get amounts needed to refill proof state + #[instrument(skip(self))] + pub async fn amounts_needed_for_state_target(&self) -> Result, Error> { + let unspent_proofs = self.get_proofs().await?; - let mut left_proofs = vec![]; - let mut send_proofs = vec![]; + let amounts_count: HashMap = + 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 + }); - for proof in post_swap_proofs { - let nut10: Result = proof.secret.clone().try_into(); + let all_possible_amounts: Vec = (0..32).map(|i| 2usize.pow(i as u32)).collect(); - match nut10 { - Ok(_) => send_proofs.push(proof), - Err(_) => left_proofs.push(proof), - } - } + 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 proof in left_proofs { - if (proof.amount + send_proofs.iter().map(|p| p.amount).sum()).gt(&amount) { - keep_proofs.push(proof); - } else { - send_proofs.push(proof); - } + for _i in 0..count_needed { + acc.push(Amount::from(*amount as u64)); } - let send_amount: Amount = send_proofs.iter().map(|p| p.amount).sum(); + acc + }); + Ok(needed_amounts) + } - if send_amount.ne(&amount) { - tracing::warn!( - "Send amount proofs is {:?} expected {:?}", - send_amount, - amount - ); - } + /// Determine [`SplitTarget`] for amount based on state + #[instrument(skip(self))] + async fn determine_split_target_values( + &self, + change_amount: Amount, + ) -> Result { + let mut amounts_needed_refill = self.amounts_needed_for_state_target().await?; - let send_proofs_info = send_proofs - .clone() - .into_iter() - .flat_map(|proof| { - ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit) - }) - .collect(); + amounts_needed_refill.sort(); - self.localstore.add_proofs(send_proofs_info).await?; + let mut values = Vec::new(); - proofs_to_send = Some(send_proofs); - } - None => { - keep_proofs = post_swap_proofs; - proofs_to_send = None; + for amount in amounts_needed_refill { + let values_sum: Amount = values.clone().into_iter().sum(); + if values_sum + amount <= change_amount { + values.push(amount); } } - for proof in input_proofs { - self.localstore - .set_proof_state(proof.y()?, State::Reserved) - .await?; - } - - if let Some(proofs) = proofs_to_send.clone() { - let send_proofs = proofs - .into_iter() - .flat_map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit)) - .collect(); - - self.localstore.add_proofs(send_proofs).await?; - } - - let keep_proofs = keep_proofs - .into_iter() - .flat_map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit)) - .collect(); - - self.localstore.add_proofs(keep_proofs).await?; - - Ok(proofs_to_send) + Ok(SplitTarget::Values(values)) } /// Create Swap Payload @@ -779,35 +687,71 @@ impl Wallet { pub async fn create_swap( &self, amount: Option, - amount_split_target: &SplitTarget, + amount_split_target: SplitTarget, proofs: Proofs, spending_conditions: Option, + include_fees: bool, ) -> Result { - let active_keyset_id = self.active_mint_keyset().await.unwrap(); + let active_keyset_id = self.get_active_mint_keyset().await?.id; - // Desired amount is either amount passwed or value of all proof - let proofs_total = proofs.iter().map(|p| p.amount).sum(); + // Desired amount is either amount passed or value of all proof + let proofs_total: Amount = proofs.iter().map(|p| p.amount).sum(); - let desired_amount = amount.unwrap_or(proofs_total); - let change_amount = proofs_total - desired_amount; + for proof in proofs.iter() { + self.localstore + .set_proof_state(proof.y()?, State::Pending) + .await + .ok(); + } - let derived_secret_count; + let fee = self.get_proofs_fee(&proofs).await?; - let (mut desired_messages, change_messages) = match spending_conditions { - Some(conditions) => { - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) + 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_redeam = self + .get_keyset_count_fee(&active_keyset_id, split_count as u64) .await?; - let count = count.map_or(0, |c| c + 1); + ( + amount.map(|a| a + fee_to_redeam), + change_amount - fee_to_redeam, + ) + } + 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, - amount_split_target, + &change_split_target, )?; derived_secret_count = change_premint_secrets.len(); @@ -815,27 +759,20 @@ impl Wallet { ( PreMintSecrets::with_conditions( active_keyset_id, - desired_amount, - amount_split_target, + send_amount.unwrap_or(Amount::ZERO), + &SplitTarget::default(), &conditions, )?, change_premint_secrets, ) } None => { - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; - - let mut count = count.map_or(0, |c| c + 1); - let premint_secrets = PreMintSecrets::from_xpriv( active_keyset_id, count, self.xpriv, - desired_amount, - amount_split_target, + send_amount.unwrap_or(Amount::ZERO), + &SplitTarget::default(), )?; count += premint_secrets.len() as u32; @@ -845,7 +782,7 @@ impl Wallet { count, self.xpriv, change_amount, - amount_split_target, + &change_split_target, )?; derived_secret_count = change_premint_secrets.len() + premint_secrets.len(); @@ -865,9 +802,167 @@ impl Wallet { pre_mint_secrets: desired_messages, swap_request, derived_secret_count: derived_secret_count as u32, + fee, }) } + /// Swap + #[instrument(skip(self, input_proofs))] + pub async fn swap( + &self, + amount: Option, + amount_split_target: SplitTarget, + input_proofs: Proofs, + spending_conditions: Option, + include_fees: bool, + ) -> Result, 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 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 = 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 = + proofs_to_send.iter().map(|p| p.amount).sum::(); + 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 = proofs_to_send.iter().map(|p| p.amount).sum(); + + 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() + .flat_map(|proof| { + ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit) + }) + .collect(); + + self.localstore.add_proofs(send_proofs_info).await?; + + 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() + .flat_map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit)) + .collect(); + + self.localstore.add_proofs(keep_proofs).await?; + + // Remove spent proofs used as inputs + self.localstore.remove_proofs(&input_proofs).await?; + + Ok(send_proofs) + } + + #[instrument(skip(self))] + async fn swap_from_unspent( + &self, + amount: Amount, + conditions: Option, + include_fees: bool, + ) -> Result { + let available_proofs = self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(self.unit), + Some(vec![State::Unspent]), + None, + ) + .await? + .ok_or(Error::InsufficientFunds)?; + + let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect(); + + 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) + } + /// Send #[instrument(skip(self))] pub async fn send( @@ -876,61 +971,137 @@ impl Wallet { memo: Option, conditions: Option, amount_split_target: &SplitTarget, + send_kind: &SendKind, + include_fees: bool, ) -> Result { + // 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? + .unwrap_or_default(); - let (condition_input_proofs, input_proofs) = self - .select_proofs(amount, conditions.clone().map(|p| vec![p])) - .await?; + let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect(); - let send_proofs = match conditions { - Some(_) => { - let condition_input_proof_total = condition_input_proofs - .iter() - .map(|p| p.amount) - .sum::(); - assert!(condition_input_proof_total.le(&amount)); - let needed_amount = amount - condition_input_proof_total; - - let top_up_proofs = match needed_amount > Amount::ZERO { - true => { - self.swap( - Some(needed_amount), - amount_split_target, - input_proofs, - conditions, - ) + 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 = + selected_proofs.iter().map(|p| p.amount).sum::(); + + 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 = + selected_proofs.iter().map(|p| p.amount).sum::(); + + 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? - } - false => Some(vec![]), + } + } + + // Handle offline tolerance + (SendKind::OfflineTolerance(tolerance), Ok(selected_proofs), _) => { + let selected_proofs_amount = + selected_proofs.iter().map(|p| p.amount).sum::(); + + 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); + } + } - Some( - [ - condition_input_proofs, - top_up_proofs.ok_or(Error::InsufficientFunds)?, - ] - .concat(), - ) + // 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? } - None => { - match input_proofs - .iter() - .map(|p| p.amount) - .sum::() - .eq(&amount) - { - true => Some(input_proofs), - false => { - self.swap(Some(amount), amount_split_target, input_proofs, conditions) - .await? - } + + // Handle online tolerance with successful selection + (SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => { + let selected_proofs_amount = + selected_proofs.iter().map(|p| p.amount).sum::(); + 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); + } }; - let send_proofs = send_proofs.ok_or(Error::InsufficientFunds)?; for proof in send_proofs.iter() { self.localstore .set_proof_state(proof.y()?, State::Reserved) @@ -1031,30 +1202,11 @@ impl Wallet { let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve; - let proofs = self.select_proofs(inputs_needed_amount, None).await?.1; - - let proofs_amount = proofs.iter().map(|p| p.amount).sum::(); - - let input_proofs = match proofs_amount > inputs_needed_amount { - true => { - let proofs = self - .swap( - Some(inputs_needed_amount), - &amount_split_target, - proofs, - None, - ) - .await?; + let available_proofs = self.get_proofs().await?; - match proofs { - Some(proofs) => proofs, - None => { - return Err(Error::InsufficientFunds); - } - } - } - false => proofs, - }; + let input_proofs = self + .select_proofs_to_swap(inputs_needed_amount, available_proofs) + .await?; for proof in input_proofs.iter() { self.localstore @@ -1062,7 +1214,7 @@ impl Wallet { .await?; } - let active_keyset_id = self.active_mint_keyset().await?; + let active_keyset_id = self.get_active_mint_keyset().await?.id; let count = self .localstore @@ -1100,12 +1252,18 @@ impl Wallet { } }; + let active_keys = self + .localstore + .get_keys(&active_keyset_id) + .await? + .ok_or(Error::NoActiveKeyset)?; + let change_proofs = match melt_response.change { Some(change) => Some(construct_proofs( change, premint_secrets.rs(), premint_secrets.secrets(), - &self.active_keys().await?.ok_or(Error::UnknownKey)?, + &active_keys, )?), None => None, }; @@ -1154,125 +1312,113 @@ impl Wallet { Ok(melted) } - /// Select proofs - #[instrument(skip(self))] - pub async fn select_proofs( + /// Select proofs to send + #[instrument(skip_all)] + pub async fn select_proofs_to_send( &self, amount: Amount, - conditions: Option>, - ) -> Result<(Proofs, Proofs), Error> { - let mint_url = self.mint_url.clone(); - let mut condition_mint_proofs = Vec::new(); + proofs: Proofs, + include_fees: bool, + ) -> Result { + // TODO: Check all proofs are same unit - if conditions.is_some() { - condition_mint_proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - conditions, - ) - .await? - .unwrap_or_default() - .into_iter() - .map(|p| p.proof) - .collect(); + if proofs.iter().map(|p| p.amount).sum::() < amount { + return Err(Error::InsufficientFunds); } - let mint_keysets = match self.localstore.get_mint_keysets(mint_url.clone()).await? { - Some(keysets) => keysets, - None => self.get_mint_keysets().await?, - }; + let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) = + proofs.into_iter().partition(|p| p.amount > amount); - let (active, inactive): (HashSet, HashSet) = mint_keysets - .into_iter() - .filter(|p| p.unit.eq(&self.unit)) - .partition(|x| x.active); + let next_bigger_proof = proofs_larger.first().cloned(); - let active: HashSet = active.iter().map(|k| k.id).collect(); - let inactive: HashSet = inactive.iter().map(|k| k.id).collect(); + let mut selected_proofs: Vec = Vec::new(); + let mut remaining_amount = amount; - let (mut condition_active_proofs, mut condition_inactive_proofs): (Proofs, Proofs) = - condition_mint_proofs - .into_iter() - .partition(|p| active.contains(&p.keyset_id)); + 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)); - condition_active_proofs.sort_by(|a, b| b.cmp(a)); - condition_inactive_proofs.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 condition_proofs = [condition_inactive_proofs, condition_active_proofs].concat(); + let proof_amount = selected_proof.amount; - let mut condition_selected_proofs: Proofs = Vec::new(); + selected_proofs.push(selected_proof); - for proof in condition_proofs { - let mut condition_selected_proof_total = condition_selected_proofs - .iter() - .map(|p| p.amount) - .sum::(); + let fees = match include_fees { + true => self.get_proofs_fee(&selected_proofs).await?, + false => Amount::ZERO, + }; - if condition_selected_proof_total + proof.amount <= amount { - condition_selected_proof_total += proof.amount; - condition_selected_proofs.push(proof); + if proof_amount >= remaining_amount + fees { + remaining_amount = Amount::ZERO; + break; } - if condition_selected_proof_total == amount { - return Ok((condition_selected_proofs, vec![])); + remaining_amount = + amount + fees - selected_proofs.iter().map(|p| p.amount).sum::(); + (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); } - condition_selected_proofs.sort(); + Ok(selected_proofs) + } - let condition_proof_total = condition_selected_proofs.iter().map(|p| p.amount).sum(); + /// Select proofs to send + #[instrument(skip_all)] + pub async fn select_proofs_to_swap( + &self, + amount: Amount, + proofs: Proofs, + ) -> Result { + let active_keyset_id = self.get_active_mint_keyset().await?.id; - let mint_proofs: Proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await? - .ok_or(Error::InsufficientFunds)? + let (mut active_proofs, mut inactive_proofs): (Proofs, Proofs) = proofs .into_iter() - .map(|p| p.proof) - .collect(); + .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)); - let mut active_proofs: Proofs = Vec::new(); - let mut inactive_proofs: Proofs = Vec::new(); + for inactive_proof in inactive_proofs { + selected_proofs.push(inactive_proof); + let selected_total = selected_proofs.iter().map(|p| p.amount).sum::(); + let fees = self.get_proofs_fee(&selected_proofs).await?; - for proof in mint_proofs { - if active.contains(&proof.keyset_id) { - active_proofs.push(proof); - } else if inactive.contains(&proof.keyset_id) { - inactive_proofs.push(proof); + if selected_total >= amount + fees { + return Ok(selected_proofs); } } active_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); - inactive_proofs.sort_by(|a: &Proof, b: &Proof| b.cmp(a)); - let mut selected_proofs: Proofs = Vec::new(); + for active_proof in active_proofs { + selected_proofs.push(active_proof); + let selected_total = selected_proofs.iter().map(|p| p.amount).sum::(); + let fees = self.get_proofs_fee(&selected_proofs).await?; - for proof in [inactive_proofs, active_proofs].concat() { - if selected_proofs.iter().map(|p| p.amount).sum::() + condition_proof_total - <= amount - { - selected_proofs.push(proof); - } else { - break; + if selected_total >= amount + fees { + return Ok(selected_proofs); } } - if selected_proofs.iter().map(|p| p.amount).sum::() + condition_proof_total < amount - { - return Err(Error::InsufficientFunds); - } - - selected_proofs.sort(); - - Ok((condition_selected_proofs, selected_proofs)) + Err(Error::InsufficientFunds) } /// Receive proofs @@ -1280,10 +1426,12 @@ impl Wallet { pub async fn receive_proofs( &self, proofs: Proofs, - amount_split_target: &SplitTarget, + amount_split_target: SplitTarget, p2pk_signing_keys: &[SecretKey], preimages: &[String], ) -> Result { + let _ = self.get_active_mint_keyset().await?; + let mut received_proofs: HashMap = HashMap::new(); let mint_url = &self.mint_url; // Add mint if it does not exist in the store @@ -1296,13 +1444,10 @@ impl Wallet { self.get_mint_info().await?; } - let active_keyset_id = self.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?; - // Sum amount of all proofs - let amount: Amount = proofs.iter().map(|p| p.amount).sum(); - let mut proofs = proofs; let mut sig_flag = SigFlag::SigInputs; @@ -1367,7 +1512,7 @@ impl Wallet { } let mut pre_swap = self - .create_swap(Some(amount), amount_split_target, proofs, None) + .create_swap(None, amount_split_target, proofs, None, false) .await?; if sig_flag.eq(&SigFlag::SigAll) { @@ -1416,7 +1561,7 @@ impl Wallet { pub async fn receive( &self, encoded_token: &str, - amount_split_target: &SplitTarget, + amount_split_target: SplitTarget, p2pk_signing_keys: &[SecretKey], preimages: &[String], ) -> Result { diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index eeeae8d1..a87f0f56 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use tracing::instrument; +use super::types::SendKind; use super::Error; use crate::amount::SplitTarget; use crate::nuts::{CurrencyUnit, SecretKey, SpendingConditions, Token}; @@ -122,6 +123,8 @@ impl MultiMintWallet { amount: Amount, memo: Option, conditions: Option, + send_kind: SendKind, + include_fees: bool, ) -> Result { let wallet = self .get_wallet(wallet_key) @@ -129,7 +132,14 @@ impl MultiMintWallet { .ok_or(Error::UnknownWallet(wallet_key.to_string()))?; wallet - .send(amount, memo, conditions, &SplitTarget::default()) + .send( + amount, + memo, + conditions, + &SplitTarget::default(), + &send_kind, + include_fees, + ) .await } @@ -228,12 +238,7 @@ impl MultiMintWallet { .ok_or(Error::UnknownWallet(wallet_key.to_string()))?; let amount = wallet - .receive_proofs( - proofs, - &SplitTarget::default(), - p2pk_signing_keys, - preimages, - ) + .receive_proofs(proofs, SplitTarget::default(), p2pk_signing_keys, preimages) .await?; amount_received += amount; diff --git a/crates/cdk/src/wallet/types.rs b/crates/cdk/src/wallet/types.rs index cbd17713..d78f2e11 100644 --- a/crates/cdk/src/wallet/types.rs +++ b/crates/cdk/src/wallet/types.rs @@ -44,3 +44,17 @@ pub struct MeltQuote { /// Payment preimage pub payment_preimage: Option, } + +/// Send Kind +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum SendKind { + #[default] + /// Allow online swap before send if wallet does not have exact amount + OnlineExact, + /// Prefer offline send if difference is less then tolerance + OnlineTolerance(Amount), + /// Wallet cannot do an online swap and selectedp proof must be exactly send amount + OfflineExact, + /// Wallet must remain offline but can over pay if below tolerance + OfflineTolerance(Amount), +}