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