1use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use bitcoin::bip32::DerivationPath;
8use cashu::nuts::nut30::MeltQuoteOnchainFeeOption;
9use cashu::quote_id::QuoteId;
10use cashu::util::unix_time;
11use cashu::{
12 Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Response,
13 MeltQuoteCustomResponse, MeltQuoteOnchainResponse, MintQuoteBolt11Response,
14 MintQuoteBolt12Response, MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod,
15 Proofs, State,
16};
17use lightning::offers::offer::Offer;
18use serde::{Deserialize, Serialize};
19use tracing::instrument;
20use uuid::Uuid;
21
22use crate::common::IssuerVersion;
23use crate::mint_quote::MintQuoteResponse;
24use crate::nuts::{MeltQuoteState, MintQuoteState};
25use crate::payment::PaymentIdentifier;
26use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum OperationKind {
32 Swap,
34 Mint,
36 Melt,
38 BatchMint,
40}
41
42#[derive(Debug)]
74pub struct ProofsWithState {
75 proofs: Proofs,
76 pub state: State,
78}
79
80impl Deref for ProofsWithState {
81 type Target = Proofs;
82
83 fn deref(&self) -> &Self::Target {
84 &self.proofs
85 }
86}
87
88impl ProofsWithState {
89 pub fn new(proofs: Proofs, current_state: State) -> Self {
96 Self {
97 proofs,
98 state: current_state,
99 }
100 }
101}
102
103impl fmt::Display for OperationKind {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 OperationKind::Swap => write!(f, "swap"),
107 OperationKind::Mint => write!(f, "mint"),
108 OperationKind::Melt => write!(f, "melt"),
109 OperationKind::BatchMint => write!(f, "batch_mint"),
110 }
111 }
112}
113
114impl FromStr for OperationKind {
115 type Err = Error;
116 fn from_str(value: &str) -> Result<Self, Self::Err> {
117 let value = value.to_lowercase();
118 match value.as_str() {
119 "swap" => Ok(OperationKind::Swap),
120 "mint" => Ok(OperationKind::Mint),
121 "melt" => Ok(OperationKind::Melt),
122 "batch_mint" => Ok(OperationKind::BatchMint),
123 _ => Err(Error::Custom(format!("Invalid operation kind: {value}"))),
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum SwapSagaState {
132 SetupComplete,
134 Signed,
136}
137
138impl fmt::Display for SwapSagaState {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 match self {
141 SwapSagaState::SetupComplete => write!(f, "setup_complete"),
142 SwapSagaState::Signed => write!(f, "signed"),
143 }
144 }
145}
146
147impl FromStr for SwapSagaState {
148 type Err = Error;
149 fn from_str(value: &str) -> Result<Self, Self::Err> {
150 let value = value.to_lowercase();
151 match value.as_str() {
152 "setup_complete" => Ok(SwapSagaState::SetupComplete),
153 "signed" => Ok(SwapSagaState::Signed),
154 _ => Err(Error::Custom(format!("Invalid swap saga state: {value}"))),
155 }
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum MeltSagaState {
163 SetupComplete,
165 PaymentAttempted,
167 PaymentPending,
171 Finalizing,
173}
174
175impl fmt::Display for MeltSagaState {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 MeltSagaState::SetupComplete => write!(f, "setup_complete"),
179 MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
180 MeltSagaState::PaymentPending => write!(f, "payment_pending"),
181 MeltSagaState::Finalizing => write!(f, "finalizing"),
182 }
183 }
184}
185
186impl FromStr for MeltSagaState {
187 type Err = Error;
188 fn from_str(value: &str) -> Result<Self, Self::Err> {
189 let value = value.to_lowercase();
190 match value.as_str() {
191 "setup_complete" => Ok(MeltSagaState::SetupComplete),
192 "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
193 "payment_pending" => Ok(MeltSagaState::PaymentPending),
194 "finalizing" => Ok(MeltSagaState::Finalizing),
195 _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(tag = "type", rename_all = "snake_case")]
203pub enum SagaStateEnum {
204 Swap(SwapSagaState),
206 Melt(MeltSagaState),
208 }
211
212impl SagaStateEnum {
213 pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
215 match operation_kind {
216 OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)),
217 OperationKind::Melt => Ok(SagaStateEnum::Melt(MeltSagaState::from_str(s)?)),
218 OperationKind::Mint | OperationKind::BatchMint => {
219 Err(Error::Custom("Mint saga not implemented yet".to_string()))
220 }
221 }
222 }
223
224 pub fn state(&self) -> &str {
226 match self {
227 SagaStateEnum::Swap(state) => match state {
228 SwapSagaState::SetupComplete => "setup_complete",
229 SwapSagaState::Signed => "signed",
230 },
231 SagaStateEnum::Melt(state) => match state {
232 MeltSagaState::SetupComplete => "setup_complete",
233 MeltSagaState::PaymentAttempted => "payment_attempted",
234 MeltSagaState::PaymentPending => "payment_pending",
235 MeltSagaState::Finalizing => "finalizing",
236 },
237 }
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct Saga {
244 pub operation_id: Uuid,
246 pub operation_kind: OperationKind,
248 pub state: SagaStateEnum,
250 pub quote_id: Option<String>,
253 pub finalization_data: Option<MeltFinalizationData>,
255 pub created_at: u64,
257 pub updated_at: u64,
259}
260
261#[derive(Clone, PartialEq, Eq)]
263pub struct MeltFinalizationData {
264 pub total_spent: Amount<CurrencyUnit>,
266 pub payment_lookup_id: PaymentIdentifier,
268 pub payment_proof: Option<String>,
270}
271
272impl fmt::Debug for MeltFinalizationData {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 f.debug_struct("MeltFinalizationData")
275 .field("total_spent", &self.total_spent)
276 .field("payment_lookup_id", &self.payment_lookup_id)
277 .field(
278 "payment_proof",
279 &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
280 )
281 .finish()
282 }
283}
284
285impl Serialize for MeltFinalizationData {
286 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
287 where
288 S: serde::Serializer,
289 {
290 #[derive(Serialize)]
291 struct MeltFinalizationDataSer<'a> {
292 total_spent: Amount,
293 unit: &'a CurrencyUnit,
294 payment_lookup_id: &'a PaymentIdentifier,
295 payment_proof: &'a Option<String>,
296 }
297
298 MeltFinalizationDataSer {
299 total_spent: self.total_spent.clone().into(),
300 unit: self.total_spent.unit(),
301 payment_lookup_id: &self.payment_lookup_id,
302 payment_proof: &self.payment_proof,
303 }
304 .serialize(serializer)
305 }
306}
307
308impl<'de> Deserialize<'de> for MeltFinalizationData {
309 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
310 where
311 D: serde::Deserializer<'de>,
312 {
313 #[derive(Deserialize)]
314 struct MeltFinalizationDataDe {
315 total_spent: Amount,
316 unit: CurrencyUnit,
317 payment_lookup_id: PaymentIdentifier,
318 payment_proof: Option<String>,
319 }
320
321 let data = MeltFinalizationDataDe::deserialize(deserializer)?;
322
323 Ok(Self {
324 total_spent: data.total_spent.with_unit(data.unit),
325 payment_lookup_id: data.payment_lookup_id,
326 payment_proof: data.payment_proof,
327 })
328 }
329}
330
331impl Saga {
332 pub fn new_swap(operation_id: Uuid, state: SwapSagaState) -> Self {
334 let now = unix_time();
335 Self {
336 operation_id,
337 operation_kind: OperationKind::Swap,
338 state: SagaStateEnum::Swap(state),
339 quote_id: None,
340 finalization_data: None,
341 created_at: now,
342 updated_at: now,
343 }
344 }
345
346 pub fn update_swap_state(&mut self, new_state: SwapSagaState) {
348 self.state = SagaStateEnum::Swap(new_state);
349 self.updated_at = unix_time();
350 }
351
352 pub fn new_melt(operation_id: Uuid, state: MeltSagaState, quote_id: String) -> Self {
354 let now = unix_time();
355 Self {
356 operation_id,
357 operation_kind: OperationKind::Melt,
358 state: SagaStateEnum::Melt(state),
359 quote_id: Some(quote_id),
360 finalization_data: None,
361 created_at: now,
362 updated_at: now,
363 }
364 }
365
366 pub fn update_melt_state(&mut self, new_state: MeltSagaState) {
368 self.state = SagaStateEnum::Melt(new_state);
369 self.updated_at = unix_time();
370 }
371
372 pub fn set_melt_finalization_data(&mut self, finalization_data: MeltFinalizationData) {
374 self.finalization_data = Some(finalization_data);
375 self.updated_at = unix_time();
376 }
377}
378
379#[derive(Debug)]
381pub struct Operation {
382 id: Uuid,
383 kind: OperationKind,
384 total_issued: Amount,
385 total_redeemed: Amount,
386 fee_collected: Amount,
387 complete_at: Option<u64>,
388 payment_amount: Option<Amount>,
390 payment_fee: Option<Amount>,
392 payment_method: Option<PaymentMethod>,
394}
395
396impl Operation {
397 pub fn new(
399 id: Uuid,
400 kind: OperationKind,
401 total_issued: Amount,
402 total_redeemed: Amount,
403 fee_collected: Amount,
404 complete_at: Option<u64>,
405 payment_method: Option<PaymentMethod>,
406 ) -> Self {
407 Self {
408 id,
409 kind,
410 total_issued,
411 total_redeemed,
412 fee_collected,
413 complete_at,
414 payment_amount: None,
415 payment_fee: None,
416 payment_method,
417 }
418 }
419
420 pub fn new_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
422 Self {
423 id: Uuid::now_v7(),
424 kind: OperationKind::Mint,
425 total_issued,
426 total_redeemed: Amount::ZERO,
427 fee_collected: Amount::ZERO,
428 complete_at: None,
429 payment_amount: None,
430 payment_fee: None,
431 payment_method: Some(payment_method),
432 }
433 }
434
435 pub fn new_batch_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
437 Self {
438 id: Uuid::now_v7(),
439 kind: OperationKind::BatchMint,
440 total_issued,
441 total_redeemed: Amount::ZERO,
442 fee_collected: Amount::ZERO,
443 complete_at: None,
444 payment_amount: None,
445 payment_fee: None,
446 payment_method: Some(payment_method),
447 }
448 }
449
450 pub fn new_melt(
454 total_redeemed: Amount,
455 fee_collected: Amount,
456 payment_method: PaymentMethod,
457 ) -> Self {
458 Self {
459 id: Uuid::now_v7(),
460 kind: OperationKind::Melt,
461 total_issued: Amount::ZERO,
462 total_redeemed,
463 fee_collected,
464 complete_at: None,
465 payment_amount: None,
466 payment_fee: None,
467 payment_method: Some(payment_method),
468 }
469 }
470
471 pub fn new_swap(total_issued: Amount, total_redeemed: Amount, fee_collected: Amount) -> Self {
473 Self {
474 id: Uuid::now_v7(),
475 kind: OperationKind::Swap,
476 total_issued,
477 total_redeemed,
478 fee_collected,
479 complete_at: None,
480 payment_amount: None,
481 payment_fee: None,
482 payment_method: None,
483 }
484 }
485
486 pub fn id(&self) -> &Uuid {
488 &self.id
489 }
490
491 pub fn kind(&self) -> OperationKind {
493 self.kind
494 }
495
496 pub fn total_issued(&self) -> Amount {
498 self.total_issued
499 }
500
501 pub fn total_redeemed(&self) -> Amount {
503 self.total_redeemed
504 }
505
506 pub fn fee_collected(&self) -> Amount {
508 self.fee_collected
509 }
510
511 pub fn completed_at(&self) -> &Option<u64> {
513 &self.complete_at
514 }
515
516 pub fn add_change(&mut self, change: Amount) {
518 self.total_issued = change;
519 }
520
521 pub fn payment_amount(&self) -> Option<Amount> {
523 self.payment_amount
524 }
525
526 pub fn payment_fee(&self) -> Option<Amount> {
528 self.payment_fee
529 }
530
531 pub fn set_payment_details(&mut self, payment_amount: Amount, payment_fee: Amount) {
533 self.payment_amount = Some(payment_amount);
534 self.payment_fee = Some(payment_fee);
535 }
536
537 pub fn payment_method(&self) -> Option<PaymentMethod> {
539 self.payment_method.clone()
540 }
541}
542
543#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
554pub struct MintQuoteChange {
555 pub payments: Option<Vec<IncomingPayment>>,
557 pub issuances: Option<Vec<Amount>>,
559}
560
561#[derive(Debug, Clone, Hash, PartialEq, Eq)]
563pub struct MintQuote {
564 pub id: QuoteId,
566 pub amount: Option<Amount<CurrencyUnit>>,
568 pub unit: CurrencyUnit,
570 pub request: String,
572 pub expiry: u64,
574 pub request_lookup_id: PaymentIdentifier,
576 pub pubkey: Option<PublicKey>,
578 pub created_time: u64,
580 amount_paid: Amount<CurrencyUnit>,
582 amount_issued: Amount<CurrencyUnit>,
584 updated_at: u64,
586 last_checked: u64,
588 pub payments: Vec<IncomingPayment>,
590 pub payment_method: PaymentMethod,
592 pub issuance: Vec<Issuance>,
594 pub extra_json: Option<serde_json::Value>,
596 changes: Option<MintQuoteChange>,
602}
603
604impl MintQuote {
605 #[allow(clippy::too_many_arguments)]
607 pub fn new(
608 id: Option<QuoteId>,
609 request: String,
610 unit: CurrencyUnit,
611 amount: Option<Amount<CurrencyUnit>>,
612 expiry: u64,
613 request_lookup_id: PaymentIdentifier,
614 pubkey: Option<PublicKey>,
615 amount_paid: Amount<CurrencyUnit>,
616 amount_issued: Amount<CurrencyUnit>,
617 payment_method: PaymentMethod,
618 created_time: u64,
619 updated_at: u64,
620 payments: Vec<IncomingPayment>,
621 issuance: Vec<Issuance>,
622 extra_json: Option<serde_json::Value>,
623 ) -> Self {
624 let id = id.unwrap_or_default();
625
626 Self {
627 id,
628 amount,
629 unit: unit.clone(),
630 request,
631 expiry,
632 request_lookup_id,
633 pubkey,
634 created_time,
635 amount_paid,
636 amount_issued,
637 updated_at,
638 last_checked: 0,
639 payment_method,
640 payments,
641 issuance,
642 extra_json,
643 changes: None,
644 }
645 }
646
647 #[instrument(skip(self))]
649 pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
650 self.amount_paid.clone()
651 }
652
653 #[instrument(skip(self))]
676 pub fn add_issuance(
677 &mut self,
678 additional_amount: Amount<CurrencyUnit>,
679 ) -> Result<Amount<CurrencyUnit>, crate::Error> {
680 let new_amount_issued = self
681 .amount_issued
682 .checked_add(&additional_amount)
683 .map_err(|_| crate::Error::AmountOverflow)?;
684
685 if new_amount_issued > self.amount_paid {
687 return Err(crate::Error::OverIssue);
688 }
689
690 self.changes
691 .get_or_insert_default()
692 .issuances
693 .get_or_insert_default()
694 .push(additional_amount.into());
695
696 self.amount_issued = new_amount_issued;
697
698 Ok(self.amount_issued.clone())
699 }
700
701 #[instrument(skip(self))]
703 pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
704 self.amount_issued.clone()
705 }
706
707 pub fn updated_at(&self) -> u64 {
709 self.updated_at
710 }
711
712 pub fn set_updated_at(&mut self, updated_at: u64) {
714 self.updated_at = updated_at;
715 }
716
717 pub fn last_checked(&self) -> u64 {
719 self.last_checked
720 }
721
722 pub fn set_last_checked(&mut self, last_checked: u64) {
724 self.last_checked = last_checked;
725 }
726
727 #[instrument(skip(self))]
729 pub fn state(&self) -> MintQuoteState {
730 self.compute_quote_state()
731 }
732
733 pub fn payment_ids(&self) -> Vec<&String> {
735 self.payments.iter().map(|a| &a.payment_id).collect()
736 }
737
738 pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
745 self.amount_paid
746 .checked_sub(&self.amount_issued)
747 .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
748 }
749
750 pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
760 self.changes.take()
761 }
762
763 #[instrument(skip(self))]
783 pub fn add_payment(
784 &mut self,
785 amount: Amount<CurrencyUnit>,
786 payment_id: String,
787 time: Option<u64>,
788 ) -> Result<(), crate::Error> {
789 let time = time.unwrap_or_else(unix_time);
790
791 let payment_ids = self.payment_ids();
792 if payment_ids.contains(&&payment_id) {
793 return Err(crate::Error::DuplicatePaymentId);
794 }
795
796 self.amount_paid = self
797 .amount_paid
798 .checked_add(&amount)
799 .map_err(|_| crate::Error::AmountOverflow)?;
800
801 let payment = IncomingPayment::new(amount, payment_id, time);
802
803 self.payments.push(payment.clone());
804
805 self.changes
806 .get_or_insert_default()
807 .payments
808 .get_or_insert_default()
809 .push(payment);
810
811 Ok(())
812 }
813
814 #[instrument(skip(self))]
816 fn compute_quote_state(&self) -> MintQuoteState {
817 let zero_amount = Amount::new(0, self.unit.clone());
818
819 if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
820 return MintQuoteState::Unpaid;
821 }
822
823 match self.amount_paid.value().cmp(&self.amount_issued.value()) {
824 std::cmp::Ordering::Less => {
825 tracing::error!("We should not have issued more then has been paid");
826 MintQuoteState::Issued
827 }
828 std::cmp::Ordering::Equal => MintQuoteState::Issued,
829 std::cmp::Ordering::Greater => MintQuoteState::Paid,
830 }
831 }
832}
833
834#[derive(Debug, Clone, Hash, PartialEq, Eq)]
836pub struct IncomingPayment {
837 pub amount: Amount<CurrencyUnit>,
839 pub time: u64,
841 pub payment_id: String,
843}
844
845impl IncomingPayment {
846 pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
848 Self {
849 payment_id,
850 time,
851 amount,
852 }
853 }
854}
855
856#[derive(Debug, Clone, Hash, PartialEq, Eq)]
858pub struct Issuance {
859 pub amount: Amount<CurrencyUnit>,
861 pub time: u64,
863}
864
865impl Issuance {
866 pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
868 Self { amount, time }
869 }
870}
871
872#[derive(Clone, Hash, PartialEq, Eq)]
874pub struct MeltQuote {
875 pub id: QuoteId,
877 pub unit: CurrencyUnit,
879 pub request: MeltPaymentRequest,
881 amount: Amount<CurrencyUnit>,
883 fee_reserve: Amount<CurrencyUnit>,
885 pub state: MeltQuoteState,
887 pub expiry: u64,
889 pub payment_proof: Option<String>,
891 pub request_lookup_id: Option<PaymentIdentifier>,
893 pub options: Option<MeltOptions>,
897 pub created_time: u64,
899 pub paid_time: Option<u64>,
901 pub payment_method: PaymentMethod,
903 pub extra_json: Option<serde_json::Value>,
905 pub estimated_blocks: Option<u32>,
907 fee_options: Vec<MeltQuoteOnchainFeeOption>,
917 pub selected_fee_index: Option<u32>,
919}
920
921impl fmt::Debug for MeltQuote {
922 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
923 f.debug_struct("MeltQuote")
924 .field("id", &self.id)
925 .field("unit", &self.unit)
926 .field("request", &self.request)
927 .field("amount", &self.amount)
928 .field("fee_reserve", &self.fee_reserve)
929 .field("state", &self.state)
930 .field("expiry", &self.expiry)
931 .field(
932 "payment_proof",
933 &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
934 )
935 .field("request_lookup_id", &self.request_lookup_id)
936 .field("options", &self.options)
937 .field("created_time", &self.created_time)
938 .field("paid_time", &self.paid_time)
939 .field("payment_method", &self.payment_method)
940 .field("extra_json", &self.extra_json)
941 .field("estimated_blocks", &self.estimated_blocks)
942 .field("fee_options", &self.fee_options)
943 .field("selected_fee_index", &self.selected_fee_index)
944 .finish()
945 }
946}
947
948impl MeltQuote {
949 #[allow(clippy::too_many_arguments)]
951 pub fn new(
952 id: Option<QuoteId>,
953 request: MeltPaymentRequest,
954 unit: CurrencyUnit,
955 amount: Amount<CurrencyUnit>,
956 fee_reserve: Amount<CurrencyUnit>,
957 expiry: u64,
958 request_lookup_id: Option<PaymentIdentifier>,
959 options: Option<MeltOptions>,
960 payment_method: PaymentMethod,
961 extra_json: Option<serde_json::Value>,
962 estimated_blocks: Option<u32>,
963 ) -> Self {
964 let id = id.unwrap_or_default();
965
966 let fee_options = estimated_blocks
967 .map(|estimated_blocks| {
968 vec![MeltQuoteOnchainFeeOption {
969 fee_index: 0,
970 fee_reserve: fee_reserve.clone().into(),
971 estimated_blocks,
972 }]
973 })
974 .unwrap_or_default();
975
976 Self {
977 id,
978 unit: unit.clone(),
979 request,
980 amount,
981 fee_reserve,
982 state: MeltQuoteState::Unpaid,
983 expiry,
984 payment_proof: None,
985 request_lookup_id,
986 options,
987 created_time: unix_time(),
988 paid_time: None,
989 payment_method,
990 extra_json,
991 estimated_blocks,
992 fee_options,
993 selected_fee_index: None,
994 }
995 }
996
997 #[allow(clippy::too_many_arguments)]
1009 pub fn new_onchain(
1010 id: Option<QuoteId>,
1011 request: MeltPaymentRequest,
1012 unit: CurrencyUnit,
1013 amount: Amount<CurrencyUnit>,
1014 expiry: u64,
1015 request_lookup_id: Option<PaymentIdentifier>,
1016 extra_json: Option<serde_json::Value>,
1017 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1018 ) -> Result<Self, crate::Error> {
1019 if fee_options.is_empty() {
1020 return Err(crate::Error::OnchainFeeOptionsEmpty);
1021 }
1022
1023 validate_onchain_fee_options(&fee_options)?;
1024
1025 let id = id.unwrap_or_default();
1026
1027 let initial = fee_options
1031 .iter()
1032 .min_by_key(|option| u64::from(option.fee_reserve))
1033 .copied()
1034 .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
1035
1036 let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
1037 let estimated_blocks = Some(initial.estimated_blocks);
1038
1039 Ok(Self {
1040 id,
1041 unit: unit.clone(),
1042 request,
1043 amount,
1044 fee_reserve,
1045 state: MeltQuoteState::Unpaid,
1046 expiry,
1047 payment_proof: None,
1048 request_lookup_id,
1049 options: None,
1050 created_time: unix_time(),
1051 paid_time: None,
1052 payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1053 extra_json,
1054 estimated_blocks,
1055 fee_options,
1056 selected_fee_index: None,
1057 })
1058 }
1059
1060 #[inline]
1066 pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1067 &self.fee_options
1068 }
1069
1070 #[inline]
1072 pub fn amount(&self) -> Amount<CurrencyUnit> {
1073 self.amount.clone()
1074 }
1075
1076 #[inline]
1078 pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1079 self.fee_reserve.clone()
1080 }
1081
1082 pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1084 let option = self
1085 .fee_options
1086 .iter()
1087 .find(|option| option.fee_index == fee_index)
1088 .copied()
1089 .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1090
1091 if self
1092 .selected_fee_index
1093 .is_some_and(|selected| selected != fee_index)
1094 {
1095 return Err(crate::Error::InvalidPaymentRequest);
1096 }
1097
1098 self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1099 self.estimated_blocks = Some(option.estimated_blocks);
1100 self.selected_fee_index = Some(fee_index);
1101
1102 Ok(())
1103 }
1104
1105 pub fn into_response(
1111 self,
1112 change: Option<Vec<cashu::nuts::BlindSignature>>,
1113 ) -> crate::MeltQuoteResponse<QuoteId> {
1114 match self.payment_method {
1115 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1116 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1117 response.change = change;
1118 crate::MeltQuoteResponse::Bolt11(response)
1119 }
1120 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1121 let mut response: MeltQuoteBolt12Response<QuoteId> = self.into();
1122 response.change = change;
1123 crate::MeltQuoteResponse::Bolt12(response)
1124 }
1125 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1126 let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1127 response.change = change;
1128 crate::MeltQuoteResponse::Onchain(response)
1129 }
1130 _ => {
1131 let method = self.payment_method.clone();
1132 let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1133 response.change = change;
1134 crate::MeltQuoteResponse::Custom((method, response))
1135 }
1136 }
1137 }
1138
1139 pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1141 let total = self
1142 .amount
1143 .checked_add(&self.fee_reserve)
1144 .map_err(|_| crate::Error::AmountOverflow)?;
1145 Ok(Amount::from(total.value()))
1146 }
1147
1148 #[allow(clippy::too_many_arguments)]
1150 pub fn from_db(
1151 id: QuoteId,
1152 unit: CurrencyUnit,
1153 request: MeltPaymentRequest,
1154 amount: u64,
1155 fee_reserve: u64,
1156 state: MeltQuoteState,
1157 expiry: u64,
1158 payment_proof: Option<String>,
1159 request_lookup_id: Option<PaymentIdentifier>,
1160 options: Option<MeltOptions>,
1161 created_time: u64,
1162 paid_time: Option<u64>,
1163 payment_method: PaymentMethod,
1164 extra_json: Option<serde_json::Value>,
1165 estimated_blocks: Option<u32>,
1166 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1167 selected_fee_index: Option<u32>,
1168 ) -> Result<Self, crate::Error> {
1169 if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1174 validate_onchain_fee_options(&fee_options)?;
1175 }
1176
1177 Ok(Self {
1178 id,
1179 unit: unit.clone(),
1180 request,
1181 amount: Amount::new(amount, unit.clone()),
1182 fee_reserve: Amount::new(fee_reserve, unit),
1183 state,
1184 expiry,
1185 payment_proof,
1186 request_lookup_id,
1187 options,
1188 created_time,
1189 paid_time,
1190 payment_method,
1191 extra_json,
1192 estimated_blocks,
1193 fee_options,
1194 selected_fee_index,
1195 })
1196 }
1197}
1198
1199pub fn validate_onchain_fee_options(
1208 fee_options: &[MeltQuoteOnchainFeeOption],
1209) -> Result<(), crate::Error> {
1210 if fee_options.is_empty() {
1211 return Err(crate::Error::OnchainFeeOptionsEmpty);
1212 }
1213
1214 Ok(())
1215}
1216
1217impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1218 fn from(quote: MeltQuote) -> Self {
1219 Self {
1220 quote: quote.id.clone(),
1221 amount: quote.amount().into(),
1222 unit: quote.unit.clone(),
1223 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1224 state: quote.state,
1225 expiry: quote.expiry,
1226 request: quote.request.to_string(),
1227 fee_options: quote.fee_options().to_vec(),
1228 selected_fee_index: quote.selected_fee_index,
1229 outpoint: quote.payment_proof.clone(),
1230 change: None,
1231 }
1232 }
1233}
1234
1235impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1236 type Error = crate::error::Error;
1237 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1238 Ok(Self {
1239 quote: quote.id.clone(),
1240 request: quote.request.clone(),
1241 unit: quote.unit.clone(),
1242 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1243 expiry: (quote.expiry != 0).then_some(quote.expiry),
1244 pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1245 amount_paid: quote.amount_paid().into(),
1246 amount_issued: quote.amount_issued().into(),
1247 updated_at: quote.updated_at(),
1248 })
1249 }
1250}
1251
1252#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1254pub struct MintKeySetInfo {
1255 pub id: Id,
1257 pub unit: CurrencyUnit,
1259 pub active: bool,
1262 pub valid_from: u64,
1264 pub derivation_path: DerivationPath,
1266 pub derivation_path_index: Option<u32>,
1268 pub amounts: Vec<u64>,
1270 #[serde(default = "default_fee")]
1272 pub input_fee_ppk: u64,
1273 pub final_expiry: Option<u64>,
1275 pub issuer_version: Option<IssuerVersion>,
1277}
1278
1279impl MintKeySetInfo {
1280 pub fn is_expired(&self) -> bool {
1282 self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1283 }
1284}
1285
1286pub fn default_fee() -> u64 {
1288 0
1289}
1290
1291impl From<MintKeySetInfo> for KeySetInfo {
1292 fn from(keyset_info: MintKeySetInfo) -> Self {
1293 Self {
1294 id: keyset_info.id,
1295 unit: keyset_info.unit,
1296 active: keyset_info.active,
1297 input_fee_ppk: keyset_info.input_fee_ppk,
1298 final_expiry: keyset_info.final_expiry,
1299 }
1300 }
1301}
1302
1303impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1304 fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1305 let amount_paid = mint_quote.amount_paid().into();
1306 let amount_issued = mint_quote.amount_issued().into();
1307 let updated_at = mint_quote.updated_at();
1308
1309 MintQuoteBolt11Response {
1310 quote: mint_quote.id.clone(),
1311 state: mint_quote.state(),
1312 request: mint_quote.request,
1313 expiry: Some(mint_quote.expiry),
1314 pubkey: mint_quote.pubkey,
1315 amount: mint_quote.amount.map(Into::into),
1316 unit: Some(mint_quote.unit),
1317 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1318 amount_paid,
1319 amount_issued,
1320 updated_at,
1321 }
1322 }
1323}
1324
1325impl From<MintQuote> for MintQuoteBolt11Response<String> {
1326 fn from(quote: MintQuote) -> Self {
1327 let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1328 quote.into()
1329 }
1330}
1331
1332impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1333 type Error = Error;
1334
1335 fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1336 let amount_paid = mint_quote.amount_paid().into();
1337 let amount_issued = mint_quote.amount_issued().into();
1338 let updated_at = mint_quote.updated_at();
1339
1340 Ok(MintQuoteBolt12Response {
1341 quote: mint_quote.id.clone(),
1342 request: mint_quote.request,
1343 expiry: (mint_quote.expiry != 0).then_some(mint_quote.expiry),
1344 amount_paid,
1345 amount_issued,
1346 pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1347 amount: mint_quote.amount.map(Into::into),
1348 unit: mint_quote.unit,
1349 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1350 updated_at,
1351 })
1352 }
1353}
1354
1355impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1356 type Error = Error;
1357
1358 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1359 let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1360 Ok(quote.into())
1361 }
1362}
1363
1364impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1365 type Error = Error;
1366
1367 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1368 let amount_paid = quote.amount_paid().into();
1369 let amount_issued = quote.amount_issued().into();
1370 let updated_at = quote.updated_at();
1371
1372 Ok(MintQuoteCustomResponse {
1373 quote: quote.id,
1374 request: quote.request,
1375 method: quote.payment_method,
1376 unit: Some(quote.unit),
1377 expiry: Some(quote.expiry),
1378 pubkey: quote.pubkey,
1379 amount: quote.amount.map(Into::into),
1380 amount_paid,
1381 amount_issued,
1382 updated_at,
1383 extra: quote.extra_json.unwrap_or_default(),
1384 })
1385 }
1386}
1387
1388impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1389 type Error = Error;
1390
1391 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1392 let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1393 Ok(quote.into())
1394 }
1395}
1396
1397impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1398 fn from(melt_quote: MeltQuote) -> Self {
1399 let method = melt_quote.payment_method.clone();
1400 let request = match melt_quote.request {
1401 MeltPaymentRequest::Custom { request, .. } => Some(request),
1402 _ => None,
1403 };
1404
1405 Self {
1406 quote: melt_quote.id,
1407 method,
1408 amount: melt_quote.amount.into(),
1409 fee_reserve: Some(melt_quote.fee_reserve.into()),
1410 state: melt_quote.state,
1411 expiry: melt_quote.expiry,
1412 payment_preimage: melt_quote.payment_proof,
1413 change: None,
1414 request,
1415 unit: Some(melt_quote.unit),
1416 extra: melt_quote.extra_json.unwrap_or_default(),
1417 }
1418 }
1419}
1420
1421impl From<&MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1422 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1423 MeltQuoteBolt12Response {
1424 quote: melt_quote.id.clone(),
1425 payment_preimage: None,
1426 change: None,
1427 state: melt_quote.state,
1428 expiry: melt_quote.expiry,
1429 amount: melt_quote.amount().into(),
1430 fee_reserve: melt_quote.fee_reserve().into(),
1431 request: None,
1432 unit: Some(melt_quote.unit.clone()),
1433 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1434 }
1435 }
1436}
1437
1438impl From<MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1439 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1440 MeltQuoteBolt12Response {
1441 quote: melt_quote.id.clone(),
1442 amount: melt_quote.amount().into(),
1443 fee_reserve: melt_quote.fee_reserve().into(),
1444 state: melt_quote.state,
1445 expiry: melt_quote.expiry,
1446 payment_preimage: melt_quote.payment_proof,
1447 change: None,
1448 request: Some(melt_quote.request.to_string()),
1449 unit: Some(melt_quote.unit.clone()),
1450 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1451 }
1452 }
1453}
1454
1455impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1456 type Error = Error;
1457
1458 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1459 if quote.payment_method.is_bolt11() {
1460 Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1461 quote: quote.id.clone(),
1462 request: quote.request.clone(),
1463 state: quote.state(),
1464 expiry: Some(quote.expiry),
1465 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1466 unit: Some(quote.unit.clone()),
1467 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1468 pubkey: quote.pubkey,
1469 amount_paid: quote.amount_paid().into(),
1470 amount_issued: quote.amount_issued().into(),
1471 updated_at: quote.updated_at(),
1472 }))
1473 } else if quote.payment_method.is_bolt12() {
1474 Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1475 quote: quote.id.clone(),
1476 request: quote.request.clone(),
1477 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1478 unit: quote.unit.clone(),
1479 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1480 expiry: (quote.expiry != 0).then_some(quote.expiry),
1481 pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1482 amount_paid: quote.amount_paid().into(),
1483 amount_issued: quote.amount_issued().into(),
1484 updated_at: quote.updated_at(),
1485 }))
1486 } else if quote.payment_method.is_onchain() {
1487 let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1488 Ok(MintQuoteResponse::Onchain(onchain_response))
1489 } else {
1490 let method = quote.payment_method.clone();
1491 Ok(MintQuoteResponse::Custom {
1492 method: method.clone(),
1493 response: crate::nuts::nut04::MintQuoteCustomResponse {
1494 quote: quote.id.clone(),
1495 request: quote.request.clone(),
1496 method: method.clone(),
1497 expiry: Some(quote.expiry),
1498 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1499 amount_paid: quote.amount_paid().into(),
1500 amount_issued: quote.amount_issued().into(),
1501 updated_at: quote.updated_at(),
1502 unit: Some(quote.unit.clone()),
1503 pubkey: quote.pubkey,
1504 extra: quote.extra_json.clone().unwrap_or_default(),
1505 },
1506 })
1507 }
1508 }
1509}
1510
1511impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1512 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1513 match response {
1514 MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1515 MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1516 MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1517 MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1518 method,
1519 response: response.into(),
1520 },
1521 }
1522 }
1523}
1524
1525impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1526 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1527 match response {
1528 MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1529 quote: bolt11_response.quote.to_string(),
1530 state: bolt11_response.state,
1531 request: bolt11_response.request,
1532 expiry: bolt11_response.expiry,
1533 pubkey: bolt11_response.pubkey,
1534 amount: bolt11_response.amount,
1535 unit: bolt11_response.unit,
1536 method: bolt11_response.method,
1537 amount_paid: bolt11_response.amount_paid,
1538 amount_issued: bolt11_response.amount_issued,
1539 updated_at: bolt11_response.updated_at,
1540 },
1541 _ => panic!("Expected Bolt11 response"),
1542 }
1543 }
1544}
1545
1546impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1547 type Error = Error;
1548
1549 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1550 match response {
1551 MintQuoteResponse::Bolt11(r) => Ok(r),
1552 _ => Err(Error::InvalidPaymentMethod),
1553 }
1554 }
1555}
1556
1557impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1558 type Error = Error;
1559
1560 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1561 match response {
1562 MintQuoteResponse::Bolt12(r) => Ok(r),
1563 _ => Err(Error::InvalidPaymentMethod),
1564 }
1565 }
1566}
1567
1568impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1569 type Error = Error;
1570
1571 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1572 match response {
1573 MintQuoteResponse::Onchain(r) => Ok(r),
1574 _ => Err(Error::InvalidPaymentMethod),
1575 }
1576 }
1577}
1578
1579impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1580 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1581 MeltQuoteBolt11Response {
1582 quote: melt_quote.id.clone(),
1583 payment_preimage: None,
1584 change: None,
1585 state: melt_quote.state,
1586 expiry: melt_quote.expiry,
1587 amount: melt_quote.amount().into(),
1588 fee_reserve: melt_quote.fee_reserve().into(),
1589 request: None,
1590 unit: Some(melt_quote.unit.clone()),
1591 method: melt_quote.payment_method.clone(),
1592 }
1593 }
1594}
1595
1596impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1597 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1598 MeltQuoteBolt11Response {
1599 quote: melt_quote.id.clone(),
1600 amount: melt_quote.amount().into(),
1601 fee_reserve: melt_quote.fee_reserve().into(),
1602 state: melt_quote.state,
1603 expiry: melt_quote.expiry,
1604 payment_preimage: melt_quote.payment_proof,
1605 change: None,
1606 request: Some(melt_quote.request.to_string()),
1607 unit: Some(melt_quote.unit.clone()),
1608 method: melt_quote.payment_method.clone(),
1609 }
1610 }
1611}
1612
1613#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1615pub enum MeltPaymentRequest {
1616 Bolt11 {
1618 bolt11: Bolt11Invoice,
1620 },
1621 Bolt12 {
1623 #[serde(with = "offer_serde")]
1625 offer: Box<Offer>,
1626 },
1627 Custom {
1629 method: String,
1631 request: String,
1633 },
1634 Onchain {
1636 address: String,
1638 },
1639}
1640
1641impl std::fmt::Display for MeltPaymentRequest {
1642 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1643 match self {
1644 MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1645 MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1646 MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1647 MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1648 }
1649 }
1650}
1651
1652mod offer_serde {
1653 use std::str::FromStr;
1654
1655 use serde::{self, Deserialize, Deserializer, Serializer};
1656
1657 use super::Offer;
1658
1659 pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1660 where
1661 S: Serializer,
1662 {
1663 let s = offer.to_string();
1664 serializer.serialize_str(&s)
1665 }
1666
1667 pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1668 where
1669 D: Deserializer<'de>,
1670 {
1671 let s = String::deserialize(deserializer)?;
1672 Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1673 serde::de::Error::custom("Invalid Bolt12 Offer")
1674 })?))
1675 }
1676}
1677
1678#[cfg(test)]
1679mod tests {
1680 use std::str::FromStr;
1681
1682 use cashu::Bolt11Invoice;
1683
1684 use super::*;
1685
1686 #[test]
1687 fn test_operation_new_mint_uses_uuid_v7() {
1688 let operation = Operation::new_mint(Amount::from(100), PaymentMethod::BOLT11);
1689
1690 assert_eq!(operation.id.get_version(), Some(uuid::Version::SortRand));
1691 }
1692
1693 #[test]
1694 fn mint_payment_records_debug_redact_payment_proofs() {
1695 let secret = "mint-payment-preimage-secret";
1696 let lookup_id = PaymentIdentifier::CustomId("public-lookup-id".to_string());
1697 let mut quote = MeltQuote::new(
1698 Some(QuoteId::new()),
1699 MeltPaymentRequest::Custom {
1700 method: "custom".to_string(),
1701 request: "public-payment-request".to_string(),
1702 },
1703 CurrencyUnit::Sat,
1704 Amount::new(100, CurrencyUnit::Sat),
1705 Amount::new(2, CurrencyUnit::Sat),
1706 unix_time() + 3_600,
1707 Some(lookup_id.clone()),
1708 None,
1709 PaymentMethod::Custom("custom".to_string()),
1710 None,
1711 None,
1712 );
1713 quote.payment_proof = Some(secret.to_string());
1714 let finalization = MeltFinalizationData {
1715 total_spent: Amount::new(102, CurrencyUnit::Sat),
1716 payment_lookup_id: lookup_id,
1717 payment_proof: Some(secret.to_string()),
1718 };
1719
1720 for debug in [format!("{quote:?}"), format!("{finalization:?}")] {
1721 assert!(debug.contains("public-lookup-id"));
1722 assert!(debug.contains("[REDACTED]"));
1723 assert!(!debug.contains(secret));
1724 }
1725 }
1726
1727 #[test]
1728 fn test_melt_quote_to_custom_response_with_custom_request() {
1729 let melt_quote = MeltQuote::new(
1730 Some(QuoteId::new()),
1731 MeltPaymentRequest::Custom {
1732 method: "custom".to_string(),
1733 request: "custom_request_string".to_string(),
1734 },
1735 CurrencyUnit::Sat,
1736 Amount::new(100, CurrencyUnit::Sat),
1737 Amount::new(2, CurrencyUnit::Sat),
1738 unix_time() + 3600,
1739 None,
1740 None,
1741 PaymentMethod::Custom("custom".to_string()),
1742 Some(serde_json::json!({"extra_field": "value"})),
1743 None,
1744 );
1745
1746 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1747
1748 assert_eq!(response.quote, melt_quote.id);
1749 assert_eq!(response.amount, 100.into());
1750 assert_eq!(response.fee_reserve, Some(2.into()));
1751 assert_eq!(response.state, melt_quote.state);
1752 assert_eq!(response.expiry, melt_quote.expiry);
1753 assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1754 assert_eq!(response.change, None);
1755 assert_eq!(response.request, Some("custom_request_string".to_string()));
1756 assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1757 assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1758 }
1759
1760 #[test]
1761 fn test_melt_quote_to_custom_response_with_bolt11_request() {
1762 let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1763 let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1764
1765 let melt_quote = MeltQuote::new(
1766 Some(QuoteId::new()),
1767 MeltPaymentRequest::Bolt11 { bolt11 },
1768 CurrencyUnit::Sat,
1769 Amount::new(100, CurrencyUnit::Sat),
1770 Amount::new(2, CurrencyUnit::Sat),
1771 unix_time() + 3600,
1772 None,
1773 None,
1774 PaymentMethod::BOLT11,
1775 None,
1776 None,
1777 );
1778
1779 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1780
1781 assert_eq!(response.quote, melt_quote.id);
1782 assert_eq!(response.request, None);
1783 }
1784
1785 #[test]
1786 fn test_melt_quote_to_custom_response_with_bolt12_request() {
1787 use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1788 use lightning::offers::offer::OfferBuilder;
1789 let secp = Secp256k1::new();
1790 let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1791 let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1792 let offer = OfferBuilder::new(pubkey).build().unwrap();
1793
1794 let melt_quote = MeltQuote::new(
1795 Some(QuoteId::new()),
1796 MeltPaymentRequest::Bolt12 {
1797 offer: Box::new(offer),
1798 },
1799 CurrencyUnit::Sat,
1800 Amount::new(100, CurrencyUnit::Sat),
1801 Amount::new(2, CurrencyUnit::Sat),
1802 unix_time() + 3600,
1803 None,
1804 None,
1805 PaymentMethod::BOLT12,
1806 None,
1807 None,
1808 );
1809
1810 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1811
1812 assert_eq!(response.quote, melt_quote.id);
1813 assert_eq!(response.request, None);
1814 }
1815
1816 fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1817 use std::str::FromStr;
1818 MintKeySetInfo {
1819 id: Id::from_str("009a1f293253e41e").unwrap(),
1820 unit: CurrencyUnit::Sat,
1821 active: true,
1822 valid_from: 0,
1823 derivation_path: "m/0'/0'/0'".parse().unwrap(),
1824 derivation_path_index: Some(0),
1825 amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1826 input_fee_ppk: 0,
1827 final_expiry,
1828 issuer_version: None,
1829 }
1830 }
1831
1832 #[test]
1833 fn test_is_expired_none() {
1834 let info = dummy_mint_keyset_info(None);
1835 assert!(!info.is_expired());
1836 }
1837
1838 #[test]
1839 fn test_is_expired_far_future() {
1840 let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1841 assert!(!info.is_expired());
1842 }
1843
1844 #[test]
1845 fn test_is_expired_exactly_now_is_not_expired() {
1846 let info = dummy_mint_keyset_info(Some(unix_time()));
1848 assert!(!info.is_expired());
1849 }
1850
1851 #[test]
1852 fn test_is_expired_one_second_ago() {
1853 let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1854 assert!(info.is_expired());
1855 }
1856
1857 #[test]
1858 fn test_is_expired_zero() {
1859 let info = dummy_mint_keyset_info(Some(0));
1860 assert!(info.is_expired());
1861 }
1862
1863 #[test]
1864 fn test_melt_quote_into_response_onchain() {
1865 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1866 let mut melt_quote = MeltQuote::new(
1867 Some(QuoteId::new()),
1868 MeltPaymentRequest::Onchain {
1869 address: address.to_string(),
1870 },
1871 CurrencyUnit::Sat,
1872 Amount::new(5_000, CurrencyUnit::Sat),
1873 Amount::new(250, CurrencyUnit::Sat),
1874 unix_time() + 3600,
1875 None,
1876 None,
1877 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1878 None,
1879 Some(6),
1880 );
1881
1882 melt_quote.payment_proof =
1884 Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1885 melt_quote.state = MeltQuoteState::Paid;
1886
1887 let expected_id = melt_quote.id.clone();
1888 let expected_amount: Amount = melt_quote.amount().into();
1889 let expected_fee_options = melt_quote.fee_options().to_vec();
1890 let expected_expiry = melt_quote.expiry;
1891 let expected_state = melt_quote.state;
1892 let expected_outpoint = melt_quote.payment_proof.clone();
1893
1894 let response = melt_quote.into_response(None);
1895 match response {
1896 crate::MeltQuoteResponse::Onchain(r) => {
1897 assert_eq!(r.quote, expected_id);
1898 assert_eq!(r.request, address);
1899 assert_eq!(r.amount, expected_amount);
1900 assert_eq!(r.unit, CurrencyUnit::Sat);
1901 assert_eq!(r.fee_options, expected_fee_options);
1902 assert_eq!(r.selected_fee_index, None);
1903 assert_eq!(r.state, expected_state);
1904 assert_eq!(r.expiry, expected_expiry);
1905 assert_eq!(r.outpoint, expected_outpoint);
1906 assert_eq!(r.change, None);
1907 }
1908 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1909 }
1910 }
1911
1912 #[test]
1913 fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1914 let pubkey = PublicKey::from_hex(
1915 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1916 )
1917 .unwrap();
1918 let quote_id = QuoteId::new();
1919 let now = unix_time();
1920 let mint_quote = MintQuote::new(
1921 Some(quote_id.clone()),
1922 "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1923 CurrencyUnit::Sat,
1924 None,
1925 0,
1926 PaymentIdentifier::QuoteId(quote_id.clone()),
1927 Some(pubkey),
1928 Amount::new(10_000, CurrencyUnit::Sat),
1929 Amount::new(1_000, CurrencyUnit::Sat),
1930 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1931 now,
1932 now,
1933 vec![],
1934 vec![],
1935 None,
1936 );
1937
1938 let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1939
1940 assert_eq!(response.quote, quote_id);
1941 assert_eq!(response.expiry, None);
1942 assert_eq!(response.pubkey, pubkey);
1943 assert_eq!(response.amount_paid, Amount::from(10_000));
1944 assert_eq!(response.amount_issued, Amount::from(1_000));
1945 }
1946
1947 fn dummy_bolt12_mint_quote(expiry: u64) -> (MintQuote, QuoteId, PublicKey) {
1948 let pubkey = PublicKey::from_hex(
1949 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1950 )
1951 .expect("test pubkey must parse");
1952 let quote_id = QuoteId::new();
1953 let now = unix_time();
1954 let mint_quote = MintQuote::new(
1955 Some(quote_id.clone()),
1956 "lno1testoffer".to_string(),
1957 CurrencyUnit::Sat,
1958 Some(Amount::new(10_000, CurrencyUnit::Sat)),
1959 expiry,
1960 PaymentIdentifier::QuoteId(quote_id.clone()),
1961 Some(pubkey),
1962 Amount::new(10_000, CurrencyUnit::Sat),
1963 Amount::new(1_000, CurrencyUnit::Sat),
1964 PaymentMethod::BOLT12,
1965 now,
1966 now,
1967 vec![],
1968 vec![],
1969 None,
1970 );
1971
1972 (mint_quote, quote_id, pubkey)
1973 }
1974
1975 #[test]
1976 fn test_mint_quote_bolt12_response_converts_zero_expiry_to_none() {
1977 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1978
1979 let response: MintQuoteBolt12Response<QuoteId> =
1980 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1981
1982 assert_eq!(response.quote, quote_id);
1983 assert_eq!(response.expiry, None);
1984 assert_eq!(response.pubkey, pubkey);
1985 assert_eq!(response.amount_paid, Amount::from(10_000));
1986 assert_eq!(response.amount_issued, Amount::from(1_000));
1987 }
1988
1989 #[test]
1990 fn test_mint_quote_bolt12_response_preserves_nonzero_expiry() {
1991 let expiry = unix_time() + 3600;
1992 let (mint_quote, quote_id, _) = dummy_bolt12_mint_quote(expiry);
1993
1994 let response: MintQuoteBolt12Response<QuoteId> =
1995 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1996
1997 assert_eq!(response.quote, quote_id);
1998 assert_eq!(response.expiry, Some(expiry));
1999 }
2000
2001 #[test]
2002 fn test_mint_quote_response_bolt12_converts_zero_expiry_to_none() {
2003 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
2004
2005 let response = MintQuoteResponse::try_from(mint_quote).unwrap();
2006
2007 match response {
2008 MintQuoteResponse::Bolt12(response) => {
2009 assert_eq!(response.quote, quote_id);
2010 assert_eq!(response.expiry, None);
2011 assert_eq!(response.pubkey, pubkey);
2012 }
2013 _ => panic!("expected MintQuoteResponse::Bolt12 variant"),
2014 }
2015 }
2016
2017 #[test]
2018 fn test_melt_quote_into_response_onchain_includes_change() {
2019 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
2020 let melt_quote = MeltQuote::new(
2021 Some(QuoteId::new()),
2022 MeltPaymentRequest::Onchain {
2023 address: address.to_string(),
2024 },
2025 CurrencyUnit::Sat,
2026 Amount::new(1_000, CurrencyUnit::Sat),
2027 Amount::new(10, CurrencyUnit::Sat),
2028 unix_time() + 3600,
2029 None,
2030 None,
2031 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2032 None,
2033 Some(3),
2034 );
2035
2036 let response = melt_quote.into_response(Some(vec![]));
2037 match response {
2038 crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
2039 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
2040 }
2041 }
2042
2043 #[test]
2044 fn validate_onchain_fee_options_rejects_empty() {
2045 let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
2046 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2047 }
2048
2049 #[test]
2050 fn validate_onchain_fee_options_allows_duplicate_fee_index() {
2051 let options = [
2052 MeltQuoteOnchainFeeOption {
2053 fee_index: 10,
2054 fee_reserve: Amount::from(10),
2055 estimated_blocks: 3,
2056 },
2057 MeltQuoteOnchainFeeOption {
2058 fee_index: 10,
2059 fee_reserve: Amount::from(20),
2060 estimated_blocks: 6,
2061 },
2062 ];
2063 validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
2064 }
2065
2066 #[test]
2067 fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
2068 let options = [
2071 MeltQuoteOnchainFeeOption {
2072 fee_index: 20,
2073 fee_reserve: Amount::from(10),
2074 estimated_blocks: 3,
2075 },
2076 MeltQuoteOnchainFeeOption {
2077 fee_index: 1,
2078 fee_reserve: Amount::from(20),
2079 estimated_blocks: 3,
2080 },
2081 ];
2082 validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
2083 }
2084
2085 #[test]
2086 fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
2087 let options = [
2090 MeltQuoteOnchainFeeOption {
2091 fee_index: 0,
2092 fee_reserve: Amount::from(42),
2093 estimated_blocks: 1,
2094 },
2095 MeltQuoteOnchainFeeOption {
2096 fee_index: 1,
2097 fee_reserve: Amount::from(42),
2098 estimated_blocks: 6,
2099 },
2100 ];
2101 validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
2102 }
2103
2104 #[test]
2105 fn validate_onchain_fee_options_accepts_well_formed() {
2106 let options = [
2107 MeltQuoteOnchainFeeOption {
2108 fee_index: 0,
2109 fee_reserve: Amount::from(500),
2110 estimated_blocks: 1,
2111 },
2112 MeltQuoteOnchainFeeOption {
2113 fee_index: 1,
2114 fee_reserve: Amount::from(200),
2115 estimated_blocks: 6,
2116 },
2117 MeltQuoteOnchainFeeOption {
2118 fee_index: 2,
2119 fee_reserve: Amount::from(50),
2120 estimated_blocks: 144,
2121 },
2122 ];
2123 validate_onchain_fee_options(&options).expect("well-formed must validate");
2124 }
2125
2126 #[test]
2127 fn new_onchain_rejects_empty_fee_options() {
2128 let err = MeltQuote::new_onchain(
2129 None,
2130 MeltPaymentRequest::Onchain {
2131 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2132 },
2133 CurrencyUnit::Sat,
2134 Amount::new(1_000, CurrencyUnit::Sat),
2135 unix_time() + 3600,
2136 None,
2137 None,
2138 vec![],
2139 )
2140 .expect_err("empty fee_options must be rejected");
2141 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2142 }
2143
2144 #[test]
2145 fn new_onchain_initializes_reserve_to_cheapest_tier() {
2146 let options = vec![
2149 MeltQuoteOnchainFeeOption {
2150 fee_index: 10,
2151 fee_reserve: Amount::from(500),
2152 estimated_blocks: 1,
2153 },
2154 MeltQuoteOnchainFeeOption {
2155 fee_index: 30,
2156 fee_reserve: Amount::from(50),
2157 estimated_blocks: 144,
2158 },
2159 MeltQuoteOnchainFeeOption {
2160 fee_index: 20,
2161 fee_reserve: Amount::from(200),
2162 estimated_blocks: 6,
2163 },
2164 ];
2165 let quote = MeltQuote::new_onchain(
2166 None,
2167 MeltPaymentRequest::Onchain {
2168 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2169 },
2170 CurrencyUnit::Sat,
2171 Amount::new(10_000, CurrencyUnit::Sat),
2172 unix_time() + 3600,
2173 None,
2174 None,
2175 options.clone(),
2176 )
2177 .expect("well-formed quote must construct");
2178
2179 assert_eq!(quote.fee_reserve().value(), 50);
2180 assert_eq!(quote.estimated_blocks, Some(144));
2181 assert_eq!(quote.selected_fee_index, None);
2182 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2183 assert_eq!(returned, vec![10, 30, 20]);
2184 }
2185
2186 #[test]
2187 fn new_onchain_preserves_duplicate_backend_fee_index() {
2188 let options = vec![
2189 MeltQuoteOnchainFeeOption {
2190 fee_index: 7,
2191 fee_reserve: Amount::from(500),
2192 estimated_blocks: 1,
2193 },
2194 MeltQuoteOnchainFeeOption {
2195 fee_index: 7,
2196 fee_reserve: Amount::from(200),
2197 estimated_blocks: 6,
2198 },
2199 ];
2200 let quote = MeltQuote::new_onchain(
2201 None,
2202 MeltPaymentRequest::Onchain {
2203 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2204 },
2205 CurrencyUnit::Sat,
2206 Amount::new(10_000, CurrencyUnit::Sat),
2207 unix_time() + 3600,
2208 None,
2209 None,
2210 options,
2211 )
2212 .expect("duplicate backend fee_index must be preserved");
2213
2214 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2215 assert_eq!(returned, vec![7, 7]);
2216 }
2217
2218 #[test]
2219 fn select_onchain_fee_option_leaves_fee_options_untouched() {
2220 let options = vec![
2221 MeltQuoteOnchainFeeOption {
2222 fee_index: 1,
2223 fee_reserve: Amount::from(500),
2224 estimated_blocks: 1,
2225 },
2226 MeltQuoteOnchainFeeOption {
2227 fee_index: 2,
2228 fee_reserve: Amount::from(200),
2229 estimated_blocks: 6,
2230 },
2231 ];
2232 let mut quote = MeltQuote::new_onchain(
2233 None,
2234 MeltPaymentRequest::Onchain {
2235 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2236 },
2237 CurrencyUnit::Sat,
2238 Amount::new(10_000, CurrencyUnit::Sat),
2239 unix_time() + 3600,
2240 None,
2241 None,
2242 options.clone(),
2243 )
2244 .unwrap();
2245
2246 let before = quote.fee_options().to_vec();
2247 quote
2248 .select_onchain_fee_option(1)
2249 .expect("selecting a known fee_index must succeed");
2250
2251 assert_eq!(
2252 quote.fee_options(),
2253 before.as_slice(),
2254 "fee_options is fixed for the lifetime of the quote and must not \
2255 mutate on selection"
2256 );
2257 assert_eq!(quote.selected_fee_index, Some(1));
2258 assert_eq!(quote.estimated_blocks, Some(1));
2259 assert_eq!(quote.fee_reserve().value(), 500);
2260 }
2261
2262 #[test]
2263 fn select_onchain_fee_option_unknown_index_rejected() {
2264 let options = vec![MeltQuoteOnchainFeeOption {
2265 fee_index: 0,
2266 fee_reserve: Amount::from(500),
2267 estimated_blocks: 1,
2268 }];
2269 let mut quote = MeltQuote::new_onchain(
2270 None,
2271 MeltPaymentRequest::Onchain {
2272 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2273 },
2274 CurrencyUnit::Sat,
2275 Amount::new(10_000, CurrencyUnit::Sat),
2276 unix_time() + 3600,
2277 None,
2278 None,
2279 options,
2280 )
2281 .unwrap();
2282
2283 match quote
2284 .select_onchain_fee_option(7)
2285 .expect_err("unknown fee_index must be rejected")
2286 {
2287 crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2288 other => panic!("unexpected error: {other:?}"),
2289 }
2290 }
2291
2292 #[test]
2293 fn from_db_preserves_duplicate_onchain_fee_options() {
2294 let options = vec![
2295 MeltQuoteOnchainFeeOption {
2296 fee_index: 0,
2297 fee_reserve: Amount::from(100),
2298 estimated_blocks: 6,
2299 },
2300 MeltQuoteOnchainFeeOption {
2301 fee_index: 0,
2302 fee_reserve: Amount::from(200),
2303 estimated_blocks: 6,
2304 },
2305 ];
2306 let quote = MeltQuote::from_db(
2307 QuoteId::new(),
2308 CurrencyUnit::Sat,
2309 MeltPaymentRequest::Onchain {
2310 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2311 },
2312 10_000,
2313 100,
2314 MeltQuoteState::Unpaid,
2315 unix_time() + 3600,
2316 None,
2317 None,
2318 None,
2319 unix_time(),
2320 None,
2321 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2322 None,
2323 None,
2324 options,
2325 None,
2326 )
2327 .expect("duplicate onchain fee_options on reload must be preserved");
2328
2329 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2330 assert_eq!(returned, vec![0, 0]);
2331 }
2332
2333 #[test]
2334 fn test_custom_mint_quote_response_surfaces_extra_json() {
2335 let extra = serde_json::json!({"payment_url": "https://example.com/pay", "ref": 42});
2336 let now = unix_time();
2337 let quote = MintQuote::new(
2338 Some(QuoteId::new()),
2339 "custom://request".to_string(),
2340 CurrencyUnit::Sat,
2341 Some(Amount::new(500, CurrencyUnit::Sat)),
2342 unix_time() + 3600,
2343 PaymentIdentifier::Label("test".to_string()),
2344 None,
2345 Amount::new(0, CurrencyUnit::Sat),
2346 Amount::new(0, CurrencyUnit::Sat),
2347 PaymentMethod::Custom("custom".to_string()),
2348 now,
2349 now,
2350 Vec::new(),
2351 Vec::new(),
2352 Some(extra.clone()),
2353 );
2354
2355 let response: MintQuoteResponse<QuoteId> = quote.try_into().expect("conversion succeeds");
2356 match response {
2357 MintQuoteResponse::Custom { response, .. } => {
2358 assert_eq!(response.extra, extra);
2359 }
2360 other => panic!("expected Custom variant, got {:?}", other),
2361 }
2362 }
2363
2364 #[test]
2365 fn from_db_rejects_empty_onchain_fee_options() {
2366 let err = MeltQuote::from_db(
2367 QuoteId::new(),
2368 CurrencyUnit::Sat,
2369 MeltPaymentRequest::Onchain {
2370 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2371 },
2372 10_000,
2373 100,
2374 MeltQuoteState::Unpaid,
2375 unix_time() + 3600,
2376 None,
2377 None,
2378 None,
2379 unix_time(),
2380 None,
2381 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2382 None,
2383 Some(6),
2384 Vec::new(),
2385 None,
2386 )
2387 .expect_err("empty onchain fee_options on reload must be rejected");
2388 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2389 }
2390}