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 pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
655 self.amount_paid.clone()
656 }
657
658 #[instrument(skip(self))]
681 pub fn add_issuance(
682 &mut self,
683 additional_amount: Amount<CurrencyUnit>,
684 ) -> Result<Amount<CurrencyUnit>, crate::Error> {
685 let new_amount_issued = self
686 .amount_issued
687 .checked_add(&additional_amount)
688 .map_err(|_| crate::Error::AmountOverflow)?;
689
690 if new_amount_issued > self.amount_paid {
692 return Err(crate::Error::OverIssue);
693 }
694
695 self.changes
696 .get_or_insert_default()
697 .issuances
698 .get_or_insert_default()
699 .push(additional_amount.into());
700
701 self.amount_issued = new_amount_issued;
702
703 Ok(self.amount_issued.clone())
704 }
705
706 pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
708 self.amount_issued.clone()
709 }
710
711 pub fn updated_at(&self) -> u64 {
713 self.updated_at
714 }
715
716 pub fn set_updated_at(&mut self, updated_at: u64) {
718 self.updated_at = updated_at;
719 }
720
721 pub fn last_checked(&self) -> u64 {
723 self.last_checked
724 }
725
726 pub fn set_last_checked(&mut self, last_checked: u64) {
728 self.last_checked = last_checked;
729 }
730
731 pub fn state(&self) -> MintQuoteState {
733 self.compute_quote_state()
734 }
735
736 pub fn payment_ids(&self) -> Vec<&String> {
738 self.payments.iter().map(|a| &a.payment_id).collect()
739 }
740
741 pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
748 self.amount_paid
749 .checked_sub(&self.amount_issued)
750 .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
751 }
752
753 pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
763 self.changes.take()
764 }
765
766 #[instrument(skip(self))]
786 pub fn add_payment(
787 &mut self,
788 amount: Amount<CurrencyUnit>,
789 payment_id: String,
790 time: Option<u64>,
791 ) -> Result<(), crate::Error> {
792 let time = time.unwrap_or_else(unix_time);
793
794 let payment_ids = self.payment_ids();
795 if payment_ids.contains(&&payment_id) {
796 return Err(crate::Error::DuplicatePaymentId);
797 }
798
799 self.amount_paid = self
800 .amount_paid
801 .checked_add(&amount)
802 .map_err(|_| crate::Error::AmountOverflow)?;
803
804 let payment = IncomingPayment::new(amount, payment_id, time);
805
806 self.payments.push(payment.clone());
807
808 self.changes
809 .get_or_insert_default()
810 .payments
811 .get_or_insert_default()
812 .push(payment);
813
814 Ok(())
815 }
816
817 fn compute_quote_state(&self) -> MintQuoteState {
819 let zero_amount = Amount::new(0, self.unit.clone());
820
821 if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
822 return MintQuoteState::Unpaid;
823 }
824
825 match self.amount_paid.value().cmp(&self.amount_issued.value()) {
826 std::cmp::Ordering::Less => {
827 tracing::error!("We should not have issued more then has been paid");
828 MintQuoteState::Issued
829 }
830 std::cmp::Ordering::Equal => MintQuoteState::Issued,
831 std::cmp::Ordering::Greater => MintQuoteState::Paid,
832 }
833 }
834}
835
836#[derive(Debug, Clone, Hash, PartialEq, Eq)]
838pub struct IncomingPayment {
839 pub amount: Amount<CurrencyUnit>,
841 pub time: u64,
843 pub payment_id: String,
845}
846
847impl IncomingPayment {
848 pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
850 Self {
851 payment_id,
852 time,
853 amount,
854 }
855 }
856}
857
858#[derive(Debug, Clone, Hash, PartialEq, Eq)]
860pub struct Issuance {
861 pub amount: Amount<CurrencyUnit>,
863 pub time: u64,
865}
866
867impl Issuance {
868 pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
870 Self { amount, time }
871 }
872}
873
874#[derive(Clone, Hash, PartialEq, Eq)]
876pub struct MeltQuote {
877 pub id: QuoteId,
879 pub unit: CurrencyUnit,
881 pub request: MeltPaymentRequest,
883 amount: Amount<CurrencyUnit>,
885 fee_reserve: Amount<CurrencyUnit>,
887 pub state: MeltQuoteState,
889 pub expiry: u64,
891 pub payment_proof: Option<String>,
893 pub request_lookup_id: Option<PaymentIdentifier>,
895 pub options: Option<MeltOptions>,
899 pub created_time: u64,
901 pub paid_time: Option<u64>,
903 pub payment_method: PaymentMethod,
905 pub extra_json: Option<serde_json::Value>,
907 pub estimated_blocks: Option<u32>,
909 fee_options: Vec<MeltQuoteOnchainFeeOption>,
919 pub selected_fee_index: Option<u32>,
921}
922
923impl fmt::Debug for MeltQuote {
924 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
925 f.debug_struct("MeltQuote")
926 .field("id", &self.id)
927 .field("unit", &self.unit)
928 .field("request", &self.request)
929 .field("amount", &self.amount)
930 .field("fee_reserve", &self.fee_reserve)
931 .field("state", &self.state)
932 .field("expiry", &self.expiry)
933 .field(
934 "payment_proof",
935 &self.payment_proof.as_ref().map(|_| "[REDACTED]"),
936 )
937 .field("request_lookup_id", &self.request_lookup_id)
938 .field("options", &self.options)
939 .field("created_time", &self.created_time)
940 .field("paid_time", &self.paid_time)
941 .field("payment_method", &self.payment_method)
942 .field("extra_json", &self.extra_json)
943 .field("estimated_blocks", &self.estimated_blocks)
944 .field("fee_options", &self.fee_options)
945 .field("selected_fee_index", &self.selected_fee_index)
946 .finish()
947 }
948}
949
950impl MeltQuote {
951 #[allow(clippy::too_many_arguments)]
953 pub fn new(
954 id: Option<QuoteId>,
955 request: MeltPaymentRequest,
956 unit: CurrencyUnit,
957 amount: Amount<CurrencyUnit>,
958 fee_reserve: Amount<CurrencyUnit>,
959 expiry: u64,
960 request_lookup_id: Option<PaymentIdentifier>,
961 options: Option<MeltOptions>,
962 payment_method: PaymentMethod,
963 extra_json: Option<serde_json::Value>,
964 estimated_blocks: Option<u32>,
965 ) -> Self {
966 let id = id.unwrap_or_default();
967
968 let fee_options = estimated_blocks
969 .map(|estimated_blocks| {
970 vec![MeltQuoteOnchainFeeOption {
971 fee_index: 0,
972 fee_reserve: fee_reserve.clone().into(),
973 estimated_blocks,
974 }]
975 })
976 .unwrap_or_default();
977
978 Self {
979 id,
980 unit: unit.clone(),
981 request,
982 amount,
983 fee_reserve,
984 state: MeltQuoteState::Unpaid,
985 expiry,
986 payment_proof: None,
987 request_lookup_id,
988 options,
989 created_time: unix_time(),
990 paid_time: None,
991 payment_method,
992 extra_json,
993 estimated_blocks,
994 fee_options,
995 selected_fee_index: None,
996 }
997 }
998
999 #[allow(clippy::too_many_arguments)]
1011 pub fn new_onchain(
1012 id: Option<QuoteId>,
1013 request: MeltPaymentRequest,
1014 unit: CurrencyUnit,
1015 amount: Amount<CurrencyUnit>,
1016 expiry: u64,
1017 request_lookup_id: Option<PaymentIdentifier>,
1018 extra_json: Option<serde_json::Value>,
1019 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1020 ) -> Result<Self, crate::Error> {
1021 if fee_options.is_empty() {
1022 return Err(crate::Error::OnchainFeeOptionsEmpty);
1023 }
1024
1025 validate_onchain_fee_options(&fee_options)?;
1026
1027 let id = id.unwrap_or_default();
1028
1029 let initial = fee_options
1033 .iter()
1034 .min_by_key(|option| u64::from(option.fee_reserve))
1035 .copied()
1036 .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
1037
1038 let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
1039 let estimated_blocks = Some(initial.estimated_blocks);
1040
1041 Ok(Self {
1042 id,
1043 unit: unit.clone(),
1044 request,
1045 amount,
1046 fee_reserve,
1047 state: MeltQuoteState::Unpaid,
1048 expiry,
1049 payment_proof: None,
1050 request_lookup_id,
1051 options: None,
1052 created_time: unix_time(),
1053 paid_time: None,
1054 payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1055 extra_json,
1056 estimated_blocks,
1057 fee_options,
1058 selected_fee_index: None,
1059 })
1060 }
1061
1062 #[inline]
1068 pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1069 &self.fee_options
1070 }
1071
1072 #[inline]
1074 pub fn amount(&self) -> Amount<CurrencyUnit> {
1075 self.amount.clone()
1076 }
1077
1078 #[inline]
1080 pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1081 self.fee_reserve.clone()
1082 }
1083
1084 pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1086 let option = self
1087 .fee_options
1088 .iter()
1089 .find(|option| option.fee_index == fee_index)
1090 .copied()
1091 .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1092
1093 if self
1094 .selected_fee_index
1095 .is_some_and(|selected| selected != fee_index)
1096 {
1097 return Err(crate::Error::InvalidPaymentRequest);
1098 }
1099
1100 self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1101 self.estimated_blocks = Some(option.estimated_blocks);
1102 self.selected_fee_index = Some(fee_index);
1103
1104 Ok(())
1105 }
1106
1107 pub fn into_response(
1113 self,
1114 change: Option<Vec<cashu::nuts::BlindSignature>>,
1115 ) -> crate::MeltQuoteResponse<QuoteId> {
1116 match self.payment_method {
1117 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1118 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1119 response.change = change;
1120 crate::MeltQuoteResponse::Bolt11(response)
1121 }
1122 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1123 let mut response: MeltQuoteBolt12Response<QuoteId> = self.into();
1124 response.change = change;
1125 crate::MeltQuoteResponse::Bolt12(response)
1126 }
1127 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1128 let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1129 response.change = change;
1130 crate::MeltQuoteResponse::Onchain(response)
1131 }
1132 _ => {
1133 let method = self.payment_method.clone();
1134 let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1135 response.change = change;
1136 crate::MeltQuoteResponse::Custom((method, response))
1137 }
1138 }
1139 }
1140
1141 pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1143 let total = self
1144 .amount
1145 .checked_add(&self.fee_reserve)
1146 .map_err(|_| crate::Error::AmountOverflow)?;
1147 Ok(Amount::from(total.value()))
1148 }
1149
1150 #[allow(clippy::too_many_arguments)]
1152 pub fn from_db(
1153 id: QuoteId,
1154 unit: CurrencyUnit,
1155 request: MeltPaymentRequest,
1156 amount: u64,
1157 fee_reserve: u64,
1158 state: MeltQuoteState,
1159 expiry: u64,
1160 payment_proof: Option<String>,
1161 request_lookup_id: Option<PaymentIdentifier>,
1162 options: Option<MeltOptions>,
1163 created_time: u64,
1164 paid_time: Option<u64>,
1165 payment_method: PaymentMethod,
1166 extra_json: Option<serde_json::Value>,
1167 estimated_blocks: Option<u32>,
1168 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1169 selected_fee_index: Option<u32>,
1170 ) -> Result<Self, crate::Error> {
1171 if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1176 validate_onchain_fee_options(&fee_options)?;
1177 }
1178
1179 Ok(Self {
1180 id,
1181 unit: unit.clone(),
1182 request,
1183 amount: Amount::new(amount, unit.clone()),
1184 fee_reserve: Amount::new(fee_reserve, unit),
1185 state,
1186 expiry,
1187 payment_proof,
1188 request_lookup_id,
1189 options,
1190 created_time,
1191 paid_time,
1192 payment_method,
1193 extra_json,
1194 estimated_blocks,
1195 fee_options,
1196 selected_fee_index,
1197 })
1198 }
1199}
1200
1201pub fn validate_onchain_fee_options(
1210 fee_options: &[MeltQuoteOnchainFeeOption],
1211) -> Result<(), crate::Error> {
1212 if fee_options.is_empty() {
1213 return Err(crate::Error::OnchainFeeOptionsEmpty);
1214 }
1215
1216 Ok(())
1217}
1218
1219impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1220 fn from(quote: MeltQuote) -> Self {
1221 Self {
1222 quote: quote.id.clone(),
1223 amount: quote.amount().into(),
1224 unit: quote.unit.clone(),
1225 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1226 state: quote.state,
1227 expiry: quote.expiry,
1228 request: quote.request.to_string(),
1229 fee_options: quote.fee_options().to_vec(),
1230 selected_fee_index: quote.selected_fee_index,
1231 outpoint: quote.payment_proof.clone(),
1232 change: None,
1233 }
1234 }
1235}
1236
1237impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1238 type Error = crate::error::Error;
1239 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1240 Ok(Self {
1241 quote: quote.id.clone(),
1242 request: quote.request.clone(),
1243 unit: quote.unit.clone(),
1244 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1245 expiry: (quote.expiry != 0).then_some(quote.expiry),
1246 pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1247 amount_paid: quote.amount_paid().into(),
1248 amount_issued: quote.amount_issued().into(),
1249 updated_at: quote.updated_at(),
1250 })
1251 }
1252}
1253
1254#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1256pub struct MintKeySetInfo {
1257 pub id: Id,
1259 pub unit: CurrencyUnit,
1261 pub active: bool,
1264 pub valid_from: u64,
1266 pub derivation_path: DerivationPath,
1268 pub derivation_path_index: Option<u32>,
1270 pub amounts: Vec<u64>,
1272 #[serde(default = "default_fee")]
1274 pub input_fee_ppk: u64,
1275 pub final_expiry: Option<u64>,
1277 pub issuer_version: Option<IssuerVersion>,
1279}
1280
1281impl MintKeySetInfo {
1282 pub fn is_expired(&self) -> bool {
1284 self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1285 }
1286}
1287
1288pub fn default_fee() -> u64 {
1290 0
1291}
1292
1293impl From<MintKeySetInfo> for KeySetInfo {
1294 fn from(keyset_info: MintKeySetInfo) -> Self {
1295 Self {
1296 id: keyset_info.id,
1297 unit: keyset_info.unit,
1298 active: keyset_info.active,
1299 input_fee_ppk: keyset_info.input_fee_ppk,
1300 final_expiry: keyset_info.final_expiry,
1301 }
1302 }
1303}
1304
1305impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1306 fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1307 let amount_paid = mint_quote.amount_paid().into();
1308 let amount_issued = mint_quote.amount_issued().into();
1309 let updated_at = mint_quote.updated_at();
1310
1311 MintQuoteBolt11Response {
1312 quote: mint_quote.id.clone(),
1313 state: mint_quote.state(),
1314 request: mint_quote.request,
1315 expiry: Some(mint_quote.expiry),
1316 pubkey: mint_quote.pubkey,
1317 amount: mint_quote.amount.map(Into::into),
1318 unit: Some(mint_quote.unit),
1319 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1320 amount_paid,
1321 amount_issued,
1322 updated_at,
1323 }
1324 }
1325}
1326
1327impl From<MintQuote> for MintQuoteBolt11Response<String> {
1328 fn from(quote: MintQuote) -> Self {
1329 let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1330 quote.into()
1331 }
1332}
1333
1334impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1335 type Error = Error;
1336
1337 fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1338 let amount_paid = mint_quote.amount_paid().into();
1339 let amount_issued = mint_quote.amount_issued().into();
1340 let updated_at = mint_quote.updated_at();
1341
1342 Ok(MintQuoteBolt12Response {
1343 quote: mint_quote.id.clone(),
1344 request: mint_quote.request,
1345 expiry: (mint_quote.expiry != 0).then_some(mint_quote.expiry),
1346 amount_paid,
1347 amount_issued,
1348 pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1349 amount: mint_quote.amount.map(Into::into),
1350 unit: mint_quote.unit,
1351 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1352 updated_at,
1353 })
1354 }
1355}
1356
1357impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1358 type Error = Error;
1359
1360 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1361 let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1362 Ok(quote.into())
1363 }
1364}
1365
1366impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1367 type Error = Error;
1368
1369 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1370 let amount_paid = quote.amount_paid().into();
1371 let amount_issued = quote.amount_issued().into();
1372 let updated_at = quote.updated_at();
1373
1374 Ok(MintQuoteCustomResponse {
1375 quote: quote.id,
1376 request: quote.request,
1377 method: quote.payment_method,
1378 unit: Some(quote.unit),
1379 expiry: Some(quote.expiry),
1380 pubkey: quote.pubkey,
1381 amount: quote.amount.map(Into::into),
1382 amount_paid,
1383 amount_issued,
1384 updated_at,
1385 extra: quote.extra_json.unwrap_or_default(),
1386 })
1387 }
1388}
1389
1390impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1391 type Error = Error;
1392
1393 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1394 let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1395 Ok(quote.into())
1396 }
1397}
1398
1399impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1400 fn from(melt_quote: MeltQuote) -> Self {
1401 let method = melt_quote.payment_method.clone();
1402 let request = match melt_quote.request {
1403 MeltPaymentRequest::Custom { request, .. } => Some(request),
1404 _ => None,
1405 };
1406
1407 Self {
1408 quote: melt_quote.id,
1409 method,
1410 amount: melt_quote.amount.into(),
1411 fee_reserve: Some(melt_quote.fee_reserve.into()),
1412 state: melt_quote.state,
1413 expiry: melt_quote.expiry,
1414 payment_preimage: melt_quote.payment_proof,
1415 change: None,
1416 request,
1417 unit: Some(melt_quote.unit),
1418 extra: melt_quote.extra_json.unwrap_or_default(),
1419 }
1420 }
1421}
1422
1423impl From<&MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1424 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1425 MeltQuoteBolt12Response {
1426 quote: melt_quote.id.clone(),
1427 payment_preimage: None,
1428 change: None,
1429 state: melt_quote.state,
1430 expiry: melt_quote.expiry,
1431 amount: melt_quote.amount().into(),
1432 fee_reserve: melt_quote.fee_reserve().into(),
1433 request: None,
1434 unit: Some(melt_quote.unit.clone()),
1435 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1436 }
1437 }
1438}
1439
1440impl From<MeltQuote> for MeltQuoteBolt12Response<QuoteId> {
1441 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt12Response<QuoteId> {
1442 MeltQuoteBolt12Response {
1443 quote: melt_quote.id.clone(),
1444 amount: melt_quote.amount().into(),
1445 fee_reserve: melt_quote.fee_reserve().into(),
1446 state: melt_quote.state,
1447 expiry: melt_quote.expiry,
1448 payment_preimage: melt_quote.payment_proof,
1449 change: None,
1450 request: Some(melt_quote.request.to_string()),
1451 unit: Some(melt_quote.unit.clone()),
1452 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1453 }
1454 }
1455}
1456
1457impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1458 type Error = Error;
1459
1460 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1461 if quote.payment_method.is_bolt11() {
1462 Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1463 quote: quote.id.clone(),
1464 request: quote.request.clone(),
1465 state: quote.state(),
1466 expiry: Some(quote.expiry),
1467 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1468 unit: Some(quote.unit.clone()),
1469 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11),
1470 pubkey: quote.pubkey,
1471 amount_paid: quote.amount_paid().into(),
1472 amount_issued: quote.amount_issued().into(),
1473 updated_at: quote.updated_at(),
1474 }))
1475 } else if quote.payment_method.is_bolt12() {
1476 Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1477 quote: quote.id.clone(),
1478 request: quote.request.clone(),
1479 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1480 unit: quote.unit.clone(),
1481 method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12),
1482 expiry: (quote.expiry != 0).then_some(quote.expiry),
1483 pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1484 amount_paid: quote.amount_paid().into(),
1485 amount_issued: quote.amount_issued().into(),
1486 updated_at: quote.updated_at(),
1487 }))
1488 } else if quote.payment_method.is_onchain() {
1489 let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1490 Ok(MintQuoteResponse::Onchain(onchain_response))
1491 } else {
1492 let method = quote.payment_method.clone();
1493 Ok(MintQuoteResponse::Custom {
1494 method: method.clone(),
1495 response: crate::nuts::nut04::MintQuoteCustomResponse {
1496 quote: quote.id.clone(),
1497 request: quote.request.clone(),
1498 method: method.clone(),
1499 expiry: Some(quote.expiry),
1500 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1501 amount_paid: quote.amount_paid().into(),
1502 amount_issued: quote.amount_issued().into(),
1503 updated_at: quote.updated_at(),
1504 unit: Some(quote.unit.clone()),
1505 pubkey: quote.pubkey,
1506 extra: quote.extra_json.clone().unwrap_or_default(),
1507 },
1508 })
1509 }
1510 }
1511}
1512
1513impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1514 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1515 match response {
1516 MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1517 MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1518 MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1519 MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1520 method,
1521 response: response.into(),
1522 },
1523 }
1524 }
1525}
1526
1527impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1528 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1529 match response {
1530 MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1531 quote: bolt11_response.quote.to_string(),
1532 state: bolt11_response.state,
1533 request: bolt11_response.request,
1534 expiry: bolt11_response.expiry,
1535 pubkey: bolt11_response.pubkey,
1536 amount: bolt11_response.amount,
1537 unit: bolt11_response.unit,
1538 method: bolt11_response.method,
1539 amount_paid: bolt11_response.amount_paid,
1540 amount_issued: bolt11_response.amount_issued,
1541 updated_at: bolt11_response.updated_at,
1542 },
1543 _ => panic!("Expected Bolt11 response"),
1544 }
1545 }
1546}
1547
1548impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1549 type Error = Error;
1550
1551 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1552 match response {
1553 MintQuoteResponse::Bolt11(r) => Ok(r),
1554 _ => Err(Error::InvalidPaymentMethod),
1555 }
1556 }
1557}
1558
1559impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1560 type Error = Error;
1561
1562 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1563 match response {
1564 MintQuoteResponse::Bolt12(r) => Ok(r),
1565 _ => Err(Error::InvalidPaymentMethod),
1566 }
1567 }
1568}
1569
1570impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1571 type Error = Error;
1572
1573 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1574 match response {
1575 MintQuoteResponse::Onchain(r) => Ok(r),
1576 _ => Err(Error::InvalidPaymentMethod),
1577 }
1578 }
1579}
1580
1581impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1582 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1583 MeltQuoteBolt11Response {
1584 quote: melt_quote.id.clone(),
1585 payment_preimage: None,
1586 change: None,
1587 state: melt_quote.state,
1588 expiry: melt_quote.expiry,
1589 amount: melt_quote.amount().into(),
1590 fee_reserve: melt_quote.fee_reserve().into(),
1591 request: None,
1592 unit: Some(melt_quote.unit.clone()),
1593 method: melt_quote.payment_method.clone(),
1594 }
1595 }
1596}
1597
1598impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1599 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1600 MeltQuoteBolt11Response {
1601 quote: melt_quote.id.clone(),
1602 amount: melt_quote.amount().into(),
1603 fee_reserve: melt_quote.fee_reserve().into(),
1604 state: melt_quote.state,
1605 expiry: melt_quote.expiry,
1606 payment_preimage: melt_quote.payment_proof,
1607 change: None,
1608 request: Some(melt_quote.request.to_string()),
1609 unit: Some(melt_quote.unit.clone()),
1610 method: melt_quote.payment_method.clone(),
1611 }
1612 }
1613}
1614
1615#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1617pub enum MeltPaymentRequest {
1618 Bolt11 {
1620 bolt11: Bolt11Invoice,
1622 },
1623 Bolt12 {
1625 #[serde(with = "offer_serde")]
1627 offer: Box<Offer>,
1628 },
1629 Custom {
1631 method: String,
1633 request: String,
1635 },
1636 Onchain {
1638 address: String,
1640 },
1641}
1642
1643impl std::fmt::Display for MeltPaymentRequest {
1644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1645 match self {
1646 MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1647 MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1648 MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1649 MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1650 }
1651 }
1652}
1653
1654mod offer_serde {
1655 use std::str::FromStr;
1656
1657 use serde::{self, Deserialize, Deserializer, Serializer};
1658
1659 use super::Offer;
1660
1661 pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1662 where
1663 S: Serializer,
1664 {
1665 let s = offer.to_string();
1666 serializer.serialize_str(&s)
1667 }
1668
1669 pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1670 where
1671 D: Deserializer<'de>,
1672 {
1673 let s = String::deserialize(deserializer)?;
1674 Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1675 serde::de::Error::custom("Invalid Bolt12 Offer")
1676 })?))
1677 }
1678}
1679
1680#[cfg(test)]
1681mod tests {
1682 use std::str::FromStr;
1683
1684 use cashu::Bolt11Invoice;
1685
1686 use super::*;
1687
1688 #[test]
1689 fn test_operation_new_mint_uses_uuid_v7() {
1690 let operation = Operation::new_mint(Amount::from(100), PaymentMethod::BOLT11);
1691
1692 assert_eq!(operation.id.get_version(), Some(uuid::Version::SortRand));
1693 }
1694
1695 #[test]
1696 fn mint_payment_records_debug_redact_payment_proofs() {
1697 let secret = "mint-payment-preimage-secret";
1698 let lookup_id = PaymentIdentifier::CustomId("public-lookup-id".to_string());
1699 let mut quote = MeltQuote::new(
1700 Some(QuoteId::new()),
1701 MeltPaymentRequest::Custom {
1702 method: "custom".to_string(),
1703 request: "public-payment-request".to_string(),
1704 },
1705 CurrencyUnit::Sat,
1706 Amount::new(100, CurrencyUnit::Sat),
1707 Amount::new(2, CurrencyUnit::Sat),
1708 unix_time() + 3_600,
1709 Some(lookup_id.clone()),
1710 None,
1711 PaymentMethod::Custom("custom".to_string()),
1712 None,
1713 None,
1714 );
1715 quote.payment_proof = Some(secret.to_string());
1716 let finalization = MeltFinalizationData {
1717 total_spent: Amount::new(102, CurrencyUnit::Sat),
1718 payment_lookup_id: lookup_id,
1719 payment_proof: Some(secret.to_string()),
1720 };
1721
1722 for debug in [format!("{quote:?}"), format!("{finalization:?}")] {
1723 assert!(debug.contains("public-lookup-id"));
1724 assert!(debug.contains("[REDACTED]"));
1725 assert!(!debug.contains(secret));
1726 }
1727 }
1728
1729 #[test]
1730 fn test_melt_quote_to_custom_response_with_custom_request() {
1731 let melt_quote = MeltQuote::new(
1732 Some(QuoteId::new()),
1733 MeltPaymentRequest::Custom {
1734 method: "custom".to_string(),
1735 request: "custom_request_string".to_string(),
1736 },
1737 CurrencyUnit::Sat,
1738 Amount::new(100, CurrencyUnit::Sat),
1739 Amount::new(2, CurrencyUnit::Sat),
1740 unix_time() + 3600,
1741 None,
1742 None,
1743 PaymentMethod::Custom("custom".to_string()),
1744 Some(serde_json::json!({"extra_field": "value"})),
1745 None,
1746 );
1747
1748 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1749
1750 assert_eq!(response.quote, melt_quote.id);
1751 assert_eq!(response.amount, 100.into());
1752 assert_eq!(response.fee_reserve, Some(2.into()));
1753 assert_eq!(response.state, melt_quote.state);
1754 assert_eq!(response.expiry, melt_quote.expiry);
1755 assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1756 assert_eq!(response.change, None);
1757 assert_eq!(response.request, Some("custom_request_string".to_string()));
1758 assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1759 assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1760 }
1761
1762 #[test]
1763 fn test_melt_quote_to_custom_response_with_bolt11_request() {
1764 let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1765 let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1766
1767 let melt_quote = MeltQuote::new(
1768 Some(QuoteId::new()),
1769 MeltPaymentRequest::Bolt11 { bolt11 },
1770 CurrencyUnit::Sat,
1771 Amount::new(100, CurrencyUnit::Sat),
1772 Amount::new(2, CurrencyUnit::Sat),
1773 unix_time() + 3600,
1774 None,
1775 None,
1776 PaymentMethod::BOLT11,
1777 None,
1778 None,
1779 );
1780
1781 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1782
1783 assert_eq!(response.quote, melt_quote.id);
1784 assert_eq!(response.request, None);
1785 }
1786
1787 #[test]
1788 fn test_melt_quote_to_custom_response_with_bolt12_request() {
1789 use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1790 use lightning::offers::offer::OfferBuilder;
1791 let secp = Secp256k1::new();
1792 let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1793 let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1794 let offer = OfferBuilder::new(pubkey).build().unwrap();
1795
1796 let melt_quote = MeltQuote::new(
1797 Some(QuoteId::new()),
1798 MeltPaymentRequest::Bolt12 {
1799 offer: Box::new(offer),
1800 },
1801 CurrencyUnit::Sat,
1802 Amount::new(100, CurrencyUnit::Sat),
1803 Amount::new(2, CurrencyUnit::Sat),
1804 unix_time() + 3600,
1805 None,
1806 None,
1807 PaymentMethod::BOLT12,
1808 None,
1809 None,
1810 );
1811
1812 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1813
1814 assert_eq!(response.quote, melt_quote.id);
1815 assert_eq!(response.request, None);
1816 }
1817
1818 fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1819 use std::str::FromStr;
1820 MintKeySetInfo {
1821 id: Id::from_str("009a1f293253e41e").unwrap(),
1822 unit: CurrencyUnit::Sat,
1823 active: true,
1824 valid_from: 0,
1825 derivation_path: "m/0'/0'/0'".parse().unwrap(),
1826 derivation_path_index: Some(0),
1827 amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1828 input_fee_ppk: 0,
1829 final_expiry,
1830 issuer_version: None,
1831 }
1832 }
1833
1834 #[test]
1835 fn test_is_expired_none() {
1836 let info = dummy_mint_keyset_info(None);
1837 assert!(!info.is_expired());
1838 }
1839
1840 #[test]
1841 fn test_is_expired_far_future() {
1842 let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1843 assert!(!info.is_expired());
1844 }
1845
1846 #[test]
1847 fn test_is_expired_exactly_now_is_not_expired() {
1848 let info = dummy_mint_keyset_info(Some(unix_time()));
1850 assert!(!info.is_expired());
1851 }
1852
1853 #[test]
1854 fn test_is_expired_one_second_ago() {
1855 let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1856 assert!(info.is_expired());
1857 }
1858
1859 #[test]
1860 fn test_is_expired_zero() {
1861 let info = dummy_mint_keyset_info(Some(0));
1862 assert!(info.is_expired());
1863 }
1864
1865 #[test]
1866 fn test_melt_quote_into_response_onchain() {
1867 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1868 let mut melt_quote = MeltQuote::new(
1869 Some(QuoteId::new()),
1870 MeltPaymentRequest::Onchain {
1871 address: address.to_string(),
1872 },
1873 CurrencyUnit::Sat,
1874 Amount::new(5_000, CurrencyUnit::Sat),
1875 Amount::new(250, CurrencyUnit::Sat),
1876 unix_time() + 3600,
1877 None,
1878 None,
1879 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1880 None,
1881 Some(6),
1882 );
1883
1884 melt_quote.payment_proof =
1886 Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1887 melt_quote.state = MeltQuoteState::Paid;
1888
1889 let expected_id = melt_quote.id.clone();
1890 let expected_amount: Amount = melt_quote.amount().into();
1891 let expected_fee_options = melt_quote.fee_options().to_vec();
1892 let expected_expiry = melt_quote.expiry;
1893 let expected_state = melt_quote.state;
1894 let expected_outpoint = melt_quote.payment_proof.clone();
1895
1896 let response = melt_quote.into_response(None);
1897 match response {
1898 crate::MeltQuoteResponse::Onchain(r) => {
1899 assert_eq!(r.quote, expected_id);
1900 assert_eq!(r.request, address);
1901 assert_eq!(r.amount, expected_amount);
1902 assert_eq!(r.unit, CurrencyUnit::Sat);
1903 assert_eq!(r.fee_options, expected_fee_options);
1904 assert_eq!(r.selected_fee_index, None);
1905 assert_eq!(r.state, expected_state);
1906 assert_eq!(r.expiry, expected_expiry);
1907 assert_eq!(r.outpoint, expected_outpoint);
1908 assert_eq!(r.change, None);
1909 }
1910 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1911 }
1912 }
1913
1914 #[test]
1915 fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1916 let pubkey = PublicKey::from_hex(
1917 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1918 )
1919 .unwrap();
1920 let quote_id = QuoteId::new();
1921 let now = unix_time();
1922 let mint_quote = MintQuote::new(
1923 Some(quote_id.clone()),
1924 "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1925 CurrencyUnit::Sat,
1926 None,
1927 0,
1928 PaymentIdentifier::QuoteId(quote_id.clone()),
1929 Some(pubkey),
1930 Amount::new(10_000, CurrencyUnit::Sat),
1931 Amount::new(1_000, CurrencyUnit::Sat),
1932 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1933 now,
1934 now,
1935 vec![],
1936 vec![],
1937 None,
1938 );
1939
1940 let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1941
1942 assert_eq!(response.quote, quote_id);
1943 assert_eq!(response.expiry, None);
1944 assert_eq!(response.pubkey, pubkey);
1945 assert_eq!(response.amount_paid, Amount::from(10_000));
1946 assert_eq!(response.amount_issued, Amount::from(1_000));
1947 }
1948
1949 fn dummy_bolt12_mint_quote(expiry: u64) -> (MintQuote, QuoteId, PublicKey) {
1950 let pubkey = PublicKey::from_hex(
1951 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1952 )
1953 .expect("test pubkey must parse");
1954 let quote_id = QuoteId::new();
1955 let now = unix_time();
1956 let mint_quote = MintQuote::new(
1957 Some(quote_id.clone()),
1958 "lno1testoffer".to_string(),
1959 CurrencyUnit::Sat,
1960 Some(Amount::new(10_000, CurrencyUnit::Sat)),
1961 expiry,
1962 PaymentIdentifier::QuoteId(quote_id.clone()),
1963 Some(pubkey),
1964 Amount::new(10_000, CurrencyUnit::Sat),
1965 Amount::new(1_000, CurrencyUnit::Sat),
1966 PaymentMethod::BOLT12,
1967 now,
1968 now,
1969 vec![],
1970 vec![],
1971 None,
1972 );
1973
1974 (mint_quote, quote_id, pubkey)
1975 }
1976
1977 #[test]
1978 fn test_mint_quote_bolt12_response_converts_zero_expiry_to_none() {
1979 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1980
1981 let response: MintQuoteBolt12Response<QuoteId> =
1982 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1983
1984 assert_eq!(response.quote, quote_id);
1985 assert_eq!(response.expiry, None);
1986 assert_eq!(response.pubkey, pubkey);
1987 assert_eq!(response.amount_paid, Amount::from(10_000));
1988 assert_eq!(response.amount_issued, Amount::from(1_000));
1989 }
1990
1991 #[test]
1992 fn test_mint_quote_bolt12_response_preserves_nonzero_expiry() {
1993 let expiry = unix_time() + 3600;
1994 let (mint_quote, quote_id, _) = dummy_bolt12_mint_quote(expiry);
1995
1996 let response: MintQuoteBolt12Response<QuoteId> =
1997 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1998
1999 assert_eq!(response.quote, quote_id);
2000 assert_eq!(response.expiry, Some(expiry));
2001 }
2002
2003 #[test]
2004 fn test_mint_quote_response_bolt12_converts_zero_expiry_to_none() {
2005 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
2006
2007 let response = MintQuoteResponse::try_from(mint_quote).unwrap();
2008
2009 match response {
2010 MintQuoteResponse::Bolt12(response) => {
2011 assert_eq!(response.quote, quote_id);
2012 assert_eq!(response.expiry, None);
2013 assert_eq!(response.pubkey, pubkey);
2014 }
2015 _ => panic!("expected MintQuoteResponse::Bolt12 variant"),
2016 }
2017 }
2018
2019 #[test]
2020 fn test_melt_quote_into_response_onchain_includes_change() {
2021 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
2022 let melt_quote = MeltQuote::new(
2023 Some(QuoteId::new()),
2024 MeltPaymentRequest::Onchain {
2025 address: address.to_string(),
2026 },
2027 CurrencyUnit::Sat,
2028 Amount::new(1_000, CurrencyUnit::Sat),
2029 Amount::new(10, CurrencyUnit::Sat),
2030 unix_time() + 3600,
2031 None,
2032 None,
2033 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2034 None,
2035 Some(3),
2036 );
2037
2038 let response = melt_quote.into_response(Some(vec![]));
2039 match response {
2040 crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
2041 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
2042 }
2043 }
2044
2045 #[test]
2046 fn validate_onchain_fee_options_rejects_empty() {
2047 let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
2048 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2049 }
2050
2051 #[test]
2052 fn validate_onchain_fee_options_allows_duplicate_fee_index() {
2053 let options = [
2054 MeltQuoteOnchainFeeOption {
2055 fee_index: 10,
2056 fee_reserve: Amount::from(10),
2057 estimated_blocks: 3,
2058 },
2059 MeltQuoteOnchainFeeOption {
2060 fee_index: 10,
2061 fee_reserve: Amount::from(20),
2062 estimated_blocks: 6,
2063 },
2064 ];
2065 validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
2066 }
2067
2068 #[test]
2069 fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
2070 let options = [
2073 MeltQuoteOnchainFeeOption {
2074 fee_index: 20,
2075 fee_reserve: Amount::from(10),
2076 estimated_blocks: 3,
2077 },
2078 MeltQuoteOnchainFeeOption {
2079 fee_index: 1,
2080 fee_reserve: Amount::from(20),
2081 estimated_blocks: 3,
2082 },
2083 ];
2084 validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
2085 }
2086
2087 #[test]
2088 fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
2089 let options = [
2092 MeltQuoteOnchainFeeOption {
2093 fee_index: 0,
2094 fee_reserve: Amount::from(42),
2095 estimated_blocks: 1,
2096 },
2097 MeltQuoteOnchainFeeOption {
2098 fee_index: 1,
2099 fee_reserve: Amount::from(42),
2100 estimated_blocks: 6,
2101 },
2102 ];
2103 validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
2104 }
2105
2106 #[test]
2107 fn validate_onchain_fee_options_accepts_well_formed() {
2108 let options = [
2109 MeltQuoteOnchainFeeOption {
2110 fee_index: 0,
2111 fee_reserve: Amount::from(500),
2112 estimated_blocks: 1,
2113 },
2114 MeltQuoteOnchainFeeOption {
2115 fee_index: 1,
2116 fee_reserve: Amount::from(200),
2117 estimated_blocks: 6,
2118 },
2119 MeltQuoteOnchainFeeOption {
2120 fee_index: 2,
2121 fee_reserve: Amount::from(50),
2122 estimated_blocks: 144,
2123 },
2124 ];
2125 validate_onchain_fee_options(&options).expect("well-formed must validate");
2126 }
2127
2128 #[test]
2129 fn new_onchain_rejects_empty_fee_options() {
2130 let err = MeltQuote::new_onchain(
2131 None,
2132 MeltPaymentRequest::Onchain {
2133 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2134 },
2135 CurrencyUnit::Sat,
2136 Amount::new(1_000, CurrencyUnit::Sat),
2137 unix_time() + 3600,
2138 None,
2139 None,
2140 vec![],
2141 )
2142 .expect_err("empty fee_options must be rejected");
2143 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2144 }
2145
2146 #[test]
2147 fn new_onchain_initializes_reserve_to_cheapest_tier() {
2148 let options = vec![
2151 MeltQuoteOnchainFeeOption {
2152 fee_index: 10,
2153 fee_reserve: Amount::from(500),
2154 estimated_blocks: 1,
2155 },
2156 MeltQuoteOnchainFeeOption {
2157 fee_index: 30,
2158 fee_reserve: Amount::from(50),
2159 estimated_blocks: 144,
2160 },
2161 MeltQuoteOnchainFeeOption {
2162 fee_index: 20,
2163 fee_reserve: Amount::from(200),
2164 estimated_blocks: 6,
2165 },
2166 ];
2167 let quote = MeltQuote::new_onchain(
2168 None,
2169 MeltPaymentRequest::Onchain {
2170 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2171 },
2172 CurrencyUnit::Sat,
2173 Amount::new(10_000, CurrencyUnit::Sat),
2174 unix_time() + 3600,
2175 None,
2176 None,
2177 options.clone(),
2178 )
2179 .expect("well-formed quote must construct");
2180
2181 assert_eq!(quote.fee_reserve().value(), 50);
2182 assert_eq!(quote.estimated_blocks, Some(144));
2183 assert_eq!(quote.selected_fee_index, None);
2184 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2185 assert_eq!(returned, vec![10, 30, 20]);
2186 }
2187
2188 #[test]
2189 fn new_onchain_preserves_duplicate_backend_fee_index() {
2190 let options = vec![
2191 MeltQuoteOnchainFeeOption {
2192 fee_index: 7,
2193 fee_reserve: Amount::from(500),
2194 estimated_blocks: 1,
2195 },
2196 MeltQuoteOnchainFeeOption {
2197 fee_index: 7,
2198 fee_reserve: Amount::from(200),
2199 estimated_blocks: 6,
2200 },
2201 ];
2202 let quote = MeltQuote::new_onchain(
2203 None,
2204 MeltPaymentRequest::Onchain {
2205 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2206 },
2207 CurrencyUnit::Sat,
2208 Amount::new(10_000, CurrencyUnit::Sat),
2209 unix_time() + 3600,
2210 None,
2211 None,
2212 options,
2213 )
2214 .expect("duplicate backend fee_index must be preserved");
2215
2216 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2217 assert_eq!(returned, vec![7, 7]);
2218 }
2219
2220 #[test]
2221 fn select_onchain_fee_option_leaves_fee_options_untouched() {
2222 let options = vec![
2223 MeltQuoteOnchainFeeOption {
2224 fee_index: 1,
2225 fee_reserve: Amount::from(500),
2226 estimated_blocks: 1,
2227 },
2228 MeltQuoteOnchainFeeOption {
2229 fee_index: 2,
2230 fee_reserve: Amount::from(200),
2231 estimated_blocks: 6,
2232 },
2233 ];
2234 let mut quote = MeltQuote::new_onchain(
2235 None,
2236 MeltPaymentRequest::Onchain {
2237 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2238 },
2239 CurrencyUnit::Sat,
2240 Amount::new(10_000, CurrencyUnit::Sat),
2241 unix_time() + 3600,
2242 None,
2243 None,
2244 options.clone(),
2245 )
2246 .unwrap();
2247
2248 let before = quote.fee_options().to_vec();
2249 quote
2250 .select_onchain_fee_option(1)
2251 .expect("selecting a known fee_index must succeed");
2252
2253 assert_eq!(
2254 quote.fee_options(),
2255 before.as_slice(),
2256 "fee_options is fixed for the lifetime of the quote and must not \
2257 mutate on selection"
2258 );
2259 assert_eq!(quote.selected_fee_index, Some(1));
2260 assert_eq!(quote.estimated_blocks, Some(1));
2261 assert_eq!(quote.fee_reserve().value(), 500);
2262 }
2263
2264 #[test]
2265 fn select_onchain_fee_option_unknown_index_rejected() {
2266 let options = vec![MeltQuoteOnchainFeeOption {
2267 fee_index: 0,
2268 fee_reserve: Amount::from(500),
2269 estimated_blocks: 1,
2270 }];
2271 let mut quote = MeltQuote::new_onchain(
2272 None,
2273 MeltPaymentRequest::Onchain {
2274 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2275 },
2276 CurrencyUnit::Sat,
2277 Amount::new(10_000, CurrencyUnit::Sat),
2278 unix_time() + 3600,
2279 None,
2280 None,
2281 options,
2282 )
2283 .unwrap();
2284
2285 match quote
2286 .select_onchain_fee_option(7)
2287 .expect_err("unknown fee_index must be rejected")
2288 {
2289 crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2290 other => panic!("unexpected error: {other:?}"),
2291 }
2292 }
2293
2294 #[test]
2295 fn from_db_preserves_duplicate_onchain_fee_options() {
2296 let options = vec![
2297 MeltQuoteOnchainFeeOption {
2298 fee_index: 0,
2299 fee_reserve: Amount::from(100),
2300 estimated_blocks: 6,
2301 },
2302 MeltQuoteOnchainFeeOption {
2303 fee_index: 0,
2304 fee_reserve: Amount::from(200),
2305 estimated_blocks: 6,
2306 },
2307 ];
2308 let quote = MeltQuote::from_db(
2309 QuoteId::new(),
2310 CurrencyUnit::Sat,
2311 MeltPaymentRequest::Onchain {
2312 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2313 },
2314 10_000,
2315 100,
2316 MeltQuoteState::Unpaid,
2317 unix_time() + 3600,
2318 None,
2319 None,
2320 None,
2321 unix_time(),
2322 None,
2323 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2324 None,
2325 None,
2326 options,
2327 None,
2328 )
2329 .expect("duplicate onchain fee_options on reload must be preserved");
2330
2331 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2332 assert_eq!(returned, vec![0, 0]);
2333 }
2334
2335 #[test]
2336 fn test_custom_mint_quote_response_surfaces_extra_json() {
2337 let extra = serde_json::json!({"payment_url": "https://example.com/pay", "ref": 42});
2338 let now = unix_time();
2339 let quote = MintQuote::new(
2340 Some(QuoteId::new()),
2341 "custom://request".to_string(),
2342 CurrencyUnit::Sat,
2343 Some(Amount::new(500, CurrencyUnit::Sat)),
2344 unix_time() + 3600,
2345 PaymentIdentifier::Label("test".to_string()),
2346 None,
2347 Amount::new(0, CurrencyUnit::Sat),
2348 Amount::new(0, CurrencyUnit::Sat),
2349 PaymentMethod::Custom("custom".to_string()),
2350 now,
2351 now,
2352 Vec::new(),
2353 Vec::new(),
2354 Some(extra.clone()),
2355 );
2356
2357 let response: MintQuoteResponse<QuoteId> = quote.try_into().expect("conversion succeeds");
2358 match response {
2359 MintQuoteResponse::Custom { response, .. } => {
2360 assert_eq!(response.extra, extra);
2361 }
2362 other => panic!("expected Custom variant, got {:?}", other),
2363 }
2364 }
2365
2366 #[test]
2367 fn from_db_rejects_empty_onchain_fee_options() {
2368 let err = MeltQuote::from_db(
2369 QuoteId::new(),
2370 CurrencyUnit::Sat,
2371 MeltPaymentRequest::Onchain {
2372 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2373 },
2374 10_000,
2375 100,
2376 MeltQuoteState::Unpaid,
2377 unix_time() + 3600,
2378 None,
2379 None,
2380 None,
2381 unix_time(),
2382 None,
2383 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2384 None,
2385 Some(6),
2386 Vec::new(),
2387 None,
2388 )
2389 .expect_err("empty onchain fee_options on reload must be rejected");
2390 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2391 }
2392}