Skip to main content

cdk_payment_processor/proto/
mod.rs

1use std::str::FromStr;
2
3use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption;
4use cdk_common::payment::{
5    CreateIncomingPaymentResponse, MakePaymentResponse as CdkMakePaymentResponse,
6    PaymentIdentifier as CdkPaymentIdentifier, PaymentQuoteResponse as CdkPaymentQuoteResponse,
7    WaitPaymentResponse,
8};
9use cdk_common::{CurrencyUnit, MeltOptions as CdkMeltOptions};
10
11mod client;
12mod server;
13
14pub use client::PaymentProcessorClient;
15pub use server::PaymentProcessorServer;
16
17tonic::include_proto!("cdk_payment_processor");
18
19impl From<CdkPaymentIdentifier> for PaymentIdentifier {
20    fn from(value: CdkPaymentIdentifier) -> Self {
21        match value {
22            CdkPaymentIdentifier::Label(id) => Self {
23                r#type: PaymentIdentifierType::Label.into(),
24                value: Some(payment_identifier::Value::Id(id)),
25            },
26            CdkPaymentIdentifier::OfferId(id) => Self {
27                r#type: PaymentIdentifierType::OfferId.into(),
28                value: Some(payment_identifier::Value::Id(id)),
29            },
30            CdkPaymentIdentifier::PaymentHash(hash) => Self {
31                r#type: PaymentIdentifierType::PaymentHash.into(),
32                value: Some(payment_identifier::Value::Hash(hex::encode(hash))),
33            },
34            CdkPaymentIdentifier::Bolt12PaymentHash(hash) => Self {
35                r#type: PaymentIdentifierType::Bolt12PaymentHash.into(),
36                value: Some(payment_identifier::Value::Hash(hex::encode(hash))),
37            },
38            CdkPaymentIdentifier::CustomId(id) => Self {
39                r#type: PaymentIdentifierType::CustomId.into(),
40                value: Some(payment_identifier::Value::Id(id)),
41            },
42            CdkPaymentIdentifier::PaymentId(hash) => Self {
43                r#type: PaymentIdentifierType::PaymentId.into(),
44                value: Some(payment_identifier::Value::Hash(hex::encode(hash))),
45            },
46            CdkPaymentIdentifier::QuoteId(quote_id) => Self {
47                r#type: PaymentIdentifierType::QuoteId.into(),
48                value: Some(payment_identifier::Value::Id(quote_id.to_string())),
49            },
50        }
51    }
52}
53
54impl TryFrom<PaymentIdentifier> for CdkPaymentIdentifier {
55    type Error = crate::error::Error;
56
57    fn try_from(value: PaymentIdentifier) -> Result<Self, Self::Error> {
58        match (value.r#type(), value.value) {
59            (PaymentIdentifierType::Label, Some(payment_identifier::Value::Id(id))) => {
60                Ok(CdkPaymentIdentifier::Label(id))
61            }
62            (PaymentIdentifierType::OfferId, Some(payment_identifier::Value::Id(id))) => {
63                Ok(CdkPaymentIdentifier::OfferId(id))
64            }
65            (PaymentIdentifierType::PaymentHash, Some(payment_identifier::Value::Hash(hash))) => {
66                let decoded = hex::decode(hash)?;
67                let hash_array: [u8; 32] = decoded
68                    .try_into()
69                    .map_err(|_| crate::error::Error::InvalidHash)?;
70                Ok(CdkPaymentIdentifier::PaymentHash(hash_array))
71            }
72            (
73                PaymentIdentifierType::Bolt12PaymentHash,
74                Some(payment_identifier::Value::Hash(hash)),
75            ) => {
76                let decoded = hex::decode(hash)?;
77                let hash_array: [u8; 32] = decoded
78                    .try_into()
79                    .map_err(|_| crate::error::Error::InvalidHash)?;
80                Ok(CdkPaymentIdentifier::Bolt12PaymentHash(hash_array))
81            }
82            (PaymentIdentifierType::CustomId, Some(payment_identifier::Value::Id(id))) => {
83                Ok(CdkPaymentIdentifier::CustomId(id))
84            }
85            (PaymentIdentifierType::QuoteId, Some(payment_identifier::Value::Id(id))) => {
86                Ok(CdkPaymentIdentifier::QuoteId(id.parse().map_err(|_| {
87                    crate::error::Error::InvalidPaymentIdentifier
88                })?))
89            }
90            (PaymentIdentifierType::PaymentId, Some(payment_identifier::Value::Hash(hash))) => {
91                let decoded = hex::decode(hash)?;
92                let hash_array: [u8; 32] = decoded
93                    .try_into()
94                    .map_err(|_| crate::error::Error::InvalidHash)?;
95                Ok(CdkPaymentIdentifier::PaymentId(hash_array))
96            }
97            _ => Err(crate::error::Error::InvalidPaymentIdentifier),
98        }
99    }
100}
101
102// Amount<CurrencyUnit> <-> proto AmountMessage conversions
103
104impl From<cdk_common::Amount<CurrencyUnit>> for AmountMessage {
105    fn from(value: cdk_common::Amount<CurrencyUnit>) -> Self {
106        Self {
107            value: value.value(),
108            unit: value.unit().to_string(),
109        }
110    }
111}
112
113impl TryFrom<AmountMessage> for cdk_common::Amount<CurrencyUnit> {
114    type Error = crate::error::Error;
115    fn try_from(value: AmountMessage) -> Result<Self, Self::Error> {
116        let unit = CurrencyUnit::from_str(&value.unit)?;
117        Ok(cdk_common::Amount::new(value.value, unit))
118    }
119}
120
121// Helper trait for converting Option<Amount<CurrencyUnit>> <-> Option<proto::AmountMessage>
122pub(crate) trait IntoProtoAmount {
123    fn into_proto(self) -> Option<AmountMessage>;
124}
125
126impl IntoProtoAmount for Option<cdk_common::Amount<CurrencyUnit>> {
127    fn into_proto(self) -> Option<AmountMessage> {
128        self.map(Into::into)
129    }
130}
131
132pub(crate) trait TryFromProtoAmount {
133    fn try_from_proto(
134        self,
135    ) -> Result<Option<cdk_common::Amount<CurrencyUnit>>, crate::error::Error>;
136}
137
138impl TryFromProtoAmount for Option<AmountMessage> {
139    fn try_from_proto(
140        self,
141    ) -> Result<Option<cdk_common::Amount<CurrencyUnit>>, crate::error::Error> {
142        match self {
143            Some(amount) => Ok(Some(amount.try_into()?)),
144            None => Ok(None),
145        }
146    }
147}
148
149impl TryFrom<MakePaymentResponse> for CdkMakePaymentResponse {
150    type Error = crate::error::Error;
151    fn try_from(value: MakePaymentResponse) -> Result<Self, Self::Error> {
152        // Use direct enum conversion instead of parsing string from as_str_name()
153        // as_str_name() returns "QUOTE_STATE_PAID" but MeltQuoteState::from_str expects "PAID"
154        let status: cdk_common::nuts::MeltQuoteState = value.status().into();
155        let payment_proof = value.payment_proof;
156        let total_spent = value
157            .total_spent
158            .ok_or(crate::error::Error::MissingAmount)?
159            .try_into()?;
160        let payment_identifier = value
161            .payment_identifier
162            .ok_or(crate::error::Error::InvalidPaymentIdentifier)?;
163        Ok(Self {
164            payment_lookup_id: payment_identifier.try_into()?,
165            payment_proof,
166            status,
167            total_spent,
168        })
169    }
170}
171
172impl From<CdkMakePaymentResponse> for MakePaymentResponse {
173    fn from(value: CdkMakePaymentResponse) -> Self {
174        Self {
175            payment_identifier: Some(value.payment_lookup_id.into()),
176            payment_proof: value.payment_proof,
177            status: QuoteState::from(value.status).into(),
178            total_spent: Some(value.total_spent.into()),
179            extra_json: None,
180        }
181    }
182}
183
184impl From<CreateIncomingPaymentResponse> for CreatePaymentResponse {
185    fn from(value: CreateIncomingPaymentResponse) -> Self {
186        Self {
187            request_identifier: Some(value.request_lookup_id.into()),
188            request: value.request,
189            expiry: value.expiry,
190            extra_json: value.extra_json.map(|v| v.to_string()),
191        }
192    }
193}
194
195impl TryFrom<CreatePaymentResponse> for CreateIncomingPaymentResponse {
196    type Error = crate::error::Error;
197
198    fn try_from(value: CreatePaymentResponse) -> Result<Self, Self::Error> {
199        let request_identifier = value
200            .request_identifier
201            .ok_or(crate::error::Error::InvalidPaymentIdentifier)?;
202        Ok(Self {
203            request_lookup_id: request_identifier.try_into()?,
204            request: value.request,
205            expiry: value.expiry,
206            extra_json: Some(
207                serde_json::from_str(value.extra_json.as_deref().unwrap_or("{}"))
208                    .unwrap_or_default(),
209            ),
210        })
211    }
212}
213impl From<CdkPaymentQuoteResponse> for PaymentQuoteResponse {
214    fn from(value: CdkPaymentQuoteResponse) -> Self {
215        Self {
216            request_identifier: value.request_lookup_id.map(|i| i.into()),
217            amount: Some(value.amount.into()),
218            fee: Some(value.fee.into()),
219            state: QuoteState::from(value.state).into(),
220            extra_json: value.extra_json.map(|value| value.to_string()),
221            estimated_blocks: value.estimated_blocks,
222            fee_options: value
223                .fee_options
224                .unwrap_or_default()
225                .into_iter()
226                .map(Into::into)
227                .collect(),
228        }
229    }
230}
231
232impl From<MeltQuoteOnchainFeeOption> for OnchainFeeOption {
233    fn from(value: MeltQuoteOnchainFeeOption) -> Self {
234        Self {
235            fee_reserve: value.fee_reserve.into(),
236            estimated_blocks: value.estimated_blocks,
237            fee_index: value.fee_index,
238        }
239    }
240}
241
242impl From<OnchainFeeOption> for MeltQuoteOnchainFeeOption {
243    fn from(value: OnchainFeeOption) -> Self {
244        Self {
245            fee_index: value.fee_index,
246            fee_reserve: value.fee_reserve.into(),
247            estimated_blocks: value.estimated_blocks,
248        }
249    }
250}
251
252impl TryFrom<PaymentQuoteResponse> for CdkPaymentQuoteResponse {
253    type Error = crate::error::Error;
254    fn try_from(value: PaymentQuoteResponse) -> Result<Self, Self::Error> {
255        let state_val = value.state();
256        let request_identifier = value.request_identifier;
257
258        Ok(Self {
259            request_lookup_id: request_identifier
260                .map(|i| i.try_into().expect("valid request identifier")),
261            amount: value
262                .amount
263                .ok_or(crate::error::Error::MissingAmount)?
264                .try_into()?,
265            fee: value
266                .fee
267                .ok_or(crate::error::Error::MissingAmount)?
268                .try_into()?,
269            state: state_val.into(),
270            extra_json: value
271                .extra_json
272                .and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok()),
273            estimated_blocks: value.estimated_blocks,
274            fee_options: (!value.fee_options.is_empty()).then(|| {
275                value
276                    .fee_options
277                    .into_iter()
278                    .map(Into::into)
279                    .collect::<Vec<_>>()
280            }),
281        })
282    }
283}
284
285impl TryFrom<MeltOptions> for CdkMeltOptions {
286    type Error = crate::error::Error;
287
288    fn try_from(value: MeltOptions) -> Result<Self, Self::Error> {
289        match value
290            .options
291            .ok_or(crate::error::Error::InvalidMeltOptions)?
292        {
293            melt_options::Options::Mpp(mpp) => Ok(Self::Mpp {
294                mpp: cashu::nuts::nut15::Mpp {
295                    amount: mpp.amount.into(),
296                },
297            }),
298            melt_options::Options::Amountless(amountless) => Ok(Self::Amountless {
299                amountless: cashu::nuts::nut23::Amountless {
300                    amount_msat: amountless.amount_msat.into(),
301                },
302            }),
303        }
304    }
305}
306
307impl From<CdkMeltOptions> for MeltOptions {
308    fn from(value: CdkMeltOptions) -> Self {
309        match value {
310            CdkMeltOptions::Mpp { mpp } => Self {
311                options: Some(melt_options::Options::Mpp(Mpp {
312                    amount: mpp.amount.into(),
313                })),
314            },
315            CdkMeltOptions::Amountless { amountless } => Self {
316                options: Some(melt_options::Options::Amountless(Amountless {
317                    amount_msat: amountless.amount_msat.into(),
318                })),
319            },
320        }
321    }
322}
323
324impl From<QuoteState> for cdk_common::nuts::MeltQuoteState {
325    fn from(value: QuoteState) -> Self {
326        match value {
327            QuoteState::Unpaid => Self::Unpaid,
328            QuoteState::Paid => Self::Paid,
329            QuoteState::Pending => Self::Pending,
330            QuoteState::Unknown => Self::Unknown,
331            QuoteState::Failed => Self::Failed,
332            QuoteState::Issued => Self::Unknown,
333            QuoteState::Unspecified => Self::Unknown,
334        }
335    }
336}
337
338impl From<cdk_common::nuts::MeltQuoteState> for QuoteState {
339    fn from(value: cdk_common::nuts::MeltQuoteState) -> Self {
340        match value {
341            cdk_common::nuts::MeltQuoteState::Unpaid => Self::Unpaid,
342            cdk_common::nuts::MeltQuoteState::Paid => Self::Paid,
343            cdk_common::nuts::MeltQuoteState::Pending => Self::Pending,
344            cdk_common::nuts::MeltQuoteState::Unknown => Self::Unknown,
345            cdk_common::nuts::MeltQuoteState::Failed => Self::Failed,
346        }
347    }
348}
349
350impl From<cdk_common::nuts::MintQuoteState> for QuoteState {
351    fn from(value: cdk_common::nuts::MintQuoteState) -> Self {
352        match value {
353            cdk_common::nuts::MintQuoteState::Unpaid => Self::Unpaid,
354            cdk_common::nuts::MintQuoteState::Paid => Self::Paid,
355            cdk_common::nuts::MintQuoteState::Issued => Self::Issued,
356        }
357    }
358}
359
360impl From<WaitPaymentResponse> for WaitIncomingPaymentResponse {
361    fn from(value: WaitPaymentResponse) -> Self {
362        Self {
363            payment_identifier: Some(value.payment_identifier.into()),
364            payment_amount: Some(value.payment_amount.into()),
365            payment_id: value.payment_id,
366        }
367    }
368}
369
370impl TryFrom<WaitIncomingPaymentResponse> for WaitPaymentResponse {
371    type Error = crate::error::Error;
372
373    fn try_from(value: WaitIncomingPaymentResponse) -> Result<Self, Self::Error> {
374        let payment_identifier = value
375            .payment_identifier
376            .ok_or(crate::error::Error::InvalidPaymentIdentifier)?
377            .try_into()?;
378
379        Ok(Self {
380            payment_identifier,
381            payment_amount: value
382                .payment_amount
383                .ok_or(crate::error::Error::MissingAmount)?
384                .try_into()?,
385            payment_id: value.payment_id,
386        })
387    }
388}
389
390impl From<cdk_common::payment::Event> for PaymentEventResponse {
391    fn from(value: cdk_common::payment::Event) -> Self {
392        match value {
393            cdk_common::payment::Event::PaymentReceived(response) => Self {
394                event: Some(payment_event_response::Event::PaymentReceived(
395                    response.into(),
396                )),
397            },
398            cdk_common::payment::Event::PaymentSuccessful { quote_id, details } => Self {
399                event: Some(payment_event_response::Event::PaymentSuccessful(
400                    PaymentSuccessfulResponse {
401                        quote_id: quote_id.to_string(),
402                        details: Some(details.into()),
403                    },
404                )),
405            },
406            cdk_common::payment::Event::PaymentFailed { quote_id, reason } => Self {
407                event: Some(payment_event_response::Event::PaymentFailed(
408                    PaymentFailedResponse {
409                        quote_id: quote_id.to_string(),
410                        reason,
411                    },
412                )),
413            },
414        }
415    }
416}
417
418impl TryFrom<PaymentEventResponse> for cdk_common::payment::Event {
419    type Error = crate::error::Error;
420
421    fn try_from(value: PaymentEventResponse) -> Result<Self, Self::Error> {
422        match value.event {
423            Some(payment_event_response::Event::PaymentReceived(response)) => {
424                Ok(Self::PaymentReceived(response.try_into()?))
425            }
426            Some(payment_event_response::Event::PaymentSuccessful(response)) => {
427                let quote_id = cdk_common::QuoteId::from_str(&response.quote_id)
428                    .map_err(|_| crate::error::Error::InvalidPaymentIdentifier)?;
429                let details = response
430                    .details
431                    .ok_or(crate::error::Error::InvalidPaymentIdentifier)?
432                    .try_into()?;
433                Ok(Self::PaymentSuccessful { quote_id, details })
434            }
435            Some(payment_event_response::Event::PaymentFailed(response)) => {
436                let quote_id = cdk_common::QuoteId::from_str(&response.quote_id)
437                    .map_err(|_| crate::error::Error::InvalidPaymentIdentifier)?;
438                Ok(Self::PaymentFailed {
439                    quote_id,
440                    reason: response.reason,
441                })
442            }
443            None => Err(crate::error::Error::InvalidPaymentIdentifier),
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use std::str::FromStr;
451
452    use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption;
453    use cdk_common::payment::{
454        Event, MakePaymentResponse, OnchainSettings, PaymentIdentifier,
455        PaymentQuoteResponse as CdkPaymentQuoteResponse, WaitPaymentResponse,
456    };
457    use cdk_common::{
458        Amount, CurrencyUnit, MeltOptions as CdkMeltOptions, MeltQuoteState, QuoteId,
459    };
460
461    use super::{PaymentEventResponse, PaymentQuoteResponse};
462
463    #[test]
464    fn payment_quote_response_extra_json_roundtrip() {
465        let response = CdkPaymentQuoteResponse {
466            request_lookup_id: Some(PaymentIdentifier::CustomId("processor-quote".to_string())),
467            amount: Amount::new(100, CurrencyUnit::Sat),
468            fee: Amount::new(2, CurrencyUnit::Sat),
469            state: MeltQuoteState::Unpaid,
470            estimated_blocks: None,
471            extra_json: Some(serde_json::json!({
472                "method": "custom",
473                "redirect_url": "https://example.com/pay",
474                "nested": { "attempt": 1 }
475            })),
476            fee_options: Some(vec![MeltQuoteOnchainFeeOption {
477                fee_index: 0,
478                fee_reserve: Amount::from(2),
479                estimated_blocks: 6,
480            }]),
481        };
482
483        let proto: PaymentQuoteResponse = response.clone().into();
484        let roundtrip = CdkPaymentQuoteResponse::try_from(proto).expect("valid proto response");
485
486        assert_eq!(roundtrip.request_lookup_id, response.request_lookup_id);
487        assert_eq!(roundtrip.amount, response.amount);
488        assert_eq!(roundtrip.fee, response.fee);
489        assert_eq!(roundtrip.state, response.state);
490        assert_eq!(roundtrip.extra_json, response.extra_json);
491        assert_eq!(roundtrip.fee_options, response.fee_options);
492    }
493
494    #[test]
495    fn onchain_settings_min_send_roundtrip() {
496        let settings = OnchainSettings {
497            confirmations: 3,
498            min_receive_amount_sat: 1_000,
499            min_send_amount_sat: 546,
500        };
501
502        let proto = super::OnchainSettings {
503            confirmations: settings.confirmations,
504            min_receive_amount_sat: settings.min_receive_amount_sat,
505            min_send_amount_sat: settings.min_send_amount_sat,
506        };
507
508        let roundtrip = OnchainSettings {
509            confirmations: proto.confirmations,
510            min_receive_amount_sat: proto.min_receive_amount_sat,
511            min_send_amount_sat: proto.min_send_amount_sat,
512        };
513
514        assert_eq!(roundtrip, settings);
515    }
516
517    #[test]
518    fn payment_event_response_received_roundtrip() {
519        let event = Event::PaymentReceived(WaitPaymentResponse {
520            payment_identifier: PaymentIdentifier::CustomId("incoming-lookup".to_string()),
521            payment_amount: Amount::new(500, CurrencyUnit::Msat),
522            payment_id: "payment-xyz".to_string(),
523        });
524
525        let proto: PaymentEventResponse = event.clone().into();
526        let roundtrip = Event::try_from(proto).expect("valid proto event");
527
528        match (event, roundtrip) {
529            (Event::PaymentReceived(a), Event::PaymentReceived(b)) => {
530                assert_eq!(a.payment_identifier, b.payment_identifier);
531                assert_eq!(a.payment_amount, b.payment_amount);
532                assert_eq!(a.payment_id, b.payment_id);
533            }
534            _ => panic!("expected PaymentReceived variant after roundtrip"),
535        }
536    }
537
538    #[test]
539    fn payment_event_response_successful_roundtrip() {
540        let quote_id = QuoteId::new();
541        let event = Event::PaymentSuccessful {
542            quote_id: quote_id.clone(),
543            details: MakePaymentResponse {
544                payment_lookup_id: PaymentIdentifier::CustomId("outgoing-lookup".to_string()),
545                payment_proof: Some("deadbeef".to_string()),
546                status: MeltQuoteState::Paid,
547                total_spent: Amount::new(1_000, CurrencyUnit::Sat),
548            },
549        };
550
551        let proto: PaymentEventResponse = event.clone().into();
552        let roundtrip = Event::try_from(proto).expect("valid proto event");
553
554        match (event, roundtrip) {
555            (
556                Event::PaymentSuccessful {
557                    quote_id: a_quote,
558                    details: a,
559                },
560                Event::PaymentSuccessful {
561                    quote_id: b_quote,
562                    details: b,
563                },
564            ) => {
565                assert_eq!(a_quote, b_quote);
566                assert_eq!(a.payment_lookup_id, b.payment_lookup_id);
567                assert_eq!(a.payment_proof, b.payment_proof);
568                assert_eq!(a.status, b.status);
569                assert_eq!(a.total_spent, b.total_spent);
570            }
571            _ => panic!("expected PaymentSuccessful variant after roundtrip"),
572        }
573    }
574
575    #[test]
576    fn payment_event_response_failed_roundtrip() {
577        let quote_id = QuoteId::new();
578        let event = Event::PaymentFailed {
579            quote_id: quote_id.clone(),
580            reason: "route not found".to_string(),
581        };
582
583        let proto: PaymentEventResponse = event.clone().into();
584        let roundtrip = Event::try_from(proto).expect("valid proto event");
585
586        match (event, roundtrip) {
587            (
588                Event::PaymentFailed {
589                    quote_id: a_quote,
590                    reason: a,
591                },
592                Event::PaymentFailed {
593                    quote_id: b_quote,
594                    reason: b,
595                },
596            ) => {
597                assert_eq!(a_quote, b_quote);
598                assert_eq!(a, b);
599            }
600            _ => panic!("expected PaymentFailed variant after roundtrip"),
601        }
602    }
603
604    #[test]
605    fn payment_event_response_missing_oneof_errors() {
606        let proto = PaymentEventResponse { event: None };
607        assert!(Event::try_from(proto).is_err());
608    }
609
610    #[test]
611    fn melt_options_missing_oneof_errors() {
612        let proto = super::MeltOptions { options: None };
613
614        let err = CdkMeltOptions::try_from(proto).expect_err("missing melt options should error");
615
616        assert!(matches!(err, crate::error::Error::InvalidMeltOptions));
617    }
618
619    #[test]
620    fn payment_event_response_invalid_quote_id_errors() {
621        use super::{payment_event_response, PaymentFailedResponse};
622
623        // `!!!` is neither a valid UUID nor valid URL-safe base64, so QuoteId's
624        // FromStr rejects it and try_from should surface InvalidPaymentIdentifier.
625        let bogus = "!!!";
626        assert!(QuoteId::from_str(bogus).is_err());
627
628        let proto = PaymentEventResponse {
629            event: Some(payment_event_response::Event::PaymentFailed(
630                PaymentFailedResponse {
631                    quote_id: bogus.to_string(),
632                    reason: "bad".to_string(),
633                },
634            )),
635        };
636        assert!(Event::try_from(proto).is_err());
637    }
638}