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