use std::collections::HashSet;
use std::sync::Arc;
use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv};
use bitcoin::Network;
use cdk_common::{database, SECP256K1};
use cdk_nostr::npubcash::{JwtAuthProvider, NpubCashClient, Quote};
use tracing::instrument;
use crate::error::Error;
use crate::nuts::SecretKey;
use crate::wallet::types::{MintQuote, TransactionDirection, TransactionStatus};
use crate::wallet::{MintQuoteState, Wallet};
use crate::Amount;
pub const NPUBCASH_KV_NAMESPACE: &str = "npubcash";
const QUOTES_KV_SECONDARY_NAMESPACE: &str = "quotes";
const QUOTE_KEY_NIP06: &[u8] = b"nip06";
const QUOTE_KEY_LEGACY_SEED_PREFIX: &[u8] = b"legacy-seed-prefix";
const LAST_FETCH_TIMESTAMP_KEY: &str = "last_fetch_timestamp";
const LEGACY_QUOTES_MIGRATED_V2_KEY: &str = "legacy_quotes_migrated_v2";
pub const ACTIVE_MINT_KEY: &str = "active_mint";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NpubCashQuoteKey {
Nip06,
LegacySeedPrefix,
}
impl NpubCashQuoteKey {
fn as_bytes(self) -> &'static [u8] {
match self {
Self::Nip06 => QUOTE_KEY_NIP06,
Self::LegacySeedPrefix => QUOTE_KEY_LEGACY_SEED_PREFIX,
}
}
fn from_bytes(bytes: &[u8]) -> Self {
match bytes {
QUOTE_KEY_LEGACY_SEED_PREFIX => Self::LegacySeedPrefix,
_ => Self::Nip06,
}
}
}
fn merge_npubcash_quote(mut incoming: MintQuote, existing: MintQuote) -> MintQuote {
incoming.state = match (incoming.state, existing.state) {
(MintQuoteState::Issued, _) | (_, MintQuoteState::Issued) => MintQuoteState::Issued,
(MintQuoteState::Paid, _) | (_, MintQuoteState::Paid) => MintQuoteState::Paid,
(MintQuoteState::Unpaid, MintQuoteState::Unpaid) => MintQuoteState::Unpaid,
};
incoming.amount_paid = incoming.amount_paid.max(existing.amount_paid);
incoming.amount_issued = incoming.amount_issued.max(existing.amount_issued);
incoming.updated_at = incoming.updated_at.max(existing.updated_at);
incoming.secret_key = existing.secret_key;
incoming.estimated_blocks = existing.estimated_blocks;
incoming.used_by_operation = existing.used_by_operation;
incoming.version = existing.version;
incoming
}
pub fn derive_npubcash_secret_key_from_seed(seed: &[u8; 64]) -> Result<SecretKey, Error> {
let path = DerivationPath::from(vec![
ChildNumber::from_hardened_idx(44)?,
ChildNumber::from_hardened_idx(1237)?,
ChildNumber::from_hardened_idx(0)?,
ChildNumber::from_normal_idx(0)?,
ChildNumber::from_normal_idx(0)?,
]);
let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?;
Ok(SecretKey::from(
xpriv.derive_priv(&SECP256K1, &path)?.private_key,
))
}
impl Wallet {
#[instrument(skip(self))]
pub async fn enable_npubcash(&self, npubcash_url: String) -> Result<(), Error> {
let keys = self.derive_npubcash_keys()?;
let auth_provider = Arc::new(JwtAuthProvider::new(npubcash_url.clone(), keys));
let client = Arc::new(NpubCashClient::new(npubcash_url.clone(), auth_provider));
let mint_url = self.mint_url.to_string();
match client.set_mint_url(&mint_url).await {
Ok(_) => {
tracing::info!(
"Mint URL '{}' set on NpubCash server at '{}'",
mint_url,
npubcash_url
);
}
Err(e) => {
tracing::warn!(
"Failed to set mint URL on NpubCash server: {}. Quotes may use server default.",
e
);
}
}
let response = client.set_quote_locking(true).await.map_err(|e| {
Error::Custom(format!("Failed to enable NpubCash quote locking: {}", e))
})?;
if !response.data.user().lock_quote {
return Err(Error::Custom(
"NpubCash server did not confirm quote locking".to_string(),
));
}
tracing::info!("NpubCash quote locking enabled at '{}'", npubcash_url);
if let Err(e) = self.import_legacy_npubcash_quotes_once(&npubcash_url).await {
tracing::warn!("Failed to import legacy NpubCash quotes: {}", e);
}
let mut npubcash = self.npubcash_client.write().await;
*npubcash = Some(client);
drop(npubcash);
tracing::info!("NpubCash integration enabled");
Ok(())
}
pub(crate) fn derive_npubcash_secret_key(&self) -> Result<SecretKey, Error> {
derive_npubcash_secret_key_from_seed(&self.seed)
}
fn derive_legacy_npubcash_secret_key(&self) -> Result<SecretKey, Error> {
Ok(SecretKey::from_slice(&self.seed[..32])?)
}
fn derive_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
let secret_key = self.derive_npubcash_secret_key()?;
let nostr_secret = nostr_sdk::SecretKey::from_slice(&secret_key.to_secret_bytes())
.map_err(|e| Error::Custom(format!("Failed to derive Nostr keys: {}", e)))?;
Ok(nostr_sdk::Keys::new(nostr_secret))
}
fn derive_legacy_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
let secret_key = self.derive_legacy_npubcash_secret_key()?;
let nostr_secret = nostr_sdk::SecretKey::from_slice(&secret_key.to_secret_bytes())
.map_err(|e| Error::Custom(format!("Failed to derive legacy Nostr keys: {}", e)))?;
Ok(nostr_sdk::Keys::new(nostr_secret))
}
pub fn get_npubcash_keys(&self) -> Result<nostr_sdk::Keys, Error> {
self.derive_npubcash_keys()
}
async fn get_npubcash_client(&self) -> Result<Arc<NpubCashClient>, Error> {
self.npubcash_client
.read()
.await
.clone()
.ok_or_else(|| Error::Custom("NpubCash not enabled".to_string()))
}
async fn process_npubcash_quotes(&self, quotes: Vec<Quote>) -> Result<Vec<MintQuote>, Error> {
self.process_npubcash_quotes_with_key(quotes, NpubCashQuoteKey::Nip06)
.await
}
async fn process_npubcash_quotes_with_key(
&self,
quotes: Vec<Quote>,
key: NpubCashQuoteKey,
) -> Result<Vec<MintQuote>, Error> {
let mut mint_quotes = Vec::with_capacity(quotes.len());
for quote in quotes {
if let Some(mint_quote) = self.add_npubcash_mint_quote_with_key(quote, key).await? {
mint_quotes.push(mint_quote);
}
}
Ok(mint_quotes)
}
#[instrument(skip(self))]
pub async fn sync_npubcash_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let client = self.get_npubcash_client().await?;
let since = self.get_last_npubcash_fetch_timestamp().await?;
let quotes = client
.get_quotes(since)
.await
.map_err(|e| Error::Custom(format!("Failed to sync quotes: {}", e)))?;
if let Some(max_ts) = quotes.iter().map(|q| q.created_at).max() {
self.set_last_npubcash_fetch_timestamp(max_ts).await?;
}
self.process_npubcash_quotes(quotes).await
}
#[instrument(skip(self))]
pub async fn sync_npubcash_quotes_since(&self, since: u64) -> Result<Vec<MintQuote>, Error> {
let client = self.get_npubcash_client().await?;
let quotes = client
.get_quotes(Some(since))
.await
.map_err(|e| Error::Custom(format!("Failed to sync quotes: {}", e)))?;
self.process_npubcash_quotes(quotes).await
}
#[instrument(skip(self))]
pub async fn sync_missing_npubcash_quotes(&self) -> Result<Vec<MintQuote>, Error> {
self.sync_missing_npubcash_quotes_with_ids()
.await
.map(|(quotes, _)| quotes)
}
async fn sync_missing_npubcash_quotes_with_ids(
&self,
) -> Result<(Vec<MintQuote>, HashSet<String>), Error> {
let client = self.get_npubcash_client().await?;
let remote_quotes = client
.get_quotes(None)
.await
.map_err(|e| Error::Custom(format!("Failed to fetch NpubCash quote list: {}", e)))?;
let remote_ids: HashSet<String> =
remote_quotes.iter().map(|quote| quote.id.clone()).collect();
let known_ids: HashSet<String> = self
.localstore
.get_mint_quotes()
.await?
.into_iter()
.map(|quote| quote.id)
.collect();
let missing_ids: Vec<String> = remote_quotes
.iter()
.filter(|quote| !known_ids.contains("e.id))
.map(|quote| quote.id.clone())
.collect();
if missing_ids.is_empty() {
self.process_npubcash_quotes(remote_quotes).await?;
return Ok((Vec::new(), remote_ids));
}
tracing::info!("Resolving {} missing NpubCash quotes", missing_ids.len());
let missing_quotes = match client.get_missing_quotes(&missing_ids).await {
Ok(quotes) => quotes,
Err(err) => {
tracing::warn!(
"Failed to resolve missing NpubCash quotes ({}); falling back to quote list data",
err
);
remote_quotes
.clone()
.into_iter()
.filter(|quote| missing_ids.contains("e.id))
.collect()
}
};
self.process_npubcash_quotes(
remote_quotes
.into_iter()
.filter(|quote| !missing_ids.contains("e.id))
.collect(),
)
.await?;
self.process_npubcash_quotes(missing_quotes)
.await
.map(|quotes| (quotes, remote_ids))
}
#[instrument(skip(self))]
pub async fn claim_npubcash_quotes(&self) -> Result<Amount, Error> {
let npubcash_quote_ids = self.collect_npubcash_quote_ids().await?;
let unissued_quotes = self.get_unissued_mint_quotes().await?;
let npubcash_quotes = unissued_quotes
.into_iter()
.filter(|quote| npubcash_quote_ids.contains("e.id))
.collect();
self.mint_given_unissued_quotes(npubcash_quotes).await
}
async fn collect_npubcash_quote_ids(&self) -> Result<HashSet<String>, Error> {
let synced_quotes = self.sync_npubcash_quotes().await?;
let (_, remote_ids) = self.sync_missing_npubcash_quotes_with_ids().await?;
let mut npubcash_quote_ids: HashSet<String> =
synced_quotes.into_iter().map(|quote| quote.id).collect();
npubcash_quote_ids.extend(remote_ids);
for quote in self.localstore.get_mint_quotes().await? {
if self.npubcash_quote_key("e.id).await?.is_some() {
npubcash_quote_ids.insert(quote.id);
}
}
Ok(npubcash_quote_ids)
}
pub fn npubcash_proof_stream(
&self,
split_target: cdk_common::amount::SplitTarget,
spending_conditions: Option<crate::nuts::SpendingConditions>,
poll_interval: std::time::Duration,
) -> crate::wallet::streams::npubcash::WalletNpubCashProofStream {
crate::wallet::streams::npubcash::WalletNpubCashProofStream::new(
self.clone(),
poll_interval,
split_target,
spending_conditions,
)
}
#[instrument(skip(self, mint_url))]
pub async fn set_npubcash_mint_url(
&self,
mint_url: impl Into<String>,
) -> Result<cdk_nostr::npubcash::UserResponse, Error> {
let client = self.get_npubcash_client().await?;
client
.set_mint_url(mint_url)
.await
.map_err(|e| Error::Custom(e.to_string()))
}
#[instrument(skip(self))]
pub async fn get_npubcash_user_info(&self) -> Result<cdk_nostr::npubcash::UserResponse, Error> {
let client = self.get_npubcash_client().await?;
client
.get_user_info()
.await
.map_err(|e| Error::Custom(e.to_string()))
}
#[instrument(skip(self))]
pub async fn add_npubcash_mint_quote(
&self,
npubcash_quote: cdk_nostr::npubcash::Quote,
) -> Result<Option<MintQuote>, Error> {
self.add_npubcash_mint_quote_with_key(npubcash_quote, NpubCashQuoteKey::Nip06)
.await
}
async fn add_npubcash_mint_quote_with_key(
&self,
npubcash_quote: cdk_nostr::npubcash::Quote,
key: NpubCashQuoteKey,
) -> Result<Option<MintQuote>, Error> {
let quote_locked = npubcash_quote.locked.unwrap_or(true);
let mint_quote: MintQuote = npubcash_quote.into();
let stored_quote = if quote_locked {
self.localstore
.kv_write(
NPUBCASH_KV_NAMESPACE,
QUOTES_KV_SECONDARY_NAMESPACE,
&mint_quote.id,
key.as_bytes(),
)
.await?;
match key {
NpubCashQuoteKey::Nip06 => self.localstore.get_mint_quote(&mint_quote.id).await?,
NpubCashQuoteKey::LegacySeedPrefix => {
self.scrub_proven_legacy_npubcash_quote(&mint_quote.id)
.await?
}
}
} else {
self.localstore
.kv_remove(
NPUBCASH_KV_NAMESPACE,
QUOTES_KV_SECONDARY_NAMESPACE,
&mint_quote.id,
)
.await?;
self.scrub_proven_legacy_npubcash_quote(&mint_quote.id)
.await?
};
let exists = self
.list_transactions(Some(TransactionDirection::Incoming))
.await?
.iter()
.any(|tx| {
tx.quote_id.as_ref() == Some(&mint_quote.id)
&& tx.status != TransactionStatus::Failed
});
if exists {
return Ok(None);
}
if let Some(stored_quote) = stored_quote {
let updated_quote = merge_npubcash_quote(mint_quote, stored_quote);
let quote_id = updated_quote.id.clone();
self.localstore.add_mint_quote(updated_quote).await?;
let persisted = self
.localstore
.get_mint_quote("e_id)
.await?
.ok_or(Error::UnknownQuote)?;
tracing::info!("Updated NpubCash quote {} in wallet database", quote_id);
return Ok(Some(persisted));
}
self.localstore.add_mint_quote(mint_quote.clone()).await?;
tracing::info!("Added NpubCash quote {} to wallet database", mint_quote.id);
Ok(Some(mint_quote))
}
pub(crate) async fn npubcash_quote_key(
&self,
quote_id: &str,
) -> Result<Option<NpubCashQuoteKey>, Error> {
Ok(self
.localstore
.kv_read(
NPUBCASH_KV_NAMESPACE,
QUOTES_KV_SECONDARY_NAMESPACE,
quote_id,
)
.await?
.map(|value| NpubCashQuoteKey::from_bytes(&value)))
}
fn is_legacy_npubcash_secret_key(&self, secret_key: &SecretKey) -> bool {
secret_key.as_secret_bytes() == &self.seed[..32]
}
pub(crate) fn npubcash_quote_secret_key(
&self,
key: NpubCashQuoteKey,
) -> Result<SecretKey, Error> {
match key {
NpubCashQuoteKey::Nip06 => self.derive_npubcash_secret_key(),
NpubCashQuoteKey::LegacySeedPrefix => self.derive_legacy_npubcash_secret_key(),
}
}
async fn scrub_proven_legacy_npubcash_quote(
&self,
quote_id: &str,
) -> Result<Option<MintQuote>, Error> {
let mut retry_concurrent_update = true;
loop {
let Some(mut quote) = self.localstore.get_mint_quote(quote_id).await? else {
return Ok(None);
};
let Some(secret_key) = "e.secret_key else {
return Ok(Some(quote));
};
if !self.is_legacy_npubcash_secret_key(secret_key) {
return Ok(Some(quote));
}
quote.secret_key = None;
match self.localstore.add_mint_quote(quote).await {
Ok(()) => return Ok(self.localstore.get_mint_quote(quote_id).await?),
Err(database::Error::ConcurrentUpdate) if retry_concurrent_update => {
retry_concurrent_update = false;
}
Err(error) => return Err(error.into()),
}
}
}
async fn import_legacy_npubcash_quotes_once(&self, npubcash_url: &str) -> Result<(), Error> {
if self
.localstore
.kv_read(NPUBCASH_KV_NAMESPACE, "", LEGACY_QUOTES_MIGRATED_V2_KEY)
.await?
.is_some()
{
return Ok(());
}
let keys = self.derive_legacy_npubcash_keys()?;
let auth_provider = Arc::new(JwtAuthProvider::new(npubcash_url.to_string(), keys));
let client = NpubCashClient::new(npubcash_url.to_string(), auth_provider);
let quotes = client
.get_quotes(None)
.await
.map_err(|e| Error::Custom(format!("Failed to sync legacy NpubCash quotes: {}", e)))?;
self.process_npubcash_quotes_with_key(quotes, NpubCashQuoteKey::LegacySeedPrefix)
.await?;
self.localstore
.kv_write(
NPUBCASH_KV_NAMESPACE,
"",
LEGACY_QUOTES_MIGRATED_V2_KEY,
&[1],
)
.await?;
Ok(())
}
pub async fn npubcash_client(&self) -> Option<Arc<NpubCashClient>> {
self.npubcash_client.read().await.clone()
}
pub async fn is_npubcash_enabled(&self) -> bool {
self.npubcash_client.read().await.is_some()
}
async fn get_last_npubcash_fetch_timestamp(&self) -> Result<Option<u64>, Error> {
let value = self
.localstore
.kv_read(NPUBCASH_KV_NAMESPACE, "", LAST_FETCH_TIMESTAMP_KEY)
.await?;
match value {
Some(bytes) => {
let timestamp =
u64::from_be_bytes(bytes.try_into().map_err(|_| {
Error::Custom("Invalid timestamp format in KV store".into())
})?);
Ok(Some(timestamp))
}
None => Ok(None),
}
}
async fn set_last_npubcash_fetch_timestamp(&self, timestamp: u64) -> Result<(), Error> {
self.localstore
.kv_write(
NPUBCASH_KV_NAMESPACE,
"",
LAST_FETCH_TIMESTAMP_KEY,
×tamp.to_be_bytes(),
)
.await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use std::sync::Arc;
use cdk_common::database::{self, WalletDatabase};
use super::*;
use crate::mint_url::MintUrl;
use crate::nuts::CurrencyUnit;
use crate::wallet::WalletBuilder;
async fn build_test_wallet(seed: [u8; 64]) -> Wallet {
let localstore: Arc<dyn WalletDatabase<database::Error> + Send + Sync> = Arc::new(
cdk_sqlite::wallet::memory::empty()
.await
.expect("memory db"),
);
WalletBuilder::new()
.mint_url(MintUrl::from_str("https://mint.example.com").expect("valid mint url"))
.unit(CurrencyUnit::Sat)
.localstore(localstore)
.seed(seed)
.build()
.expect("wallet builds")
}
fn test_quote() -> Quote {
Quote {
id: "npubcash-quote-1".to_string(),
amount: 1000,
unit: "sat".to_string(),
created_at: 0,
paid_at: None,
expires_at: None,
mint_url: Some("https://mint.example.com".to_string()),
request: Some("lnbc100n1pjz".to_string()),
state: Some("PAID".to_string()),
locked: None,
}
}
#[tokio::test]
async fn npubcash_key_is_nip06_derived_not_raw_seed() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let secret_key = wallet.derive_npubcash_secret_key().expect("key derives");
assert_ne!(
&secret_key.to_secret_bytes()[..],
&seed[..32],
"npubcash key must not equal raw wallet seed bytes"
);
let xpriv = Xpriv::new_master(Network::Bitcoin, &seed).expect("master key");
let path = DerivationPath::from_str("m/44'/1237'/0'/0/0").expect("valid path");
let expected = xpriv
.derive_priv(&SECP256K1, &path)
.expect("derivation")
.private_key;
assert_eq!(secret_key.to_secret_bytes(), expected.secret_bytes());
}
#[tokio::test]
async fn add_npubcash_mint_quote_does_not_persist_secret_key() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let stored = wallet
.add_npubcash_mint_quote(test_quote())
.await
.expect("add_npubcash_mint_quote succeeds")
.expect("quote was inserted");
assert!(
stored.secret_key.is_none(),
"npubcash quotes must not carry a persisted secret key"
);
let persisted = wallet
.localstore
.get_mint_quote(&stored.id)
.await
.expect("quote lookup")
.expect("quote in store");
assert!(
persisted.secret_key.is_none(),
"no secret key may be written to the localstore"
);
assert_eq!(
wallet
.npubcash_quote_key(&stored.id)
.await
.expect("kv lookup"),
Some(NpubCashQuoteKey::Nip06)
);
let signing_key = wallet
.mint_quote_signing_key(&persisted)
.await
.expect("signing key lookup")
.expect("npubcash quote signing key is re-derivable");
assert_eq!(
signing_key.to_secret_bytes(),
wallet
.derive_npubcash_secret_key()
.expect("key derives")
.to_secret_bytes()
);
assert_ne!(&signing_key.to_secret_bytes()[..], &seed[..32]);
}
#[tokio::test]
async fn legacy_npubcash_quote_uses_legacy_key_without_persisting_it() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let stored = wallet
.add_npubcash_mint_quote_with_key(test_quote(), NpubCashQuoteKey::LegacySeedPrefix)
.await
.expect("legacy quote is added")
.expect("quote was inserted");
assert!(stored.secret_key.is_none());
assert_eq!(
wallet
.npubcash_quote_key(&stored.id)
.await
.expect("kv lookup"),
Some(NpubCashQuoteKey::LegacySeedPrefix)
);
let signing_key = wallet
.mint_quote_signing_key(&stored)
.await
.expect("signing key lookup")
.expect("legacy npubcash signing key is re-derivable");
assert_eq!(&signing_key.to_secret_bytes()[..], &seed[..32]);
}
#[tokio::test]
async fn external_seed_prefix_key_is_not_claimed_by_npubcash() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let mut external_quote: MintQuote = test_quote().into();
external_quote.secret_key = Some(
wallet
.derive_legacy_npubcash_secret_key()
.expect("legacy key derives"),
);
wallet
.localstore
.add_mint_quote(external_quote.clone())
.await
.expect("external quote is stored");
let before = wallet
.localstore
.get_mint_quote(&external_quote.id)
.await
.expect("quote lookup")
.expect("quote remains stored");
let signing_key = wallet
.mint_quote_signing_key(&before)
.await
.expect("signing key lookup")
.expect("external signing key is returned");
assert_eq!(&signing_key.to_secret_bytes()[..], &seed[..32]);
let after = wallet
.localstore
.get_mint_quote(&external_quote.id)
.await
.expect("quote lookup")
.expect("quote remains stored");
assert_eq!(after.version, before.version);
assert!(
after.secret_key.is_some(),
"an unmarked external quote must keep its signing key"
);
assert_eq!(
wallet
.npubcash_quote_key(&external_quote.id)
.await
.expect("kv lookup"),
None
);
}
#[tokio::test]
async fn proven_legacy_persisted_npubcash_key_is_scrubbed_during_import() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let mut legacy_quote: MintQuote = test_quote().into();
legacy_quote.secret_key = Some(
wallet
.derive_legacy_npubcash_secret_key()
.expect("legacy key derives"),
);
wallet
.localstore
.add_mint_quote(legacy_quote.clone())
.await
.expect("legacy quote is stored");
let scrubbed = wallet
.add_npubcash_mint_quote_with_key(test_quote(), NpubCashQuoteKey::LegacySeedPrefix)
.await
.expect("legacy quote import succeeds")
.expect("legacy quote is returned");
assert!(
scrubbed.secret_key.is_none(),
"proven legacy raw seed key should be removed from storage"
);
assert!(scrubbed.version > legacy_quote.version);
assert_eq!(
wallet
.npubcash_quote_key(&legacy_quote.id)
.await
.expect("kv lookup"),
Some(NpubCashQuoteKey::LegacySeedPrefix)
);
let version_before_lookup = scrubbed.version;
let signing_key = wallet
.mint_quote_signing_key(&scrubbed)
.await
.expect("signing key lookup")
.expect("legacy signing key is returned");
assert_eq!(&signing_key.to_secret_bytes()[..], &seed[..32]);
let after_lookup = wallet
.localstore
.get_mint_quote(&legacy_quote.id)
.await
.expect("quote lookup")
.expect("quote remains stored");
assert_eq!(after_lookup.version, version_before_lookup);
}
#[tokio::test]
async fn sync_missing_npubcash_quotes_requires_enabled_client() {
let wallet = build_test_wallet([0x42u8; 64]).await;
let err = wallet
.sync_missing_npubcash_quotes()
.await
.expect_err("sync must fail when NpubCash is not enabled");
assert!(matches!(err, Error::Custom(_)));
}
#[tokio::test]
async fn unlocked_npubcash_quote_never_carries_provenance() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let mut unlocked_quote = test_quote();
unlocked_quote.locked = Some(false);
let stored = wallet
.add_npubcash_mint_quote(unlocked_quote)
.await
.expect("unlocked npubcash quote is added")
.expect("quote was inserted");
assert!(
stored.secret_key.is_none(),
"unlocked quote must not persist a secret key"
);
assert_eq!(
wallet
.npubcash_quote_key(&stored.id)
.await
.expect("kv lookup"),
None,
"unlocked quote must not carry a provenance marker"
);
assert!(
wallet
.mint_quote_signing_key(&stored)
.await
.expect("signing key lookup")
.is_none(),
"unlocked quote must resolve no signing key"
);
}
#[tokio::test]
async fn unlocked_update_scrubs_prior_provenance_and_key() {
let seed = [0x42u8; 64];
let wallet = build_test_wallet(seed).await;
let mut locked_quote = test_quote();
locked_quote.locked = Some(true);
let stored = wallet
.add_npubcash_mint_quote(locked_quote)
.await
.expect("locked npubcash quote is added")
.expect("quote was inserted");
assert_eq!(
wallet
.npubcash_quote_key(&stored.id)
.await
.expect("kv lookup"),
Some(NpubCashQuoteKey::Nip06)
);
let mut unlocked_quote = test_quote();
unlocked_quote.locked = Some(false);
let stored = wallet
.add_npubcash_mint_quote(unlocked_quote)
.await
.expect("unlocked npubcash quote update is applied")
.expect("quote was updated");
assert_eq!(
wallet
.npubcash_quote_key(&stored.id)
.await
.expect("kv lookup"),
None
);
assert!(wallet
.mint_quote_signing_key(&stored)
.await
.expect("signing key lookup")
.is_none());
}
#[tokio::test]
async fn claim_npubcash_quotes_requires_enabled_client() {
let wallet = build_test_wallet([0x42u8; 64]).await;
let err = wallet
.claim_npubcash_quotes()
.await
.expect_err("claim must fail when NpubCash is not enabled");
assert!(matches!(err, Error::Custom(_)));
}
enum LockResponse {
Rejected,
Confirmed,
NotConfirmed,
}
fn user_body(lock_quote: bool) -> String {
format!(
r#"{{"error":false,"data":{{"user":{{"pubkey":"test","mintUrl":"https://mint.example.com","lockQuote":{lock_quote}}}}}}}"#
)
}
async fn start_lock_gate_server(mode: LockResponse) -> (String, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("test server binds");
let addr = listener.local_addr().expect("test server has local addr");
let base_url = format!("http://{}", addr);
let server = tokio::spawn(async move {
loop {
let accept =
tokio::time::timeout(std::time::Duration::from_millis(500), listener.accept())
.await;
let Ok(Ok((mut stream, _))) = accept else {
break;
};
let mut buffer = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let read = stream.read(&mut chunk).await.expect("request is readable");
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let request = String::from_utf8_lossy(&buffer).to_string();
let (status, body) = if request.starts_with("PATCH /api/v2/user/lock ") {
match &mode {
LockResponse::Rejected => {
("HTTP/1.1 400 Bad Request", r#"{"error":true}"#.to_string())
}
LockResponse::Confirmed => ("HTTP/1.1 200 OK", user_body(true)),
LockResponse::NotConfirmed => ("HTTP/1.1 200 OK", user_body(false)),
}
} else if request.starts_with("PATCH /api/v2/user/mint ") {
("HTTP/1.1 200 OK", user_body(true))
} else {
("HTTP/1.1 400 Bad Request", r#"{"error":true}"#.to_string())
};
let response = format!(
"{status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.await
.expect("response is written");
}
});
(base_url, server)
}
#[tokio::test]
async fn enable_npubcash_fails_when_quote_locking_is_rejected() {
let wallet = build_test_wallet([0x42u8; 64]).await;
let (url, server) = start_lock_gate_server(LockResponse::Rejected).await;
let err = wallet
.enable_npubcash(url)
.await
.expect_err("enable must fail when the server rejects quote locking");
assert!(matches!(err, Error::Custom(_)));
assert!(
!wallet.is_npubcash_enabled().await,
"client must not be exposed when locking cannot be established"
);
server.await.expect("server completes");
}
#[tokio::test]
async fn enable_npubcash_fails_when_locking_is_not_confirmed() {
let wallet = build_test_wallet([0x42u8; 64]).await;
let (url, server) = start_lock_gate_server(LockResponse::NotConfirmed).await;
let err = wallet
.enable_npubcash(url)
.await
.expect_err("enable must fail when the server does not confirm locking");
assert!(matches!(err, Error::Custom(_)));
assert!(!wallet.is_npubcash_enabled().await);
server.await.expect("server completes");
}
#[tokio::test]
async fn enable_npubcash_publishes_client_once_locking_is_confirmed() {
let wallet = build_test_wallet([0x42u8; 64]).await;
let (url, server) = start_lock_gate_server(LockResponse::Confirmed).await;
wallet
.enable_npubcash(url)
.await
.expect("enable succeeds when locking is confirmed");
assert!(wallet.is_npubcash_enabled().await);
server.await.expect("server completes");
}
fn paid_server_quote(id: &str, amount: u64, locked: Option<bool>, mint_url: &str) -> Quote {
Quote {
id: id.to_string(),
amount,
unit: "sat".to_string(),
created_at: 0,
paid_at: Some(10),
expires_at: None,
mint_url: Some(mint_url.to_string()),
request: Some(format!("lnbc{amount}n1pjz")),
state: Some("PAID".to_string()),
locked,
}
}
#[tokio::test]
async fn upgrade_claims_legacy_locked_and_unlocked_quotes_after_reopen() {
use bitcoin::secp256k1::schnorr::Signature;
use crate::wallet::test_utils::{
create_test_db, create_test_wallet_with_mock_seed, test_mint_info, test_mint_url,
MockMintConnector,
};
let seed = [0x42u8; 64];
let mint_url = test_mint_url().to_string();
let db = create_test_db().await;
let legacy_server_quote = paid_server_quote("legacy-locked-quote", 1_000, None, &mint_url);
let mut pre_migration_quote: MintQuote = legacy_server_quote.clone().into();
pre_migration_quote.secret_key =
Some(SecretKey::from_slice(&seed[..32]).expect("legacy key is valid"));
db.add_mint_quote(pre_migration_quote)
.await
.expect("pre-migration quote is stored");
let mock_client = Arc::new(MockMintConnector::new());
mock_client.enable_mint_signing();
let mut mint_info = test_mint_info();
mint_info.nuts.nut29 = cdk_common::nut29::Settings::new(Some(100), None);
mock_client.set_mint_info_response(Ok(mint_info));
let wallet = create_test_wallet_with_mock_seed(db.clone(), mock_client.clone(), seed).await;
let stored = db
.get_mint_quote("legacy-locked-quote")
.await
.expect("quote lookup")
.expect("quote exists");
assert!(
stored.secret_key.is_some(),
"pre-migration quote still carries its persisted key"
);
wallet
.add_npubcash_mint_quote_with_key(
legacy_server_quote,
NpubCashQuoteKey::LegacySeedPrefix,
)
.await
.expect("legacy import succeeds");
wallet
.add_npubcash_mint_quote(paid_server_quote(
"locked-quote",
2_000,
Some(true),
&mint_url,
))
.await
.expect("locked import succeeds");
wallet
.add_npubcash_mint_quote(paid_server_quote(
"unlocked-quote",
4_000,
Some(false),
&mint_url,
))
.await
.expect("unlocked import succeeds");
let stored = db
.get_mint_quote("legacy-locked-quote")
.await
.expect("quote lookup")
.expect("quote exists");
assert!(
stored.secret_key.is_none(),
"import must scrub the persisted legacy key"
);
let responses = {
let mut responses = Vec::new();
for id in ["legacy-locked-quote", "locked-quote", "unlocked-quote"] {
let quote = db
.get_mint_quote(id)
.await
.expect("quote lookup")
.expect("quote exists");
let response = cdk_common::MintQuoteResponse::Bolt11(
cdk_common::nuts::MintQuoteBolt11Response {
quote: quote.id.clone(),
request: quote.request.clone(),
amount: quote.amount,
unit: Some(quote.unit.clone()),
method: quote.payment_method.clone(),
amount_paid: quote.amount_paid,
amount_issued: quote.amount_issued,
updated_at: 1,
state: crate::nuts::MintQuoteState::Paid,
expiry: Some(quote.expiry),
pubkey: None,
},
);
mock_client.set_mint_quote_status_response(id, response.clone());
responses.push(response);
}
responses
};
mock_client.push_post_batch_check_mint_quote_status_response(Ok(responses));
let minted = wallet.mint_unissued_quotes().await.expect("claim succeeds");
assert_eq!(minted, Amount::from(7_000u64));
let requests = mock_client.post_batch_mint_requests();
assert_eq!(requests.len(), 1, "one batch mint request expected");
let request = &requests[0].1;
let signatures = request
.signatures
.as_ref()
.expect("locked quotes carry signatures");
let nip06_pubkey = wallet
.derive_npubcash_secret_key()
.expect("nip06 key derives")
.public_key();
let legacy_pubkey = SecretKey::from_slice(&seed[..32])
.expect("legacy key is valid")
.public_key();
assert_eq!(request.quotes.len(), 3);
for (quote_id, signature) in request.quotes.iter().zip(signatures.iter()) {
match quote_id.as_str() {
"legacy-locked-quote" => {
let signature =
Signature::from_str(signature.as_ref().expect("legacy quote signed"))
.expect("hex schnorr signature");
legacy_pubkey
.verify(&request.msg_to_sign(quote_id), &signature)
.expect("legacy quote is signed with the legacy key");
}
"locked-quote" => {
let signature =
Signature::from_str(signature.as_ref().expect("locked quote signed"))
.expect("hex schnorr signature");
nip06_pubkey
.verify(&request.msg_to_sign(quote_id), &signature)
.expect("locked quote is signed with the NIP-06 key");
}
"unlocked-quote" => {
assert!(
signature.is_none(),
"unlocked legacy quote must be claimed unsigned"
);
}
other => panic!("unexpected quote in batch request: {other}"),
}
}
}
#[tokio::test]
async fn claim_npubcash_quotes_leaves_unrelated_unissued_quotes_untouched() {
use crate::wallet::test_utils::{
create_test_db, create_test_wallet_with_mock_seed, test_mint_info, test_mint_url,
MockMintConnector,
};
let seed = [0x42u8; 64];
let mint_url = test_mint_url().to_string();
let db = create_test_db().await;
let mut unrelated_quote: MintQuote =
paid_server_quote("unrelated-paid-quote", 2_000, None, &mint_url).into();
unrelated_quote.state = MintQuoteState::Paid;
unrelated_quote.amount_paid = Amount::from(2_000u64);
db.add_mint_quote(unrelated_quote)
.await
.expect("unrelated quote is stored");
let mock_client = Arc::new(MockMintConnector::new());
mock_client.enable_mint_signing();
let mut mint_info = test_mint_info();
mint_info.nuts.nut29 = cdk_common::nut29::Settings::new(Some(100), None);
mock_client.set_mint_info_response(Ok(mint_info));
let wallet = create_test_wallet_with_mock_seed(db.clone(), mock_client.clone(), seed).await;
wallet
.add_npubcash_mint_quote(paid_server_quote(
"npubcash-quote",
1_000,
Some(true),
&mint_url,
))
.await
.expect("npubcash import succeeds");
assert!(matches!(
wallet
.claim_npubcash_quotes()
.await
.expect_err("claim without an enabled client must fail"),
Error::Custom(_)
));
let unissued_quotes = wallet
.get_unissued_mint_quotes()
.await
.expect("unissued quotes load");
assert_eq!(unissued_quotes.len(), 2);
let npubcash_quotes = unissued_quotes
.into_iter()
.filter(|quote| quote.id == "npubcash-quote")
.collect();
for id in ["npubcash-quote", "unrelated-paid-quote"] {
let quote = db
.get_mint_quote(id)
.await
.expect("quote lookup")
.expect("quote exists");
mock_client.set_mint_quote_status_response(
id,
cdk_common::MintQuoteResponse::Bolt11(cdk_common::nuts::MintQuoteBolt11Response {
quote: quote.id.clone(),
request: quote.request.clone(),
amount: quote.amount,
unit: Some(quote.unit.clone()),
method: quote.payment_method.clone(),
amount_paid: quote.amount_paid,
amount_issued: quote.amount_issued,
updated_at: 100,
state: MintQuoteState::Paid,
expiry: Some(quote.expiry),
pubkey: None,
}),
);
}
let minted = wallet
.mint_given_unissued_quotes(npubcash_quotes)
.await
.expect("targeted claim succeeds");
assert_eq!(minted, Amount::from(1_000u64));
let npubcash_quote = db
.get_mint_quote("npubcash-quote")
.await
.expect("quote lookup")
.expect("quote exists");
assert_eq!(npubcash_quote.state, MintQuoteState::Issued);
assert_eq!(npubcash_quote.amount_issued, Amount::from(1_000u64));
let unrelated_quote = db
.get_mint_quote("unrelated-paid-quote")
.await
.expect("quote lookup")
.expect("quote exists");
assert_eq!(unrelated_quote.state, MintQuoteState::Paid);
assert_eq!(unrelated_quote.amount_issued, Amount::ZERO);
assert!(
unrelated_quote.updated_at < 100,
"the mint's status response must not have been applied"
);
let individual_requests = mock_client.post_mint_requests();
assert!(
individual_requests
.iter()
.all(|(_, request)| request.quote == "npubcash-quote"),
"no individual mint request may be issued for the unrelated quote"
);
assert!(
mock_client
.post_batch_mint_requests()
.iter()
.all(|(_, request)| !request.quotes.contains(&"unrelated-paid-quote".to_string())),
"no batch mint request may include the unrelated quote"
);
let minted = wallet
.mint_unissued_quotes()
.await
.expect("unrelated quote is mintable afterwards");
assert_eq!(minted, Amount::from(2_000u64));
}
}