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
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: 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 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}