Browse Source

feat: cdk-cli

thesimplekid 9 months ago
parent
commit
5ebdd4f506

+ 3 - 1
.github/workflows/ci.yml

@@ -31,7 +31,9 @@ jobs:
             -p cdk --no-default-features --features wallet,
             -p cdk --no-default-features --features mint,
             -p cdk --no-default-features --features wallet --features nostr,
-            -p cdk-redb
+            -p cdk-redb,
+            -p cdk-sqlite,
+            --bin cdk-cli,
             --examples
           ]
     steps:

+ 1 - 0
Cargo.toml

@@ -24,6 +24,7 @@ keywords = ["bitcoin", "e-cash", "cashu"]
 async-trait = "0.1.74"
 cdk = { path = "./crates/cdk", default-features = false }
 cdk-rexie = { path = "./crates/cdk-rexie", default-features = false }
+cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = false }
 cdk-redb = { path = "./crates/cdk-redb", default-features = false }
 tokio = { version = "1.32", default-features = false }
 thiserror = "1"

+ 24 - 0
crates/cdk-cli/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "cdk-cli"
+version = "0.1.0"
+edition = "2021"
+authors = ["CDK Developers"]
+homepage.workspace = true
+repository.workspace = true
+rust-version.workspace = true # MSRV
+license.workspace = true
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0.75"
+cdk = { workspace = true, default-features = false, features = ["wallet", "nostr"] }
+cdk-redb = { workspace = true, default-features = false, features = ["wallet", "nostr"] }
+cdk-sqlite = { workspace = true, default-features = false, features = ["wallet", "nostr"] }
+clap = { version = "4.4.8", features = ["derive", "env"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+tracing-subscriber = "0.3.18"
+rand = "0.8.5"

+ 14 - 0
crates/cdk-cli/README.md

@@ -0,0 +1,14 @@
+> **Warning**
+> This project is in early development, it does however work with real sats! Always use amounts you don't mind loosing.
+
+cdk-cli is a CLI wallet implementation using of CDK(../cdk)
+
+## License
+
+Code is under the [MIT](../../LICENSE)
+
+## Contribution
+
+All contributions welcome.
+
+Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, shall be licensed as above, without any additional terms or conditions.

+ 117 - 0
crates/cdk-cli/src/main.rs

@@ -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
+        }
+    }
+}

+ 41 - 0
crates/cdk-cli/src/sub_commands/check_spent.rs

@@ -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(())
+}

+ 164 - 0
crates/cdk-cli/src/sub_commands/create_token.rs

@@ -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(())
+}

+ 18 - 0
crates/cdk-cli/src/sub_commands/decode_token.rs

@@ -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(())
+}

+ 79 - 0
crates/cdk-cli/src/sub_commands/melt.rs

@@ -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(())
+}

+ 59 - 0
crates/cdk-cli/src/sub_commands/mint.rs

@@ -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(())
+}

+ 23 - 0
crates/cdk-cli/src/sub_commands/mint_info.rs

@@ -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(())
+}

+ 8 - 0
crates/cdk-cli/src/sub_commands/mod.rs

@@ -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;

+ 81 - 0
crates/cdk-cli/src/sub_commands/receive.rs

@@ -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(())
+}

+ 21 - 0
crates/cdk-cli/src/sub_commands/restore.rs

@@ -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(())
+}

+ 1 - 0
misc/scripts/check-crates.sh

@@ -34,6 +34,7 @@ buildargs=(
     "-p cdk-redb --no-default-features --features mint"
     "-p cdk-sqlite --no-default-features --features mint"
     "-p cdk-sqlite --no-default-features --features wallet"
+    "--bin cdk-cli"
     "--examples"
 )