Skip to main content

agent_pay/
lnd_rest.rs

1//! LND REST adapter (BYO node).
2
3use async_trait::async_trait;
4use base64::engine::general_purpose::STANDARD as B64_STD;
5use base64::Engine;
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8
9use crate::error::Error;
10use crate::lightning::{
11    Invoice, InvoiceCreateRequest, InvoiceLookup, LightningNode, PaymentResult,
12};
13
14pub struct LndRestConfig {
15    pub url: String,
16    pub macaroon_hex: String,
17}
18
19pub struct LndRestNode {
20    cfg: LndRestConfig,
21    client: Client,
22}
23
24impl LndRestNode {
25    pub fn new(cfg: LndRestConfig) -> Result<Self, Error> {
26        let client = Client::builder()
27            .danger_accept_invalid_certs(true)
28            .build()
29            .map_err(|e| Error::Http(e.to_string()))?;
30        Ok(Self { cfg, client })
31    }
32
33    pub fn with_client(cfg: LndRestConfig, client: Client) -> Self {
34        Self { cfg, client }
35    }
36}
37
38#[derive(Serialize)]
39struct CreateInvoiceReq<'a> {
40    value_msat: String,
41    memo: &'a str,
42    expiry: String,
43}
44
45#[derive(Deserialize)]
46struct CreateInvoiceResp {
47    payment_request: String,
48    r_hash: String,
49}
50
51#[derive(Deserialize)]
52struct LookupResp {
53    settled: Option<bool>,
54    value_msat: Option<String>,
55    r_preimage: Option<String>,
56}
57
58#[derive(Serialize)]
59struct PayReq<'a> {
60    payment_request: &'a str,
61}
62
63#[derive(Deserialize)]
64struct PayResp {
65    payment_error: Option<String>,
66    payment_preimage: Option<String>,
67    payment_route: Option<PaymentRoute>,
68}
69
70#[derive(Deserialize)]
71struct PaymentRoute {
72    total_fees_msat: Option<String>,
73}
74
75#[async_trait]
76impl LightningNode for LndRestNode {
77    async fn create_invoice(&self, req: InvoiceCreateRequest) -> Result<Invoice, Error> {
78        let body = CreateInvoiceReq {
79            value_msat: req.amount_msat.to_string(),
80            memo: req.memo.as_deref().unwrap_or(""),
81            expiry: req.expiry_seconds.unwrap_or(300).to_string(),
82        };
83        let res = self
84            .client
85            .post(format!("{}/v1/invoices", self.cfg.url))
86            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
87            .json(&body)
88            .send()
89            .await
90            .map_err(|e| Error::Http(e.to_string()))?;
91        if !res.status().is_success() {
92            let s = res.status();
93            let t = res.text().await.unwrap_or_default();
94            return Err(Error::Http(format!("LND createInvoice {s}: {t}")));
95        }
96        let resp: CreateInvoiceResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
97        let r_hash_bytes = B64_STD
98            .decode(&resp.r_hash)
99            .map_err(|e| Error::Http(format!("r_hash b64: {e}")))?;
100        Ok(Invoice {
101            bolt11: resp.payment_request,
102            payment_hash: hex::encode(r_hash_bytes),
103        })
104    }
105
106    async fn lookup_invoice(&self, payment_hash: &str) -> Result<InvoiceLookup, Error> {
107        let bytes =
108            hex::decode(payment_hash).map_err(|e| Error::Http(format!("payment_hash hex: {e}")))?;
109        let b64 = B64_STD.encode(&bytes);
110        let path = urlencoding::encode(&b64).into_owned();
111        let res = self
112            .client
113            .get(format!("{}/v1/invoice/{}", self.cfg.url, path))
114            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
115            .send()
116            .await
117            .map_err(|e| Error::Http(e.to_string()))?;
118        if !res.status().is_success() {
119            let s = res.status();
120            let t = res.text().await.unwrap_or_default();
121            return Err(Error::Http(format!("LND lookupInvoice {s}: {t}")));
122        }
123        let resp: LookupResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
124        let preimage = if let Some(b64) = resp.r_preimage {
125            Some(
126                B64_STD
127                    .decode(b64)
128                    .map_err(|e| Error::Http(format!("preimage b64: {e}")))?,
129            )
130        } else {
131            None
132        };
133        Ok(InvoiceLookup {
134            settled: resp.settled.unwrap_or(false),
135            amount_msat: resp
136                .value_msat
137                .as_deref()
138                .unwrap_or("0")
139                .parse()
140                .unwrap_or(0),
141            preimage,
142        })
143    }
144
145    async fn pay_invoice(&self, bolt11: &str) -> Result<PaymentResult, Error> {
146        let res = self
147            .client
148            .post(format!("{}/v1/channels/transactions", self.cfg.url))
149            .header("grpc-metadata-macaroon", &self.cfg.macaroon_hex)
150            .json(&PayReq {
151                payment_request: bolt11,
152            })
153            .send()
154            .await
155            .map_err(|e| Error::Http(e.to_string()))?;
156        if !res.status().is_success() {
157            let s = res.status();
158            let t = res.text().await.unwrap_or_default();
159            return Err(Error::Http(format!("LND payInvoice {s}: {t}")));
160        }
161        let resp: PayResp = res.json().await.map_err(|e| Error::Http(e.to_string()))?;
162        if let Some(err) = resp.payment_error {
163            return Err(Error::Http(format!("LND payment_error: {err}")));
164        }
165        let preimage = resp
166            .payment_preimage
167            .ok_or_else(|| Error::Http("missing payment_preimage".into()))?;
168        let bytes = B64_STD
169            .decode(&preimage)
170            .map_err(|e| Error::Http(format!("preimage b64: {e}")))?;
171        Ok(PaymentResult {
172            preimage: bytes,
173            fee_msat: resp
174                .payment_route
175                .and_then(|r| r.total_fees_msat)
176                .and_then(|s| s.parse().ok())
177                .unwrap_or(0),
178        })
179    }
180}