1use std::path::{Path, PathBuf};
2use std::pin::Pin;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::Arc;
5use std::task::{Context, Poll};
6
7use anyhow::{anyhow, Context as AnyhowContext};
8use cdk_common::grpc::{VersionInterceptor, VERSION_HEADER};
9use cdk_common::payment::{
10 CreateIncomingPaymentResponse, IncomingPaymentOptions as CdkIncomingPaymentOptions,
11 MakePaymentResponse as CdkMakePaymentResponse, MintPayment,
12 PaymentQuoteResponse as CdkPaymentQuoteResponse, WaitPaymentResponse,
13};
14use futures::{Stream, StreamExt};
15use tokio::sync::Mutex;
16use tokio_util::sync::CancellationToken;
17use tonic::codegen::InterceptedService;
18use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
19use tonic::{async_trait, Request};
20use tracing::instrument;
21
22use crate::proto::cdk_payment_processor_client::CdkPaymentProcessorClient;
23use crate::proto::{
24 CheckIncomingPaymentRequest, CheckOutgoingPaymentRequest, CreatePaymentRequest, EmptyRequest,
25 IncomingPaymentOptions, IntoProtoAmount, MakePaymentRequest, OutgoingPaymentRequestType,
26 PaymentQuoteRequest,
27};
28
29#[derive(Clone)]
31pub struct PaymentProcessorClient {
32 inner: CdkPaymentProcessorClient<InterceptedService<Channel, VersionInterceptor>>,
33 payment_event_stream_is_active: Arc<AtomicBool>,
34 cancel_payment_event_stream: Arc<Mutex<CancellationToken>>,
35}
36
37struct ActivePaymentEventStream {
38 inner: Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>,
39 active_flag: Arc<AtomicBool>,
40}
41
42impl ActivePaymentEventStream {
43 fn new(
44 inner: Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>,
45 active_flag: Arc<AtomicBool>,
46 ) -> Self {
47 Self { inner, active_flag }
48 }
49}
50
51impl Drop for ActivePaymentEventStream {
52 fn drop(&mut self) {
53 self.active_flag.store(false, Ordering::SeqCst);
54 tracing::info!("Payment event stream inactive");
55 }
56}
57
58impl Stream for ActivePaymentEventStream {
59 type Item = cdk_common::payment::Event;
60
61 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
62 let this = self.get_mut();
63 this.inner.as_mut().poll_next(cx)
64 }
65}
66
67impl std::fmt::Debug for PaymentProcessorClient {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("PaymentProcessorClient")
70 .finish_non_exhaustive()
71 }
72}
73
74impl PaymentProcessorClient {
75 pub async fn new(addr: &str, port: u16, tls_dir: Option<PathBuf>) -> anyhow::Result<Self> {
81 let scheme = if tls_dir.is_some() { "https" } else { "http" };
82 let endpoint = format!("{scheme}://{addr}:{port}");
83
84 let channel = if let Some(tls_dir) = tls_dir {
85 let tls = load_mtls_config(&tls_dir)?;
86 Channel::from_shared(endpoint)?
87 .tls_config(tls)?
88 .connect()
89 .await?
90 } else {
91 Channel::from_shared(endpoint)?.connect().await?
93 };
94
95 let interceptor = VersionInterceptor::new(
96 VERSION_HEADER,
97 cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION,
98 );
99 let client = CdkPaymentProcessorClient::with_interceptor(channel, interceptor);
100
101 Ok(Self {
102 inner: client,
103 payment_event_stream_is_active: Arc::new(AtomicBool::new(false)),
104 cancel_payment_event_stream: Arc::new(Mutex::new(CancellationToken::new())),
105 })
106 }
107}
108
109fn load_mtls_config(tls_dir: &Path) -> anyhow::Result<ClientTlsConfig> {
110 let ca_pem_path = tls_dir.join("ca.pem");
111 let client_pem_path = tls_dir.join("client.pem");
112 let client_key_path = tls_dir.join("client.key");
113
114 let server_root_ca_cert = std::fs::read(&ca_pem_path)
115 .with_context(|| format!("failed to read CA certificate `{}`", ca_pem_path.display()))?;
116 let client_cert = std::fs::read(&client_pem_path).with_context(|| {
117 format!(
118 "failed to read client certificate `{}`",
119 client_pem_path.display()
120 )
121 })?;
122 let client_key = std::fs::read(&client_key_path).with_context(|| {
123 format!(
124 "failed to read client private key `{}`",
125 client_key_path.display()
126 )
127 })?;
128
129 Ok(ClientTlsConfig::new()
130 .ca_certificate(Certificate::from_pem(server_root_ca_cert))
131 .identity(Identity::from_pem(client_cert, client_key)))
132}
133
134#[async_trait]
135impl MintPayment for PaymentProcessorClient {
136 type Err = cdk_common::payment::Error;
137
138 async fn get_settings(&self) -> Result<cdk_common::payment::SettingsResponse, Self::Err> {
139 let mut inner = self.inner.clone();
140 let response = inner
141 .get_settings(Request::new(EmptyRequest {}))
142 .await
143 .map_err(|err| {
144 tracing::error!("Could not get settings: {}", err);
145 cdk_common::payment::Error::Custom(err.to_string())
146 })?;
147
148 let settings = response.into_inner();
149
150 Ok(cdk_common::payment::SettingsResponse {
151 unit: settings.unit,
152 bolt11: settings
153 .bolt11
154 .map(|b| cdk_common::payment::Bolt11Settings {
155 mpp: b.mpp,
156 amountless: b.amountless,
157 invoice_description: b.invoice_description,
158 }),
159 bolt12: settings
160 .bolt12
161 .map(|b| cdk_common::payment::Bolt12Settings {
162 amountless: b.amountless,
163 invoice_description: b.invoice_description,
164 }),
165 onchain: settings
166 .onchain
167 .map(|o| cdk_common::payment::OnchainSettings {
168 confirmations: o.confirmations,
169 min_receive_amount_sat: o.min_receive_amount_sat,
170 min_send_amount_sat: o.min_send_amount_sat,
171 }),
172 custom: settings.custom,
173 })
174 }
175
176 async fn create_incoming_payment_request(
178 &self,
179 options: CdkIncomingPaymentOptions,
180 ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
181 let mut inner = self.inner.clone();
182
183 let proto_options = match options {
184 CdkIncomingPaymentOptions::Custom(opts) => IncomingPaymentOptions {
185 options: Some(super::incoming_payment_options::Options::Custom(
186 super::CustomIncomingPaymentOptions {
187 description: opts.description,
188 amount: opts.amount.map(Into::into),
189 unix_expiry: opts.unix_expiry,
190 extra_json: opts.extra_json,
191 quote_id: opts.quote_id.to_string(),
192 pubkey: opts.pubkey.map(|p| p.to_hex()),
193 },
194 )),
195 },
196 CdkIncomingPaymentOptions::Bolt11(opts) => IncomingPaymentOptions {
197 options: Some(super::incoming_payment_options::Options::Bolt11(
198 super::Bolt11IncomingPaymentOptions {
199 description: opts.description,
200 amount: Some(opts.amount.into()),
201 unix_expiry: opts.unix_expiry,
202 },
203 )),
204 },
205 CdkIncomingPaymentOptions::Bolt12(opts) => IncomingPaymentOptions {
206 options: Some(super::incoming_payment_options::Options::Bolt12(
207 super::Bolt12IncomingPaymentOptions {
208 description: opts.description,
209 amount: opts.amount.map(Into::into),
210 unix_expiry: opts.unix_expiry,
211 },
212 )),
213 },
214 CdkIncomingPaymentOptions::Onchain(opts) => IncomingPaymentOptions {
215 options: Some(super::incoming_payment_options::Options::Onchain(
216 super::OnchainIncomingPaymentOptions {
217 quote_id: opts.quote_id.to_string(),
218 },
219 )),
220 },
221 };
222
223 let response = inner
224 .create_payment(Request::new(CreatePaymentRequest {
225 options: Some(proto_options),
226 }))
227 .await
228 .map_err(|err| {
229 tracing::error!("Could not create payment request: {}", err);
230 cdk_common::payment::Error::Custom(err.to_string())
231 })?;
232
233 let response = response.into_inner();
234
235 Ok(response.try_into().map_err(|_| {
236 cdk_common::payment::Error::Anyhow(anyhow!("Could not create create payment response"))
237 })?)
238 }
239
240 async fn get_payment_quote(
241 &self,
242 unit: &cdk_common::CurrencyUnit,
243 options: cdk_common::payment::OutgoingPaymentOptions,
244 ) -> Result<CdkPaymentQuoteResponse, Self::Err> {
245 let mut inner = self.inner.clone();
246
247 let request_type = match &options {
248 cdk_common::payment::OutgoingPaymentOptions::Custom(_) => {
249 OutgoingPaymentRequestType::Custom
250 }
251 cdk_common::payment::OutgoingPaymentOptions::Bolt11(_) => {
252 OutgoingPaymentRequestType::Bolt11Invoice
253 }
254 cdk_common::payment::OutgoingPaymentOptions::Bolt12(_) => {
255 OutgoingPaymentRequestType::Bolt12Offer
256 }
257 cdk_common::payment::OutgoingPaymentOptions::Onchain(_) => {
258 OutgoingPaymentRequestType::Onchain
259 }
260 };
261
262 let proto_request = match &options {
263 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => opts.request.to_string(),
264 cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11.to_string(),
265 cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.offer.to_string(),
266 cdk_common::payment::OutgoingPaymentOptions::Onchain(opts) => opts.address.clone(),
267 };
268
269 let proto_options = match &options {
270 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => opts.melt_options,
271 cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.melt_options,
272 cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.melt_options,
273 cdk_common::payment::OutgoingPaymentOptions::Onchain(_) => None,
274 };
275
276 let onchain_options = match &options {
277 cdk_common::payment::OutgoingPaymentOptions::Onchain(opts) => {
278 Some(super::OnchainOutgoingPaymentOptions {
279 address: opts.address.clone(),
280 amount: Some(opts.amount.clone().into()),
281 max_fee_amount: opts.max_fee_amount.clone().into_proto(),
282 quote_id: opts.quote_id.to_string(),
283 fee_index: opts.fee_index,
284 metadata: opts.metadata.clone(),
285 })
286 }
287 _ => None,
288 };
289
290 let extra_json = match &options {
291 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => opts.extra_json.clone(),
292 _ => None,
293 };
294
295 let amount = match &options {
296 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => {
297 opts.amount.clone().into_proto()
298 }
299 _ => None,
300 };
301
302 let quote_id = match &options {
303 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => opts.quote_id.to_string(),
304 cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.quote_id.to_string(),
305 cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.quote_id.to_string(),
306 cdk_common::payment::OutgoingPaymentOptions::Onchain(opts) => opts.quote_id.to_string(),
307 };
308
309 let response = inner
310 .get_payment_quote(Request::new(PaymentQuoteRequest {
311 request: proto_request,
312 unit: unit.to_string(),
313 options: proto_options.map(Into::into),
314 request_type: request_type.into(),
315 extra_json,
316 quote_id,
317 onchain_options,
318 amount,
319 }))
320 .await
321 .map_err(|err| {
322 tracing::error!("Could not get payment quote: {}", err);
323 cdk_common::payment::Error::Custom(err.to_string())
324 })?;
325
326 let response = response.into_inner();
327
328 Ok(response.try_into().map_err(|_| {
329 cdk_common::payment::Error::Custom(
330 "Failed to convert payment quote response".to_string(),
331 )
332 })?)
333 }
334
335 async fn make_payment(
336 &self,
337 unit: &cdk_common::CurrencyUnit,
338 options: cdk_common::payment::OutgoingPaymentOptions,
339 ) -> Result<CdkMakePaymentResponse, Self::Err> {
340 let mut inner = self.inner.clone();
341 let payment_options = match options {
342 cdk_common::payment::OutgoingPaymentOptions::Custom(opts) => {
343 super::OutgoingPaymentVariant {
344 options: Some(super::outgoing_payment_variant::Options::Custom(
345 super::CustomOutgoingPaymentOptions {
346 offer: opts.request.to_string(),
347 amount: opts.amount.map(Into::into),
348 max_fee_amount: opts.max_fee_amount.into_proto(),
349 timeout_secs: opts.timeout_secs,
350 melt_options: opts.melt_options.map(Into::into),
351 extra_json: opts.extra_json.clone(),
352 quote_id: opts.quote_id.to_string(),
353 },
354 )),
355 }
356 }
357 cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => {
358 super::OutgoingPaymentVariant {
359 options: Some(super::outgoing_payment_variant::Options::Bolt11(
360 super::Bolt11OutgoingPaymentOptions {
361 bolt11: opts.bolt11.to_string(),
362 max_fee_amount: opts.max_fee_amount.into_proto(),
363 timeout_secs: opts.timeout_secs,
364 melt_options: opts.melt_options.map(Into::into),
365 quote_id: opts.quote_id.to_string(),
366 },
367 )),
368 }
369 }
370 cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => {
371 super::OutgoingPaymentVariant {
372 options: Some(super::outgoing_payment_variant::Options::Bolt12(
373 super::Bolt12OutgoingPaymentOptions {
374 offer: opts.offer.to_string(),
375 max_fee_amount: opts.max_fee_amount.into_proto(),
376 timeout_secs: opts.timeout_secs,
377 melt_options: opts.melt_options.map(Into::into),
378 quote_id: opts.quote_id.to_string(),
379 },
380 )),
381 }
382 }
383 cdk_common::payment::OutgoingPaymentOptions::Onchain(opts) => {
384 super::OutgoingPaymentVariant {
385 options: Some(super::outgoing_payment_variant::Options::Onchain(
386 super::OnchainOutgoingPaymentOptions {
387 address: opts.address.clone(),
388 amount: Some(opts.amount.into()),
389 max_fee_amount: opts.max_fee_amount.into_proto(),
390 quote_id: opts.quote_id.to_string(),
391 fee_index: opts.fee_index,
392 metadata: opts.metadata.clone(),
393 },
394 )),
395 }
396 }
397 };
398
399 let response = inner
400 .make_payment(Request::new(MakePaymentRequest {
401 payment_options: Some(payment_options),
402 partial_amount: None,
403 max_fee_amount: None,
404 unit: unit.to_string(),
405 }))
406 .await
407 .map_err(|err| {
408 tracing::error!("Could not pay payment request: {}", err);
409
410 if err.message().contains("already paid") {
411 cdk_common::payment::Error::InvoiceAlreadyPaid
412 } else if err.message().contains("pending") {
413 cdk_common::payment::Error::InvoicePaymentPending
414 } else {
415 cdk_common::payment::Error::Custom(err.to_string())
416 }
417 })?;
418
419 let response = response.into_inner();
420
421 Ok(response.try_into().map_err(|_err| {
422 cdk_common::payment::Error::Anyhow(anyhow!("could not make payment"))
423 })?)
424 }
425
426 #[instrument(skip_all)]
427 async fn wait_payment_event(
428 &self,
429 ) -> Result<Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>, Self::Err> {
430 tracing::debug!("Client waiting for payment");
431 let mut inner = self.inner.clone();
432 let stream = inner
433 .wait_payment_event(Request::new(EmptyRequest {}))
434 .await
435 .map_err(|err| {
436 self.payment_event_stream_is_active
437 .store(false, Ordering::SeqCst);
438 tracing::error!("Could not open payment event stream: {}", err);
439 cdk_common::payment::Error::Custom(err.to_string())
440 })?
441 .into_inner();
442
443 self.payment_event_stream_is_active
444 .store(true, Ordering::SeqCst);
445
446 let cancel_token = self.cancel_payment_event_stream.lock().await.clone();
447 let cancel_fut = cancel_token.cancelled_owned();
448 let active_flag = self.payment_event_stream_is_active.clone();
449
450 let transformed_stream = stream.take_until(cancel_fut).filter_map(|item| async {
451 match item {
452 Ok(value) => match value.try_into() {
453 Ok(payment_event) => Some(payment_event),
454 Err(e) => {
455 tracing::error!("Error converting payment event: {}", e);
456 None
457 }
458 },
459 Err(e) => {
460 tracing::error!("Error in payment event stream: {}", e);
461 None
462 }
463 }
464 });
465
466 Ok(Box::pin(ActivePaymentEventStream::new(
467 Box::pin(transformed_stream),
468 active_flag,
469 )))
470 }
471
472 fn is_payment_event_stream_active(&self) -> bool {
474 self.payment_event_stream_is_active.load(Ordering::SeqCst)
475 }
476
477 fn cancel_payment_event_stream(&self) {
479 let cancel_payment_event_stream = Arc::clone(&self.cancel_payment_event_stream);
480
481 tokio::spawn(async move {
482 let mut cancel_token = cancel_payment_event_stream.lock().await;
483 cancel_token.cancel();
484 *cancel_token = CancellationToken::new();
485 });
486 }
487
488 async fn check_incoming_payment_status(
489 &self,
490 payment_identifier: &cdk_common::payment::PaymentIdentifier,
491 ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
492 let mut inner = self.inner.clone();
493 let response = inner
494 .check_incoming_payment(Request::new(CheckIncomingPaymentRequest {
495 request_identifier: Some(payment_identifier.clone().into()),
496 }))
497 .await
498 .map_err(|err| {
499 tracing::error!("Could not check incoming payment: {}", err);
500 cdk_common::payment::Error::Custom(err.to_string())
501 })?;
502
503 let check_incoming = response.into_inner();
504 check_incoming
505 .payments
506 .into_iter()
507 .map(|resp| resp.try_into().map_err(Self::Err::from))
508 .collect()
509 }
510
511 async fn check_outgoing_payment(
512 &self,
513 payment_identifier: &cdk_common::payment::PaymentIdentifier,
514 ) -> Result<CdkMakePaymentResponse, Self::Err> {
515 let mut inner = self.inner.clone();
516 let response = inner
517 .check_outgoing_payment(Request::new(CheckOutgoingPaymentRequest {
518 request_identifier: Some(payment_identifier.clone().into()),
519 }))
520 .await
521 .map_err(|err| {
522 tracing::error!("Could not check outgoing payment: {}", err);
523 cdk_common::payment::Error::Custom(err.to_string())
524 })?;
525
526 let check_outgoing = response.into_inner();
527
528 Ok(check_outgoing
529 .try_into()
530 .map_err(|_| cdk_common::payment::Error::UnknownPaymentState)?)
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use std::fs;
537 use std::path::{Path, PathBuf};
538 use std::sync::atomic::{AtomicU64, Ordering};
539
540 use super::load_mtls_config;
541
542 static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
543
544 struct TestDirectory(PathBuf);
545
546 impl TestDirectory {
547 fn new() -> Self {
548 let sequence = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
549 let path = std::env::temp_dir().join(format!(
550 "cdk-payment-processor-mtls-{}-{sequence}",
551 std::process::id()
552 ));
553 fs::create_dir(&path).expect("create mTLS test directory");
554 Self(path)
555 }
556
557 fn path(&self) -> &Path {
558 &self.0
559 }
560 }
561
562 impl Drop for TestDirectory {
563 fn drop(&mut self) {
564 let _ = fs::remove_dir_all(&self.0);
565 }
566 }
567
568 #[test]
569 fn configured_tls_requires_ca_and_client_identity() {
570 let tls_dir = TestDirectory::new();
571
572 let error = load_mtls_config(tls_dir.path()).expect_err("missing CA should fail");
573 assert!(error.to_string().contains("failed to read CA certificate"));
574
575 fs::write(tls_dir.path().join("ca.pem"), "test CA").expect("write test CA");
576 let error =
577 load_mtls_config(tls_dir.path()).expect_err("missing client certificate should fail");
578 assert!(error
579 .to_string()
580 .contains("failed to read client certificate"));
581
582 fs::write(tls_dir.path().join("client.pem"), "test client certificate")
583 .expect("write test client certificate");
584 let error =
585 load_mtls_config(tls_dir.path()).expect_err("missing client private key should fail");
586 assert!(error
587 .to_string()
588 .contains("failed to read client private key"));
589
590 fs::write(tls_dir.path().join("client.key"), "test client key")
591 .expect("write test client key");
592 load_mtls_config(tls_dir.path()).expect("complete mTLS configuration should load");
593 }
594}