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
103impl 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
122pub(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 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 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}