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
102impl 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
121pub(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 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 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}