Prepared Send (#596)

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
Co-authored-by: ok300 <106775972+ok300@users.noreply.github.com>
This commit is contained in:
David Caseria
2025-03-20 07:44:44 -04:00
committed by GitHub
parent c4488ce436
commit db1db86509
24 changed files with 1179 additions and 460 deletions

View File

@@ -49,6 +49,9 @@ impl Amount {
/// Amount zero
pub const ZERO: Amount = Amount(0);
// Amount one
pub const ONE: Amount = Amount(1);
/// Split into parts that are powers of two
pub fn split(&self) -> Vec<Self> {
let sats = self.0;
@@ -119,6 +122,27 @@ impl Amount {
Ok(parts)
}
/// Splits amount into powers of two while accounting for the swap fee
pub fn split_with_fee(&self, fee_ppk: u64) -> Result<Vec<Self>, Error> {
let without_fee_amounts = self.split();
let fee_ppk = fee_ppk * without_fee_amounts.len() as u64;
let fee = Amount::from((fee_ppk + 999) / 1000);
let new_amount = self.checked_add(fee).ok_or(Error::AmountOverflow)?;
let split = new_amount.split();
let split_fee_ppk = split.len() as u64 * fee_ppk;
let split_fee = Amount::from((split_fee_ppk + 999) / 1000);
if let Some(net_amount) = new_amount.checked_sub(split_fee) {
if net_amount >= *self {
return Ok(split);
}
}
self.checked_add(Amount::ONE)
.ok_or(Error::AmountOverflow)?
.split_with_fee(fee_ppk)
}
/// Checked addition for Amount. Returns None if overflow occurs.
pub fn checked_add(self, other: Amount) -> Option<Amount> {
self.0.checked_add(other.0).map(Amount)
@@ -129,6 +153,16 @@ impl Amount {
self.0.checked_sub(other.0).map(Amount)
}
/// Checked multiplication for Amount. Returns None if overflow occurs.
pub fn checked_mul(self, other: Amount) -> Option<Amount> {
self.0.checked_mul(other.0).map(Amount)
}
/// Checked division for Amount. Returns None if overflow occurs.
pub fn checked_div(self, other: Amount) -> Option<Amount> {
self.0.checked_div(other.0).map(Amount)
}
/// Try sum to check for overflow
pub fn try_sum<I>(iter: I) -> Result<Self, Error>
where
@@ -334,6 +368,27 @@ mod tests {
);
}
#[test]
fn test_split_with_fee() {
let amount = Amount(2);
let fee_ppk = 1;
let split = amount.split_with_fee(fee_ppk).unwrap();
assert_eq!(split, vec![Amount(2), Amount(1)]);
let amount = Amount(3);
let fee_ppk = 1;
let split = amount.split_with_fee(fee_ppk).unwrap();
assert_eq!(split, vec![Amount(4)]);
let amount = Amount(3);
let fee_ppk = 1000;
let split = amount.split_with_fee(fee_ppk).unwrap();
assert_eq!(split, vec![Amount(32)]);
}
#[test]
fn test_split_values() {
let amount = Amount(10);

View File

@@ -3,6 +3,7 @@
//! <https://github.com/cashubtc/nuts/blob/main/00.md>
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
@@ -38,6 +39,12 @@ pub type Proofs = Vec<Proof>;
/// Utility methods for [Proofs]
pub trait ProofsMethods {
/// Count proofs by keyset
fn count_by_keyset(&self) -> HashMap<Id, u64>;
/// Sum proofs by keyset
fn sum_by_keyset(&self) -> HashMap<Id, Amount>;
/// Try to sum up the amounts of all [Proof]s
fn total_amount(&self) -> Result<Amount, Error>;
@@ -46,17 +53,65 @@ pub trait ProofsMethods {
}
impl ProofsMethods for Proofs {
fn count_by_keyset(&self) -> HashMap<Id, u64> {
count_by_keyset(self.iter())
}
fn sum_by_keyset(&self) -> HashMap<Id, Amount> {
sum_by_keyset(self.iter())
}
fn total_amount(&self) -> Result<Amount, Error> {
Amount::try_sum(self.iter().map(|p| p.amount)).map_err(Into::into)
total_amount(self.iter())
}
fn ys(&self) -> Result<Vec<PublicKey>, Error> {
self.iter()
.map(|p| p.y())
.collect::<Result<Vec<PublicKey>, _>>()
ys(self.iter())
}
}
impl ProofsMethods for HashSet<Proof> {
fn count_by_keyset(&self) -> HashMap<Id, u64> {
count_by_keyset(self.iter())
}
fn sum_by_keyset(&self) -> HashMap<Id, Amount> {
sum_by_keyset(self.iter())
}
fn total_amount(&self) -> Result<Amount, Error> {
total_amount(self.iter())
}
fn ys(&self) -> Result<Vec<PublicKey>, Error> {
ys(self.iter())
}
}
fn count_by_keyset<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> HashMap<Id, u64> {
let mut counts = HashMap::new();
for proof in proofs {
*counts.entry(proof.keyset_id).or_insert(0) += 1;
}
counts
}
fn sum_by_keyset<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> HashMap<Id, Amount> {
let mut sums = HashMap::new();
for proof in proofs {
*sums.entry(proof.keyset_id).or_insert(Amount::ZERO) += proof.amount;
}
sums
}
fn total_amount<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> Result<Amount, Error> {
Amount::try_sum(proofs.map(|p| p.amount)).map_err(Into::into)
}
fn ys<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> Result<Vec<PublicKey>, Error> {
proofs.map(|p| p.y()).collect::<Result<Vec<PublicKey>, _>>()
}
/// NUT00 Error
#[derive(Debug, Error)]
pub enum Error {
@@ -272,6 +327,11 @@ impl Proof {
}
}
/// Check if proof is in active keyset `Id`s
pub fn is_active(&self, active_keyset_ids: &[Id]) -> bool {
active_keyset_ids.contains(&self.keyset_id)
}
/// Get y from proof
///
/// Where y is `hash_to_curve(secret)`

View File

@@ -12,13 +12,19 @@ use super::Error;
use crate::SECP256K1;
/// PublicKey
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
pub struct PublicKey {
#[cfg_attr(feature = "swagger", schema(value_type = String))]
inner: secp256k1::PublicKey,
}
impl fmt::Debug for PublicKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PublicKey({})", self.to_hex())
}
}
impl Deref for PublicKey {
type Target = secp256k1::PublicKey;

View File

@@ -31,10 +31,12 @@ pub enum State {
///
/// Currently being used in a transaction i.e. melt in progress
Pending,
/// Proof is reserved
/// Reserved
///
/// i.e. used to create a token
/// Proof is reserved for future token creation
Reserved,
/// Pending spent (i.e., spent but not yet swapped by receiver)
PendingSpent,
}
impl fmt::Display for State {
@@ -44,6 +46,7 @@ impl fmt::Display for State {
Self::Unspent => "UNSPENT",
Self::Pending => "PENDING",
Self::Reserved => "RESERVED",
Self::PendingSpent => "PENDING_SPENT",
};
write!(f, "{}", s)
@@ -59,6 +62,7 @@ impl FromStr for State {
"UNSPENT" => Ok(Self::Unspent),
"PENDING" => Ok(Self::Pending),
"RESERVED" => Ok(Self::Reserved),
"PENDING_SPENT" => Ok(Self::PendingSpent),
_ => Err(Error::UnknownState),
}
}

View File

@@ -85,3 +85,25 @@ pub enum SendKind {
/// Wallet must remain offline but can over pay if below tolerance
OfflineTolerance(Amount),
}
impl SendKind {
/// Check if send kind is online
pub fn is_online(&self) -> bool {
matches!(self, Self::OnlineExact | Self::OnlineTolerance(_))
}
/// Check if send kind is offline
pub fn is_offline(&self) -> bool {
matches!(self, Self::OfflineExact | Self::OfflineTolerance(_))
}
/// Check if send kind is exact
pub fn is_exact(&self) -> bool {
matches!(self, Self::OnlineExact | Self::OfflineExact)
}
/// Check if send kind has tolerance
pub fn has_tolerance(&self) -> bool {
matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_))
}
}

View File

@@ -1,10 +1,9 @@
use std::io::{self, Write};
use anyhow::{anyhow, Result};
use cdk::amount::SplitTarget;
use cdk::nuts::nut18::TransportType;
use cdk::nuts::{PaymentRequest, PaymentRequestPayload};
use cdk::wallet::{MultiMintWallet, SendKind};
use cdk::wallet::{MultiMintWallet, SendOptions};
use clap::Args;
use nostr_sdk::nips::nip19::Nip19Profile;
use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys};
@@ -81,17 +80,16 @@ pub async fn pay_request(
})
.ok_or(anyhow!("No supported transport method found"))?;
let proofs = matching_wallet
.send(
let prepared_send = matching_wallet
.prepare_send(
amount,
None,
None,
&SplitTarget::default(),
&SendKind::default(),
true,
SendOptions {
include_fee: true,
..Default::default()
},
)
.await?
.proofs();
.await?;
let proofs = matching_wallet.send(prepared_send, None).await?.proofs();
let payload = PaymentRequestPayload {
id: payment_request.payment_id.clone(),

View File

@@ -3,10 +3,9 @@ use std::io::Write;
use std::str::FromStr;
use anyhow::{bail, Result};
use cdk::amount::SplitTarget;
use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
use cdk::wallet::types::{SendKind, WalletKey};
use cdk::wallet::MultiMintWallet;
use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions};
use cdk::Amount;
use clap::Args;
@@ -170,16 +169,22 @@ pub async fn send(
(false, None) => SendKind::OnlineExact,
};
let token = wallet
.send(
let prepared_send = wallet
.prepare_send(
token_amount,
sub_command_args.memo.clone(),
SendOptions {
memo: sub_command_args.memo.clone().map(|memo| SendMemo {
memo,
include_memo: true,
}),
send_kind,
include_fee: sub_command_args.include_fee,
conditions,
&SplitTarget::default(),
&send_kind,
sub_command_args.include_fee,
..Default::default()
},
)
.await?;
let token = wallet.send(prepared_send, None).await?;
match sub_command_args.v3 {
true => {

View File

@@ -84,14 +84,6 @@ pub trait Database: Debug {
added: Vec<ProofInfo>,
removed_ys: Vec<PublicKey>,
) -> Result<(), Self::Err>;
/// Set proofs as pending in storage. Proofs are identified by their Y
/// value.
async fn set_pending_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
/// Reserve proofs in storage. Proofs are identified by their Y value.
async fn reserve_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
/// Set proofs as unspent in storage. Proofs are identified by their Y
/// value.
async fn set_unspent_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
/// Get proofs from storage
async fn get_proofs(
&self,
@@ -100,6 +92,8 @@ pub trait Database: Debug {
state: Option<Vec<State>>,
spending_conditions: Option<Vec<SpendingConditions>>,
) -> Result<Vec<ProofInfo>, Self::Err>;
/// Update proofs state in storage
async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err>;
/// Increment Keyset counter
async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>;

View File

@@ -169,6 +169,9 @@ pub enum Error {
/// Insufficient Funds
#[error("Insufficient funds")]
InsufficientFunds,
/// Unexpected proof state
#[error("Unexpected proof state")]
UnexpectedProofState,
/// No active keyset
#[error("No active keyset")]
NoActiveKeyset,

View File

@@ -1,8 +1,10 @@
use std::assert_eq;
use std::collections::HashSet;
use std::hash::RandomState;
use cdk::amount::SplitTarget;
use cdk::nuts::nut00::ProofsMethods;
use cdk::wallet::SendKind;
use cdk::wallet::SendOptions;
use cdk::Amount;
use cdk_integration_tests::init_pure_tests::*;
@@ -18,18 +20,20 @@ async fn test_swap_to_send() -> anyhow::Result<()> {
assert_eq!(Amount::from(64), balance_alice);
// Alice wants to send 40 sats, which internally swaps
let token = wallet_alice
.send(
Amount::from(40),
None,
None,
&SplitTarget::None,
&SendKind::OnlineExact,
false,
)
let prepared_send = wallet_alice
.prepare_send(Amount::from(40), SendOptions::default())
.await?;
assert_eq!(
HashSet::<_, RandomState>::from_iter(prepared_send.proofs().ys()?),
HashSet::from_iter(wallet_alice.get_reserved_proofs().await?.ys()?)
);
let token = wallet_alice.send(prepared_send, None).await?;
assert_eq!(Amount::from(40), token.proofs().total_amount()?);
assert_eq!(Amount::from(24), wallet_alice.total_balance().await?);
assert_eq!(
HashSet::<_, RandomState>::from_iter(token.proofs().ys()?),
HashSet::from_iter(wallet_alice.get_pending_spent_proofs().await?.ys()?)
);
// Alice sends cashu, Carol receives
let wallet_carol = create_test_wallet_arc_for_mint(mint_bob.clone()).await?;

View File

@@ -145,46 +145,6 @@ impl WalletRedbDatabase {
Ok(Self { db: Arc::new(db) })
}
async fn update_proof_states(
&self,
ys: Vec<PublicKey>,
state: State,
) -> Result<(), database::Error> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
let write_txn = self.db.begin_write().map_err(Error::from)?;
for y in ys {
let y_slice = y.to_bytes();
let proof = table
.get(y_slice.as_slice())
.map_err(Error::from)?
.ok_or(Error::UnknownY)?;
let mut proof_info =
serde_json::from_str::<ProofInfo>(proof.value()).map_err(Error::from)?;
proof_info.state = state;
{
let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
table
.insert(
y_slice.as_slice(),
serde_json::to_string(&proof_info)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
}
#[async_trait]
@@ -611,21 +571,6 @@ impl WalletDatabase for WalletRedbDatabase {
Ok(())
}
#[instrument(skip(self, ys))]
async fn set_pending_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
self.update_proof_states(ys, State::Pending).await
}
#[instrument(skip(self, ys))]
async fn reserve_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
self.update_proof_states(ys, State::Reserved).await
}
#[instrument(skip(self, ys))]
async fn set_unspent_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
self.update_proof_states(ys, State::Unspent).await
}
#[instrument(skip_all)]
async fn get_proofs(
&self,
@@ -659,6 +604,46 @@ impl WalletDatabase for WalletRedbDatabase {
Ok(proofs)
}
async fn update_proofs_state(
&self,
ys: Vec<PublicKey>,
state: State,
) -> Result<(), database::Error> {
let read_txn = self.db.begin_read().map_err(Error::from)?;
let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
let write_txn = self.db.begin_write().map_err(Error::from)?;
for y in ys {
let y_slice = y.to_bytes();
let proof = table
.get(y_slice.as_slice())
.map_err(Error::from)?
.ok_or(Error::UnknownY)?;
let mut proof_info =
serde_json::from_str::<ProofInfo>(proof.value()).map_err(Error::from)?;
proof_info.state = state;
{
let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
table
.insert(
y_slice.as_slice(),
serde_json::to_string(&proof_info)
.map_err(Error::from)?
.as_str(),
)
.map_err(Error::from)?;
}
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
#[instrument(skip(self), fields(keyset_id = %keyset_id))]
async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> {
let write_txn = self.db.begin_write().map_err(Error::from)?;

View File

@@ -0,0 +1,31 @@
-- Create a new table with the updated CHECK constraint
CREATE TABLE IF NOT EXISTS proof_new (
y BLOB PRIMARY KEY,
mint_url TEXT NOT NULL,
state TEXT CHECK ( state IN ('SPENT', 'UNSPENT', 'PENDING', 'RESERVED', 'PENDING_SPENT' ) ) NOT NULL,
spending_condition TEXT,
unit TEXT NOT NULL,
amount INTEGER NOT NULL,
keyset_id TEXT NOT NULL,
secret TEXT NOT NULL,
c BLOB NOT NULL,
witness TEXT
);
CREATE INDEX IF NOT EXISTS secret_index ON proof_new(secret);
CREATE INDEX IF NOT EXISTS state_index ON proof_new(state);
CREATE INDEX IF NOT EXISTS spending_condition_index ON proof_new(spending_condition);
CREATE INDEX IF NOT EXISTS unit_index ON proof_new(unit);
CREATE INDEX IF NOT EXISTS amount_index ON proof_new(amount);
CREATE INDEX IF NOT EXISTS mint_url_index ON proof_new(mint_url);
-- Copy data from old proof table to new proof table
INSERT INTO proof_new (y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness)
SELECT y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness
FROM proof;
-- Drop the old proof table
DROP TABLE proof;
-- Rename the new proof table to proof
ALTER TABLE proof_new RENAME TO proof;

View File

@@ -59,23 +59,6 @@ impl WalletSqliteDatabase {
.await
.expect("Could not run migrations");
}
async fn set_proof_state(&self, y: PublicKey, state: State) -> Result<(), database::Error> {
sqlx::query(
r#"
UPDATE proof
SET state=?
WHERE y IS ?;
"#,
)
.bind(state.to_string())
.bind(y.to_bytes().to_vec())
.execute(&self.pool)
.await
.map_err(Error::from)?;
Ok(())
}
}
#[async_trait]
@@ -658,30 +641,6 @@ WHERE id=?
Ok(())
}
async fn set_pending_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
for y in ys {
self.set_proof_state(y, State::Pending).await?;
}
Ok(())
}
async fn reserve_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
for y in ys {
self.set_proof_state(y, State::Reserved).await?;
}
Ok(())
}
async fn set_unspent_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err> {
for y in ys {
self.set_proof_state(y, State::Unspent).await?;
}
Ok(())
}
#[instrument(skip(self, state, spending_conditions))]
async fn get_proofs(
&self,
@@ -734,6 +693,31 @@ FROM proof;
}
}
async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;
let update_sql = format!(
"UPDATE proof SET state = ? WHERE y IN ({})",
"?,".repeat(ys.len()).trim_end_matches(',')
);
ys.iter()
.fold(
sqlx::query(&update_sql).bind(state.to_string()),
|query, y| query.bind(y.to_bytes().to_vec()),
)
.execute(&mut *transaction)
.await
.map_err(|err| {
tracing::error!("SQLite could not update proof state: {err:?}");
Error::SQLX(err)
})?;
transaction.commit().await.map_err(Error::from)?;
Ok(())
}
#[instrument(skip(self), fields(keyset_id = %keyset_id))]
async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> {
let mut transaction = self.pool.begin().await.map_err(Error::from)?;

View File

@@ -4,14 +4,23 @@ use cdk::amount::SplitTarget;
use cdk::error::Error;
use cdk::nuts::nut00::ProofsMethods;
use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload};
use cdk::wallet::types::SendKind;
use cdk::wallet::{Wallet, WalletSubscription};
use cdk::wallet::{SendOptions, Wallet, WalletSubscription};
use cdk::Amount;
use cdk_sqlite::wallet::memory;
use rand::Rng;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<(), Error> {
let default_filter = "debug";
let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn";
let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
// Parse input
tracing_subscriber::fmt().with_env_filter(env_filter).init();
// Initialize the memory store for the wallet
let localstore = memory::empty().await?;
@@ -52,16 +61,8 @@ async fn main() -> Result<(), Error> {
println!("Received {} from mint {}", receive_amount, mint_url);
// Send a token with the specified amount
let token = wallet
.send(
amount,
None,
None,
&SplitTarget::default(),
&SendKind::OnlineExact,
false,
)
.await?;
let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?;
let token = wallet.send(prepared_send, None).await?;
println!("Token:");
println!("{}", token);

View File

@@ -3,8 +3,7 @@ use std::sync::Arc;
use cdk::amount::SplitTarget;
use cdk::error::Error;
use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, SecretKey, SpendingConditions};
use cdk::wallet::types::SendKind;
use cdk::wallet::{Wallet, WalletSubscription};
use cdk::wallet::{SendOptions, Wallet, WalletSubscription};
use cdk::Amount;
use cdk_sqlite::wallet::memory;
use rand::Rng;
@@ -30,10 +29,10 @@ async fn main() -> Result<(), Error> {
// Define the mint URL and currency unit
let mint_url = "https://testnut.cashu.space";
let unit = CurrencyUnit::Sat;
let amount = Amount::from(50);
let amount = Amount::from(100);
// Create a new wallet
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?;
let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, Some(1))?;
// Request a mint quote from the wallet
let quote = wallet.mint_quote(amount, None).await?;
@@ -57,7 +56,14 @@ async fn main() -> Result<(), Error> {
}
// Mint the received amount
let _receive_amount = wallet.mint(&quote.id, SplitTarget::default(), None).await?;
let received_proofs = wallet.mint(&quote.id, SplitTarget::default(), None).await?;
println!(
"Minted nuts: {:?}",
received_proofs
.into_iter()
.map(|p| p.amount)
.collect::<Vec<_>>()
);
// Generate a secret key for spending conditions
let secret = SecretKey::generate();
@@ -67,19 +73,21 @@ async fn main() -> Result<(), Error> {
// Get the total balance of the wallet
let bal = wallet.total_balance().await?;
println!("{}", bal);
println!("Total balance: {}", bal);
// Send a token with the specified amount and spending conditions
let token = wallet
.send(
let prepared_send = wallet
.prepare_send(
10.into(),
None,
Some(spending_conditions),
&SplitTarget::default(),
&SendKind::default(),
false,
SendOptions {
conditions: Some(spending_conditions),
include_fee: true,
..Default::default()
},
)
.await?;
println!("Fee: {}", prepared_send.fee());
let token = wallet.send(prepared_send, None).await?;
println!("Created token locked to pubkey: {}", secret.public_key());
println!("{}", token);

View File

@@ -1,5 +1,6 @@
//! Wallet example with memory store
use std::collections::HashMap;
use std::sync::Arc;
use cdk::amount::SplitTarget;
@@ -59,9 +60,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let proofs = wallet.get_unspent_proofs().await?;
// Select proofs to send
let selected = wallet
.select_proofs_to_send(Amount::from(64), proofs, false)
.await?;
let amount = Amount::from(64);
let active_keyset_ids = wallet
.get_active_mint_keysets()
.await?
.into_iter()
.map(|keyset| keyset.id)
.collect();
let selected =
Wallet::select_proofs(amount, proofs, &active_keyset_ids, &HashMap::new(), false)?;
for (i, proof) in selected.iter().enumerate() {
println!("{}: {}", i, proof.amount);
}

View File

@@ -4,8 +4,7 @@ use std::time::Duration;
use cdk::amount::SplitTarget;
use cdk::nuts::nut00::ProofsMethods;
use cdk::nuts::{CurrencyUnit, MintQuoteState};
use cdk::wallet::types::SendKind;
use cdk::wallet::Wallet;
use cdk::wallet::{SendOptions, Wallet};
use cdk::Amount;
use cdk_sqlite::wallet::memory;
use rand::Rng;
@@ -59,16 +58,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("Minted {}", receive_amount);
// Send the token
let token = wallet
.send(
amount,
None,
None,
&SplitTarget::None,
&SendKind::default(),
false,
)
.await?;
let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?;
let token = wallet.send(prepared_send, None).await?;
println!("{}", token);

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use tracing::instrument;
use crate::nuts::{Id, KeySetInfo, Keys};
@@ -99,4 +101,29 @@ impl Wallet {
.ok_or(Error::NoActiveKeyset)?;
Ok(keyset_with_lowest_fee)
}
/// Get keyset fees for mint
pub async fn get_keyset_fees(&self) -> Result<HashMap<Id, u64>, Error> {
let keysets = self
.localstore
.get_mint_keysets(self.mint_url.clone())
.await?
.ok_or(Error::UnknownKeySet)?;
let mut fees = HashMap::new();
for keyset in keysets {
fees.insert(keyset.id, keyset.input_fee_ppk);
}
Ok(fees)
}
/// Get keyset fees for mint by keyset id
pub async fn get_keyset_fees_by_id(&self, keyset_id: Id) -> Result<u64, Error> {
self.get_keyset_fees()
.await?
.get(&keyset_id)
.cloned()
.ok_or(Error::UnknownKeySet)
}
}

View File

@@ -130,7 +130,9 @@ impl Wallet {
}
let ys = proofs.ys()?;
self.localstore.set_pending_proofs(ys).await?;
self.localstore
.update_proofs_state(ys, State::Pending)
.await?;
let active_keyset_id = self.get_active_mint_keyset().await?.id;
@@ -287,9 +289,20 @@ impl Wallet {
let available_proofs = self.get_unspent_proofs().await?;
let input_proofs = self
.select_proofs_to_swap(inputs_needed_amount, available_proofs)
.await?;
let active_keyset_ids = self
.get_active_mint_keysets()
.await?
.into_iter()
.map(|k| k.id)
.collect();
let keyset_fees = self.get_keyset_fees().await?;
let input_proofs = Wallet::select_proofs(
inputs_needed_amount,
available_proofs,
&active_keyset_ids,
&keyset_fees,
true,
)?;
self.melt_proofs(quote_id, input_proofs).await
}

View File

@@ -43,6 +43,7 @@ mod swap;
pub mod util;
pub use cdk_common::wallet as types;
pub use send::{PreparedSend, SendMemo, SendOptions};
use crate::nuts::nut00::ProofsMethods;
@@ -173,25 +174,24 @@ impl Wallet {
/// Fee required for proof set
#[instrument(skip_all)]
pub async fn get_proofs_fee(&self, proofs: &Proofs) -> Result<Amount, Error> {
let mut proofs_per_keyset = HashMap::new();
let mut fee_per_keyset = HashMap::new();
for proof in proofs {
if let std::collections::hash_map::Entry::Vacant(e) =
fee_per_keyset.entry(proof.keyset_id)
{
let mint_keyset_info = self
.localstore
.get_keyset_by_id(&proof.keyset_id)
.await?
.ok_or(Error::UnknownKeySet)?;
e.insert(mint_keyset_info.input_fee_ppk);
let proofs_per_keyset = proofs.count_by_keyset();
self.get_proofs_fee_by_count(proofs_per_keyset).await
}
proofs_per_keyset
.entry(proof.keyset_id)
.and_modify(|count| *count += 1)
.or_insert(1);
/// Fee required for proof set by count
pub async fn get_proofs_fee_by_count(
&self,
proofs_per_keyset: HashMap<Id, u64>,
) -> Result<Amount, Error> {
let mut fee_per_keyset = HashMap::new();
for keyset_id in proofs_per_keyset.keys() {
let mint_keyset_info = self
.localstore
.get_keyset_by_id(keyset_id)
.await?
.ok_or(Error::UnknownKeySet)?;
fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk);
}
let fee = calculate_fee(&proofs_per_keyset, &fee_per_keyset)?;

View File

@@ -11,7 +11,7 @@ use cdk_common::wallet::WalletKey;
use tokio::sync::Mutex;
use tracing::instrument;
use super::types::SendKind;
use super::send::{PreparedSend, SendMemo, SendOptions};
use super::Error;
use crate::amount::SplitTarget;
use crate::mint_url::MintUrl;
@@ -110,32 +110,36 @@ impl MultiMintWallet {
Ok(mint_proofs)
}
/// Prepare to send
#[instrument(skip(self))]
pub async fn prepare_send(
&self,
wallet_key: &WalletKey,
amount: Amount,
opts: SendOptions,
) -> Result<PreparedSend, Error> {
let wallet = self
.get_wallet(wallet_key)
.await
.ok_or(Error::UnknownWallet(wallet_key.clone()))?;
wallet.prepare_send(amount, opts).await
}
/// Create cashu token
#[instrument(skip(self))]
pub async fn send(
&self,
wallet_key: &WalletKey,
amount: Amount,
memo: Option<String>,
conditions: Option<SpendingConditions>,
send_kind: SendKind,
include_fees: bool,
send: PreparedSend,
memo: Option<SendMemo>,
) -> Result<Token, Error> {
let wallet = self
.get_wallet(wallet_key)
.await
.ok_or(Error::UnknownWallet(wallet_key.clone()))?;
wallet
.send(
amount,
memo,
conditions,
&SplitTarget::default(),
&send_kind,
include_fees,
)
.await
wallet.send(send, memo).await
}
/// Mint quote for wallet

View File

@@ -1,8 +1,10 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use cdk_common::Id;
use tracing::instrument;
use crate::amount::SplitTarget;
use crate::fees::calculate_fee;
use crate::nuts::nut00::ProofsMethods;
use crate::nuts::{
CheckStateRequest, Proof, ProofState, Proofs, PublicKey, SpendingConditions, State,
@@ -30,6 +32,13 @@ impl Wallet {
.await
}
/// Get pending spent [`Proofs`]
#[instrument(skip(self))]
pub async fn get_pending_spent_proofs(&self) -> Result<Proofs, Error> {
self.get_proofs_with(Some(vec![State::PendingSpent]), None)
.await
}
/// Get this wallet's [Proofs] that match the args
pub async fn get_proofs_with(
&self,
@@ -53,7 +62,10 @@ impl Wallet {
/// 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?)
Ok(self
.localstore
.update_proofs_state(ys, State::Unspent)
.await?)
}
/// Reclaim unspent proofs
@@ -112,7 +124,7 @@ impl Wallet {
.get_proofs(
Some(self.mint_url.clone()),
Some(self.unit.clone()),
Some(vec![State::Pending, State::Reserved]),
Some(vec![State::Pending, State::Reserved, State::PendingSpent]),
None,
)
.await?;
@@ -153,123 +165,375 @@ impl Wallet {
Ok(balance)
}
/// Select proofs to send
/// Select proofs
#[instrument(skip_all)]
pub async fn select_proofs_to_send(
&self,
pub fn select_proofs(
amount: Amount,
proofs: Proofs,
active_keyset_ids: &Vec<Id>,
keyset_fees: &HashMap<Id, u64>,
include_fees: bool,
) -> Result<Proofs, Error> {
tracing::debug!(
"Selecting proofs to send {} from {}",
"amount={}, proofs={:?}",
amount,
proofs.total_amount()?
proofs.iter().map(|p| p.amount.into()).collect::<Vec<u64>>()
);
if amount == Amount::ZERO {
return Ok(vec![]);
}
ensure_cdk!(proofs.total_amount()? >= amount, Error::InsufficientFunds);
let (mut proofs_larger, mut proofs_smaller): (Proofs, Proofs) =
proofs.into_iter().partition(|p| p.amount > amount);
// Sort proofs in descending order
let mut proofs = proofs;
proofs.sort_by(|a, b| a.cmp(b).reverse());
let next_bigger_proof = proofs_larger.first().cloned();
// Split the amount into optimal amounts
let optimal_amounts = amount.split();
let mut selected_proofs: Proofs = Vec::new();
let mut remaining_amount = amount;
// Track selected proofs and remaining amounts (include all inactive proofs first)
let mut selected_proofs: HashSet<Proof> = proofs
.iter()
.filter(|p| !p.is_active(active_keyset_ids))
.cloned()
.collect();
if selected_proofs.total_amount()? >= amount {
tracing::debug!("All inactive proofs are sufficient");
return Ok(selected_proofs.into_iter().collect());
}
let mut remaining_amounts: Vec<Amount> = Vec::new();
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));
// Select proof with the exact amount and not already selected
let mut select_proof = |proofs: &Proofs, amount: Amount, exact: bool| -> bool {
let mut last_proof = None;
for proof in proofs.iter() {
if !selected_proofs.contains(proof) {
if proof.amount == amount {
selected_proofs.insert(proof.clone());
return true;
} else if !exact && proof.amount > amount {
last_proof = Some(proof.clone());
} else if proof.amount < amount {
break;
}
}
}
if let Some(proof) = last_proof {
selected_proofs.insert(proof);
true
} else {
false
}
};
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()
// Select proofs with the optimal amounts
for optimal_amount in optimal_amounts {
if !select_proof(&proofs, optimal_amount, true) {
// Add the remaining amount to the remaining amounts because proof with the optimal amount was not found
remaining_amounts.push(optimal_amount);
}
}
// If all the optimal amounts are selected, return the selected proofs
if remaining_amounts.is_empty() {
tracing::debug!("All optimal amounts are selected");
if include_fees {
return Self::include_fees(
amount,
proofs,
selected_proofs.into_iter().collect(),
active_keyset_ids,
keyset_fees,
);
} else {
return Ok(selected_proofs.into_iter().collect());
}
}
// Select proofs with the remaining amounts by checking for 2 of the half amount, 4 of the quarter amount, etc.
tracing::debug!("Selecting proofs with the remaining amounts");
for remaining_amount in remaining_amounts {
// Number of proofs to select
let mut n = 2;
let mut target_amount = remaining_amount;
let mut found = false;
while let Some(curr_amount) = target_amount.checked_div(Amount::from(2)) {
if curr_amount == Amount::ZERO {
break;
}
// Select proofs with the current amount
let mut count = 0;
for _ in 0..n {
if select_proof(&proofs, curr_amount, true) {
count += 1;
} else {
break;
};
}
}
n -= count;
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;
// All proofs with the current amount are selected
if n == 0 {
found = true;
break;
}
remaining_amount = amount.checked_add(fees).ok_or(Error::AmountOverflow)?
- selected_proofs.total_amount()?;
(proofs_larger, proofs_smaller) = proofs_smaller
.into_iter()
.skip(1)
.partition(|p| p.amount > remaining_amount);
// Try to find double the number of the next amount
n *= 2;
target_amount = curr_amount;
}
if remaining_amount > Amount::ZERO {
if let Some(next_bigger) = next_bigger_proof {
return Ok(vec![next_bigger.clone()]);
// Find closest amount over the remaining amount
if !found {
select_proof(&proofs, remaining_amount, false);
}
}
return Err(Error::InsufficientFunds);
// Check if the selected proofs total amount is equal to the amount else filter out unnecessary proofs
let mut selected_proofs = selected_proofs.into_iter().collect::<Vec<_>>();
let total_amount = selected_proofs.total_amount()?;
if total_amount != amount && selected_proofs.len() > 1 {
selected_proofs.sort_by(|a, b| a.cmp(b).reverse());
selected_proofs = Self::select_least_amount_over(selected_proofs, amount)?;
}
if include_fees {
return Self::include_fees(
amount,
proofs,
selected_proofs,
active_keyset_ids,
keyset_fees,
);
}
Ok(selected_proofs)
}
/// Select proofs to send
#[instrument(skip_all)]
pub async fn select_proofs_to_swap(
&self,
fn select_least_amount_over(proofs: Proofs, amount: Amount) -> Result<Vec<Proof>, Error> {
let total_amount = proofs.total_amount()?;
if total_amount < amount {
return Err(Error::InsufficientFunds);
}
if proofs.len() == 1 {
return Ok(proofs);
}
for i in 1..proofs.len() {
let (left, right) = proofs.split_at(i);
let left = left.to_vec();
let right = right.to_vec();
let left_amount = left.total_amount()?;
let right_amount = right.total_amount()?;
if left_amount >= amount && right_amount >= amount {
match (
Self::select_least_amount_over(left, amount),
Self::select_least_amount_over(right, amount),
) {
(Ok(left_proofs), Ok(right_proofs)) => {
let left_total_amount = left_proofs.total_amount()?;
let right_total_amount = right_proofs.total_amount()?;
if left_total_amount < right_total_amount {
return Ok(left_proofs);
} else {
return Ok(right_proofs);
}
}
(Ok(left_proofs), Err(_)) => return Ok(left_proofs),
(Err(_), Ok(right_proofs)) => return Ok(right_proofs),
(Err(_), Err(_)) => return Err(Error::InsufficientFunds),
}
} else if left_amount >= amount {
return Self::select_least_amount_over(left, amount);
} else if right_amount >= amount {
return Self::select_least_amount_over(right, amount);
}
}
Ok(proofs)
}
fn include_fees(
amount: Amount,
proofs: Proofs,
mut selected_proofs: Proofs,
active_keyset_ids: &Vec<Id>,
keyset_fees: &HashMap<Id, u64>,
) -> Result<Proofs, Error> {
tracing::debug!("Including fees");
let fee =
calculate_fee(&selected_proofs.count_by_keyset(), keyset_fees).unwrap_or_default();
let net_amount = selected_proofs.total_amount()? - fee;
tracing::debug!(
"Selecting proofs to swap {} from {}",
amount,
proofs.total_amount()?
);
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 = selected_proofs.total_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 = selected_proofs.total_amount()?;
let fees = self.get_proofs_fee(&selected_proofs).await?;
if selected_total >= amount + fees {
return Ok(selected_proofs);
}
}
tracing::debug!(
"Could not select proofs to swap: total selected: {}",
"Net amount={}, fee={}, total amount={}",
net_amount,
fee,
selected_proofs.total_amount()?
);
if net_amount >= amount {
tracing::debug!(
"Selected proofs: {:?}",
selected_proofs
.iter()
.map(|p| p.amount.into())
.collect::<Vec<u64>>(),
);
return Ok(selected_proofs);
}
Err(Error::InsufficientFunds)
tracing::debug!("Net amount is less than the required amount");
let remaining_amount = amount - net_amount;
let remaining_proofs = proofs
.into_iter()
.filter(|p| !selected_proofs.contains(p))
.collect::<Proofs>();
selected_proofs.extend(Wallet::select_proofs(
remaining_amount,
remaining_proofs,
active_keyset_ids,
&HashMap::new(), // Fees are already calculated
false,
)?);
tracing::debug!(
"Selected proofs: {:?}",
selected_proofs
.iter()
.map(|p| p.amount.into())
.collect::<Vec<u64>>(),
);
Ok(selected_proofs)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use cdk_common::secret::Secret;
use cdk_common::{Amount, Id, Proof, PublicKey};
use crate::Wallet;
fn id() -> Id {
Id::from_bytes(&[0; 8]).unwrap()
}
fn proof(amount: u64) -> Proof {
Proof::new(
Amount::from(amount),
id(),
Secret::generate(),
PublicKey::from_hex(
"03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
)
.unwrap(),
)
}
#[test]
fn test_select_proofs_empty() {
let proofs = vec![];
let selected_proofs =
Wallet::select_proofs(0.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap();
assert_eq!(selected_proofs.len(), 0);
}
#[test]
fn test_select_proofs_insufficient() {
let proofs = vec![proof(1), proof(2), proof(4)];
let selected_proofs =
Wallet::select_proofs(8.into(), proofs, &vec![id()], &HashMap::new(), false);
assert!(selected_proofs.is_err());
}
#[test]
fn test_select_proofs_exact() {
let proofs = vec![
proof(1),
proof(2),
proof(4),
proof(8),
proof(16),
proof(32),
proof(64),
];
let mut selected_proofs =
Wallet::select_proofs(77.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap();
selected_proofs.sort();
assert_eq!(selected_proofs.len(), 4);
assert_eq!(selected_proofs[0].amount, 1.into());
assert_eq!(selected_proofs[1].amount, 4.into());
assert_eq!(selected_proofs[2].amount, 8.into());
assert_eq!(selected_proofs[3].amount, 64.into());
}
#[test]
fn test_select_proofs_over() {
let proofs = vec![proof(1), proof(2), proof(4), proof(8), proof(32), proof(64)];
let selected_proofs =
Wallet::select_proofs(31.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap();
assert_eq!(selected_proofs.len(), 1);
assert_eq!(selected_proofs[0].amount, 32.into());
}
#[test]
fn test_select_proofs_smaller_over() {
let proofs = vec![proof(8), proof(16), proof(32)];
let selected_proofs =
Wallet::select_proofs(23.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap();
assert_eq!(selected_proofs.len(), 2);
assert_eq!(selected_proofs[0].amount, 16.into());
assert_eq!(selected_proofs[1].amount, 8.into());
}
#[test]
fn test_select_proofs_many_ones() {
let proofs = (0..1024).into_iter().map(|_| proof(1)).collect::<Vec<_>>();
let selected_proofs =
Wallet::select_proofs(1024.into(), proofs, &vec![id()], &HashMap::new(), false)
.unwrap();
assert_eq!(selected_proofs.len(), 1024);
for i in 0..1024 {
assert_eq!(selected_proofs[i].amount, 1.into());
}
}
#[test]
fn test_select_proofs_huge_proofs() {
let proofs = (0..32)
.flat_map(|i| {
(0..5)
.into_iter()
.map(|_| proof(1 << i))
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
let mut selected_proofs = Wallet::select_proofs(
((1u64 << 32) - 1).into(),
proofs,
&vec![id()],
&HashMap::new(),
false,
)
.unwrap();
selected_proofs.sort();
assert_eq!(selected_proofs.len(), 32);
for i in 0..32 {
assert_eq!(selected_proofs[i].amount, (1 << i).into());
}
}
#[test]
fn test_select_proofs_with_fees() {
let proofs = vec![proof(64), proof(4), proof(32)];
let mut keyset_fees = HashMap::new();
keyset_fees.insert(id(), 100);
let selected_proofs =
Wallet::select_proofs(10.into(), proofs, &vec![id()], &keyset_fees, false).unwrap();
assert_eq!(selected_proofs.len(), 1);
assert_eq!(selected_proofs[0].amount, 32.into());
}
}

View File

@@ -1,3 +1,5 @@
use std::fmt::Debug;
use tracing::instrument;
use super::SendKind;
@@ -7,36 +9,26 @@ use crate::nuts::{Proofs, SpendingConditions, State, Token};
use crate::{Amount, Error, Wallet};
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.ys()?;
self.localstore.reserve_proofs(ys).await?;
Ok(Token::new(
self.mint_url.clone(),
proofs,
memo,
self.unit.clone(),
))
}
/// Send
#[instrument(skip(self))]
pub async fn send(
/// Prepare A Send Transaction
///
/// This function prepares a send transaction by selecting proofs to send and proofs to swap.
/// By doing so, it ensures that the wallet user is able to view the fees associated with the send transaction.
///
/// ```no_compile
/// let send = wallet.prepare_send(Amount::from(10), SendOptions::default()).await?;
/// assert!(send.fee() <= Amount::from(1));
/// let token = wallet.send(send, None).await?;
/// ```
#[instrument(skip(self), err)]
pub async fn prepare_send(
&self,
amount: Amount,
memo: Option<String>,
conditions: Option<SpendingConditions>,
amount_split_target: &SplitTarget,
send_kind: &SendKind,
include_fees: bool,
) -> Result<Token, Error> {
opts: SendOptions,
) -> Result<PreparedSend, Error> {
tracing::info!("Preparing send");
// If online send check mint for current keysets fees
if matches!(
send_kind,
SendKind::OnlineExact | SendKind::OnlineTolerance(_)
) {
if opts.send_kind.is_online() {
if let Err(e) = self.get_active_mint_keyset().await {
tracing::error!(
"Error fetching active mint keyset: {:?}. Using stored keysets",
@@ -45,135 +37,377 @@ impl Wallet {
}
}
let available_proofs = self
// Get keyset fees from localstore
let keyset_fees = self.get_keyset_fees().await?;
// Get available proofs matching conditions
let mut available_proofs = self
.get_proofs_with(
Some(vec![State::Unspent]),
conditions.clone().map(|c| vec![c]),
opts.conditions.clone().map(|c| vec![c]),
)
.await?;
let proofs_sum = available_proofs.total_amount()?;
let available_proofs = if proofs_sum < amount {
match &conditions {
Some(conditions) => {
tracing::debug!("Insufficient prrofs matching conditions attempting swap");
let unspent_proofs = self.get_unspent_proofs().await?;
let proofs_to_swap = self.select_proofs_to_swap(amount, unspent_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 => Err(Error::InsufficientFunds),
}?
// Check if sufficient proofs are available
let mut force_swap = false;
let available_sum = available_proofs.total_amount()?;
if available_sum < amount {
if opts.conditions.is_none() || opts.send_kind.is_offline() {
return Err(Error::InsufficientFunds);
} else {
available_proofs
};
// Swap is required for send
tracing::debug!("Insufficient proofs matching conditions");
force_swap = true;
available_proofs = self
.localstore
.get_proofs(
Some(self.mint_url.clone()),
Some(self.unit.clone()),
Some(vec![State::Unspent]),
Some(vec![]),
)
.await?
.into_iter()
.map(|p| p.proof)
.collect();
}
}
let selected = self
.select_proofs_to_send(amount, available_proofs, include_fees)
// Select proofs
let active_keyset_ids = self
.get_active_mint_keysets()
.await?
.into_iter()
.map(|k| k.id)
.collect();
let selected_proofs = Wallet::select_proofs(
amount,
available_proofs,
&active_keyset_ids,
&keyset_fees,
opts.include_fee,
)?;
let selected_total = selected_proofs.total_amount()?;
// Check if selected proofs are exact
let send_fee = if opts.include_fee {
self.get_proofs_fee(&selected_proofs).await?
} else {
Amount::ZERO
};
if selected_total == amount + send_fee {
return self
.internal_prepare_send(amount, opts, selected_proofs, force_swap)
.await;
} else if opts.send_kind == SendKind::OfflineExact {
return Err(Error::InsufficientFunds);
}
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.total_amount()?;
let amount_to_send = match include_fees {
true => amount + self.get_proofs_fee(&selected_proofs).await?,
false => amount,
// Check if selected proofs are sufficient for tolerance
let tolerance = match opts.send_kind {
SendKind::OfflineTolerance(tolerance) => Some(tolerance),
SendKind::OnlineTolerance(tolerance) => Some(tolerance),
_ => None,
};
if selected_proofs_amount == amount_to_send {
selected_proofs
} else {
if let Some(tolerance) = tolerance {
if selected_total - amount > tolerance && opts.send_kind.is_offline() {
return Err(Error::InsufficientFunds);
}
}
// Handle exact matches
(SendKind::OnlineExact, Ok(selected_proofs), _) => {
let selected_proofs_amount = selected_proofs.total_amount()?;
self.internal_prepare_send(amount, opts, selected_proofs, force_swap)
.await
}
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
async fn internal_prepare_send(
&self,
amount: Amount,
opts: SendOptions,
proofs: Proofs,
force_swap: bool,
) -> Result<PreparedSend, Error> {
// Split amount with fee if necessary
let (send_amounts, send_fee) = if opts.include_fee {
let active_keyset_id = self.get_active_mint_keyset().await?.id;
let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?;
tracing::debug!("Keyset fee per proof: {:?}", keyset_fee_ppk);
let send_split = amount.split_with_fee(keyset_fee_ppk)?;
let send_fee = self
.get_proofs_fee_by_count(
vec![(active_keyset_id, send_split.len() as u64)]
.into_iter()
.collect(),
)
.await?;
(send_split, send_fee)
} else {
tracing::info!("Could not select proofs exact while offline.");
tracing::info!("Attempting to select proofs and swapping");
let send_split = amount.split();
let send_fee = Amount::ZERO;
(send_split, send_fee)
};
tracing::debug!("Send amounts: {:?}", send_amounts);
tracing::debug!("Send fee: {:?}", send_fee);
self.swap_from_unspent(amount, conditions, include_fees)
// Reserve proofs
self.localstore
.update_proofs_state(proofs.ys()?, State::Reserved)
.await?;
// Check if proofs are exact send amount
let proofs_exact_amount = proofs.total_amount()? == amount + send_fee;
// Split proofs to swap and send
let mut proofs_to_swap = Proofs::new();
let mut proofs_to_send = Proofs::new();
if force_swap {
proofs_to_swap = proofs;
} else if proofs_exact_amount
|| opts.send_kind.is_offline()
|| opts.send_kind.has_tolerance()
{
proofs_to_send = proofs;
} else {
let mut remaining_send_amounts = send_amounts.clone();
for proof in proofs {
if let Some(idx) = remaining_send_amounts
.iter()
.position(|a| a == &proof.amount)
{
proofs_to_send.push(proof);
remaining_send_amounts.remove(idx);
} else {
proofs_to_swap.push(proof);
}
}
}
// Calculate swap fee
let swap_fee = self.get_proofs_fee(&proofs_to_swap).await?;
// Return prepared send
Ok(PreparedSend {
amount,
options: opts,
proofs_to_swap,
swap_fee,
proofs_to_send,
send_fee,
})
}
/// Finalize A Send Transaction
///
/// This function finalizes a send transaction by constructing a token the [`PreparedSend`].
/// See [`Wallet::prepare_send`] for more information.
#[instrument(skip(self), err)]
pub async fn send(&self, send: PreparedSend, memo: Option<SendMemo>) -> Result<Token, Error> {
tracing::info!("Sending prepared send");
let mut proofs_to_send = send.proofs_to_send;
// Get active keyset ID
let active_keyset_id = self.get_active_mint_keyset().await?.id;
tracing::debug!("Active keyset ID: {:?}", active_keyset_id);
// Get keyset fees
let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?;
tracing::debug!("Keyset fees: {:?}", keyset_fee_ppk);
// Calculate total send amount
let total_send_amount = send.amount + send.send_fee;
tracing::debug!("Total send amount: {}", total_send_amount);
// Swap proofs if necessary
if !send.proofs_to_swap.is_empty() {
let swap_amount = total_send_amount - proofs_to_send.total_amount()?;
tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount);
if let Some(proofs) = self
.swap(
Some(swap_amount),
SplitTarget::None,
send.proofs_to_swap,
send.options.conditions.clone(),
false, // already included in swap_amount
)
.await?
{
proofs_to_send.extend(proofs);
}
}
tracing::debug!(
"Proofs to send: {:?}",
proofs_to_send.iter().map(|p| p.amount).collect::<Vec<_>>()
);
// Handle offline tolerance
(SendKind::OfflineTolerance(tolerance), Ok(selected_proofs), _) => {
let selected_proofs_amount = selected_proofs.total_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");
// Check if sufficient proofs are available
if send.amount > proofs_to_send.total_amount()? {
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)
// Check if proofs are reserved or unspent
let sendable_proof_ys = self
.get_proofs_with(
Some(vec![State::Reserved, State::Unspent]),
send.options.conditions.clone().map(|c| vec![c]),
)
.await?
.ys()?;
if proofs_to_send
.ys()?
.iter()
.any(|y| !sendable_proof_ys.contains(y))
{
tracing::warn!("Proofs to send are not reserved or unspent");
return Err(Error::UnexpectedProofState);
}
// Handle online tolerance with successful selection
(SendKind::OnlineTolerance(tolerance), Ok(selected_proofs), _) => {
let selected_proofs_amount = selected_proofs.total_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?
// Update proofs state to pending spent
tracing::debug!(
"Updating proofs state to pending spent: {:?}",
proofs_to_send.ys()?
);
self.localstore
.update_proofs_state(proofs_to_send.ys()?, State::PendingSpent)
.await?;
// Include token memo
let send_memo = send.options.memo.or(memo);
let memo = send_memo.and_then(|m| if m.include_memo { Some(m.memo) } else { None });
// Create and return token
Ok(Token::new(
self.mint_url.clone(),
proofs_to_send,
memo,
self.unit.clone(),
))
}
/// Cancel prepared send
pub async fn cancel_send(&self, send: PreparedSend) -> Result<(), Error> {
tracing::info!("Cancelling prepared send");
// Double-check proofs state
let reserved_proofs = self.get_reserved_proofs().await?.ys()?;
if !send
.proofs()
.ys()?
.iter()
.all(|y| reserved_proofs.contains(y))
{
return Err(Error::UnexpectedProofState);
}
self.localstore
.update_proofs_state(send.proofs().ys()?, State::Unspent)
.await?;
Ok(())
}
}
// 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);
/// Prepared send
pub struct PreparedSend {
amount: Amount,
options: SendOptions,
proofs_to_swap: Proofs,
swap_fee: Amount,
proofs_to_send: Proofs,
send_fee: Amount,
}
};
self.send_proofs(memo, send_proofs).await
impl PreparedSend {
/// Amount
pub fn amount(&self) -> Amount {
self.amount
}
/// Send options
pub fn options(&self) -> &SendOptions {
&self.options
}
/// Proofs to swap (i.e., proofs that need to be swapped before constructing the token)
pub fn proofs_to_swap(&self) -> &Proofs {
&self.proofs_to_swap
}
/// Swap fee
pub fn swap_fee(&self) -> Amount {
self.swap_fee
}
/// Proofs to send (i.e., proofs that will be included in the token)
pub fn proofs_to_send(&self) -> &Proofs {
&self.proofs_to_send
}
/// Send fee
pub fn send_fee(&self) -> Amount {
self.send_fee
}
/// All proofs
pub fn proofs(&self) -> Proofs {
let mut proofs = self.proofs_to_swap.clone();
proofs.extend(self.proofs_to_send.clone());
proofs
}
/// Total fee
pub fn fee(&self) -> Amount {
self.swap_fee + self.send_fee
}
}
impl Debug for PreparedSend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedSend")
.field("amount", &self.amount)
.field("options", &self.options)
.field(
"proofs_to_swap",
&self
.proofs_to_swap
.iter()
.map(|p| p.amount)
.collect::<Vec<_>>(),
)
.field("swap_fee", &self.swap_fee)
.field(
"proofs_to_send",
&self
.proofs_to_send
.iter()
.map(|p| p.amount)
.collect::<Vec<_>>(),
)
.field("send_fee", &self.send_fee)
.finish()
}
}
/// Send options
#[derive(Debug, Clone, Default)]
pub struct SendOptions {
/// Memo
pub memo: Option<SendMemo>,
/// Spending conditions
pub conditions: Option<SpendingConditions>,
/// Amount split target
pub amount_split_target: SplitTarget,
/// Send kind
pub send_kind: SendKind,
/// Include fee
///
/// When this is true the token created will include the amount of fees needed to redeem the token (amount + fee_to_redeem)
pub include_fee: bool,
}
/// Send memo
#[derive(Debug, Clone)]
pub struct SendMemo {
/// Memo
pub memo: String,
/// Include memo in token
pub include_memo: bool,
}

View File

@@ -20,13 +20,14 @@ impl Wallet {
spending_conditions: Option<SpendingConditions>,
include_fees: bool,
) -> Result<Option<Proofs>, Error> {
tracing::info!("Swapping");
let mint_url = &self.mint_url;
let unit = &self.unit;
let pre_swap = self
.create_swap(
amount,
amount_split_target,
amount_split_target.clone(),
input_proofs.clone(),
spending_conditions.clone(),
include_fees,
@@ -72,13 +73,15 @@ impl Wallet {
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();
let mut proofs_to_send = Proofs::new();
let mut proofs_to_keep = Proofs::new();
let mut amount_split = amount.split_targeted(&amount_split_target)?;
for proof in all_proofs {
let proofs_to_send_amount = proofs_to_send.total_amount()?;
if proof.amount + proofs_to_send_amount <= amount + pre_swap.fee {
if let Some(idx) = amount_split.iter().position(|&a| a == proof.amount)
{
proofs_to_send.push(proof);
amount_split.remove(idx);
} else {
proofs_to_keep.push(proof);
}
@@ -163,7 +166,20 @@ impl Wallet {
ensure_cdk!(proofs_sum >= amount, Error::InsufficientFunds);
let proofs = self.select_proofs_to_swap(amount, available_proofs).await?;
let active_keyset_ids = self
.get_active_mint_keysets()
.await?
.into_iter()
.map(|k| k.id)
.collect();
let keyset_fees = self.get_keyset_fees().await?;
let proofs = Wallet::select_proofs(
amount,
available_proofs,
&active_keyset_ids,
&keyset_fees,
true,
)?;
self.swap(
Some(amount),
@@ -186,13 +202,16 @@ impl Wallet {
spending_conditions: Option<SpendingConditions>,
include_fees: bool,
) -> Result<PreSwap, Error> {
tracing::info!("Creating swap");
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 = proofs.total_amount()?;
let ys: Vec<PublicKey> = proofs.ys()?;
self.localstore.set_pending_proofs(ys).await?;
self.localstore
.update_proofs_state(ys, State::Reserved)
.await?;
let fee = self.get_proofs_fee(&proofs).await?;