Skip to main content

cdk_payment_processor/proto/
mod.rs

1use std::str::FromStr;
2
3use cdk_common::nuts::nut_onchain::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: None,
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 From<MeltOptions> for CdkMeltOptions {
286    fn from(value: MeltOptions) -> Self {
287        match value.options.expect("option defined") {
288            melt_options::Options::Mpp(mpp) => Self::Mpp {
289                mpp: cashu::nuts::nut15::Mpp {
290                    amount: mpp.amount.into(),
291                },
292            },
293            melt_options::Options::Amountless(amountless) => Self::Amountless {
294                amountless: cashu::nuts::nut23::Amountless {
295                    amount_msat: amountless.amount_msat.into(),
296                },
297            },
298        }
299    }
300}
301
302impl From<CdkMeltOptions> for MeltOptions {
303    fn from(value: CdkMeltOptions) -> Self {
304        match value {
305            CdkMeltOptions::Mpp { mpp } => Self {
306                options: Some(melt_options::Options::Mpp(Mpp {
307                    amount: mpp.amount.into(),
308                })),
309            },
310            CdkMeltOptions::Amountless { amountless } => Self {
311                options: Some(melt_options::Options::Amountless(Amountless {
312                    amount_msat: amountless.amount_msat.into(),
313                })),
314            },
315        }
316    }
317}
318
319impl From<QuoteState> for cdk_common::nuts::MeltQuoteState {
320    fn from(value: QuoteState) -> Self {
321        match value {
322            QuoteState::Unpaid => Self::Unpaid,
323            QuoteState::Paid => Self::Paid,
324            QuoteState::Pending => Self::Pending,
325            QuoteState::Unknown => Self::Unknown,
326            QuoteState::Failed => Self::Failed,
327            QuoteState::Issued => Self::Unknown,
328            QuoteState::Unspecified => Self::Unknown,
329        }
330    }
331}
332
333impl From<cdk_common::nuts::MeltQuoteState> for QuoteState {
334    fn from(value: cdk_common::nuts::MeltQuoteState) -> Self {
335        match value {
336            cdk_common::nuts::MeltQuoteState::Unpaid => Self::Unpaid,
337            cdk_common::nuts::MeltQuoteState::Paid => Self::Paid,
338            cdk_common::nuts::MeltQuoteState::Pending => Self::Pending,
339            cdk_common::nuts::MeltQuoteState::Unknown => Self::Unknown,
340            cdk_common::nuts::MeltQuoteState::Failed => Self::Failed,
341        }
342    }
343}
344
345impl From<cdk_common::nuts::MintQuoteState> for QuoteState {
346    fn from(value: cdk_common::nuts::MintQuoteState) -> Self {
347        match value {
348            cdk_common::nuts::MintQuoteState::Unpaid => Self::Unpaid,
349            cdk_common::nuts::MintQuoteState::Paid => Self::Paid,
350            cdk_common::nuts::MintQuoteState::Issued => Self::Issued,
351        }
352    }
353}
354
355impl From<WaitPaymentResponse> for WaitIncomingPaymentResponse {
356    fn from(value: WaitPaymentResponse) -> Self {
357        Self {
358            payment_identifier: Some(value.payment_identifier.into()),
359            payment_amount: Some(value.payment_amount.into()),
360            payment_id: value.payment_id,
361        }
362    }
363}
364
365impl TryFrom<WaitIncomingPaymentResponse> for WaitPaymentResponse {
366    type Error = crate::error::Error;
367
368    fn try_from(value: WaitIncomingPaymentResponse) -> Result<Self, Self::Error> {
369        let payment_identifier = value
370            .payment_identifier
371            .ok_or(crate::error::Error::InvalidPaymentIdentifier)?
372            .try_into()?;
373
374        Ok(Self {
375            payment_identifier,
376            payment_amount: value
377                .payment_amount
378                .ok_or(crate::error::Error::MissingAmount)?
379                .try_into()?,
380            payment_id: value.payment_id,
381        })
382    }
383}
384
385impl From<cdk_common::payment::Event> for PaymentEventResponse {
386    fn from(value: cdk_common::payment::Event) -> Self {
387        match value {
388            cdk_common::payment::Event::PaymentReceived(response) => Self {
389                event: Some(payment_event_response::Event::PaymentReceived(
390                    response.into(),
391                )),
392            },
393            cdk_common::payment::Event::PaymentSuccessful { quote_id, details } => Self {
394                event: Some(payment_event_response::Event::PaymentSuccessful(
395                    PaymentSuccessfulResponse {
396                        quote_id: quote_id.to_string(),
397                        details: Some(details.into()),
398                    },
399                )),
400            },
401            cdk_common::payment::Event::PaymentFailed { quote_id, reason } => Self {
402                event: Some(payment_event_response::Event::PaymentFailed(
403                    PaymentFailedResponse {
404                        quote_id: quote_id.to_string(),
405                        reason,
406                    },
407                )),
408            },
409        }
410    }
411}
412
413impl TryFrom<PaymentEventResponse> for cdk_common::payment::Event {
414    type Error = crate::error::Error;
415
416    fn try_from(value: PaymentEventResponse) -> Result<Self, Self::Error> {
417        match value.event {
418            Some(payment_event_response::Event::PaymentReceived(response)) => {
419                Ok(Self::PaymentReceived(response.try_into()?))
420            }
421            Some(payment_event_response::Event::PaymentSuccessful(response)) => {
422                let quote_id = cdk_common::QuoteId::from_str(&response.quote_id)
423                    .map_err(|_| crate::error::Error::InvalidPaymentIdentifier)?;
424                let details = response
425                    .details
426                    .ok_or(crate::error::Error::InvalidPaymentIdentifier)?
427                    .try_into()?;
428                Ok(Self::PaymentSuccessful { quote_id, details })
429            }
430            Some(payment_event_response::Event::PaymentFailed(response)) => {
431                let quote_id = cdk_common::QuoteId::from_str(&response.quote_id)
432                    .map_err(|_| crate::error::Error::InvalidPaymentIdentifier)?;
433                Ok(Self::PaymentFailed {
434                    quote_id,
435                    reason: response.reason,
436                })
437            }
438            None => Err(crate::error::Error::InvalidPaymentIdentifier),
439        }
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use std::str::FromStr;
446
447    use cdk_common::nuts::nut_onchain::MeltQuoteOnchainFeeOption;
448    use cdk_common::payment::{
449        Event, MakePaymentResponse, OnchainSettings, PaymentIdentifier,
450        PaymentQuoteResponse as CdkPaymentQuoteResponse, WaitPaymentResponse,
451    };
452    use cdk_common::{Amount, CurrencyUnit, MeltQuoteState, QuoteId};
453
454    use super::{PaymentEventResponse, PaymentQuoteResponse};
455
456    #[test]
457    fn payment_quote_response_extra_json_roundtrip() {
458        let response = CdkPaymentQuoteResponse {
459            request_lookup_id: Some(PaymentIdentifier::CustomId("processor-quote".to_string())),
460            amount: Amount::new(100, CurrencyUnit::Sat),
461            fee: Amount::new(2, CurrencyUnit::Sat),
462            state: MeltQuoteState::Unpaid,
463            estimated_blocks: None,
464            extra_json: Some(serde_json::json!({
465                "method": "custom",
466                "redirect_url": "https://example.com/pay",
467                "nested": { "attempt": 1 }
468            })),
469            fee_options: Some(vec![MeltQuoteOnchainFeeOption {
470                fee_index: 0,
471                fee_reserve: Amount::from(2),
472                estimated_blocks: 6,
473            }]),
474        };
475
476        let proto: PaymentQuoteResponse = response.clone().into();
477        let roundtrip = CdkPaymentQuoteResponse::try_from(proto).expect("valid proto response");
478
479        assert_eq!(roundtrip.request_lookup_id, response.request_lookup_id);
480        assert_eq!(roundtrip.amount, response.amount);
481        assert_eq!(roundtrip.fee, response.fee);
482        assert_eq!(roundtrip.state, response.state);
483        assert_eq!(roundtrip.extra_json, response.extra_json);
484        assert_eq!(roundtrip.fee_options, response.fee_options);
485    }
486
487    #[test]
488    fn onchain_settings_min_send_roundtrip() {
489        let settings = OnchainSettings {
490            confirmations: 3,
491            min_receive_amount_sat: 1_000,
492            min_send_amount_sat: 546,
493        };
494
495        let proto = super::OnchainSettings {
496            confirmations: settings.confirmations,
497            min_receive_amount_sat: settings.min_receive_amount_sat,
498            min_send_amount_sat: settings.min_send_amount_sat,
499        };
500
501        let roundtrip = OnchainSettings {
502            confirmations: proto.confirmations,
503            min_receive_amount_sat: proto.min_receive_amount_sat,
504            min_send_amount_sat: proto.min_send_amount_sat,
505        };
506
507        assert_eq!(roundtrip, settings);
508    }
509
510    #[test]
511    fn payment_event_response_received_roundtrip() {
512        let event = Event::PaymentReceived(WaitPaymentResponse {
513            payment_identifier: PaymentIdentifier::CustomId("incoming-lookup".to_string()),
514            payment_amount: Amount::new(500, CurrencyUnit::Msat),
515            payment_id: "payment-xyz".to_string(),
516        });
517
518        let proto: PaymentEventResponse = event.clone().into();
519        let roundtrip = Event::try_from(proto).expect("valid proto event");
520
521        match (event, roundtrip) {
522            (Event::PaymentReceived(a), Event::PaymentReceived(b)) => {
523                assert_eq!(a.payment_identifier, b.payment_identifier);
524                assert_eq!(a.payment_amount, b.payment_amount);
525                assert_eq!(a.payment_id, b.payment_id);
526            }
527            _ => panic!("expected PaymentReceived variant after roundtrip"),
528        }
529    }
530
531    #[test]
532    fn payment_event_response_successful_roundtrip() {
533        let quote_id = QuoteId::new_uuid();
534        let event = Event::PaymentSuccessful {
535            quote_id: quote_id.clone(),
536            details: MakePaymentResponse {
537                payment_lookup_id: PaymentIdentifier::CustomId("outgoing-lookup".to_string()),
538                payment_proof: Some("deadbeef".to_string()),
539                status: MeltQuoteState::Paid,
540                total_spent: Amount::new(1_000, CurrencyUnit::Sat),
541            },
542        };
543
544        let proto: PaymentEventResponse = event.clone().into();
545        let roundtrip = Event::try_from(proto).expect("valid proto event");
546
547        match (event, roundtrip) {
548            (
549                Event::PaymentSuccessful {
550                    quote_id: a_quote,
551                    details: a,
552                },
553                Event::PaymentSuccessful {
554                    quote_id: b_quote,
555                    details: b,
556                },
557            ) => {
558                assert_eq!(a_quote, b_quote);
559                assert_eq!(a.payment_lookup_id, b.payment_lookup_id);
560                assert_eq!(a.payment_proof, b.payment_proof);
561                assert_eq!(a.status, b.status);
562                assert_eq!(a.total_spent, b.total_spent);
563            }
564            _ => panic!("expected PaymentSuccessful variant after roundtrip"),
565        }
566    }
567
568    #[test]
569    fn payment_event_response_failed_roundtrip() {
570        let quote_id = QuoteId::new_uuid();
571        let event = Event::PaymentFailed {
572            quote_id: quote_id.clone(),
573            reason: "route not found".to_string(),
574        };
575
576        let proto: PaymentEventResponse = event.clone().into();
577        let roundtrip = Event::try_from(proto).expect("valid proto event");
578
579        match (event, roundtrip) {
580            (
581                Event::PaymentFailed {
582                    quote_id: a_quote,
583                    reason: a,
584                },
585                Event::PaymentFailed {
586                    quote_id: b_quote,
587                    reason: b,
588                },
589            ) => {
590                assert_eq!(a_quote, b_quote);
591                assert_eq!(a, b);
592            }
593            _ => panic!("expected PaymentFailed variant after roundtrip"),
594        }
595    }
596
597    #[test]
598    fn payment_event_response_missing_oneof_errors() {
599        let proto = PaymentEventResponse { event: None };
600        assert!(Event::try_from(proto).is_err());
601    }
602
603    #[test]
604    fn payment_event_response_invalid_quote_id_errors() {
605        use super::{payment_event_response, PaymentFailedResponse};
606
607        // `!!!` is neither a valid UUID nor valid URL-safe base64, so QuoteId's
608        // FromStr rejects it and try_from should surface InvalidPaymentIdentifier.
609        let bogus = "!!!";
610        assert!(QuoteId::from_str(bogus).is_err());
611
612        let proto = PaymentEventResponse {
613            event: Some(payment_event_response::Event::PaymentFailed(
614                PaymentFailedResponse {
615                    quote_id: bogus.to_string(),
616                    reason: "bad".to_string(),
617                },
618            )),
619        };
620        assert!(Event::try_from(proto).is_err());
621    }
622}