1use std::fmt;
4use std::ops::Deref;
5use std::str::FromStr;
6
7use bitcoin::bip32::DerivationPath;
8use cashu::nuts::nut_onchain::MeltQuoteOnchainFeeOption;
9use cashu::quote_id::QuoteId;
10use cashu::util::unix_time;
11use cashu::{
12 Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MeltQuoteCustomResponse,
13 MeltQuoteOnchainResponse, MintQuoteBolt11Response, MintQuoteBolt12Response,
14 MintQuoteCustomResponse, MintQuoteOnchainResponse, PaymentMethod, Proofs, State,
15};
16use lightning::offers::offer::Offer;
17use serde::{Deserialize, Serialize};
18use tracing::instrument;
19use uuid::Uuid;
20
21use crate::common::IssuerVersion;
22use crate::mint_quote::MintQuoteResponse;
23use crate::nuts::{MeltQuoteState, MintQuoteState};
24use crate::payment::PaymentIdentifier;
25use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum OperationKind {
31 Swap,
33 Mint,
35 Melt,
37 BatchMint,
39}
40
41#[derive(Debug)]
73pub struct ProofsWithState {
74 proofs: Proofs,
75 pub state: State,
77}
78
79impl Deref for ProofsWithState {
80 type Target = Proofs;
81
82 fn deref(&self) -> &Self::Target {
83 &self.proofs
84 }
85}
86
87impl ProofsWithState {
88 pub fn new(proofs: Proofs, current_state: State) -> Self {
95 Self {
96 proofs,
97 state: current_state,
98 }
99 }
100}
101
102impl fmt::Display for OperationKind {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 OperationKind::Swap => write!(f, "swap"),
106 OperationKind::Mint => write!(f, "mint"),
107 OperationKind::Melt => write!(f, "melt"),
108 OperationKind::BatchMint => write!(f, "batch_mint"),
109 }
110 }
111}
112
113impl FromStr for OperationKind {
114 type Err = Error;
115 fn from_str(value: &str) -> Result<Self, Self::Err> {
116 let value = value.to_lowercase();
117 match value.as_str() {
118 "swap" => Ok(OperationKind::Swap),
119 "mint" => Ok(OperationKind::Mint),
120 "melt" => Ok(OperationKind::Melt),
121 "batch_mint" => Ok(OperationKind::BatchMint),
122 _ => Err(Error::Custom(format!("Invalid operation kind: {value}"))),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "snake_case")]
130pub enum SwapSagaState {
131 SetupComplete,
133 Signed,
135}
136
137impl fmt::Display for SwapSagaState {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 SwapSagaState::SetupComplete => write!(f, "setup_complete"),
141 SwapSagaState::Signed => write!(f, "signed"),
142 }
143 }
144}
145
146impl FromStr for SwapSagaState {
147 type Err = Error;
148 fn from_str(value: &str) -> Result<Self, Self::Err> {
149 let value = value.to_lowercase();
150 match value.as_str() {
151 "setup_complete" => Ok(SwapSagaState::SetupComplete),
152 "signed" => Ok(SwapSagaState::Signed),
153 _ => Err(Error::Custom(format!("Invalid swap saga state: {value}"))),
154 }
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum MeltSagaState {
162 SetupComplete,
164 PaymentAttempted,
166 Finalizing,
168}
169
170impl fmt::Display for MeltSagaState {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 match self {
173 MeltSagaState::SetupComplete => write!(f, "setup_complete"),
174 MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
175 MeltSagaState::Finalizing => write!(f, "finalizing"),
176 }
177 }
178}
179
180impl FromStr for MeltSagaState {
181 type Err = Error;
182 fn from_str(value: &str) -> Result<Self, Self::Err> {
183 let value = value.to_lowercase();
184 match value.as_str() {
185 "setup_complete" => Ok(MeltSagaState::SetupComplete),
186 "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
187 "finalizing" => Ok(MeltSagaState::Finalizing),
188 _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
189 }
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(tag = "type", rename_all = "snake_case")]
196pub enum SagaStateEnum {
197 Swap(SwapSagaState),
199 Melt(MeltSagaState),
201 }
204
205impl SagaStateEnum {
206 pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
208 match operation_kind {
209 OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)),
210 OperationKind::Melt => Ok(SagaStateEnum::Melt(MeltSagaState::from_str(s)?)),
211 OperationKind::Mint | OperationKind::BatchMint => {
212 Err(Error::Custom("Mint saga not implemented yet".to_string()))
213 }
214 }
215 }
216
217 pub fn state(&self) -> &str {
219 match self {
220 SagaStateEnum::Swap(state) => match state {
221 SwapSagaState::SetupComplete => "setup_complete",
222 SwapSagaState::Signed => "signed",
223 },
224 SagaStateEnum::Melt(state) => match state {
225 MeltSagaState::SetupComplete => "setup_complete",
226 MeltSagaState::PaymentAttempted => "payment_attempted",
227 MeltSagaState::Finalizing => "finalizing",
228 },
229 }
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Saga {
236 pub operation_id: Uuid,
238 pub operation_kind: OperationKind,
240 pub state: SagaStateEnum,
242 pub quote_id: Option<String>,
245 pub finalization_data: Option<MeltFinalizationData>,
247 pub created_at: u64,
249 pub updated_at: u64,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct MeltFinalizationData {
256 pub total_spent: Amount<CurrencyUnit>,
258 pub payment_lookup_id: PaymentIdentifier,
260 pub payment_proof: Option<String>,
262}
263
264impl Serialize for MeltFinalizationData {
265 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
266 where
267 S: serde::Serializer,
268 {
269 #[derive(Serialize)]
270 struct MeltFinalizationDataSer<'a> {
271 total_spent: Amount,
272 unit: &'a CurrencyUnit,
273 payment_lookup_id: &'a PaymentIdentifier,
274 payment_proof: &'a Option<String>,
275 }
276
277 MeltFinalizationDataSer {
278 total_spent: self.total_spent.clone().into(),
279 unit: self.total_spent.unit(),
280 payment_lookup_id: &self.payment_lookup_id,
281 payment_proof: &self.payment_proof,
282 }
283 .serialize(serializer)
284 }
285}
286
287impl<'de> Deserialize<'de> for MeltFinalizationData {
288 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
289 where
290 D: serde::Deserializer<'de>,
291 {
292 #[derive(Deserialize)]
293 struct MeltFinalizationDataDe {
294 total_spent: Amount,
295 unit: CurrencyUnit,
296 payment_lookup_id: PaymentIdentifier,
297 payment_proof: Option<String>,
298 }
299
300 let data = MeltFinalizationDataDe::deserialize(deserializer)?;
301
302 Ok(Self {
303 total_spent: data.total_spent.with_unit(data.unit),
304 payment_lookup_id: data.payment_lookup_id,
305 payment_proof: data.payment_proof,
306 })
307 }
308}
309
310impl Saga {
311 pub fn new_swap(operation_id: Uuid, state: SwapSagaState) -> Self {
313 let now = unix_time();
314 Self {
315 operation_id,
316 operation_kind: OperationKind::Swap,
317 state: SagaStateEnum::Swap(state),
318 quote_id: None,
319 finalization_data: None,
320 created_at: now,
321 updated_at: now,
322 }
323 }
324
325 pub fn update_swap_state(&mut self, new_state: SwapSagaState) {
327 self.state = SagaStateEnum::Swap(new_state);
328 self.updated_at = unix_time();
329 }
330
331 pub fn new_melt(operation_id: Uuid, state: MeltSagaState, quote_id: String) -> Self {
333 let now = unix_time();
334 Self {
335 operation_id,
336 operation_kind: OperationKind::Melt,
337 state: SagaStateEnum::Melt(state),
338 quote_id: Some(quote_id),
339 finalization_data: None,
340 created_at: now,
341 updated_at: now,
342 }
343 }
344
345 pub fn update_melt_state(&mut self, new_state: MeltSagaState) {
347 self.state = SagaStateEnum::Melt(new_state);
348 self.updated_at = unix_time();
349 }
350
351 pub fn set_melt_finalization_data(&mut self, finalization_data: MeltFinalizationData) {
353 self.finalization_data = Some(finalization_data);
354 self.updated_at = unix_time();
355 }
356}
357
358#[derive(Debug)]
360pub struct Operation {
361 id: Uuid,
362 kind: OperationKind,
363 total_issued: Amount,
364 total_redeemed: Amount,
365 fee_collected: Amount,
366 complete_at: Option<u64>,
367 payment_amount: Option<Amount>,
369 payment_fee: Option<Amount>,
371 payment_method: Option<PaymentMethod>,
373}
374
375impl Operation {
376 pub fn new(
378 id: Uuid,
379 kind: OperationKind,
380 total_issued: Amount,
381 total_redeemed: Amount,
382 fee_collected: Amount,
383 complete_at: Option<u64>,
384 payment_method: Option<PaymentMethod>,
385 ) -> Self {
386 Self {
387 id,
388 kind,
389 total_issued,
390 total_redeemed,
391 fee_collected,
392 complete_at,
393 payment_amount: None,
394 payment_fee: None,
395 payment_method,
396 }
397 }
398
399 pub fn new_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
401 Self {
402 id: Uuid::new_v4(),
403 kind: OperationKind::Mint,
404 total_issued,
405 total_redeemed: Amount::ZERO,
406 fee_collected: Amount::ZERO,
407 complete_at: None,
408 payment_amount: None,
409 payment_fee: None,
410 payment_method: Some(payment_method),
411 }
412 }
413
414 pub fn new_batch_mint(total_issued: Amount, payment_method: PaymentMethod) -> Self {
416 Self {
417 id: Uuid::new_v4(),
418 kind: OperationKind::BatchMint,
419 total_issued,
420 total_redeemed: Amount::ZERO,
421 fee_collected: Amount::ZERO,
422 complete_at: None,
423 payment_amount: None,
424 payment_fee: None,
425 payment_method: Some(payment_method),
426 }
427 }
428
429 pub fn new_melt(
433 total_redeemed: Amount,
434 fee_collected: Amount,
435 payment_method: PaymentMethod,
436 ) -> Self {
437 Self {
438 id: Uuid::new_v4(),
439 kind: OperationKind::Melt,
440 total_issued: Amount::ZERO,
441 total_redeemed,
442 fee_collected,
443 complete_at: None,
444 payment_amount: None,
445 payment_fee: None,
446 payment_method: Some(payment_method),
447 }
448 }
449
450 pub fn new_swap(total_issued: Amount, total_redeemed: Amount, fee_collected: Amount) -> Self {
452 Self {
453 id: Uuid::new_v4(),
454 kind: OperationKind::Swap,
455 total_issued,
456 total_redeemed,
457 fee_collected,
458 complete_at: None,
459 payment_amount: None,
460 payment_fee: None,
461 payment_method: None,
462 }
463 }
464
465 pub fn id(&self) -> &Uuid {
467 &self.id
468 }
469
470 pub fn kind(&self) -> OperationKind {
472 self.kind
473 }
474
475 pub fn total_issued(&self) -> Amount {
477 self.total_issued
478 }
479
480 pub fn total_redeemed(&self) -> Amount {
482 self.total_redeemed
483 }
484
485 pub fn fee_collected(&self) -> Amount {
487 self.fee_collected
488 }
489
490 pub fn completed_at(&self) -> &Option<u64> {
492 &self.complete_at
493 }
494
495 pub fn add_change(&mut self, change: Amount) {
497 self.total_issued = change;
498 }
499
500 pub fn payment_amount(&self) -> Option<Amount> {
502 self.payment_amount
503 }
504
505 pub fn payment_fee(&self) -> Option<Amount> {
507 self.payment_fee
508 }
509
510 pub fn set_payment_details(&mut self, payment_amount: Amount, payment_fee: Amount) {
512 self.payment_amount = Some(payment_amount);
513 self.payment_fee = Some(payment_fee);
514 }
515
516 pub fn payment_method(&self) -> Option<PaymentMethod> {
518 self.payment_method.clone()
519 }
520}
521
522#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
533pub struct MintQuoteChange {
534 pub payments: Option<Vec<IncomingPayment>>,
536 pub issuances: Option<Vec<Amount>>,
538}
539
540#[derive(Debug, Clone, Hash, PartialEq, Eq)]
542pub struct MintQuote {
543 pub id: QuoteId,
545 pub amount: Option<Amount<CurrencyUnit>>,
547 pub unit: CurrencyUnit,
549 pub request: String,
551 pub expiry: u64,
553 pub request_lookup_id: PaymentIdentifier,
555 pub pubkey: Option<PublicKey>,
557 pub created_time: u64,
559 amount_paid: Amount<CurrencyUnit>,
561 amount_issued: Amount<CurrencyUnit>,
563 pub payments: Vec<IncomingPayment>,
565 pub payment_method: PaymentMethod,
567 pub issuance: Vec<Issuance>,
569 pub extra_json: Option<serde_json::Value>,
571 changes: Option<MintQuoteChange>,
577}
578
579impl MintQuote {
580 #[allow(clippy::too_many_arguments)]
582 pub fn new(
583 id: Option<QuoteId>,
584 request: String,
585 unit: CurrencyUnit,
586 amount: Option<Amount<CurrencyUnit>>,
587 expiry: u64,
588 request_lookup_id: PaymentIdentifier,
589 pubkey: Option<PublicKey>,
590 amount_paid: Amount<CurrencyUnit>,
591 amount_issued: Amount<CurrencyUnit>,
592 payment_method: PaymentMethod,
593 created_time: u64,
594 payments: Vec<IncomingPayment>,
595 issuance: Vec<Issuance>,
596 extra_json: Option<serde_json::Value>,
597 ) -> Self {
598 let id = id.unwrap_or_else(QuoteId::new_uuid);
599
600 Self {
601 id,
602 amount,
603 unit: unit.clone(),
604 request,
605 expiry,
606 request_lookup_id,
607 pubkey,
608 created_time,
609 amount_paid,
610 amount_issued,
611 payment_method,
612 payments,
613 issuance,
614 extra_json,
615 changes: None,
616 }
617 }
618
619 #[instrument(skip(self))]
621 pub fn increment_amount_paid(
622 &mut self,
623 additional_amount: Amount<CurrencyUnit>,
624 ) -> Result<Amount, crate::Error> {
625 self.amount_paid = self
626 .amount_paid
627 .checked_add(&additional_amount)
628 .map_err(|_| crate::Error::AmountOverflow)?;
629 Ok(Amount::from(self.amount_paid.value()))
630 }
631
632 #[instrument(skip(self))]
634 pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
635 self.amount_paid.clone()
636 }
637
638 #[instrument(skip(self))]
661 pub fn add_issuance(
662 &mut self,
663 additional_amount: Amount<CurrencyUnit>,
664 ) -> Result<Amount<CurrencyUnit>, crate::Error> {
665 let new_amount_issued = self
666 .amount_issued
667 .checked_add(&additional_amount)
668 .map_err(|_| crate::Error::AmountOverflow)?;
669
670 if new_amount_issued > self.amount_paid {
672 return Err(crate::Error::OverIssue);
673 }
674
675 self.changes
676 .get_or_insert_default()
677 .issuances
678 .get_or_insert_default()
679 .push(additional_amount.into());
680
681 self.amount_issued = new_amount_issued;
682
683 Ok(self.amount_issued.clone())
684 }
685
686 #[instrument(skip(self))]
688 pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
689 self.amount_issued.clone()
690 }
691
692 #[instrument(skip(self))]
694 pub fn state(&self) -> MintQuoteState {
695 self.compute_quote_state()
696 }
697
698 pub fn payment_ids(&self) -> Vec<&String> {
700 self.payments.iter().map(|a| &a.payment_id).collect()
701 }
702
703 pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
710 self.amount_paid
711 .checked_sub(&self.amount_issued)
712 .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
713 }
714
715 pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
725 self.changes.take()
726 }
727
728 #[instrument(skip(self))]
748 pub fn add_payment(
749 &mut self,
750 amount: Amount<CurrencyUnit>,
751 payment_id: String,
752 time: Option<u64>,
753 ) -> Result<(), crate::Error> {
754 let time = time.unwrap_or_else(unix_time);
755
756 let payment_ids = self.payment_ids();
757 if payment_ids.contains(&&payment_id) {
758 return Err(crate::Error::DuplicatePaymentId);
759 }
760
761 self.amount_paid = self
762 .amount_paid
763 .checked_add(&amount)
764 .map_err(|_| crate::Error::AmountOverflow)?;
765
766 let payment = IncomingPayment::new(amount, payment_id, time);
767
768 self.payments.push(payment.clone());
769
770 self.changes
771 .get_or_insert_default()
772 .payments
773 .get_or_insert_default()
774 .push(payment);
775
776 Ok(())
777 }
778
779 #[instrument(skip(self))]
781 fn compute_quote_state(&self) -> MintQuoteState {
782 let zero_amount = Amount::new(0, self.unit.clone());
783
784 if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
785 return MintQuoteState::Unpaid;
786 }
787
788 match self.amount_paid.value().cmp(&self.amount_issued.value()) {
789 std::cmp::Ordering::Less => {
790 tracing::error!("We should not have issued more then has been paid");
791 MintQuoteState::Issued
792 }
793 std::cmp::Ordering::Equal => MintQuoteState::Issued,
794 std::cmp::Ordering::Greater => MintQuoteState::Paid,
795 }
796 }
797}
798
799#[derive(Debug, Clone, Hash, PartialEq, Eq)]
801pub struct IncomingPayment {
802 pub amount: Amount<CurrencyUnit>,
804 pub time: u64,
806 pub payment_id: String,
808}
809
810impl IncomingPayment {
811 pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
813 Self {
814 payment_id,
815 time,
816 amount,
817 }
818 }
819}
820
821#[derive(Debug, Clone, Hash, PartialEq, Eq)]
823pub struct Issuance {
824 pub amount: Amount<CurrencyUnit>,
826 pub time: u64,
828}
829
830impl Issuance {
831 pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
833 Self { amount, time }
834 }
835}
836
837#[derive(Debug, Clone, Hash, PartialEq, Eq)]
839pub struct MeltQuote {
840 pub id: QuoteId,
842 pub unit: CurrencyUnit,
844 pub request: MeltPaymentRequest,
846 amount: Amount<CurrencyUnit>,
848 fee_reserve: Amount<CurrencyUnit>,
850 pub state: MeltQuoteState,
852 pub expiry: u64,
854 pub payment_proof: Option<String>,
856 pub request_lookup_id: Option<PaymentIdentifier>,
858 pub options: Option<MeltOptions>,
862 pub created_time: u64,
864 pub paid_time: Option<u64>,
866 pub payment_method: PaymentMethod,
868 pub extra_json: Option<serde_json::Value>,
870 pub estimated_blocks: Option<u32>,
872 fee_options: Vec<MeltQuoteOnchainFeeOption>,
882 pub selected_fee_index: Option<u32>,
884}
885
886impl MeltQuote {
887 #[allow(clippy::too_many_arguments)]
889 pub fn new(
890 id: Option<QuoteId>,
891 request: MeltPaymentRequest,
892 unit: CurrencyUnit,
893 amount: Amount<CurrencyUnit>,
894 fee_reserve: Amount<CurrencyUnit>,
895 expiry: u64,
896 request_lookup_id: Option<PaymentIdentifier>,
897 options: Option<MeltOptions>,
898 payment_method: PaymentMethod,
899 extra_json: Option<serde_json::Value>,
900 estimated_blocks: Option<u32>,
901 ) -> Self {
902 let id = id.unwrap_or_else(QuoteId::new_uuid);
903
904 let fee_options = estimated_blocks
905 .map(|estimated_blocks| {
906 vec![MeltQuoteOnchainFeeOption {
907 fee_index: 0,
908 fee_reserve: fee_reserve.clone().into(),
909 estimated_blocks,
910 }]
911 })
912 .unwrap_or_default();
913
914 Self {
915 id,
916 unit: unit.clone(),
917 request,
918 amount,
919 fee_reserve,
920 state: MeltQuoteState::Unpaid,
921 expiry,
922 payment_proof: None,
923 request_lookup_id,
924 options,
925 created_time: unix_time(),
926 paid_time: None,
927 payment_method,
928 extra_json,
929 estimated_blocks,
930 fee_options,
931 selected_fee_index: None,
932 }
933 }
934
935 #[allow(clippy::too_many_arguments)]
947 pub fn new_onchain(
948 id: Option<QuoteId>,
949 request: MeltPaymentRequest,
950 unit: CurrencyUnit,
951 amount: Amount<CurrencyUnit>,
952 expiry: u64,
953 request_lookup_id: Option<PaymentIdentifier>,
954 extra_json: Option<serde_json::Value>,
955 fee_options: Vec<MeltQuoteOnchainFeeOption>,
956 ) -> Result<Self, crate::Error> {
957 if fee_options.is_empty() {
958 return Err(crate::Error::OnchainFeeOptionsEmpty);
959 }
960
961 validate_onchain_fee_options(&fee_options)?;
962
963 let id = id.unwrap_or_else(QuoteId::new_uuid);
964
965 let initial = fee_options
969 .iter()
970 .min_by_key(|option| u64::from(option.fee_reserve))
971 .copied()
972 .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
973
974 let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
975 let estimated_blocks = Some(initial.estimated_blocks);
976
977 Ok(Self {
978 id,
979 unit: unit.clone(),
980 request,
981 amount,
982 fee_reserve,
983 state: MeltQuoteState::Unpaid,
984 expiry,
985 payment_proof: None,
986 request_lookup_id,
987 options: None,
988 created_time: unix_time(),
989 paid_time: None,
990 payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
991 extra_json,
992 estimated_blocks,
993 fee_options,
994 selected_fee_index: None,
995 })
996 }
997
998 #[inline]
1004 pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1005 &self.fee_options
1006 }
1007
1008 #[inline]
1010 pub fn amount(&self) -> Amount<CurrencyUnit> {
1011 self.amount.clone()
1012 }
1013
1014 #[inline]
1016 pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1017 self.fee_reserve.clone()
1018 }
1019
1020 pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1022 let option = self
1023 .fee_options
1024 .iter()
1025 .find(|option| option.fee_index == fee_index)
1026 .copied()
1027 .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1028
1029 if self
1030 .selected_fee_index
1031 .is_some_and(|selected| selected != fee_index)
1032 {
1033 return Err(crate::Error::InvalidPaymentRequest);
1034 }
1035
1036 self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1037 self.estimated_blocks = Some(option.estimated_blocks);
1038 self.selected_fee_index = Some(fee_index);
1039
1040 Ok(())
1041 }
1042
1043 pub fn into_response(
1051 self,
1052 change: Option<Vec<cashu::nuts::BlindSignature>>,
1053 ) -> crate::MeltQuoteResponse<QuoteId> {
1054 match self.payment_method {
1055 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1056 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1057 response.change = change;
1058 crate::MeltQuoteResponse::Bolt11(response)
1059 }
1060 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1061 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1062 response.change = change;
1063 crate::MeltQuoteResponse::Bolt12(response)
1064 }
1065 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1066 let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1067 response.change = change;
1068 crate::MeltQuoteResponse::Onchain(response)
1069 }
1070 _ => {
1071 let method = self.payment_method.clone();
1072 let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1073 response.change = change;
1074 crate::MeltQuoteResponse::Custom((method, response))
1075 }
1076 }
1077 }
1078
1079 pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1081 let total = self
1082 .amount
1083 .checked_add(&self.fee_reserve)
1084 .map_err(|_| crate::Error::AmountOverflow)?;
1085 Ok(Amount::from(total.value()))
1086 }
1087
1088 #[allow(clippy::too_many_arguments)]
1090 pub fn from_db(
1091 id: QuoteId,
1092 unit: CurrencyUnit,
1093 request: MeltPaymentRequest,
1094 amount: u64,
1095 fee_reserve: u64,
1096 state: MeltQuoteState,
1097 expiry: u64,
1098 payment_proof: Option<String>,
1099 request_lookup_id: Option<PaymentIdentifier>,
1100 options: Option<MeltOptions>,
1101 created_time: u64,
1102 paid_time: Option<u64>,
1103 payment_method: PaymentMethod,
1104 extra_json: Option<serde_json::Value>,
1105 estimated_blocks: Option<u32>,
1106 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1107 selected_fee_index: Option<u32>,
1108 ) -> Result<Self, crate::Error> {
1109 if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1114 validate_onchain_fee_options(&fee_options)?;
1115 }
1116
1117 Ok(Self {
1118 id,
1119 unit: unit.clone(),
1120 request,
1121 amount: Amount::new(amount, unit.clone()),
1122 fee_reserve: Amount::new(fee_reserve, unit),
1123 state,
1124 expiry,
1125 payment_proof,
1126 request_lookup_id,
1127 options,
1128 created_time,
1129 paid_time,
1130 payment_method,
1131 extra_json,
1132 estimated_blocks,
1133 fee_options,
1134 selected_fee_index,
1135 })
1136 }
1137}
1138
1139pub fn validate_onchain_fee_options(
1148 fee_options: &[MeltQuoteOnchainFeeOption],
1149) -> Result<(), crate::Error> {
1150 if fee_options.is_empty() {
1151 return Err(crate::Error::OnchainFeeOptionsEmpty);
1152 }
1153
1154 Ok(())
1155}
1156
1157impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1158 fn from(quote: MeltQuote) -> Self {
1159 Self {
1160 quote: quote.id.clone(),
1161 amount: quote.amount().into(),
1162 unit: quote.unit.clone(),
1163 state: quote.state,
1164 expiry: quote.expiry,
1165 request: quote.request.to_string(),
1166 fee_options: quote.fee_options().to_vec(),
1167 selected_fee_index: quote.selected_fee_index,
1168 outpoint: quote.payment_proof.clone(),
1169 change: None,
1170 }
1171 }
1172}
1173
1174impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1175 type Error = crate::error::Error;
1176 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1177 Ok(Self {
1178 quote: quote.id.clone(),
1179 request: quote.request.clone(),
1180 unit: quote.unit.clone(),
1181 expiry: (quote.expiry != 0).then_some(quote.expiry),
1182 pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1183 amount_paid: quote.amount_paid().into(),
1184 amount_issued: quote.amount_issued().into(),
1185 })
1186 }
1187}
1188
1189#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1191pub struct MintKeySetInfo {
1192 pub id: Id,
1194 pub unit: CurrencyUnit,
1196 pub active: bool,
1199 pub valid_from: u64,
1201 pub derivation_path: DerivationPath,
1203 pub derivation_path_index: Option<u32>,
1205 pub amounts: Vec<u64>,
1207 #[serde(default = "default_fee")]
1209 pub input_fee_ppk: u64,
1210 pub final_expiry: Option<u64>,
1212 pub issuer_version: Option<IssuerVersion>,
1214}
1215
1216impl MintKeySetInfo {
1217 pub fn is_expired(&self) -> bool {
1219 self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1220 }
1221}
1222
1223pub fn default_fee() -> u64 {
1225 0
1226}
1227
1228impl From<MintKeySetInfo> for KeySetInfo {
1229 fn from(keyset_info: MintKeySetInfo) -> Self {
1230 Self {
1231 id: keyset_info.id,
1232 unit: keyset_info.unit,
1233 active: keyset_info.active,
1234 input_fee_ppk: keyset_info.input_fee_ppk,
1235 final_expiry: keyset_info.final_expiry,
1236 }
1237 }
1238}
1239
1240impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1241 fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1242 MintQuoteBolt11Response {
1243 quote: mint_quote.id.clone(),
1244 state: mint_quote.state(),
1245 request: mint_quote.request,
1246 expiry: Some(mint_quote.expiry),
1247 pubkey: mint_quote.pubkey,
1248 amount: mint_quote.amount.map(Into::into),
1249 unit: Some(mint_quote.unit),
1250 }
1251 }
1252}
1253
1254impl From<MintQuote> for MintQuoteBolt11Response<String> {
1255 fn from(quote: MintQuote) -> Self {
1256 let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1257 quote.into()
1258 }
1259}
1260
1261impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1262 type Error = Error;
1263
1264 fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1265 Ok(MintQuoteBolt12Response {
1266 quote: mint_quote.id.clone(),
1267 request: mint_quote.request,
1268 expiry: Some(mint_quote.expiry),
1269 amount_paid: mint_quote.amount_paid.into(),
1270 amount_issued: mint_quote.amount_issued.into(),
1271 pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1272 amount: mint_quote.amount.map(Into::into),
1273 unit: mint_quote.unit,
1274 })
1275 }
1276}
1277
1278impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1279 type Error = Error;
1280
1281 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1282 let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1283 Ok(quote.into())
1284 }
1285}
1286
1287impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1288 type Error = Error;
1289
1290 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1291 let amount_paid = quote.amount_paid().into();
1292 let amount_issued = quote.amount_issued().into();
1293
1294 Ok(MintQuoteCustomResponse {
1295 quote: quote.id,
1296 request: quote.request,
1297 unit: Some(quote.unit),
1298 expiry: Some(quote.expiry),
1299 pubkey: quote.pubkey,
1300 amount: quote.amount.map(Into::into),
1301 amount_paid,
1302 amount_issued,
1303 extra: quote.extra_json.unwrap_or_default(),
1304 })
1305 }
1306}
1307
1308impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1309 type Error = Error;
1310
1311 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1312 let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1313 Ok(quote.into())
1314 }
1315}
1316
1317impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1318 fn from(melt_quote: MeltQuote) -> Self {
1319 let request = match melt_quote.request {
1320 MeltPaymentRequest::Custom { request, .. } => Some(request),
1321 _ => None,
1322 };
1323
1324 Self {
1325 quote: melt_quote.id,
1326 amount: melt_quote.amount.into(),
1327 fee_reserve: Some(melt_quote.fee_reserve.into()),
1328 state: melt_quote.state,
1329 expiry: melt_quote.expiry,
1330 payment_preimage: melt_quote.payment_proof,
1331 change: None,
1332 request,
1333 unit: Some(melt_quote.unit),
1334 extra: melt_quote.extra_json.unwrap_or_default(),
1335 }
1336 }
1337}
1338impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1339 type Error = Error;
1340
1341 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1342 if quote.payment_method.is_bolt11() {
1343 Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1344 quote: quote.id.clone(),
1345 request: quote.request.clone(),
1346 state: quote.state(),
1347 expiry: Some(quote.expiry),
1348 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1349 unit: Some(quote.unit.clone()),
1350 pubkey: quote.pubkey,
1351 }))
1352 } else if quote.payment_method.is_bolt12() {
1353 Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1354 quote: quote.id.clone(),
1355 request: quote.request.clone(),
1356 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1357 unit: quote.unit.clone(),
1358 expiry: Some(quote.expiry),
1359 pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1360 amount_paid: quote.amount_paid().into(),
1361 amount_issued: quote.amount_issued().into(),
1362 }))
1363 } else if quote.payment_method.is_onchain() {
1364 let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1365 Ok(MintQuoteResponse::Onchain(onchain_response))
1366 } else {
1367 let method = quote.payment_method.clone();
1368 Ok(MintQuoteResponse::Custom {
1369 method,
1370 response: crate::nuts::nut04::MintQuoteCustomResponse {
1371 quote: quote.id.clone(),
1372 request: quote.request.clone(),
1373 expiry: Some(quote.expiry),
1374 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1375 amount_paid: quote.amount_paid().into(),
1376 amount_issued: quote.amount_issued().into(),
1377 unit: Some(quote.unit.clone()),
1378 pubkey: quote.pubkey,
1379 extra: serde_json::Value::Null,
1380 },
1381 })
1382 }
1383 }
1384}
1385
1386impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1387 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1388 match response {
1389 MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1390 MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1391 MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1392 MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1393 method,
1394 response: response.into(),
1395 },
1396 }
1397 }
1398}
1399
1400impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1401 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1402 match response {
1403 MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1404 quote: bolt11_response.quote.to_string(),
1405 state: bolt11_response.state,
1406 request: bolt11_response.request,
1407 expiry: bolt11_response.expiry,
1408 pubkey: bolt11_response.pubkey,
1409 amount: bolt11_response.amount,
1410 unit: bolt11_response.unit,
1411 },
1412 _ => panic!("Expected Bolt11 response"),
1413 }
1414 }
1415}
1416
1417impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1418 type Error = Error;
1419
1420 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1421 match response {
1422 MintQuoteResponse::Bolt11(r) => Ok(r),
1423 _ => Err(Error::InvalidPaymentMethod),
1424 }
1425 }
1426}
1427
1428impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1429 type Error = Error;
1430
1431 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1432 match response {
1433 MintQuoteResponse::Bolt12(r) => Ok(r),
1434 _ => Err(Error::InvalidPaymentMethod),
1435 }
1436 }
1437}
1438
1439impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1440 type Error = Error;
1441
1442 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1443 match response {
1444 MintQuoteResponse::Onchain(r) => Ok(r),
1445 _ => Err(Error::InvalidPaymentMethod),
1446 }
1447 }
1448}
1449
1450impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1451 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1452 MeltQuoteBolt11Response {
1453 quote: melt_quote.id.clone(),
1454 payment_preimage: None,
1455 change: None,
1456 state: melt_quote.state,
1457 expiry: melt_quote.expiry,
1458 amount: melt_quote.amount().into(),
1459 fee_reserve: melt_quote.fee_reserve().into(),
1460 request: None,
1461 unit: Some(melt_quote.unit.clone()),
1462 }
1463 }
1464}
1465
1466impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1467 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1468 MeltQuoteBolt11Response {
1469 quote: melt_quote.id.clone(),
1470 amount: melt_quote.amount().into(),
1471 fee_reserve: melt_quote.fee_reserve().into(),
1472 state: melt_quote.state,
1473 expiry: melt_quote.expiry,
1474 payment_preimage: melt_quote.payment_proof,
1475 change: None,
1476 request: Some(melt_quote.request.to_string()),
1477 unit: Some(melt_quote.unit.clone()),
1478 }
1479 }
1480}
1481
1482#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1484pub enum MeltPaymentRequest {
1485 Bolt11 {
1487 bolt11: Bolt11Invoice,
1489 },
1490 Bolt12 {
1492 #[serde(with = "offer_serde")]
1494 offer: Box<Offer>,
1495 },
1496 Custom {
1498 method: String,
1500 request: String,
1502 },
1503 Onchain {
1505 address: String,
1507 },
1508}
1509
1510impl std::fmt::Display for MeltPaymentRequest {
1511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1512 match self {
1513 MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1514 MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1515 MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1516 MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1517 }
1518 }
1519}
1520
1521mod offer_serde {
1522 use std::str::FromStr;
1523
1524 use serde::{self, Deserialize, Deserializer, Serializer};
1525
1526 use super::Offer;
1527
1528 pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1529 where
1530 S: Serializer,
1531 {
1532 let s = offer.to_string();
1533 serializer.serialize_str(&s)
1534 }
1535
1536 pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1537 where
1538 D: Deserializer<'de>,
1539 {
1540 let s = String::deserialize(deserializer)?;
1541 Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1542 serde::de::Error::custom("Invalid Bolt12 Offer")
1543 })?))
1544 }
1545}
1546
1547#[cfg(test)]
1548mod tests {
1549 use std::str::FromStr;
1550
1551 use cashu::Bolt11Invoice;
1552
1553 use super::*;
1554
1555 #[test]
1556 fn test_melt_quote_to_custom_response_with_custom_request() {
1557 let melt_quote = MeltQuote::new(
1558 Some(QuoteId::new_uuid()),
1559 MeltPaymentRequest::Custom {
1560 method: "custom".to_string(),
1561 request: "custom_request_string".to_string(),
1562 },
1563 CurrencyUnit::Sat,
1564 Amount::new(100, CurrencyUnit::Sat),
1565 Amount::new(2, CurrencyUnit::Sat),
1566 unix_time() + 3600,
1567 None,
1568 None,
1569 PaymentMethod::Custom("custom".to_string()),
1570 Some(serde_json::json!({"extra_field": "value"})),
1571 None,
1572 );
1573
1574 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1575
1576 assert_eq!(response.quote, melt_quote.id);
1577 assert_eq!(response.amount, 100.into());
1578 assert_eq!(response.fee_reserve, Some(2.into()));
1579 assert_eq!(response.state, melt_quote.state);
1580 assert_eq!(response.expiry, melt_quote.expiry);
1581 assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1582 assert_eq!(response.change, None);
1583 assert_eq!(response.request, Some("custom_request_string".to_string()));
1584 assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1585 assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1586 }
1587
1588 #[test]
1589 fn test_melt_quote_to_custom_response_with_bolt11_request() {
1590 let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1591 let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1592
1593 let melt_quote = MeltQuote::new(
1594 Some(QuoteId::new_uuid()),
1595 MeltPaymentRequest::Bolt11 { bolt11 },
1596 CurrencyUnit::Sat,
1597 Amount::new(100, CurrencyUnit::Sat),
1598 Amount::new(2, CurrencyUnit::Sat),
1599 unix_time() + 3600,
1600 None,
1601 None,
1602 PaymentMethod::BOLT11,
1603 None,
1604 None,
1605 );
1606
1607 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1608
1609 assert_eq!(response.quote, melt_quote.id);
1610 assert_eq!(response.request, None);
1611 }
1612
1613 #[test]
1614 fn test_melt_quote_to_custom_response_with_bolt12_request() {
1615 use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1616 use lightning::offers::offer::OfferBuilder;
1617 let secp = Secp256k1::new();
1618 let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1619 let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1620 let offer = OfferBuilder::new(pubkey).build().unwrap();
1621
1622 let melt_quote = MeltQuote::new(
1623 Some(QuoteId::new_uuid()),
1624 MeltPaymentRequest::Bolt12 {
1625 offer: Box::new(offer),
1626 },
1627 CurrencyUnit::Sat,
1628 Amount::new(100, CurrencyUnit::Sat),
1629 Amount::new(2, CurrencyUnit::Sat),
1630 unix_time() + 3600,
1631 None,
1632 None,
1633 PaymentMethod::BOLT12,
1634 None,
1635 None,
1636 );
1637
1638 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1639
1640 assert_eq!(response.quote, melt_quote.id);
1641 assert_eq!(response.request, None);
1642 }
1643
1644 fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1645 use std::str::FromStr;
1646 MintKeySetInfo {
1647 id: Id::from_str("009a1f293253e41e").unwrap(),
1648 unit: CurrencyUnit::Sat,
1649 active: true,
1650 valid_from: 0,
1651 derivation_path: "m/0'/0'/0'".parse().unwrap(),
1652 derivation_path_index: Some(0),
1653 amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1654 input_fee_ppk: 0,
1655 final_expiry,
1656 issuer_version: None,
1657 }
1658 }
1659
1660 #[test]
1661 fn test_is_expired_none() {
1662 let info = dummy_mint_keyset_info(None);
1663 assert!(!info.is_expired());
1664 }
1665
1666 #[test]
1667 fn test_is_expired_far_future() {
1668 let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1669 assert!(!info.is_expired());
1670 }
1671
1672 #[test]
1673 fn test_is_expired_exactly_now_is_not_expired() {
1674 let info = dummy_mint_keyset_info(Some(unix_time()));
1676 assert!(!info.is_expired());
1677 }
1678
1679 #[test]
1680 fn test_is_expired_one_second_ago() {
1681 let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1682 assert!(info.is_expired());
1683 }
1684
1685 #[test]
1686 fn test_is_expired_zero() {
1687 let info = dummy_mint_keyset_info(Some(0));
1688 assert!(info.is_expired());
1689 }
1690
1691 #[test]
1692 fn test_melt_quote_into_response_onchain() {
1693 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1694 let mut melt_quote = MeltQuote::new(
1695 Some(QuoteId::new_uuid()),
1696 MeltPaymentRequest::Onchain {
1697 address: address.to_string(),
1698 },
1699 CurrencyUnit::Sat,
1700 Amount::new(5_000, CurrencyUnit::Sat),
1701 Amount::new(250, CurrencyUnit::Sat),
1702 unix_time() + 3600,
1703 None,
1704 None,
1705 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1706 None,
1707 Some(6),
1708 );
1709
1710 melt_quote.payment_proof =
1712 Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1713 melt_quote.state = MeltQuoteState::Paid;
1714
1715 let expected_id = melt_quote.id.clone();
1716 let expected_amount: Amount = melt_quote.amount().into();
1717 let expected_fee_options = melt_quote.fee_options().to_vec();
1718 let expected_expiry = melt_quote.expiry;
1719 let expected_state = melt_quote.state;
1720 let expected_outpoint = melt_quote.payment_proof.clone();
1721
1722 let response = melt_quote.into_response(None);
1723 match response {
1724 crate::MeltQuoteResponse::Onchain(r) => {
1725 assert_eq!(r.quote, expected_id);
1726 assert_eq!(r.request, address);
1727 assert_eq!(r.amount, expected_amount);
1728 assert_eq!(r.unit, CurrencyUnit::Sat);
1729 assert_eq!(r.fee_options, expected_fee_options);
1730 assert_eq!(r.selected_fee_index, None);
1731 assert_eq!(r.state, expected_state);
1732 assert_eq!(r.expiry, expected_expiry);
1733 assert_eq!(r.outpoint, expected_outpoint);
1734 assert_eq!(r.change, None);
1735 }
1736 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1737 }
1738 }
1739
1740 #[test]
1741 fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1742 let pubkey = PublicKey::from_hex(
1743 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1744 )
1745 .unwrap();
1746 let quote_id = QuoteId::new_uuid();
1747 let mint_quote = MintQuote::new(
1748 Some(quote_id.clone()),
1749 "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1750 CurrencyUnit::Sat,
1751 None,
1752 0,
1753 PaymentIdentifier::QuoteId(quote_id.clone()),
1754 Some(pubkey),
1755 Amount::new(10_000, CurrencyUnit::Sat),
1756 Amount::new(1_000, CurrencyUnit::Sat),
1757 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1758 unix_time(),
1759 vec![],
1760 vec![],
1761 None,
1762 );
1763
1764 let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1765
1766 assert_eq!(response.quote, quote_id);
1767 assert_eq!(response.expiry, None);
1768 assert_eq!(response.pubkey, pubkey);
1769 assert_eq!(response.amount_paid, Amount::from(10_000));
1770 assert_eq!(response.amount_issued, Amount::from(1_000));
1771 }
1772
1773 #[test]
1774 fn test_melt_quote_into_response_onchain_includes_change() {
1775 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1776 let melt_quote = MeltQuote::new(
1777 Some(QuoteId::new_uuid()),
1778 MeltPaymentRequest::Onchain {
1779 address: address.to_string(),
1780 },
1781 CurrencyUnit::Sat,
1782 Amount::new(1_000, CurrencyUnit::Sat),
1783 Amount::new(10, CurrencyUnit::Sat),
1784 unix_time() + 3600,
1785 None,
1786 None,
1787 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1788 None,
1789 Some(3),
1790 );
1791
1792 let response = melt_quote.into_response(Some(vec![]));
1793 match response {
1794 crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
1795 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1796 }
1797 }
1798
1799 #[test]
1800 fn validate_onchain_fee_options_rejects_empty() {
1801 let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
1802 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
1803 }
1804
1805 #[test]
1806 fn validate_onchain_fee_options_allows_duplicate_fee_index() {
1807 let options = [
1808 MeltQuoteOnchainFeeOption {
1809 fee_index: 10,
1810 fee_reserve: Amount::from(10),
1811 estimated_blocks: 3,
1812 },
1813 MeltQuoteOnchainFeeOption {
1814 fee_index: 10,
1815 fee_reserve: Amount::from(20),
1816 estimated_blocks: 6,
1817 },
1818 ];
1819 validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
1820 }
1821
1822 #[test]
1823 fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
1824 let options = [
1827 MeltQuoteOnchainFeeOption {
1828 fee_index: 20,
1829 fee_reserve: Amount::from(10),
1830 estimated_blocks: 3,
1831 },
1832 MeltQuoteOnchainFeeOption {
1833 fee_index: 1,
1834 fee_reserve: Amount::from(20),
1835 estimated_blocks: 3,
1836 },
1837 ];
1838 validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
1839 }
1840
1841 #[test]
1842 fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
1843 let options = [
1846 MeltQuoteOnchainFeeOption {
1847 fee_index: 0,
1848 fee_reserve: Amount::from(42),
1849 estimated_blocks: 1,
1850 },
1851 MeltQuoteOnchainFeeOption {
1852 fee_index: 1,
1853 fee_reserve: Amount::from(42),
1854 estimated_blocks: 6,
1855 },
1856 ];
1857 validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
1858 }
1859
1860 #[test]
1861 fn validate_onchain_fee_options_accepts_well_formed() {
1862 let options = [
1863 MeltQuoteOnchainFeeOption {
1864 fee_index: 0,
1865 fee_reserve: Amount::from(500),
1866 estimated_blocks: 1,
1867 },
1868 MeltQuoteOnchainFeeOption {
1869 fee_index: 1,
1870 fee_reserve: Amount::from(200),
1871 estimated_blocks: 6,
1872 },
1873 MeltQuoteOnchainFeeOption {
1874 fee_index: 2,
1875 fee_reserve: Amount::from(50),
1876 estimated_blocks: 144,
1877 },
1878 ];
1879 validate_onchain_fee_options(&options).expect("well-formed must validate");
1880 }
1881
1882 #[test]
1883 fn new_onchain_rejects_empty_fee_options() {
1884 let err = MeltQuote::new_onchain(
1885 None,
1886 MeltPaymentRequest::Onchain {
1887 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
1888 },
1889 CurrencyUnit::Sat,
1890 Amount::new(1_000, CurrencyUnit::Sat),
1891 unix_time() + 3600,
1892 None,
1893 None,
1894 vec![],
1895 )
1896 .expect_err("empty fee_options must be rejected");
1897 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
1898 }
1899
1900 #[test]
1901 fn new_onchain_initializes_reserve_to_cheapest_tier() {
1902 let options = vec![
1905 MeltQuoteOnchainFeeOption {
1906 fee_index: 10,
1907 fee_reserve: Amount::from(500),
1908 estimated_blocks: 1,
1909 },
1910 MeltQuoteOnchainFeeOption {
1911 fee_index: 30,
1912 fee_reserve: Amount::from(50),
1913 estimated_blocks: 144,
1914 },
1915 MeltQuoteOnchainFeeOption {
1916 fee_index: 20,
1917 fee_reserve: Amount::from(200),
1918 estimated_blocks: 6,
1919 },
1920 ];
1921 let quote = MeltQuote::new_onchain(
1922 None,
1923 MeltPaymentRequest::Onchain {
1924 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
1925 },
1926 CurrencyUnit::Sat,
1927 Amount::new(10_000, CurrencyUnit::Sat),
1928 unix_time() + 3600,
1929 None,
1930 None,
1931 options.clone(),
1932 )
1933 .expect("well-formed quote must construct");
1934
1935 assert_eq!(quote.fee_reserve().value(), 50);
1936 assert_eq!(quote.estimated_blocks, Some(144));
1937 assert_eq!(quote.selected_fee_index, None);
1938 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
1939 assert_eq!(returned, vec![10, 30, 20]);
1940 }
1941
1942 #[test]
1943 fn new_onchain_preserves_duplicate_backend_fee_index() {
1944 let options = vec![
1945 MeltQuoteOnchainFeeOption {
1946 fee_index: 7,
1947 fee_reserve: Amount::from(500),
1948 estimated_blocks: 1,
1949 },
1950 MeltQuoteOnchainFeeOption {
1951 fee_index: 7,
1952 fee_reserve: Amount::from(200),
1953 estimated_blocks: 6,
1954 },
1955 ];
1956 let quote = MeltQuote::new_onchain(
1957 None,
1958 MeltPaymentRequest::Onchain {
1959 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
1960 },
1961 CurrencyUnit::Sat,
1962 Amount::new(10_000, CurrencyUnit::Sat),
1963 unix_time() + 3600,
1964 None,
1965 None,
1966 options,
1967 )
1968 .expect("duplicate backend fee_index must be preserved");
1969
1970 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
1971 assert_eq!(returned, vec![7, 7]);
1972 }
1973
1974 #[test]
1975 fn select_onchain_fee_option_leaves_fee_options_untouched() {
1976 let options = vec![
1977 MeltQuoteOnchainFeeOption {
1978 fee_index: 1,
1979 fee_reserve: Amount::from(500),
1980 estimated_blocks: 1,
1981 },
1982 MeltQuoteOnchainFeeOption {
1983 fee_index: 2,
1984 fee_reserve: Amount::from(200),
1985 estimated_blocks: 6,
1986 },
1987 ];
1988 let mut quote = MeltQuote::new_onchain(
1989 None,
1990 MeltPaymentRequest::Onchain {
1991 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
1992 },
1993 CurrencyUnit::Sat,
1994 Amount::new(10_000, CurrencyUnit::Sat),
1995 unix_time() + 3600,
1996 None,
1997 None,
1998 options.clone(),
1999 )
2000 .unwrap();
2001
2002 let before = quote.fee_options().to_vec();
2003 quote
2004 .select_onchain_fee_option(1)
2005 .expect("selecting a known fee_index must succeed");
2006
2007 assert_eq!(
2008 quote.fee_options(),
2009 before.as_slice(),
2010 "fee_options is fixed for the lifetime of the quote and must not \
2011 mutate on selection"
2012 );
2013 assert_eq!(quote.selected_fee_index, Some(1));
2014 assert_eq!(quote.estimated_blocks, Some(1));
2015 assert_eq!(quote.fee_reserve().value(), 500);
2016 }
2017
2018 #[test]
2019 fn select_onchain_fee_option_unknown_index_rejected() {
2020 let options = vec![MeltQuoteOnchainFeeOption {
2021 fee_index: 0,
2022 fee_reserve: Amount::from(500),
2023 estimated_blocks: 1,
2024 }];
2025 let mut quote = MeltQuote::new_onchain(
2026 None,
2027 MeltPaymentRequest::Onchain {
2028 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2029 },
2030 CurrencyUnit::Sat,
2031 Amount::new(10_000, CurrencyUnit::Sat),
2032 unix_time() + 3600,
2033 None,
2034 None,
2035 options,
2036 )
2037 .unwrap();
2038
2039 match quote
2040 .select_onchain_fee_option(7)
2041 .expect_err("unknown fee_index must be rejected")
2042 {
2043 crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2044 other => panic!("unexpected error: {other:?}"),
2045 }
2046 }
2047
2048 #[test]
2049 fn from_db_preserves_duplicate_onchain_fee_options() {
2050 let options = vec![
2051 MeltQuoteOnchainFeeOption {
2052 fee_index: 0,
2053 fee_reserve: Amount::from(100),
2054 estimated_blocks: 6,
2055 },
2056 MeltQuoteOnchainFeeOption {
2057 fee_index: 0,
2058 fee_reserve: Amount::from(200),
2059 estimated_blocks: 6,
2060 },
2061 ];
2062 let quote = MeltQuote::from_db(
2063 QuoteId::new_uuid(),
2064 CurrencyUnit::Sat,
2065 MeltPaymentRequest::Onchain {
2066 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2067 },
2068 10_000,
2069 100,
2070 MeltQuoteState::Unpaid,
2071 unix_time() + 3600,
2072 None,
2073 None,
2074 None,
2075 unix_time(),
2076 None,
2077 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2078 None,
2079 None,
2080 options,
2081 None,
2082 )
2083 .expect("duplicate onchain fee_options on reload must be preserved");
2084
2085 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2086 assert_eq!(returned, vec![0, 0]);
2087 }
2088
2089 #[test]
2090 fn from_db_rejects_empty_onchain_fee_options() {
2091 let err = MeltQuote::from_db(
2092 QuoteId::new_uuid(),
2093 CurrencyUnit::Sat,
2094 MeltPaymentRequest::Onchain {
2095 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2096 },
2097 10_000,
2098 100,
2099 MeltQuoteState::Unpaid,
2100 unix_time() + 3600,
2101 None,
2102 None,
2103 None,
2104 unix_time(),
2105 None,
2106 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2107 None,
2108 Some(6),
2109 Vec::new(),
2110 None,
2111 )
2112 .expect_err("empty onchain fee_options on reload must be rejected");
2113 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2114 }
2115}