cdk-cli 0.18.0-rc.0

Cashu cli wallet built on CDK
use anyhow::Result;
use cdk::mint_url::MintUrl;
use cdk::nuts::CurrencyUnit;
use cdk::wallet::{payment_request as pr, NostrWaitInfo, WalletRepository};
use clap::Args;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub(super) struct StoredNostrWaitInfo {
    pub(super) secret_key_hex: String,
    pub(super) relays: Vec<String>,
    pub(super) pubkey_hex: String,
    #[serde(default)]
    pub(super) mints: Vec<MintUrl>,
    #[serde(default)]
    pub(super) mint_preferred: Option<bool>,
}

impl StoredNostrWaitInfo {
    pub(super) fn accepts_mint(&self, mint_url: &MintUrl) -> bool {
        self.mints.is_empty() || self.mint_preferred == Some(true) || self.mints.contains(mint_url)
    }
}

impl From<NostrWaitInfo> for StoredNostrWaitInfo {
    fn from(info: NostrWaitInfo) -> Self {
        Self {
            secret_key_hex: info.keys.secret_key().to_secret_hex(),
            relays: info.relays,
            pubkey_hex: info.pubkey.to_hex(),
            mints: info.mints,
            mint_preferred: info.mint_preferred,
        }
    }
}

#[derive(Args)]
pub struct CreateRequestSubCommand {
    #[arg(short, long)]
    amount: Option<u64>,
    /// Quote description
    description: Option<String>,
    /// P2PK: Public key(s) for which the token can be spent with valid signature(s)
    /// Can be specified multiple times for multiple pubkeys
    #[arg(long, action = clap::ArgAction::Append)]
    pubkey: Option<Vec<String>>,
    /// Number of required signatures (for multiple pubkeys)
    /// Defaults to 1 if not specified
    #[arg(long, default_value = "1")]
    num_sigs: u64,
    /// HTLC: Hash for hash time locked contract
    #[arg(long, conflicts_with = "preimage")]
    hash: Option<String>,
    /// HTLC: Preimage of the hash (to be used instead of hash)
    #[arg(long, conflicts_with = "hash")]
    preimage: Option<String>,
    /// Transport type to use (nostr, http, or none)
    /// - nostr: Use Nostr transport and listen for payment
    /// - http: Use HTTP transport but only print the request
    /// - none: Don't use any transport, just print the request
    #[arg(long, default_value = "nostr")]
    transport: String,
    /// URL for HTTP transport (only used when transport=http)
    #[arg(long)]
    http_url: Option<String>,
    /// Nostr relays to use (only used when transport=nostr)
    /// Can be specified multiple times for multiple relays
    /// If not provided, defaults to standard relays
    #[arg(long, action = clap::ArgAction::Append)]
    nostr_relay: Option<Vec<String>>,
    /// Mint URLs the receiver trusts. Can be specified multiple times.
    #[arg(long, action = clap::ArgAction::Append)]
    mints: Option<Vec<String>>,
    /// Prefer the listed mints while allowing payment from other mints
    #[arg(long)]
    mint_preferred: bool,
    /// Use bech32 encoding (CREQ-B)
    #[arg(short, long)]
    bech32: bool,
}

pub async fn create_request(
    wallet_repository: &WalletRepository,
    sub_command_args: &CreateRequestSubCommand,
    unit: &CurrencyUnit,
) -> Result<()> {
    // Gather parameters for library call
    let params = pr::CreateRequestParams {
        amount: sub_command_args.amount,
        unit: unit.to_string(),
        description: sub_command_args.description.clone(),
        pubkeys: sub_command_args.pubkey.clone(),
        num_sigs: sub_command_args.num_sigs,
        hash: sub_command_args.hash.clone(),
        preimage: sub_command_args.preimage.clone(),
        transport: sub_command_args.transport.to_lowercase(),
        http_url: sub_command_args.http_url.clone(),
        nostr_relays: sub_command_args.nostr_relay.clone(),
        mints: sub_command_args.mints.clone(),
        mint_preferred: sub_command_args.mint_preferred.then_some(true),
    };

    let (req, nostr_wait) = wallet_repository.create_request(params).await?;

    // Print the request to stdout
    if sub_command_args.bech32 {
        println!("{}", req.to_bech32_string()?);
    } else {
        println!("{}", req);
    }

    // If we set up Nostr transport, optionally wait for payment and receive it
    if let Some(info) = nostr_wait {
        let key = info.pubkey.to_string();

        if let Some(wallet) = wallet_repository.get_wallets().await.first() {
            let serializable_info = StoredNostrWaitInfo::from(info.clone());
            let val = serde_json::to_vec(&serializable_info)?;
            wallet
                .localstore
                .kv_write("cdk_cli", "pending_nostr_requests", &key, &val)
                .await?;
        }

        println!("Listening for payment via Nostr...");
        let amount = wallet_repository.wait_for_nostr_payment(info).await?;
        println!("Received {}", amount);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use std::str::FromStr;

    use super::*;

    #[test]
    fn stored_nostr_wait_info_enforces_strict_mints() {
        let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid mint");
        let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid mint");
        let info = stored_info(vec![listed_mint.clone()], None);

        assert!(info.accepts_mint(&listed_mint));
        assert!(!info.accepts_mint(&unlisted_mint));
    }

    #[test]
    fn stored_nostr_wait_info_allows_preferred_or_empty_mints() {
        let listed_mint = MintUrl::from_str("https://listed.example.com").expect("valid mint");
        let unlisted_mint = MintUrl::from_str("https://unlisted.example.com").expect("valid mint");

        assert!(stored_info(vec![listed_mint], Some(true)).accepts_mint(&unlisted_mint));
        assert!(stored_info(vec![], None).accepts_mint(&unlisted_mint));
    }

    #[test]
    fn old_stored_nostr_wait_info_deserializes_with_empty_policy() {
        let json = r#"{
            "secret_key_hex":"secret",
            "relays":["wss://relay.example.com"],
            "pubkey_hex":"pubkey"
        }"#;

        let info: StoredNostrWaitInfo = serde_json::from_str(json).expect("old record");

        assert!(info.mints.is_empty());
        assert!(info.mint_preferred.is_none());
    }

    fn stored_info(mints: Vec<MintUrl>, mint_preferred: Option<bool>) -> StoredNostrWaitInfo {
        StoredNostrWaitInfo {
            secret_key_hex: "secret".to_string(),
            relays: vec![],
            pubkey_hex: "pubkey".to_string(),
            mints,
            mint_preferred,
        }
    }
}