feat: cdk-cli

This commit is contained in:
thesimplekid
2024-06-08 23:59:48 +01:00
parent 1cc3fa794a
commit 5ebdd4f506
15 changed files with 654 additions and 1 deletions

117
crates/cdk-cli/src/main.rs Normal file
View File

@@ -0,0 +1,117 @@
use std::fs;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::{bail, Result};
use cdk::cdk_database::WalletDatabase;
use cdk::wallet::Wallet;
use cdk::{cdk_database, Mnemonic};
use cdk_redb::RedbWalletDatabase;
use cdk_sqlite::WalletSQLiteDatabase;
use clap::{Parser, Subcommand};
use rand::Rng;
mod sub_commands;
/// Simple CLI application to interact with cashu
#[derive(Parser)]
#[command(name = "cashu-tool")]
#[command(author = "thesimplekid <tsk@thesimplekid.com>")]
#[command(version = "0.1")]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Database engine to use (sqlite/redb)
#[arg(short, long, default_value = "sqlite")]
engine: String,
/// Path to Seed
#[arg(short, long, default_value = "./seed")]
seed_path: String,
/// File Path to save proofs
#[arg(short, long)]
db_path: Option<String>,
#[command(subcommand)]
command: Commands,
}
const DEFAULT_REDB_DB_PATH: &str = "./cashu_tool.redb";
const DEFAULT_SQLITE_DB_PATH: &str = "./cashu_tool.redb";
#[derive(Subcommand)]
enum Commands {
/// Decode a token
DecodeToken(sub_commands::decode_token::DecodeTokenSubCommand),
/// Pay bolt11 invoice
Melt(sub_commands::melt::MeltSubCommand),
/// Receive token
Receive(sub_commands::receive::ReceiveSubCommand),
/// Create token from wallet balance
CreateToken(sub_commands::create_token::CreateTokenSubCommand),
/// Check if wallet balance is spendable
CheckSpendable,
/// View mint info
MintInfo(sub_commands::mint_info::MintInfoSubcommand),
/// Mint proofs via bolt11
Mint(sub_commands::mint::MintSubCommand),
/// Restore proofs from seed
Restore(sub_commands::restore::RestoreSubCommand),
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.init();
// Parse input
let args: Cli = Cli::parse();
let localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync> =
match args.engine.as_str() {
"sqlite" => Arc::new(RedbWalletDatabase::new(DEFAULT_REDB_DB_PATH)?),
"redb" => Arc::new(WalletSQLiteDatabase::new(DEFAULT_SQLITE_DB_PATH).await?),
_ => bail!("Unknown DB engine"),
};
let mnemonic = match fs::metadata(args.seed_path.clone()) {
Ok(_) => {
let contents = fs::read_to_string(args.seed_path.clone())?;
Mnemonic::from_str(&contents)?
}
Err(_e) => {
let mut rng = rand::thread_rng();
let random_bytes: [u8; 32] = rng.gen();
let mnemnic = Mnemonic::from_entropy(&random_bytes)?;
tracing::info!("Using randomly generated seed you will not be able to restore");
mnemnic
}
};
let wallet = Wallet::new(localstore, &mnemonic.to_seed_normalized(""), vec![]);
match &args.command {
Commands::DecodeToken(sub_command_args) => {
sub_commands::decode_token::decode_token(sub_command_args)
}
Commands::Melt(sub_command_args) => {
sub_commands::melt::melt(wallet, sub_command_args).await
}
Commands::Receive(sub_command_args) => {
sub_commands::receive::receive(wallet, sub_command_args).await
}
Commands::CreateToken(sub_command_args) => {
sub_commands::create_token::create_token(wallet, sub_command_args).await
}
Commands::CheckSpendable => sub_commands::check_spent::check_spent(wallet).await,
Commands::MintInfo(sub_command_args) => {
sub_commands::mint_info::mint_info(sub_command_args).await
}
Commands::Mint(sub_command_args) => {
sub_commands::mint::mint(wallet, sub_command_args).await
}
Commands::Restore(sub_command_args) => {
sub_commands::restore::restore(wallet, sub_command_args).await
}
}
}

View File

