Skip to content

Commit 674893f

Browse files
committed
fix(wallet): handle stale and invalid mint quote accounting
Ignore stale mint quote responses that would move updated_at or accounting counters backwards, derive quote state from canonical amount_paid and amount_issued counters, and treat amount_issued greater than amount_paid as invalid accounting.
1 parent f7dd5d1 commit 674893f

10 files changed

Lines changed: 357 additions & 120 deletions

File tree

crates/cashu/src/nuts/nut17/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,11 @@ mod tests {
582582
NotificationPayload::MintQuoteBolt11Response(resp.clone());
583583

584584
let encoded = serde_json::to_string(&payload).unwrap();
585-
let decoded: NotificationPayload<String> = serde_json::from_str(&encoded).unwrap();
585+
let decoded = deserialize_payload_for_kind::<String, serde_json::Error>(
586+
&Kind::Bolt11MintQuote,
587+
serde_json::from_str(&encoded).unwrap(),
588+
)
589+
.unwrap();
586590

587591
match decoded {
588592
NotificationPayload::MintQuoteBolt11Response(r) => {
@@ -790,6 +794,34 @@ mod tests {
790794
}
791795
}
792796

797+
#[test]
798+
fn notification_payload_bolt12_mint_tolerates_unknown_fields() {
799+
let encoded = r#"{
800+
"quote": "abc",
801+
"request": "lno1...",
802+
"amount": 100,
803+
"unit": "sat",
804+
"expiry": 1701704757,
805+
"pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
806+
"amount_paid": 0,
807+
"amount_issued": 0,
808+
"some_future_extension": {"nested": true}
809+
}"#;
810+
811+
let decoded = deserialize_payload_for_kind::<String, serde_json::Error>(
812+
&Kind::Bolt12MintQuote,
813+
serde_json::from_str(encoded).unwrap(),
814+
)
815+
.unwrap();
816+
817+
match decoded {
818+
NotificationPayload::MintQuoteBolt12Response(r) => {
819+
assert_eq!(r.quote, "abc");
820+
}
821+
other => panic!("expected MintQuoteBolt12Response, got {:?}", other),
822+
}
823+
}
824+
793825
#[test]
794826
fn notification_payload_custom_tuple_uses_kind() {
795827
let encoded = serde_json::json!([

crates/cashu/src/nuts/nut25.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,8 @@ pub struct MintQuoteBolt12Request {
4040
}
4141

4242
/// Mint quote response [NUT-24]
43-
///
44-
/// `deny_unknown_fields` is intentional: the `NotificationPayload` enum is
45-
/// `#[serde(untagged)]` and Bolt11 mint quotes share the same core fields as
46-
/// Bolt12 mint quotes. Rejecting unknown fields lets `NotificationPayload`
47-
/// try the Bolt12 variant before Bolt11 without classifying Bolt11 payloads
48-
/// that carry a `state` field as Bolt12.
4943
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
5044
#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")]
51-
#[serde(deny_unknown_fields)]
5245
pub struct MintQuoteBolt12Response<Q> {
5346
/// Quote Id
5447
pub quote: Q,
@@ -243,6 +236,28 @@ mod tests {
243236
assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12));
244237
}
245238

239+
#[test]
240+
fn mint_quote_bolt12_response_tolerates_unknown_fields() {
241+
let value = json!({
242+
"quote": "quote-id",
243+
"request": "lno1...",
244+
"amount": 10,
245+
"unit": "sat",
246+
"expiry": 1_701_704_757,
247+
"pubkey": "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
248+
"amount_paid": 0,
249+
"amount_issued": 0,
250+
"future_extension": {
251+
"payjoin": "https://payjoin.example/pj"
252+
}
253+
});
254+
255+
let decoded: MintQuoteBolt12Response<String> =
256+
from_value(value).expect("deserialize response");
257+
assert_eq!(decoded.quote, "quote-id");
258+
assert_eq!(decoded.method, PaymentMethod::Known(KnownMethod::Bolt12));
259+
}
260+
246261
#[test]
247262
fn melt_quote_bolt12_response_serializes_method() {
248263
let response = MeltQuoteBolt12Response {

crates/cdk-common/src/mint.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1869,6 +1869,7 @@ mod tests {
18691869
)
18701870
.expect("test pubkey must parse");
18711871
let quote_id = QuoteId::new();
1872+
let now = unix_time();
18721873
let mint_quote = MintQuote::new(
18731874
Some(quote_id.clone()),
18741875
"lno1testoffer".to_string(),
@@ -1880,7 +1881,8 @@ mod tests {
18801881
Amount::new(10_000, CurrencyUnit::Sat),
18811882
Amount::new(1_000, CurrencyUnit::Sat),
18821883
PaymentMethod::BOLT12,
1883-
unix_time(),
1884+
now,
1885+
now,
18841886
vec![],
18851887
vec![],
18861888
None,

crates/cdk-common/src/mint_quote.rs

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ pub enum MintQuoteResponse<Q> {
112112
},
113113
}
114114

115+
/// Errors from mint quote accounting validation.
116+
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
117+
pub enum MintQuoteAccountingError {
118+
/// The response reports more issued ecash than paid amount.
119+
#[error("mint quote amount_issued ({amount_issued}) exceeds amount_paid ({amount_paid})")]
120+
AmountIssuedExceedsAmountPaid {
121+
/// Amount paid to the mint.
122+
amount_paid: Amount,
123+
/// Amount of ecash issued by the mint.
124+
amount_issued: Amount,
125+
},
126+
}
127+
115128
impl<Q> MintQuoteResponse<Q> {
116129
/// Returns the payment method for this response.
117130
pub fn method(&self) -> PaymentMethod {
@@ -145,20 +158,24 @@ impl<Q> MintQuoteResponse<Q> {
145158

146159
/// Returns the quote state derived from the response data.
147160
pub fn state(&self) -> Option<QuoteState> {
161+
self.try_state().ok()
162+
}
163+
164+
/// Returns the quote state derived from the response data, validating quote accounting.
165+
pub fn try_state(&self) -> Result<QuoteState, MintQuoteAccountingError> {
148166
match self {
149167
Self::Bolt11(r) => {
150168
if r.amount_paid > Amount::ZERO || r.amount_issued > Amount::ZERO {
151-
Some(quote_state_from_amounts(r.amount_paid, r.amount_issued))
169+
quote_state_from_amounts(r.amount_paid, r.amount_issued)
152170
} else {
153-
Some(r.state)
171+
Ok(r.state)
154172
}
155173
}
156-
Self::Bolt12(r) => Some(quote_state_from_amounts(r.amount_paid, r.amount_issued)),
157-
Self::Onchain(r) => Some(quote_state_from_amounts(r.amount_paid, r.amount_issued)),
158-
Self::Custom { response, .. } => Some(quote_state_from_amounts(
159-
response.amount_paid,
160-
response.amount_issued,
161-
)),
174+
Self::Bolt12(r) => quote_state_from_amounts(r.amount_paid, r.amount_issued),
175+
Self::Onchain(r) => quote_state_from_amounts(r.amount_paid, r.amount_issued),
176+
Self::Custom { response, .. } => {
177+
quote_state_from_amounts(response.amount_paid, response.amount_issued)
178+
}
162179
}
163180
}
164181

@@ -174,15 +191,26 @@ impl<Q> MintQuoteResponse<Q> {
174191
}
175192

176193
/// Derive the deprecated single-use mint quote state from canonical quote counters.
177-
pub fn quote_state_from_amounts(amount_paid: Amount, amount_issued: Amount) -> QuoteState {
194+
pub fn quote_state_from_amounts(
195+
amount_paid: Amount,
196+
amount_issued: Amount,
197+
) -> Result<QuoteState, MintQuoteAccountingError> {
198+
if amount_issued > amount_paid {
199+
return Err(MintQuoteAccountingError::AmountIssuedExceedsAmountPaid {
200+
amount_paid,
201+
amount_issued,
202+
});
203+
}
204+
178205
if amount_paid == Amount::ZERO && amount_issued == Amount::ZERO {
179-
return QuoteState::Unpaid;
206+
return Ok(QuoteState::Unpaid);
180207
}
181208

182-
match amount_paid.cmp(&amount_issued) {
183-
std::cmp::Ordering::Less | std::cmp::Ordering::Equal => QuoteState::Issued,
184-
std::cmp::Ordering::Greater => QuoteState::Paid,
209+
if amount_paid == amount_issued {
210+
return Ok(QuoteState::Issued);
185211
}
212+
213+
Ok(QuoteState::Paid)
186214
}
187215

188216
#[cfg(test)]
@@ -222,6 +250,14 @@ mod tests {
222250
custom_response(Amount::from(100), Amount::from(100)).state(),
223251
Some(QuoteState::Issued)
224252
);
253+
assert_eq!(
254+
custom_response(Amount::from(50), Amount::from(100)).state(),
255+
None
256+
);
257+
assert!(matches!(
258+
custom_response(Amount::from(50), Amount::from(100)).try_state(),
259+
Err(MintQuoteAccountingError::AmountIssuedExceedsAmountPaid { .. })
260+
));
225261
}
226262

227263
#[test]

crates/cdk-common/src/wallet/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ impl MintQuote {
290290

291291
/// Derive quote state from the tracked payment and issuance counters.
292292
pub fn state_from_amounts(&self) -> MintQuoteState {
293-
quote_state_from_amounts(self.amount_paid, self.amount_issued)
293+
quote_state_from_amounts(self.amount_paid, self.amount_issued).unwrap_or(self.state)
294294
}
295295

296296
/// Update quote state from the tracked payment and issuance counters.

crates/cdk-npubcash/src/types.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ impl From<Quote> for MintQuote {
168168
} else {
169169
Amount::ZERO
170170
},
171-
updated_at: quote.paid_at.unwrap_or(quote.created_at),
171+
updated_at: quote.paid_at.unwrap_or_default(),
172172
estimated_blocks: None,
173173
used_by_operation: None,
174174
version: 0,
@@ -198,5 +198,27 @@ mod tests {
198198
let mint_quote = MintQuote::from(quote);
199199

200200
assert_eq!(mint_quote.expiry, u64::MAX);
201+
assert_eq!(mint_quote.updated_at, 0);
202+
}
203+
204+
#[test]
205+
fn from_paid_quote_uses_paid_at_as_updated_at() {
206+
let quote = Quote {
207+
id: "paid-quote".to_string(),
208+
amount: 1_000,
209+
unit: "sat".to_string(),
210+
created_at: 100,
211+
paid_at: Some(200),
212+
expires_at: None,
213+
mint_url: None,
214+
request: None,
215+
state: Some("PAID".to_string()),
216+
locked: None,
217+
};
218+
219+
let mint_quote = MintQuote::from(quote);
220+
221+
assert_eq!(mint_quote.amount_paid, Amount::from(1_000));
222+
assert_eq!(mint_quote.updated_at, 200);
201223
}
202224
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Track the last quote update timestamp so wallet quote responses cannot
2+
-- move amount_paid or amount_issued backwards after stale notifications.
3+
4+
ALTER TABLE mint_quote ADD COLUMN IF NOT EXISTS updated_at BIGINT NOT NULL DEFAULT 0;
5+
6+
INSERT INTO schema_info (key, value) VALUES ('schema_version', '8')
7+
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;

crates/cdk-supabase/src/wallet.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ impl SupabaseWalletDatabase {
213213
/// This must match the latest `schema_version` value set in the migration files.
214214
/// When adding new migrations, update this constant and set the same value
215215
/// in the new migration's `INSERT INTO schema_info` statement.
216-
pub const REQUIRED_SCHEMA_VERSION: u32 = 7;
216+
pub const REQUIRED_SCHEMA_VERSION: u32 = 8;
217217

218218
/// Get the full database schema SQL
219219
///

0 commit comments

Comments
 (0)