agora_lnd_client/
lib.rs

1use crate::https_service::HttpsService;
2use http::uri::Authority;
3#[cfg(test)]
4use lnd_test_context::LndTestContext;
5use lnrpc::{
6  lightning_client::LightningClient, AddInvoiceResponse, Invoice, ListInvoiceRequest, PaymentHash,
7};
8use openssl::x509::X509;
9use std::convert::TryInto;
10#[cfg(test)]
11use std::sync::Arc;
12use tonic::{
13  metadata::AsciiMetadataValue,
14  service::interceptor::{InterceptedService, Interceptor},
15  Code, Request, Status,
16};
17
18pub use millisatoshi::Millisatoshi;
19
20mod https_service;
21mod millisatoshi;
22
23pub mod lnrpc {
24  use crate::millisatoshi::Millisatoshi;
25  use std::convert::TryInto;
26
27  tonic::include_proto!("lnrpc");
28
29  impl Invoice {
30    pub fn value_msat(&self) -> Millisatoshi {
31      Millisatoshi::new(
32        self
33          .value_msat
34          .try_into()
35          .expect("value_msat is always positive"),
36      )
37    }
38  }
39}
40
41#[derive(Clone)]
42struct MacaroonInterceptor {
43  macaroon: Option<AsciiMetadataValue>,
44}
45
46impl Interceptor for MacaroonInterceptor {
47  fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
48    if let Some(macaroon) = &self.macaroon {
49      request.metadata_mut().insert("macaroon", macaroon.clone());
50    }
51    Ok(request)
52  }
53}
54
55#[derive(Debug, Clone)]
56pub struct Client {
57  inner: LightningClient<InterceptedService<HttpsService, MacaroonInterceptor>>,
58  #[cfg(test)]
59  lnd_test_context: Arc<LndTestContext>,
60}
61
62impl Client {
63  pub async fn new(
64    authority: Authority,
65    certificate: Option<X509>,
66    macaroon: Option<Vec<u8>>,
67    #[cfg(test)] lnd_test_context: LndTestContext,
68  ) -> Result<Client, openssl::error::ErrorStack> {
69    let grpc_service = HttpsService::new(authority, certificate)?;
70
71    let macaroon = macaroon.map(|macaroon| {
72      hex::encode_upper(macaroon)
73        .parse::<AsciiMetadataValue>()
74        .expect("Client::new: hex characters are valid metadata values")
75    });
76
77    let inner = LightningClient::with_interceptor(grpc_service, MacaroonInterceptor { macaroon });
78
79    Ok(Client {
80      inner,
81      #[cfg(test)]
82      lnd_test_context: Arc::new(lnd_test_context),
83    })
84  }
85
86  pub async fn ping(&mut self) -> Result<(), Status> {
87    let request = tonic::Request::new(ListInvoiceRequest {
88      index_offset: 0,
89      num_max_invoices: 0,
90      pending_only: false,
91      reversed: false,
92    });
93
94    self.inner.list_invoices(request).await?;
95
96    Ok(())
97  }
98
99  pub async fn add_invoice(
100    &mut self,
101    memo: &str,
102    value_msat: Millisatoshi,
103  ) -> Result<AddInvoiceResponse, Status> {
104    let request = tonic::Request::new(Invoice {
105      memo: memo.to_owned(),
106      value_msat: value_msat.value().try_into().map_err(|source| {
107        Status::new(
108          Code::InvalidArgument,
109          format!("invalid value for `value_msat`: {}", source),
110        )
111      })?,
112      ..Invoice::default()
113    });
114    Ok(self.inner.add_invoice(request).await?.into_inner())
115  }
116
117  pub async fn lookup_invoice(&mut self, r_hash: [u8; 32]) -> Result<Option<Invoice>, Status> {
118    let request = tonic::Request::new(PaymentHash {
119      r_hash: r_hash.to_vec(),
120      ..PaymentHash::default()
121    });
122    match self.inner.lookup_invoice(request).await {
123      Ok(response) => Ok(Some(response.into_inner())),
124      Err(status) => {
125        if status.code() == Code::Unknown
126          && (status.message() == "there are no existing invoices"
127            || status.message() == "unable to locate invoice")
128        {
129          Ok(None)
130        } else {
131          Err(status)
132        }
133      }
134    }
135  }
136
137  #[cfg(test)]
138  async fn with_cert(lnd_test_context: LndTestContext, cert: &str) -> Self {
139    Self::new(
140      format!("localhost:{}", lnd_test_context.lnd_rpc_port)
141        .parse()
142        .unwrap(),
143      Some(X509::from_pem(cert.as_bytes()).unwrap()),
144      Some(
145        tokio::fs::read(lnd_test_context.invoice_macaroon_path())
146          .await
147          .unwrap(),
148      ),
149      lnd_test_context,
150    )
151    .await
152    .unwrap()
153  }
154
155  #[cfg(test)]
156  async fn with_test_context(lnd_test_context: LndTestContext) -> Self {
157    let cert = std::fs::read_to_string(lnd_test_context.cert_path()).unwrap();
158    Self::with_cert(lnd_test_context, &cert).await
159  }
160}
161
162#[cfg(test)]
163mod tests {
164  use super::*;
165
166  #[tokio::test]
167  async fn ping() {
168    Client::with_test_context(LndTestContext::new().await)
169      .await
170      .ping()
171      .await
172      .unwrap();
173  }
174
175  #[tokio::test]
176  async fn fails_on_wrong_lnd_certificate() {
177    const INVALID_TEST_CERT: &str = "-----BEGIN CERTIFICATE-----
178MIICTDCCAfGgAwIBAgIQdJJBvsv1/V23RMoX9fOOuTAKBggqhkjOPQQDAjAwMR8w
179HQYDVQQKExZsbmQgYXV0b2dlbmVyYXRlZCBjZXJ0MQ0wCwYDVQQDEwRwcmFnMB4X
180DTIxMDYyNzIxMTg1NloXDTIyMDgyMjIxMTg1NlowMDEfMB0GA1UEChMWbG5kIGF1
181dG9nZW5lcmF0ZWQgY2VydDENMAsGA1UEAxMEcHJhZzBZMBMGByqGSM49AgEGCCqG
182SM49AwEHA0IABL4lYBbOPVAtglBKPV3LwB7eC1j/Y6Nt0O23M1dSrcLdrNHUP87n
1835clDvrur4EaJTmnZHI2141usNs/pljzMHmqjgewwgekwDgYDVR0PAQH/BAQDAgKk
184MBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
185FIQ2zY1Z6g9NRGbMtXbSZEesaIqhMIGRBgNVHREEgYkwgYaCBHByYWeCCWxvY2Fs
186aG9zdIIEdW5peIIKdW5peHBhY2tldIIHYnVmY29ubocEfwAAAYcQAAAAAAAAAAAA
187AAAAAAAAAYcEwKgBDocErBEAAYcErBIAAYcErBMAAYcEwKgBC4cQ/oAAAAAAAAA2
1886QIJT4EyIocQ/oAAAAAAAABD0/8gsXGsVzAKBggqhkjOPQQDAgNJADBGAiEA3lrs
189qmJp1luuw/ElVG3DdHtz4Lx8iK8EanRdHA3T+78CIQDfuWGMe0IGtwLuDpDixvGy
190jlZBq5hr8Nv2qStFfw9qzw==
191-----END CERTIFICATE-----
192";
193    let error = Client::with_cert(LndTestContext::new().await, INVALID_TEST_CERT)
194      .await
195      .ping()
196      .await
197      .unwrap_err();
198    #[track_caller]
199    fn assert_contains(input: &str, expected: &str) {
200      assert!(
201        input.contains(expected),
202        "assert_contains:\n{}\ndidn't contain\n{}",
203        input,
204        expected
205      );
206    }
207    assert_contains(&error.to_string(), "error trying to connect: ");
208    assert_contains(&error.to_string(), "certificate verify failed");
209    assert_contains(&error.to_string(), "self signed certificate");
210  }
211
212  #[tokio::test]
213  async fn add_invoice() {
214    let mut client = Client::with_test_context(LndTestContext::new().await).await;
215    let response = client
216      .add_invoice("", Millisatoshi::new(1_000))
217      .await
218      .unwrap();
219    assert!(
220      !response.payment_request.is_empty(),
221      "Bad response: {:?}",
222      response
223    );
224  }
225
226  #[tokio::test]
227  async fn add_invoice_memo_and_value() {
228    let mut client = Client::with_test_context(LndTestContext::new().await).await;
229    let r_hash = client
230      .add_invoice("test-memo", Millisatoshi::new(42_000))
231      .await
232      .unwrap()
233      .r_hash;
234    let invoice = client
235      .lookup_invoice(r_hash.try_into().unwrap())
236      .await
237      .unwrap()
238      .unwrap();
239    assert_eq!(invoice.memo, "test-memo");
240    assert_eq!(invoice.value, 42);
241  }
242
243  #[tokio::test]
244  async fn lookup_invoice() {
245    let mut client = Client::with_test_context(LndTestContext::new().await).await;
246    let _ignored1 = client
247      .add_invoice("foo", Millisatoshi::new(1_000))
248      .await
249      .unwrap();
250    let created = client
251      .add_invoice("bar", Millisatoshi::new(2_000))
252      .await
253      .unwrap();
254    let _ignored2 = client
255      .add_invoice("baz", Millisatoshi::new(3_000))
256      .await
257      .unwrap();
258    let retrieved = client
259      .lookup_invoice(created.r_hash.as_slice().try_into().unwrap())
260      .await
261      .unwrap()
262      .unwrap();
263    assert_eq!(
264      (
265        created.add_index,
266        created.r_hash,
267        created.payment_request,
268        created.payment_addr
269      ),
270      (
271        retrieved.add_index,
272        retrieved.r_hash,
273        retrieved.payment_request,
274        retrieved.payment_addr
275      )
276    );
277    assert_eq!(retrieved.memo, "bar");
278    assert_eq!(retrieved.value, 2);
279  }
280
281  #[tokio::test]
282  async fn lookup_invoice_not_found_no_invoices() {
283    let mut client = Client::with_test_context(LndTestContext::new().await).await;
284    assert_eq!(client.lookup_invoice([0; 32]).await.unwrap(), None);
285  }
286
287  #[tokio::test]
288  async fn lookup_invoice_not_found_some_invoices() {
289    let mut client = Client::with_test_context(LndTestContext::new().await).await;
290    let _ignored1 = client
291      .add_invoice("foo", Millisatoshi::new(1_000))
292      .await
293      .unwrap();
294    assert_eq!(client.lookup_invoice([0; 32]).await.unwrap(), None);
295  }
296}