feat: nostr receive

This commit is contained in:
thesimplekid
2024-05-18 19:59:50 +01:00
parent 711175739f
commit c64c741e14
12 changed files with 314 additions and 66 deletions

View File

@@ -30,6 +30,7 @@ jobs:
-p cdk --no-default-features,
-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
]
steps:
@@ -64,6 +65,7 @@ jobs:
-p cdk,
-p cdk --no-default-features,
-p cdk --no-default-features --features wallet,
-p cdk --no-default-features --features wallet --features nostr,
-p cdk-js
]
steps:

View File

@@ -1,2 +1,2 @@
[language-server.rust-analyzer.config]
cargo = { features = ["wallet", "mint"] }
cargo = { features = ["wallet", "mint", "nostr"] }

View File

@@ -12,6 +12,7 @@ rust-version.workspace = true
default = ["mint", "wallet"]
mint = ["cdk/mint"]
wallet = ["cdk/wallet"]
nostr = ["cdk/nostr"]
[dependencies]
async-trait.workspace = true

View File

@@ -5,6 +5,8 @@ use std::sync::Arc;
use async_trait::async_trait;
use cdk::cdk_database;
use cdk::cdk_database::WalletDatabase;
#[cfg(feature = "nostr")]
use cdk::nuts::PublicKey;
use cdk::nuts::{Id, KeySetInfo, Keys, MintInfo, Proofs};
use cdk::types::{MeltQuote, MintQuote};
use cdk::url::UncheckedUrl;
@@ -25,6 +27,8 @@ const PENDING_PROOFS_TABLE: MultimapTableDefinition<&str, &str> =
MultimapTableDefinition::new("pending_proofs");
const CONFIG_TABLE: TableDefinition<&str, &str> = TableDefinition::new("config");
const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_counter");
#[cfg(feature = "nostr")]
const NOSTR_LAST_CHECKED: TableDefinition<&str, u32> = TableDefinition::new("keyset_counter");
const DATABASE_VERSION: u32 = 0;
@@ -64,6 +68,8 @@ impl RedbWalletDatabase {
let _ = write_txn.open_table(MINT_KEYS_TABLE)?;
let _ = write_txn.open_multimap_table(PROOFS_TABLE)?;
let _ = write_txn.open_table(KEYSET_COUNTER)?;
#[cfg(feature = "nostr")]
let _ = write_txn.open_table(NOSTR_LAST_CHECKED)?;
table.insert("db_version", "0")?;
}
}
@@ -562,4 +568,45 @@ impl WalletDatabase for RedbWalletDatabase {
Ok(counter.map(|c| c.value()))
}
#[cfg(feature = "nostr")]
#[instrument(skip(self))]
async fn get_nostr_last_checked(
&self,
verifying_key: &PublicKey,
) -> Result<Option<u32>, Self::Err> {
let db = self.db.lock().await;
let read_txn = db.begin_read().map_err(Error::from)?;
let table = read_txn
.open_table(NOSTR_LAST_CHECKED)
.map_err(Error::from)?;
let last_checked = table
.get(verifying_key.to_string().as_str())
.map_err(Error::from)?;
Ok(last_checked.map(|c| c.value()))
}
#[cfg(feature = "nostr")]
#[instrument(skip(self))]
async fn add_nostr_last_checked(
&self,
verifying_key: PublicKey,
last_checked: u32,
) -> Result<(), Self::Err> {
let db = self.db.lock().await;
let write_txn = db.begin_write().map_err(Error::from)?;
{
let mut table = write_txn
.open_table(NOSTR_LAST_CHECKED)
.map_err(Error::from)?;
table
.insert(verifying_key.to_string().as_str(), last_checked)
.map_err(Error::from)?;
}
write_txn.commit().map_err(Error::from)?;
Ok(())
}
}

View File

