Skip to main content

gl_client/lnurl/
models.rs

1use anyhow::{anyhow, ensure, Result};
2use async_trait::async_trait;
3use log::debug;
4use mockall::automock;
5use reqwest::Response;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Serialize};
8
9#[derive(Serialize, Deserialize, Debug, Clone)]
10pub struct PayRequestResponse {
11    pub callback: String,
12    #[serde(rename = "maxSendable")]
13    pub max_sendable: u64,
14    #[serde(rename = "minSendable")]
15    pub min_sendable: u64,
16    pub tag: String,
17    pub metadata: String,
18    /// Maximum comment length the service accepts (LUD-12).
19    /// None or 0 means comments are not supported.
20    #[serde(rename = "commentAllowed")]
21    #[serde(default)]
22    pub comment_allowed: Option<u64>,
23}
24
25#[derive(Deserialize, Clone, Debug)]
26pub struct PayRequestCallbackResponse {
27    pub pr: String,
28    pub routes: Vec<String>,
29    /// Optional success action returned by the service (LUD-09).
30    #[serde(rename = "successAction")]
31    #[serde(default)]
32    pub success_action: Option<SuccessAction>,
33}
34
35#[derive(Debug, Deserialize, Serialize)]
36pub struct OkResponse {
37    pub status: String,
38}
39
40#[derive(Debug, Deserialize, Serialize)]
41pub struct ErrorResponse {
42    pub status: String,
43    pub reason: String,
44}
45
46#[derive(Serialize, Deserialize, Debug, Clone)]
47pub struct WithdrawRequestResponse {
48    pub tag: String,
49    pub callback: String,
50    pub k1: String,
51    #[serde(rename = "defaultDescription")]
52    pub default_description: String,
53    #[serde(rename = "minWithdrawable")]
54    pub min_withdrawable: u64,
55    #[serde(rename = "maxWithdrawable")]
56    pub max_withdrawable: u64,
57}
58
59/// Raw success action from an LNURL-pay callback response (LUD-09/10).
60///
61/// Deserialized directly from the service's JSON. For the AES variant,
62/// the ciphertext has not yet been decrypted -- use
63/// [`process_success_action`] with the payment preimage to produce a
64/// [`ProcessedSuccessAction`].
65#[derive(Clone, Debug, Serialize, Deserialize)]
66#[serde(tag = "tag")]
67pub enum SuccessAction {
68    #[serde(rename = "message")]
69    Message { message: String },
70    #[serde(rename = "url")]
71    Url { description: String, url: String },
72    #[serde(rename = "aes")]
73    Aes {
74        description: String,
75        /// Base64-encoded ciphertext (max 4096 chars).
76        ciphertext: String,
77        /// Base64-encoded IV (24 chars = 16 bytes).
78        iv: String,
79    },
80}
81
82/// A success action after client-side processing.
83///
84/// For the Message and Url variants this is identical to the raw
85/// [`SuccessAction`]. For AES the ciphertext has been decrypted into
86/// plaintext using the payment preimage.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum ProcessedSuccessAction {
89    Message { message: String },
90    Url { description: String, url: String },
91    Aes { description: String, plaintext: String },
92}
93
94impl SuccessAction {
95    /// Process this success action, decrypting AES content if needed.
96    ///
97    /// `preimage` is the 32-byte payment preimage from the PayResponse.
98    /// For Message and Url variants this is a simple conversion; for Aes
99    /// it decrypts the ciphertext using the preimage as the AES-256 key.
100    ///
101    /// All payload-shape checks here are required by the LNURL specs:
102    /// - Message.message ≤ 144 chars (LUD-09)
103    /// - Url.description ≤ 144 chars (LUD-09)
104    /// - Aes.description ≤ 144 chars (LUD-10)
105    /// - Aes.ciphertext ≤ 4096 chars (LUD-10)
106    /// - Aes.iv == exactly 24 base64 chars / 16 bytes (LUD-10)
107    pub fn process(self, preimage: &[u8]) -> Result<ProcessedSuccessAction> {
108        match self {
109            SuccessAction::Message { message } => {
110                ensure!(
111                    message.len() <= 144,
112                    "Message success action exceeds 144 chars"
113                );
114                Ok(ProcessedSuccessAction::Message { message })
115            }
116            SuccessAction::Url { description, url } => {
117                ensure!(
118                    description.len() <= 144,
119                    "Url success action description exceeds 144 chars"
120                );
121                Ok(ProcessedSuccessAction::Url { description, url })
122            }
123            SuccessAction::Aes {
124                description,
125                ciphertext,
126                iv,
127            } => {
128                ensure!(
129                    description.len() <= 144,
130                    "AES success action description exceeds 144 chars"
131                );
132                ensure!(
133                    ciphertext.len() <= 4096,
134                    "AES success action ciphertext exceeds 4096 chars"
135                );
136                ensure!(
137                    iv.len() == 24,
138                    "AES success action IV must be exactly 24 base64 chars"
139                );
140                let plaintext =
141                    super::pay::decrypt_aes_success_action(preimage, &ciphertext, &iv)?;
142                Ok(ProcessedSuccessAction::Aes {
143                    description,
144                    plaintext,
145                })
146            }
147        }
148    }
149}
150
151#[async_trait]
152#[automock]
153pub trait LnUrlHttpClient {
154    async fn get_pay_request_response(&self, lnurl: &str) -> Result<PayRequestResponse>;
155    async fn get_pay_request_callback_response(
156        &self,
157        callback_url: &str,
158    ) -> Result<PayRequestCallbackResponse>;
159    async fn get_withdrawal_request_response(&self, url: &str) -> Result<WithdrawRequestResponse>;
160    async fn send_invoice_for_withdraw_request(&self, url: &str) -> Result<OkResponse>;
161    async fn get_json(&self, url: &str) -> Result<serde_json::Value>;
162}
163
164pub struct LnUrlHttpClearnetClient {
165    client: reqwest::Client,
166}
167
168impl LnUrlHttpClearnetClient {
169    pub fn new() -> LnUrlHttpClearnetClient {
170        LnUrlHttpClearnetClient {
171            client: reqwest::Client::new(),
172        }
173    }
174
175    async fn get<T: DeserializeOwned + 'static>(&self, url: &str) -> Result<T> {
176        let response: Response = self.client.get(url).send().await?;
177        match response.json::<T>().await {
178            Ok(body) => Ok(body),
179            Err(e) => {
180                debug!("{}", e);
181                Err(anyhow!("Unable to parse http response body as json"))
182            }
183        }
184    }
185}
186
187#[async_trait]
188impl LnUrlHttpClient for LnUrlHttpClearnetClient {
189    async fn get_pay_request_response(&self, lnurl: &str) -> Result<PayRequestResponse> {
190        self.get::<PayRequestResponse>(lnurl).await
191    }
192
193    async fn get_pay_request_callback_response(
194        &self,
195        callback_url: &str,
196    ) -> Result<PayRequestCallbackResponse> {
197        self.get::<PayRequestCallbackResponse>(callback_url).await
198    }
199
200    async fn get_withdrawal_request_response(&self, url: &str) -> Result<WithdrawRequestResponse> {
201        self.get::<WithdrawRequestResponse>(url).await
202    }
203
204    async fn send_invoice_for_withdraw_request(&self, url: &str) -> Result<OkResponse> {
205        self.get::<OkResponse>(url).await
206    }
207
208    async fn get_json(&self, url: &str) -> Result<serde_json::Value> {
209        self.get::<serde_json::Value>(url).await
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_success_action_message_serde() {
219        let json = r#"{"tag":"message","message":"Thank you!"}"#;
220        let action: SuccessAction = serde_json::from_str(json).unwrap();
221        match action {
222            SuccessAction::Message { message } => assert_eq!(message, "Thank you!"),
223            _ => panic!("Expected Message variant"),
224        }
225    }
226
227    #[test]
228    fn test_success_action_url_serde() {
229        let json = r#"{"tag":"url","description":"View order","url":"https://example.com/order/123"}"#;
230        let action: SuccessAction = serde_json::from_str(json).unwrap();
231        match action {
232            SuccessAction::Url { description, url } => {
233                assert_eq!(description, "View order");
234                assert_eq!(url, "https://example.com/order/123");
235            }
236            _ => panic!("Expected Url variant"),
237        }
238    }
239
240    #[test]
241    fn test_success_action_aes_serde() {
242        let json = r#"{"tag":"aes","description":"Secret","ciphertext":"YWJj","iv":"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0"}"#;
243        let action: SuccessAction = serde_json::from_str(json).unwrap();
244        match action {
245            SuccessAction::Aes {
246                description,
247                ciphertext,
248                iv,
249            } => {
250                assert_eq!(description, "Secret");
251                assert_eq!(ciphertext, "YWJj");
252                assert_eq!(iv, "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0");
253            }
254            _ => panic!("Expected Aes variant"),
255        }
256    }
257
258    #[test]
259    fn test_callback_response_without_success_action() {
260        let json = r#"{"pr":"lnbc1...","routes":[]}"#;
261        let resp: PayRequestCallbackResponse = serde_json::from_str(json).unwrap();
262        assert!(resp.success_action.is_none());
263    }
264
265    #[test]
266    fn test_callback_response_with_success_action() {
267        let json =
268            r#"{"pr":"lnbc1...","routes":[],"successAction":{"tag":"message","message":"Done"}}"#;
269        let resp: PayRequestCallbackResponse = serde_json::from_str(json).unwrap();
270        assert!(resp.success_action.is_some());
271    }
272
273    #[test]
274    fn test_pay_request_response_with_comment_allowed() {
275        let json = r#"{"callback":"https://example.com/cb","maxSendable":100000,"minSendable":1000,"tag":"payRequest","metadata":"[]","commentAllowed":140}"#;
276        let resp: PayRequestResponse = serde_json::from_str(json).unwrap();
277        assert_eq!(resp.comment_allowed, Some(140));
278    }
279
280    #[test]
281    fn test_pay_request_response_without_comment_allowed() {
282        let json = r#"{"callback":"https://example.com/cb","maxSendable":100000,"minSendable":1000,"tag":"payRequest","metadata":"[]"}"#;
283        let resp: PayRequestResponse = serde_json::from_str(json).unwrap();
284        assert_eq!(resp.comment_allowed, None);
285    }
286}