use std::str::FromStr;
use anyhow::{anyhow, Result};
use cdk::mint_url::MintUrl;
use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
use cdk::wallet::types::SendKind;
use cdk::wallet::{SendMemo, SendOptions, WalletRepository};
use cdk::Amount;
use clap::Args;
use crate::utils::{get_number_input, get_or_create_wallet};
#[derive(Args)]
pub struct SendSubCommand {
#[arg(short, long)]
memo: Option<String>,
#[arg(long, conflicts_with = "hash")]
preimage: Option<String>,
#[arg(long, conflicts_with = "preimage")]
hash: Option<String>,
#[arg(long)]
required_sigs: Option<u64>,
#[arg(short, long)]
locktime: Option<u64>,
#[arg(short, long, action = clap::ArgAction::Append)]
pubkey: Vec<String>,
#[arg(long, action = clap::ArgAction::Append)]
refund_keys: Vec<String>,
#[arg(short, long)]
v3: bool,
#[arg(short, long)]
offline: bool,
#[arg(short, long)]
include_fee: bool,
#[arg(short, long)]
tolerance: Option<u64>,
#[arg(long)]
mint_url: Option<String>,
#[arg(long)]
use_p2bk: bool,
#[arg(short, long)]
amount: Option<u64>,
#[arg(long)]
animate: bool,
}
pub async fn send(
wallet_repository: &WalletRepository,
sub_command_args: &SendSubCommand,
unit: &CurrencyUnit,
) -> Result<()> {
let selected_mint = if let Some(mint_url) = &sub_command_args.mint_url {
MintUrl::from_str(mint_url)?
} else {
let balances_map = wallet_repository.get_balances().await?;
if balances_map.is_empty() {
return Err(anyhow!("No mints available in the wallet"));
}
let balances_vec: Vec<_> = balances_map.into_iter().collect();
if balances_vec.len() == 1 {
balances_vec[0].0.mint_url.clone()
} else {
println!("\nAvailable mints and balances:");
for (index, (key, balance)) in balances_vec.iter().enumerate() {
println!(
" {}: {} ({}) - {} {}",
index, key.mint_url, key.unit, balance, unit
);
}
loop {
let selection: usize = get_number_input("Enter mint number to send from")?;
if let Some((key, _)) = balances_vec.get(selection) {
break key.mint_url.clone();
}
println!("Invalid selection, please try again.");
}
}
};
let token_amount = match sub_command_args.amount {
Some(amount) => Amount::from(amount),
None => Amount::from(get_number_input::<u64>(&format!(
"Enter value of token in {}",
unit
))?),
};
let wallet = get_or_create_wallet(wallet_repository, &selected_mint, unit).await?;
let balance = wallet.total_balance().await?;
if balance < token_amount {
return Err(anyhow!(
"Insufficient funds. Wallet balance: {}, Required: {}",
balance,
token_amount
));
}
let conditions = match (&sub_command_args.preimage, &sub_command_args.hash) {
(Some(_), Some(_)) => {
unreachable!("Both preimage and hash were provided despite conflicts_with attribute")
}
(Some(preimage), None) => {
let pubkeys = match sub_command_args.pubkey.is_empty() {
true => None,
false => Some(
sub_command_args
.pubkey
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?,
),
};
let refund_keys = match sub_command_args.refund_keys.is_empty() {
true => None,
false => Some(
sub_command_args
.refund_keys
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?,
),
};
let conditions = Conditions::new(
sub_command_args.locktime,
pubkeys,
refund_keys,
sub_command_args.required_sigs,
None,
None,
)?;
Some(SpendingConditions::new_htlc(
preimage.clone(),
Some(conditions),
)?)
}
(None, Some(hash)) => {
let pubkeys = match sub_command_args.pubkey.is_empty() {
true => None,
false => Some(
sub_command_args
.pubkey
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?,
),
};
let refund_keys = match sub_command_args.refund_keys.is_empty() {
true => None,
false => Some(
sub_command_args
.refund_keys
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?,
),
};
let conditions = Conditions::new(
sub_command_args.locktime,
pubkeys,
refund_keys,
sub_command_args.required_sigs,
None,
None,
)?;
Some(SpendingConditions::new_htlc_hash(hash, Some(conditions))?)
}
(None, None) => match sub_command_args.pubkey.is_empty() {
true => None,
false => {
let pubkeys: Vec<PublicKey> = sub_command_args
.pubkey
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?;
let refund_keys: Vec<PublicKey> = sub_command_args
.refund_keys
.iter()
.map(|p| PublicKey::from_str(p))
.collect::<Result<Vec<_>, _>>()?;
let refund_keys = (!refund_keys.is_empty()).then_some(refund_keys);
let data_pubkey = pubkeys[0];
let pubkeys = pubkeys[1..].to_vec();
let pubkeys = (!pubkeys.is_empty()).then_some(pubkeys);
let conditions = Conditions::new(
sub_command_args.locktime,
pubkeys,
refund_keys,
sub_command_args.required_sigs,
None,
None,
)?;
Some(SpendingConditions::P2PKConditions {
data: data_pubkey,
conditions: Some(conditions),
})
}
},
};
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 send_options = SendOptions {
memo: sub_command_args.memo.clone().map(|memo| SendMemo {
memo,
include_memo: true,
}),
send_kind,
include_fee: sub_command_args.include_fee,
conditions,
use_p2bk: sub_command_args.use_p2bk,
..Default::default()
};
let prepared = wallet
.prepare_send(token_amount, send_options.clone())
.await?;
let memo = send_options.memo;
let token = prepared.confirm(memo).await?;
match sub_command_args.v3 {
true => {
println!("{}", token.to_v3_string());
}
false => {
println!("{token}");
}
}
if sub_command_args.animate {
display_animated_qr(&token).await?;
}
Ok(())
}
async fn display_animated_qr(token: &cdk::nuts::Token) -> Result<()> {
const TERMINAL_MAX_FRAGMENT_LENGTH: usize = 100;
use std::io;
use std::time::Duration;
use qrcode::render::unicode;
use qrcode::QrCode;
let mut encoder = token.ur_encoder(TERMINAL_MAX_FRAGMENT_LENGTH)?;
let render_frame = |part: &str| -> Result<String> {
let qr_payload = part.to_ascii_uppercase();
let code = QrCode::new(qr_payload.as_bytes())?;
Ok(code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build())
};
if encoder.is_single_fragment() {
println!("{}", render_frame(&encoder.next_part()?)?);
return Ok(());
}
println!(
"Displaying {} QR frames in a loop. Press q or Ctrl+C to stop.",
encoder.fragment_count()
);
crossterm::terminal::enable_raw_mode()?;
let result = tokio::select! {
result = async {
loop {
let part = encoder.next_part()?;
let frame = render_frame(&part)?;
let mut stdout = io::stdout();
draw_animated_qr_frame(&mut stdout, &frame)?;
tokio::time::sleep(Duration::from_millis(250)).await;
}
#[allow(unreachable_code)]
Ok::<(), anyhow::Error>(())
} => result,
_ = quit_key_pressed() => Ok(()),
_ = tokio::signal::ctrl_c() => Ok(()),
};
crossterm::terminal::disable_raw_mode()?;
println!("\nStopped QR animation.");
result
}
fn draw_animated_qr_frame(stdout: &mut impl std::io::Write, frame: &str) -> std::io::Result<()> {
use crossterm::SynchronizedUpdate;
let frame = frame.replace('\n', "\r\n");
stdout.sync_update(|stdout| {
crossterm::queue!(
stdout,
crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
crossterm::cursor::MoveTo(0, 0),
crossterm::style::Print(&frame)
)
})?
}
async fn quit_key_pressed() {
use crossterm::event::{Event, EventStream, KeyCode, KeyModifiers};
use futures::StreamExt;
let mut events = EventStream::new();
while let Some(Ok(event)) = events.next().await {
let quit = matches!(event,
Event::Key(key)
if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q'))
|| (key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)));
if quit {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::draw_animated_qr_frame;
#[test]
fn animated_qr_rows_return_to_the_first_column() {
let mut output = Vec::new();
draw_animated_qr_frame(&mut output, "first row\nsecond row")
.expect("terminal frame should render");
let output = String::from_utf8(output).expect("terminal output should be UTF-8");
assert!(output.contains("first row\r\nsecond row"));
assert!(!output.contains("first row\nsecond row"));
}
}