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, 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::now_v7(),
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::now_v7(),
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::now_v7(),
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::now_v7(),
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 last_checked: u64,
565 pub payments: Vec<IncomingPayment>,
567 pub payment_method: PaymentMethod,
569 pub issuance: Vec<Issuance>,
571 pub extra_json: Option<serde_json::Value>,
573 changes: Option<MintQuoteChange>,
579}
580
581impl MintQuote {
582 #[allow(clippy::too_many_arguments)]
584 pub fn new(
585 id: Option<QuoteId>,
586 request: String,
587 unit: CurrencyUnit,
588 amount: Option<Amount<CurrencyUnit>>,
589 expiry: u64,
590 request_lookup_id: PaymentIdentifier,
591 pubkey: Option<PublicKey>,
592 amount_paid: Amount<CurrencyUnit>,
593 amount_issued: Amount<CurrencyUnit>,
594 payment_method: PaymentMethod,
595 created_time: u64,
596 payments: Vec<IncomingPayment>,
597 issuance: Vec<Issuance>,
598 extra_json: Option<serde_json::Value>,
599 ) -> Self {
600 let id = id.unwrap_or_default();
601
602 Self {
603 id,
604 amount,
605 unit: unit.clone(),
606 request,
607 expiry,
608 request_lookup_id,
609 pubkey,
610 created_time,
611 amount_paid,
612 amount_issued,
613 last_checked: 0,
614 payment_method,
615 payments,
616 issuance,
617 extra_json,
618 changes: None,
619 }
620 }
621
622 #[instrument(skip(self))]
624 pub fn increment_amount_paid(
625 &mut self,
626 additional_amount: Amount<CurrencyUnit>,
627 ) -> Result<Amount, crate::Error> {
628 self.amount_paid = self
629 .amount_paid
630 .checked_add(&additional_amount)
631 .map_err(|_| crate::Error::AmountOverflow)?;
632 Ok(Amount::from(self.amount_paid.value()))
633 }
634
635 #[instrument(skip(self))]
637 pub fn amount_paid(&self) -> Amount<CurrencyUnit> {
638 self.amount_paid.clone()
639 }
640
641 #[instrument(skip(self))]
664 pub fn add_issuance(
665 &mut self,
666 additional_amount: Amount<CurrencyUnit>,
667 ) -> Result<Amount<CurrencyUnit>, crate::Error> {
668 let new_amount_issued = self
669 .amount_issued
670 .checked_add(&additional_amount)
671 .map_err(|_| crate::Error::AmountOverflow)?;
672
673 if new_amount_issued > self.amount_paid {
675 return Err(crate::Error::OverIssue);
676 }
677
678 self.changes
679 .get_or_insert_default()
680 .issuances
681 .get_or_insert_default()
682 .push(additional_amount.into());
683
684 self.amount_issued = new_amount_issued;
685
686 Ok(self.amount_issued.clone())
687 }
688
689 #[instrument(skip(self))]
691 pub fn amount_issued(&self) -> Amount<CurrencyUnit> {
692 self.amount_issued.clone()
693 }
694
695 pub fn last_checked(&self) -> u64 {
697 self.last_checked
698 }
699
700 pub fn set_last_checked(&mut self, last_checked: u64) {
702 self.last_checked = last_checked;
703 }
704
705 #[instrument(skip(self))]
707 pub fn state(&self) -> MintQuoteState {
708 self.compute_quote_state()
709 }
710
711 pub fn payment_ids(&self) -> Vec<&String> {
713 self.payments.iter().map(|a| &a.payment_id).collect()
714 }
715
716 pub fn amount_mintable(&self) -> Amount<CurrencyUnit> {
723 self.amount_paid
724 .checked_sub(&self.amount_issued)
725 .unwrap_or_else(|_| Amount::new(0, self.unit.clone()))
726 }
727
728 pub fn take_changes(&mut self) -> Option<MintQuoteChange> {
738 self.changes.take()
739 }
740
741 #[instrument(skip(self))]
761 pub fn add_payment(
762 &mut self,
763 amount: Amount<CurrencyUnit>,
764 payment_id: String,
765 time: Option<u64>,
766 ) -> Result<(), crate::Error> {
767 let time = time.unwrap_or_else(unix_time);
768
769 let payment_ids = self.payment_ids();
770 if payment_ids.contains(&&payment_id) {
771 return Err(crate::Error::DuplicatePaymentId);
772 }
773
774 self.amount_paid = self
775 .amount_paid
776 .checked_add(&amount)
777 .map_err(|_| crate::Error::AmountOverflow)?;
778
779 let payment = IncomingPayment::new(amount, payment_id, time);
780
781 self.payments.push(payment.clone());
782
783 self.changes
784 .get_or_insert_default()
785 .payments
786 .get_or_insert_default()
787 .push(payment);
788
789 Ok(())
790 }
791
792 #[instrument(skip(self))]
794 fn compute_quote_state(&self) -> MintQuoteState {
795 let zero_amount = Amount::new(0, self.unit.clone());
796
797 if self.amount_paid == zero_amount && self.amount_issued == zero_amount {
798 return MintQuoteState::Unpaid;
799 }
800
801 match self.amount_paid.value().cmp(&self.amount_issued.value()) {
802 std::cmp::Ordering::Less => {
803 tracing::error!("We should not have issued more then has been paid");
804 MintQuoteState::Issued
805 }
806 std::cmp::Ordering::Equal => MintQuoteState::Issued,
807 std::cmp::Ordering::Greater => MintQuoteState::Paid,
808 }
809 }
810}
811
812#[derive(Debug, Clone, Hash, PartialEq, Eq)]
814pub struct IncomingPayment {
815 pub amount: Amount<CurrencyUnit>,
817 pub time: u64,
819 pub payment_id: String,
821}
822
823impl IncomingPayment {
824 pub fn new(amount: Amount<CurrencyUnit>, payment_id: String, time: u64) -> Self {
826 Self {
827 payment_id,
828 time,
829 amount,
830 }
831 }
832}
833
834#[derive(Debug, Clone, Hash, PartialEq, Eq)]
836pub struct Issuance {
837 pub amount: Amount<CurrencyUnit>,
839 pub time: u64,
841}
842
843impl Issuance {
844 pub fn new(amount: Amount<CurrencyUnit>, time: u64) -> Self {
846 Self { amount, time }
847 }
848}
849
850#[derive(Debug, Clone, Hash, PartialEq, Eq)]
852pub struct MeltQuote {
853 pub id: QuoteId,
855 pub unit: CurrencyUnit,
857 pub request: MeltPaymentRequest,
859 amount: Amount<CurrencyUnit>,
861 fee_reserve: Amount<CurrencyUnit>,
863 pub state: MeltQuoteState,
865 pub expiry: u64,
867 pub payment_proof: Option<String>,
869 pub request_lookup_id: Option<PaymentIdentifier>,
871 pub options: Option<MeltOptions>,
875 pub created_time: u64,
877 pub paid_time: Option<u64>,
879 pub payment_method: PaymentMethod,
881 pub extra_json: Option<serde_json::Value>,
883 pub estimated_blocks: Option<u32>,
885 fee_options: Vec<MeltQuoteOnchainFeeOption>,
895 pub selected_fee_index: Option<u32>,
897}
898
899impl MeltQuote {
900 #[allow(clippy::too_many_arguments)]
902 pub fn new(
903 id: Option<QuoteId>,
904 request: MeltPaymentRequest,
905 unit: CurrencyUnit,
906 amount: Amount<CurrencyUnit>,
907 fee_reserve: Amount<CurrencyUnit>,
908 expiry: u64,
909 request_lookup_id: Option<PaymentIdentifier>,
910 options: Option<MeltOptions>,
911 payment_method: PaymentMethod,
912 extra_json: Option<serde_json::Value>,
913 estimated_blocks: Option<u32>,
914 ) -> Self {
915 let id = id.unwrap_or_default();
916
917 let fee_options = estimated_blocks
918 .map(|estimated_blocks| {
919 vec![MeltQuoteOnchainFeeOption {
920 fee_index: 0,
921 fee_reserve: fee_reserve.clone().into(),
922 estimated_blocks,
923 }]
924 })
925 .unwrap_or_default();
926
927 Self {
928 id,
929 unit: unit.clone(),
930 request,
931 amount,
932 fee_reserve,
933 state: MeltQuoteState::Unpaid,
934 expiry,
935 payment_proof: None,
936 request_lookup_id,
937 options,
938 created_time: unix_time(),
939 paid_time: None,
940 payment_method,
941 extra_json,
942 estimated_blocks,
943 fee_options,
944 selected_fee_index: None,
945 }
946 }
947
948 #[allow(clippy::too_many_arguments)]
960 pub fn new_onchain(
961 id: Option<QuoteId>,
962 request: MeltPaymentRequest,
963 unit: CurrencyUnit,
964 amount: Amount<CurrencyUnit>,
965 expiry: u64,
966 request_lookup_id: Option<PaymentIdentifier>,
967 extra_json: Option<serde_json::Value>,
968 fee_options: Vec<MeltQuoteOnchainFeeOption>,
969 ) -> Result<Self, crate::Error> {
970 if fee_options.is_empty() {
971 return Err(crate::Error::OnchainFeeOptionsEmpty);
972 }
973
974 validate_onchain_fee_options(&fee_options)?;
975
976 let id = id.unwrap_or_default();
977
978 let initial = fee_options
982 .iter()
983 .min_by_key(|option| u64::from(option.fee_reserve))
984 .copied()
985 .ok_or(crate::Error::OnchainFeeOptionsEmpty)?;
986
987 let fee_reserve = initial.fee_reserve.with_unit(unit.clone());
988 let estimated_blocks = Some(initial.estimated_blocks);
989
990 Ok(Self {
991 id,
992 unit: unit.clone(),
993 request,
994 amount,
995 fee_reserve,
996 state: MeltQuoteState::Unpaid,
997 expiry,
998 payment_proof: None,
999 request_lookup_id,
1000 options: None,
1001 created_time: unix_time(),
1002 paid_time: None,
1003 payment_method: PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1004 extra_json,
1005 estimated_blocks,
1006 fee_options,
1007 selected_fee_index: None,
1008 })
1009 }
1010
1011 #[inline]
1017 pub fn fee_options(&self) -> &[MeltQuoteOnchainFeeOption] {
1018 &self.fee_options
1019 }
1020
1021 #[inline]
1023 pub fn amount(&self) -> Amount<CurrencyUnit> {
1024 self.amount.clone()
1025 }
1026
1027 #[inline]
1029 pub fn fee_reserve(&self) -> Amount<CurrencyUnit> {
1030 self.fee_reserve.clone()
1031 }
1032
1033 pub fn select_onchain_fee_option(&mut self, fee_index: u32) -> Result<(), crate::Error> {
1035 let option = self
1036 .fee_options
1037 .iter()
1038 .find(|option| option.fee_index == fee_index)
1039 .copied()
1040 .ok_or(crate::Error::OnchainFeeIndexNotFound { index: fee_index })?;
1041
1042 if self
1043 .selected_fee_index
1044 .is_some_and(|selected| selected != fee_index)
1045 {
1046 return Err(crate::Error::InvalidPaymentRequest);
1047 }
1048
1049 self.fee_reserve = option.fee_reserve.with_unit(self.unit.clone());
1050 self.estimated_blocks = Some(option.estimated_blocks);
1051 self.selected_fee_index = Some(fee_index);
1052
1053 Ok(())
1054 }
1055
1056 pub fn into_response(
1064 self,
1065 change: Option<Vec<cashu::nuts::BlindSignature>>,
1066 ) -> crate::MeltQuoteResponse<QuoteId> {
1067 match self.payment_method {
1068 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt11) => {
1069 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1070 response.change = change;
1071 crate::MeltQuoteResponse::Bolt11(response)
1072 }
1073 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Bolt12) => {
1074 let mut response: MeltQuoteBolt11Response<QuoteId> = self.into();
1075 response.change = change;
1076 crate::MeltQuoteResponse::Bolt12(response)
1077 }
1078 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) => {
1079 let mut response: MeltQuoteOnchainResponse<QuoteId> = self.into();
1080 response.change = change;
1081 crate::MeltQuoteResponse::Onchain(response)
1082 }
1083 _ => {
1084 let method = self.payment_method.clone();
1085 let mut response: MeltQuoteCustomResponse<QuoteId> = self.into();
1086 response.change = change;
1087 crate::MeltQuoteResponse::Custom((method, response))
1088 }
1089 }
1090 }
1091
1092 pub fn total_needed(&self) -> Result<Amount, crate::Error> {
1094 let total = self
1095 .amount
1096 .checked_add(&self.fee_reserve)
1097 .map_err(|_| crate::Error::AmountOverflow)?;
1098 Ok(Amount::from(total.value()))
1099 }
1100
1101 #[allow(clippy::too_many_arguments)]
1103 pub fn from_db(
1104 id: QuoteId,
1105 unit: CurrencyUnit,
1106 request: MeltPaymentRequest,
1107 amount: u64,
1108 fee_reserve: u64,
1109 state: MeltQuoteState,
1110 expiry: u64,
1111 payment_proof: Option<String>,
1112 request_lookup_id: Option<PaymentIdentifier>,
1113 options: Option<MeltOptions>,
1114 created_time: u64,
1115 paid_time: Option<u64>,
1116 payment_method: PaymentMethod,
1117 extra_json: Option<serde_json::Value>,
1118 estimated_blocks: Option<u32>,
1119 fee_options: Vec<MeltQuoteOnchainFeeOption>,
1120 selected_fee_index: Option<u32>,
1121 ) -> Result<Self, crate::Error> {
1122 if payment_method == PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain) {
1127 validate_onchain_fee_options(&fee_options)?;
1128 }
1129
1130 Ok(Self {
1131 id,
1132 unit: unit.clone(),
1133 request,
1134 amount: Amount::new(amount, unit.clone()),
1135 fee_reserve: Amount::new(fee_reserve, unit),
1136 state,
1137 expiry,
1138 payment_proof,
1139 request_lookup_id,
1140 options,
1141 created_time,
1142 paid_time,
1143 payment_method,
1144 extra_json,
1145 estimated_blocks,
1146 fee_options,
1147 selected_fee_index,
1148 })
1149 }
1150}
1151
1152pub fn validate_onchain_fee_options(
1161 fee_options: &[MeltQuoteOnchainFeeOption],
1162) -> Result<(), crate::Error> {
1163 if fee_options.is_empty() {
1164 return Err(crate::Error::OnchainFeeOptionsEmpty);
1165 }
1166
1167 Ok(())
1168}
1169
1170impl From<MeltQuote> for MeltQuoteOnchainResponse<QuoteId> {
1171 fn from(quote: MeltQuote) -> Self {
1172 Self {
1173 quote: quote.id.clone(),
1174 amount: quote.amount().into(),
1175 unit: quote.unit.clone(),
1176 state: quote.state,
1177 expiry: quote.expiry,
1178 request: quote.request.to_string(),
1179 fee_options: quote.fee_options().to_vec(),
1180 selected_fee_index: quote.selected_fee_index,
1181 outpoint: quote.payment_proof.clone(),
1182 change: None,
1183 }
1184 }
1185}
1186
1187impl TryFrom<MintQuote> for MintQuoteOnchainResponse<QuoteId> {
1188 type Error = crate::error::Error;
1189 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1190 Ok(Self {
1191 quote: quote.id.clone(),
1192 request: quote.request.clone(),
1193 unit: quote.unit.clone(),
1194 expiry: (quote.expiry != 0).then_some(quote.expiry),
1195 pubkey: quote.pubkey.ok_or(crate::error::Error::MissingPubkey)?,
1196 amount_paid: quote.amount_paid().into(),
1197 amount_issued: quote.amount_issued().into(),
1198 })
1199 }
1200}
1201
1202#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1204pub struct MintKeySetInfo {
1205 pub id: Id,
1207 pub unit: CurrencyUnit,
1209 pub active: bool,
1212 pub valid_from: u64,
1214 pub derivation_path: DerivationPath,
1216 pub derivation_path_index: Option<u32>,
1218 pub amounts: Vec<u64>,
1220 #[serde(default = "default_fee")]
1222 pub input_fee_ppk: u64,
1223 pub final_expiry: Option<u64>,
1225 pub issuer_version: Option<IssuerVersion>,
1227}
1228
1229impl MintKeySetInfo {
1230 pub fn is_expired(&self) -> bool {
1232 self.final_expiry.is_some_and(|expiry| expiry < unix_time())
1233 }
1234}
1235
1236pub fn default_fee() -> u64 {
1238 0
1239}
1240
1241impl From<MintKeySetInfo> for KeySetInfo {
1242 fn from(keyset_info: MintKeySetInfo) -> Self {
1243 Self {
1244 id: keyset_info.id,
1245 unit: keyset_info.unit,
1246 active: keyset_info.active,
1247 input_fee_ppk: keyset_info.input_fee_ppk,
1248 final_expiry: keyset_info.final_expiry,
1249 }
1250 }
1251}
1252
1253impl From<MintQuote> for MintQuoteBolt11Response<QuoteId> {
1254 fn from(mint_quote: MintQuote) -> MintQuoteBolt11Response<QuoteId> {
1255 MintQuoteBolt11Response {
1256 quote: mint_quote.id.clone(),
1257 state: mint_quote.state(),
1258 request: mint_quote.request,
1259 expiry: Some(mint_quote.expiry),
1260 pubkey: mint_quote.pubkey,
1261 amount: mint_quote.amount.map(Into::into),
1262 unit: Some(mint_quote.unit),
1263 }
1264 }
1265}
1266
1267impl From<MintQuote> for MintQuoteBolt11Response<String> {
1268 fn from(quote: MintQuote) -> Self {
1269 let quote: MintQuoteBolt11Response<QuoteId> = quote.into();
1270 quote.into()
1271 }
1272}
1273
1274impl TryFrom<MintQuote> for MintQuoteBolt12Response<QuoteId> {
1275 type Error = Error;
1276
1277 fn try_from(mint_quote: MintQuote) -> Result<Self, Self::Error> {
1278 Ok(MintQuoteBolt12Response {
1279 quote: mint_quote.id.clone(),
1280 request: mint_quote.request,
1281 expiry: (mint_quote.expiry != 0).then_some(mint_quote.expiry),
1282 amount_paid: mint_quote.amount_paid.into(),
1283 amount_issued: mint_quote.amount_issued.into(),
1284 pubkey: mint_quote.pubkey.ok_or(Error::PubkeyRequired)?,
1285 amount: mint_quote.amount.map(Into::into),
1286 unit: mint_quote.unit,
1287 })
1288 }
1289}
1290
1291impl TryFrom<MintQuote> for MintQuoteBolt12Response<String> {
1292 type Error = Error;
1293
1294 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1295 let quote: MintQuoteBolt12Response<QuoteId> = quote.try_into()?;
1296 Ok(quote.into())
1297 }
1298}
1299
1300impl TryFrom<MintQuote> for MintQuoteCustomResponse<QuoteId> {
1301 type Error = Error;
1302
1303 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1304 let amount_paid = quote.amount_paid().into();
1305 let amount_issued = quote.amount_issued().into();
1306
1307 Ok(MintQuoteCustomResponse {
1308 quote: quote.id,
1309 request: quote.request,
1310 unit: Some(quote.unit),
1311 expiry: Some(quote.expiry),
1312 pubkey: quote.pubkey,
1313 amount: quote.amount.map(Into::into),
1314 amount_paid,
1315 amount_issued,
1316 extra: quote.extra_json.unwrap_or_default(),
1317 })
1318 }
1319}
1320
1321impl TryFrom<MintQuote> for MintQuoteCustomResponse<String> {
1322 type Error = Error;
1323
1324 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1325 let quote: MintQuoteCustomResponse<QuoteId> = quote.try_into()?;
1326 Ok(quote.into())
1327 }
1328}
1329
1330impl From<MeltQuote> for crate::nuts::MeltQuoteCustomResponse<QuoteId> {
1331 fn from(melt_quote: MeltQuote) -> Self {
1332 let request = match melt_quote.request {
1333 MeltPaymentRequest::Custom { request, .. } => Some(request),
1334 _ => None,
1335 };
1336
1337 Self {
1338 quote: melt_quote.id,
1339 amount: melt_quote.amount.into(),
1340 fee_reserve: Some(melt_quote.fee_reserve.into()),
1341 state: melt_quote.state,
1342 expiry: melt_quote.expiry,
1343 payment_preimage: melt_quote.payment_proof,
1344 change: None,
1345 request,
1346 unit: Some(melt_quote.unit),
1347 extra: melt_quote.extra_json.unwrap_or_default(),
1348 }
1349 }
1350}
1351impl TryFrom<MintQuote> for MintQuoteResponse<QuoteId> {
1352 type Error = Error;
1353
1354 fn try_from(quote: MintQuote) -> Result<Self, Self::Error> {
1355 if quote.payment_method.is_bolt11() {
1356 Ok(Self::Bolt11(crate::nuts::nut23::MintQuoteBolt11Response {
1357 quote: quote.id.clone(),
1358 request: quote.request.clone(),
1359 state: quote.state(),
1360 expiry: Some(quote.expiry),
1361 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1362 unit: Some(quote.unit.clone()),
1363 pubkey: quote.pubkey,
1364 }))
1365 } else if quote.payment_method.is_bolt12() {
1366 Ok(Self::Bolt12(crate::nuts::nut25::MintQuoteBolt12Response {
1367 quote: quote.id.clone(),
1368 request: quote.request.clone(),
1369 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1370 unit: quote.unit.clone(),
1371 expiry: (quote.expiry != 0).then_some(quote.expiry),
1372 pubkey: quote.pubkey.ok_or(Error::PubkeyRequired)?,
1373 amount_paid: quote.amount_paid().into(),
1374 amount_issued: quote.amount_issued().into(),
1375 }))
1376 } else if quote.payment_method.is_onchain() {
1377 let onchain_response = MintQuoteOnchainResponse::try_from(quote)?;
1378 Ok(MintQuoteResponse::Onchain(onchain_response))
1379 } else {
1380 let method = quote.payment_method.clone();
1381 Ok(MintQuoteResponse::Custom {
1382 method,
1383 response: crate::nuts::nut04::MintQuoteCustomResponse {
1384 quote: quote.id.clone(),
1385 request: quote.request.clone(),
1386 expiry: Some(quote.expiry),
1387 amount: quote.amount.as_ref().map(|a| a.clone().into()),
1388 amount_paid: quote.amount_paid().into(),
1389 amount_issued: quote.amount_issued().into(),
1390 unit: Some(quote.unit.clone()),
1391 pubkey: quote.pubkey,
1392 extra: quote.extra_json.clone().unwrap_or_default(),
1393 },
1394 })
1395 }
1396 }
1397}
1398
1399impl From<MintQuoteResponse<QuoteId>> for MintQuoteResponse<String> {
1400 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1401 match response {
1402 MintQuoteResponse::Bolt11(response) => MintQuoteResponse::Bolt11(response.into()),
1403 MintQuoteResponse::Bolt12(response) => MintQuoteResponse::Bolt12(response.into()),
1404 MintQuoteResponse::Onchain(response) => MintQuoteResponse::Onchain(response.into()),
1405 MintQuoteResponse::Custom { method, response } => MintQuoteResponse::Custom {
1406 method,
1407 response: response.into(),
1408 },
1409 }
1410 }
1411}
1412
1413impl From<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<String> {
1414 fn from(response: MintQuoteResponse<QuoteId>) -> Self {
1415 match response {
1416 MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response {
1417 quote: bolt11_response.quote.to_string(),
1418 state: bolt11_response.state,
1419 request: bolt11_response.request,
1420 expiry: bolt11_response.expiry,
1421 pubkey: bolt11_response.pubkey,
1422 amount: bolt11_response.amount,
1423 unit: bolt11_response.unit,
1424 },
1425 _ => panic!("Expected Bolt11 response"),
1426 }
1427 }
1428}
1429
1430impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt11Response<QuoteId> {
1431 type Error = Error;
1432
1433 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1434 match response {
1435 MintQuoteResponse::Bolt11(r) => Ok(r),
1436 _ => Err(Error::InvalidPaymentMethod),
1437 }
1438 }
1439}
1440
1441impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteBolt12Response<QuoteId> {
1442 type Error = Error;
1443
1444 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1445 match response {
1446 MintQuoteResponse::Bolt12(r) => Ok(r),
1447 _ => Err(Error::InvalidPaymentMethod),
1448 }
1449 }
1450}
1451
1452impl TryFrom<MintQuoteResponse<QuoteId>> for MintQuoteOnchainResponse<QuoteId> {
1453 type Error = Error;
1454
1455 fn try_from(response: MintQuoteResponse<QuoteId>) -> Result<Self, Self::Error> {
1456 match response {
1457 MintQuoteResponse::Onchain(r) => Ok(r),
1458 _ => Err(Error::InvalidPaymentMethod),
1459 }
1460 }
1461}
1462
1463impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1464 fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1465 MeltQuoteBolt11Response {
1466 quote: melt_quote.id.clone(),
1467 payment_preimage: None,
1468 change: None,
1469 state: melt_quote.state,
1470 expiry: melt_quote.expiry,
1471 amount: melt_quote.amount().into(),
1472 fee_reserve: melt_quote.fee_reserve().into(),
1473 request: None,
1474 unit: Some(melt_quote.unit.clone()),
1475 }
1476 }
1477}
1478
1479impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
1480 fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
1481 MeltQuoteBolt11Response {
1482 quote: melt_quote.id.clone(),
1483 amount: melt_quote.amount().into(),
1484 fee_reserve: melt_quote.fee_reserve().into(),
1485 state: melt_quote.state,
1486 expiry: melt_quote.expiry,
1487 payment_preimage: melt_quote.payment_proof,
1488 change: None,
1489 request: Some(melt_quote.request.to_string()),
1490 unit: Some(melt_quote.unit.clone()),
1491 }
1492 }
1493}
1494
1495#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
1497pub enum MeltPaymentRequest {
1498 Bolt11 {
1500 bolt11: Bolt11Invoice,
1502 },
1503 Bolt12 {
1505 #[serde(with = "offer_serde")]
1507 offer: Box<Offer>,
1508 },
1509 Custom {
1511 method: String,
1513 request: String,
1515 },
1516 Onchain {
1518 address: String,
1520 },
1521}
1522
1523impl std::fmt::Display for MeltPaymentRequest {
1524 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1525 match self {
1526 MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"),
1527 MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"),
1528 MeltPaymentRequest::Custom { request, .. } => write!(f, "{request}"),
1529 MeltPaymentRequest::Onchain { address } => write!(f, "{address}"),
1530 }
1531 }
1532}
1533
1534mod offer_serde {
1535 use std::str::FromStr;
1536
1537 use serde::{self, Deserialize, Deserializer, Serializer};
1538
1539 use super::Offer;
1540
1541 pub fn serialize<S>(offer: &Offer, serializer: S) -> Result<S::Ok, S::Error>
1542 where
1543 S: Serializer,
1544 {
1545 let s = offer.to_string();
1546 serializer.serialize_str(&s)
1547 }
1548
1549 pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<Offer>, D::Error>
1550 where
1551 D: Deserializer<'de>,
1552 {
1553 let s = String::deserialize(deserializer)?;
1554 Ok(Box::new(Offer::from_str(&s).map_err(|_| {
1555 serde::de::Error::custom("Invalid Bolt12 Offer")
1556 })?))
1557 }
1558}
1559
1560#[cfg(test)]
1561mod tests {
1562 use std::str::FromStr;
1563
1564 use cashu::Bolt11Invoice;
1565
1566 use super::*;
1567
1568 #[test]
1569 fn test_operation_new_mint_uses_uuid_v7() {
1570 let operation = Operation::new_mint(Amount::from(100), PaymentMethod::BOLT11);
1571
1572 assert_eq!(operation.id.get_version(), Some(uuid::Version::SortRand));
1573 }
1574
1575 #[test]
1576 fn test_melt_quote_to_custom_response_with_custom_request() {
1577 let melt_quote = MeltQuote::new(
1578 Some(QuoteId::new()),
1579 MeltPaymentRequest::Custom {
1580 method: "custom".to_string(),
1581 request: "custom_request_string".to_string(),
1582 },
1583 CurrencyUnit::Sat,
1584 Amount::new(100, CurrencyUnit::Sat),
1585 Amount::new(2, CurrencyUnit::Sat),
1586 unix_time() + 3600,
1587 None,
1588 None,
1589 PaymentMethod::Custom("custom".to_string()),
1590 Some(serde_json::json!({"extra_field": "value"})),
1591 None,
1592 );
1593
1594 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1595
1596 assert_eq!(response.quote, melt_quote.id);
1597 assert_eq!(response.amount, 100.into());
1598 assert_eq!(response.fee_reserve, Some(2.into()));
1599 assert_eq!(response.state, melt_quote.state);
1600 assert_eq!(response.expiry, melt_quote.expiry);
1601 assert_eq!(response.payment_preimage, melt_quote.payment_proof);
1602 assert_eq!(response.change, None);
1603 assert_eq!(response.request, Some("custom_request_string".to_string()));
1604 assert_eq!(response.unit, Some(CurrencyUnit::Sat));
1605 assert_eq!(response.extra, serde_json::json!({"extra_field": "value"}));
1606 }
1607
1608 #[test]
1609 fn test_melt_quote_to_custom_response_with_bolt11_request() {
1610 let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
1611 let bolt11 = Bolt11Invoice::from_str(bolt11_str).unwrap();
1612
1613 let melt_quote = MeltQuote::new(
1614 Some(QuoteId::new()),
1615 MeltPaymentRequest::Bolt11 { bolt11 },
1616 CurrencyUnit::Sat,
1617 Amount::new(100, CurrencyUnit::Sat),
1618 Amount::new(2, CurrencyUnit::Sat),
1619 unix_time() + 3600,
1620 None,
1621 None,
1622 PaymentMethod::BOLT11,
1623 None,
1624 None,
1625 );
1626
1627 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1628
1629 assert_eq!(response.quote, melt_quote.id);
1630 assert_eq!(response.request, None);
1631 }
1632
1633 #[test]
1634 fn test_melt_quote_to_custom_response_with_bolt12_request() {
1635 use bitcoin::secp256k1::{PublicKey as Secp256k1PublicKey, Secp256k1, SecretKey};
1636 use lightning::offers::offer::OfferBuilder;
1637 let secp = Secp256k1::new();
1638 let secret_key = SecretKey::from_slice(&[0xcd; 32]).unwrap();
1639 let pubkey = Secp256k1PublicKey::from_secret_key(&secp, &secret_key);
1640 let offer = OfferBuilder::new(pubkey).build().unwrap();
1641
1642 let melt_quote = MeltQuote::new(
1643 Some(QuoteId::new()),
1644 MeltPaymentRequest::Bolt12 {
1645 offer: Box::new(offer),
1646 },
1647 CurrencyUnit::Sat,
1648 Amount::new(100, CurrencyUnit::Sat),
1649 Amount::new(2, CurrencyUnit::Sat),
1650 unix_time() + 3600,
1651 None,
1652 None,
1653 PaymentMethod::BOLT12,
1654 None,
1655 None,
1656 );
1657
1658 let response: crate::nuts::MeltQuoteCustomResponse<QuoteId> = melt_quote.clone().into();
1659
1660 assert_eq!(response.quote, melt_quote.id);
1661 assert_eq!(response.request, None);
1662 }
1663
1664 fn dummy_mint_keyset_info(final_expiry: Option<u64>) -> MintKeySetInfo {
1665 use std::str::FromStr;
1666 MintKeySetInfo {
1667 id: Id::from_str("009a1f293253e41e").unwrap(),
1668 unit: CurrencyUnit::Sat,
1669 active: true,
1670 valid_from: 0,
1671 derivation_path: "m/0'/0'/0'".parse().unwrap(),
1672 derivation_path_index: Some(0),
1673 amounts: vec![1, 2, 4, 8, 16, 32, 64, 128, 256, 512],
1674 input_fee_ppk: 0,
1675 final_expiry,
1676 issuer_version: None,
1677 }
1678 }
1679
1680 #[test]
1681 fn test_is_expired_none() {
1682 let info = dummy_mint_keyset_info(None);
1683 assert!(!info.is_expired());
1684 }
1685
1686 #[test]
1687 fn test_is_expired_far_future() {
1688 let info = dummy_mint_keyset_info(Some(unix_time() + 1_000_000));
1689 assert!(!info.is_expired());
1690 }
1691
1692 #[test]
1693 fn test_is_expired_exactly_now_is_not_expired() {
1694 let info = dummy_mint_keyset_info(Some(unix_time()));
1696 assert!(!info.is_expired());
1697 }
1698
1699 #[test]
1700 fn test_is_expired_one_second_ago() {
1701 let info = dummy_mint_keyset_info(Some(unix_time() - 1));
1702 assert!(info.is_expired());
1703 }
1704
1705 #[test]
1706 fn test_is_expired_zero() {
1707 let info = dummy_mint_keyset_info(Some(0));
1708 assert!(info.is_expired());
1709 }
1710
1711 #[test]
1712 fn test_melt_quote_into_response_onchain() {
1713 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1714 let mut melt_quote = MeltQuote::new(
1715 Some(QuoteId::new()),
1716 MeltPaymentRequest::Onchain {
1717 address: address.to_string(),
1718 },
1719 CurrencyUnit::Sat,
1720 Amount::new(5_000, CurrencyUnit::Sat),
1721 Amount::new(250, CurrencyUnit::Sat),
1722 unix_time() + 3600,
1723 None,
1724 None,
1725 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1726 None,
1727 Some(6),
1728 );
1729
1730 melt_quote.payment_proof =
1732 Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:1".to_string());
1733 melt_quote.state = MeltQuoteState::Paid;
1734
1735 let expected_id = melt_quote.id.clone();
1736 let expected_amount: Amount = melt_quote.amount().into();
1737 let expected_fee_options = melt_quote.fee_options().to_vec();
1738 let expected_expiry = melt_quote.expiry;
1739 let expected_state = melt_quote.state;
1740 let expected_outpoint = melt_quote.payment_proof.clone();
1741
1742 let response = melt_quote.into_response(None);
1743 match response {
1744 crate::MeltQuoteResponse::Onchain(r) => {
1745 assert_eq!(r.quote, expected_id);
1746 assert_eq!(r.request, address);
1747 assert_eq!(r.amount, expected_amount);
1748 assert_eq!(r.unit, CurrencyUnit::Sat);
1749 assert_eq!(r.fee_options, expected_fee_options);
1750 assert_eq!(r.selected_fee_index, None);
1751 assert_eq!(r.state, expected_state);
1752 assert_eq!(r.expiry, expected_expiry);
1753 assert_eq!(r.outpoint, expected_outpoint);
1754 assert_eq!(r.change, None);
1755 }
1756 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1757 }
1758 }
1759
1760 #[test]
1761 fn test_mint_quote_onchain_response_converts_zero_expiry_to_none() {
1762 let pubkey = PublicKey::from_hex(
1763 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1764 )
1765 .unwrap();
1766 let quote_id = QuoteId::new();
1767 let mint_quote = MintQuote::new(
1768 Some(quote_id.clone()),
1769 "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
1770 CurrencyUnit::Sat,
1771 None,
1772 0,
1773 PaymentIdentifier::QuoteId(quote_id.clone()),
1774 Some(pubkey),
1775 Amount::new(10_000, CurrencyUnit::Sat),
1776 Amount::new(1_000, CurrencyUnit::Sat),
1777 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1778 unix_time(),
1779 vec![],
1780 vec![],
1781 None,
1782 );
1783
1784 let response = MintQuoteOnchainResponse::try_from(mint_quote).unwrap();
1785
1786 assert_eq!(response.quote, quote_id);
1787 assert_eq!(response.expiry, None);
1788 assert_eq!(response.pubkey, pubkey);
1789 assert_eq!(response.amount_paid, Amount::from(10_000));
1790 assert_eq!(response.amount_issued, Amount::from(1_000));
1791 }
1792
1793 fn dummy_bolt12_mint_quote(expiry: u64) -> (MintQuote, QuoteId, PublicKey) {
1794 let pubkey = PublicKey::from_hex(
1795 "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac",
1796 )
1797 .expect("test pubkey must parse");
1798 let quote_id = QuoteId::new();
1799 let mint_quote = MintQuote::new(
1800 Some(quote_id.clone()),
1801 "lno1testoffer".to_string(),
1802 CurrencyUnit::Sat,
1803 Some(Amount::new(10_000, CurrencyUnit::Sat)),
1804 expiry,
1805 PaymentIdentifier::QuoteId(quote_id.clone()),
1806 Some(pubkey),
1807 Amount::new(10_000, CurrencyUnit::Sat),
1808 Amount::new(1_000, CurrencyUnit::Sat),
1809 PaymentMethod::BOLT12,
1810 unix_time(),
1811 vec![],
1812 vec![],
1813 None,
1814 );
1815
1816 (mint_quote, quote_id, pubkey)
1817 }
1818
1819 #[test]
1820 fn test_mint_quote_bolt12_response_converts_zero_expiry_to_none() {
1821 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1822
1823 let response: MintQuoteBolt12Response<QuoteId> =
1824 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1825
1826 assert_eq!(response.quote, quote_id);
1827 assert_eq!(response.expiry, None);
1828 assert_eq!(response.pubkey, pubkey);
1829 assert_eq!(response.amount_paid, Amount::from(10_000));
1830 assert_eq!(response.amount_issued, Amount::from(1_000));
1831 }
1832
1833 #[test]
1834 fn test_mint_quote_bolt12_response_preserves_nonzero_expiry() {
1835 let expiry = unix_time() + 3600;
1836 let (mint_quote, quote_id, _) = dummy_bolt12_mint_quote(expiry);
1837
1838 let response: MintQuoteBolt12Response<QuoteId> =
1839 MintQuoteBolt12Response::try_from(mint_quote).unwrap();
1840
1841 assert_eq!(response.quote, quote_id);
1842 assert_eq!(response.expiry, Some(expiry));
1843 }
1844
1845 #[test]
1846 fn test_mint_quote_response_bolt12_converts_zero_expiry_to_none() {
1847 let (mint_quote, quote_id, pubkey) = dummy_bolt12_mint_quote(0);
1848
1849 let response = MintQuoteResponse::try_from(mint_quote).unwrap();
1850
1851 match response {
1852 MintQuoteResponse::Bolt12(response) => {
1853 assert_eq!(response.quote, quote_id);
1854 assert_eq!(response.expiry, None);
1855 assert_eq!(response.pubkey, pubkey);
1856 }
1857 _ => panic!("expected MintQuoteResponse::Bolt12 variant"),
1858 }
1859 }
1860
1861 #[test]
1862 fn test_melt_quote_into_response_onchain_includes_change() {
1863 let address = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
1864 let melt_quote = MeltQuote::new(
1865 Some(QuoteId::new()),
1866 MeltPaymentRequest::Onchain {
1867 address: address.to_string(),
1868 },
1869 CurrencyUnit::Sat,
1870 Amount::new(1_000, CurrencyUnit::Sat),
1871 Amount::new(10, CurrencyUnit::Sat),
1872 unix_time() + 3600,
1873 None,
1874 None,
1875 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
1876 None,
1877 Some(3),
1878 );
1879
1880 let response = melt_quote.into_response(Some(vec![]));
1881 match response {
1882 crate::MeltQuoteResponse::Onchain(r) => assert_eq!(r.change, Some(vec![])),
1883 _ => panic!("expected MeltQuoteResponse::Onchain variant"),
1884 }
1885 }
1886
1887 #[test]
1888 fn validate_onchain_fee_options_rejects_empty() {
1889 let err = validate_onchain_fee_options(&[]).expect_err("empty must be rejected");
1890 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
1891 }
1892
1893 #[test]
1894 fn validate_onchain_fee_options_allows_duplicate_fee_index() {
1895 let options = [
1896 MeltQuoteOnchainFeeOption {
1897 fee_index: 10,
1898 fee_reserve: Amount::from(10),
1899 estimated_blocks: 3,
1900 },
1901 MeltQuoteOnchainFeeOption {
1902 fee_index: 10,
1903 fee_reserve: Amount::from(20),
1904 estimated_blocks: 6,
1905 },
1906 ];
1907 validate_onchain_fee_options(&options).expect("duplicate fee_index must be allowed");
1908 }
1909
1910 #[test]
1911 fn validate_onchain_fee_options_allows_duplicate_estimated_blocks() {
1912 let options = [
1915 MeltQuoteOnchainFeeOption {
1916 fee_index: 20,
1917 fee_reserve: Amount::from(10),
1918 estimated_blocks: 3,
1919 },
1920 MeltQuoteOnchainFeeOption {
1921 fee_index: 1,
1922 fee_reserve: Amount::from(20),
1923 estimated_blocks: 3,
1924 },
1925 ];
1926 validate_onchain_fee_options(&options).expect("duplicate blocks must be allowed");
1927 }
1928
1929 #[test]
1930 fn validate_onchain_fee_options_allows_duplicate_fee_reserve() {
1931 let options = [
1934 MeltQuoteOnchainFeeOption {
1935 fee_index: 0,
1936 fee_reserve: Amount::from(42),
1937 estimated_blocks: 1,
1938 },
1939 MeltQuoteOnchainFeeOption {
1940 fee_index: 1,
1941 fee_reserve: Amount::from(42),
1942 estimated_blocks: 6,
1943 },
1944 ];
1945 validate_onchain_fee_options(&options).expect("duplicate fee must be allowed");
1946 }
1947
1948 #[test]
1949 fn validate_onchain_fee_options_accepts_well_formed() {
1950 let options = [
1951 MeltQuoteOnchainFeeOption {
1952 fee_index: 0,
1953 fee_reserve: Amount::from(500),
1954 estimated_blocks: 1,
1955 },
1956 MeltQuoteOnchainFeeOption {
1957 fee_index: 1,
1958 fee_reserve: Amount::from(200),
1959 estimated_blocks: 6,
1960 },
1961 MeltQuoteOnchainFeeOption {
1962 fee_index: 2,
1963 fee_reserve: Amount::from(50),
1964 estimated_blocks: 144,
1965 },
1966 ];
1967 validate_onchain_fee_options(&options).expect("well-formed must validate");
1968 }
1969
1970 #[test]
1971 fn new_onchain_rejects_empty_fee_options() {
1972 let err = MeltQuote::new_onchain(
1973 None,
1974 MeltPaymentRequest::Onchain {
1975 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
1976 },
1977 CurrencyUnit::Sat,
1978 Amount::new(1_000, CurrencyUnit::Sat),
1979 unix_time() + 3600,
1980 None,
1981 None,
1982 vec![],
1983 )
1984 .expect_err("empty fee_options must be rejected");
1985 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
1986 }
1987
1988 #[test]
1989 fn new_onchain_initializes_reserve_to_cheapest_tier() {
1990 let options = vec![
1993 MeltQuoteOnchainFeeOption {
1994 fee_index: 10,
1995 fee_reserve: Amount::from(500),
1996 estimated_blocks: 1,
1997 },
1998 MeltQuoteOnchainFeeOption {
1999 fee_index: 30,
2000 fee_reserve: Amount::from(50),
2001 estimated_blocks: 144,
2002 },
2003 MeltQuoteOnchainFeeOption {
2004 fee_index: 20,
2005 fee_reserve: Amount::from(200),
2006 estimated_blocks: 6,
2007 },
2008 ];
2009 let quote = MeltQuote::new_onchain(
2010 None,
2011 MeltPaymentRequest::Onchain {
2012 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2013 },
2014 CurrencyUnit::Sat,
2015 Amount::new(10_000, CurrencyUnit::Sat),
2016 unix_time() + 3600,
2017 None,
2018 None,
2019 options.clone(),
2020 )
2021 .expect("well-formed quote must construct");
2022
2023 assert_eq!(quote.fee_reserve().value(), 50);
2024 assert_eq!(quote.estimated_blocks, Some(144));
2025 assert_eq!(quote.selected_fee_index, None);
2026 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2027 assert_eq!(returned, vec![10, 30, 20]);
2028 }
2029
2030 #[test]
2031 fn new_onchain_preserves_duplicate_backend_fee_index() {
2032 let options = vec![
2033 MeltQuoteOnchainFeeOption {
2034 fee_index: 7,
2035 fee_reserve: Amount::from(500),
2036 estimated_blocks: 1,
2037 },
2038 MeltQuoteOnchainFeeOption {
2039 fee_index: 7,
2040 fee_reserve: Amount::from(200),
2041 estimated_blocks: 6,
2042 },
2043 ];
2044 let quote = MeltQuote::new_onchain(
2045 None,
2046 MeltPaymentRequest::Onchain {
2047 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2048 },
2049 CurrencyUnit::Sat,
2050 Amount::new(10_000, CurrencyUnit::Sat),
2051 unix_time() + 3600,
2052 None,
2053 None,
2054 options,
2055 )
2056 .expect("duplicate backend fee_index must be preserved");
2057
2058 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2059 assert_eq!(returned, vec![7, 7]);
2060 }
2061
2062 #[test]
2063 fn select_onchain_fee_option_leaves_fee_options_untouched() {
2064 let options = vec![
2065 MeltQuoteOnchainFeeOption {
2066 fee_index: 1,
2067 fee_reserve: Amount::from(500),
2068 estimated_blocks: 1,
2069 },
2070 MeltQuoteOnchainFeeOption {
2071 fee_index: 2,
2072 fee_reserve: Amount::from(200),
2073 estimated_blocks: 6,
2074 },
2075 ];
2076 let mut quote = MeltQuote::new_onchain(
2077 None,
2078 MeltPaymentRequest::Onchain {
2079 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2080 },
2081 CurrencyUnit::Sat,
2082 Amount::new(10_000, CurrencyUnit::Sat),
2083 unix_time() + 3600,
2084 None,
2085 None,
2086 options.clone(),
2087 )
2088 .unwrap();
2089
2090 let before = quote.fee_options().to_vec();
2091 quote
2092 .select_onchain_fee_option(1)
2093 .expect("selecting a known fee_index must succeed");
2094
2095 assert_eq!(
2096 quote.fee_options(),
2097 before.as_slice(),
2098 "fee_options is fixed for the lifetime of the quote and must not \
2099 mutate on selection"
2100 );
2101 assert_eq!(quote.selected_fee_index, Some(1));
2102 assert_eq!(quote.estimated_blocks, Some(1));
2103 assert_eq!(quote.fee_reserve().value(), 500);
2104 }
2105
2106 #[test]
2107 fn select_onchain_fee_option_unknown_index_rejected() {
2108 let options = vec![MeltQuoteOnchainFeeOption {
2109 fee_index: 0,
2110 fee_reserve: Amount::from(500),
2111 estimated_blocks: 1,
2112 }];
2113 let mut quote = MeltQuote::new_onchain(
2114 None,
2115 MeltPaymentRequest::Onchain {
2116 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2117 },
2118 CurrencyUnit::Sat,
2119 Amount::new(10_000, CurrencyUnit::Sat),
2120 unix_time() + 3600,
2121 None,
2122 None,
2123 options,
2124 )
2125 .unwrap();
2126
2127 match quote
2128 .select_onchain_fee_option(7)
2129 .expect_err("unknown fee_index must be rejected")
2130 {
2131 crate::Error::OnchainFeeIndexNotFound { index: 7 } => {}
2132 other => panic!("unexpected error: {other:?}"),
2133 }
2134 }
2135
2136 #[test]
2137 fn from_db_preserves_duplicate_onchain_fee_options() {
2138 let options = vec![
2139 MeltQuoteOnchainFeeOption {
2140 fee_index: 0,
2141 fee_reserve: Amount::from(100),
2142 estimated_blocks: 6,
2143 },
2144 MeltQuoteOnchainFeeOption {
2145 fee_index: 0,
2146 fee_reserve: Amount::from(200),
2147 estimated_blocks: 6,
2148 },
2149 ];
2150 let quote = MeltQuote::from_db(
2151 QuoteId::new(),
2152 CurrencyUnit::Sat,
2153 MeltPaymentRequest::Onchain {
2154 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2155 },
2156 10_000,
2157 100,
2158 MeltQuoteState::Unpaid,
2159 unix_time() + 3600,
2160 None,
2161 None,
2162 None,
2163 unix_time(),
2164 None,
2165 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2166 None,
2167 None,
2168 options,
2169 None,
2170 )
2171 .expect("duplicate onchain fee_options on reload must be preserved");
2172
2173 let returned: Vec<u32> = quote.fee_options().iter().map(|o| o.fee_index).collect();
2174 assert_eq!(returned, vec![0, 0]);
2175 }
2176
2177 #[test]
2178 fn test_custom_mint_quote_response_surfaces_extra_json() {
2179 let extra = serde_json::json!({"payment_url": "https://example.com/pay", "ref": 42});
2180 let quote = MintQuote::new(
2181 Some(QuoteId::new()),
2182 "custom://request".to_string(),
2183 CurrencyUnit::Sat,
2184 Some(Amount::new(500, CurrencyUnit::Sat)),
2185 unix_time() + 3600,
2186 PaymentIdentifier::Label("test".to_string()),
2187 None,
2188 Amount::new(0, CurrencyUnit::Sat),
2189 Amount::new(0, CurrencyUnit::Sat),
2190 PaymentMethod::Custom("custom".to_string()),
2191 unix_time(),
2192 Vec::new(),
2193 Vec::new(),
2194 Some(extra.clone()),
2195 );
2196
2197 let response: MintQuoteResponse<QuoteId> = quote.try_into().expect("conversion succeeds");
2198 match response {
2199 MintQuoteResponse::Custom { response, .. } => {
2200 assert_eq!(response.extra, extra);
2201 }
2202 other => panic!("expected Custom variant, got {:?}", other),
2203 }
2204 }
2205
2206 #[test]
2207 fn from_db_rejects_empty_onchain_fee_options() {
2208 let err = MeltQuote::from_db(
2209 QuoteId::new(),
2210 CurrencyUnit::Sat,
2211 MeltPaymentRequest::Onchain {
2212 address: "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq".to_string(),
2213 },
2214 10_000,
2215 100,
2216 MeltQuoteState::Unpaid,
2217 unix_time() + 3600,
2218 None,
2219 None,
2220 None,
2221 unix_time(),
2222 None,
2223 PaymentMethod::Known(cashu::nuts::nut00::KnownMethod::Onchain),
2224 None,
2225 Some(6),
2226 Vec::new(),
2227 None,
2228 )
2229 .expect_err("empty onchain fee_options on reload must be rejected");
2230 assert!(matches!(err, crate::Error::OnchainFeeOptionsEmpty));
2231 }
2232}