@@ -13,6 +13,7 @@ license.workspace = true
default = ["mint", "wallet"]
mint = []
wallet = ["dep:reqwest"]
nostr = ["dep:nostr-sdk"]
[dependencies]
async-trait = "0.1"
@@ -41,6 +42,10 @@ tracing = { version = "0.1", default-features = false, features = [
thiserror = "1"
url = "2.3"
uuid = { version = "1", features = ["v4"] }
nostr-sdk = { version = "0.31.0", default-features = false, features = [
"nip04",
"nip44"
], optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
tokio = { workspace = true, features = [

View File

@@ -9,8 +9,10 @@ use thiserror::Error;
#[cfg(feature = "mint")]
use crate::mint::MintKeySetInfo;
#[cfg(any(feature = "nostr", feature = "mint"))]
use crate::nuts::PublicKey;
#[cfg(feature = "mint")]
use crate::nuts::{BlindSignature, CurrencyUnit, Proof, PublicKey};
use crate::nuts::{BlindSignature, CurrencyUnit, Proof};
#[cfg(any(feature = "wallet", feature = "mint"))]
use crate::nuts::{Id, MintInfo};
#[cfg(feature = "wallet")]
@@ -91,6 +93,18 @@ pub trait WalletDatabase {
async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>;
async fn get_keyset_counter(&self, keyset_id: &Id) -> Result<Option<u32>, Self::Err>;
#[cfg(feature = "nostr")]
async fn get_nostr_last_checked(
&self,
verifying_key: &PublicKey,
) -> Result<Option<u32>, Self::Err>;
#[cfg(feature = "nostr")]
async fn add_nostr_last_checked(
&self,
verifying_key: PublicKey,
last_checked: u32,
) -> Result<(), Self::Err>;
}
#[cfg(feature = "mint")]

View File

@@ -8,10 +8,13 @@ use tokio::sync::RwLock;
use super::WalletDatabase;
use crate::cdk_database::Error;
#[cfg(feature = "nostr")]
use crate::nuts::PublicKey;
use crate::nuts::{Id, KeySetInfo, Keys, MintInfo, Proof, Proofs};
use crate::types::{MeltQuote, MintQuote};
use crate::url::UncheckedUrl;
// TODO: Change these all to RwLocks
#[derive(Default, Debug, Clone)]
pub struct WalletMemoryDatabase {
mints: Arc<RwLock<HashMap<UncheckedUrl, Option<MintInfo>>>>,
@@ -22,6 +25,8 @@ pub struct WalletMemoryDatabase {
proofs: Arc<RwLock<HashMap<UncheckedUrl, HashSet<Proof>>>>,
pending_proofs: Arc<RwLock<HashMap<UncheckedUrl, HashSet<Proof>>>>,
keyset_counter: Arc<RwLock<HashMap<Id, u32>>>,
#[cfg(feature = "nostr")]
nostr_last_checked: Arc<RwLock<HashMap<PublicKey, u32>>>,
}
impl WalletMemoryDatabase {
@@ -30,6 +35,7 @@ impl WalletMemoryDatabase {
melt_quotes: Vec<MeltQuote>,
mint_keys: Vec<Keys>,
keyset_counter: HashMap<Id, u32>,
#[cfg(feature = "nostr")] nostr_last_checked: HashMap<PublicKey, u32>,
) -> Self {
Self {
mints: Arc::new(RwLock::new(HashMap::new())),
@@ -46,6 +52,8 @@ impl WalletMemoryDatabase {
proofs: Arc::new(RwLock::new(HashMap::new())),
pending_proofs: Arc::new(RwLock::new(HashMap::new())),
keyset_counter: Arc::new(RwLock::new(keyset_counter)),
#[cfg(feature = "nostr")]
nostr_last_checked: Arc::new(RwLock::new(nostr_last_checked)),
}
}
}
@@ -233,4 +241,30 @@ impl WalletDatabase for WalletMemoryDatabase {
async fn get_keyset_counter(&self, id: &Id) -> Result<Option<u32>, Error> {
Ok(self.keyset_counter.read().await.get(id).cloned())
}
#[cfg(feature = "nostr")]
async fn get_nostr_last_checked(
&self,
verifying_key: &PublicKey,
) -> Result<Option<u32>, Self::Err> {
Ok(self
.nostr_last_checked
.read()
.await
.get(verifying_key)
.cloned())
}
#[cfg(feature = "nostr")]
async fn add_nostr_last_checked(
&self,
verifying_key: PublicKey,
last_checked: u32,
) -> Result<(), Self::Err> {
self.nostr_last_checked
.write()
.await
.insert(verifying_key, last_checked);
Ok(())
}
}

View File

@@ -24,6 +24,9 @@ pub enum Error {
Secp256k1(#[from] secp256k1::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[cfg(feature = "nostr")]
#[error(transparent)]
NostrKey(#[from] nostr_sdk::key::Error),
#[error("Invalid public key size: expected={expected}, found={found}")]
InvalidPublicKeySize { expected: usize, found: usize },
}

View File

@@ -5,7 +5,7 @@ use core::str::FromStr;
use bitcoin::hashes::sha256::Hash as Sha256Hash;
use bitcoin::hashes::Hash;
use bitcoin::secp256k1::schnorr::Signature;
use bitcoin::secp256k1::{self, Message};
use bitcoin::secp256k1::{self, Message, XOnlyPublicKey};
use serde::{Deserialize, Deserializer, Serialize};
use super::Error;
@@ -30,6 +30,30 @@ impl From<secp256k1::PublicKey> for PublicKey {
}
}
#[cfg(feature = "nostr")]
impl TryFrom<PublicKey> for nostr_sdk::PublicKey {
type Error = Error;
fn try_from(pubkey: PublicKey) -> Result<Self, Self::Error> {
Ok(nostr_sdk::PublicKey::from_slice(&pubkey.to_bytes())?)
}
}
#[cfg(feature = "nostr")]
impl TryFrom<nostr_sdk::PublicKey> for PublicKey {
type Error = Error;
fn try_from(pubkey: nostr_sdk::PublicKey) -> Result<Self, Self::Error> {
(&pubkey).try_into()
}
}
#[cfg(feature = "nostr")]
impl TryFrom<&nostr_sdk::PublicKey> for PublicKey {
type Error = Error;
fn try_from(pubkey: &nostr_sdk::PublicKey) -> Result<Self, Self::Error> {
PublicKey::from_slice(&pubkey.to_bytes())
}
}
impl PublicKey {
/// Parse from `bytes`
#[inline]
@@ -70,6 +94,11 @@ impl PublicKey {
self.inner.serialize_uncompressed()
}
#[inline]
pub fn x_only_pubkey(&self) -> XOnlyPublicKey {
self.inner.x_only_public_key().0
}
/// Get public key as `hex` string
#[inline]
pub fn to_hex(&self) -> String {

View File

@@ -32,6 +32,22 @@ impl From<secp256k1::SecretKey> for SecretKey {
}
}
#[cfg(feature = "nostr")]
impl TryFrom<SecretKey> for nostr_sdk::SecretKey {
type Error = Error;
fn try_from(seckey: SecretKey) -> Result<Self, Self::Error> {
(&seckey).try_into()
}
}
#[cfg(feature = "nostr")]
impl TryFrom<&SecretKey> for nostr_sdk::SecretKey {
type Error = Error;
fn try_from(seckey: &SecretKey) -> Result<Self, Self::Error> {
Ok(nostr_sdk::SecretKey::from_slice(&seckey.to_secret_bytes())?)
}
}
impl fmt::Display for SecretKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_secret_hex())

View File

@@ -9,6 +9,10 @@ use bitcoin::bip32::ExtendedPrivKey;
use bitcoin::hashes::sha256::Hash as Sha256Hash;
use bitcoin::hashes::Hash;
use bitcoin::Network;
#[cfg(feature = "nostr")]
use nostr_sdk::nips::nip04;
#[cfg(feature = "nostr")]
use nostr_sdk::{Filter, NostrSigner, RelayPoolNotification, Timestamp};
use thiserror::Error;
use tracing::instrument;
@@ -81,6 +85,12 @@ pub enum Error {
Invoice(#[from] lightning_invoice::ParseOrSemanticError),
#[error(transparent)]
Serde(#[from] serde_json::Error),
#[cfg(feature = "nostr")]
#[error(transparent)]
NostrClient(#[from] nostr_sdk::client::Error),
#[cfg(feature = "nostr")]
#[error(transparent)]
NostrKey(#[from] nostr_sdk::key::Error),
#[error("`{0}`")]
Custom(String),
}
@@ -96,6 +106,8 @@ pub struct Wallet {
pub client: HttpClient,
pub localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync>,
xpriv: ExtendedPrivKey,
#[cfg(feature = "nostr")]
nostr_client: nostr_sdk::Client,
}
impl Wallet {
@@ -105,13 +117,24 @@ impl Wallet {
) -> Self {
let xpriv = ExtendedPrivKey::new_master(Network::Bitcoin, seed)
.expect("Could not create master key");
Self {
client: HttpClient::new(),
localstore,
xpriv,
#[cfg(feature = "nostr")]
nostr_client: nostr_sdk::Client::default(),
}
}
/// Add nostr relays to client
#[cfg(feature = "nostr")]
#[instrument(skip(self))]
pub async fn add_nostr_relays(&self, relays: Vec<String>) -> Result<(), Error> {
self.nostr_client.add_relays(relays).await?;
Ok(())
}
/// Total Balance of wallet
#[instrument(skip(self))]
pub async fn total_balance(&self) -> Result<Amount, Error> {
@@ -676,7 +699,7 @@ impl Wallet {
/// Create Swap Payload
#[instrument(skip(self, proofs), fields(mint_url = %mint_url))]
async fn create_swap(
&mut self,
&self,
mint_url: &UncheckedUrl,
unit: &CurrencyUnit,
amount: Option<Amount>,
@@ -784,7 +807,7 @@ impl Wallet {
};
Ok(self
.proofs_to_token(
.proof_to_token(
mint_url.clone(),
send_proofs.ok_or(Error::InsufficientFunds)?,
memo,
@@ -1003,7 +1026,7 @@ impl Wallet {
/// Receive
#[instrument(skip_all)]
pub async fn receive(
&mut self,
&self,
encoded_token: &str,
signing_keys: Option<Vec<SecretKey>>,
preimages: Option<Vec<String>>,
@@ -1061,7 +1084,7 @@ impl Wallet {
for proof in &mut proofs {
// Verify that proof DLEQ is valid
{
if proof.dleq.is_some() {
let keys = self.get_keyset_keys(&token.mint, proof.keyset_id).await?;
let key = keys.amount_key(proof.amount).ok_or(Error::UnknownKey)?;
proof.verify_dleq(key)?;
@@ -1145,8 +1168,131 @@ impl Wallet {
Ok(total_amount)
}
#[cfg(feature = "nostr")]
#[instrument(skip(self))]
pub async fn nostr_receive(&self, nostr_signing_key: SecretKey) -> Result<Amount, Error> {
use nostr_sdk::{Keys, Kind, RelayMessage};
use tokio::sync::Mutex;
let verifying_key = nostr_signing_key.public_key();
let nostr_pubkey =
nostr_sdk::PublicKey::from_hex(verifying_key.x_only_pubkey().to_string())?;
let filter = match self
.localstore
.get_nostr_last_checked(&verifying_key)
.await?
{
Some(since) => Filter::new()
.pubkey(nostr_pubkey)
.since(Timestamp::from(since as u64)),
None => Filter::new().pubkey(nostr_pubkey),
};
self.nostr_client.connect().await;
self.nostr_client.subscribe(vec![filter], None).await;
let tokens: Mutex<HashSet<String>> = Mutex::new(HashSet::new());
// Handle subscription notifications with `handle_notifications` method
self.nostr_client
.handle_notifications(|notification| async {
let mut exit = false;
let keys = Keys::from_str(&nostr_signing_key.to_secret_hex()).unwrap();
match notification {
RelayPoolNotification::Event {
relay_url: _,
subscription_id: _,
event,
} => {
// Check kind
if event.kind() == Kind::EncryptedDirectMessage {
if let Ok(msg) = nip04::decrypt(
keys.secret_key()?,
event.author_ref(),
event.content(),
) {
println!("DM: {msg}");
if let Some(token) = Self::token_from_text(&msg) {
tokens.lock().await.insert(token.to_string());
}
} else {
tracing::error!("Impossible to decrypt direct message");
}
} else {
println!("Other event: {:?}", event.kind);
}
}
RelayPoolNotification::Message { relay_url, message } => match message {
RelayMessage::Auth { challenge } => {
self.nostr_client
.set_signer(Some(NostrSigner::Keys(keys)))
.await;
let r = self.nostr_client.auth(challenge, relay_url).await?;
tracing::debug!("Event id: {r}");
}
RelayMessage::Notice { message } => {
tracing::debug!("Notice: {message}");
}
RelayMessage::Ok {
event_id,
status: _,
message: _,
} => {
println!("Ok: {:?}", event_id);
}
RelayMessage::EndOfStoredEvents(_sub_id) => {
exit = true;
}
_ => {
tracing::debug!("{:?}", message);
}
},
_ => {
tracing::debug!("{:?}", notification);
}
}
Ok(exit) // Set to true to exit from the loop
})
.await?;
let mut total_received = Amount::ZERO;
for token in tokens.lock().await.iter() {
match self.receive(token, None, None).await {
Ok(amount) => total_received += amount,
Err(err) => {
tracing::error!("Could not receive token: {}", err);
}
}
}
self.localstore
.add_nostr_last_checked(verifying_key, unix_time() as u32)
.await?;
Ok(total_received)
}
#[cfg(feature = "nostr")]
fn token_from_text(text: &str) -> Option<&str> {
let text = text.trim();
if let Some(start) = text.find("cashu") {
match text[start..].find(' ') {
Some(end) => return Some(&text[start..(end + start)]),
None => return Some(&text[start..]),
}
}
None
}
#[instrument(skip(self, proofs), fields(mint_url = %mint_url))]
pub fn proofs_to_token(
pub fn proof_to_token(
&self,
mint_url: UncheckedUrl,
proofs: Proofs,
@@ -1408,71 +1554,20 @@ impl Wallet {
}
}
/*
#[cfg(feature = "nostr")]
#[cfg(test)]
mod tests {
use std::collections::{HashMap, HashSet};
use super::*;
use crate::client::Client;
use crate::mint::Mint;
use cashu::nuts::nut04;
#[test]
fn test_wallet() {
let mut mint = Mint::new(
"supersecretsecret",
"0/0/0/0",
HashMap::new(),
HashSet::new(),
32,
);
fn test_token_from_text() {
let text = " Here is some ecash: cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjIsInNlY3JldCI6ImI2Zjk1ODIxYmZlNjUyYjYwZGQ2ZjYwMDU4N2UyZjNhOTk4MzVhMGMyNWI4MTQzODNlYWIwY2QzOWFiNDFjNzUiLCJDIjoiMDI1YWU4ZGEyOTY2Y2E5OGVmYjA5ZDcwOGMxM2FiZmEwZDkxNGUwYTk3OTE4MmFjMzQ4MDllMjYxODY5YTBhNDJlIiwicmVzZXJ2ZWQiOmZhbHNlLCJpZCI6IjAwOWExZjI5MzI1M2U0MWUifSx7ImFtb3VudCI6Miwic2VjcmV0IjoiZjU0Y2JjNmNhZWZmYTY5MTUyOTgyM2M1MjU1MDkwYjRhMDZjNGQ3ZDRjNzNhNDFlZTFkNDBlM2ExY2EzZGZhNyIsIkMiOiIwMjMyMTIzN2JlYjcyMWU3NGI1NzcwNWE5MjJjNjUxMGQwOTYyYzAzNzlhZDM0OTJhMDYwMDliZTAyNjA5ZjA3NTAiLCJyZXNlcnZlZCI6ZmFsc2UsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSJ9LHsiYW1vdW50IjoxLCJzZWNyZXQiOiJhNzdhM2NjODY4YWM4ZGU3YmNiOWMxMzJmZWI3YzEzMDY4Nzg3ODk5Yzk3YTk2NWE2ZThkZTFiMzliMmQ2NmQ3IiwiQyI6IjAzMTY0YTMxNWVhNjM0NGE5NWI2NzM1NzBkYzg0YmZlMTQ2NDhmMTQwM2EwMDJiZmJlMDhlNWFhMWE0NDQ0YWE0MCIsInJlc2VydmVkIjpmYWxzZSwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0= fdfdfg
sdfs";
let token = Wallet::token_from_text(text).unwrap();
let keys = mint.active_keyset_pubkeys();
let token_str = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjIsInNlY3JldCI6ImI2Zjk1ODIxYmZlNjUyYjYwZGQ2ZjYwMDU4N2UyZjNhOTk4MzVhMGMyNWI4MTQzODNlYWIwY2QzOWFiNDFjNzUiLCJDIjoiMDI1YWU4ZGEyOTY2Y2E5OGVmYjA5ZDcwOGMxM2FiZmEwZDkxNGUwYTk3OTE4MmFjMzQ4MDllMjYxODY5YTBhNDJlIiwicmVzZXJ2ZWQiOmZhbHNlLCJpZCI6IjAwOWExZjI5MzI1M2U0MWUifSx7ImFtb3VudCI6Miwic2VjcmV0IjoiZjU0Y2JjNmNhZWZmYTY5MTUyOTgyM2M1MjU1MDkwYjRhMDZjNGQ3ZDRjNzNhNDFlZTFkNDBlM2ExY2EzZGZhNyIsIkMiOiIwMjMyMTIzN2JlYjcyMWU3NGI1NzcwNWE5MjJjNjUxMGQwOTYyYzAzNzlhZDM0OTJhMDYwMDliZTAyNjA5ZjA3NTAiLCJyZXNlcnZlZCI6ZmFsc2UsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSJ9LHsiYW1vdW50IjoxLCJzZWNyZXQiOiJhNzdhM2NjODY4YWM4ZGU3YmNiOWMxMzJmZWI3YzEzMDY4Nzg3ODk5Yzk3YTk2NWE2ZThkZTFiMzliMmQ2NmQ3IiwiQyI6IjAzMTY0YTMxNWVhNjM0NGE5NWI2NzM1NzBkYzg0YmZlMTQ2NDhmMTQwM2EwMDJiZmJlMDhlNWFhMWE0NDQ0YWE0MCIsInJlc2VydmVkIjpmYWxzZSwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0=";
let client = Client::new("https://cashu-rs.thesimplekid.space/").unwrap();
let wallet = Wallet::new(client, keys.keys);
let blinded_messages = BlindedMessages::random(Amount::from_sat(64)).unwrap();
let mint_request = nut04::MintRequest {
outputs: blinded_messages.blinded_messages.clone(),
};
let res = mint.process_mint_request(mint_request).unwrap();
let proofs = wallet
.process_split_response(blinded_messages, res.promises)
.unwrap();
for proof in &proofs {
mint.verify_proof(proof).unwrap();
}
let split = wallet.create_split(proofs.clone()).unwrap();
let split_request = split.split_payload;
let split_response = mint.process_split_request(split_request).unwrap();
let p = split_response.promises;
let snd_proofs = wallet
.process_split_response(split.blinded_messages, p.unwrap())
.unwrap();
let mut error = false;
for proof in &snd_proofs {
if let Err(err) = mint.verify_proof(proof) {
println!("{err}{:?}", serde_json::to_string(proof));
error = true;
}
}
if error {
panic!()
}
assert_eq!(token, token_str)
}
}
*/

View File

@@ -26,9 +26,11 @@ buildargs=(
"-p cdk"
"-p cdk --no-default-features"
"-p cdk --no-default-features --features wallet"
"-p cdk --no-default-features --features wallet --features nostr"
"-p cdk --no-default-features --features mint"
"-p cdk-redb"
"-p cdk-redb --no-default-features --features wallet"
"-p cdk-redb --no-default-features --features wallet --features nostr"
"-p cdk-redb --no-default-features --features mint"
)