From 528d3dd7a7ae4b1d17a695fba8d8cd75144ba66a Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 17 Jun 2026 12:15:27 +0100 Subject: [PATCH 1/2] feat: align mint quote accounting with NUT-04 Implement the mint quote accounting changes from cashubtc/nuts#377 by exposing amount_paid, amount_issued, and updated_at on mint quote responses. Keep the deprecated state field only for BOLT11 compatibility while deriving local quote state from the canonical counters for all mint methods. Persist updated_at in wallet quote storage so stale responses cannot move amount_paid or amount_issued backwards. --- crates/cashu/src/nuts/nut04.rs | 7 + crates/cashu/src/nuts/nut17/mod.rs | 51 ++++-- crates/cashu/src/nuts/nut23.rs | 16 ++ crates/cashu/src/nuts/nut25.rs | 12 ++ crates/cashu/src/nuts/nut30.rs | 6 + crates/cdk-common/src/mint.rs | 37 +++- crates/cdk-common/src/mint_quote.rs | 13 +- crates/cdk-common/src/wallet/mod.rs | 4 + crates/cdk-ffi/src/lib.rs | 1 + crates/cdk-ffi/src/types/quote.rs | 23 +++ crates/cdk-npubcash/src/types.rs | 1 + ...617000000_add_updated_at_to_mint_quote.sql | 1 + ...617000000_add_updated_at_to_mint_quote.sql | 1 + crates/cdk-sql-common/src/wallet/mod.rs | 12 +- crates/cdk-sqlite/src/wallet/mod.rs | 5 + crates/cdk-supabase/src/wallet.rs | 4 + crates/cdk/src/wallet/issue/mod.rs | 168 ++++++++++++++++-- .../src/wallet/mint_connector/http_client.rs | 1 + crates/cdk/src/wallet/streams/payment.rs | 34 +++- crates/cdk/src/wallet/subscription.rs | 1 - 20 files changed, 357 insertions(+), 41 deletions(-) create mode 100644 crates/cdk-sql-common/src/wallet/migrations/postgres/20260617000000_add_updated_at_to_mint_quote.sql create mode 100644 crates/cdk-sql-common/src/wallet/migrations/sqlite/20260617000000_add_updated_at_to_mint_quote.sql diff --git a/crates/cashu/src/nuts/nut04.rs b/crates/cashu/src/nuts/nut04.rs index b5293f8419..edd9d6bb72 100644 --- a/crates/cashu/src/nuts/nut04.rs +++ b/crates/cashu/src/nuts/nut04.rs @@ -404,6 +404,9 @@ pub struct MintQuoteCustomResponse { pub amount_paid: Amount, /// Amount that has been issued pub amount_issued: Amount, + /// Unix timestamp indicating when the quote was last updated + #[serde(default)] + pub updated_at: u64, /// Currency unit pub unit: Option, /// Unix timestamp until the quote is valid @@ -433,6 +436,7 @@ impl MintQuoteCustomResponse { amount: self.amount, amount_paid: self.amount_paid, amount_issued: self.amount_issued, + updated_at: self.updated_at, unit: self.unit.clone(), expiry: self.expiry, pubkey: self.pubkey, @@ -450,6 +454,7 @@ impl From> for MintQuoteCustomResponse amount: value.amount, amount_paid: value.amount_paid, amount_issued: value.amount_issued, + updated_at: value.updated_at, unit: value.unit, expiry: value.expiry, pubkey: value.pubkey, @@ -846,6 +851,7 @@ mod tests { amount: Some(Amount::from(1000)), amount_paid: Amount::ZERO, amount_issued: Amount::ZERO, + updated_at: 0, unit: Some(CurrencyUnit::Sat), expiry: Some(9999999), pubkey: None, @@ -868,6 +874,7 @@ mod tests { amount: Some(Amount::from(100)), amount_paid: Amount::ZERO, amount_issued: Amount::ZERO, + updated_at: 0, unit: Some(CurrencyUnit::Sat), expiry: Some(9999), pubkey: None, diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 206b93b8a1..0e9dae757a 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -190,13 +190,9 @@ where /// Subscription response /// /// Note on variant ordering: serde `untagged` deserialization tries variants -/// in declaration order and selects the first that matches. The Onchain -/// variants are declared before the Bolt11/Bolt12 variants because the -/// Onchain response structs use `#[serde(deny_unknown_fields)]`, which makes -/// them reject Bolt11/Bolt12 payloads cleanly. Placing them first ensures -/// onchain payloads are classified correctly without being consumed by the -/// more permissive Bolt12 variant (which carries a superset of Onchain's -/// field names). +/// in declaration order and selects the first that matches. The stricter +/// variants are declared before the more permissive ones so payloads with +/// overlapping field names are classified correctly. pub enum NotificationPayload where T: Clone, @@ -213,12 +209,12 @@ where /// Declared before `MeltQuoteBolt11Response`/`MeltQuoteBolt12Response` /// for the same reason. MeltQuoteOnchainResponse(MeltQuoteOnchainResponse), + /// Mint Quote Bolt12 Response + MintQuoteBolt12Response(MintQuoteBolt12Response), /// Melt Quote Bolt11 Response MeltQuoteBolt11Response(MeltQuoteBolt11Response), /// Mint Quote Bolt11 Response MintQuoteBolt11Response(MintQuoteBolt11Response), - /// Mint Quote Bolt12 Response - MintQuoteBolt12Response(MintQuoteBolt12Response), /// Melt Quote Bolt12 Response MeltQuoteBolt12Response(MeltQuoteBolt12Response), /// Custom Mint Quote Response (method, response) @@ -336,7 +332,7 @@ mod tests { use super::*; use crate::nuts::nut00::CurrencyUnit; use crate::nuts::nut01::PublicKey; - use crate::nuts::MeltQuoteState; + use crate::nuts::{MeltQuoteState, MintQuoteState}; use crate::Amount; #[test] @@ -352,6 +348,7 @@ mod tests { .unwrap(), amount_paid: Amount::from(100_000), amount_issued: Amount::from(0), + updated_at: 0, }; let payload: NotificationPayload = NotificationPayload::MintQuoteOnchainResponse(resp.clone()); @@ -384,6 +381,7 @@ mod tests { .unwrap(), amount_paid: Amount::from(0), amount_issued: Amount::from(0), + updated_at: 0, }; let payload: NotificationPayload = NotificationPayload::MintQuoteBolt12Response(resp.clone()); @@ -399,6 +397,39 @@ mod tests { } } + #[test] + fn notification_payload_bolt11_mint_with_pubkey_roundtrip() { + let resp: MintQuoteBolt11Response = MintQuoteBolt11Response { + quote: "abc".to_string(), + request: "lnbc...".to_string(), + amount: Some(Amount::from(100_000)), + unit: Some(CurrencyUnit::Sat), + amount_paid: Amount::from(0), + amount_issued: Amount::from(0), + updated_at: 0, + state: MintQuoteState::Unpaid, + expiry: Some(1701704757), + pubkey: Some( + PublicKey::from_hex( + "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", + ) + .unwrap(), + ), + }; + let payload: NotificationPayload = + NotificationPayload::MintQuoteBolt11Response(resp.clone()); + + let encoded = serde_json::to_string(&payload).unwrap(); + let decoded: NotificationPayload = serde_json::from_str(&encoded).unwrap(); + + match decoded { + NotificationPayload::MintQuoteBolt11Response(r) => { + assert_eq!(r, resp); + } + other => panic!("expected MintQuoteBolt11Response, got {:?}", other), + } + } + #[test] fn notification_payload_onchain_melt_roundtrip() { let resp: MeltQuoteOnchainResponse = MeltQuoteOnchainResponse { diff --git a/crates/cashu/src/nuts/nut23.rs b/crates/cashu/src/nuts/nut23.rs index 14f9ef413c..bbccd22985 100644 --- a/crates/cashu/src/nuts/nut23.rs +++ b/crates/cashu/src/nuts/nut23.rs @@ -93,7 +93,17 @@ pub struct MintQuoteBolt11Response { /// Unit // REVIEW: This is now required in the spec, we should remove the option once all mints update pub unit: Option, + /// Amount that has been paid + #[serde(default)] + pub amount_paid: Amount, + /// Amount that has been issued + #[serde(default)] + pub amount_issued: Amount, + /// Unix timestamp indicating when the quote was last updated + #[serde(default)] + pub updated_at: u64, /// Quote State + #[serde(default)] pub state: QuoteState, /// Unix timestamp until the quote is valid pub expiry: Option, @@ -116,6 +126,9 @@ impl MintQuoteBolt11Response { pubkey: self.pubkey, amount: self.amount, unit: self.unit.clone(), + amount_paid: self.amount_paid, + amount_issued: self.amount_issued, + updated_at: self.updated_at, } } } @@ -131,6 +144,9 @@ impl From> for MintQuoteBolt11Response pubkey: value.pubkey, amount: value.amount, unit: value.unit.clone(), + amount_paid: value.amount_paid, + amount_issued: value.amount_issued, + updated_at: value.updated_at, } } } diff --git a/crates/cashu/src/nuts/nut25.rs b/crates/cashu/src/nuts/nut25.rs index 7fc37149c2..97f756c616 100644 --- a/crates/cashu/src/nuts/nut25.rs +++ b/crates/cashu/src/nuts/nut25.rs @@ -35,8 +35,15 @@ pub struct MintQuoteBolt12Request { } /// Mint quote response [NUT-24] +/// +/// `deny_unknown_fields` is intentional: the `NotificationPayload` enum is +/// `#[serde(untagged)]` and Bolt11 mint quotes share the same core fields as +/// Bolt12 mint quotes. Rejecting unknown fields lets `NotificationPayload` +/// try the Bolt12 variant before Bolt11 without classifying Bolt11 payloads +/// that carry a `state` field as Bolt12. #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")] +#[serde(deny_unknown_fields)] pub struct MintQuoteBolt12Response { /// Quote Id pub quote: Q, @@ -54,6 +61,9 @@ pub struct MintQuoteBolt12Response { pub amount_paid: Amount, /// Amount that has been issued pub amount_issued: Amount, + /// Unix timestamp indicating when the quote was last updated + #[serde(default)] + pub updated_at: u64, } #[cfg(feature = "mint")] @@ -69,6 +79,7 @@ impl MintQuoteBolt12Response { pubkey: self.pubkey, amount_paid: self.amount_paid, amount_issued: self.amount_issued, + updated_at: self.updated_at, } } } @@ -85,6 +96,7 @@ impl From> for MintQuoteBolt12Response pubkey: value.pubkey, amount: value.amount, unit: value.unit, + updated_at: value.updated_at, } } } diff --git a/crates/cashu/src/nuts/nut30.rs b/crates/cashu/src/nuts/nut30.rs index 1af19eea4c..ee008a941d 100644 --- a/crates/cashu/src/nuts/nut30.rs +++ b/crates/cashu/src/nuts/nut30.rs @@ -52,6 +52,9 @@ pub struct MintQuoteOnchainResponse { /// Amount of ecash that has been issued for the given mint quote #[serde(default)] pub amount_issued: Amount, + /// Unix timestamp indicating when the quote was last updated + #[serde(default)] + pub updated_at: u64, } impl MintQuoteOnchainResponse { @@ -65,6 +68,7 @@ impl MintQuoteOnchainResponse { pubkey: self.pubkey, amount_paid: self.amount_paid, amount_issued: self.amount_issued, + updated_at: self.updated_at, } } } @@ -80,6 +84,7 @@ impl From> for MintQuoteOnchainResponse u64 { + self.payments + .iter() + .map(|payment| payment.time) + .chain(self.issuance.iter().map(|issuance| issuance.time)) + .max() + .unwrap_or(self.created_time) + } + /// Get state of mint quote #[instrument(skip(self))] pub fn state(&self) -> MintQuoteState { @@ -1182,6 +1192,7 @@ impl TryFrom for MintQuoteOnchainResponse { pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?, amount_paid: quote.amount_paid().into(), amount_issued: quote.amount_issued().into(), + updated_at: quote.updated_at(), }) } } @@ -1239,6 +1250,10 @@ impl From for KeySetInfo { impl From for MintQuoteBolt11Response { fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response { + let amount_paid = mint_quote.amount_paid().into(); + let amount_issued = mint_quote.amount_issued().into(); + let updated_at = mint_quote.updated_at(); + MintQuoteBolt11Response { quote: mint_quote.id.clone(), state: mint_quote.state(), @@ -1247,6 +1262,9 @@ impl From for MintQuoteBolt11Response { pubkey: mint_quote.pubkey, amount: mint_quote.amount.map(Into::into), unit: Some(mint_quote.unit), + amount_paid, + amount_issued, + updated_at, } } } @@ -1262,15 +1280,20 @@ impl TryFrom for MintQuoteBolt12Response { type Error = Error; fn try_from(mint_quote: MintQuote) -> Result { + let amount_paid = mint_quote.amount_paid().into(); + let amount_issued = mint_quote.amount_issued().into(); + let updated_at = mint_quote.updated_at(); + Ok(MintQuoteBolt12Response { quote: mint_quote.id.clone(), request: mint_quote.request, expiry: Some(mint_quote.expiry), - amount_paid: mint_quote.amount_paid.into(), - amount_issued: mint_quote.amount_issued.into(), + amount_paid, + amount_issued, pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?, amount: mint_quote.amount.map(Into::into), unit: mint_quote.unit, + updated_at, }) } } @@ -1290,6 +1313,7 @@ impl TryFrom for MintQuoteCustomResponse { fn try_from(quote: MintQuote) -> Result { let amount_paid = quote.amount_paid().into(); let amount_issued = quote.amount_issued().into(); + let updated_at = quote.updated_at(); Ok(MintQuoteCustomResponse { quote: quote.id, @@ -1300,6 +1324,7 @@ impl TryFrom for MintQuoteCustomResponse { amount: quote.amount.map(Into::into), amount_paid, amount_issued, + updated_at, extra: quote.extra_json.unwrap_or_default(), }) } @@ -1348,6 +1373,9 @@ impl TryFrom for MintQuoteResponse { amount: quote.amount.as_ref().map(|a| a.clone().into()), unit: Some(quote.unit.clone()), pubkey: quote.pubkey, + amount_paid: quote.amount_paid().into(), + amount_issued: quote.amount_issued().into(), + updated_at: quote.updated_at(), })) } else if quote.payment_method.is_bolt12() { Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response { @@ -1359,6 +1387,7 @@ impl TryFrom for MintQuoteResponse { pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?, amount_paid: quote.amount_paid().into(), amount_issued: quote.amount_issued().into(), + updated_at: quote.updated_at(), })) } else if quote.payment_method.is_onchain() { let onchain_response = MintQuoteOnchainResponse::try_from(quote)?; @@ -1374,6 +1403,7 @@ impl TryFrom for MintQuoteResponse { amount: quote.amount.as_ref().map(|a| a.clone().into()), amount_paid: quote.amount_paid().into(), amount_issued: quote.amount_issued().into(), + updated_at: quote.updated_at(), unit: Some(quote.unit.clone()), pubkey: quote.pubkey, extra: quote.extra_json.clone().unwrap_or_default(), @@ -1408,6 +1438,9 @@ impl From> for MintQuoteBolt11Response { pubkey: bolt11_response.pubkey, amount: bolt11_response.amount, unit: bolt11_response.unit, + amount_paid: bolt11_response.amount_paid, + amount_issued: bolt11_response.amount_issued, + updated_at: bolt11_response.updated_at, }, _ => panic!("Expected Bolt11 response"), } diff --git a/crates/cdk-common/src/mint_quote.rs b/crates/cdk-common/src/mint_quote.rs index f48625e338..7487c7b9f4 100644 --- a/crates/cdk-common/src/mint_quote.rs +++ b/crates/cdk-common/src/mint_quote.rs @@ -146,7 +146,13 @@ impl MintQuoteResponse { /// Returns the quote state derived from the response data. pub fn state(&self) -> Option { match self { - Self::Bolt11(r) => Some(r.state), + Self::Bolt11(r) => { + if r.amount_paid > Amount::ZERO || r.amount_issued > Amount::ZERO { + Some(quote_state_from_amounts(r.amount_paid, r.amount_issued)) + } else { + Some(r.state) + } + } Self::Bolt12(r) => Some(quote_state_from_amounts(r.amount_paid, r.amount_issued)), Self::Onchain(r) => Some(quote_state_from_amounts(r.amount_paid, r.amount_issued)), Self::Custom { response, .. } => Some(quote_state_from_amounts( @@ -167,7 +173,8 @@ impl MintQuoteResponse { } } -pub(crate) fn quote_state_from_amounts(amount_paid: Amount, amount_issued: Amount) -> QuoteState { +/// Derive the deprecated single-use mint quote state from canonical quote counters. +pub fn quote_state_from_amounts(amount_paid: Amount, amount_issued: Amount) -> QuoteState { if amount_paid == Amount::ZERO && amount_issued == Amount::ZERO { return QuoteState::Unpaid; } @@ -191,6 +198,7 @@ mod tests { amount: Some(Amount::from(100)), amount_paid, amount_issued, + updated_at: 0, unit: Some(CurrencyUnit::Sat), expiry: None, pubkey: None, @@ -229,6 +237,7 @@ mod tests { .expect("valid public key"), amount_paid: Amount::from(100), amount_issued: Amount::from(40), + updated_at: 0, }); assert_eq!(response.state(), Some(QuoteState::Paid)); diff --git a/crates/cdk-common/src/wallet/mod.rs b/crates/cdk-common/src/wallet/mod.rs index 33a0b2ea83..6f24b9f5c7 100644 --- a/crates/cdk-common/src/wallet/mod.rs +++ b/crates/cdk-common/src/wallet/mod.rs @@ -200,6 +200,9 @@ pub struct MintQuote { /// Amount paid to the mint for the quote #[serde(default)] pub amount_paid: Amount, + /// Unix timestamp indicating when the mint quote was last updated + #[serde(default)] + pub updated_at: u64, /// Estimated confirmation target in blocks for onchain quotes pub estimated_blocks: Option, /// Operation ID that has reserved this quote (for saga pattern) @@ -273,6 +276,7 @@ impl MintQuote { secret_key, amount_issued: Amount::ZERO, amount_paid: Amount::ZERO, + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 0d65f8e178..2298813f91 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -478,6 +478,7 @@ mod tests { .expect("valid mint URL should convert successfully"), amount_issued: Amount::zero(), amount_paid: Amount::zero(), + updated_at: 0, estimated_blocks: None, payment_method: PaymentMethod::Bolt11, secret_key: None, diff --git a/crates/cdk-ffi/src/types/quote.rs b/crates/cdk-ffi/src/types/quote.rs index 79fffcba1b..0aa39409bc 100644 --- a/crates/cdk-ffi/src/types/quote.rs +++ b/crates/cdk-ffi/src/types/quote.rs @@ -30,6 +30,9 @@ pub struct MintQuote { pub amount_issued: Amount, /// Amount paid pub amount_paid: Amount, + /// Last update timestamp + #[serde(default)] + pub updated_at: u64, /// Estimated confirmation target in blocks for onchain quotes pub estimated_blocks: Option, /// Payment method @@ -55,6 +58,7 @@ impl From for MintQuote { mint_url: quote.mint_url.clone().into(), amount_issued: quote.amount_issued.into(), amount_paid: quote.amount_paid.into(), + updated_at: quote.updated_at, estimated_blocks: quote.estimated_blocks, payment_method: quote.payment_method.into(), secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()), @@ -84,6 +88,7 @@ impl TryFrom for cdk::wallet::MintQuote { mint_url: quote.mint_url.try_into()?, amount_issued: quote.amount_issued.into(), amount_paid: quote.amount_paid.into(), + updated_at: quote.updated_at, estimated_blocks: quote.estimated_blocks, payment_method: quote.payment_method.into(), secret_key, @@ -142,6 +147,12 @@ pub struct MintQuoteBolt11Response { pub amount: Option, /// Unit (optional) pub unit: Option, + /// Amount paid + pub amount_paid: Amount, + /// Amount issued + pub amount_issued: Amount, + /// Last update timestamp + pub updated_at: u64, /// Pubkey (optional) pub pubkey: Option, } @@ -155,6 +166,9 @@ impl From> for MintQuoteBolt11Respons expiry: response.expiry, amount: response.amount.map(Into::into), unit: response.unit.map(Into::into), + amount_paid: response.amount_paid.into(), + amount_issued: response.amount_issued.into(), + updated_at: response.updated_at, pubkey: response.pubkey.map(|p| p.to_string()), } } @@ -169,6 +183,9 @@ impl From for MintQuoteBolt11Response { expiry: Some(quote.expiry), amount: quote.amount.map(Into::into), unit: Some(quote.unit.into()), + amount_paid: quote.amount_paid.into(), + amount_issued: quote.amount_issued.into(), + updated_at: quote.updated_at, pubkey: quote.secret_key.map(|sk| sk.public_key().to_string()), } } @@ -192,6 +209,8 @@ pub struct MintQuoteCustomResponse { pub amount_paid: Amount, /// Amount issued pub amount_issued: Amount, + /// Last update timestamp + pub updated_at: u64, /// Unit (optional) pub unit: Option, /// Pubkey (optional) @@ -218,6 +237,7 @@ impl From> for MintQuoteCustomRespons amount: response.amount.map(Into::into), amount_paid: response.amount_paid.into(), amount_issued: response.amount_issued.into(), + updated_at: response.updated_at, unit: response.unit.map(Into::into), pubkey: response.pubkey.map(|p| p.to_string()), extra, @@ -366,6 +386,8 @@ pub struct MintQuoteOnchainResponse { pub amount_paid: Amount, /// Amount already issued for this quote pub amount_issued: Amount, + /// Last update timestamp + pub updated_at: u64, } impl From> for MintQuoteOnchainResponse { @@ -378,6 +400,7 @@ impl From> for MintQuoteOnchainRespo pubkey: response.pubkey.to_string(), amount_paid: response.amount_paid.into(), amount_issued: response.amount_issued.into(), + updated_at: response.updated_at, } } } diff --git a/crates/cdk-npubcash/src/types.rs b/crates/cdk-npubcash/src/types.rs index d6056252d0..cb21e4298f 100644 --- a/crates/cdk-npubcash/src/types.rs +++ b/crates/cdk-npubcash/src/types.rs @@ -168,6 +168,7 @@ impl From for MintQuote { } else { Amount::ZERO }, + updated_at: quote.paid_at.unwrap_or(quote.created_at), estimated_blocks: None, used_by_operation: None, version: 0, diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20260617000000_add_updated_at_to_mint_quote.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260617000000_add_updated_at_to_mint_quote.sql new file mode 100644 index 0000000000..7b12946a70 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20260617000000_add_updated_at_to_mint_quote.sql @@ -0,0 +1 @@ +ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260617000000_add_updated_at_to_mint_quote.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260617000000_add_updated_at_to_mint_quote.sql new file mode 100644 index 0000000000..c0c032ced5 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20260617000000_add_updated_at_to_mint_quote.sql @@ -0,0 +1 @@ +ALTER TABLE mint_quote ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0; diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 5af04887d6..cedc1ea48d 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -347,6 +347,7 @@ where payment_method, amount_issued, amount_paid, + updated_at, estimated_blocks, used_by_operation, version @@ -384,6 +385,7 @@ where payment_method, amount_issued, amount_paid, + updated_at, estimated_blocks, used_by_operation, version @@ -419,6 +421,7 @@ where payment_method, amount_issued, amount_paid, + updated_at, estimated_blocks, used_by_operation, version @@ -1184,9 +1187,9 @@ where let rows_affected = query( r#" INSERT INTO mint_quote - (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, estimated_blocks, version, used_by_operation) + (id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid, updated_at, estimated_blocks, version, used_by_operation) VALUES - (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :estimated_blocks, :version, :used_by_operation) + (:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid, :updated_at, :estimated_blocks, :version, :used_by_operation) ON CONFLICT(id) DO UPDATE SET mint_url = excluded.mint_url, amount = excluded.amount, @@ -1198,6 +1201,7 @@ where payment_method = excluded.payment_method, amount_issued = excluded.amount_issued, amount_paid = excluded.amount_paid, + updated_at = excluded.updated_at, estimated_blocks = excluded.estimated_blocks, version = :new_version, used_by_operation = excluded.used_by_operation @@ -1216,6 +1220,7 @@ where .bind("payment_method", quote.payment_method.to_string()) .bind("amount_issued", quote.amount_issued.to_i64()) .bind("amount_paid", quote.amount_paid.to_i64()) + .bind("updated_at", quote.updated_at as i64) .bind("estimated_blocks", quote.estimated_blocks.map(i64::from)) .bind("version", quote.version as i64) .bind("new_version", new_version as i64) @@ -2001,6 +2006,7 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { row_method, row_amount_minted, row_amount_paid, + updated_at, estimated_blocks, used_by_operation, version @@ -2012,6 +2018,7 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { let amount_paid: u64 = column_as_number!(row_amount_paid); let amount_minted: u64 = column_as_number!(row_amount_minted); let expiry_val: u64 = column_as_number!(expiry); + let updated_at: u64 = column_as_number!(updated_at); let version_val: u32 = column_as_number!(version); let payment_method = PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?; @@ -2028,6 +2035,7 @@ fn sql_row_to_mint_quote(row: Vec) -> Result { payment_method, amount_issued: Amount::from(amount_minted), amount_paid: Amount::from(amount_paid), + updated_at, estimated_blocks: column_as_nullable_number!(estimated_blocks), used_by_operation: column_as_nullable_string!(used_by_operation), version: version_val, diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 66ebab87bd..72da4a86a3 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -177,6 +177,7 @@ mod tests { payment_method: payment_method.clone(), amount_issued: Amount::from(0), amount_paid: Amount::from(0), + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, @@ -322,6 +323,7 @@ mod tests { payment_method: PaymentMethod::Known(KnownMethod::Bolt11), amount_issued: Amount::from(100), amount_paid: Amount::from(100), + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, @@ -340,6 +342,7 @@ mod tests { payment_method: PaymentMethod::Known(KnownMethod::Bolt11), amount_issued: Amount::from(0), amount_paid: Amount::from(100), + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, @@ -358,6 +361,7 @@ mod tests { payment_method: PaymentMethod::Known(KnownMethod::Bolt12), amount_issued: Amount::from(0), amount_paid: Amount::from(0), + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, @@ -376,6 +380,7 @@ mod tests { payment_method: PaymentMethod::Known(KnownMethod::Bolt11), amount_issued: Amount::from(0), amount_paid: Amount::from(0), + updated_at: 0, estimated_blocks: None, used_by_operation: None, version: 0, diff --git a/crates/cdk-supabase/src/wallet.rs b/crates/cdk-supabase/src/wallet.rs index 26baa357af..563dc29644 100644 --- a/crates/cdk-supabase/src/wallet.rs +++ b/crates/cdk-supabase/src/wallet.rs @@ -2568,6 +2568,8 @@ struct MintQuoteTable { amount_issued: i64, amount_paid: i64, #[serde(default)] + updated_at: i64, + #[serde(default)] used_by_operation: Option, #[serde(default)] version: Option, @@ -2601,6 +2603,7 @@ impl TryInto for MintQuoteTable { .map_err(|_| DatabaseError::Internal("Invalid payment method".into()))?, amount_issued: cdk_common::Amount::from(self.amount_issued as u64), amount_paid: cdk_common::Amount::from(self.amount_paid as u64), + updated_at: self.updated_at as u64, estimated_blocks: None, used_by_operation: self.used_by_operation, version: self.version.unwrap_or(0) as u32, @@ -2623,6 +2626,7 @@ impl TryFrom for MintQuoteTable { payment_method: q.payment_method.to_string(), amount_issued: q.amount_issued.to_u64() as i64, amount_paid: q.amount_paid.to_u64() as i64, + updated_at: q.updated_at as i64, used_by_operation: q.used_by_operation, version: Some(q.version as i32), _extra: Default::default(), diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index dd09693f16..d5194d5bb6 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -17,40 +17,110 @@ use crate::wallet::recovery::RecoveryAction; use crate::wallet::{MintQuote, MintQuoteState}; use crate::{Amount, Error, Wallet}; -fn apply_mint_quote_response(quote: &mut MintQuote, response: &MintQuoteResponse) { +pub(crate) fn apply_mint_quote_response( + quote: &mut MintQuote, + response: &MintQuoteResponse, +) { match response { MintQuoteResponse::Bolt11(response) => { - quote.state = response.state; - match response.state { + let state = + if response.amount_paid > Amount::ZERO || response.amount_issued > Amount::ZERO { + cdk_common::mint_quote::quote_state_from_amounts( + response.amount_paid, + response.amount_issued, + ) + } else { + response.state + }; + let (amount_paid, amount_issued) = match state { MintQuoteState::Paid => { - quote.amount_paid = response.amount.unwrap_or_default(); + let amount_paid = if response.amount_paid > Amount::ZERO { + response.amount_paid + } else { + response.amount.unwrap_or_default() + }; + (amount_paid, response.amount_issued) } MintQuoteState::Issued => { let amount = response.amount.unwrap_or_default(); - quote.amount_paid = amount; - quote.amount_issued = amount; + let amount_paid = if response.amount_paid > Amount::ZERO { + response.amount_paid + } else { + amount + }; + let amount_issued = if response.amount_issued > Amount::ZERO { + response.amount_issued + } else { + amount + }; + (amount_paid, amount_issued) } - MintQuoteState::Unpaid => (), + MintQuoteState::Unpaid => (response.amount_paid, response.amount_issued), + }; + + if is_stale_mint_quote_update(quote, response.updated_at, amount_paid, amount_issued) { + return; } + + quote.state = state; + quote.amount_paid = amount_paid; + quote.amount_issued = amount_issued; + quote.updated_at = quote.updated_at.max(response.updated_at); } MintQuoteResponse::Bolt12(response) => { - quote.amount_paid = response.amount_paid; - quote.amount_issued = response.amount_issued; - quote.update_state_from_amounts(); + apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ); } MintQuoteResponse::Onchain(response) => { - quote.amount_paid = response.amount_paid; - quote.amount_issued = response.amount_issued; - quote.update_state_from_amounts(); + apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ); } MintQuoteResponse::Custom { response, .. } => { - quote.amount_paid = response.amount_paid; - quote.amount_issued = response.amount_issued; - quote.update_state_from_amounts(); + apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ); } } } +pub(crate) fn apply_accounting_mint_quote_update( + quote: &mut MintQuote, + amount_paid: Amount, + amount_issued: Amount, + updated_at: u64, +) { + if is_stale_mint_quote_update(quote, updated_at, amount_paid, amount_issued) { + return; + } + + quote.amount_paid = amount_paid; + quote.amount_issued = amount_issued; + quote.updated_at = quote.updated_at.max(updated_at); + quote.update_state_from_amounts(); +} + +fn is_stale_mint_quote_update( + quote: &MintQuote, + updated_at: u64, + amount_paid: Amount, + amount_issued: Amount, +) -> bool { + updated_at < quote.updated_at + || amount_paid < quote.amount_paid + || amount_issued < quote.amount_issued +} + fn local_mint_quote_amount(method: &PaymentMethod, amount: Option) -> Option { match method { PaymentMethod::Known(KnownMethod::Onchain) => None, @@ -609,6 +679,9 @@ impl Wallet { #[cfg(test)] mod tests { + use std::str::FromStr; + + use cdk_common::mint_url::MintUrl; use cdk_common::nuts::CurrencyUnit; use super::*; @@ -634,8 +707,71 @@ mod tests { pubkey: SecretKey::generate().public_key(), amount_paid: Amount::from(1_000), amount_issued: Amount::from(250), + updated_at: 0, }); assert_eq!(mint_quote_response_amount(&response), None); } + + #[test] + fn stale_mint_quote_response_does_not_decrease_accounting() { + let mut quote = MintQuote::new( + "quote-id".to_string(), + MintUrl::from_str("https://mint.example.com").expect("valid mint url"), + PaymentMethod::Custom("custom".to_string()), + Some(Amount::from(200)), + CurrencyUnit::Sat, + "custom-request".to_string(), + 1_700_000_000, + None, + ); + quote.amount_paid = Amount::from(100); + quote.amount_issued = Amount::from(20); + quote.updated_at = 10; + quote.update_state_from_amounts(); + + let stale_response = custom_mint_quote_response(Amount::from(200), Amount::from(20), 9); + apply_mint_quote_response(&mut quote, &stale_response); + + assert_eq!(quote.amount_paid, Amount::from(100)); + assert_eq!(quote.amount_issued, Amount::from(20)); + assert_eq!(quote.updated_at, 10); + + let decreasing_response = + custom_mint_quote_response(Amount::from(90), Amount::from(20), 11); + apply_mint_quote_response(&mut quote, &decreasing_response); + + assert_eq!(quote.amount_paid, Amount::from(100)); + assert_eq!(quote.amount_issued, Amount::from(20)); + assert_eq!(quote.updated_at, 10); + + let fresh_response = custom_mint_quote_response(Amount::from(150), Amount::from(30), 12); + apply_mint_quote_response(&mut quote, &fresh_response); + + assert_eq!(quote.amount_paid, Amount::from(150)); + assert_eq!(quote.amount_issued, Amount::from(30)); + assert_eq!(quote.updated_at, 12); + } + + fn custom_mint_quote_response( + amount_paid: Amount, + amount_issued: Amount, + updated_at: u64, + ) -> MintQuoteResponse { + MintQuoteResponse::Custom { + method: PaymentMethod::Custom("custom".to_string()), + response: cdk_common::nut04::MintQuoteCustomResponse { + quote: "quote-id".to_string(), + request: "custom-request".to_string(), + amount: Some(Amount::from(200)), + amount_paid, + amount_issued, + updated_at, + unit: Some(CurrencyUnit::Sat), + expiry: Some(1_700_000_000), + pubkey: None, + extra: serde_json::Value::Null, + }, + } + } } diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index e3438f69d5..bf2350fce9 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -958,6 +958,7 @@ mod tests { amount: Some(cdk_common::Amount::from(1000)), amount_paid: cdk_common::Amount::ZERO, amount_issued: cdk_common::Amount::ZERO, + updated_at: 0, unit: Some(cdk_common::CurrencyUnit::Sat), expiry: Some(9999999), pubkey: None, diff --git a/crates/cdk/src/wallet/streams/payment.rs b/crates/cdk/src/wallet/streams/payment.rs index 725949205b..51ef2ddb13 100644 --- a/crates/cdk/src/wallet/streams/payment.rs +++ b/crates/cdk/src/wallet/streams/payment.rs @@ -15,6 +15,7 @@ use tokio_util::sync::CancellationToken; use super::RecvFuture; use crate::event::MintEvent; +use crate::wallet::issue::{apply_accounting_mint_quote_update, apply_mint_quote_response}; use crate::wallet::subscription::ActiveSubscription; use crate::{Wallet, WalletSubscription}; @@ -147,8 +148,10 @@ impl<'a> PaymentStream<'a> { if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { - quote.state = info.state; - quote.amount_paid = info.amount.unwrap_or(Amount::ZERO); + apply_mint_quote_response( + &mut quote, + &cdk_common::MintQuoteResponse::Bolt11(info.clone()), + ); if let Err(e) = localstore.add_mint_quote(quote).await { tracing::warn!("Failed to update quote state: {}", e); } @@ -159,8 +162,12 @@ impl<'a> PaymentStream<'a> { if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { - quote.amount_paid = info.amount_paid; - quote.amount_issued = info.amount_issued; + apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); if let Err(e) = localstore.add_mint_quote(quote).await { tracing::warn!("Failed to update quote state: {}", e); } @@ -171,8 +178,12 @@ impl<'a> PaymentStream<'a> { if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { - quote.amount_paid = info.amount_paid; - quote.amount_issued = info.amount_issued; + apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); if let Err(e) = localstore.add_mint_quote(quote).await { tracing::warn!("Failed to update quote state: {}", e); } @@ -183,8 +194,12 @@ impl<'a> PaymentStream<'a> { if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { - quote.amount_paid = info.amount_paid; - quote.amount_issued = info.amount_issued; + apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); if let Err(e) = localstore.add_mint_quote(quote).await { tracing::warn!("Failed to update quote state: {}", e); } @@ -333,6 +348,7 @@ mod tests { pubkey, amount_paid: Amount::from(50u64), amount_issued: Amount::from(100u64), + updated_at: 0, }, )), MintEvent::new(NotificationPayload::MintQuoteOnchainResponse( @@ -344,6 +360,7 @@ mod tests { pubkey, amount_paid: Amount::from(50u64), amount_issued: Amount::from(100u64), + updated_at: 0, }, )), MintEvent::new(NotificationPayload::CustomMintQuoteResponse( @@ -354,6 +371,7 @@ mod tests { amount: None, amount_paid: Amount::from(50u64), amount_issued: Amount::from(100u64), + updated_at: 0, unit: Some(CurrencyUnit::Sat), expiry: None, pubkey: Some(pubkey), diff --git a/crates/cdk/src/wallet/subscription.rs b/crates/cdk/src/wallet/subscription.rs index 91c7ad173d..41c0dcfcc3 100644 --- a/crates/cdk/src/wallet/subscription.rs +++ b/crates/cdk/src/wallet/subscription.rs @@ -753,7 +753,6 @@ mod tests { "request": "lni1...", "amount": null, "unit": "sat", - "state": "UNPAID", "expiry": 1234, "pubkey": "02194603ffa062682c4f10e2dfe8f53e17d5d0329db51c8d3935cc74a4c0e0d4cb", "amount_paid": 0, From d5257c10293c2f1cdcc2c3f5d8674b2a6dca6d35 Mon Sep 17 00:00:00 2001 From: thesimplekid Date: Wed, 17 Jun 2026 14:54:40 +0100 Subject: [PATCH 2/2] fix(wallet): handle stale mint quote updates Add the Supabase mint_quote.updated_at migration and require schema version 8. Keep imported unpaid NpubCash quotes at updated_at 0 so legacy BOLT11 status responses can still advance them, and suppress stale websocket notifications after monotonic quote accounting rejects an update. --- crates/cdk-npubcash/src/types.rs | 24 +- ...617000000_add_updated_at_to_mint_quote.sql | 7 + crates/cdk-supabase/src/wallet.rs | 2 +- crates/cdk/src/wallet/issue/mod.rs | 58 +++-- crates/cdk/src/wallet/streams/payment.rs | 228 ++++++++++++------ 5 files changed, 213 insertions(+), 106 deletions(-) create mode 100644 crates/cdk-supabase/migrations/supabase/migrations/20260617000000_add_updated_at_to_mint_quote.sql diff --git a/crates/cdk-npubcash/src/types.rs b/crates/cdk-npubcash/src/types.rs index cb21e4298f..7b0b65de75 100644 --- a/crates/cdk-npubcash/src/types.rs +++ b/crates/cdk-npubcash/src/types.rs @@ -168,7 +168,7 @@ impl From for MintQuote { } else { Amount::ZERO }, - updated_at: quote.paid_at.unwrap_or(quote.created_at), + updated_at: quote.paid_at.unwrap_or_default(), estimated_blocks: None, used_by_operation: None, version: 0, @@ -198,5 +198,27 @@ mod tests { let mint_quote = MintQuote::from(quote); assert_eq!(mint_quote.expiry, u64::MAX); + assert_eq!(mint_quote.updated_at, 0); + } + + #[test] + fn from_paid_quote_uses_paid_at_as_updated_at() { + let quote = Quote { + id: "paid-quote".to_string(), + amount: 1_000, + unit: "sat".to_string(), + created_at: 100, + paid_at: Some(200), + expires_at: None, + mint_url: None, + request: None, + state: Some("PAID".to_string()), + locked: None, + }; + + let mint_quote = MintQuote::from(quote); + + assert_eq!(mint_quote.amount_paid, Amount::from(1_000)); + assert_eq!(mint_quote.updated_at, 200); } } diff --git a/crates/cdk-supabase/migrations/supabase/migrations/20260617000000_add_updated_at_to_mint_quote.sql b/crates/cdk-supabase/migrations/supabase/migrations/20260617000000_add_updated_at_to_mint_quote.sql new file mode 100644 index 0000000000..a4b90f49bc --- /dev/null +++ b/crates/cdk-supabase/migrations/supabase/migrations/20260617000000_add_updated_at_to_mint_quote.sql @@ -0,0 +1,7 @@ +-- Track the last quote update timestamp so wallet quote responses cannot +-- move amount_paid or amount_issued backwards after stale notifications. + +ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0; + +INSERT INTO schema_info (key, value) VALUES ('schema_version', '8') +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; diff --git a/crates/cdk-supabase/src/wallet.rs b/crates/cdk-supabase/src/wallet.rs index 563dc29644..647da44524 100644 --- a/crates/cdk-supabase/src/wallet.rs +++ b/crates/cdk-supabase/src/wallet.rs @@ -213,7 +213,7 @@ impl SupabaseWalletDatabase { /// This must match the latest `schema_version` value set in the migration files. /// When adding new migrations, update this constant and set the same value /// in the new migration's `INSERT INTO schema_info` statement. - pub const REQUIRED_SCHEMA_VERSION: u32 = 7; + pub const REQUIRED_SCHEMA_VERSION: u32 = 8; /// Get the full database schema SQL /// diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs index d5194d5bb6..9a5190dbe1 100644 --- a/crates/cdk/src/wallet/issue/mod.rs +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -20,7 +20,7 @@ use crate::{Amount, Error, Wallet}; pub(crate) fn apply_mint_quote_response( quote: &mut MintQuote, response: &MintQuoteResponse, -) { +) -> bool { match response { MintQuoteResponse::Bolt11(response) => { let state = @@ -59,38 +59,33 @@ pub(crate) fn apply_mint_quote_response( }; if is_stale_mint_quote_update(quote, response.updated_at, amount_paid, amount_issued) { - return; + return false; } quote.state = state; quote.amount_paid = amount_paid; quote.amount_issued = amount_issued; quote.updated_at = quote.updated_at.max(response.updated_at); + true } - MintQuoteResponse::Bolt12(response) => { - apply_accounting_mint_quote_update( - quote, - response.amount_paid, - response.amount_issued, - response.updated_at, - ); - } - MintQuoteResponse::Onchain(response) => { - apply_accounting_mint_quote_update( - quote, - response.amount_paid, - response.amount_issued, - response.updated_at, - ); - } - MintQuoteResponse::Custom { response, .. } => { - apply_accounting_mint_quote_update( - quote, - response.amount_paid, - response.amount_issued, - response.updated_at, - ); - } + MintQuoteResponse::Bolt12(response) => apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ), + MintQuoteResponse::Onchain(response) => apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ), + MintQuoteResponse::Custom { response, .. } => apply_accounting_mint_quote_update( + quote, + response.amount_paid, + response.amount_issued, + response.updated_at, + ), } } @@ -99,15 +94,16 @@ pub(crate) fn apply_accounting_mint_quote_update( amount_paid: Amount, amount_issued: Amount, updated_at: u64, -) { +) -> bool { if is_stale_mint_quote_update(quote, updated_at, amount_paid, amount_issued) { - return; + return false; } quote.amount_paid = amount_paid; quote.amount_issued = amount_issued; quote.updated_at = quote.updated_at.max(updated_at); quote.update_state_from_amounts(); + true } fn is_stale_mint_quote_update( @@ -731,7 +727,7 @@ mod tests { quote.update_state_from_amounts(); let stale_response = custom_mint_quote_response(Amount::from(200), Amount::from(20), 9); - apply_mint_quote_response(&mut quote, &stale_response); + assert!(!apply_mint_quote_response(&mut quote, &stale_response)); assert_eq!(quote.amount_paid, Amount::from(100)); assert_eq!(quote.amount_issued, Amount::from(20)); @@ -739,14 +735,14 @@ mod tests { let decreasing_response = custom_mint_quote_response(Amount::from(90), Amount::from(20), 11); - apply_mint_quote_response(&mut quote, &decreasing_response); + assert!(!apply_mint_quote_response(&mut quote, &decreasing_response)); assert_eq!(quote.amount_paid, Amount::from(100)); assert_eq!(quote.amount_issued, Amount::from(20)); assert_eq!(quote.updated_at, 10); let fresh_response = custom_mint_quote_response(Amount::from(150), Amount::from(30), 12); - apply_mint_quote_response(&mut quote, &fresh_response); + assert!(apply_mint_quote_response(&mut quote, &fresh_response)); assert_eq!(quote.amount_paid, Amount::from(150)); assert_eq!(quote.amount_issued, Amount::from(30)); diff --git a/crates/cdk/src/wallet/streams/payment.rs b/crates/cdk/src/wallet/streams/payment.rs index 51ef2ddb13..576cc43d6a 100644 --- a/crates/cdk/src/wallet/streams/payment.rs +++ b/crates/cdk/src/wallet/streams/payment.rs @@ -7,7 +7,8 @@ use std::sync::Arc; use std::task::Poll; -use cdk_common::{Amount, Error, MeltQuoteState, MintQuoteState, NotificationPayload}; +use cdk_common::database::wallet::Database as WalletDatabase; +use cdk_common::{database, Amount, Error, MeltQuoteState, MintQuoteState, NotificationPayload}; use futures::future::join_all; use futures::stream::FuturesUnordered; use futures::{FutureExt, Stream, StreamExt}; @@ -19,9 +20,86 @@ use crate::wallet::issue::{apply_accounting_mint_quote_update, apply_mint_quote_ use crate::wallet::subscription::ActiveSubscription; use crate::{Wallet, WalletSubscription}; -type SubscribeReceived = (Option>, Vec); +type SubscribeReceived = (Option>, Vec, bool); type PaymentValue = (String, Option); +async fn apply_mint_quote_notification( + localstore: &Arc + Send + Sync>, + event: &MintEvent, +) -> bool { + match event.inner() { + NotificationPayload::MintQuoteBolt11Response(info) => { + let quote_id = info.quote.clone(); + if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { + let applied = apply_mint_quote_response( + &mut quote, + &cdk_common::MintQuoteResponse::Bolt11(info.clone()), + ); + if applied { + if let Err(e) = localstore.add_mint_quote(quote).await { + tracing::warn!("Failed to update quote state: {}", e); + } + } + return applied; + } + } + NotificationPayload::MintQuoteBolt12Response(info) => { + let quote_id = info.quote.clone(); + if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { + let applied = apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); + if applied { + if let Err(e) = localstore.add_mint_quote(quote).await { + tracing::warn!("Failed to update quote state: {}", e); + } + } + return applied; + } + } + NotificationPayload::MintQuoteOnchainResponse(info) => { + let quote_id = info.quote.clone(); + if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { + let applied = apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); + if applied { + if let Err(e) = localstore.add_mint_quote(quote).await { + tracing::warn!("Failed to update quote state: {}", e); + } + } + return applied; + } + } + NotificationPayload::CustomMintQuoteResponse(_, info) => { + let quote_id = info.quote.clone(); + if let Ok(Some(mut quote)) = localstore.get_mint_quote("e_id).await { + let applied = apply_accounting_mint_quote_update( + &mut quote, + info.amount_paid, + info.amount_issued, + info.updated_at, + ); + if applied { + if let Err(e) = localstore.add_mint_quote(quote).await { + tracing::warn!("Failed to update quote state: {}", e); + } + } + return applied; + } + } + _ => (), + } + + true +} + /// PaymentWaiter #[allow(missing_debug_implementations)] pub struct PaymentStream<'a> { @@ -141,79 +219,20 @@ impl<'a> PaymentStream<'a> { if let Some(res) = futures.next().await { drop(futures); + let mut should_emit = true; if let Some(event) = &res { - match event.inner() { - NotificationPayload::MintQuoteBolt11Response(info) => { - let quote_id = info.quote.clone(); - if let Ok(Some(mut quote)) = - localstore.get_mint_quote("e_id).await - { - apply_mint_quote_response( - &mut quote, - &cdk_common::MintQuoteResponse::Bolt11(info.clone()), - ); - if let Err(e) = localstore.add_mint_quote(quote).await { - tracing::warn!("Failed to update quote state: {}", e); - } - } - } - NotificationPayload::MintQuoteBolt12Response(info) => { - let quote_id = info.quote.clone(); - if let Ok(Some(mut quote)) = - localstore.get_mint_quote("e_id).await - { - apply_accounting_mint_quote_update( - &mut quote, - info.amount_paid, - info.amount_issued, - info.updated_at, - ); - if let Err(e) = localstore.add_mint_quote(quote).await { - tracing::warn!("Failed to update quote state: {}", e); - } - } - } - NotificationPayload::MintQuoteOnchainResponse(info) => { - let quote_id = info.quote.clone(); - if let Ok(Some(mut quote)) = - localstore.get_mint_quote("e_id).await - { - apply_accounting_mint_quote_update( - &mut quote, - info.amount_paid, - info.amount_issued, - info.updated_at, - ); - if let Err(e) = localstore.add_mint_quote(quote).await { - tracing::warn!("Failed to update quote state: {}", e); - } - } - } - NotificationPayload::CustomMintQuoteResponse(_, info) => { - let quote_id = info.quote.clone(); - if let Ok(Some(mut quote)) = - localstore.get_mint_quote("e_id).await - { - apply_accounting_mint_quote_update( - &mut quote, - info.amount_paid, - info.amount_issued, - info.updated_at, - ); - if let Err(e) = localstore.add_mint_quote(quote).await { - tracing::warn!("Failed to update quote state: {}", e); - } - } - } - _ => (), - } + should_emit = apply_mint_quote_notification(&localstore, event).await; } - return (res, subscription_receiver); + return ( + if should_emit { res } else { None }, + subscription_receiver, + false, + ); } drop(futures); - (None, subscription_receiver) + (None, subscription_receiver, true) }) }); @@ -222,17 +241,18 @@ impl<'a> PaymentStream<'a> { self.subscription_receiver_future = Some(receiver); Poll::Pending } - Poll::Ready((notification, subscription)) => { + Poll::Ready((notification, subscription, is_complete)) => { tracing::debug!("Receive payment notification {:?}", notification); // This future is now fulfilled, put the active_subscription again back to object. Next time next().await is called, // the future will be created in subscription_receiver_future. self.active_subscription = Some(subscription); self.cancellation_future = None; // resets timeout match notification { - None => { + None if is_complete => { self.is_finalized = true; Poll::Ready(None) } + None => self.poll_event(cx), Some(info) => { match info.into_inner() { NotificationPayload::MintQuoteBolt11Response(info) @@ -317,11 +337,13 @@ impl Stream for PaymentStream<'_> { #[cfg(test)] mod tests { use std::pin::Pin; + use std::str::FromStr; use std::task::{Context, Poll, Waker}; + use cdk_common::mint_url::MintUrl; use cdk_common::{ Amount, CurrencyUnit, MintQuoteBolt12Response, MintQuoteCustomResponse, - MintQuoteOnchainResponse, NotificationPayload, + MintQuoteOnchainResponse, NotificationPayload, PaymentMethod, }; use futures::Stream; @@ -330,6 +352,7 @@ mod tests { use crate::nuts::SecretKey; use crate::wallet::subscription::ActiveSubscription; use crate::wallet::test_utils::{create_test_db, create_test_wallet}; + use crate::wallet::MintQuote; #[tokio::test] async fn mint_quote_notification_underflow_does_not_panic() { @@ -385,7 +408,7 @@ mod tests { stream.filters = None; stream.subscription_receiver_future = Some(Box::pin(async move { let subscriptions: Vec = Vec::new(); - (Some(event), subscriptions) + (Some(event), subscriptions, false) })); let mut cx = Context::from_waker(Waker::noop()); @@ -400,4 +423,63 @@ mod tests { )); } } + + #[tokio::test] + async fn stale_mint_quote_notification_is_not_emitted() { + let db = create_test_db().await; + let wallet = create_test_wallet(db.clone()).await; + let pubkey = SecretKey::generate().public_key(); + let quote_id = "custom_quote".to_string(); + let mut quote = MintQuote::new( + quote_id.clone(), + MintUrl::from_str("https://mint.example.com").expect("valid mint URL"), + PaymentMethod::Custom("custom".to_string()), + Some(Amount::from(200)), + CurrencyUnit::Sat, + "test_request".to_string(), + 1_700_000_000, + None, + ); + quote.amount_paid = Amount::from(150); + quote.amount_issued = Amount::from(100); + quote.updated_at = 10; + quote.update_state_from_amounts(); + db.add_mint_quote(quote) + .await + .expect("mint quote should be stored"); + assert!( + db.get_mint_quote("e_id) + .await + .expect("mint quote lookup should succeed") + .is_some(), + "mint quote should be readable before polling" + ); + + let event = MintEvent::new(NotificationPayload::CustomMintQuoteResponse( + "custom".to_string(), + MintQuoteCustomResponse:: { + quote: quote_id.clone(), + request: "test_request".to_string(), + amount: None, + amount_paid: Amount::from(120), + amount_issued: Amount::from(100), + updated_at: 11, + unit: Some(CurrencyUnit::Sat), + expiry: None, + pubkey: Some(pubkey), + extra: serde_json::Value::Null, + }, + )); + + assert!(!super::apply_mint_quote_notification(&wallet.localstore, &event).await); + + let stored_quote = db + .get_mint_quote("e_id) + .await + .expect("mint quote lookup should succeed") + .expect("mint quote should exist"); + assert_eq!(stored_quote.amount_paid, Amount::from(150)); + assert_eq!(stored_quote.amount_issued, Amount::from(100)); + assert_eq!(stored_quote.updated_at, 10); + } }