@@ -0,0 +1,41 @@
use std::collections::HashMap;
use std::io::Write;
use std::{io, println};
use anyhow::{bail, Result};
use cdk::url::UncheckedUrl;
use cdk::wallet::Wallet;
pub async fn check_spent(wallet: Wallet) -> Result<()> {
let mints_amounts: Vec<(UncheckedUrl, HashMap<_, _>)> =
wallet.mint_balances().await?.into_iter().collect();
for (i, (mint, amount)) in mints_amounts.iter().enumerate() {
println!("{}: {}, {:?} sats", i, mint, amount);
}
println!("Enter mint number to create token");
let mut user_input = String::new();
let stdin = io::stdin();
io::stdout().flush().unwrap();
stdin.read_line(&mut user_input)?;
let mint_number: usize = user_input.trim().parse()?;
if mint_number.gt(&(mints_amounts.len() - 1)) {
bail!("Invalid mint number");
}
let mint_url = mints_amounts[mint_number].0.clone();
let proofs = wallet.get_proofs(mint_url.clone()).await?.unwrap();
let send_proofs = wallet.check_proofs_spent(mint_url, proofs.to_vec()).await?;
for proof in send_proofs {
println!("{:#?}", proof);
}
Ok(())
}

View File

@@ -0,0 +1,164 @@
use std::collections::HashMap;
use std::io::Write;
use std::str::FromStr;
use std::{io, println};
use anyhow::{bail, Result};
use cdk::amount::SplitTarget;
use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
use cdk::url::UncheckedUrl;
use cdk::wallet::Wallet;
use cdk::Amount;
use clap::Args;
#[derive(Args)]
pub struct CreateTokenSubCommand {
/// Token Memo
#[arg(short, long)]
memo: Option<String>,
/// Preimage
#[arg(long)]
preimage: Option<String>,
/// Required number of signatures
#[arg(long)]
required_sigs: Option<u64>,
/// Locktime before refund keys can be used
#[arg(short, long)]
locktime: Option<u64>,
/// Publey to lock proofs to
#[arg(short, long, action = clap::ArgAction::Append)]
pubkey: Vec<String>,
/// Publey to lock proofs to
#[arg(long, action = clap::ArgAction::Append)]
refund_keys: Vec<String>,
}
pub async fn create_token(wallet: Wallet, sub_command_args: &CreateTokenSubCommand) -> Result<()> {
let mints_amounts: Vec<(UncheckedUrl, HashMap<_, _>)> =
wallet.mint_balances().await?.into_iter().collect();
for (i, (mint, amount)) in mints_amounts.iter().enumerate() {
println!("{}: {}, {:?} sats", i, mint, amount);
}
println!("Enter mint number to create token");
let mut user_input = String::new();
let stdin = io::stdin();
io::stdout().flush().unwrap();
stdin.read_line(&mut user_input)?;
let mint_number: usize = user_input.trim().parse()?;
if mint_number.gt(&(mints_amounts.len() - 1)) {
bail!("Invalid mint number");
}
let mint_url = mints_amounts[mint_number].0.clone();
println!("Enter value of token in sats");
let mut user_input = String::new();
let stdin = io::stdin();
io::stdout().flush().unwrap();
stdin.read_line(&mut user_input)?;
let token_amount = Amount::from(user_input.trim().parse::<u64>()?);
if token_amount.gt(mints_amounts[mint_number]
.1
.get(&CurrencyUnit::Sat)
.unwrap())
{
bail!("Not enough funds");
}
let conditions = match &sub_command_args.preimage {
Some(preimage) => {
let pubkeys = match sub_command_args.pubkey.is_empty() {
true => None,
false => Some(
sub_command_args
.pubkey
.iter()
.map(|p| PublicKey::from_str(p).unwrap())
.collect(),
),
};
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).unwrap())
.collect(),
),
};
let conditions = Conditions::new(
sub_command_args.locktime,
pubkeys,
refund_keys,
sub_command_args.required_sigs,
None,
)
.unwrap();
Some(SpendingConditions::new_htlc(preimage.clone(), conditions)?)
}
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).unwrap())
.collect();
let refund_keys: Vec<PublicKey> = sub_command_args
.refund_keys
.iter()
.map(|p| PublicKey::from_str(p).unwrap())
.collect();
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,
)
.unwrap();
tracing::debug!("{}", data_pubkey.to_string());
Some(SpendingConditions::P2PKConditions {
data: data_pubkey,
conditions,
})
}
},
};
let token = wallet
.send(
&mint_url,
CurrencyUnit::Sat,
sub_command_args.memo.clone(),
token_amount,
&SplitTarget::default(),
conditions,
)
.await?;
println!("{}", token);
Ok(())
}

