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

View File

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

View File

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

24
crates/cdk-cli/Cargo.toml Normal file
View File

@@ -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
crates/cdk-cli/README.md Normal file
View File

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

View File

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