View File

@@ -0,0 +1,18 @@
use std::str::FromStr;
use anyhow::Result;
use cdk::nuts::Token;
use clap::Args;
#[derive(Args)]
pub struct DecodeTokenSubCommand {
/// Cashu Token
token: String,
}
pub fn decode_token(sub_command_args: &DecodeTokenSubCommand) -> Result<()> {
let token = Token::from_str(&sub_command_args.token)?;
println!("{:}", serde_json::to_string_pretty(&token)?);
Ok(())
}

View File

@@ -0,0 +1,79 @@
use std::collections::HashMap;
use std::io::Write;
use std::str::FromStr;
use std::{io, println};
use anyhow::{bail, Result};
use cdk::amount::SplitTarget;
use cdk::nuts::CurrencyUnit;
use cdk::url::UncheckedUrl;
use cdk::wallet::Wallet;
use cdk::Bolt11Invoice;
use clap::Args;
#[derive(Args)]
pub struct MeltSubCommand {}
pub async fn melt(wallet: Wallet, _sub_command_args: &MeltSubCommand) -> Result<()> {
let mints_amounts: Vec<(UncheckedUrl, HashMap<_, _>)> =
wallet.mint_balances().await?.into_iter().collect();
for (i, (mint, amount)) in mints_amounts.iter().enumerate() {
println!("{}: {}, {:?} sats", i, mint, amount);
}
println!("Enter mint number to create token");
let mut user_input = String::new();
let stdin = io::stdin();
io::stdout().flush().unwrap();
stdin.read_line(&mut user_input)?;
let mint_number: usize = user_input.trim().parse()?;
if mint_number.gt(&(mints_amounts.len() - 1)) {
bail!("Invalid mint number");
}
let mint_url = mints_amounts[mint_number].0.clone();
println!("Enter bolt11 invoice request");
let mut user_input = String::new();
let stdin = io::stdin();
io::stdout().flush().unwrap();
stdin.read_line(&mut user_input)?;
let bolt11 = Bolt11Invoice::from_str(user_input.trim())?;
if bolt11
.amount_milli_satoshis()
.unwrap()
.gt(&(<cdk::Amount as Into<u64>>::into(
*mints_amounts[mint_number]
.1
.get(&CurrencyUnit::Sat)
.unwrap(),
) * 1000_u64))
{
bail!("Not enough funds");
}
let quote = wallet
.melt_quote(
mint_url.clone(),
cdk::nuts::CurrencyUnit::Sat,
bolt11.to_string(),
)
.await?;
let melt = wallet
.melt(&mint_url, &quote.id, SplitTarget::default())
.await
.unwrap();
println!("Paid invoice: {}", melt.paid);
if let Some(preimage) = melt.preimage {
println!("Payment preimage: {}", preimage);
}
Ok(())
}

View File

@@ -0,0 +1,59 @@
use std::time::Duration;
use anyhow::Result;
use cdk::amount::SplitTarget;
use cdk::nuts::CurrencyUnit;
use cdk::url::UncheckedUrl;
use cdk::wallet::Wallet;
use cdk::Amount;
use clap::Args;
use tokio::time::sleep;
#[derive(Args)]
pub struct MintSubCommand {
/// Amount
#[arg(short, long)]
amount: u64,
/// Currency unit e.g. sat
#[arg(short, long)]
unit: String,
/// Mint url
#[arg(short, long)]
mint_url: UncheckedUrl,
}
pub async fn mint(wallet: Wallet, sub_command_args: &MintSubCommand) -> Result<()> {
let mint_url = sub_command_args.mint_url.clone();
let quote = wallet
.mint_quote(
mint_url.clone(),
Amount::from(sub_command_args.amount),
CurrencyUnit::from(&sub_command_args.unit),
)
.await?;
println!("Quote: {:#?}", quote);
println!("Please pay: {}", quote.request);
loop {
let status = wallet
.mint_quote_status(mint_url.clone(), &quote.id)
.await?;
if status.paid {
break;
}
sleep(Duration::from_secs(2)).await;
}
let receive_amount = wallet
.mint(mint_url.clone(), &quote.id, SplitTarget::default(), None)
.await?;
println!("Received {receive_amount} from mint {mint_url}");
Ok(())
}

View File

@@ -0,0 +1,23 @@
use anyhow::Result;
use cdk::url::UncheckedUrl;
use cdk::HttpClient;
use clap::Args;
#[derive(Args)]
pub struct MintInfoSubcommand {
/// Cashu Token
#[arg(short, long)]
mint_url: UncheckedUrl,
}
pub async fn mint_info(sub_command_args: &MintInfoSubcommand) -> Result<()> {
let client = HttpClient::default();
let info = client
.get_mint_info(sub_command_args.mint_url.clone().try_into()?)
.await?;
println!("{:#?}", info);
Ok(())
}

View File

@@ -0,0 +1,8 @@
pub mod check_spent;
pub mod create_token;
pub mod decode_token;
pub mod melt;
pub mod mint;
pub mod mint_info;
pub mod receive;
pub mod restore;

View File

@@ -0,0 +1,81 @@
use std::str::FromStr;
use anyhow::{anyhow, Result};
use cdk::amount::SplitTarget;
use cdk::nuts::SecretKey;
use cdk::wallet::Wallet;
use clap::Args;
#[derive(Args)]
pub struct ReceiveSubCommand {
/// Cashu Token
token: Option<String>,
/// Nostr key
#[arg(short, long)]
nostr_key: Option<String>,
/// Signing Key
#[arg(short, long, action = clap::ArgAction::Append)]
signing_key: Vec<String>,
/// Nostr relay
#[arg(short, long, action = clap::ArgAction::Append)]
relay: Vec<String>,
/// Preimage
#[arg(short, long, action = clap::ArgAction::Append)]
preimage: Vec<String>,
}
pub async fn receive(wallet: Wallet, sub_command_args: &ReceiveSubCommand) -> Result<()> {
let nostr_key = match sub_command_args.nostr_key.as_ref() {
Some(nostr_key) => {
let secret_key = SecretKey::from_str(nostr_key)?;
wallet.add_p2pk_signing_key(secret_key.clone()).await;
Some(secret_key)
}
None => None,
};
if !sub_command_args.signing_key.is_empty() {
let signing_keys: Vec<SecretKey> = sub_command_args
.signing_key
.iter()
.map(|s| SecretKey::from_str(s).unwrap())
.collect();
for signing_key in signing_keys {
wallet.add_p2pk_signing_key(signing_key).await;
}
}
let preimage = match sub_command_args.preimage.is_empty() {
true => None,
false => Some(sub_command_args.preimage.clone()),
};
let amount = match nostr_key {
Some(nostr_key) => {
assert!(!sub_command_args.relay.is_empty());
wallet
.add_nostr_relays(sub_command_args.relay.clone())
.await?;
wallet
.nostr_receive(nostr_key, SplitTarget::default())
.await?
}
None => {
wallet
.receive(
sub_command_args
.token
.as_ref()
.ok_or(anyhow!("Token Required"))?,
&SplitTarget::default(),
preimage,
)
.await?
}
};
println!("Received: {}", amount);
Ok(())
}

View File

@@ -0,0 +1,21 @@
use anyhow::Result;
use cdk::url::UncheckedUrl;
use cdk::wallet::Wallet;
use clap::Args;
#[derive(Args)]
pub struct RestoreSubCommand {
/// Mint Url
#[arg(short, long)]
mint_url: UncheckedUrl,
}
pub async fn restore(wallet: Wallet, sub_command_args: &RestoreSubCommand) -> Result<()> {
let mint_url = sub_command_args.mint_url.clone();
let amount = wallet.restore(mint_url).await?;
println!("Restored {}", amount);
Ok(())
}