From 3154a3a5f514226d6c1b29d30946b28606f2d96e Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 13 Apr 2026 16:15:31 +0200 Subject: [PATCH 01/13] gl-client: Add LNURL protocol primitives (LUD-01/03/06/09/10/16) Build out gl-client's lnurl module as a complete LNURL protocol library. This lays the foundation for exposing LNURL support through gl-sdk in subsequent commits. Changes: - Make lnurl sub-modules public so gl-sdk can access the types - Add lnurl_encode() for bech32 LNURL encoding (LUD-01) - Add SuccessAction enum with Message/Url/Aes variants (LUD-09/10) - Add ProcessedSuccessAction and SuccessAction::process() for AES decryption using the payment preimage - Add LnUrlResponse enum and LNURL::resolve() for tag-based dispatch - Add comment_allowed to PayRequestResponse (LUD-12 prep) - Add success_action to PayRequestCallbackResponse - Refactor pay/withdraw to method-based API on the response types: PayRequestResponse::validate(), .description(), .get_invoice() WithdrawRequestResponse::build_callback_url() - Add extract_description_from_metadata() utility - Add get_json() to LnUrlHttpClient trait for generic resolution - Add aes/cbc dependencies for LUD-10 AES-256-CBC decryption - 29 tests (up from 12) Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 52 ++- libs/gl-client/Cargo.toml | 2 + libs/gl-client/src/lnurl/mod.rs | 112 +++--- libs/gl-client/src/lnurl/models.rs | 168 +++++++- libs/gl-client/src/lnurl/pay/mod.rs | 465 ++++++++++++----------- libs/gl-client/src/lnurl/utils.rs | 89 ++++- libs/gl-client/src/lnurl/withdraw/mod.rs | 68 ++-- 7 files changed, 614 insertions(+), 342 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f59ce895a..e93ada8bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,17 @@ dependencies = [ "opaque-debug", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures", +] + [[package]] name = "aes-gcm" version = "0.9.2" @@ -55,7 +66,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc3be92e19a7ef47457b8e6f90707e12b6ac5d20c6f3866584fa3be0787d839f" dependencies = [ "aead 0.4.3", - "aes", + "aes 0.7.5", "cipher 0.3.0", "ctr", "ghash", @@ -580,7 +591,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "block-padding", + "block-padding 0.2.1", "generic-array", ] @@ -599,6 +610,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -672,6 +692,15 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cc" version = "1.2.46" @@ -992,7 +1021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix", "windows-sys 0.61.2", ] @@ -1483,12 +1512,14 @@ dependencies = [ name = "gl-client" version = "0.4.0" dependencies = [ + "aes 0.8.4", "anyhow", "async-stream", "async-trait", "base64 0.21.7", "bech32 0.9.1", "bytes", + "cbc", "chacha20poly1305", "chrono", "cln-grpc", @@ -1586,7 +1617,7 @@ dependencies = [ "lazy_static", "linemux", "log", - "nix 0.30.1", + "nix", "prost 0.12.6", "serde", "serde_json", @@ -2095,6 +2126,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding 0.3.3", "generic-array", ] @@ -2512,18 +2544,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nix" version = "0.31.2" diff --git a/libs/gl-client/Cargo.toml b/libs/gl-client/Cargo.toml index eb397e840..9a63085f1 100644 --- a/libs/gl-client/Cargo.toml +++ b/libs/gl-client/Cargo.toml @@ -16,10 +16,12 @@ permissive = [] export = ["chacha20poly1305", "secp256k1"] [dependencies] +aes = "0.8" anyhow = "1.0.82" async-stream = "0.3.5" base64 = "^0.21" bech32 = "0.9.1" +cbc = { version = "0.1", features = ["alloc"] } bytes = "1.2.1" chrono = "0.4.31" hex = "0.4.3" diff --git a/libs/gl-client/src/lnurl/mod.rs b/libs/gl-client/src/lnurl/mod.rs index ddf506d09..5c63a4084 100644 --- a/libs/gl-client/src/lnurl/mod.rs +++ b/libs/gl-client/src/lnurl/mod.rs @@ -1,19 +1,20 @@ -mod models; -mod pay; -mod utils; -mod withdraw; - -use self::models::{ - LnUrlHttpClient, PayRequestCallbackResponse, PayRequestResponse, WithdrawRequestResponse, -}; -use self::utils::{parse_invoice, parse_lnurl}; +pub mod models; +pub mod pay; +pub mod utils; +pub mod withdraw; + +use self::models::{LnUrlHttpClient, PayRequestResponse, WithdrawRequestResponse}; +use self::utils::parse_lnurl; use crate::node::ClnClient; use crate::pb::cln::{amount_or_any, Amount, AmountOrAny}; use anyhow::{anyhow, Result}; use models::LnUrlHttpClearnetClient; -use pay::{resolve_lnurl_to_invoice, validate_invoice_from_callback_response}; -use url::Url; -use withdraw::{build_withdraw_request_callback_url, parse_withdraw_request_response_from_url}; + +/// Result of resolving an LNURL endpoint via HTTP. +pub enum LnUrlResponse { + Pay(PayRequestResponse), + Withdraw(WithdrawRequestResponse), +} pub struct LNURL { http_client: T, @@ -29,37 +30,34 @@ impl LNURL { LNURL { http_client } } - pub async fn get_pay_request_response(&self, lnurl: &str) -> Result { - let url = parse_lnurl(lnurl)?; - - let lnurl_pay_request_response: PayRequestResponse = - self.http_client.get_pay_request_response(&url).await?; - - if lnurl_pay_request_response.tag != "payRequest" { - return Err(anyhow!("Expected tag to say 'payRequest'")); + /// Resolve an LNURL to its endpoint data with a single HTTP GET. + /// + /// Decodes the bech32, fetches the URL, inspects the `tag` field, + /// and returns the appropriate typed response. + pub async fn resolve(&self, url: &str) -> Result { + let json = self.http_client.get_json(url).await?; + + let tag = json + .get("tag") + .and_then(|t| t.as_str()) + .unwrap_or(""); + + match tag { + "payRequest" => { + let response: PayRequestResponse = serde_json::from_value(json) + .map_err(|e| anyhow!("Failed to parse payRequest response: {}", e))?; + Ok(LnUrlResponse::Pay(response)) + } + "withdrawRequest" => { + let response: WithdrawRequestResponse = serde_json::from_value(json) + .map_err(|e| anyhow!("Failed to parse withdrawRequest response: {}", e))?; + Ok(LnUrlResponse::Withdraw(response)) + } + _ => Err(anyhow!( + "Unknown LNURL tag: '{}'. Expected 'payRequest' or 'withdrawRequest'.", + tag + )), } - - Ok(lnurl_pay_request_response) - } - - pub async fn get_pay_request_callback_response( - &self, - base_callback_url: &str, - amount_msats: u64, - metadata: &str, - ) -> Result { - let mut url = Url::parse(base_callback_url)?; - url.query_pairs_mut() - .append_pair("amount", &amount_msats.to_string()); - - let callback_response: PayRequestCallbackResponse = self - .http_client - .get_pay_request_callback_response(&url.to_string()) - .await?; - - let invoice = parse_invoice(&callback_response.pr)?; - validate_invoice_from_callback_response(&invoice, amount_msats, metadata)?; - Ok(callback_response) } pub async fn pay( @@ -68,24 +66,27 @@ impl LNURL { amount_msats: u64, node: &mut ClnClient, ) -> Result> { - let invoice = resolve_lnurl_to_invoice(&self.http_client, lnurl, amount_msats).await?; + let (invoice, _success_action) = + pay::resolve_lnurl_to_invoice(&self.http_client, lnurl, amount_msats, None).await?; node.pay(crate::pb::cln::PayRequest { - bolt11: invoice.to_string(), + bolt11: invoice, ..Default::default() }) .await .map_err(|e| anyhow!(e)) } - pub async fn get_withdraw_request_response( + pub async fn withdraw( &self, lnurl: &str, - ) -> Result { + amount_msats: u64, + node: &mut ClnClient, + ) -> Result<()> { let url = parse_lnurl(lnurl)?; - let withdrawal_request_response = parse_withdraw_request_response_from_url(&url); + let withdrawal_request_response = + withdraw::parse_withdraw_request_response_from_url(&url); - //If it's not a quick withdraw, then get the withdrawal_request_response from the web. let withdrawal_request_response = match withdrawal_request_response { Some(w) => w, None => { @@ -95,24 +96,13 @@ impl LNURL { } }; - Ok(withdrawal_request_response) - } - - pub async fn withdraw( - &self, - lnurl: &str, - amount_msats: u64, - node: &mut ClnClient, - ) -> Result<()> { - let withdraw_request_response = self.get_withdraw_request_response(lnurl).await?; - let amount = AmountOrAny { value: Some(amount_or_any::Value::Amount(Amount { msat: amount_msats })), }; let invoice = node .invoice(crate::pb::cln::InvoiceRequest { amount_msat: Some(amount), - description: withdraw_request_response.default_description.clone(), + description: withdrawal_request_response.default_description.clone(), ..Default::default() }) .await @@ -120,7 +110,7 @@ impl LNURL { .into_inner(); let callback_url = - build_withdraw_request_callback_url(&withdraw_request_response, invoice.bolt11)?; + withdrawal_request_response.build_callback_url(&invoice.bolt11)?; let _ = self .http_client diff --git a/libs/gl-client/src/lnurl/models.rs b/libs/gl-client/src/lnurl/models.rs index 326696e68..e02e30b7f 100644 --- a/libs/gl-client/src/lnurl/models.rs +++ b/libs/gl-client/src/lnurl/models.rs @@ -6,7 +6,7 @@ use reqwest::Response; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct PayRequestResponse { pub callback: String, #[serde(rename = "maxSendable")] @@ -15,26 +15,35 @@ pub struct PayRequestResponse { pub min_sendable: u64, pub tag: String, pub metadata: String, + /// Maximum comment length the service accepts (LUD-12). + /// None or 0 means comments are not supported. + #[serde(rename = "commentAllowed")] + #[serde(default)] + pub comment_allowed: Option, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone, Debug)] pub struct PayRequestCallbackResponse { pub pr: String, pub routes: Vec, + /// Optional success action returned by the service (LUD-09). + #[serde(rename = "successAction")] + #[serde(default)] + pub success_action: Option, } #[derive(Debug, Deserialize, Serialize)] pub struct OkResponse { - status: String, + pub status: String, } #[derive(Debug, Deserialize, Serialize)] pub struct ErrorResponse { - status: String, - reason: String, + pub status: String, + pub reason: String, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct WithdrawRequestResponse { pub tag: String, pub callback: String, @@ -47,6 +56,71 @@ pub struct WithdrawRequestResponse { pub max_withdrawable: u64, } +/// Raw success action from an LNURL-pay callback response (LUD-09/10). +/// +/// Deserialized directly from the service's JSON. For the AES variant, +/// the ciphertext has not yet been decrypted -- use +/// [`process_success_action`] with the payment preimage to produce a +/// [`ProcessedSuccessAction`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "tag")] +pub enum SuccessAction { + #[serde(rename = "message")] + Message { message: String }, + #[serde(rename = "url")] + Url { description: String, url: String }, + #[serde(rename = "aes")] + Aes { + description: String, + /// Base64-encoded ciphertext (max 4096 chars). + ciphertext: String, + /// Base64-encoded IV (24 chars = 16 bytes). + iv: String, + }, +} + +/// A success action after client-side processing. +/// +/// For the Message and Url variants this is identical to the raw +/// [`SuccessAction`]. For AES the ciphertext has been decrypted into +/// plaintext using the payment preimage. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProcessedSuccessAction { + Message { message: String }, + Url { description: String, url: String }, + Aes { description: String, plaintext: String }, +} + +impl SuccessAction { + /// Process this success action, decrypting AES content if needed. + /// + /// `preimage` is the 32-byte payment preimage from the PayResponse. + /// For Message and Url variants this is a simple conversion; for Aes + /// it decrypts the ciphertext using the preimage as the AES-256 key. + pub fn process(self, preimage: &[u8]) -> Result { + match self { + SuccessAction::Message { message } => { + Ok(ProcessedSuccessAction::Message { message }) + } + SuccessAction::Url { description, url } => { + Ok(ProcessedSuccessAction::Url { description, url }) + } + SuccessAction::Aes { + description, + ciphertext, + iv, + } => { + let plaintext = + super::pay::decrypt_aes_success_action(preimage, &ciphertext, &iv)?; + Ok(ProcessedSuccessAction::Aes { + description, + plaintext, + }) + } + } + } +} + #[async_trait] #[automock] pub trait LnUrlHttpClient { @@ -57,6 +131,7 @@ pub trait LnUrlHttpClient { ) -> Result; async fn get_withdrawal_request_response(&self, url: &str) -> Result; async fn send_invoice_for_withdraw_request(&self, url: &str) -> Result; + async fn get_json(&self, url: &str) -> Result; } pub struct LnUrlHttpClearnetClient { @@ -99,7 +174,86 @@ impl LnUrlHttpClient for LnUrlHttpClearnetClient { self.get::(url).await } - async fn send_invoice_for_withdraw_request(&self, url: &str) -> Result{ + async fn send_invoice_for_withdraw_request(&self, url: &str) -> Result { self.get::(url).await } + + async fn get_json(&self, url: &str) -> Result { + self.get::(url).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_success_action_message_serde() { + let json = r#"{"tag":"message","message":"Thank you!"}"#; + let action: SuccessAction = serde_json::from_str(json).unwrap(); + match action { + SuccessAction::Message { message } => assert_eq!(message, "Thank you!"), + _ => panic!("Expected Message variant"), + } + } + + #[test] + fn test_success_action_url_serde() { + let json = r#"{"tag":"url","description":"View order","url":"https://example.com/order/123"}"#; + let action: SuccessAction = serde_json::from_str(json).unwrap(); + match action { + SuccessAction::Url { description, url } => { + assert_eq!(description, "View order"); + assert_eq!(url, "https://example.com/order/123"); + } + _ => panic!("Expected Url variant"), + } + } + + #[test] + fn test_success_action_aes_serde() { + let json = r#"{"tag":"aes","description":"Secret","ciphertext":"YWJj","iv":"MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0"}"#; + let action: SuccessAction = serde_json::from_str(json).unwrap(); + match action { + SuccessAction::Aes { + description, + ciphertext, + iv, + } => { + assert_eq!(description, "Secret"); + assert_eq!(ciphertext, "YWJj"); + assert_eq!(iv, "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0"); + } + _ => panic!("Expected Aes variant"), + } + } + + #[test] + fn test_callback_response_without_success_action() { + let json = r#"{"pr":"lnbc1...","routes":[]}"#; + let resp: PayRequestCallbackResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success_action.is_none()); + } + + #[test] + fn test_callback_response_with_success_action() { + let json = + r#"{"pr":"lnbc1...","routes":[],"successAction":{"tag":"message","message":"Done"}}"#; + let resp: PayRequestCallbackResponse = serde_json::from_str(json).unwrap(); + assert!(resp.success_action.is_some()); + } + + #[test] + fn test_pay_request_response_with_comment_allowed() { + let json = r#"{"callback":"https://example.com/cb","maxSendable":100000,"minSendable":1000,"tag":"payRequest","metadata":"[]","commentAllowed":140}"#; + let resp: PayRequestResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.comment_allowed, Some(140)); + } + + #[test] + fn test_pay_request_response_without_comment_allowed() { + let json = r#"{"callback":"https://example.com/cb","maxSendable":100000,"minSendable":1000,"tag":"payRequest","metadata":"[]"}"#; + let resp: PayRequestResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.comment_allowed, None); + } } diff --git a/libs/gl-client/src/lnurl/pay/mod.rs b/libs/gl-client/src/lnurl/pay/mod.rs index 9ac4a9cfa..b83f8fa5d 100644 --- a/libs/gl-client/src/lnurl/pay/mod.rs +++ b/libs/gl-client/src/lnurl/pay/mod.rs @@ -1,4 +1,4 @@ -use super::models; +use super::models::SuccessAction; use super::utils::parse_lnurl; use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; @@ -12,99 +12,111 @@ use log::debug; use reqwest::Url; use sha256; -pub async fn resolve_lnurl_to_invoice( - http_client: &T, - lnurl_identifier: &str, - amount_msats: u64, -) -> Result { - let url = match is_lnurl(lnurl_identifier) { - true => parse_lnurl(lnurl_identifier)?, - false => parse_lightning_address(lnurl_identifier)?, - }; - - debug!("Domain: {}", Url::parse(&url).unwrap().host().unwrap()); - - let lnurl_pay_request_response: PayRequestResponse = - http_client.get_pay_request_response(&url).await?; - - validate_pay_request_response(lnurl_identifier, &lnurl_pay_request_response, amount_msats)?; - - let callback_url = build_callback_url(&lnurl_pay_request_response, amount_msats)?; - let callback_response: PayRequestCallbackResponse = http_client - .get_pay_request_callback_response(&callback_url) - .await?; - - let invoice = parse_invoice(&callback_response.pr)?; - validate_invoice_from_callback_response( - &invoice, - amount_msats, - &lnurl_pay_request_response.metadata, - )?; - Ok(invoice.to_string()) -} - -fn is_lnurl(lnurl_identifier: &str) -> bool { - const LNURL_PREFIX: &str = "LNURL"; - lnurl_identifier - .trim() - .to_uppercase() - .starts_with(LNURL_PREFIX) -} - -pub fn validate_pay_request_response( - lnurl_identifier: &str, - lnurl_pay_request_response: &PayRequestResponse, - amount_msats: u64, -) -> Result<()> { - if lnurl_pay_request_response.tag != "payRequest" { - return Err(anyhow!("Expected tag to say 'payRequest'")); +impl PayRequestResponse { + /// Extract the "text/plain" description from the metadata JSON. + pub fn description(&self) -> Option { + super::utils::extract_description_from_metadata(&self.metadata) } - ensure_amount_is_within_range(&lnurl_pay_request_response, amount_msats)?; - let description = extract_description(&lnurl_pay_request_response)?; - - debug!("Description: {}", description); - debug!( - "Accepted range (in millisatoshis): {} - {}", - lnurl_pay_request_response.min_sendable, lnurl_pay_request_response.max_sendable - ); - if !is_lnurl(lnurl_identifier) { - let deserialized_metadata: Vec> = - serde_json::from_str(&lnurl_pay_request_response.metadata.to_owned()) - .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?; + /// Validate this pay request response for a given amount. + /// + /// Checks the tag, amount range, and — for lightning addresses — + /// that the metadata contains a matching identifier. + pub fn validate(&self, identifier: &str, amount_msats: u64) -> Result<()> { + if self.tag != "payRequest" { + return Err(anyhow!("Expected tag to say 'payRequest'")); + } - let mut identifier = String::new(); + if amount_msats < self.min_sendable { + return Err(anyhow!( + "Amount must be {} or greater", + self.min_sendable + )); + } + if amount_msats > self.max_sendable { + return Err(anyhow!( + "Amount must be {} or less", + self.max_sendable + )); + } - let metadata_entry_types = ["text/email", "text/identifier"]; + debug!( + "Accepted range (in millisatoshis): {} - {}", + self.min_sendable, self.max_sendable + ); - for metadata in deserialized_metadata { - let x = &*metadata[0].clone(); - if metadata_entry_types.contains(&x) { - identifier = String::from(metadata[1].clone()); - break; + // For lightning addresses, verify the identifier appears in metadata + if !is_lnurl(identifier) { + let entries: Vec> = + serde_json::from_str(&self.metadata) + .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?; + + let found = entries.iter().any(|entry| { + entry.len() >= 2 + && (entry[0] == "text/email" || entry[0] == "text/identifier") + && entry[1] == identifier + }); + + if !found { + return Err(anyhow!( + "The lightning address specified in the original request \ + does not match what was found in the metadata array" + )); } } - if identifier.is_empty() { - return Err(anyhow!("Could not find an entry of type ")); - } + Ok(()) + } - if identifier != lnurl_identifier { - return Err(anyhow!("The lightning address specified in the original request does not match what was found in the metadata array")); - } + /// Fetch an invoice from this pay request's callback endpoint. + /// + /// Builds the callback URL with the given amount and optional comment, + /// fetches the invoice, validates it against the metadata, and returns + /// the invoice string along with any success action. + pub async fn get_invoice( + &self, + http_client: &T, + amount_msats: u64, + comment: Option<&str>, + ) -> Result<(String, Option)> { + let callback_url = self.build_callback_url(amount_msats, comment)?; + let callback_response: PayRequestCallbackResponse = http_client + .get_pay_request_callback_response(&callback_url) + .await?; + + let invoice = parse_invoice(&callback_response.pr)?; + validate_invoice(&invoice, amount_msats, &self.metadata)?; + Ok((invoice.to_string(), callback_response.success_action)) } - Ok(()) + /// Build the callback URL with amount and optional comment. + fn build_callback_url( + &self, + amount: u64, + comment: Option<&str>, + ) -> Result { + let mut url = Url::parse(&self.callback)?; + url.query_pairs_mut() + .append_pair("amount", &amount.to_string()); + if let Some(c) = comment { + url.query_pairs_mut().append_pair("comment", c); + } + Ok(url.to_string()) + } } -// Validates the invoice on the pay request's callback response -pub fn validate_invoice_from_callback_response( +/// Validate a BOLT11 invoice against the expected amount and metadata. +fn validate_invoice( invoice: &Bolt11Invoice, amount_msats: u64, metadata: &str, ) -> Result<()> { - ensure!(invoice.amount_milli_satoshis().unwrap_or_default() == amount_msats , - "Amount found in invoice was not equal to the amount found in the original request\nRequest amount: {}\nInvoice amount:{:?}", amount_msats, invoice.amount_milli_satoshis().unwrap() + ensure!( + invoice.amount_milli_satoshis().unwrap_or_default() == amount_msats, + "Amount found in invoice was not equal to the amount found in the original request\n\ + Request amount: {}\nInvoice amount: {:?}", + amount_msats, + invoice.amount_milli_satoshis() ); let description_hash: String = match invoice.description() { @@ -122,90 +134,106 @@ pub fn validate_invoice_from_callback_response( Ok(()) } -// Function to extract the description from the lnurl pay request response -fn extract_description(lnurl_pay_request_response: &PayRequestResponse) -> Result { - let mut description = String::new(); - - let serialized_metadata = lnurl_pay_request_response.metadata.clone(); +/// Decrypt an AES-256-CBC encrypted success action payload (LUD-10). +/// +/// - `preimage`: 32-byte payment preimage (used as the AES key) +/// - `ciphertext_b64`: base64-encoded ciphertext +/// - `iv_b64`: base64-encoded IV (decodes to 16 bytes) +pub fn decrypt_aes_success_action( + preimage: &[u8], + ciphertext_b64: &str, + iv_b64: &str, +) -> Result { + use aes::Aes256; + use base64::{engine::general_purpose::STANDARD, Engine}; + use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; + + let ciphertext = STANDARD + .decode(ciphertext_b64) + .map_err(|e| anyhow!("Invalid base64 ciphertext: {}", e))?; + let iv = STANDARD + .decode(iv_b64) + .map_err(|e| anyhow!("Invalid base64 IV: {}", e))?; + + if preimage.len() != 32 { + return Err(anyhow!( + "Payment preimage must be 32 bytes, got {}", + preimage.len() + )); + } + if iv.len() != 16 { + return Err(anyhow!("IV must be 16 bytes, got {}", iv.len())); + } - let deserialized_metadata: Vec> = - serde_json::from_str(&serialized_metadata.to_owned()) - .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?; + type Aes256CbcDec = cbc::Decryptor; + let decryptor = Aes256CbcDec::new_from_slices(preimage, &iv) + .map_err(|e| anyhow!("AES init failed: {}", e))?; - for metadata in deserialized_metadata { - if metadata[0] == "text/plain" { - description = metadata[1].clone(); - } - } + let plaintext_bytes = decryptor + .decrypt_padded_vec_mut::(&ciphertext) + .map_err(|e| anyhow!("AES decryption failed: {}", e))?; - Ok(description) + String::from_utf8(plaintext_bytes) + .map_err(|e| anyhow!("Decrypted data is not valid UTF-8: {}", e)) } -// Function to build the callback URL based on lnurl pay request response and amount -fn build_callback_url( - lnurl_pay_request_response: &models::PayRequestResponse, - amount: u64, -) -> Result { - let mut url = Url::parse(&lnurl_pay_request_response.callback)?; - url.query_pairs_mut() - .append_pair("amount", &amount.to_string()); - Ok(url.to_string()) +fn is_lnurl(lnurl_identifier: &str) -> bool { + const LNURL_PREFIX: &str = "LNURL"; + lnurl_identifier + .trim() + .to_uppercase() + .starts_with(LNURL_PREFIX) } -// Validates the pay request response for expected values -fn ensure_amount_is_within_range( - lnurl_pay_request_response: &PayRequestResponse, - amount: u64, -) -> Result<()> { - if amount < lnurl_pay_request_response.min_sendable { - return Err(anyhow!( - "Amount must be {} or greater", - lnurl_pay_request_response.min_sendable - )); - } +/// Resolve an LNURL or lightning address to an invoice in one shot. +/// +/// Convenience function that combines resolution + validation + invoice +/// fetching. For a two-phase flow, use `PayRequestResponse::get_invoice()` +/// directly after resolving. +pub async fn resolve_lnurl_to_invoice( + http_client: &T, + lnurl_identifier: &str, + amount_msats: u64, + comment: Option<&str>, +) -> Result<(String, Option)> { + let url = match is_lnurl(lnurl_identifier) { + true => parse_lnurl(lnurl_identifier)?, + false => parse_lightning_address(lnurl_identifier)?, + }; - if amount > lnurl_pay_request_response.max_sendable { - return Err(anyhow!( - "Amount must be {} or less", - lnurl_pay_request_response.max_sendable - )); - } + debug!("Domain: {}", Url::parse(&url).unwrap().host().unwrap()); - Ok(()) + let pay_request: PayRequestResponse = + http_client.get_pay_request_response(&url).await?; + + pay_request.validate(lnurl_identifier, amount_msats)?; + pay_request.get_invoice(http_client, amount_msats, comment).await } -//LUD-16: Paying to static internet identifiers. +/// Parse a lightning address into its well-known LNURL-pay URL (LUD-16). pub fn parse_lightning_address(lightning_address: &str) -> Result { - let lightning_address_components: Vec<&str> = lightning_address.split("@").collect(); - - if lightning_address_components.len() != 2 { - return Err(anyhow!("The provided lightning address is improperly formatted")); - } + let parts: Vec<&str> = lightning_address.split('@').collect(); - let username = match lightning_address_components.get(0) { - None => return Err(anyhow!("Could not parse username in lightning address")), - Some(u) => { - if u.is_empty() { - return Err(anyhow!("Username can not be empty")) - } - - u - } - }; + if parts.len() != 2 { + return Err(anyhow!( + "The provided lightning address is improperly formatted" + )); + } - let domain = match lightning_address_components.get(1) { - None => return Err(anyhow!("Could not parse domain in lightning address")), - Some(d) => { - if d.is_empty() { - return Err(anyhow!("Domain can not be empty")) - } + let username = parts[0]; + let domain = parts[1]; - d - } - }; + if username.is_empty() { + return Err(anyhow!("Username can not be empty")); + } + if domain.is_empty() { + return Err(anyhow!("Domain can not be empty")); + } - let pay_request_url = ["https://", domain, "/.well-known/lnurlp/", username].concat(); - return Ok(pay_request_url); + Ok(format!( + "https://{}/.well-known/lnurlp/{}", + domain, username + )) } #[cfg(test)] @@ -246,7 +274,7 @@ mod tests { mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { let invoice = "lnbc1u1pjv9qrvsp5e5wwexctzp9yklcrzx448c68q2a7kma55cm67ruajjwfkrswnqvqpp55x6mmz8ch6nahrcuxjsjvs23xkgt8eu748nukq463zhjcjk4s65shp5dd6hc533r655wtyz63jpf6ja08srn6rz6cjhwsjuyckrqwanhjtsxqzjccqpjrzjqw6lfdpjecp4d5t0gxk5khkrzfejjxyxtxg5exqsd95py6rhwwh72rpgrgqq3hcqqgqqqqlgqqqqqqgq9q9qxpqysgq95njz4sz6h7r2qh7txnevcrvg0jdsfpe72cecmjfka8mw5nvm7tydd0j34ps2u9q9h6v5u8h3vxs8jqq5fwehdda6a8qmpn93fm290cquhuc6r"; - let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice).to_string(); + let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice); let x = serde_json::from_str(&callback_response_json).unwrap(); convert_to_async_return_value(Ok(x)) }); @@ -254,8 +282,8 @@ mod tests { let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; let amount = 100000; - let invoice = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; - assert!(invoice.is_ok()); + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount, None).await; + assert!(result.is_ok()); } #[tokio::test] @@ -281,99 +309,56 @@ mod tests { mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { let invoice = "lnbcrt1u1pj0ypx6sp5hzczugdw9eyw3fcsjkssux7awjlt68vpj7uhmen7sup0hdlrqxaqpp5gp5fm2sn5rua2jlzftkf5h22rxppwgszs7ncm73pmwhvjcttqp3qdy2tddjyar90p6z7urvv95kug3vyq39xarpwf6zqargv5syxmmfde28yctfdc396tpqtv38getcwshkjer9de6xjenfv4ezytpqyfekzar0wd5xjsrrd9cxsetjwp6ku6ewvdhk6gjat5xqyjw5qcqp29qxpqysgqujuf5zavazln2q9gks7nqwdgjypg2qlvv7aqwfmwg7xmjt8hy4hx2ctr5fcspjvmz9x5wvmur8vh6nkynsvateafm73zwg5hkf7xszsqajqwcf"; - let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice).to_string(); + let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice); let x = serde_json::from_str(&callback_response_json).unwrap(); convert_to_async_return_value(Ok(x)) }); let amount = 100000; - let invoice = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; - assert!(invoice.is_ok()); + let result = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount, None).await; + assert!(result.is_ok()); } - #[tokio::test] async fn test_lnurl_pay_with_lightning_address_fails_with_empty_username() { let mock_http_client = MockLnUrlHttpClient::new(); - let lightning_address_username = ""; - let lightning_address_domain = "cipherpunk.com"; - let lnurl = format!( - "{}@{}", - lightning_address_username, lightning_address_domain - ); - - let amount = 100000; - - let error = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; - assert!(error.is_err()); - assert!(error.unwrap_err().to_string().contains("Username can not be empty")); + let lnurl = "@cipherpunk.com"; + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Username can not be empty")); } #[tokio::test] async fn test_lnurl_pay_with_lightning_address_fails_with_empty_domain() { let mock_http_client = MockLnUrlHttpClient::new(); - let lightning_address_username = "satoshi"; - let lightning_address_domain = ""; - let lnurl = format!( - "{}@{}", - lightning_address_username, lightning_address_domain - ); - - let amount = 100000; - - let error = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount).await; - assert!(error.is_err()); - assert!(error.unwrap_err().to_string().contains("Domain can not be empty")); + let lnurl = "satoshi@"; + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Domain can not be empty")); } #[tokio::test] async fn test_lnurl_pay_returns_error_on_invalid_lnurl() { let mock_http_client = MockLnUrlHttpClient::new(); - let lnurl = "LNURL1111111111111111111111111111111111111111111111111111111111111111111"; - let amount = 100000; - let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount).await; - - match result { - Err(err) => { - assert!(err - .to_string() - .contains("Failed to decode lnurl: invalid length")); - } - _ => panic!("Expected an error, but got Ok"), - } + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await; + assert!(result.unwrap_err().to_string().contains("Failed to decode lnurl: invalid length")); } #[tokio::test] async fn test_lnurl_pay_returns_error_on_amount_less_than_min_sendable() { let mut mock_http_client = MockLnUrlHttpClient::new(); - // Set up expectations for the first two calls mock_http_client.expect_get_pay_request_response().returning(|_url| { let x: PayRequestResponse = serde_json::from_str("{ \"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100000, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"]]\" }").unwrap(); convert_to_async_return_value(Ok(x)) }); - mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { - let invoice = "lnbc1u1pjv9qrvsp5e5wwexctzp9yklcrzx448c68q2a7kma55cm67ruajjwfkrswnqvqpp55x6mmz8ch6nahrcuxjsjvs23xkgt8eu748nukq463zhjcjk4s65shp5dd6hc533r655wtyz63jpf6ja08srn6rz6cjhwsjuyckrqwanhjtsxqzjccqpjrzjqw6lfdpjecp4d5t0gxk5khkrzfejjxyxtxg5exqsd95py6rhwwh72rpgrgqq3hcqqgqqqqlgqqqqqqgq9q9qxpqysgq95njz4sz6h7r2qh7txnevcrvg0jdsfpe72cecmjfka8mw5nvm7tydd0j34ps2u9q9h6v5u8h3vxs8jqq5fwehdda6a8qmpn93fm290cquhuc6r"; - let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice).to_string(); - let callback_response = serde_json::from_str(&callback_response_json).unwrap(); - convert_to_async_return_value(Ok(callback_response)) - }); - let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - let amount = 1; - - let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount).await; - - match result { - Err(err) => { - assert!(err.to_string().contains("Amount must be")); - } - _ => panic!("Expected an error, but got Ok"), - } + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 1, None).await; + assert!(result.unwrap_err().to_string().contains("Amount must be")); } #[tokio::test] @@ -385,23 +370,61 @@ mod tests { convert_to_async_return_value(Ok(x)) }); - mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { - let invoice = "lnbc1u1pjv9qrvsp5e5wwexctzp9yklcrzx448c68q2a7kma55cm67ruajjwfkrswnqvqpp55x6mmz8ch6nahrcuxjsjvs23xkgt8eu748nukq463zhjcjk4s65shp5dd6hc533r655wtyz63jpf6ja08srn6rz6cjhwsjuyckrqwanhjtsxqzjccqpjrzjqw6lfdpjecp4d5t0gxk5khkrzfejjxyxtxg5exqsd95py6rhwwh72rpgrgqq3hcqqgqqqqlgqqqqqqgq9q9qxpqysgq95njz4sz6h7r2qh7txnevcrvg0jdsfpe72cecmjfka8mw5nvm7tydd0j34ps2u9q9h6v5u8h3vxs8jqq5fwehdda6a8qmpn93fm290cquhuc6r"; - let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice).to_string(); - let value = serde_json::from_str(&callback_response_json).unwrap(); - convert_to_async_return_value(Ok(value)) - }); - let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - let amount = 1; + let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 200000, None).await; + assert!(result.unwrap_err().to_string().contains("Amount must be")); + } - let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount).await; + #[test] + fn test_aes_decrypt_known_vector() { + use aes::Aes256; + use base64::{engine::general_purpose::STANDARD, Engine}; + use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; + + let key = [0x42u8; 32]; + let iv = [0x24u8; 16]; + let plaintext = b"hello world"; + + // Encrypt + type Aes256CbcEnc = cbc::Encryptor; + let ciphertext = Aes256CbcEnc::new_from_slices(&key, &iv) + .unwrap() + .encrypt_padded_vec_mut::(plaintext); + + let ciphertext_b64 = STANDARD.encode(&ciphertext); + let iv_b64 = STANDARD.encode(&iv); + + // Decrypt + let result = decrypt_aes_success_action(&key, &ciphertext_b64, &iv_b64).unwrap(); + assert_eq!(result, "hello world"); + } - match result { - Err(err) => { - assert!(err.to_string().contains("Amount must be")); - } - _ => panic!("Expected an error, amount specified is greater than maxSendable"), - } + #[test] + fn test_aes_decrypt_wrong_preimage_length() { + let result = decrypt_aes_success_action(&[0u8; 16], "YWJj", "MTIzNDU2Nzg5MDEyMzQ1Ng=="); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("32 bytes")); + } + + #[test] + fn test_pay_request_description() { + let resp: PayRequestResponse = serde_json::from_str( + r#"{"callback":"https://x.com/cb","maxSendable":1000,"minSendable":1,"tag":"payRequest","metadata":"[[\"text/plain\",\"Buy coffee\"]]"}"# + ).unwrap(); + assert_eq!(resp.description(), Some("Buy coffee".to_string())); + } + + #[test] + fn test_pay_request_validate_amount_range() { + let resp: PayRequestResponse = serde_json::from_str( + r#"{"callback":"https://x.com/cb","maxSendable":10000,"minSendable":1000,"tag":"payRequest","metadata":"[[\"text/plain\",\"test\"]]"}"# + ).unwrap(); + + // In range + assert!(resp.validate("LNURL1TEST", 5000).is_ok()); + // Below min + assert!(resp.validate("LNURL1TEST", 500).is_err()); + // Above max + assert!(resp.validate("LNURL1TEST", 20000).is_err()); } } diff --git a/libs/gl-client/src/lnurl/utils.rs b/libs/gl-client/src/lnurl/utils.rs index b8b73818c..744b6949e 100644 --- a/libs/gl-client/src/lnurl/utils.rs +++ b/libs/gl-client/src/lnurl/utils.rs @@ -1,11 +1,11 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; -use bech32::FromBase32; +use bech32::{FromBase32, ToBase32}; use crate::lightning_invoice::Bolt11Invoice; -// Function to decode and parse the lnurl into a URL +/// Decode an LNURL bech32 string into the underlying URL (LUD-01). pub fn parse_lnurl(lnurl: &str) -> Result { let (_hrp, data, _variant) = bech32::decode(lnurl).map_err(|e| anyhow!("Failed to decode lnurl: {}", e))?; @@ -17,7 +17,88 @@ pub fn parse_lnurl(lnurl: &str) -> Result { Ok(url) } -// Get an Invoice from a Lightning Network URL pay request +/// Encode a URL as an LNURL bech32 string (LUD-01). +/// +/// Returns uppercase by convention (for QR code compatibility). +pub fn lnurl_encode(url: &str) -> Result { + let data = url.as_bytes().to_base32(); + bech32::encode("lnurl", data, bech32::Variant::Bech32) + .map(|s| s.to_uppercase()) + .map_err(|e| anyhow!("Failed to encode lnurl: {}", e)) +} + +/// Extract the "text/plain" description from LNURL metadata JSON. +/// +/// Metadata is a JSON array of `["mime", "content"]` pairs. +/// Returns the content of the first "text/plain" entry, or None. +pub fn extract_description_from_metadata(metadata: &str) -> Option { + let entries: Vec> = serde_json::from_str(metadata).ok()?; + for entry in entries { + if entry.len() >= 2 && entry[0] == "text/plain" { + return Some(entry[1].clone()); + } + } + None +} + +/// Parse a BOLT11 invoice string. pub fn parse_invoice(invoice_str: &str) -> Result { - Bolt11Invoice::from_str(&invoice_str).map_err(|e| anyhow!(format!("Failed to parse invoice: {}", e))) + Bolt11Invoice::from_str(invoice_str) + .map_err(|e| anyhow!(format!("Failed to parse invoice: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lnurl_encode_decode_roundtrip() { + let url = "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b7e86a0850"; + let encoded = lnurl_encode(url).unwrap(); + assert!(encoded.starts_with("LNURL1")); + let decoded = parse_lnurl(&encoded).unwrap(); + assert_eq!(decoded, url); + } + + #[test] + fn test_lnurl_decode_is_case_insensitive() { + let url = "https://example.com/lnurl"; + let encoded = lnurl_encode(url).unwrap(); + // Uppercase (default) should work + let decoded = parse_lnurl(&encoded).unwrap(); + assert_eq!(decoded, url); + // Lowercase should also work + let decoded = parse_lnurl(&encoded.to_lowercase()).unwrap(); + assert_eq!(decoded, url); + } + + #[test] + fn test_extract_description_from_metadata() { + let metadata = r#"[["text/plain", "Pay to example"]]"#; + assert_eq!( + extract_description_from_metadata(metadata), + Some("Pay to example".to_string()) + ); + } + + #[test] + fn test_extract_description_from_metadata_with_multiple_entries() { + let metadata = + r#"[["text/identifier", "user@example.com"], ["text/plain", "Pay user"]]"#; + assert_eq!( + extract_description_from_metadata(metadata), + Some("Pay user".to_string()) + ); + } + + #[test] + fn test_extract_description_from_metadata_missing() { + let metadata = r#"[["text/identifier", "user@example.com"]]"#; + assert_eq!(extract_description_from_metadata(metadata), None); + } + + #[test] + fn test_extract_description_from_metadata_invalid_json() { + assert_eq!(extract_description_from_metadata("not json"), None); + } } diff --git a/libs/gl-client/src/lnurl/withdraw/mod.rs b/libs/gl-client/src/lnurl/withdraw/mod.rs index 5f1e85747..c3725a055 100644 --- a/libs/gl-client/src/lnurl/withdraw/mod.rs +++ b/libs/gl-client/src/lnurl/withdraw/mod.rs @@ -1,19 +1,20 @@ use super::models::WithdrawRequestResponse; -use anyhow::{anyhow, Result}; +use anyhow::Result; use log::debug; use reqwest::Url; use serde_json::{to_value, Map, Value}; -pub fn build_withdraw_request_callback_url( - lnurl_pay_request_response: &WithdrawRequestResponse, - invoice: String, -) -> Result { - let mut url = Url::parse(&lnurl_pay_request_response.callback)?; - url.query_pairs_mut() - .append_pair("k1", &lnurl_pay_request_response.k1) - .append_pair("pr", &invoice); - - Ok(url.to_string()) +impl WithdrawRequestResponse { + /// Build the callback URL for submitting an invoice to the service. + /// + /// Appends `k1` and `pr` (the BOLT11 invoice) as query parameters. + pub fn build_callback_url(&self, invoice: &str) -> Result { + let mut url = Url::parse(&self.callback)?; + url.query_pairs_mut() + .append_pair("k1", &self.k1) + .append_pair("pr", invoice); + Ok(url.to_string()) + } } fn convert_value_field_from_str_to_u64( @@ -24,17 +25,18 @@ fn convert_value_field_from_str_to_u64( Some(field_value) => match field_value.as_str() { Some(field_value_str) => { let converted_field_value = field_value_str.parse::()?; - - //overwrites old type value value.insert( String::from(field_name), to_value(converted_field_value).unwrap(), ); - return Ok(()); + Ok(()) } - None => return Err(anyhow!("Failed to convert {} into a str", field_name)), + None => Err(anyhow::anyhow!( + "Failed to convert {} into a str", + field_name + )), }, - None => return Err(anyhow!("Failed to find {} in map", field_name)), + None => Err(anyhow::anyhow!("Failed to find {} in map", field_name)), } } @@ -56,7 +58,7 @@ pub fn parse_withdraw_request_response_from_url(url: &str) -> Option { return w; - }, + } Err(e) => { debug!("{:?}", e); return None; @@ -73,25 +75,25 @@ mod test { #[test] fn test_build_withdraw_request_callback_url() -> Result<()> { + let resp = WithdrawRequestResponse { + tag: String::from("withdraw"), + callback: String::from("https://cipherpunk.com/"), + k1: String::from("unique"), + default_description: String::from(""), + min_withdrawable: 2, + max_withdrawable: 300, + }; - let k1 = String::from("unique"); - let invoice = String::from("invoice"); - - let built_withdraw_request_callback_url = build_withdraw_request_callback_url(&WithdrawRequestResponse { - tag: String::from("withdraw"), - callback: String::from("https://cipherpunk.com/"), - k1: k1.clone(), - default_description: String::from(""), - min_withdrawable: 2, - max_withdrawable: 300, - }, invoice.clone()); - - let url = Url::parse(&built_withdraw_request_callback_url.unwrap())?; + let url_str = resp.build_callback_url("invoice")?; + let url = Url::parse(&url_str)?; let query_pairs = url.query_pairs().collect::(); let query_params: &Map = query_pairs.as_object().unwrap(); - - assert_eq!(query_params.get("k1").unwrap().as_str().unwrap(), k1); - assert_eq!(query_params.get("pr").unwrap().as_str().unwrap(), invoice); + + assert_eq!(query_params.get("k1").unwrap().as_str().unwrap(), "unique"); + assert_eq!( + query_params.get("pr").unwrap().as_str().unwrap(), + "invoice" + ); Ok(()) } From e047a88f3ddfba2579c97ed109c077e8ac78d7fd Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 13 Apr 2026 16:48:24 +0200 Subject: [PATCH 02/13] gl-sdk: Add UniFFI LNURL type wrappers (Step 3) Thin binding layer that wraps gl-client's LNURL protocol types with UniFFI annotations for cross-language export. No protocol logic here, only type definitions and From conversions. New types: ResolvedLnUrl, LnUrlPayRequestData, LnUrlPayRequest, LnUrlPayResult, LnUrlPaySuccessData, LnUrlWithdrawRequestData, LnUrlWithdrawRequest, LnUrlWithdrawResult, LnUrlWithdrawSuccessData, LnUrlErrorData, SuccessActionProcessed. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-sdk/src/lib.rs | 6 + libs/gl-sdk/src/lnurl.rs | 315 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 libs/gl-sdk/src/lnurl.rs diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index 16276e84f..e329a1c35 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -27,6 +27,7 @@ pub enum Error { mod config; mod credentials; mod input; +mod lnurl; mod node; mod scheduler; mod signer; @@ -44,6 +45,11 @@ pub use crate::{ Peer, PeerChannel, ReceiveResponse, SendResponse, }, input::{InputType, ParsedInvoice}, + lnurl::{ + LnUrlErrorData, LnUrlPayRequest, LnUrlPayRequestData, LnUrlPayResult, + LnUrlPaySuccessData, LnUrlWithdrawRequest, LnUrlWithdrawRequestData, + LnUrlWithdrawResult, LnUrlWithdrawSuccessData, ResolvedLnUrl, SuccessActionProcessed, + }, scheduler::Scheduler, signer::{Handle, Signer}, }; diff --git a/libs/gl-sdk/src/lnurl.rs b/libs/gl-sdk/src/lnurl.rs new file mode 100644 index 000000000..1bacfebf2 --- /dev/null +++ b/libs/gl-sdk/src/lnurl.rs @@ -0,0 +1,315 @@ +// LNURL types for UniFFI language bindings. +// +// These are thin wrappers around gl-client's protocol types, adding +// UniFFI annotations so they can be exported to Python, Kotlin, Swift, +// and Ruby. Protocol logic lives in gl-client; this module only does +// type conversion. + +use gl_client::lnurl::models as wire; + +// ── Resolved endpoint data ────────────────────────────────────────── + +/// Result of resolving an LNURL or lightning address via HTTP. +#[derive(Clone, uniffi::Enum)] +pub enum ResolvedLnUrl { + /// The endpoint is an LNURL-pay service (LUD-06). + Pay { data: LnUrlPayRequestData }, + /// The endpoint is an LNURL-withdraw service (LUD-03). + Withdraw { data: LnUrlWithdrawRequestData }, +} + +/// Data from an LNURL-pay endpoint (LUD-06). +/// +/// Contains the service's accepted amount range and metadata. +/// Returned inside `ResolvedLnUrl::Pay` after resolving an LNURL. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlPayRequestData { + /// The callback URL to request an invoice from. + pub callback: String, + /// Minimum amount the service accepts, in millisatoshis. + pub min_sendable: u64, + /// Maximum amount the service accepts, in millisatoshis. + pub max_sendable: u64, + /// Raw metadata JSON string (array of `["mime", "content"]` pairs). + pub metadata: String, + /// Maximum comment length the service accepts. 0 means no comments. + pub comment_allowed: u64, + /// Human-readable description extracted from metadata. + pub description: String, + /// The original LNURL or lightning address that was resolved. + pub lnurl: String, +} + +/// Data from an LNURL-withdraw endpoint (LUD-03). +/// +/// Contains the service's accepted withdrawal range and session key. +/// Returned inside `ResolvedLnUrl::Withdraw` after resolving an LNURL. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlWithdrawRequestData { + /// The callback URL to submit the invoice to. + pub callback: String, + /// Ephemeral secret linking this wallet session to the service. + pub k1: String, + /// Default description for the invoice. + pub default_description: String, + /// Minimum withdrawable amount in millisatoshis. + pub min_withdrawable: u64, + /// Maximum withdrawable amount in millisatoshis. + pub max_withdrawable: u64, + /// The original LNURL that was resolved. + pub lnurl: String, +} + +// ── User request types ────────────────────────────────────────────── + +/// Request to execute an LNURL-pay flow. +/// +/// Combines the resolved service data with the user's chosen amount. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlPayRequest { + /// The resolved pay request data from `resolve_lnurl()`. + pub data: LnUrlPayRequestData, + /// Amount to pay in millisatoshis. + pub amount_msat: u64, + /// Optional comment to send with the payment. + pub comment: Option, +} + +/// Request to execute an LNURL-withdraw flow. +/// +/// Combines the resolved service data with the user's chosen amount. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlWithdrawRequest { + /// The resolved withdraw request data from `resolve_lnurl()`. + pub data: LnUrlWithdrawRequestData, + /// Amount to withdraw in millisatoshis. + pub amount_msat: u64, + /// Optional description for the invoice (overrides default). + pub description: Option, +} + +// ── Result types ──────────────────────────────────────────────────── + +/// Result of an LNURL-pay operation. +#[derive(Clone, uniffi::Enum)] +pub enum LnUrlPayResult { + /// Payment succeeded. + EndpointSuccess { data: LnUrlPaySuccessData }, + /// The LNURL service returned an error. + EndpointError { data: LnUrlErrorData }, +} + +/// Successful LNURL-pay result data. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlPaySuccessData { + /// The payment preimage (proof of payment), hex-encoded. + pub payment_preimage: String, + /// Optional success action from the service (LUD-09). + pub success_action: Option, +} + +/// Result of an LNURL-withdraw operation. +#[derive(Clone, uniffi::Enum)] +pub enum LnUrlWithdrawResult { + /// The service accepted our invoice and will pay it. + Ok { data: LnUrlWithdrawSuccessData }, + /// The LNURL service returned an error. + ErrorStatus { data: LnUrlErrorData }, +} + +/// Successful LNURL-withdraw result data. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlWithdrawSuccessData { + /// The BOLT11 invoice that was submitted for withdrawal. + pub invoice: String, +} + +/// Error returned by an LNURL service endpoint. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlErrorData { + pub reason: String, +} + +// ── Success action types (LUD-09 / LUD-10) ───────────────────────── + +/// A processed success action from an LNURL-pay callback. +/// +/// For Message and Url this is passed through as-is. For Aes the +/// ciphertext has been decrypted using the payment preimage. +#[derive(Clone, uniffi::Enum)] +pub enum SuccessActionProcessed { + /// Display a message to the user. + Message { message: String }, + /// Display a URL to the user. + Url { description: String, url: String }, + /// Decrypted AES payload (LUD-10). + Aes { description: String, plaintext: String }, +} + +// ── From conversions (gl-client → gl-sdk) ─────────────────────────── + +impl From for LnUrlPayRequestData { + fn from(r: wire::PayRequestResponse) -> Self { + Self { + description: r.description().unwrap_or_default(), + callback: r.callback, + min_sendable: r.min_sendable, + max_sendable: r.max_sendable, + metadata: r.metadata, + comment_allowed: r.comment_allowed.unwrap_or(0), + lnurl: String::new(), // caller sets this after conversion + } + } +} + +impl From for LnUrlWithdrawRequestData { + fn from(r: wire::WithdrawRequestResponse) -> Self { + Self { + callback: r.callback, + k1: r.k1, + default_description: r.default_description, + min_withdrawable: r.min_withdrawable, + max_withdrawable: r.max_withdrawable, + lnurl: String::new(), // caller sets this after conversion + } + } +} + +impl From for SuccessActionProcessed { + fn from(a: wire::ProcessedSuccessAction) -> Self { + match a { + wire::ProcessedSuccessAction::Message { message } => { + SuccessActionProcessed::Message { message } + } + wire::ProcessedSuccessAction::Url { description, url } => { + SuccessActionProcessed::Url { description, url } + } + wire::ProcessedSuccessAction::Aes { + description, + plaintext, + } => SuccessActionProcessed::Aes { + description, + plaintext, + }, + } + } +} + +impl From for ResolvedLnUrl { + fn from(r: gl_client::lnurl::LnUrlResponse) -> Self { + match r { + gl_client::lnurl::LnUrlResponse::Pay(data) => ResolvedLnUrl::Pay { + data: data.into(), + }, + gl_client::lnurl::LnUrlResponse::Withdraw(data) => ResolvedLnUrl::Withdraw { + data: data.into(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pay_request_data_from_conversion() { + let wire_resp = wire::PayRequestResponse { + callback: "https://example.com/cb".to_string(), + max_sendable: 100000, + min_sendable: 1000, + tag: "payRequest".to_string(), + metadata: r#"[["text/plain", "Buy coffee"]]"#.to_string(), + comment_allowed: Some(140), + }; + + let data: LnUrlPayRequestData = wire_resp.into(); + assert_eq!(data.callback, "https://example.com/cb"); + assert_eq!(data.min_sendable, 1000); + assert_eq!(data.max_sendable, 100000); + assert_eq!(data.comment_allowed, 140); + assert_eq!(data.description, "Buy coffee"); + assert!(data.lnurl.is_empty()); // caller sets this + } + + #[test] + fn test_pay_request_data_no_comment_allowed() { + let wire_resp = wire::PayRequestResponse { + callback: "https://example.com/cb".to_string(), + max_sendable: 100000, + min_sendable: 1000, + tag: "payRequest".to_string(), + metadata: r#"[["text/plain", "test"]]"#.to_string(), + comment_allowed: None, + }; + + let data: LnUrlPayRequestData = wire_resp.into(); + assert_eq!(data.comment_allowed, 0); + } + + #[test] + fn test_withdraw_request_data_from_conversion() { + let wire_resp = wire::WithdrawRequestResponse { + tag: "withdrawRequest".to_string(), + callback: "https://example.com/withdraw".to_string(), + k1: "secret123".to_string(), + default_description: "Withdraw from service".to_string(), + min_withdrawable: 1000, + max_withdrawable: 50000, + }; + + let data: LnUrlWithdrawRequestData = wire_resp.into(); + assert_eq!(data.callback, "https://example.com/withdraw"); + assert_eq!(data.k1, "secret123"); + assert_eq!(data.default_description, "Withdraw from service"); + assert_eq!(data.min_withdrawable, 1000); + assert_eq!(data.max_withdrawable, 50000); + } + + #[test] + fn test_processed_success_action_from_message() { + let processed = wire::ProcessedSuccessAction::Message { + message: "Thanks!".to_string(), + }; + let sdk: SuccessActionProcessed = processed.into(); + match sdk { + SuccessActionProcessed::Message { message } => assert_eq!(message, "Thanks!"), + _ => panic!("Expected Message variant"), + } + } + + #[test] + fn test_processed_success_action_from_url() { + let processed = wire::ProcessedSuccessAction::Url { + description: "View order".to_string(), + url: "https://example.com/order".to_string(), + }; + let sdk: SuccessActionProcessed = processed.into(); + match sdk { + SuccessActionProcessed::Url { description, url } => { + assert_eq!(description, "View order"); + assert_eq!(url, "https://example.com/order"); + } + _ => panic!("Expected Url variant"), + } + } + + #[test] + fn test_processed_success_action_from_aes() { + let processed = wire::ProcessedSuccessAction::Aes { + description: "Your code".to_string(), + plaintext: "ABC-123".to_string(), + }; + let sdk: SuccessActionProcessed = processed.into(); + match sdk { + SuccessActionProcessed::Aes { + description, + plaintext, + } => { + assert_eq!(description, "Your code"); + assert_eq!(plaintext, "ABC-123"); + } + _ => panic!("Expected Aes variant"), + } + } +} From 385505423d654039ba4d3334e46d12934cf564f5 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 13 Apr 2026 17:23:38 +0200 Subject: [PATCH 03/13] gl-sdk: Extend parse_input() with LNURL and Lightning Address detection Add LnUrl and LnUrlAddress variants to InputType. parse_input() now recognizes bech32 LNURL strings (lnurl1...) and Lightning Addresses (user@domain.com) in addition to BOLT11 invoices and node IDs. Detection is offline only -- no HTTP calls. The caller should use Node::resolve_lnurl() to resolve LnUrl/LnUrlAddress inputs to their typed endpoint data (pay or withdraw). Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-sdk/src/input.rs | 185 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 172 insertions(+), 13 deletions(-) diff --git a/libs/gl-sdk/src/input.rs b/libs/gl-sdk/src/input.rs index 4d4f3f3b9..5f6c7778d 100644 --- a/libs/gl-sdk/src/input.rs +++ b/libs/gl-sdk/src/input.rs @@ -1,5 +1,6 @@ -// Input parsing for BOLT11 invoices and Lightning node IDs. -// Works offline — no node connection needed. +// Input parsing for BOLT11 invoices, Lightning node IDs, LNURL +// strings, and Lightning Addresses. +// Works offline — no node connection or HTTP calls needed. use crate::Error; @@ -29,12 +30,20 @@ pub enum InputType { Bolt11 { invoice: ParsedInvoice }, /// A Lightning node public key (66 hex characters, 33 bytes compressed). NodeId { node_id: String }, + /// An LNURL bech32 string (LUD-01). The `url` field contains the + /// decoded URL. Call `Node::resolve_lnurl()` to determine whether + /// this is a pay or withdraw endpoint. + LnUrl { url: String }, + /// A Lightning Address (LUD-16), e.g. `user@domain.com`. + /// Call `Node::resolve_lnurl()` to resolve it to a pay request. + LnUrlAddress { address: String }, } -/// Parse a string and identify whether it's a BOLT11 invoice or a node ID. +/// Parse a string and identify its type. /// -/// Strips `lightning:` / `LIGHTNING:` prefixes automatically. -/// Returns an error if the input is not recognized or is malformed. +/// Recognizes BOLT11 invoices, node IDs, LNURL bech32 strings, and +/// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes +/// automatically. Works offline — no node connection needed. pub fn parse_input(input: String) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { @@ -50,11 +59,21 @@ pub fn parse_input(input: String) -> Result { trimmed }; + // Try LNURL bech32 (must come before BOLT11 since both start with "ln") + if let Some(result) = try_parse_lnurl(stripped) { + return result; + } + // Try BOLT11 if let Some(input_type) = try_parse_bolt11(stripped) { return input_type; } + // Try Lightning Address (user@domain) + if let Some(input_type) = try_parse_lightning_address(stripped) { + return Ok(input_type); + } + // Try Node ID if let Some(input_type) = try_parse_node_id(stripped) { return Ok(input_type); @@ -63,6 +82,45 @@ pub fn parse_input(input: String) -> Result { Err(Error::Other("Unrecognized input".to_string())) } +/// Try parsing as an LNURL bech32 string (LUD-01). +/// Returns None if the input doesn't look like an LNURL. +fn try_parse_lnurl(input: &str) -> Option> { + if !input.to_uppercase().starts_with("LNURL1") { + return None; + } + match gl_client::lnurl::utils::parse_lnurl(input) { + Ok(url) => Some(Ok(InputType::LnUrl { url })), + Err(e) => Some(Err(Error::Other(format!("Invalid LNURL: {}", e)))), + } +} + +/// Try parsing as a Lightning Address (LUD-16): `user@domain.tld`. +fn try_parse_lightning_address(input: &str) -> Option { + let parts: Vec<&str> = input.split('@').collect(); + if parts.len() != 2 { + return None; + } + let (username, domain) = (parts[0], parts[1]); + if username.is_empty() || domain.is_empty() { + return None; + } + // Domain must contain a dot (rules out bare hostnames and emails + // to local domains which aren't valid Lightning Addresses). + if !domain.contains('.') { + return None; + } + // Username: alphanumeric + limited symbols per LUD-16. + if !username + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') + { + return None; + } + Some(InputType::LnUrlAddress { + address: input.to_string(), + }) +} + /// Try parsing as a BOLT11 invoice. Returns None if the input doesn't /// look like an invoice, or Some(Result) if it does (even if malformed). fn try_parse_bolt11(input: &str) -> Option> { @@ -82,14 +140,10 @@ fn try_parse_bolt11(input: &str) -> Option> { ))); } - let payee_pubkey = parsed - .recover_payee_pub_key() - .serialize() - .to_vec(); + let payee_pubkey = parsed.recover_payee_pub_key().serialize().to_vec(); let payment_hash = format!("{}", parsed.payment_hash()); - let payment_hash = hex::decode(&payment_hash) - .unwrap_or_default(); + let payment_hash = hex::decode(&payment_hash).unwrap_or_default(); let description = match parsed.description() { lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(d) => Some(d.to_string()), @@ -97,9 +151,7 @@ fn try_parse_bolt11(input: &str) -> Option> { }; let amount_msat = parsed.amount_milli_satoshis(); - let expiry = parsed.expiry_time().as_secs(); - let timestamp = parsed .timestamp() .duration_since(std::time::SystemTime::UNIX_EPOCH) @@ -136,3 +188,110 @@ fn try_parse_node_id(input: &str) -> Option { node_id: input.to_string(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_lnurl_string() { + let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; + match parse_input(lnurl.to_string()).unwrap() { + InputType::LnUrl { url } => { + assert!(url.starts_with("https://")); + } + other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_parse_lnurl_lowercase() { + let lnurl = "lnurl1dp68gurn8ghj7cmfwp5x2unsw4hxktnrdakj7ctsdyhhvvf0d3h82unv9ucsaxqze2"; + match parse_input(lnurl.to_string()).unwrap() { + InputType::LnUrl { .. } => {} + other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_parse_lnurl_with_lightning_prefix() { + let input = "lightning:LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; + match parse_input(input.to_string()).unwrap() { + InputType::LnUrl { .. } => {} + other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_parse_invalid_lnurl() { + let result = parse_input("LNURL1INVALIDDATA".to_string()); + assert!(result.is_err()); + } + + #[test] + fn test_parse_lightning_address() { + match parse_input("user@example.com".to_string()).unwrap() { + InputType::LnUrlAddress { address } => { + assert_eq!(address, "user@example.com"); + } + other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_parse_lightning_address_with_symbols() { + // LUD-16 allows a-z0-9-_. + match parse_input("sat.oshi-99@example.com".to_string()).unwrap() { + InputType::LnUrlAddress { address } => { + assert_eq!(address, "sat.oshi-99@example.com"); + } + other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_parse_lightning_address_no_dot_in_domain() { + // "user@localhost" is not a valid Lightning Address + let result = parse_input("user@localhost".to_string()); + // Should fall through to "Unrecognized input" + assert!(result.is_err()); + } + + #[test] + fn test_parse_lightning_address_empty_parts() { + assert!(parse_input("@example.com".to_string()).is_err()); + assert!(parse_input("user@".to_string()).is_err()); + } + + #[test] + fn test_existing_bolt11_still_works() { + // A known valid mainnet invoice + let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; + match parse_input(invoice.to_string()).unwrap() { + InputType::Bolt11 { invoice: parsed } => { + assert_eq!(parsed.amount_msat, Some(10)); + } + other => panic!("Expected Bolt11, got {:?}", variant_name(&other)), + } + } + + #[test] + fn test_existing_node_id_still_works() { + // A compressed pubkey (starts with 02 or 03) + let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"; + match parse_input(node_id.to_string()).unwrap() { + InputType::NodeId { node_id: id } => assert_eq!(id, node_id), + other => panic!("Expected NodeId, got {:?}", variant_name(&other)), + } + } + + /// Helper for readable test failures. + fn variant_name(input: &InputType) -> &'static str { + match input { + InputType::Bolt11 { .. } => "Bolt11", + InputType::NodeId { .. } => "NodeId", + InputType::LnUrl { .. } => "LnUrl", + InputType::LnUrlAddress { .. } => "LnUrlAddress", + } + } +} From f6d4f47b5be662c5eb9ad776048e5b737f7956bf Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 13 Apr 2026 17:47:58 +0200 Subject: [PATCH 04/13] gl-sdk: Add resolve_lnurl(), lnurl_pay(), lnurl_withdraw() to Node Wire up the LNURL flows as methods on Node, following the two-phase pattern: resolve first to inspect metadata, then pay or withdraw. - resolve_lnurl(): accepts LNURL bech32, lightning address, or raw URL. Single HTTP GET with tag-based dispatch via gl-client. - lnurl_pay(): validates, fetches invoice, pays it, processes any success action (message/url/aes decryption). - lnurl_withdraw(): creates invoice via receive(), submits it to the service's callback URL. All three are pure orchestration -- protocol logic is in gl-client. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-sdk/src/node.rs | 172 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 190187f19..f1637bc07 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -1,6 +1,7 @@ use crate::{credentials::Credentials, signer::Handle, util::exec, Error}; use std::sync::atomic::{AtomicBool, Ordering}; use gl_client::credentials::NodeIdProvider; +use gl_client::lnurl::models::LnUrlHttpClient as _; use gl_client::node::{Client as GlClient, ClnClient, Node as ClientNode}; use gl_client::pb::{self as glpb, cln as clnpb}; use lightning_invoice::Bolt11Invoice; @@ -448,6 +449,177 @@ impl Node { inner: Mutex::new(stream), })) } + + // ── LNURL methods ─────────────────────────────────────────── + + /// Resolve an LNURL or Lightning Address to its endpoint data. + /// + /// Performs the HTTP GET to the LNURL endpoint and returns the + /// typed request data. The result tells you whether this is a + /// pay or withdraw request, and includes the service's parameters. + /// + /// Accepts an LNURL bech32 string, a decoded URL (from + /// `parse_input()`), or a Lightning Address (`user@domain`). + pub fn resolve_lnurl( + &self, + input: String, + ) -> Result { + use gl_client::lnurl::models::LnUrlHttpClearnetClient; + use gl_client::lnurl::LNURL; + + let lnurl_client = LNURL::new(LnUrlHttpClearnetClient::new()); + let trimmed = input.trim(); + + // Determine the URL to fetch + let url = if trimmed.contains('@') { + gl_client::lnurl::pay::parse_lightning_address(trimmed) + .map_err(|e| Error::Other(e.to_string()))? + } else if trimmed.to_uppercase().starts_with("LNURL1") { + gl_client::lnurl::utils::parse_lnurl(trimmed) + .map_err(|e| Error::Other(e.to_string()))? + } else { + // Assume it's already a decoded URL + trimmed.to_string() + }; + + let response = exec(lnurl_client.resolve(&url)) + .map_err(|e| Error::Other(e.to_string()))?; + + let mut resolved: crate::lnurl::ResolvedLnUrl = response.into(); + + // Preserve the original input as the lnurl field + match &mut resolved { + crate::lnurl::ResolvedLnUrl::Pay { data } => { + data.lnurl = trimmed.to_string(); + } + crate::lnurl::ResolvedLnUrl::Withdraw { data } => { + data.lnurl = trimmed.to_string(); + } + } + + Ok(resolved) + } + + /// Execute an LNURL-pay flow (LUD-06). + /// + /// Sends the chosen amount (and optional comment) to the service's + /// callback, receives and validates a BOLT11 invoice, pays it, and + /// processes any success action (LUD-09/10). + /// + /// Call `resolve_lnurl()` first to get the `LnUrlPayRequestData`, + /// then build an `LnUrlPayRequest` with the user's chosen amount. + pub fn lnurl_pay( + &self, + request: crate::lnurl::LnUrlPayRequest, + ) -> Result { + self.check_connected()?; + + let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new(); + + // Reconstruct the wire type for validation + invoice fetch + let wire_pay_request = gl_client::lnurl::models::PayRequestResponse { + callback: request.data.callback.clone(), + max_sendable: request.data.max_sendable, + min_sendable: request.data.min_sendable, + tag: "payRequest".to_string(), + metadata: request.data.metadata.clone(), + comment_allowed: if request.data.comment_allowed > 0 { + Some(request.data.comment_allowed) + } else { + None + }, + }; + + // Phase 1: Get invoice from service callback + let comment = request.comment.as_deref(); + let (invoice_str, success_action) = exec( + wire_pay_request.get_invoice(&http_client, request.amount_msat, comment), + ) + .map_err(|e| Error::Other(e.to_string()))?; + + // Phase 2: Pay the invoice + let mut cln_client = exec(self.get_cln_client())?.clone(); + let pay_response = exec(cln_client.pay(clnpb::PayRequest { + bolt11: invoice_str, + ..Default::default() + })) + .map_err(|e| Error::Rpc(e.to_string()))? + .into_inner(); + + // Phase 3: Process success action if present + let processed_action = match success_action { + Some(action) => { + let processed = action + .process(&pay_response.payment_preimage) + .map_err(|e| Error::Other(e.to_string()))?; + Some(processed.into()) + } + None => None, + }; + + Ok(crate::lnurl::LnUrlPayResult::EndpointSuccess { + data: crate::lnurl::LnUrlPaySuccessData { + payment_preimage: hex::encode(&pay_response.payment_preimage), + success_action: processed_action, + }, + }) + } + + /// Execute an LNURL-withdraw flow (LUD-03). + /// + /// Creates an invoice on this node for the requested amount, sends + /// it to the service's callback URL, and the service pays it + /// asynchronously. + /// + /// Call `resolve_lnurl()` first to get the `LnUrlWithdrawRequestData`, + /// then build an `LnUrlWithdrawRequest` with the user's chosen amount. + pub fn lnurl_withdraw( + &self, + request: crate::lnurl::LnUrlWithdrawRequest, + ) -> Result { + self.check_connected()?; + + let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new(); + + // Step 1: Create an invoice on our node + let description = request + .description + .unwrap_or(request.data.default_description.clone()); + + let invoice_response = self.receive( + format!("lnurl-withdraw-{}", request.data.k1), + description, + Some(request.amount_msat), + )?; + + // Step 2: Build callback URL and submit invoice to service + let wire_withdraw = gl_client::lnurl::models::WithdrawRequestResponse { + tag: "withdrawRequest".to_string(), + callback: request.data.callback.clone(), + k1: request.data.k1.clone(), + default_description: request.data.default_description.clone(), + min_withdrawable: request.data.min_withdrawable, + max_withdrawable: request.data.max_withdrawable, + }; + + let callback_url = wire_withdraw + .build_callback_url(&invoice_response.bolt11) + .map_err(|e| Error::Other(e.to_string()))?; + + // Step 3: Send invoice to service + match exec(http_client.send_invoice_for_withdraw_request(&callback_url)) { + Ok(_) => Ok(crate::lnurl::LnUrlWithdrawResult::Ok { + data: crate::lnurl::LnUrlWithdrawSuccessData { + invoice: invoice_response.bolt11, + }, + }), + Err(e) => Ok(crate::lnurl::LnUrlWithdrawResult::ErrorStatus { + data: crate::lnurl::LnUrlErrorData { + reason: e.to_string(), + }, + }), + } + } } // Not exported through uniffi From d28c27b62d7bb53c1d5e6d7883797f3ba272b56c Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 13 Apr 2026 18:43:49 +0200 Subject: [PATCH 05/13] gl-sdk-napi: Add NAPI bindings for LNURL types and Node methods Add TypeScript/Node.js wrappers for the LNURL functionality: - resolve_lnurl(), lnurl_pay(), lnurl_withdraw() on Node - All LNURL types: ResolvedLnUrl, LnUrlPayRequest, LnUrlPayResult, LnUrlWithdrawRequest, LnUrlWithdrawResult, SuccessActionProcessed - Enums represented as discriminated unions with string `type` field - Millisatoshi amounts as i64 for JS number compatibility Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-sdk-napi/src/lib.rs | 320 ++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index ecec2f323..2b85d5fda 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -183,6 +183,115 @@ pub struct FundChannel { pub channel_id: Option, } +// ============================================================================ +// LNURL Types +// ============================================================================ + +#[napi(object)] +pub struct LnUrlPayRequestData { + pub callback: String, + /// Minimum amount in millisatoshis (i64 for JS) + pub min_sendable: i64, + /// Maximum amount in millisatoshis (i64 for JS) + pub max_sendable: i64, + pub metadata: String, + pub comment_allowed: i64, + pub description: String, + pub lnurl: String, +} + +#[napi(object)] +pub struct LnUrlWithdrawRequestData { + pub callback: String, + pub k1: String, + pub default_description: String, + /// Minimum withdrawable in millisatoshis (i64 for JS) + pub min_withdrawable: i64, + /// Maximum withdrawable in millisatoshis (i64 for JS) + pub max_withdrawable: i64, + pub lnurl: String, +} + +/// Result of resolving an LNURL. Discriminated by `type` field. +#[napi(object)] +pub struct ResolvedLnUrl { + /// "pay" or "withdraw" + pub r#type: String, + /// Present when type == "pay" + pub pay: Option, + /// Present when type == "withdraw" + pub withdraw: Option, +} + +#[napi(object)] +pub struct LnUrlPayRequest { + pub data: LnUrlPayRequestData, + /// Amount in millisatoshis (i64 for JS) + pub amount_msat: i64, + pub comment: Option, +} + +#[napi(object)] +pub struct LnUrlWithdrawRequest { + pub data: LnUrlWithdrawRequestData, + /// Amount in millisatoshis (i64 for JS) + pub amount_msat: i64, + pub description: Option, +} + +#[napi(object)] +pub struct LnUrlPaySuccessData { + pub payment_preimage: String, + pub success_action: Option, +} + +#[napi(object)] +pub struct LnUrlErrorData { + pub reason: String, +} + +/// Result of an LNURL-pay operation. Discriminated by `type` field. +#[napi(object)] +pub struct LnUrlPayResult { + /// "success" or "error" + pub r#type: String, + /// Present when type == "success" + pub success: Option, + /// Present when type == "error" + pub error: Option, +} + +#[napi(object)] +pub struct LnUrlWithdrawSuccessData { + pub invoice: String, +} + +/// Result of an LNURL-withdraw operation. Discriminated by `type` field. +#[napi(object)] +pub struct LnUrlWithdrawResult { + /// "ok" or "error" + pub r#type: String, + /// Present when type == "ok" + pub ok: Option, + /// Present when type == "error" + pub error: Option, +} + +/// Processed success action. Discriminated by `type` field. +#[napi(object)] +pub struct SuccessActionProcessed { + /// "message", "url", or "aes" + pub r#type: String, + /// Present for "message" type + pub message: Option, + /// Present for "url" type + pub description: Option, + /// Present for "url" type + pub url: Option, + /// Present for "aes" type (decrypted plaintext) + pub plaintext: Option, +} + // ============================================================================ // Struct Definitions (all structs must be defined before impl blocks) // ============================================================================ @@ -732,6 +841,64 @@ impl Node { .collect(), }) } + + // ── LNURL methods ─────────────────────────────────────────── + + /// Resolve an LNURL or Lightning Address to its endpoint data. + /// + /// Accepts an LNURL bech32 string, a decoded URL, or a Lightning + /// Address (user@domain). + #[napi] + pub async fn resolve_lnurl(&self, input: String) -> Result { + let inner = self.inner.clone(); + let resolved = tokio::task::spawn_blocking(move || { + inner + .resolve_lnurl(input) + .map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + + Ok(napi_resolved_lnurl_from_gl(resolved)) + } + + /// Execute an LNURL-pay flow. + /// + /// Call `resolve_lnurl()` first, then pass the pay data with a + /// chosen amount. + #[napi] + pub async fn lnurl_pay(&self, request: LnUrlPayRequest) -> Result { + let inner = self.inner.clone(); + let gl_request = gl_lnurl_pay_request_from_napi(request); + let result = tokio::task::spawn_blocking(move || { + inner + .lnurl_pay(gl_request) + .map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + + Ok(napi_lnurl_pay_result_from_gl(result)) + } + + /// Execute an LNURL-withdraw flow. + /// + /// Call `resolve_lnurl()` first, then pass the withdraw data with + /// a chosen amount. + #[napi] + pub async fn lnurl_withdraw(&self, request: LnUrlWithdrawRequest) -> Result { + let inner = self.inner.clone(); + let gl_request = gl_lnurl_withdraw_request_from_napi(request); + let result = tokio::task::spawn_blocking(move || { + inner + .lnurl_withdraw(gl_request) + .map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + + Ok(napi_lnurl_withdraw_result_from_gl(result)) + } } // ============================================================================ @@ -784,3 +951,156 @@ fn output_status_to_string(status: &GlOutputStatus) -> String { GlOutputStatus::Immature => "immature".to_string(), } } + +// ============================================================================ +// LNURL Conversion Helpers +// ============================================================================ + +fn napi_pay_request_data_from_gl(data: glsdk::LnUrlPayRequestData) -> LnUrlPayRequestData { + LnUrlPayRequestData { + callback: data.callback, + min_sendable: data.min_sendable as i64, + max_sendable: data.max_sendable as i64, + metadata: data.metadata, + comment_allowed: data.comment_allowed as i64, + description: data.description, + lnurl: data.lnurl, + } +} + +fn napi_withdraw_request_data_from_gl( + data: glsdk::LnUrlWithdrawRequestData, +) -> LnUrlWithdrawRequestData { + LnUrlWithdrawRequestData { + callback: data.callback, + k1: data.k1, + default_description: data.default_description, + min_withdrawable: data.min_withdrawable as i64, + max_withdrawable: data.max_withdrawable as i64, + lnurl: data.lnurl, + } +} + +fn napi_resolved_lnurl_from_gl(resolved: glsdk::ResolvedLnUrl) -> ResolvedLnUrl { + match resolved { + glsdk::ResolvedLnUrl::Pay { data } => ResolvedLnUrl { + r#type: "pay".to_string(), + pay: Some(napi_pay_request_data_from_gl(data)), + withdraw: None, + }, + glsdk::ResolvedLnUrl::Withdraw { data } => ResolvedLnUrl { + r#type: "withdraw".to_string(), + pay: None, + withdraw: Some(napi_withdraw_request_data_from_gl(data)), + }, + } +} + +fn gl_pay_request_data_from_napi(data: LnUrlPayRequestData) -> glsdk::LnUrlPayRequestData { + glsdk::LnUrlPayRequestData { + callback: data.callback, + min_sendable: data.min_sendable as u64, + max_sendable: data.max_sendable as u64, + metadata: data.metadata, + comment_allowed: data.comment_allowed as u64, + description: data.description, + lnurl: data.lnurl, + } +} + +fn gl_withdraw_request_data_from_napi( + data: LnUrlWithdrawRequestData, +) -> glsdk::LnUrlWithdrawRequestData { + glsdk::LnUrlWithdrawRequestData { + callback: data.callback, + k1: data.k1, + default_description: data.default_description, + min_withdrawable: data.min_withdrawable as u64, + max_withdrawable: data.max_withdrawable as u64, + lnurl: data.lnurl, + } +} + +fn gl_lnurl_pay_request_from_napi(req: LnUrlPayRequest) -> glsdk::LnUrlPayRequest { + glsdk::LnUrlPayRequest { + data: gl_pay_request_data_from_napi(req.data), + amount_msat: req.amount_msat as u64, + comment: req.comment, + } +} + +fn gl_lnurl_withdraw_request_from_napi(req: LnUrlWithdrawRequest) -> glsdk::LnUrlWithdrawRequest { + glsdk::LnUrlWithdrawRequest { + data: gl_withdraw_request_data_from_napi(req.data), + amount_msat: req.amount_msat as u64, + description: req.description, + } +} + +fn napi_success_action_from_gl(action: glsdk::SuccessActionProcessed) -> SuccessActionProcessed { + match action { + glsdk::SuccessActionProcessed::Message { message } => SuccessActionProcessed { + r#type: "message".to_string(), + message: Some(message), + description: None, + url: None, + plaintext: None, + }, + glsdk::SuccessActionProcessed::Url { description, url } => SuccessActionProcessed { + r#type: "url".to_string(), + message: None, + description: Some(description), + url: Some(url), + plaintext: None, + }, + glsdk::SuccessActionProcessed::Aes { + description, + plaintext, + } => SuccessActionProcessed { + r#type: "aes".to_string(), + message: None, + description: Some(description), + url: None, + plaintext: Some(plaintext), + }, + } +} + +fn napi_lnurl_pay_result_from_gl(result: glsdk::LnUrlPayResult) -> LnUrlPayResult { + match result { + glsdk::LnUrlPayResult::EndpointSuccess { data } => LnUrlPayResult { + r#type: "success".to_string(), + success: Some(LnUrlPaySuccessData { + payment_preimage: data.payment_preimage, + success_action: data.success_action.map(napi_success_action_from_gl), + }), + error: None, + }, + glsdk::LnUrlPayResult::EndpointError { data } => LnUrlPayResult { + r#type: "error".to_string(), + success: None, + error: Some(LnUrlErrorData { + reason: data.reason, + }), + }, + } +} + +fn napi_lnurl_withdraw_result_from_gl(result: glsdk::LnUrlWithdrawResult) -> LnUrlWithdrawResult { + match result { + glsdk::LnUrlWithdrawResult::Ok { data } => LnUrlWithdrawResult { + r#type: "ok".to_string(), + ok: Some(LnUrlWithdrawSuccessData { + invoice: data.invoice, + }), + error: None, + }, + glsdk::LnUrlWithdrawResult::ErrorStatus { data } => LnUrlWithdrawResult { + r#type: "error".to_string(), + ok: None, + error: Some(LnUrlErrorData { + reason: data.reason, + }), + }, + } +} From 43df4ef97003efba3621bcc23564b5956adf19ca Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 20 Apr 2026 15:10:55 +0200 Subject: [PATCH 06/13] gl-testing: Add CLN-backed LNURL server and integration tests Add a test LNURL server backed by a real CLN node, implementing LUD-01/03/06/09/16. The server issues real BOLT11 invoices and pays real invoices for withdraw, enabling full end-to-end testing. New files: - gltesting/lnurl_server.py: LnurlServer class with pay, withdraw, and lightning address endpoints - tests/test_lnurl_server.py: 6 tests for the server itself (HTTP responses, invoice generation, k1 management) - tests/test_lnurl.py: 5 integration tests using the full Greenlight stack (scheduler, signer, SDK node, channels) against the LNURL server. Includes end-to-end LNURL-pay with actual Lightning payments and success action verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-testing/gltesting/fixtures.py | 21 ++ libs/gl-testing/gltesting/lnurl_server.py | 277 +++++++++++++++++++++ libs/gl-testing/tests/test_lnurl.py | 246 ++++++++++++++++++ libs/gl-testing/tests/test_lnurl_server.py | 92 +++++++ 4 files changed, 636 insertions(+) create mode 100644 libs/gl-testing/gltesting/lnurl_server.py create mode 100644 libs/gl-testing/tests/test_lnurl.py create mode 100644 libs/gl-testing/tests/test_lnurl_server.py diff --git a/libs/gl-testing/gltesting/fixtures.py b/libs/gl-testing/gltesting/fixtures.py index bf7f62588..655848d2e 100644 --- a/libs/gl-testing/gltesting/fixtures.py +++ b/libs/gl-testing/gltesting/fixtures.py @@ -25,6 +25,7 @@ from pyln.testing.fixtures import directory as str_directory from decimal import Decimal from gltesting.grpcweb import GrpcWebProxy, NodeHandler +from gltesting.lnurl_server import LnurlServer from clnvm import ClnVersionManager @@ -235,6 +236,26 @@ def node_grpc_web_proxy(scheduler): p.stop() +@pytest.fixture +def lnurl_service(node_factory): + """A CLN-backed LNURL service. + + Spins up a dedicated CLN node and an HTTP server that exposes + LNURL-pay, LNURL-withdraw and Lightning Address endpoints backed + by that node. Tests can then open channels to/from this node and + exercise the full LNURL flow end-to-end. + + Returns an `LnurlServer` instance. Use `.cln_rpc` to access the + backing CLN node's RPC; `.pay_url`, `.withdraw_url` and + `.lightning_address` to get the endpoints. + """ + cln_node = node_factory.get_node(options={"disable-plugin": "cln-grpc"}) + server = LnurlServer(cln_node) + server.start() + yield server + server.stop() + + @pytest.fixture def lsps_server(node_factory): """Provision and start an LSPs server.""" diff --git a/libs/gl-testing/gltesting/lnurl_server.py b/libs/gl-testing/gltesting/lnurl_server.py new file mode 100644 index 000000000..e95366bd1 --- /dev/null +++ b/libs/gl-testing/gltesting/lnurl_server.py @@ -0,0 +1,277 @@ +# A CLN-backed LNURL server for integration testing. +# +# Implements enough of LUD-01, LUD-03, LUD-06, LUD-09 and LUD-16 to +# exercise gl-client and gl-sdk LNURL flows end-to-end. Backed by a +# real CLN node (via pyln.client) so invoices are real BOLT11s that +# the system-under-test can actually pay / receive. + +from ephemeral_port_reserve import reserve +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from threading import Thread +from urllib.parse import urlparse, parse_qs +import hashlib +import json +import logging +import secrets + + +class LnurlServer: + """HTTP server that exposes LNURL-pay, LNURL-withdraw and Lightning + Address endpoints backed by a CLN node. + + Routes: + GET /lnurlp → LUD-06 payRequest response + GET /lnurlp/callback?amount=&comment= + → BOLT11 invoice + optional successAction + GET /.well-known/lnurlp/{username} → LUD-16 (same payRequest) + GET /lnurlw → LUD-03 withdrawRequest response + GET /lnurlw/callback?k1=&pr= → service pays the invoice + """ + + def __init__( + self, + cln_node, + *, + domain: str = "127.0.0.1", + username: str = "alice", + min_sendable: int = 1_000, + max_sendable: int = 100_000_000, + min_withdrawable: int = 1_000, + max_withdrawable: int = 100_000_000, + comment_allowed: int = 0, + success_action: dict | None = None, + ): + self.logger = logging.getLogger("gltesting.lnurl_server") + self.cln_node = cln_node + self.cln_rpc = cln_node.rpc + self.domain = domain + self.username = username + self.min_sendable = min_sendable + self.max_sendable = max_sendable + self.min_withdrawable = min_withdrawable + self.max_withdrawable = max_withdrawable + self.comment_allowed = comment_allowed + self.success_action = success_action + + self.port = reserve() + self._thread: Thread | None = None + self._httpd: ThreadingHTTPServer | None = None + + # Metadata for the pay request (LUD-06 mandates text/plain, + # LUD-16 requires a text/identifier entry for lightning addresses). + # We include the port in the identifier because the test domain is + # localhost-based and the lightning address includes the port. + self.metadata = json.dumps( + [ + ["text/plain", f"Pay to {username}"], + ["text/identifier", f"{username}@{domain}:{self.port}"], + ] + ) + + # Each withdraw session issues a fresh k1 and remembers it until consumed + self._pending_withdrawals: dict[str, dict] = {} + + # Logs of all incoming callback requests — tests inspect these + self.pay_callbacks: list[dict] = [] + self.withdraw_callbacks: list[dict] = [] + + # ── URLs ────────────────────────────────────────────────── + + @property + def base_url(self) -> str: + return f"http://{self.domain}:{self.port}" + + @property + def pay_url(self) -> str: + return f"{self.base_url}/lnurlp" + + @property + def lightning_address(self) -> str: + return f"{self.username}@{self.domain}:{self.port}" + + @property + def lightning_address_url(self) -> str: + return f"{self.base_url}/.well-known/lnurlp/{self.username}" + + @property + def withdraw_url(self) -> str: + return f"{self.base_url}/lnurlw" + + # ── Lifecycle ──────────────────────────────────────────── + + def start(self): + server_address = ("127.0.0.1", self.port) + handler_cls = _handler_factory(self) + self._httpd = ThreadingHTTPServer(server_address, handler_cls) + self._thread = Thread(target=self._httpd.serve_forever, daemon=True) + self._thread.start() + self.logger.info(f"LnurlServer running on {self.base_url}") + + def stop(self): + if self._httpd is not None: + self._httpd.shutdown() + self._httpd.server_close() + if self._thread is not None: + self._thread.join() + self.logger.info("LnurlServer stopped") + + # ── Handler callbacks (invoked from the HTTP thread) ───── + + def build_pay_response(self, callback_path: str) -> dict: + return { + "tag": "payRequest", + "callback": f"{self.base_url}{callback_path}", + "minSendable": self.min_sendable, + "maxSendable": self.max_sendable, + "metadata": self.metadata, + "commentAllowed": self.comment_allowed, + } + + def handle_pay_callback(self, amount_msat: int, comment: str | None) -> dict: + """Generate an invoice on the CLN backend for the requested amount. + + The description is set to the raw metadata string so the client's + BOLT11 description-hash check passes (as mandated by LUD-06). + """ + self.pay_callbacks.append({"amount_msat": amount_msat, "comment": comment}) + + # CLN requires a unique label per invoice + label = f"lnurl-pay-{secrets.token_hex(8)}" + + # LUD-06: the BOLT11 description hash must equal SHA256(metadata). + # pyln's `invoice` accepts `description` as a string; when CLN + # encodes the invoice it hashes that string into the description + # hash field, so passing our raw metadata JSON matches what the + # client re-computes. + invoice = self.cln_rpc.invoice( + amount_msat=amount_msat, + label=label, + description=self.metadata, + deschashonly=True, + ) + + response = { + "pr": invoice["bolt11"], + "routes": [], + } + if self.success_action is not None: + response["successAction"] = self.success_action + return response + + def build_withdraw_response(self, callback_path: str) -> dict: + k1 = secrets.token_hex(16) + self._pending_withdrawals[k1] = {"used": False} + return { + "tag": "withdrawRequest", + "callback": f"{self.base_url}{callback_path}", + "k1": k1, + "defaultDescription": f"Withdraw from {self.domain}", + "minWithdrawable": self.min_withdrawable, + "maxWithdrawable": self.max_withdrawable, + } + + def handle_withdraw_callback(self, k1: str, invoice: str) -> dict: + """Pay the supplied BOLT11 invoice from the CLN backend.""" + self.withdraw_callbacks.append({"k1": k1, "pr": invoice}) + + session = self._pending_withdrawals.get(k1) + if session is None: + return {"status": "ERROR", "reason": f"unknown k1: {k1}"} + if session["used"]: + return {"status": "ERROR", "reason": "k1 already used"} + session["used"] = True + + try: + self.cln_rpc.pay(invoice) + except Exception as e: + return {"status": "ERROR", "reason": f"pay failed: {e}"} + + return {"status": "OK"} + + +def _handler_factory(server: LnurlServer): + """Build a BaseHTTPRequestHandler class bound to a specific server. + + Using a closure avoids squirreling state onto the ThreadingHTTPServer + itself (as grpcweb.py does) and keeps the handler readable. + """ + + class _Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + server.logger.debug("%s - - %s" % (self.address_string(), format % args)) + + def _reply_json(self, code: int, payload: dict): + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + query = parse_qs(parsed.query) + + try: + if path == "/lnurlp": + self._reply_json(200, server.build_pay_response("/lnurlp/callback")) + return + + if path == f"/.well-known/lnurlp/{server.username}": + # LUD-16: use a different callback path so tests can + # distinguish address vs raw-lnurl code paths if they + # want to. + self._reply_json( + 200, + server.build_pay_response( + f"/.well-known/lnurlp/{server.username}/callback" + ), + ) + return + + if path in ("/lnurlp/callback", f"/.well-known/lnurlp/{server.username}/callback"): + amount = query.get("amount", [None])[0] + if amount is None: + self._reply_json( + 200, + {"status": "ERROR", "reason": "missing amount"}, + ) + return + comment = query.get("comment", [None])[0] + self._reply_json( + 200, + server.handle_pay_callback(int(amount), comment), + ) + return + + if path == "/lnurlw": + self._reply_json( + 200, server.build_withdraw_response("/lnurlw/callback") + ) + return + + if path == "/lnurlw/callback": + k1 = query.get("k1", [None])[0] + pr = query.get("pr", [None])[0] + if not k1 or not pr: + self._reply_json( + 200, {"status": "ERROR", "reason": "missing k1 or pr"} + ) + return + self._reply_json(200, server.handle_withdraw_callback(k1, pr)) + return + + self.send_response(404) + self.end_headers() + except Exception as e: + server.logger.exception("Unhandled error in LnurlServer handler") + self._reply_json(500, {"status": "ERROR", "reason": str(e)}) + + return _Handler + + +def metadata_sha256(metadata: str) -> str: + """Helper for tests that want to assert on description-hash matching.""" + return hashlib.sha256(metadata.encode("utf-8")).hexdigest() diff --git a/libs/gl-testing/tests/test_lnurl.py b/libs/gl-testing/tests/test_lnurl.py new file mode 100644 index 000000000..5c59b98bb --- /dev/null +++ b/libs/gl-testing/tests/test_lnurl.py @@ -0,0 +1,246 @@ +"""End-to-end integration tests for LNURL flows. + +These tests spin up real CLN nodes, a CLN-backed LNURL server, and a +Greenlight SDK node, then exercise the full LNURL-pay and +LNURL-withdraw protocols. + +Network topology: + gl_sdk_node ── channel ── relay ── channel ── service_node (LNURL server) +""" + +from gltesting.fixtures import * # noqa: F401, F403 +from pyln.testing.utils import wait_for + +import glsdk + + +MNEMONIC = ( + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" +) + +CHANNEL_SATS = 1_000_000 # 1M sats + + +def make_sdk_node(nobody_id, scheduler): + """Register a GL node via the SDK and return it with signer running.""" + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + node = glsdk.register(MNEMONIC, None, config) + return node, config + + +def fund_and_connect(node_factory, bitcoind, lnurl_service): + """Create a relay node with channels to the LNURL service node. + + Returns the relay node, already funded and with a NORMAL channel to + the service. + """ + relay = node_factory.get_node(options={"disable-plugin": "cln-grpc"}) + service_node = lnurl_service.cln_node + service_id = service_node.info["id"] + + # Connect relay <-> service + relay.rpc.connect(service_id, "127.0.0.1", service_node.daemon.port) + + # Fund relay + addr = relay.rpc.newaddr()["bech32"] + bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(relay.rpc.listfunds()["outputs"]) > 0) + + # Open channel relay -> service + relay.rpc.fundchannel(service_id, CHANNEL_SATS) + bitcoind.generate_block(6, wait_for_mempool=1) + wait_for( + lambda: any( + ch["state"] == "CHANNELD_NORMAL" + for ch in relay.rpc.listpeerchannels(service_id)["channels"] + ) + ) + + return relay + + +def test_resolve_lnurl_pay( + scheduler, nobody_id, node_factory, bitcoind, lnurl_service +): + """Resolve an LNURL-pay endpoint via the SDK.""" + sdk_node, config = make_sdk_node(nobody_id, scheduler) + + try: + resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) + + assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) + data = resolved.data + assert data.min_sendable == lnurl_service.min_sendable + assert data.max_sendable == lnurl_service.max_sendable + assert len(data.description) > 0 + assert data.callback.startswith(lnurl_service.base_url) + finally: + sdk_node.disconnect() + + +def test_resolve_lnurl_withdraw( + scheduler, nobody_id, node_factory, bitcoind, lnurl_service +): + """Resolve an LNURL-withdraw endpoint via the SDK.""" + sdk_node, config = make_sdk_node(nobody_id, scheduler) + + try: + resolved = sdk_node.resolve_lnurl(lnurl_service.withdraw_url) + + assert isinstance(resolved, glsdk.ResolvedLnUrl.WITHDRAW) + data = resolved.data + assert data.min_withdrawable == lnurl_service.min_withdrawable + assert data.max_withdrawable == lnurl_service.max_withdrawable + assert len(data.k1) > 0 + finally: + sdk_node.disconnect() + + +def test_resolve_lightning_address_url( + scheduler, nobody_id, node_factory, bitcoind, lnurl_service +): + """Resolve a Lightning Address well-known URL (LUD-16).""" + sdk_node, config = make_sdk_node(nobody_id, scheduler) + + try: + resolved = sdk_node.resolve_lnurl(lnurl_service.lightning_address_url) + + assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) + data = resolved.data + assert data.min_sendable == lnurl_service.min_sendable + finally: + sdk_node.disconnect() + + +def test_lnurl_pay_end_to_end( + scheduler, nobody_id, clients, node_factory, bitcoind, lnurl_service +): + """Full LNURL-pay flow: resolve → pay → verify. + + Uses a GL SDK node with outbound liquidity to pay an LNURL service. + """ + # Use the low-level client to set up channels, since the SDK node + # doesn't expose connect_peer / fund_channel directly. + relay = fund_and_connect(node_factory, bitcoind, lnurl_service) + + c = clients.new() + c.register(configure=True) + gl1 = c.node() + s = c.signer().run_in_thread() + + relay_id = relay.info["id"] + + # Connect GL node to relay and open channel + gl1.connect_peer(relay_id, f"127.0.0.1:{relay.daemon.port}") + gl_addr = gl1.new_address().bech32 + bitcoind.rpc.sendtoaddress(gl_addr, 0.5) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(gl1.list_funds().outputs) > 0) + + from pyln import grpc as clnpb + + gl1.fund_channel( + bytes.fromhex(relay_id), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=CHANNEL_SATS * 1000)), + ) + bitcoind.generate_block(6, wait_for_mempool=1) + wait_for( + lambda: any( + ch.state == 2 # CHANNELD_NORMAL + for ch in gl1.list_peer_channels().channels + ) + ) + + # Now build an SDK-level Node for LNURL operations + creds_bytes = c.creds().to_bytes() + sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) + + try: + # Resolve + resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) + assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) + pay_data = resolved.data + + amount_msat = 50_000 # 50 sats + + # Pay + result = sdk_node.lnurl_pay( + glsdk.LnUrlPayRequest( + data=pay_data, + amount_msat=amount_msat, + comment=None, + ) + ) + + assert isinstance(result, glsdk.LnUrlPayResult.ENDPOINT_SUCCESS) + assert len(result.data.payment_preimage) == 64 # hex-encoded 32 bytes + + # Verify the LNURL server saw the callback + assert len(lnurl_service.pay_callbacks) == 1 + assert lnurl_service.pay_callbacks[0]["amount_msat"] == amount_msat + finally: + sdk_node.disconnect() + + +def test_lnurl_pay_with_message_success_action( + scheduler, nobody_id, clients, node_factory, bitcoind, lnurl_service +): + """LNURL-pay with a message-type success action (LUD-09).""" + lnurl_service.success_action = { + "tag": "message", + "message": "Thank you for your payment!", + } + + relay = fund_and_connect(node_factory, bitcoind, lnurl_service) + + c = clients.new() + c.register(configure=True) + gl1 = c.node() + s = c.signer().run_in_thread() + + relay_id = relay.info["id"] + gl1.connect_peer(relay_id, f"127.0.0.1:{relay.daemon.port}") + gl_addr = gl1.new_address().bech32 + bitcoind.rpc.sendtoaddress(gl_addr, 0.5) + bitcoind.generate_block(1, wait_for_mempool=1) + wait_for(lambda: len(gl1.list_funds().outputs) > 0) + + from pyln import grpc as clnpb + + gl1.fund_channel( + bytes.fromhex(relay_id), + clnpb.AmountOrAll(amount=clnpb.Amount(msat=CHANNEL_SATS * 1000)), + ) + bitcoind.generate_block(6, wait_for_mempool=1) + wait_for( + lambda: any( + ch.state == 2 # CHANNELD_NORMAL + for ch in gl1.list_peer_channels().channels + ) + ) + + creds_bytes = c.creds().to_bytes() + sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) + + try: + resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) + pay_data = resolved.data + + result = sdk_node.lnurl_pay( + glsdk.LnUrlPayRequest( + data=pay_data, + amount_msat=50_000, + comment=None, + ) + ) + + assert isinstance(result, glsdk.LnUrlPayResult.ENDPOINT_SUCCESS) + sa = result.data.success_action + assert sa is not None + assert isinstance(sa, glsdk.SuccessActionProcessed.MESSAGE) + assert sa.message == "Thank you for your payment!" + finally: + sdk_node.disconnect() diff --git a/libs/gl-testing/tests/test_lnurl_server.py b/libs/gl-testing/tests/test_lnurl_server.py new file mode 100644 index 000000000..e9f9424e8 --- /dev/null +++ b/libs/gl-testing/tests/test_lnurl_server.py @@ -0,0 +1,92 @@ +"""Tests for the CLN-backed LNURL mock server. + +These tests verify the server itself works correctly (invoice generation, +callback handling, etc.) before we layer in gl-client/gl-sdk tests that +depend on it. +""" + +from gltesting.fixtures import * # noqa: F401, F403 +from gltesting.lnurl_server import metadata_sha256 + +import httpx +import json + + +def test_pay_request_response(lnurl_service): + """GET /lnurlp returns a valid payRequest response.""" + r = httpx.get(lnurl_service.pay_url) + assert r.status_code == 200 + body = r.json() + + assert body["tag"] == "payRequest" + assert body["callback"].startswith(lnurl_service.base_url) + assert body["minSendable"] == lnurl_service.min_sendable + assert body["maxSendable"] == lnurl_service.max_sendable + # metadata must parse as a JSON array of [mime, content] pairs + meta = json.loads(body["metadata"]) + assert any(entry[0] == "text/plain" for entry in meta) + + +def test_lightning_address_endpoint(lnurl_service): + """LUD-16: GET /.well-known/lnurlp/{user} returns a payRequest.""" + r = httpx.get(lnurl_service.lightning_address_url) + assert r.status_code == 200 + body = r.json() + assert body["tag"] == "payRequest" + # Metadata must include a text/identifier entry for LUD-16 + meta = json.loads(body["metadata"]) + assert any( + entry[0] == "text/identifier" and entry[1] == f"{lnurl_service.username}@{lnurl_service.domain}:{lnurl_service.port}" + for entry in meta + ) + + +def test_pay_callback_returns_valid_invoice(lnurl_service): + """GET /lnurlp/callback?amount=X returns a BOLT11 with the right + description hash and amount.""" + # Fetch the pay request to get the callback URL + pay_req = httpx.get(lnurl_service.pay_url).json() + amount_msat = 10_000 + + # Call the callback + r = httpx.get(pay_req["callback"], params={"amount": amount_msat}) + assert r.status_code == 200 + body = r.json() + assert "pr" in body + + # Decode the BOLT11 using the backing CLN node + decoded = lnurl_service.cln_rpc.decodepay(body["pr"]) + assert decoded["amount_msat"] == amount_msat + # description hash must match SHA256(metadata) + expected_hash = metadata_sha256(pay_req["metadata"]) + assert decoded["description_hash"] == expected_hash + + +def test_pay_callback_tracked_in_callbacks_list(lnurl_service): + """The server records every callback invocation for test inspection.""" + pay_req = httpx.get(lnurl_service.pay_url).json() + httpx.get(pay_req["callback"], params={"amount": 5_000, "comment": "hello"}) + + assert len(lnurl_service.pay_callbacks) == 1 + assert lnurl_service.pay_callbacks[0]["amount_msat"] == 5_000 + assert lnurl_service.pay_callbacks[0]["comment"] == "hello" + + +def test_withdraw_request_response(lnurl_service): + """GET /lnurlw returns a valid withdrawRequest response with a fresh k1.""" + r = httpx.get(lnurl_service.withdraw_url) + assert r.status_code == 200 + body = r.json() + + assert body["tag"] == "withdrawRequest" + assert body["callback"].startswith(lnurl_service.base_url) + assert len(body["k1"]) > 0 + assert body["minWithdrawable"] == lnurl_service.min_withdrawable + assert body["maxWithdrawable"] == lnurl_service.max_withdrawable + + +def test_withdraw_issues_distinct_k1s(lnurl_service): + """Each call to /lnurlw returns a fresh k1.""" + r1 = httpx.get(lnurl_service.withdraw_url).json() + r2 = httpx.get(lnurl_service.withdraw_url).json() + assert r1["k1"] != r2["k1"] From 183353e70997124cf5182cdd8600a533e1a12c0d Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 21 Apr 2026 15:50:37 +0200 Subject: [PATCH 07/13] gl-sdk: Add .gitignore for UniFFI-generated build artifacts Ignore glsdk.py, libglsdk.so, __pycache__, and bindings/ since these are regenerated by uniffi-bindgen from the compiled Rust library. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-sdk/.gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 libs/gl-sdk/.gitignore diff --git a/libs/gl-sdk/.gitignore b/libs/gl-sdk/.gitignore new file mode 100644 index 000000000..1bf662cdb --- /dev/null +++ b/libs/gl-sdk/.gitignore @@ -0,0 +1,5 @@ +# UniFFI-generated build artifacts +glsdk/glsdk.py +glsdk/libglsdk.* +glsdk/__pycache__/ +bindings/ From fcb63cea4f46c3f3feff15da2b34d57d9b9726da Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 21 Apr 2026 16:27:09 +0200 Subject: [PATCH 08/13] gl-client/gl-sdk: Extract fetch_invoice() free function The SDK's lnurl_pay() was reconstructing a PayRequestResponse struct (a server response type) on the client side just to call its get_invoice() method. Fix by extracting the logic into a public fetch_invoice() free function that takes callback/amount/metadata directly. The method on PayRequestResponse now delegates to it. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-client/src/lnurl/pay/mod.rs | 59 +++++++++++++++++++---------- libs/gl-sdk/src/node.rs | 22 ++++------- 2 files changed, 45 insertions(+), 36 deletions(-) diff --git a/libs/gl-client/src/lnurl/pay/mod.rs b/libs/gl-client/src/lnurl/pay/mod.rs index b83f8fa5d..20aadf865 100644 --- a/libs/gl-client/src/lnurl/pay/mod.rs +++ b/libs/gl-client/src/lnurl/pay/mod.rs @@ -79,30 +79,47 @@ impl PayRequestResponse { amount_msats: u64, comment: Option<&str>, ) -> Result<(String, Option)> { - let callback_url = self.build_callback_url(amount_msats, comment)?; - let callback_response: PayRequestCallbackResponse = http_client - .get_pay_request_callback_response(&callback_url) - .await?; - - let invoice = parse_invoice(&callback_response.pr)?; - validate_invoice(&invoice, amount_msats, &self.metadata)?; - Ok((invoice.to_string(), callback_response.success_action)) + fetch_invoice(http_client, &self.callback, amount_msats, &self.metadata, comment).await } +} - /// Build the callback URL with amount and optional comment. - fn build_callback_url( - &self, - amount: u64, - comment: Option<&str>, - ) -> Result { - let mut url = Url::parse(&self.callback)?; - url.query_pairs_mut() - .append_pair("amount", &amount.to_string()); - if let Some(c) = comment { - url.query_pairs_mut().append_pair("comment", c); - } - Ok(url.to_string()) +/// Fetch an invoice from a pay-request callback URL. +/// +/// This is the "phase 2" of the two-phase LNURL-pay flow: the caller +/// already has the callback URL and metadata from the initial +/// `payRequest` response, and now requests an invoice for a specific +/// amount. The returned invoice is validated against the expected +/// amount and metadata hash before being returned. +pub async fn fetch_invoice( + http_client: &T, + callback: &str, + amount_msats: u64, + metadata: &str, + comment: Option<&str>, +) -> Result<(String, Option)> { + let callback_url = build_callback_url(callback, amount_msats, comment)?; + let callback_response: PayRequestCallbackResponse = http_client + .get_pay_request_callback_response(&callback_url) + .await?; + + let invoice = parse_invoice(&callback_response.pr)?; + validate_invoice(&invoice, amount_msats, metadata)?; + Ok((invoice.to_string(), callback_response.success_action)) +} + +/// Build a callback URL with amount and optional comment query parameters. +fn build_callback_url( + callback: &str, + amount: u64, + comment: Option<&str>, +) -> Result { + let mut url = Url::parse(callback)?; + url.query_pairs_mut() + .append_pair("amount", &amount.to_string()); + if let Some(c) = comment { + url.query_pairs_mut().append_pair("comment", c); } + Ok(url.to_string()) } /// Validate a BOLT11 invoice against the expected amount and metadata. diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index f1637bc07..e52dc9803 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -516,24 +516,16 @@ impl Node { let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new(); - // Reconstruct the wire type for validation + invoice fetch - let wire_pay_request = gl_client::lnurl::models::PayRequestResponse { - callback: request.data.callback.clone(), - max_sendable: request.data.max_sendable, - min_sendable: request.data.min_sendable, - tag: "payRequest".to_string(), - metadata: request.data.metadata.clone(), - comment_allowed: if request.data.comment_allowed > 0 { - Some(request.data.comment_allowed) - } else { - None - }, - }; - // Phase 1: Get invoice from service callback let comment = request.comment.as_deref(); let (invoice_str, success_action) = exec( - wire_pay_request.get_invoice(&http_client, request.amount_msat, comment), + gl_client::lnurl::pay::fetch_invoice( + &http_client, + &request.data.callback, + request.amount_msat, + &request.data.metadata, + comment, + ), ) .map_err(|e| Error::Other(e.to_string()))?; From 93acaa64cf42ae17ae3676c4459d88f9462fd72d Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Tue, 21 Apr 2026 16:34:44 +0200 Subject: [PATCH 09/13] gl-client/gl-sdk: Extract build_withdraw_callback_url() free function Same fix as the previous commit but for the withdraw side: the SDK was reconstructing a WithdrawRequestResponse just to call build_callback_url(). Extract a free function that takes callback, k1, and invoice directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/gl-client/src/lnurl/withdraw/mod.rs | 18 +++++++++++++----- libs/gl-sdk/src/node.rs | 18 ++++++------------ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/libs/gl-client/src/lnurl/withdraw/mod.rs b/libs/gl-client/src/lnurl/withdraw/mod.rs index c3725a055..5b2295f4a 100644 --- a/libs/gl-client/src/lnurl/withdraw/mod.rs +++ b/libs/gl-client/src/lnurl/withdraw/mod.rs @@ -9,14 +9,22 @@ impl WithdrawRequestResponse { /// /// Appends `k1` and `pr` (the BOLT11 invoice) as query parameters. pub fn build_callback_url(&self, invoice: &str) -> Result { - let mut url = Url::parse(&self.callback)?; - url.query_pairs_mut() - .append_pair("k1", &self.k1) - .append_pair("pr", invoice); - Ok(url.to_string()) + build_withdraw_callback_url(&self.callback, &self.k1, invoice) } } +/// Build a withdraw callback URL from its individual components. +/// +/// Appends `k1` and `pr` (the BOLT11 invoice) as query parameters +/// to the callback base URL. +pub fn build_withdraw_callback_url(callback: &str, k1: &str, invoice: &str) -> Result { + let mut url = Url::parse(callback)?; + url.query_pairs_mut() + .append_pair("k1", k1) + .append_pair("pr", invoice); + Ok(url.to_string()) +} + fn convert_value_field_from_str_to_u64( value: &mut Map, field_name: &str, diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index e52dc9803..5aad632fb 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -585,18 +585,12 @@ impl Node { )?; // Step 2: Build callback URL and submit invoice to service - let wire_withdraw = gl_client::lnurl::models::WithdrawRequestResponse { - tag: "withdrawRequest".to_string(), - callback: request.data.callback.clone(), - k1: request.data.k1.clone(), - default_description: request.data.default_description.clone(), - min_withdrawable: request.data.min_withdrawable, - max_withdrawable: request.data.max_withdrawable, - }; - - let callback_url = wire_withdraw - .build_callback_url(&invoice_response.bolt11) - .map_err(|e| Error::Other(e.to_string()))?; + let callback_url = gl_client::lnurl::withdraw::build_withdraw_callback_url( + &request.data.callback, + &request.data.k1, + &invoice_response.bolt11, + ) + .map_err(|e| Error::Other(e.to_string()))?; // Step 3: Send invoice to service match exec(http_client.send_invoice_for_withdraw_request(&callback_url)) { From 973b174eeaf494fb814b3f724cc659db1b81c994 Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Fri, 24 Apr 2026 15:28:39 +0200 Subject: [PATCH 10/13] gl-testing/gl-sdk: Move test_lnurl.py to gl-sdk tests test_lnurl.py imports glsdk, but gl-testing doesn't depend on gl-sdk (only gl-client). On CI this caused `task testing:check` to fail collection with ModuleNotFoundError. The SDK already depends on gl-testing for fixtures, so the test naturally belongs under libs/gl-sdk/tests/. The lnurl_server fixture stays in gl-testing for reuse (test_lnurl_server.py doesn't need glsdk). Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/{gl-testing => gl-sdk}/tests/test_lnurl.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename libs/{gl-testing => gl-sdk}/tests/test_lnurl.py (100%) diff --git a/libs/gl-testing/tests/test_lnurl.py b/libs/gl-sdk/tests/test_lnurl.py similarity index 100% rename from libs/gl-testing/tests/test_lnurl.py rename to libs/gl-sdk/tests/test_lnurl.py From b64558a01e0eec13082e078ed6ab8c0ec92ce7c0 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Wed, 22 Apr 2026 21:16:40 -0400 Subject: [PATCH 11/13] gl-client/gl-sdk: Harden LNURL-pay against real-world services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gaps surfaced when paying stacker.news exposed several mismatches between the current implementation and what wallets need in practice. Changes align gl-sdk's LNURL-pay surface with the LUD specs and with what Breez SDK does for the convention-level gaps: - Parse LUD-06 service errors: callback responses with {"status":"ERROR"} are now recognised and surfaced as LnUrlPayResult::EndpointError with the service's reason, instead of failing JSON deserialization. - Add LnUrlPayResult::PayError { payment_hash, reason } so CLN pay-side failures return structured results rather than Error::Rpc. - Pre-flight amount/comment validation in gl-sdk::lnurl_pay to reject out-of-bounds requests before any network round-trip. - Drop the description_hash == SHA256(metadata) check that rejected compliant-enough services whose metadata embeds per-request data. - Skip the empty `comment` query param in the callback URL when the caller passes None or an empty string. - Enforce LUD-09/10 bounds on SuccessAction payloads (Message/Url description ≤ 144, AES description ≤ 144, ciphertext ≤ 4096, IV exactly 24 chars) before any AES decryption. - Validate the invoice's BOLT-11 currency prefix against the node's configured network; thread `network` through Node::with_signer so the check has something to compare against. - Validate that a URL success action's domain matches the callback domain, with an opt-out via LnUrlPayRequest.validate_success_action_url (defaults to true). - Sync the gl-sdk-napi (Node.js bindings) shapes: add validate_success_action_url to the NAPI LnUrlPayRequest, add LnUrlPayErrorData and the "pay_error" discriminator on LnUrlPayResult, wire the new variant through napi_lnurl_pay_result_from_gl. --- Cargo.lock | 1 + libs/gl-client/src/lnurl/models.rs | 29 +++- libs/gl-client/src/lnurl/pay/mod.rs | 68 ++++----- .../com/blockstream/glsdk/LnurlParseTest.kt | 92 ++++++++++++ libs/gl-sdk-napi/src/lib.rs | 27 +++- libs/gl-sdk/Cargo.toml | 1 + libs/gl-sdk/src/lib.rs | 4 +- libs/gl-sdk/src/lnurl.rs | 22 ++- libs/gl-sdk/src/node.rs | 142 ++++++++++++++++-- 9 files changed, 335 insertions(+), 51 deletions(-) create mode 100644 libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt diff --git a/Cargo.lock b/Cargo.lock index e93ada8bd..3e4c145b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1647,6 +1647,7 @@ dependencies = [ "tonic 0.11.0", "tracing", "uniffi", + "url", ] [[package]] diff --git a/libs/gl-client/src/lnurl/models.rs b/libs/gl-client/src/lnurl/models.rs index e02e30b7f..547cdd5ab 100644 --- a/libs/gl-client/src/lnurl/models.rs +++ b/libs/gl-client/src/lnurl/models.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, ensure, Result}; use async_trait::async_trait; use log::debug; use mockall::automock; @@ -97,12 +97,27 @@ impl SuccessAction { /// `preimage` is the 32-byte payment preimage from the PayResponse. /// For Message and Url variants this is a simple conversion; for Aes /// it decrypts the ciphertext using the preimage as the AES-256 key. + /// + /// All payload-shape checks here are required by the LNURL specs: + /// - Message.message ≤ 144 chars (LUD-09) + /// - Url.description ≤ 144 chars (LUD-09) + /// - Aes.description ≤ 144 chars (LUD-10) + /// - Aes.ciphertext ≤ 4096 chars (LUD-10) + /// - Aes.iv == exactly 24 base64 chars / 16 bytes (LUD-10) pub fn process(self, preimage: &[u8]) -> Result { match self { SuccessAction::Message { message } => { + ensure!( + message.len() <= 144, + "Message success action exceeds 144 chars" + ); Ok(ProcessedSuccessAction::Message { message }) } SuccessAction::Url { description, url } => { + ensure!( + description.len() <= 144, + "Url success action description exceeds 144 chars" + ); Ok(ProcessedSuccessAction::Url { description, url }) } SuccessAction::Aes { @@ -110,6 +125,18 @@ impl SuccessAction { ciphertext, iv, } => { + ensure!( + description.len() <= 144, + "AES success action description exceeds 144 chars" + ); + ensure!( + ciphertext.len() <= 4096, + "AES success action ciphertext exceeds 4096 chars" + ); + ensure!( + iv.len() == 24, + "AES success action IV must be exactly 24 base64 chars" + ); let plaintext = super::pay::decrypt_aes_success_action(preimage, &ciphertext, &iv)?; Ok(ProcessedSuccessAction::Aes { diff --git a/libs/gl-client/src/lnurl/pay/mod.rs b/libs/gl-client/src/lnurl/pay/mod.rs index 20aadf865..8ece34da4 100644 --- a/libs/gl-client/src/lnurl/pay/mod.rs +++ b/libs/gl-client/src/lnurl/pay/mod.rs @@ -1,7 +1,7 @@ use super::models::SuccessAction; use super::utils::parse_lnurl; -use crate::lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; +use crate::lightning_invoice::Bolt11Invoice; use crate::lnurl::{ models::{LnUrlHttpClient, PayRequestCallbackResponse, PayRequestResponse}, utils::parse_invoice, @@ -10,7 +10,6 @@ use crate::lnurl::{ use anyhow::{anyhow, ensure, Result}; use log::debug; use reqwest::Url; -use sha256; impl PayRequestResponse { /// Extract the "text/plain" description from the metadata JSON. @@ -79,34 +78,46 @@ impl PayRequestResponse { amount_msats: u64, comment: Option<&str>, ) -> Result<(String, Option)> { - fetch_invoice(http_client, &self.callback, amount_msats, &self.metadata, comment).await + fetch_invoice(http_client, &self.callback, amount_msats, comment).await } } /// Fetch an invoice from a pay-request callback URL. /// /// This is the "phase 2" of the two-phase LNURL-pay flow: the caller -/// already has the callback URL and metadata from the initial -/// `payRequest` response, and now requests an invoice for a specific -/// amount. The returned invoice is validated against the expected -/// amount and metadata hash before being returned. +/// already has the callback URL from the initial `payRequest` response, +/// and now requests an invoice for a specific amount. The returned +/// invoice is validated to be parseable and match the requested amount. pub async fn fetch_invoice( http_client: &T, callback: &str, amount_msats: u64, - metadata: &str, comment: Option<&str>, ) -> Result<(String, Option)> { let callback_url = build_callback_url(callback, amount_msats, comment)?; - let callback_response: PayRequestCallbackResponse = http_client - .get_pay_request_callback_response(&callback_url) - .await?; + let raw: serde_json::Value = http_client.get_json(&callback_url).await?; + + if raw.get("status").and_then(|v| v.as_str()) == Some("ERROR") { + let reason = raw + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(anyhow!("{}{}", LNURL_SERVICE_ERROR_PREFIX, reason)); + } + + let callback_response: PayRequestCallbackResponse = serde_json::from_value(raw)?; let invoice = parse_invoice(&callback_response.pr)?; - validate_invoice(&invoice, amount_msats, metadata)?; + validate_invoice(&invoice, amount_msats)?; Ok((invoice.to_string(), callback_response.success_action)) } +/// Prefix used on `fetch_invoice` errors that originate from the LNURL +/// service returning a `{"status":"ERROR"}` body. Callers can match on +/// this prefix to distinguish service-side rejections from transport +/// or parsing failures. +pub const LNURL_SERVICE_ERROR_PREFIX: &str = "LNURL service error: "; + /// Build a callback URL with amount and optional comment query parameters. fn build_callback_url( callback: &str, @@ -117,17 +128,15 @@ fn build_callback_url( url.query_pairs_mut() .append_pair("amount", &amount.to_string()); if let Some(c) = comment { - url.query_pairs_mut().append_pair("comment", c); + if !c.is_empty() { + url.query_pairs_mut().append_pair("comment", c); + } } Ok(url.to_string()) } -/// Validate a BOLT11 invoice against the expected amount and metadata. -fn validate_invoice( - invoice: &Bolt11Invoice, - amount_msats: u64, - metadata: &str, -) -> Result<()> { +/// Validate a BOLT11 invoice against the user-requested amount. +fn validate_invoice(invoice: &Bolt11Invoice, amount_msats: u64) -> Result<()> { ensure!( invoice.amount_milli_satoshis().unwrap_or_default() == amount_msats, "Amount found in invoice was not equal to the amount found in the original request\n\ @@ -135,19 +144,6 @@ fn validate_invoice( amount_msats, invoice.amount_milli_satoshis() ); - - let description_hash: String = match invoice.description() { - Bolt11InvoiceDescriptionRef::Direct(d) => sha256::digest(d.to_string()), - Bolt11InvoiceDescriptionRef::Hash(h) => h.0.to_string(), - }; - - ensure!( - description_hash == sha256::digest(metadata), - "description_hash {} does not match the hash of the metadata {}", - description_hash, - sha256::digest(metadata) - ); - Ok(()) } @@ -289,10 +285,10 @@ mod tests { convert_to_async_return_value(Ok(x)) }); - mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { + mock_http_client.expect_get_json().returning(|_url| { let invoice = "lnbc1u1pjv9qrvsp5e5wwexctzp9yklcrzx448c68q2a7kma55cm67ruajjwfkrswnqvqpp55x6mmz8ch6nahrcuxjsjvs23xkgt8eu748nukq463zhjcjk4s65shp5dd6hc533r655wtyz63jpf6ja08srn6rz6cjhwsjuyckrqwanhjtsxqzjccqpjrzjqw6lfdpjecp4d5t0gxk5khkrzfejjxyxtxg5exqsd95py6rhwwh72rpgrgqq3hcqqgqqqqlgqqqqqqgq9q9qxpqysgq95njz4sz6h7r2qh7txnevcrvg0jdsfpe72cecmjfka8mw5nvm7tydd0j34ps2u9q9h6v5u8h3vxs8jqq5fwehdda6a8qmpn93fm290cquhuc6r"; let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice); - let x = serde_json::from_str(&callback_response_json).unwrap(); + let x: serde_json::Value = serde_json::from_str(&callback_response_json).unwrap(); convert_to_async_return_value(Ok(x)) }); @@ -324,10 +320,10 @@ mod tests { convert_to_async_return_value(Ok(x)) }); - mock_http_client.expect_get_pay_request_callback_response().returning(|_url| { + mock_http_client.expect_get_json().returning(|_url| { let invoice = "lnbcrt1u1pj0ypx6sp5hzczugdw9eyw3fcsjkssux7awjlt68vpj7uhmen7sup0hdlrqxaqpp5gp5fm2sn5rua2jlzftkf5h22rxppwgszs7ncm73pmwhvjcttqp3qdy2tddjyar90p6z7urvv95kug3vyq39xarpwf6zqargv5syxmmfde28yctfdc396tpqtv38getcwshkjer9de6xjenfv4ezytpqyfekzar0wd5xjsrrd9cxsetjwp6ku6ewvdhk6gjat5xqyjw5qcqp29qxpqysgqujuf5zavazln2q9gks7nqwdgjypg2qlvv7aqwfmwg7xmjt8hy4hx2ctr5fcspjvmz9x5wvmur8vh6nkynsvateafm73zwg5hkf7xszsqajqwcf"; let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice); - let x = serde_json::from_str(&callback_response_json).unwrap(); + let x: serde_json::Value = serde_json::from_str(&callback_response_json).unwrap(); convert_to_async_return_value(Ok(x)) }); diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt new file mode 100644 index 000000000..d3384c891 --- /dev/null +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt @@ -0,0 +1,92 @@ +// Instrumented tests for parse_input() LNURL handling. +// Covers LNURL bech32 strings, lightning addresses, prefix handling, +// and error cases. Pure parsing only — no node, no network. + +package com.blockstream.glsdk + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LnurlParseTest { + + // LNURL bech32 encoding of https://service.com/lnurl (LUD-01 example). + private val lnurlBech32 = + "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2" + + // ============================================================ + // LNURL bech32 parsing + // ============================================================ + + @Test + fun parse_lnurl_bech32_uppercase() { + val result = parseInput(lnurlBech32) + assertTrue( + "Expected LnUrl, got $result", + result is InputType.LnUrl, + ) + } + + @Test + fun parse_lnurl_bech32_lowercase() { + val result = parseInput(lnurlBech32.lowercase()) + assertTrue( + "Expected LnUrl, got $result", + result is InputType.LnUrl, + ) + } + + @Test + fun parse_lnurl_with_lightning_prefix() { + val result = parseInput("lightning:$lnurlBech32") + assertTrue( + "Expected LnUrl, got $result", + result is InputType.LnUrl, + ) + } + + @Test(expected = Exception::class) + fun parse_invalid_lnurl_bech32_returns_error() { + parseInput("LNURL1INVALIDDATA") + } + + // ============================================================ + // Lightning address parsing + // ============================================================ + + @Test + fun parse_lightning_address_simple() { + val result = parseInput("user@example.com") + assertTrue( + "Expected LnUrlAddress, got $result", + result is InputType.LnUrlAddress, + ) + assertEquals("user@example.com", (result as InputType.LnUrlAddress).address) + } + + @Test + fun parse_lightning_address_with_symbols() { + val result = parseInput("sat.oshi-99@example.com") + assertTrue( + "Expected LnUrlAddress, got $result", + result is InputType.LnUrlAddress, + ) + } + + @Test(expected = Exception::class) + fun parse_lightning_address_no_dot_in_domain_returns_error() { + parseInput("user@localhost") + } + + @Test(expected = Exception::class) + fun parse_lightning_address_empty_local_part_returns_error() { + parseInput("@example.com") + } + + @Test(expected = Exception::class) + fun parse_lightning_address_empty_domain_returns_error() { + parseInput("user@") + } +} \ No newline at end of file diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index 2b85d5fda..56155146d 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -229,6 +229,9 @@ pub struct LnUrlPayRequest { /// Amount in millisatoshis (i64 for JS) pub amount_msat: i64, pub comment: Option, + /// When true (the default), a URL success action is rejected if its + /// domain differs from the callback's domain. + pub validate_success_action_url: Option, } #[napi(object)] @@ -250,15 +253,23 @@ pub struct LnUrlErrorData { pub reason: String, } +#[napi(object)] +pub struct LnUrlPayErrorData { + pub payment_hash: String, + pub reason: String, +} + /// Result of an LNURL-pay operation. Discriminated by `type` field. #[napi(object)] pub struct LnUrlPayResult { - /// "success" or "error" + /// "success", "error", or "pay_error" pub r#type: String, /// Present when type == "success" pub success: Option, - /// Present when type == "error" + /// Present when type == "error" (LNURL service rejected the request) pub error: Option, + /// Present when type == "pay_error" (invoice fetched but paying it failed) + pub pay_error: Option, } #[napi(object)] @@ -1026,6 +1037,7 @@ fn gl_lnurl_pay_request_from_napi(req: LnUrlPayRequest) -> glsdk::LnUrlPayReques data: gl_pay_request_data_from_napi(req.data), amount_msat: req.amount_msat as u64, comment: req.comment, + validate_success_action_url: req.validate_success_action_url, } } @@ -1075,6 +1087,7 @@ fn napi_lnurl_pay_result_from_gl(result: glsdk::LnUrlPayResult) -> LnUrlPayResul success_action: data.success_action.map(napi_success_action_from_gl), }), error: None, + pay_error: None, }, glsdk::LnUrlPayResult::EndpointError { data } => LnUrlPayResult { r#type: "error".to_string(), @@ -1082,6 +1095,16 @@ fn napi_lnurl_pay_result_from_gl(result: glsdk::LnUrlPayResult) -> LnUrlPayResul error: Some(LnUrlErrorData { reason: data.reason, }), + pay_error: None, + }, + glsdk::LnUrlPayResult::PayError { data } => LnUrlPayResult { + r#type: "pay_error".to_string(), + success: None, + error: None, + pay_error: Some(LnUrlPayErrorData { + payment_hash: data.payment_hash, + reason: data.reason, + }), }, } } diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 63de7d25e..51d2481ab 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -22,6 +22,7 @@ tokio = { version = "1", features = ["sync"] } tonic.workspace = true tracing = { version = "0.1.43", features = ["async-await", "log"] } uniffi = { version = "0.29.4" } +url = "2" [build-dependencies] uniffi = { version = "0.29.4", features = [ "build" ] } diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index e329a1c35..dab3ac875 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -108,7 +108,7 @@ fn schedule_node( .map_err(|e| Error::Other(e.to_string()))?; let handle = signer::Handle::spawn(authenticated_signer); - let node = node::Node::with_signer(credentials, handle)?; + let node = node::Node::with_signer(credentials, handle, network)?; Ok(Arc::new(node)) } @@ -198,7 +198,7 @@ pub fn connect( .map_err(|e| Error::Other(e.to_string()))?; let handle = signer::Handle::spawn(authenticated_signer); - let node = node::Node::with_signer(creds, handle)?; + let node = node::Node::with_signer(creds, handle, network)?; Ok(Arc::new(node)) } diff --git a/libs/gl-sdk/src/lnurl.rs b/libs/gl-sdk/src/lnurl.rs index 1bacfebf2..5ff01ecd3 100644 --- a/libs/gl-sdk/src/lnurl.rs +++ b/libs/gl-sdk/src/lnurl.rs @@ -73,6 +73,15 @@ pub struct LnUrlPayRequest { pub amount_msat: u64, /// Optional comment to send with the payment. pub comment: Option, + /// When true (the default), a URL success action is rejected if its + /// domain differs from the callback's domain. + /// + /// This is a wallet-side safety convention, not a LUD-09 requirement: + /// LUD-09 does not mandate same-domain URLs, but a divergent domain + /// can be used to phish users, so the SDK rejects it by default. + /// Set to `Some(false)` only if you have a specific reason to trust + /// cross-domain success-action URLs from this service. + pub validate_success_action_url: Option, } /// Request to execute an LNURL-withdraw flow. @@ -95,8 +104,10 @@ pub struct LnUrlWithdrawRequest { pub enum LnUrlPayResult { /// Payment succeeded. EndpointSuccess { data: LnUrlPaySuccessData }, - /// The LNURL service returned an error. + /// The LNURL service returned an error before the invoice was paid. EndpointError { data: LnUrlErrorData }, + /// The invoice was fetched successfully but paying it failed. + PayError { data: LnUrlPayErrorData }, } /// Successful LNURL-pay result data. @@ -108,6 +119,15 @@ pub struct LnUrlPaySuccessData { pub success_action: Option, } +/// Details of a failed LNURL-pay attempt on the pay phase. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlPayErrorData { + /// Hex-encoded payment hash of the invoice the service returned. + pub payment_hash: String, + /// Human-readable reason the pay attempt failed. + pub reason: String, +} + /// Result of an LNURL-withdraw operation. #[derive(Clone, uniffi::Enum)] pub enum LnUrlWithdrawResult { diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 5aad632fb..4032641f7 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -19,6 +19,7 @@ pub struct Node { stored_credentials: Option, signer_handle: Option, disconnected: AtomicBool, + network: gl_client::bitcoin::Network, } #[uniffi::export] @@ -41,6 +42,7 @@ impl Node { stored_credentials: Some(credentials.clone()), signer_handle: None, disconnected: AtomicBool::new(false), + network: gl_client::bitcoin::Network::Bitcoin, }) } @@ -513,37 +515,80 @@ impl Node { request: crate::lnurl::LnUrlPayRequest, ) -> Result { self.check_connected()?; + validate_lnurl_pay_input(&request)?; let http_client = gl_client::lnurl::models::LnUrlHttpClearnetClient::new(); // Phase 1: Get invoice from service callback let comment = request.comment.as_deref(); - let (invoice_str, success_action) = exec( + let (invoice_str, success_action) = match exec( gl_client::lnurl::pay::fetch_invoice( &http_client, &request.data.callback, request.amount_msat, - &request.data.metadata, comment, ), - ) - .map_err(|e| Error::Other(e.to_string()))?; + ) { + Ok(v) => v, + Err(e) => { + let msg = e.to_string(); + let reason = msg + .strip_prefix(gl_client::lnurl::pay::LNURL_SERVICE_ERROR_PREFIX) + .unwrap_or(&msg) + .to_string(); + return Ok(crate::lnurl::LnUrlPayResult::EndpointError { + data: crate::lnurl::LnUrlErrorData { reason }, + }); + } + }; + + if let Some(reason) = invoice_network_mismatch(&invoice_str, self.network) { + return Ok(crate::lnurl::LnUrlPayResult::EndpointError { + data: crate::lnurl::LnUrlErrorData { reason }, + }); + } // Phase 2: Pay the invoice let mut cln_client = exec(self.get_cln_client())?.clone(); - let pay_response = exec(cln_client.pay(clnpb::PayRequest { - bolt11: invoice_str, + let pay_response = match exec(cln_client.pay(clnpb::PayRequest { + bolt11: invoice_str.clone(), ..Default::default() - })) - .map_err(|e| Error::Rpc(e.to_string()))? - .into_inner(); + })) { + Ok(r) => r.into_inner(), + Err(e) => { + let payment_hash = invoice_str + .parse::() + .ok() + .map(|inv| inv.payment_hash().to_string()) + .unwrap_or_default(); + return Ok(crate::lnurl::LnUrlPayResult::PayError { + data: crate::lnurl::LnUrlPayErrorData { + payment_hash, + reason: e.to_string(), + }, + }); + } + }; // Phase 3: Process success action if present + let validate_url = request.validate_success_action_url.unwrap_or(true); let processed_action = match success_action { Some(action) => { let processed = action .process(&pay_response.payment_preimage) .map_err(|e| Error::Other(e.to_string()))?; + if validate_url { + if let gl_client::lnurl::models::ProcessedSuccessAction::Url { + url, .. + } = &processed + { + if let Some(reason) = + url_action_domain_mismatch(&request.data.callback, url) + { + return Err(Error::Other(reason)); + } + } + } Some(processed.into()) } None => None, @@ -608,6 +653,83 @@ impl Node { } } +/// Returns a human-readable reason if the invoice's BOLT-11 currency +/// prefix does not match the node's configured network. +/// +/// Not a LUD-06 requirement; this is a wallet-side safety check that +/// prevents attempting to pay e.g. a testnet invoice from a mainnet +/// wallet. The payment would fail at the node layer regardless, but +/// this surfaces a clean error earlier. +fn invoice_network_mismatch( + invoice_str: &str, + node_network: gl_client::bitcoin::Network, +) -> Option { + use lightning_invoice::Currency; + let invoice = invoice_str.parse::().ok()?; + let expected = match node_network { + gl_client::bitcoin::Network::Bitcoin => Currency::Bitcoin, + gl_client::bitcoin::Network::Testnet => Currency::BitcoinTestnet, + gl_client::bitcoin::Network::Signet => Currency::Signet, + gl_client::bitcoin::Network::Regtest => Currency::Regtest, + _ => return None, + }; + if invoice.currency() == expected { + None + } else { + Some(format!( + "invoice is for {:?}, but this node is on {:?}", + invoice.currency(), + node_network + )) + } +} + +fn url_action_domain_mismatch(callback_url: &str, action_url: &str) -> Option { + let cb = url::Url::parse(callback_url).ok()?; + let action = url::Url::parse(action_url).ok()?; + let cb_domain = cb.domain()?; + let action_domain = action.domain()?; + if cb_domain == action_domain { + None + } else { + Some(format!( + "success action URL domain ({}) does not match the callback domain ({})", + action_domain, cb_domain + )) + } +} + +fn validate_lnurl_pay_input(request: &crate::lnurl::LnUrlPayRequest) -> Result<(), Error> { + let data = &request.data; + if request.amount_msat < data.min_sendable { + return Err(Error::Other(format!( + "amount_msat {} is below the service's min_sendable ({})", + request.amount_msat, data.min_sendable + ))); + } + if request.amount_msat > data.max_sendable { + return Err(Error::Other(format!( + "amount_msat {} is above the service's max_sendable ({})", + request.amount_msat, data.max_sendable + ))); + } + if let Some(comment) = request.comment.as_deref() { + if data.comment_allowed == 0 && !comment.is_empty() { + return Err(Error::Other( + "this LNURL service does not accept comments".to_string(), + )); + } + if (comment.len() as u64) > data.comment_allowed { + return Err(Error::Other(format!( + "comment length {} exceeds the service's comment_allowed ({})", + comment.len(), + data.comment_allowed + ))); + } + } + Ok(()) +} + // Not exported through uniffi impl Node { fn check_connected(&self) -> Result<(), Error> { @@ -622,6 +744,7 @@ impl Node { pub(crate) fn with_signer( credentials: Credentials, handle: Handle, + network: gl_client::bitcoin::Network, ) -> Result { let node_id = credentials .inner @@ -639,6 +762,7 @@ impl Node { stored_credentials: Some(credentials), signer_handle: Some(handle), disconnected: AtomicBool::new(false), + network, }) } From 24e80323bdadf56afa3d049fe182ccae8867b056 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Mon, 27 Apr 2026 00:47:07 +0300 Subject: [PATCH 12/13] gl-sdk: Make parse_input async and absorb LNURL resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_input is now a single async entry point that resolves LNURL bech32 strings and Lightning Addresses end-to-end over HTTP, returning typed pay or withdraw request data. BOLT11 invoices and node IDs still resolve without I/O. Mobile callers get one async call, one error path, one loading state. InputType variants are now Bolt11, NodeId, LnUrlPay, LnUrlWithdraw — the intermediate LnUrl / LnUrlAddress states are gone from the public surface. Node::resolve_lnurl and the ResolvedLnUrl enum are removed; callers obtain LnUrlPayRequestData / LnUrlWithdrawRequestData from parse_input and pass them to Node::lnurl_pay / Node::lnurl_withdraw. Also adds the parse_input async wrapper to the napi (Node.js) binding so JS callers regain LNURL resolution after Node::resolve_lnurl removal, plus a jest spec covering BOLT11 / NodeId pass-through and error-before-HTTP cases. Tests updated: gl-sdk Rust unit tests cover the network-free paths; gl-sdk Python integration tests in test_lnurl.py and test_parse_input.py exercise the LNURL service fixture; gl-sdk-android tests run under runBlocking against the suspend fun parseInput. Python bindings regenerated. --- Cargo.lock | 14 + .../com/blockstream/glsdk/LnurlParseTest.kt | 79 +- .../com/blockstream/glsdk/ParseInputTest.kt | 41 +- libs/gl-sdk-napi/package-lock.json | 4 +- libs/gl-sdk-napi/src/lib.rs | 146 +- libs/gl-sdk-napi/tests/parse-input.spec.ts | 81 + libs/gl-sdk/CHANGELOG.md | 9 + libs/gl-sdk/Cargo.toml | 2 +- libs/gl-sdk/glsdk/glsdk.py | 1928 +++++++++++++++-- libs/gl-sdk/src/input.rs | 256 ++- libs/gl-sdk/src/lib.rs | 16 +- libs/gl-sdk/src/lnurl.rs | 32 +- libs/gl-sdk/src/node.rs | 58 +- libs/gl-sdk/tests/test_lnurl.py | 116 +- libs/gl-sdk/tests/test_parse_input.py | 64 +- 15 files changed, 2239 insertions(+), 607 deletions(-) create mode 100644 libs/gl-sdk-napi/tests/parse-input.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 3e4c145b9..98aa5e193 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,6 +252,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -4781,6 +4794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt index d3384c891..0c3ddf431 100644 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt @@ -1,92 +1,37 @@ -// Instrumented tests for parse_input() LNURL handling. -// Covers LNURL bech32 strings, lightning addresses, prefix handling, -// and error cases. Pure parsing only — no node, no network. +// Instrumented tests for parse_input() error-before-HTTP cases on +// LNURL / Lightning Address inputs. +// +// Successful resolution requires a reachable LNURL service and is +// covered by gl-testing integration tests, not by Android instrumented +// tests. package com.blockstream.glsdk import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.* +import kotlinx.coroutines.runBlocking import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LnurlParseTest { - // LNURL bech32 encoding of https://service.com/lnurl (LUD-01 example). - private val lnurlBech32 = - "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2" - - // ============================================================ - // LNURL bech32 parsing - // ============================================================ - - @Test - fun parse_lnurl_bech32_uppercase() { - val result = parseInput(lnurlBech32) - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - - @Test - fun parse_lnurl_bech32_lowercase() { - val result = parseInput(lnurlBech32.lowercase()) - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - - @Test - fun parse_lnurl_with_lightning_prefix() { - val result = parseInput("lightning:$lnurlBech32") - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - @Test(expected = Exception::class) - fun parse_invalid_lnurl_bech32_returns_error() { + fun parse_invalid_lnurl_bech32_returns_error(): Unit = runBlocking { parseInput("LNURL1INVALIDDATA") } - // ============================================================ - // Lightning address parsing - // ============================================================ - - @Test - fun parse_lightning_address_simple() { - val result = parseInput("user@example.com") - assertTrue( - "Expected LnUrlAddress, got $result", - result is InputType.LnUrlAddress, - ) - assertEquals("user@example.com", (result as InputType.LnUrlAddress).address) - } - - @Test - fun parse_lightning_address_with_symbols() { - val result = parseInput("sat.oshi-99@example.com") - assertTrue( - "Expected LnUrlAddress, got $result", - result is InputType.LnUrlAddress, - ) - } - @Test(expected = Exception::class) - fun parse_lightning_address_no_dot_in_domain_returns_error() { + fun parse_lightning_address_no_dot_in_domain_returns_error(): Unit = runBlocking { parseInput("user@localhost") } @Test(expected = Exception::class) - fun parse_lightning_address_empty_local_part_returns_error() { + fun parse_lightning_address_empty_local_part_returns_error(): Unit = runBlocking { parseInput("@example.com") } @Test(expected = Exception::class) - fun parse_lightning_address_empty_domain_returns_error() { + fun parse_lightning_address_empty_domain_returns_error(): Unit = runBlocking { parseInput("user@") } -} \ No newline at end of file +} diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt index eb27f57d0..838a79b06 100644 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt @@ -1,9 +1,12 @@ // Instrumented tests for parse_input(). -// Tests BOLT11 invoice parsing, node ID parsing, and error cases. +// Tests BOLT11 invoice parsing, node ID parsing, and error cases that +// resolve without HTTP. LNURL / Lightning Address paths are exercised +// in gl-testing integration tests against a live LNURL fixture. package com.blockstream.glsdk import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @@ -27,18 +30,21 @@ class ParseInputTest { // ============================================================ @Test - fun parse_valid_node_id() { + fun parse_valid_node_id() = runBlocking { val result = parseInput(validNodeId) - assertNotNull(result) + assertTrue( + "Expected NodeId, got $result", + result is InputType.NodeId, + ) } @Test(expected = Exception::class) - fun parse_invalid_hex_returns_error() { + fun parse_invalid_hex_returns_error(): Unit = runBlocking { parseInput("not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx") } @Test(expected = Exception::class) - fun parse_wrong_prefix_returns_error() { + fun parse_wrong_prefix_returns_error(): Unit = runBlocking { parseInput("04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619") } @@ -47,21 +53,30 @@ class ParseInputTest { // ============================================================ @Test - fun parse_valid_bolt11() { + fun parse_valid_bolt11() = runBlocking { val result = parseInput(bolt11Invoice) - assertNotNull(result) + assertTrue( + "Expected Bolt11, got $result", + result is InputType.Bolt11, + ) } @Test - fun parse_bolt11_with_lightning_prefix() { + fun parse_bolt11_with_lightning_prefix() = runBlocking { val result = parseInput("lightning:$bolt11Invoice") - assertNotNull(result) + assertTrue( + "Expected Bolt11, got $result", + result is InputType.Bolt11, + ) } @Test - fun parse_bolt11_with_uppercase_prefix() { + fun parse_bolt11_with_uppercase_prefix() = runBlocking { val result = parseInput("LIGHTNING:$bolt11Invoice") - assertNotNull(result) + assertTrue( + "Expected Bolt11, got $result", + result is InputType.Bolt11, + ) } // ============================================================ @@ -69,12 +84,12 @@ class ParseInputTest { // ============================================================ @Test(expected = Exception::class) - fun parse_empty_string_returns_error() { + fun parse_empty_string_returns_error(): Unit = runBlocking { parseInput("") } @Test(expected = Exception::class) - fun parse_garbage_returns_error() { + fun parse_garbage_returns_error(): Unit = runBlocking { parseInput("hello world") } } diff --git a/libs/gl-sdk-napi/package-lock.json b/libs/gl-sdk-napi/package-lock.json index a64414f10..573bf5939 100644 --- a/libs/gl-sdk-napi/package-lock.json +++ b/libs/gl-sdk-napi/package-lock.json @@ -1,12 +1,12 @@ { "name": "@greenlightcln/glsdk", - "version": "0.0.3", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@greenlightcln/glsdk", - "version": "0.0.3", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@jest/globals": "^30.2.0", diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index 56155146d..ac055b0e3 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -10,11 +10,13 @@ use glsdk::{ Credentials as GlCredentials, DeveloperCert as GlDeveloperCert, Handle as GlHandle, + InputType as GlInputType, Network as GlNetwork, Node as GlNode, NodeEvent as GlNodeEvent, NodeEventStream as GlNodeEventStream, OutputStatus as GlOutputStatus, + ParsedInvoice as GlParsedInvoice, Scheduler as GlScheduler, Signer as GlSigner, }; @@ -183,6 +185,41 @@ pub struct FundChannel { pub channel_id: Option, } +// ============================================================================ +// Input Parsing Types +// ============================================================================ + +#[napi(object)] +pub struct ParsedInvoice { + pub bolt11: String, + pub payee_pubkey: Option, + pub payment_hash: Buffer, + pub description: Option, + /// Amount in millisatoshis (i64 for JS), `None` for any-amount invoices. + pub amount_msat: Option, + /// Seconds from creation until the invoice expires. + pub expiry: i64, + /// Unix timestamp (seconds) when the invoice was created. + pub timestamp: i64, +} + +/// Parsed input. Discriminated by `type` field. Exactly one of the +/// variant fields (`bolt11`, `node_id`, `lnurl_pay`, `lnurl_withdraw`) +/// is populated based on the discriminant. +#[napi(object)] +pub struct InputType { + /// "bolt11" | "node_id" | "lnurl_pay" | "lnurl_withdraw" + pub r#type: String, + /// Present when type == "bolt11" + pub bolt11: Option, + /// Present when type == "node_id" + pub node_id: Option, + /// Present when type == "lnurl_pay" + pub lnurl_pay: Option, + /// Present when type == "lnurl_withdraw" + pub lnurl_withdraw: Option, +} + // ============================================================================ // LNURL Types // ============================================================================ @@ -212,17 +249,6 @@ pub struct LnUrlWithdrawRequestData { pub lnurl: String, } -/// Result of resolving an LNURL. Discriminated by `type` field. -#[napi(object)] -pub struct ResolvedLnUrl { - /// "pay" or "withdraw" - pub r#type: String, - /// Present when type == "pay" - pub pay: Option, - /// Present when type == "withdraw" - pub withdraw: Option, -} - #[napi(object)] pub struct LnUrlPayRequest { pub data: LnUrlPayRequestData, @@ -855,28 +881,10 @@ impl Node { // ── LNURL methods ─────────────────────────────────────────── - /// Resolve an LNURL or Lightning Address to its endpoint data. - /// - /// Accepts an LNURL bech32 string, a decoded URL, or a Lightning - /// Address (user@domain). - #[napi] - pub async fn resolve_lnurl(&self, input: String) -> Result { - let inner = self.inner.clone(); - let resolved = tokio::task::spawn_blocking(move || { - inner - .resolve_lnurl(input) - .map_err(|e| Error::from_reason(e.to_string())) - }) - .await - .map_err(|e| Error::from_reason(e.to_string()))??; - - Ok(napi_resolved_lnurl_from_gl(resolved)) - } - /// Execute an LNURL-pay flow. /// - /// Call `resolve_lnurl()` first, then pass the pay data with a - /// chosen amount. + /// Build the request from `LnUrlPayRequestData` (obtained out of + /// band) and a chosen amount. #[napi] pub async fn lnurl_pay(&self, request: LnUrlPayRequest) -> Result { let inner = self.inner.clone(); @@ -894,8 +902,8 @@ impl Node { /// Execute an LNURL-withdraw flow. /// - /// Call `resolve_lnurl()` first, then pass the withdraw data with - /// a chosen amount. + /// Build the request from `LnUrlWithdrawRequestData` (obtained out + /// of band) and a chosen amount. #[napi] pub async fn lnurl_withdraw(&self, request: LnUrlWithdrawRequest) -> Result { let inner = self.inner.clone(); @@ -964,9 +972,21 @@ fn output_status_to_string(status: &GlOutputStatus) -> String { } // ============================================================================ -// LNURL Conversion Helpers +// Input Parsing Conversion Helpers // ============================================================================ +fn napi_parsed_invoice_from_gl(invoice: GlParsedInvoice) -> ParsedInvoice { + ParsedInvoice { + bolt11: invoice.bolt11, + payee_pubkey: invoice.payee_pubkey.map(Buffer::from), + payment_hash: Buffer::from(invoice.payment_hash), + description: invoice.description, + amount_msat: invoice.amount_msat.map(|v| v as i64), + expiry: invoice.expiry as i64, + timestamp: invoice.timestamp as i64, + } +} + fn napi_pay_request_data_from_gl(data: glsdk::LnUrlPayRequestData) -> LnUrlPayRequestData { LnUrlPayRequestData { callback: data.callback, @@ -992,21 +1012,59 @@ fn napi_withdraw_request_data_from_gl( } } -fn napi_resolved_lnurl_from_gl(resolved: glsdk::ResolvedLnUrl) -> ResolvedLnUrl { - match resolved { - glsdk::ResolvedLnUrl::Pay { data } => ResolvedLnUrl { - r#type: "pay".to_string(), - pay: Some(napi_pay_request_data_from_gl(data)), - withdraw: None, +fn napi_input_type_from_gl(input: GlInputType) -> InputType { + match input { + GlInputType::Bolt11 { invoice } => InputType { + r#type: "bolt11".to_string(), + bolt11: Some(napi_parsed_invoice_from_gl(invoice)), + node_id: None, + lnurl_pay: None, + lnurl_withdraw: None, }, - glsdk::ResolvedLnUrl::Withdraw { data } => ResolvedLnUrl { - r#type: "withdraw".to_string(), - pay: None, - withdraw: Some(napi_withdraw_request_data_from_gl(data)), + GlInputType::NodeId { node_id } => InputType { + r#type: "node_id".to_string(), + bolt11: None, + node_id: Some(node_id), + lnurl_pay: None, + lnurl_withdraw: None, + }, + GlInputType::LnUrlPay { data } => InputType { + r#type: "lnurl_pay".to_string(), + bolt11: None, + node_id: None, + lnurl_pay: Some(napi_pay_request_data_from_gl(data)), + lnurl_withdraw: None, + }, + GlInputType::LnUrlWithdraw { data } => InputType { + r#type: "lnurl_withdraw".to_string(), + bolt11: None, + node_id: None, + lnurl_pay: None, + lnurl_withdraw: Some(napi_withdraw_request_data_from_gl(data)), }, } } +/// Parse and resolve any supported input. +/// +/// For LNURL bech32 strings and Lightning Addresses this performs the +/// HTTP GET to the LNURL endpoint and returns typed pay or withdraw +/// request data. For BOLT11 invoices and node IDs it returns +/// immediately without I/O. +/// +/// Strips `lightning:` / `LIGHTNING:` prefixes automatically. +#[napi] +pub async fn parse_input(input: String) -> Result { + let resolved = glsdk::parse_input(input) + .await + .map_err(|e| Error::from_reason(e.to_string()))?; + Ok(napi_input_type_from_gl(resolved)) +} + +// ============================================================================ +// LNURL Conversion Helpers +// ============================================================================ + fn gl_pay_request_data_from_napi(data: LnUrlPayRequestData) -> glsdk::LnUrlPayRequestData { glsdk::LnUrlPayRequestData { callback: data.callback, diff --git a/libs/gl-sdk-napi/tests/parse-input.spec.ts b/libs/gl-sdk-napi/tests/parse-input.spec.ts new file mode 100644 index 000000000..f924fce58 --- /dev/null +++ b/libs/gl-sdk-napi/tests/parse-input.spec.ts @@ -0,0 +1,81 @@ +import { parseInput } from '../index.js'; + +const VALID_NODE_ID = + '02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'; + +const BOLT11_INVOICE = + 'lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhm' + + 'nsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhh' + + 'd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy' + + '22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz'; + +describe('parseInput', () => { + describe('BOLT11 invoices (no HTTP)', () => { + it('classifies a valid BOLT11 invoice', async () => { + const result = await parseInput(BOLT11_INVOICE); + expect(result.type).toBe('bolt11'); + expect(result.bolt11).toBeDefined(); + expect(result.bolt11!.bolt11).toBe(BOLT11_INVOICE); + }); + + it('strips a lowercase lightning: prefix', async () => { + const result = await parseInput(`lightning:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + }); + + it('strips an uppercase LIGHTNING: prefix', async () => { + const result = await parseInput(`LIGHTNING:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + }); + }); + + describe('node IDs (no HTTP)', () => { + it('classifies a valid compressed pubkey', async () => { + const result = await parseInput(VALID_NODE_ID); + expect(result.type).toBe('node_id'); + expect(result.nodeId).toBe(VALID_NODE_ID); + }); + + it('rejects a 66-char string that is not valid hex', async () => { + await expect( + parseInput('not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx'), + ).rejects.toThrow(); + }); + + it('rejects an uncompressed (0x04) pubkey', async () => { + await expect( + parseInput('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + ).rejects.toThrow(); + }); + }); + + describe('error cases that resolve before HTTP', () => { + it('rejects empty input', async () => { + await expect(parseInput('')).rejects.toThrow(); + }); + + it('rejects whitespace-only input', async () => { + await expect(parseInput(' ')).rejects.toThrow(); + }); + + it('rejects unrecognized garbage', async () => { + await expect(parseInput('hello world')).rejects.toThrow(); + }); + + it('rejects an invalid LNURL bech32 string before HTTP', async () => { + await expect(parseInput('LNURL1INVALIDDATA')).rejects.toThrow(); + }); + + it('rejects a malformed Lightning Address (no dot in domain)', async () => { + await expect(parseInput('user@localhost')).rejects.toThrow(); + }); + + it('rejects an empty local-part Lightning Address', async () => { + await expect(parseInput('@example.com')).rejects.toThrow(); + }); + + it('rejects an empty domain Lightning Address', async () => { + await expect(parseInput('user@')).rejects.toThrow(); + }); + }); +}); diff --git a/libs/gl-sdk/CHANGELOG.md b/libs/gl-sdk/CHANGELOG.md index 26b1fab85..43a7a21a3 100644 --- a/libs/gl-sdk/CHANGELOG.md +++ b/libs/gl-sdk/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Changed + +- `parse_input()` is now `async` and resolves LNURL bech32 strings and Lightning Addresses end-to-end over HTTP, returning typed pay or withdraw request data. BOLT11 invoices and node IDs still resolve without I/O. +- `InputType` variants now: `Bolt11`, `NodeId`, `LnUrlPay`, `LnUrlWithdraw`. Replaces the previous `LnUrl` / `LnUrlAddress` intermediate-state variants. + +### Removed + +- `Node::resolve_lnurl()` and the `ResolvedLnUrl` enum. Use `parse_input()` to obtain `LnUrlPayRequestData` / `LnUrlWithdrawRequestData` directly, then call `Node::lnurl_pay()` / `Node::lnurl_withdraw()`. + ## [0.2.0] - 2026-04-02 ### Added diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 51d2481ab..1f2830b80 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -21,7 +21,7 @@ thiserror = "2.0.17" tokio = { version = "1", features = ["sync"] } tonic.workspace = true tracing = { version = "0.1.43", features = ["async-await", "log"] } -uniffi = { version = "0.29.4" } +uniffi = { version = "0.29.4", features = ["tokio"] } url = "2" [build-dependencies] diff --git a/libs/gl-sdk/glsdk/glsdk.py b/libs/gl-sdk/glsdk/glsdk.py index 72227636e..9bf67f437 100644 --- a/libs/gl-sdk/glsdk/glsdk.py +++ b/libs/gl-sdk/glsdk/glsdk.py @@ -27,6 +27,7 @@ import itertools import traceback import typing +import asyncio import platform # Used for default argument values @@ -462,6 +463,8 @@ def _uniffi_check_contract_api_version(lib): def _uniffi_check_api_checksums(lib): if lib.uniffi_glsdk_checksum_func_connect() != 43555: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_parse_input() != 43159: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_recover() != 39257: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_register() != 39628: @@ -494,9 +497,13 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_list_peers() != 29567: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_onchain_receive() != 57530: + if lib.uniffi_glsdk_checksum_method_node_lnurl_pay() != 61306: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_lnurl_withdraw() != 61467: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_onchain_send() != 44346: + if lib.uniffi_glsdk_checksum_method_node_onchain_receive() != 46432: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_onchain_send() != 12590: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_receive() != 39761: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -791,6 +798,18 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_list_peers.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_pay.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_pay.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_withdraw.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_withdraw.restype = _UniffiRustBuffer _UniffiLib.uniffi_glsdk_fn_method_node_onchain_receive.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -920,6 +939,10 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_func_connect.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_func_parse_input.argtypes = ( + _UniffiRustBuffer, +) +_UniffiLib.uniffi_glsdk_fn_func_parse_input.restype = ctypes.c_uint64 _UniffiLib.uniffi_glsdk_fn_func_recover.argtypes = ( _UniffiRustBuffer, ctypes.c_void_p, @@ -1211,6 +1234,9 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_func_connect.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_connect.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_func_parse_input.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_parse_input.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_func_recover.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_recover.restype = ctypes.c_uint16 @@ -1259,6 +1285,12 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_list_peers.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_list_peers.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_pay.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_pay.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_withdraw.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_withdraw.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_receive.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_onchain_receive.restype = ctypes.c_uint16 @@ -2127,261 +2159,901 @@ def write(value, buf): _UniffiConverterSequenceTypePeer.write(value.peers, buf) -class OnchainReceiveResponse: - bech32: "str" - p2tr: "str" - def __init__(self, *, bech32: "str", p2tr: "str"): - self.bech32 = bech32 - self.p2tr = p2tr +class LnUrlErrorData: + """ + Error returned by an LNURL service endpoint. + """ + + reason: "str" + def __init__(self, *, reason: "str"): + self.reason = reason def __str__(self): - return "OnchainReceiveResponse(bech32={}, p2tr={})".format(self.bech32, self.p2tr) + return "LnUrlErrorData(reason={})".format(self.reason) def __eq__(self, other): - if self.bech32 != other.bech32: - return False - if self.p2tr != other.p2tr: + if self.reason != other.reason: return False return True -class _UniffiConverterTypeOnchainReceiveResponse(_UniffiConverterRustBuffer): +class _UniffiConverterTypeLnUrlErrorData(_UniffiConverterRustBuffer): @staticmethod def read(buf): - return OnchainReceiveResponse( - bech32=_UniffiConverterString.read(buf), - p2tr=_UniffiConverterString.read(buf), + return LnUrlErrorData( + reason=_UniffiConverterString.read(buf), ) @staticmethod def check_lower(value): - _UniffiConverterString.check_lower(value.bech32) - _UniffiConverterString.check_lower(value.p2tr) + _UniffiConverterString.check_lower(value.reason) @staticmethod def write(value, buf): - _UniffiConverterString.write(value.bech32, buf) - _UniffiConverterString.write(value.p2tr, buf) + _UniffiConverterString.write(value.reason, buf) -class OnchainSendResponse: - tx: "bytes" - txid: "bytes" - psbt: "str" - def __init__(self, *, tx: "bytes", txid: "bytes", psbt: "str"): - self.tx = tx - self.txid = txid - self.psbt = psbt +class LnUrlPayErrorData: + """ + Details of a failed LNURL-pay attempt on the pay phase. + """ + + payment_hash: "str" + """ + Hex-encoded payment hash of the invoice the service returned. + """ + + reason: "str" + """ + Human-readable reason the pay attempt failed. + """ + + def __init__(self, *, payment_hash: "str", reason: "str"): + self.payment_hash = payment_hash + self.reason = reason def __str__(self): - return "OnchainSendResponse(tx={}, txid={}, psbt={})".format(self.tx, self.txid, self.psbt) + return "LnUrlPayErrorData(payment_hash={}, reason={})".format(self.payment_hash, self.reason) def __eq__(self, other): - if self.tx != other.tx: - return False - if self.txid != other.txid: + if self.payment_hash != other.payment_hash: return False - if self.psbt != other.psbt: + if self.reason != other.reason: return False return True -class _UniffiConverterTypeOnchainSendResponse(_UniffiConverterRustBuffer): +class _UniffiConverterTypeLnUrlPayErrorData(_UniffiConverterRustBuffer): @staticmethod def read(buf): - return OnchainSendResponse( - tx=_UniffiConverterBytes.read(buf), - txid=_UniffiConverterBytes.read(buf), - psbt=_UniffiConverterString.read(buf), + return LnUrlPayErrorData( + payment_hash=_UniffiConverterString.read(buf), + reason=_UniffiConverterString.read(buf), ) @staticmethod def check_lower(value): - _UniffiConverterBytes.check_lower(value.tx) - _UniffiConverterBytes.check_lower(value.txid) - _UniffiConverterString.check_lower(value.psbt) + _UniffiConverterString.check_lower(value.payment_hash) + _UniffiConverterString.check_lower(value.reason) @staticmethod def write(value, buf): - _UniffiConverterBytes.write(value.tx, buf) - _UniffiConverterBytes.write(value.txid, buf) - _UniffiConverterString.write(value.psbt, buf) + _UniffiConverterString.write(value.payment_hash, buf) + _UniffiConverterString.write(value.reason, buf) -class Pay: - payment_hash: "bytes" - status: "PayStatus" - destination_pubkey: "typing.Optional[bytes]" - amount_msat: "typing.Optional[int]" - amount_sent_msat: "typing.Optional[int]" - label: "typing.Optional[str]" - bolt11: "typing.Optional[str]" - description: "typing.Optional[str]" - bolt12: "typing.Optional[str]" - preimage: "typing.Optional[bytes]" - created_at: "int" - completed_at: "typing.Optional[int]" - number_of_parts: "typing.Optional[int]" - def __init__(self, *, payment_hash: "bytes", status: "PayStatus", destination_pubkey: "typing.Optional[bytes]", amount_msat: "typing.Optional[int]", amount_sent_msat: "typing.Optional[int]", label: "typing.Optional[str]", bolt11: "typing.Optional[str]", description: "typing.Optional[str]", bolt12: "typing.Optional[str]", preimage: "typing.Optional[bytes]", created_at: "int", completed_at: "typing.Optional[int]", number_of_parts: "typing.Optional[int]"): - self.payment_hash = payment_hash - self.status = status - self.destination_pubkey = destination_pubkey +class LnUrlPayRequest: + """ + Request to execute an LNURL-pay flow. + + Combines the resolved service data with the user's chosen amount. + """ + + data: "LnUrlPayRequestData" + """ + The resolved pay request data from `parse_input()`. + """ + + amount_msat: "int" + """ + Amount to pay in millisatoshis. + """ + + comment: "typing.Optional[str]" + """ + Optional comment to send with the payment. + """ + + validate_success_action_url: "typing.Optional[bool]" + """ + When true (the default), a URL success action is rejected if its + domain differs from the callback's domain. + + This is a wallet-side safety convention, not a LUD-09 requirement: + LUD-09 does not mandate same-domain URLs, but a divergent domain + can be used to phish users, so the SDK rejects it by default. + Set to `Some(false)` only if you have a specific reason to trust + cross-domain success-action URLs from this service. + """ + + def __init__(self, *, data: "LnUrlPayRequestData", amount_msat: "int", comment: "typing.Optional[str]", validate_success_action_url: "typing.Optional[bool]"): + self.data = data self.amount_msat = amount_msat - self.amount_sent_msat = amount_sent_msat - self.label = label - self.bolt11 = bolt11 - self.description = description - self.bolt12 = bolt12 - self.preimage = preimage - self.created_at = created_at - self.completed_at = completed_at - self.number_of_parts = number_of_parts + self.comment = comment + self.validate_success_action_url = validate_success_action_url def __str__(self): - return "Pay(payment_hash={}, status={}, destination_pubkey={}, amount_msat={}, amount_sent_msat={}, label={}, bolt11={}, description={}, bolt12={}, preimage={}, created_at={}, completed_at={}, number_of_parts={})".format(self.payment_hash, self.status, self.destination_pubkey, self.amount_msat, self.amount_sent_msat, self.label, self.bolt11, self.description, self.bolt12, self.preimage, self.created_at, self.completed_at, self.number_of_parts) + return "LnUrlPayRequest(data={}, amount_msat={}, comment={}, validate_success_action_url={})".format(self.data, self.amount_msat, self.comment, self.validate_success_action_url) def __eq__(self, other): - if self.payment_hash != other.payment_hash: - return False - if self.status != other.status: - return False - if self.destination_pubkey != other.destination_pubkey: + if self.data != other.data: return False if self.amount_msat != other.amount_msat: return False - if self.amount_sent_msat != other.amount_sent_msat: - return False - if self.label != other.label: - return False - if self.bolt11 != other.bolt11: - return False - if self.description != other.description: - return False - if self.bolt12 != other.bolt12: - return False - if self.preimage != other.preimage: - return False - if self.created_at != other.created_at: - return False - if self.completed_at != other.completed_at: + if self.comment != other.comment: return False - if self.number_of_parts != other.number_of_parts: + if self.validate_success_action_url != other.validate_success_action_url: return False return True -class _UniffiConverterTypePay(_UniffiConverterRustBuffer): +class _UniffiConverterTypeLnUrlPayRequest(_UniffiConverterRustBuffer): @staticmethod def read(buf): - return Pay( - payment_hash=_UniffiConverterBytes.read(buf), - status=_UniffiConverterTypePayStatus.read(buf), - destination_pubkey=_UniffiConverterOptionalBytes.read(buf), - amount_msat=_UniffiConverterOptionalUInt64.read(buf), - amount_sent_msat=_UniffiConverterOptionalUInt64.read(buf), - label=_UniffiConverterOptionalString.read(buf), - bolt11=_UniffiConverterOptionalString.read(buf), - description=_UniffiConverterOptionalString.read(buf), - bolt12=_UniffiConverterOptionalString.read(buf), - preimage=_UniffiConverterOptionalBytes.read(buf), - created_at=_UniffiConverterUInt64.read(buf), - completed_at=_UniffiConverterOptionalUInt64.read(buf), - number_of_parts=_UniffiConverterOptionalUInt64.read(buf), + return LnUrlPayRequest( + data=_UniffiConverterTypeLnUrlPayRequestData.read(buf), + amount_msat=_UniffiConverterUInt64.read(buf), + comment=_UniffiConverterOptionalString.read(buf), + validate_success_action_url=_UniffiConverterOptionalBool.read(buf), ) @staticmethod def check_lower(value): - _UniffiConverterBytes.check_lower(value.payment_hash) - _UniffiConverterTypePayStatus.check_lower(value.status) - _UniffiConverterOptionalBytes.check_lower(value.destination_pubkey) - _UniffiConverterOptionalUInt64.check_lower(value.amount_msat) - _UniffiConverterOptionalUInt64.check_lower(value.amount_sent_msat) - _UniffiConverterOptionalString.check_lower(value.label) - _UniffiConverterOptionalString.check_lower(value.bolt11) - _UniffiConverterOptionalString.check_lower(value.description) - _UniffiConverterOptionalString.check_lower(value.bolt12) - _UniffiConverterOptionalBytes.check_lower(value.preimage) - _UniffiConverterUInt64.check_lower(value.created_at) - _UniffiConverterOptionalUInt64.check_lower(value.completed_at) - _UniffiConverterOptionalUInt64.check_lower(value.number_of_parts) + _UniffiConverterTypeLnUrlPayRequestData.check_lower(value.data) + _UniffiConverterUInt64.check_lower(value.amount_msat) + _UniffiConverterOptionalString.check_lower(value.comment) + _UniffiConverterOptionalBool.check_lower(value.validate_success_action_url) @staticmethod def write(value, buf): - _UniffiConverterBytes.write(value.payment_hash, buf) - _UniffiConverterTypePayStatus.write(value.status, buf) - _UniffiConverterOptionalBytes.write(value.destination_pubkey, buf) - _UniffiConverterOptionalUInt64.write(value.amount_msat, buf) - _UniffiConverterOptionalUInt64.write(value.amount_sent_msat, buf) - _UniffiConverterOptionalString.write(value.label, buf) - _UniffiConverterOptionalString.write(value.bolt11, buf) - _UniffiConverterOptionalString.write(value.description, buf) - _UniffiConverterOptionalString.write(value.bolt12, buf) - _UniffiConverterOptionalBytes.write(value.preimage, buf) - _UniffiConverterUInt64.write(value.created_at, buf) - _UniffiConverterOptionalUInt64.write(value.completed_at, buf) - _UniffiConverterOptionalUInt64.write(value.number_of_parts, buf) + _UniffiConverterTypeLnUrlPayRequestData.write(value.data, buf) + _UniffiConverterUInt64.write(value.amount_msat, buf) + _UniffiConverterOptionalString.write(value.comment, buf) + _UniffiConverterOptionalBool.write(value.validate_success_action_url, buf) -class Payment: - id: "str" - payment_type: "PaymentType" - payment_time: "int" - amount_msat: "int" - fee_msat: "int" - status: "PaymentStatus" - description: "typing.Optional[str]" - bolt11: "typing.Optional[str]" - preimage: "typing.Optional[bytes]" - destination: "typing.Optional[bytes]" - def __init__(self, *, id: "str", payment_type: "PaymentType", payment_time: "int", amount_msat: "int", fee_msat: "int", status: "PaymentStatus", description: "typing.Optional[str]", bolt11: "typing.Optional[str]", preimage: "typing.Optional[bytes]", destination: "typing.Optional[bytes]"): - self.id = id - self.payment_type = payment_type - self.payment_time = payment_time - self.amount_msat = amount_msat - self.fee_msat = fee_msat - self.status = status +class LnUrlPayRequestData: + """ + Data from an LNURL-pay endpoint (LUD-06). + + Contains the service's accepted amount range and metadata. + Returned inside `InputType::LnUrlPay` after `parse_input` resolves + an LNURL or Lightning Address. + """ + + callback: "str" + """ + The callback URL to request an invoice from. + """ + + min_sendable: "int" + """ + Minimum amount the service accepts, in millisatoshis. + """ + + max_sendable: "int" + """ + Maximum amount the service accepts, in millisatoshis. + """ + + metadata: "str" + """ + Raw metadata JSON string (array of `["mime", "content"]` pairs). + """ + + comment_allowed: "int" + """ + Maximum comment length the service accepts. 0 means no comments. + """ + + description: "str" + """ + Human-readable description extracted from metadata. + """ + + lnurl: "str" + """ + The original LNURL or lightning address that was resolved. + """ + + def __init__(self, *, callback: "str", min_sendable: "int", max_sendable: "int", metadata: "str", comment_allowed: "int", description: "str", lnurl: "str"): + self.callback = callback + self.min_sendable = min_sendable + self.max_sendable = max_sendable + self.metadata = metadata + self.comment_allowed = comment_allowed self.description = description - self.bolt11 = bolt11 - self.preimage = preimage - self.destination = destination + self.lnurl = lnurl def __str__(self): - return "Payment(id={}, payment_type={}, payment_time={}, amount_msat={}, fee_msat={}, status={}, description={}, bolt11={}, preimage={}, destination={})".format(self.id, self.payment_type, self.payment_time, self.amount_msat, self.fee_msat, self.status, self.description, self.bolt11, self.preimage, self.destination) + return "LnUrlPayRequestData(callback={}, min_sendable={}, max_sendable={}, metadata={}, comment_allowed={}, description={}, lnurl={})".format(self.callback, self.min_sendable, self.max_sendable, self.metadata, self.comment_allowed, self.description, self.lnurl) def __eq__(self, other): - if self.id != other.id: + if self.callback != other.callback: return False - if self.payment_type != other.payment_type: - return False - if self.payment_time != other.payment_time: + if self.min_sendable != other.min_sendable: return False - if self.amount_msat != other.amount_msat: + if self.max_sendable != other.max_sendable: return False - if self.fee_msat != other.fee_msat: + if self.metadata != other.metadata: return False - if self.status != other.status: + if self.comment_allowed != other.comment_allowed: return False if self.description != other.description: return False - if self.bolt11 != other.bolt11: - return False - if self.preimage != other.preimage: - return False - if self.destination != other.destination: + if self.lnurl != other.lnurl: return False return True -class _UniffiConverterTypePayment(_UniffiConverterRustBuffer): +class _UniffiConverterTypeLnUrlPayRequestData(_UniffiConverterRustBuffer): @staticmethod def read(buf): - return Payment( - id=_UniffiConverterString.read(buf), - payment_type=_UniffiConverterTypePaymentType.read(buf), - payment_time=_UniffiConverterUInt64.read(buf), - amount_msat=_UniffiConverterUInt64.read(buf), - fee_msat=_UniffiConverterUInt64.read(buf), - status=_UniffiConverterTypePaymentStatus.read(buf), - description=_UniffiConverterOptionalString.read(buf), - bolt11=_UniffiConverterOptionalString.read(buf), - preimage=_UniffiConverterOptionalBytes.read(buf), - destination=_UniffiConverterOptionalBytes.read(buf), + return LnUrlPayRequestData( + callback=_UniffiConverterString.read(buf), + min_sendable=_UniffiConverterUInt64.read(buf), + max_sendable=_UniffiConverterUInt64.read(buf), + metadata=_UniffiConverterString.read(buf), + comment_allowed=_UniffiConverterUInt64.read(buf), + description=_UniffiConverterString.read(buf), + lnurl=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.callback) + _UniffiConverterUInt64.check_lower(value.min_sendable) + _UniffiConverterUInt64.check_lower(value.max_sendable) + _UniffiConverterString.check_lower(value.metadata) + _UniffiConverterUInt64.check_lower(value.comment_allowed) + _UniffiConverterString.check_lower(value.description) + _UniffiConverterString.check_lower(value.lnurl) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.callback, buf) + _UniffiConverterUInt64.write(value.min_sendable, buf) + _UniffiConverterUInt64.write(value.max_sendable, buf) + _UniffiConverterString.write(value.metadata, buf) + _UniffiConverterUInt64.write(value.comment_allowed, buf) + _UniffiConverterString.write(value.description, buf) + _UniffiConverterString.write(value.lnurl, buf) + + +class LnUrlPaySuccessData: + """ + Successful LNURL-pay result data. + """ + + payment_preimage: "str" + """ + The payment preimage (proof of payment), hex-encoded. + """ + + success_action: "typing.Optional[SuccessActionProcessed]" + """ + Optional success action from the service (LUD-09). + """ + + def __init__(self, *, payment_preimage: "str", success_action: "typing.Optional[SuccessActionProcessed]"): + self.payment_preimage = payment_preimage + self.success_action = success_action + + def __str__(self): + return "LnUrlPaySuccessData(payment_preimage={}, success_action={})".format(self.payment_preimage, self.success_action) + + def __eq__(self, other): + if self.payment_preimage != other.payment_preimage: + return False + if self.success_action != other.success_action: + return False + return True + +class _UniffiConverterTypeLnUrlPaySuccessData(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return LnUrlPaySuccessData( + payment_preimage=_UniffiConverterString.read(buf), + success_action=_UniffiConverterOptionalTypeSuccessActionProcessed.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.payment_preimage) + _UniffiConverterOptionalTypeSuccessActionProcessed.check_lower(value.success_action) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.payment_preimage, buf) + _UniffiConverterOptionalTypeSuccessActionProcessed.write(value.success_action, buf) + + +class LnUrlWithdrawRequest: + """ + Request to execute an LNURL-withdraw flow. + + Combines the resolved service data with the user's chosen amount. + """ + + data: "LnUrlWithdrawRequestData" + """ + The resolved withdraw request data from `parse_input()`. + """ + + amount_msat: "int" + """ + Amount to withdraw in millisatoshis. + """ + + description: "typing.Optional[str]" + """ + Optional description for the invoice (overrides default). + """ + + def __init__(self, *, data: "LnUrlWithdrawRequestData", amount_msat: "int", description: "typing.Optional[str]"): + self.data = data + self.amount_msat = amount_msat + self.description = description + + def __str__(self): + return "LnUrlWithdrawRequest(data={}, amount_msat={}, description={})".format(self.data, self.amount_msat, self.description) + + def __eq__(self, other): + if self.data != other.data: + return False + if self.amount_msat != other.amount_msat: + return False + if self.description != other.description: + return False + return True + +class _UniffiConverterTypeLnUrlWithdrawRequest(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return LnUrlWithdrawRequest( + data=_UniffiConverterTypeLnUrlWithdrawRequestData.read(buf), + amount_msat=_UniffiConverterUInt64.read(buf), + description=_UniffiConverterOptionalString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterTypeLnUrlWithdrawRequestData.check_lower(value.data) + _UniffiConverterUInt64.check_lower(value.amount_msat) + _UniffiConverterOptionalString.check_lower(value.description) + + @staticmethod + def write(value, buf): + _UniffiConverterTypeLnUrlWithdrawRequestData.write(value.data, buf) + _UniffiConverterUInt64.write(value.amount_msat, buf) + _UniffiConverterOptionalString.write(value.description, buf) + + +class LnUrlWithdrawRequestData: + """ + Data from an LNURL-withdraw endpoint (LUD-03). + + Contains the service's accepted withdrawal range and session key. + Returned inside `InputType::LnUrlWithdraw` after `parse_input` + resolves an LNURL. + """ + + callback: "str" + """ + The callback URL to submit the invoice to. + """ + + k1: "str" + """ + Ephemeral secret linking this wallet session to the service. + """ + + default_description: "str" + """ + Default description for the invoice. + """ + + min_withdrawable: "int" + """ + Minimum withdrawable amount in millisatoshis. + """ + + max_withdrawable: "int" + """ + Maximum withdrawable amount in millisatoshis. + """ + + lnurl: "str" + """ + The original LNURL that was resolved. + """ + + def __init__(self, *, callback: "str", k1: "str", default_description: "str", min_withdrawable: "int", max_withdrawable: "int", lnurl: "str"): + self.callback = callback + self.k1 = k1 + self.default_description = default_description + self.min_withdrawable = min_withdrawable + self.max_withdrawable = max_withdrawable + self.lnurl = lnurl + + def __str__(self): + return "LnUrlWithdrawRequestData(callback={}, k1={}, default_description={}, min_withdrawable={}, max_withdrawable={}, lnurl={})".format(self.callback, self.k1, self.default_description, self.min_withdrawable, self.max_withdrawable, self.lnurl) + + def __eq__(self, other): + if self.callback != other.callback: + return False + if self.k1 != other.k1: + return False + if self.default_description != other.default_description: + return False + if self.min_withdrawable != other.min_withdrawable: + return False + if self.max_withdrawable != other.max_withdrawable: + return False + if self.lnurl != other.lnurl: + return False + return True + +class _UniffiConverterTypeLnUrlWithdrawRequestData(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return LnUrlWithdrawRequestData( + callback=_UniffiConverterString.read(buf), + k1=_UniffiConverterString.read(buf), + default_description=_UniffiConverterString.read(buf), + min_withdrawable=_UniffiConverterUInt64.read(buf), + max_withdrawable=_UniffiConverterUInt64.read(buf), + lnurl=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.callback) + _UniffiConverterString.check_lower(value.k1) + _UniffiConverterString.check_lower(value.default_description) + _UniffiConverterUInt64.check_lower(value.min_withdrawable) + _UniffiConverterUInt64.check_lower(value.max_withdrawable) + _UniffiConverterString.check_lower(value.lnurl) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.callback, buf) + _UniffiConverterString.write(value.k1, buf) + _UniffiConverterString.write(value.default_description, buf) + _UniffiConverterUInt64.write(value.min_withdrawable, buf) + _UniffiConverterUInt64.write(value.max_withdrawable, buf) + _UniffiConverterString.write(value.lnurl, buf) + + +class LnUrlWithdrawSuccessData: + """ + Successful LNURL-withdraw result data. + """ + + invoice: "str" + """ + The BOLT11 invoice that was submitted for withdrawal. + """ + + def __init__(self, *, invoice: "str"): + self.invoice = invoice + + def __str__(self): + return "LnUrlWithdrawSuccessData(invoice={})".format(self.invoice) + + def __eq__(self, other): + if self.invoice != other.invoice: + return False + return True + +class _UniffiConverterTypeLnUrlWithdrawSuccessData(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return LnUrlWithdrawSuccessData( + invoice=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.invoice) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.invoice, buf) + + +class OnchainReceiveResponse: + """ + A pair of on-chain addresses for receiving funds. + """ + + bech32: "str" + """ + SegWit v0 (bech32) address — starts with `bc1q` on mainnet. + """ + + p2tr: "str" + """ + Taproot (bech32m) address — starts with `bc1p` on mainnet. + """ + + def __init__(self, *, bech32: "str", p2tr: "str"): + self.bech32 = bech32 + self.p2tr = p2tr + + def __str__(self): + return "OnchainReceiveResponse(bech32={}, p2tr={})".format(self.bech32, self.p2tr) + + def __eq__(self, other): + if self.bech32 != other.bech32: + return False + if self.p2tr != other.p2tr: + return False + return True + +class _UniffiConverterTypeOnchainReceiveResponse(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return OnchainReceiveResponse( + bech32=_UniffiConverterString.read(buf), + p2tr=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.bech32) + _UniffiConverterString.check_lower(value.p2tr) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.bech32, buf) + _UniffiConverterString.write(value.p2tr, buf) + + +class OnchainSendResponse: + """ + Result of an on-chain send. The transaction has already been broadcast. + """ + + tx: "bytes" + """ + The raw signed transaction bytes. + """ + + txid: "bytes" + """ + The transaction ID (32 bytes, reversed byte order as is standard). + """ + + psbt: "str" + """ + The transaction as a Partially Signed Bitcoin Transaction string. + """ + + def __init__(self, *, tx: "bytes", txid: "bytes", psbt: "str"): + self.tx = tx + self.txid = txid + self.psbt = psbt + + def __str__(self): + return "OnchainSendResponse(tx={}, txid={}, psbt={})".format(self.tx, self.txid, self.psbt) + + def __eq__(self, other): + if self.tx != other.tx: + return False + if self.txid != other.txid: + return False + if self.psbt != other.psbt: + return False + return True + +class _UniffiConverterTypeOnchainSendResponse(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return OnchainSendResponse( + tx=_UniffiConverterBytes.read(buf), + txid=_UniffiConverterBytes.read(buf), + psbt=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterBytes.check_lower(value.tx) + _UniffiConverterBytes.check_lower(value.txid) + _UniffiConverterString.check_lower(value.psbt) + + @staticmethod + def write(value, buf): + _UniffiConverterBytes.write(value.tx, buf) + _UniffiConverterBytes.write(value.txid, buf) + _UniffiConverterString.write(value.psbt, buf) + + +class ParsedInvoice: + """ + Parsed BOLT11 invoice with extracted fields. + """ + + bolt11: "str" + """ + The original invoice string. + """ + + payee_pubkey: "typing.Optional[bytes]" + """ + 33-byte recipient public key, recovered from the invoice signature. + """ + + payment_hash: "bytes" + """ + 32-byte payment hash identifying this payment. + """ + + description: "typing.Optional[str]" + """ + Invoice description. None if the invoice uses a description hash. + """ + + amount_msat: "typing.Optional[int]" + """ + Requested amount in millisatoshis. None for "any amount" invoices. + """ + + expiry: "int" + """ + Seconds from creation until the invoice expires. + """ + + timestamp: "int" + """ + Unix timestamp (seconds) when the invoice was created. + """ + + def __init__(self, *, bolt11: "str", payee_pubkey: "typing.Optional[bytes]", payment_hash: "bytes", description: "typing.Optional[str]", amount_msat: "typing.Optional[int]", expiry: "int", timestamp: "int"): + self.bolt11 = bolt11 + self.payee_pubkey = payee_pubkey + self.payment_hash = payment_hash + self.description = description + self.amount_msat = amount_msat + self.expiry = expiry + self.timestamp = timestamp + + def __str__(self): + return "ParsedInvoice(bolt11={}, payee_pubkey={}, payment_hash={}, description={}, amount_msat={}, expiry={}, timestamp={})".format(self.bolt11, self.payee_pubkey, self.payment_hash, self.description, self.amount_msat, self.expiry, self.timestamp) + + def __eq__(self, other): + if self.bolt11 != other.bolt11: + return False + if self.payee_pubkey != other.payee_pubkey: + return False + if self.payment_hash != other.payment_hash: + return False + if self.description != other.description: + return False + if self.amount_msat != other.amount_msat: + return False + if self.expiry != other.expiry: + return False + if self.timestamp != other.timestamp: + return False + return True + +class _UniffiConverterTypeParsedInvoice(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return ParsedInvoice( + bolt11=_UniffiConverterString.read(buf), + payee_pubkey=_UniffiConverterOptionalBytes.read(buf), + payment_hash=_UniffiConverterBytes.read(buf), + description=_UniffiConverterOptionalString.read(buf), + amount_msat=_UniffiConverterOptionalUInt64.read(buf), + expiry=_UniffiConverterUInt64.read(buf), + timestamp=_UniffiConverterUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.bolt11) + _UniffiConverterOptionalBytes.check_lower(value.payee_pubkey) + _UniffiConverterBytes.check_lower(value.payment_hash) + _UniffiConverterOptionalString.check_lower(value.description) + _UniffiConverterOptionalUInt64.check_lower(value.amount_msat) + _UniffiConverterUInt64.check_lower(value.expiry) + _UniffiConverterUInt64.check_lower(value.timestamp) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.bolt11, buf) + _UniffiConverterOptionalBytes.write(value.payee_pubkey, buf) + _UniffiConverterBytes.write(value.payment_hash, buf) + _UniffiConverterOptionalString.write(value.description, buf) + _UniffiConverterOptionalUInt64.write(value.amount_msat, buf) + _UniffiConverterUInt64.write(value.expiry, buf) + _UniffiConverterUInt64.write(value.timestamp, buf) + + +class Pay: + payment_hash: "bytes" + status: "PayStatus" + destination_pubkey: "typing.Optional[bytes]" + amount_msat: "typing.Optional[int]" + amount_sent_msat: "typing.Optional[int]" + label: "typing.Optional[str]" + bolt11: "typing.Optional[str]" + description: "typing.Optional[str]" + bolt12: "typing.Optional[str]" + preimage: "typing.Optional[bytes]" + created_at: "int" + completed_at: "typing.Optional[int]" + number_of_parts: "typing.Optional[int]" + def __init__(self, *, payment_hash: "bytes", status: "PayStatus", destination_pubkey: "typing.Optional[bytes]", amount_msat: "typing.Optional[int]", amount_sent_msat: "typing.Optional[int]", label: "typing.Optional[str]", bolt11: "typing.Optional[str]", description: "typing.Optional[str]", bolt12: "typing.Optional[str]", preimage: "typing.Optional[bytes]", created_at: "int", completed_at: "typing.Optional[int]", number_of_parts: "typing.Optional[int]"): + self.payment_hash = payment_hash + self.status = status + self.destination_pubkey = destination_pubkey + self.amount_msat = amount_msat + self.amount_sent_msat = amount_sent_msat + self.label = label + self.bolt11 = bolt11 + self.description = description + self.bolt12 = bolt12 + self.preimage = preimage + self.created_at = created_at + self.completed_at = completed_at + self.number_of_parts = number_of_parts + + def __str__(self): + return "Pay(payment_hash={}, status={}, destination_pubkey={}, amount_msat={}, amount_sent_msat={}, label={}, bolt11={}, description={}, bolt12={}, preimage={}, created_at={}, completed_at={}, number_of_parts={})".format(self.payment_hash, self.status, self.destination_pubkey, self.amount_msat, self.amount_sent_msat, self.label, self.bolt11, self.description, self.bolt12, self.preimage, self.created_at, self.completed_at, self.number_of_parts) + + def __eq__(self, other): + if self.payment_hash != other.payment_hash: + return False + if self.status != other.status: + return False + if self.destination_pubkey != other.destination_pubkey: + return False + if self.amount_msat != other.amount_msat: + return False + if self.amount_sent_msat != other.amount_sent_msat: + return False + if self.label != other.label: + return False + if self.bolt11 != other.bolt11: + return False + if self.description != other.description: + return False + if self.bolt12 != other.bolt12: + return False + if self.preimage != other.preimage: + return False + if self.created_at != other.created_at: + return False + if self.completed_at != other.completed_at: + return False + if self.number_of_parts != other.number_of_parts: + return False + return True + +class _UniffiConverterTypePay(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return Pay( + payment_hash=_UniffiConverterBytes.read(buf), + status=_UniffiConverterTypePayStatus.read(buf), + destination_pubkey=_UniffiConverterOptionalBytes.read(buf), + amount_msat=_UniffiConverterOptionalUInt64.read(buf), + amount_sent_msat=_UniffiConverterOptionalUInt64.read(buf), + label=_UniffiConverterOptionalString.read(buf), + bolt11=_UniffiConverterOptionalString.read(buf), + description=_UniffiConverterOptionalString.read(buf), + bolt12=_UniffiConverterOptionalString.read(buf), + preimage=_UniffiConverterOptionalBytes.read(buf), + created_at=_UniffiConverterUInt64.read(buf), + completed_at=_UniffiConverterOptionalUInt64.read(buf), + number_of_parts=_UniffiConverterOptionalUInt64.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterBytes.check_lower(value.payment_hash) + _UniffiConverterTypePayStatus.check_lower(value.status) + _UniffiConverterOptionalBytes.check_lower(value.destination_pubkey) + _UniffiConverterOptionalUInt64.check_lower(value.amount_msat) + _UniffiConverterOptionalUInt64.check_lower(value.amount_sent_msat) + _UniffiConverterOptionalString.check_lower(value.label) + _UniffiConverterOptionalString.check_lower(value.bolt11) + _UniffiConverterOptionalString.check_lower(value.description) + _UniffiConverterOptionalString.check_lower(value.bolt12) + _UniffiConverterOptionalBytes.check_lower(value.preimage) + _UniffiConverterUInt64.check_lower(value.created_at) + _UniffiConverterOptionalUInt64.check_lower(value.completed_at) + _UniffiConverterOptionalUInt64.check_lower(value.number_of_parts) + + @staticmethod + def write(value, buf): + _UniffiConverterBytes.write(value.payment_hash, buf) + _UniffiConverterTypePayStatus.write(value.status, buf) + _UniffiConverterOptionalBytes.write(value.destination_pubkey, buf) + _UniffiConverterOptionalUInt64.write(value.amount_msat, buf) + _UniffiConverterOptionalUInt64.write(value.amount_sent_msat, buf) + _UniffiConverterOptionalString.write(value.label, buf) + _UniffiConverterOptionalString.write(value.bolt11, buf) + _UniffiConverterOptionalString.write(value.description, buf) + _UniffiConverterOptionalString.write(value.bolt12, buf) + _UniffiConverterOptionalBytes.write(value.preimage, buf) + _UniffiConverterUInt64.write(value.created_at, buf) + _UniffiConverterOptionalUInt64.write(value.completed_at, buf) + _UniffiConverterOptionalUInt64.write(value.number_of_parts, buf) + + +class Payment: + id: "str" + payment_type: "PaymentType" + payment_time: "int" + amount_msat: "int" + fee_msat: "int" + status: "PaymentStatus" + description: "typing.Optional[str]" + bolt11: "typing.Optional[str]" + preimage: "typing.Optional[bytes]" + destination: "typing.Optional[bytes]" + def __init__(self, *, id: "str", payment_type: "PaymentType", payment_time: "int", amount_msat: "int", fee_msat: "int", status: "PaymentStatus", description: "typing.Optional[str]", bolt11: "typing.Optional[str]", preimage: "typing.Optional[bytes]", destination: "typing.Optional[bytes]"): + self.id = id + self.payment_type = payment_type + self.payment_time = payment_time + self.amount_msat = amount_msat + self.fee_msat = fee_msat + self.status = status + self.description = description + self.bolt11 = bolt11 + self.preimage = preimage + self.destination = destination + + def __str__(self): + return "Payment(id={}, payment_type={}, payment_time={}, amount_msat={}, fee_msat={}, status={}, description={}, bolt11={}, preimage={}, destination={})".format(self.id, self.payment_type, self.payment_time, self.amount_msat, self.fee_msat, self.status, self.description, self.bolt11, self.preimage, self.destination) + + def __eq__(self, other): + if self.id != other.id: + return False + if self.payment_type != other.payment_type: + return False + if self.payment_time != other.payment_time: + return False + if self.amount_msat != other.amount_msat: + return False + if self.fee_msat != other.fee_msat: + return False + if self.status != other.status: + return False + if self.description != other.description: + return False + if self.bolt11 != other.bolt11: + return False + if self.preimage != other.preimage: + return False + if self.destination != other.destination: + return False + return True + +class _UniffiConverterTypePayment(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return Payment( + id=_UniffiConverterString.read(buf), + payment_type=_UniffiConverterTypePaymentType.read(buf), + payment_time=_UniffiConverterUInt64.read(buf), + amount_msat=_UniffiConverterUInt64.read(buf), + fee_msat=_UniffiConverterUInt64.read(buf), + status=_UniffiConverterTypePaymentStatus.read(buf), + description=_UniffiConverterOptionalString.read(buf), + bolt11=_UniffiConverterOptionalString.read(buf), + preimage=_UniffiConverterOptionalBytes.read(buf), + destination=_UniffiConverterOptionalBytes.read(buf), ) @staticmethod @@ -3003,6 +3675,189 @@ def write(value, buf): +class InputType: + """ + The result of `parse_input`: a fully-resolved input ready for the + caller's next action. LNURL bech32 strings and Lightning Addresses + are resolved over HTTP into typed pay or withdraw request data. + """ + + def __init__(self): + raise RuntimeError("InputType cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class BOLT11: + """ + A BOLT11 Lightning invoice. No HTTP was performed. + """ + + invoice: "ParsedInvoice" + + def __init__(self,invoice: "ParsedInvoice"): + self.invoice = invoice + + def __str__(self): + return "InputType.BOLT11(invoice={})".format(self.invoice) + + def __eq__(self, other): + if not other.is_BOLT11(): + return False + if self.invoice != other.invoice: + return False + return True + + class NODE_ID: + """ + A Lightning node public key. No HTTP was performed. + """ + + node_id: "str" + + def __init__(self,node_id: "str"): + self.node_id = node_id + + def __str__(self): + return "InputType.NODE_ID(node_id={})".format(self.node_id) + + def __eq__(self, other): + if not other.is_NODE_ID(): + return False + if self.node_id != other.node_id: + return False + return True + + class LN_URL_PAY: + """ + An LNURL-pay endpoint with the service's parameters fetched. + """ + + data: "LnUrlPayRequestData" + + def __init__(self,data: "LnUrlPayRequestData"): + self.data = data + + def __str__(self): + return "InputType.LN_URL_PAY(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_LN_URL_PAY(): + return False + if self.data != other.data: + return False + return True + + class LN_URL_WITHDRAW: + """ + An LNURL-withdraw endpoint with the service's parameters fetched. + """ + + data: "LnUrlWithdrawRequestData" + + def __init__(self,data: "LnUrlWithdrawRequestData"): + self.data = data + + def __str__(self): + return "InputType.LN_URL_WITHDRAW(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_LN_URL_WITHDRAW(): + return False + if self.data != other.data: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_BOLT11(self) -> bool: + return isinstance(self, InputType.BOLT11) + def is_bolt11(self) -> bool: + return isinstance(self, InputType.BOLT11) + def is_NODE_ID(self) -> bool: + return isinstance(self, InputType.NODE_ID) + def is_node_id(self) -> bool: + return isinstance(self, InputType.NODE_ID) + def is_LN_URL_PAY(self) -> bool: + return isinstance(self, InputType.LN_URL_PAY) + def is_ln_url_pay(self) -> bool: + return isinstance(self, InputType.LN_URL_PAY) + def is_LN_URL_WITHDRAW(self) -> bool: + return isinstance(self, InputType.LN_URL_WITHDRAW) + def is_ln_url_withdraw(self) -> bool: + return isinstance(self, InputType.LN_URL_WITHDRAW) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +InputType.BOLT11 = type("InputType.BOLT11", (InputType.BOLT11, InputType,), {}) # type: ignore +InputType.NODE_ID = type("InputType.NODE_ID", (InputType.NODE_ID, InputType,), {}) # type: ignore +InputType.LN_URL_PAY = type("InputType.LN_URL_PAY", (InputType.LN_URL_PAY, InputType,), {}) # type: ignore +InputType.LN_URL_WITHDRAW = type("InputType.LN_URL_WITHDRAW", (InputType.LN_URL_WITHDRAW, InputType,), {}) # type: ignore + + + + +class _UniffiConverterTypeInputType(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return InputType.BOLT11( + _UniffiConverterTypeParsedInvoice.read(buf), + ) + if variant == 2: + return InputType.NODE_ID( + _UniffiConverterString.read(buf), + ) + if variant == 3: + return InputType.LN_URL_PAY( + _UniffiConverterTypeLnUrlPayRequestData.read(buf), + ) + if variant == 4: + return InputType.LN_URL_WITHDRAW( + _UniffiConverterTypeLnUrlWithdrawRequestData.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_BOLT11(): + _UniffiConverterTypeParsedInvoice.check_lower(value.invoice) + return + if value.is_NODE_ID(): + _UniffiConverterString.check_lower(value.node_id) + return + if value.is_LN_URL_PAY(): + _UniffiConverterTypeLnUrlPayRequestData.check_lower(value.data) + return + if value.is_LN_URL_WITHDRAW(): + _UniffiConverterTypeLnUrlWithdrawRequestData.check_lower(value.data) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_BOLT11(): + buf.write_i32(1) + _UniffiConverterTypeParsedInvoice.write(value.invoice, buf) + if value.is_NODE_ID(): + buf.write_i32(2) + _UniffiConverterString.write(value.node_id, buf) + if value.is_LN_URL_PAY(): + buf.write_i32(3) + _UniffiConverterTypeLnUrlPayRequestData.write(value.data, buf) + if value.is_LN_URL_WITHDRAW(): + buf.write_i32(4) + _UniffiConverterTypeLnUrlWithdrawRequestData.write(value.data, buf) + + + + + + + class InvoiceStatus(enum.Enum): UNPAID = 0 @@ -3017,31 +3872,219 @@ class _UniffiConverterTypeInvoiceStatus(_UniffiConverterRustBuffer): def read(buf): variant = buf.read_i32() if variant == 1: - return InvoiceStatus.UNPAID + return InvoiceStatus.UNPAID + if variant == 2: + return InvoiceStatus.PAID + if variant == 3: + return InvoiceStatus.EXPIRED + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == InvoiceStatus.UNPAID: + return + if value == InvoiceStatus.PAID: + return + if value == InvoiceStatus.EXPIRED: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == InvoiceStatus.UNPAID: + buf.write_i32(1) + if value == InvoiceStatus.PAID: + buf.write_i32(2) + if value == InvoiceStatus.EXPIRED: + buf.write_i32(3) + + + + + + + +class ListIndex(enum.Enum): + """ + Index field used by CLN's paginated list RPCs. + """ + + CREATED = 0 + + UPDATED = 1 + + + +class _UniffiConverterTypeListIndex(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return ListIndex.CREATED + if variant == 2: + return ListIndex.UPDATED + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value == ListIndex.CREATED: + return + if value == ListIndex.UPDATED: + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value == ListIndex.CREATED: + buf.write_i32(1) + if value == ListIndex.UPDATED: + buf.write_i32(2) + + + + + + + +class LnUrlPayResult: + """ + Result of an LNURL-pay operation. + """ + + def __init__(self): + raise RuntimeError("LnUrlPayResult cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class ENDPOINT_SUCCESS: + """ + Payment succeeded. + """ + + data: "LnUrlPaySuccessData" + + def __init__(self,data: "LnUrlPaySuccessData"): + self.data = data + + def __str__(self): + return "LnUrlPayResult.ENDPOINT_SUCCESS(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_ENDPOINT_SUCCESS(): + return False + if self.data != other.data: + return False + return True + + class ENDPOINT_ERROR: + """ + The LNURL service returned an error before the invoice was paid. + """ + + data: "LnUrlErrorData" + + def __init__(self,data: "LnUrlErrorData"): + self.data = data + + def __str__(self): + return "LnUrlPayResult.ENDPOINT_ERROR(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_ENDPOINT_ERROR(): + return False + if self.data != other.data: + return False + return True + + class PAY_ERROR: + """ + The invoice was fetched successfully but paying it failed. + """ + + data: "LnUrlPayErrorData" + + def __init__(self,data: "LnUrlPayErrorData"): + self.data = data + + def __str__(self): + return "LnUrlPayResult.PAY_ERROR(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_PAY_ERROR(): + return False + if self.data != other.data: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_ENDPOINT_SUCCESS(self) -> bool: + return isinstance(self, LnUrlPayResult.ENDPOINT_SUCCESS) + def is_endpoint_success(self) -> bool: + return isinstance(self, LnUrlPayResult.ENDPOINT_SUCCESS) + def is_ENDPOINT_ERROR(self) -> bool: + return isinstance(self, LnUrlPayResult.ENDPOINT_ERROR) + def is_endpoint_error(self) -> bool: + return isinstance(self, LnUrlPayResult.ENDPOINT_ERROR) + def is_PAY_ERROR(self) -> bool: + return isinstance(self, LnUrlPayResult.PAY_ERROR) + def is_pay_error(self) -> bool: + return isinstance(self, LnUrlPayResult.PAY_ERROR) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +LnUrlPayResult.ENDPOINT_SUCCESS = type("LnUrlPayResult.ENDPOINT_SUCCESS", (LnUrlPayResult.ENDPOINT_SUCCESS, LnUrlPayResult,), {}) # type: ignore +LnUrlPayResult.ENDPOINT_ERROR = type("LnUrlPayResult.ENDPOINT_ERROR", (LnUrlPayResult.ENDPOINT_ERROR, LnUrlPayResult,), {}) # type: ignore +LnUrlPayResult.PAY_ERROR = type("LnUrlPayResult.PAY_ERROR", (LnUrlPayResult.PAY_ERROR, LnUrlPayResult,), {}) # type: ignore + + + + +class _UniffiConverterTypeLnUrlPayResult(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return LnUrlPayResult.ENDPOINT_SUCCESS( + _UniffiConverterTypeLnUrlPaySuccessData.read(buf), + ) if variant == 2: - return InvoiceStatus.PAID + return LnUrlPayResult.ENDPOINT_ERROR( + _UniffiConverterTypeLnUrlErrorData.read(buf), + ) if variant == 3: - return InvoiceStatus.EXPIRED + return LnUrlPayResult.PAY_ERROR( + _UniffiConverterTypeLnUrlPayErrorData.read(buf), + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod def check_lower(value): - if value == InvoiceStatus.UNPAID: + if value.is_ENDPOINT_SUCCESS(): + _UniffiConverterTypeLnUrlPaySuccessData.check_lower(value.data) return - if value == InvoiceStatus.PAID: + if value.is_ENDPOINT_ERROR(): + _UniffiConverterTypeLnUrlErrorData.check_lower(value.data) return - if value == InvoiceStatus.EXPIRED: + if value.is_PAY_ERROR(): + _UniffiConverterTypeLnUrlPayErrorData.check_lower(value.data) return raise ValueError(value) @staticmethod def write(value, buf): - if value == InvoiceStatus.UNPAID: + if value.is_ENDPOINT_SUCCESS(): buf.write_i32(1) - if value == InvoiceStatus.PAID: + _UniffiConverterTypeLnUrlPaySuccessData.write(value.data, buf) + if value.is_ENDPOINT_ERROR(): buf.write_i32(2) - if value == InvoiceStatus.EXPIRED: + _UniffiConverterTypeLnUrlErrorData.write(value.data, buf) + if value.is_PAY_ERROR(): buf.write_i32(3) + _UniffiConverterTypeLnUrlPayErrorData.write(value.data, buf) @@ -3049,41 +4092,110 @@ def write(value, buf): -class ListIndex(enum.Enum): +class LnUrlWithdrawResult: """ - Index field used by CLN's paginated list RPCs. + Result of an LNURL-withdraw operation. """ - CREATED = 0 + def __init__(self): + raise RuntimeError("LnUrlWithdrawResult cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class OK: + """ + The service accepted our invoice and will pay it. + """ + + data: "LnUrlWithdrawSuccessData" + + def __init__(self,data: "LnUrlWithdrawSuccessData"): + self.data = data + + def __str__(self): + return "LnUrlWithdrawResult.OK(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_OK(): + return False + if self.data != other.data: + return False + return True + + class ERROR_STATUS: + """ + The LNURL service returned an error. + """ + + data: "LnUrlErrorData" + + def __init__(self,data: "LnUrlErrorData"): + self.data = data + + def __str__(self): + return "LnUrlWithdrawResult.ERROR_STATUS(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_ERROR_STATUS(): + return False + if self.data != other.data: + return False + return True - UPDATED = 1 + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_OK(self) -> bool: + return isinstance(self, LnUrlWithdrawResult.OK) + def is_ok(self) -> bool: + return isinstance(self, LnUrlWithdrawResult.OK) + def is_ERROR_STATUS(self) -> bool: + return isinstance(self, LnUrlWithdrawResult.ERROR_STATUS) + def is_error_status(self) -> bool: + return isinstance(self, LnUrlWithdrawResult.ERROR_STATUS) + -class _UniffiConverterTypeListIndex(_UniffiConverterRustBuffer): +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +LnUrlWithdrawResult.OK = type("LnUrlWithdrawResult.OK", (LnUrlWithdrawResult.OK, LnUrlWithdrawResult,), {}) # type: ignore +LnUrlWithdrawResult.ERROR_STATUS = type("LnUrlWithdrawResult.ERROR_STATUS", (LnUrlWithdrawResult.ERROR_STATUS, LnUrlWithdrawResult,), {}) # type: ignore + + + + +class _UniffiConverterTypeLnUrlWithdrawResult(_UniffiConverterRustBuffer): @staticmethod def read(buf): variant = buf.read_i32() if variant == 1: - return ListIndex.CREATED + return LnUrlWithdrawResult.OK( + _UniffiConverterTypeLnUrlWithdrawSuccessData.read(buf), + ) if variant == 2: - return ListIndex.UPDATED + return LnUrlWithdrawResult.ERROR_STATUS( + _UniffiConverterTypeLnUrlErrorData.read(buf), + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod def check_lower(value): - if value == ListIndex.CREATED: + if value.is_OK(): + _UniffiConverterTypeLnUrlWithdrawSuccessData.check_lower(value.data) return - if value == ListIndex.UPDATED: + if value.is_ERROR_STATUS(): + _UniffiConverterTypeLnUrlErrorData.check_lower(value.data) return raise ValueError(value) @staticmethod def write(value, buf): - if value == ListIndex.CREATED: + if value.is_OK(): buf.write_i32(1) - if value == ListIndex.UPDATED: + _UniffiConverterTypeLnUrlWithdrawSuccessData.write(value.data, buf) + if value.is_ERROR_STATUS(): buf.write_i32(2) + _UniffiConverterTypeLnUrlErrorData.write(value.data, buf) @@ -3455,6 +4567,169 @@ def write(value, buf): + + +class SuccessActionProcessed: + """ + A processed success action from an LNURL-pay callback. + + For Message and Url this is passed through as-is. For Aes the + ciphertext has been decrypted using the payment preimage. + """ + + def __init__(self): + raise RuntimeError("SuccessActionProcessed cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class MESSAGE: + """ + Display a message to the user. + """ + + message: "str" + + def __init__(self,message: "str"): + self.message = message + + def __str__(self): + return "SuccessActionProcessed.MESSAGE(message={})".format(self.message) + + def __eq__(self, other): + if not other.is_MESSAGE(): + return False + if self.message != other.message: + return False + return True + + class URL: + """ + Display a URL to the user. + """ + + description: "str" + url: "str" + + def __init__(self,description: "str", url: "str"): + self.description = description + self.url = url + + def __str__(self): + return "SuccessActionProcessed.URL(description={}, url={})".format(self.description, self.url) + + def __eq__(self, other): + if not other.is_URL(): + return False + if self.description != other.description: + return False + if self.url != other.url: + return False + return True + + class AES: + """ + Decrypted AES payload (LUD-10). + """ + + description: "str" + plaintext: "str" + + def __init__(self,description: "str", plaintext: "str"): + self.description = description + self.plaintext = plaintext + + def __str__(self): + return "SuccessActionProcessed.AES(description={}, plaintext={})".format(self.description, self.plaintext) + + def __eq__(self, other): + if not other.is_AES(): + return False + if self.description != other.description: + return False + if self.plaintext != other.plaintext: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_MESSAGE(self) -> bool: + return isinstance(self, SuccessActionProcessed.MESSAGE) + def is_message(self) -> bool: + return isinstance(self, SuccessActionProcessed.MESSAGE) + def is_URL(self) -> bool: + return isinstance(self, SuccessActionProcessed.URL) + def is_url(self) -> bool: + return isinstance(self, SuccessActionProcessed.URL) + def is_AES(self) -> bool: + return isinstance(self, SuccessActionProcessed.AES) + def is_aes(self) -> bool: + return isinstance(self, SuccessActionProcessed.AES) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +SuccessActionProcessed.MESSAGE = type("SuccessActionProcessed.MESSAGE", (SuccessActionProcessed.MESSAGE, SuccessActionProcessed,), {}) # type: ignore +SuccessActionProcessed.URL = type("SuccessActionProcessed.URL", (SuccessActionProcessed.URL, SuccessActionProcessed,), {}) # type: ignore +SuccessActionProcessed.AES = type("SuccessActionProcessed.AES", (SuccessActionProcessed.AES, SuccessActionProcessed,), {}) # type: ignore + + + + +class _UniffiConverterTypeSuccessActionProcessed(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return SuccessActionProcessed.MESSAGE( + _UniffiConverterString.read(buf), + ) + if variant == 2: + return SuccessActionProcessed.URL( + _UniffiConverterString.read(buf), + _UniffiConverterString.read(buf), + ) + if variant == 3: + return SuccessActionProcessed.AES( + _UniffiConverterString.read(buf), + _UniffiConverterString.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_MESSAGE(): + _UniffiConverterString.check_lower(value.message) + return + if value.is_URL(): + _UniffiConverterString.check_lower(value.description) + _UniffiConverterString.check_lower(value.url) + return + if value.is_AES(): + _UniffiConverterString.check_lower(value.description) + _UniffiConverterString.check_lower(value.plaintext) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_MESSAGE(): + buf.write_i32(1) + _UniffiConverterString.write(value.message, buf) + if value.is_URL(): + buf.write_i32(2) + _UniffiConverterString.write(value.description, buf) + _UniffiConverterString.write(value.url, buf) + if value.is_AES(): + buf.write_i32(3) + _UniffiConverterString.write(value.description, buf) + _UniffiConverterString.write(value.plaintext, buf) + + + + + class _UniffiConverterOptionalUInt32(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -3671,6 +4946,33 @@ def read(cls, buf): +class _UniffiConverterOptionalTypeSuccessActionProcessed(_UniffiConverterRustBuffer): + @classmethod + def check_lower(cls, value): + if value is not None: + _UniffiConverterTypeSuccessActionProcessed.check_lower(value) + + @classmethod + def write(cls, value, buf): + if value is None: + buf.write_u8(0) + return + + buf.write_u8(1) + _UniffiConverterTypeSuccessActionProcessed.write(value, buf) + + @classmethod + def read(cls, buf): + flag = buf.read_u8() + if flag == 0: + return None + elif flag == 1: + return _UniffiConverterTypeSuccessActionProcessed.read(buf) + else: + raise InternalError("Unexpected flag byte for optional type") + + + class _UniffiConverterOptionalSequenceTypePaymentTypeFilter(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -4364,10 +5666,60 @@ def list_peers(self, ): status. """ + raise NotImplementedError + def lnurl_pay(self, request: "LnUrlPayRequest"): + """ + Execute an LNURL-pay flow (LUD-06). + + Sends the chosen amount (and optional comment) to the service's + callback, receives and validates a BOLT11 invoice, pays it, and + processes any success action (LUD-09/10). + + Call the top-level `parse_input` first to obtain the + `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + user's chosen amount. + """ + + raise NotImplementedError + def lnurl_withdraw(self, request: "LnUrlWithdrawRequest"): + """ + Execute an LNURL-withdraw flow (LUD-03). + + Creates an invoice on this node for the requested amount, sends + it to the service's callback URL, and the service pays it + asynchronously. + + Call the top-level `parse_input` first to obtain the + `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + with the user's chosen amount. + """ + raise NotImplementedError def onchain_receive(self, ): + """ + Generate a fresh on-chain Bitcoin address for receiving funds. + + Returns both a bech32 (SegWit v0) and a p2tr (Taproot) address. + Either can be shared with a sender. Deposited funds will appear + in `node_state().onchain_balance_msat` once confirmed. + """ + raise NotImplementedError def onchain_send(self, destination: "str",amount_or_all: "str"): + """ + Send bitcoin on-chain to a destination address. + + # Arguments + * `destination` — A Bitcoin address (bech32, p2sh, or p2tr). + * `amount_or_all` — Amount to send. Accepts: + - `"50000"` or `"50000sat"` — 50,000 satoshis + - `"50000msat"` — 50,000 millisatoshis + - `"all"` — sweep the entire on-chain balance + + Returns the raw transaction, txid, and PSBT once broadcast. + The transaction is broadcast immediately — this is not a dry run. + """ + raise NotImplementedError def receive(self, label: "str",description: "str",amount_msat: "typing.Optional[int]"): """ @@ -4620,7 +5972,63 @@ def list_peers(self, ) -> "ListPeersResponse": + def lnurl_pay(self, request: "LnUrlPayRequest") -> "LnUrlPayResult": + """ + Execute an LNURL-pay flow (LUD-06). + + Sends the chosen amount (and optional comment) to the service's + callback, receives and validates a BOLT11 invoice, pays it, and + processes any success action (LUD-09/10). + + Call the top-level `parse_input` first to obtain the + `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + user's chosen amount. + """ + + _UniffiConverterTypeLnUrlPayRequest.check_lower(request) + + return _UniffiConverterTypeLnUrlPayResult.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_pay,self._uniffi_clone_pointer(), + _UniffiConverterTypeLnUrlPayRequest.lower(request)) + ) + + + + + + def lnurl_withdraw(self, request: "LnUrlWithdrawRequest") -> "LnUrlWithdrawResult": + """ + Execute an LNURL-withdraw flow (LUD-03). + + Creates an invoice on this node for the requested amount, sends + it to the service's callback URL, and the service pays it + asynchronously. + + Call the top-level `parse_input` first to obtain the + `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + with the user's chosen amount. + """ + + _UniffiConverterTypeLnUrlWithdrawRequest.check_lower(request) + + return _UniffiConverterTypeLnUrlWithdrawResult.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_withdraw,self._uniffi_clone_pointer(), + _UniffiConverterTypeLnUrlWithdrawRequest.lower(request)) + ) + + + + + def onchain_receive(self, ) -> "OnchainReceiveResponse": + """ + Generate a fresh on-chain Bitcoin address for receiving funds. + + Returns both a bech32 (SegWit v0) and a p2tr (Taproot) address. + Either can be shared with a sender. Deposited funds will appear + in `node_state().onchain_balance_msat` once confirmed. + """ + return _UniffiConverterTypeOnchainReceiveResponse.lift( _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_onchain_receive,self._uniffi_clone_pointer(),) ) @@ -4630,6 +6038,20 @@ def onchain_receive(self, ) -> "OnchainReceiveResponse": def onchain_send(self, destination: "str",amount_or_all: "str") -> "OnchainSendResponse": + """ + Send bitcoin on-chain to a destination address. + + # Arguments + * `destination` — A Bitcoin address (bech32, p2sh, or p2tr). + * `amount_or_all` — Amount to send. Accepts: + - `"50000"` or `"50000sat"` — 50,000 satoshis + - `"50000msat"` — 50,000 millisatoshis + - `"all"` — sweep the entire on-chain balance + + Returns the raw transaction, txid, and PSBT once broadcast. + The transaction is broadcast immediately — this is not a dry run. + """ + _UniffiConverterString.check_lower(destination) _UniffiConverterString.check_lower(amount_or_all) @@ -5079,7 +6501,69 @@ def read(cls, buf: _UniffiRustBuffer): def write(cls, value: SignerProtocol, buf: _UniffiRustBuffer): buf.write_u64(cls.lower(value)) -# Async support +# Async support# RustFuturePoll values +_UNIFFI_RUST_FUTURE_POLL_READY = 0 +_UNIFFI_RUST_FUTURE_POLL_MAYBE_READY = 1 + +# Stores futures for _uniffi_continuation_callback +_UniffiContinuationHandleMap = _UniffiHandleMap() + +_UNIFFI_GLOBAL_EVENT_LOOP = None + +""" +Set the event loop to use for async functions + +This is needed if some async functions run outside of the eventloop, for example: + - A non-eventloop thread is spawned, maybe from `EventLoop.run_in_executor` or maybe from the + Rust code spawning its own thread. + - The Rust code calls an async callback method from a sync callback function, using something + like `pollster` to block on the async call. + +In this case, we need an event loop to run the Python async function, but there's no eventloop set +for the thread. Use `uniffi_set_event_loop` to force an eventloop to be used in this case. +""" +def uniffi_set_event_loop(eventloop: asyncio.BaseEventLoop): + global _UNIFFI_GLOBAL_EVENT_LOOP + _UNIFFI_GLOBAL_EVENT_LOOP = eventloop + +def _uniffi_get_event_loop(): + if _UNIFFI_GLOBAL_EVENT_LOOP is not None: + return _UNIFFI_GLOBAL_EVENT_LOOP + else: + return asyncio.get_running_loop() + +# Continuation callback for async functions +# lift the return value or error and resolve the future, causing the async function to resume. +@_UNIFFI_RUST_FUTURE_CONTINUATION_CALLBACK +def _uniffi_continuation_callback(future_ptr, poll_code): + (eventloop, future) = _UniffiContinuationHandleMap.remove(future_ptr) + eventloop.call_soon_threadsafe(_uniffi_set_future_result, future, poll_code) + +def _uniffi_set_future_result(future, poll_code): + if not future.cancelled(): + future.set_result(poll_code) + +async def _uniffi_rust_call_async(rust_future, ffi_poll, ffi_complete, ffi_free, lift_func, error_ffi_converter): + try: + eventloop = _uniffi_get_event_loop() + + # Loop and poll until we see a _UNIFFI_RUST_FUTURE_POLL_READY value + while True: + future = eventloop.create_future() + ffi_poll( + rust_future, + _uniffi_continuation_callback, + _UniffiContinuationHandleMap.insert((eventloop, future)), + ) + poll_code = await future + if poll_code == _UNIFFI_RUST_FUTURE_POLL_READY: + break + + return lift_func( + _uniffi_rust_call_with_error(error_ffi_converter, ffi_complete, rust_future) + ) + finally: + ffi_free(rust_future) def connect(mnemonic: "str",credentials: "bytes",config: "Config") -> "Node": """ @@ -5097,6 +6581,34 @@ def connect(mnemonic: "str",credentials: "bytes",config: "Config") -> "Node": _UniffiConverterBytes.lower(credentials), _UniffiConverterTypeConfig.lower(config))) +async def parse_input(input: "str") -> "InputType": + + """ + Parse and resolve any supported input in one async call. + + For LNURL bech32 strings and Lightning Addresses this performs the + HTTP GET to the LNURL endpoint and returns typed pay or withdraw + request data. For BOLT11 invoices and node IDs it returns + immediately without I/O. + + Strips `lightning:` / `LIGHTNING:` prefixes automatically. + """ + + _UniffiConverterString.check_lower(input) + + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_glsdk_fn_func_parse_input( + _UniffiConverterString.lower(input)), + _UniffiLib.ffi_glsdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_glsdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_glsdk_rust_future_free_rust_buffer, + # lift function + _UniffiConverterTypeInputType.lift, + + # Error FFI converter +_UniffiConverterTypeError, + + ) def recover(mnemonic: "str",config: "Config") -> "Node": """ @@ -5156,8 +6668,11 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "InternalError", "ChannelState", "Error", + "InputType", "InvoiceStatus", "ListIndex", + "LnUrlPayResult", + "LnUrlWithdrawResult", "Network", "NodeEvent", "OutputStatus", @@ -5165,6 +6680,7 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "PaymentStatus", "PaymentType", "PaymentTypeFilter", + "SuccessActionProcessed", "FundChannel", "FundOutput", "GetInfoResponse", @@ -5176,8 +6692,17 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "ListPaysResponse", "ListPeerChannelsResponse", "ListPeersResponse", + "LnUrlErrorData", + "LnUrlPayErrorData", + "LnUrlPayRequest", + "LnUrlPayRequestData", + "LnUrlPaySuccessData", + "LnUrlWithdrawRequest", + "LnUrlWithdrawRequestData", + "LnUrlWithdrawSuccessData", "OnchainReceiveResponse", "OnchainSendResponse", + "ParsedInvoice", "Pay", "Payment", "Peer", @@ -5185,6 +6710,7 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "ReceiveResponse", "SendResponse", "connect", + "parse_input", "recover", "register", "register_or_recover", diff --git a/libs/gl-sdk/src/input.rs b/libs/gl-sdk/src/input.rs index 5f6c7778d..f8c9a1d64 100644 --- a/libs/gl-sdk/src/input.rs +++ b/libs/gl-sdk/src/input.rs @@ -1,7 +1,11 @@ // Input parsing for BOLT11 invoices, Lightning node IDs, LNURL // strings, and Lightning Addresses. -// Works offline — no node connection or HTTP calls needed. +// +// `parse_input` is async and resolves LNURL endpoints over HTTP, +// returning a fully-typed `InputType` ready for the caller's next +// action (display the invoice, build a pay/withdraw request, etc.). +use crate::lnurl::{LnUrlPayRequestData, LnUrlWithdrawRequestData}; use crate::Error; /// Parsed BOLT11 invoice with extracted fields. @@ -23,28 +27,54 @@ pub struct ParsedInvoice { pub timestamp: u64, } -/// The result of parsing user input. +/// The result of `parse_input`: a fully-resolved input ready for the +/// caller's next action. LNURL bech32 strings and Lightning Addresses +/// are resolved over HTTP into typed pay or withdraw request data. #[derive(Clone, uniffi::Enum)] pub enum InputType { - /// A BOLT11 Lightning invoice. + /// A BOLT11 Lightning invoice. No HTTP was performed. Bolt11 { invoice: ParsedInvoice }, - /// A Lightning node public key (66 hex characters, 33 bytes compressed). + /// A Lightning node public key. No HTTP was performed. NodeId { node_id: String }, - /// An LNURL bech32 string (LUD-01). The `url` field contains the - /// decoded URL. Call `Node::resolve_lnurl()` to determine whether - /// this is a pay or withdraw endpoint. - LnUrl { url: String }, - /// A Lightning Address (LUD-16), e.g. `user@domain.com`. - /// Call `Node::resolve_lnurl()` to resolve it to a pay request. + /// An LNURL-pay endpoint with the service's parameters fetched. + LnUrlPay { data: LnUrlPayRequestData }, + /// An LNURL-withdraw endpoint with the service's parameters fetched. + LnUrlWithdraw { data: LnUrlWithdrawRequestData }, +} + +/// Classify the input string offline before deciding whether to make +/// an HTTP request. Internal — `parse_input` is the public entry point. +enum Classification { + Bolt11(ParsedInvoice), + NodeId(String), + LnUrl { decoded_url: String, original: String }, LnUrlAddress { address: String }, } -/// Parse a string and identify its type. +/// Parse and resolve any supported input in one async call. +/// +/// Identifies BOLT11 invoices, node IDs, LNURL bech32 strings, and +/// Lightning Addresses. For LNURL and Lightning Address inputs, +/// performs the HTTP GET and returns the typed pay or withdraw request +/// data. For BOLT11 and node IDs, returns immediately without I/O. /// -/// Recognizes BOLT11 invoices, node IDs, LNURL bech32 strings, and -/// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes -/// automatically. Works offline — no node connection needed. -pub fn parse_input(input: String) -> Result { +/// Strips `lightning:` / `LIGHTNING:` prefixes automatically. +pub async fn parse_input(input: String) -> Result { + match classify(&input)? { + Classification::Bolt11(invoice) => Ok(InputType::Bolt11 { invoice }), + Classification::NodeId(node_id) => Ok(InputType::NodeId { node_id }), + Classification::LnUrl { decoded_url, original } => { + resolve_lnurl_endpoint(&decoded_url, &original).await + } + Classification::LnUrlAddress { address } => { + let url = gl_client::lnurl::pay::parse_lightning_address(&address) + .map_err(|e| Error::Other(e.to_string()))?; + resolve_lnurl_endpoint(&url, &address).await + } + } +} + +fn classify(input: &str) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Err(Error::Other("Empty input".to_string())); @@ -65,37 +95,64 @@ pub fn parse_input(input: String) -> Result { } // Try BOLT11 - if let Some(input_type) = try_parse_bolt11(stripped) { - return input_type; + if let Some(c) = try_parse_bolt11(stripped) { + return c; } // Try Lightning Address (user@domain) - if let Some(input_type) = try_parse_lightning_address(stripped) { - return Ok(input_type); + if let Some(c) = try_parse_lightning_address(stripped) { + return Ok(c); } // Try Node ID - if let Some(input_type) = try_parse_node_id(stripped) { - return Ok(input_type); + if let Some(c) = try_parse_node_id(stripped) { + return Ok(c); } Err(Error::Other("Unrecognized input".to_string())) } +async fn resolve_lnurl_endpoint(url: &str, original: &str) -> Result { + use gl_client::lnurl::models::LnUrlHttpClearnetClient; + use gl_client::lnurl::{LnUrlResponse, LNURL}; + + let client = LNURL::new(LnUrlHttpClearnetClient::new()); + let response = client + .resolve(url) + .await + .map_err(|e| Error::Other(e.to_string()))?; + + Ok(match response { + LnUrlResponse::Pay(d) => { + let mut data: LnUrlPayRequestData = d.into(); + data.lnurl = original.to_string(); + InputType::LnUrlPay { data } + } + LnUrlResponse::Withdraw(d) => { + let mut data: LnUrlWithdrawRequestData = d.into(); + data.lnurl = original.to_string(); + InputType::LnUrlWithdraw { data } + } + }) +} + /// Try parsing as an LNURL bech32 string (LUD-01). /// Returns None if the input doesn't look like an LNURL. -fn try_parse_lnurl(input: &str) -> Option> { +fn try_parse_lnurl(input: &str) -> Option> { if !input.to_uppercase().starts_with("LNURL1") { return None; } match gl_client::lnurl::utils::parse_lnurl(input) { - Ok(url) => Some(Ok(InputType::LnUrl { url })), + Ok(url) => Some(Ok(Classification::LnUrl { + decoded_url: url, + original: input.to_string(), + })), Err(e) => Some(Err(Error::Other(format!("Invalid LNURL: {}", e)))), } } /// Try parsing as a Lightning Address (LUD-16): `user@domain.tld`. -fn try_parse_lightning_address(input: &str) -> Option { +fn try_parse_lightning_address(input: &str) -> Option { let parts: Vec<&str> = input.split('@').collect(); if parts.len() != 2 { return None; @@ -116,14 +173,14 @@ fn try_parse_lightning_address(input: &str) -> Option { { return None; } - Some(InputType::LnUrlAddress { + Some(Classification::LnUrlAddress { address: input.to_string(), }) } /// Try parsing as a BOLT11 invoice. Returns None if the input doesn't /// look like an invoice, or Some(Result) if it does (even if malformed). -fn try_parse_bolt11(input: &str) -> Option> { +fn try_parse_bolt11(input: &str) -> Option> { let lower = input.to_lowercase(); if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") { return None; @@ -158,21 +215,19 @@ fn try_parse_bolt11(input: &str) -> Option> { .unwrap_or_default() .as_secs(); - Some(Ok(InputType::Bolt11 { - invoice: ParsedInvoice { - bolt11: input.to_string(), - payee_pubkey: Some(payee_pubkey), - payment_hash, - description, - amount_msat, - expiry, - timestamp, - }, - })) + Some(Ok(Classification::Bolt11(ParsedInvoice { + bolt11: input.to_string(), + payee_pubkey: Some(payee_pubkey), + payment_hash, + description, + amount_msat, + expiry, + timestamp, + }))) } /// Try parsing as a node ID (66-char hex → 33-byte compressed pubkey). -fn try_parse_node_id(input: &str) -> Option { +fn try_parse_node_id(input: &str) -> Option { if input.len() != 66 { return None; } @@ -184,114 +239,89 @@ fn try_parse_node_id(input: &str) -> Option { if bytes[0] != 0x02 && bytes[0] != 0x03 { return None; } - Some(InputType::NodeId { - node_id: input.to_string(), - }) + Some(Classification::NodeId(input.to_string())) } #[cfg(test)] mod tests { use super::*; - #[test] - fn test_parse_lnurl_string() { - let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - match parse_input(lnurl.to_string()).unwrap() { - InputType::LnUrl { url } => { - assert!(url.starts_with("https://")); - } - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), - } - } - - #[test] - fn test_parse_lnurl_lowercase() { - let lnurl = "lnurl1dp68gurn8ghj7cmfwp5x2unsw4hxktnrdakj7ctsdyhhvvf0d3h82unv9ucsaxqze2"; - match parse_input(lnurl.to_string()).unwrap() { - InputType::LnUrl { .. } => {} - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + fn variant_name(t: &InputType) -> &'static str { + match t { + InputType::Bolt11 { .. } => "Bolt11", + InputType::NodeId { .. } => "NodeId", + InputType::LnUrlPay { .. } => "LnUrlPay", + InputType::LnUrlWithdraw { .. } => "LnUrlWithdraw", } } #[test] - fn test_parse_lnurl_with_lightning_prefix() { - let input = "lightning:LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - match parse_input(input.to_string()).unwrap() { - InputType::LnUrl { .. } => {} - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + fn test_parse_input_bolt11_no_http() { + let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; + let result = crate::util::exec(parse_input(invoice.to_string())).unwrap(); + match result { + InputType::Bolt11 { invoice: parsed } => assert_eq!(parsed.amount_msat, Some(10)), + other => panic!("Expected Bolt11, got {}", variant_name(&other)), } } #[test] - fn test_parse_invalid_lnurl() { - let result = parse_input("LNURL1INVALIDDATA".to_string()); - assert!(result.is_err()); + fn test_parse_input_bolt11_with_lightning_prefix() { + let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; + let result = crate::util::exec(parse_input(format!("lightning:{}", invoice))).unwrap(); + assert!(matches!(result, InputType::Bolt11 { .. })); } #[test] - fn test_parse_lightning_address() { - match parse_input("user@example.com".to_string()).unwrap() { - InputType::LnUrlAddress { address } => { - assert_eq!(address, "user@example.com"); - } - other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), + fn test_parse_input_node_id_no_http() { + let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"; + let result = crate::util::exec(parse_input(node_id.to_string())).unwrap(); + match result { + InputType::NodeId { node_id: id } => assert_eq!(id, node_id), + other => panic!("Expected NodeId, got {}", variant_name(&other)), } } #[test] - fn test_parse_lightning_address_with_symbols() { - // LUD-16 allows a-z0-9-_. - match parse_input("sat.oshi-99@example.com".to_string()).unwrap() { - InputType::LnUrlAddress { address } => { - assert_eq!(address, "sat.oshi-99@example.com"); - } - other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), - } + fn test_parse_input_invalid_lnurl_errors_before_http() { + let result = crate::util::exec(parse_input("LNURL1INVALIDDATA".to_string())); + assert!(result.is_err()); } #[test] - fn test_parse_lightning_address_no_dot_in_domain() { - // "user@localhost" is not a valid Lightning Address - let result = parse_input("user@localhost".to_string()); - // Should fall through to "Unrecognized input" + fn test_parse_input_address_with_no_dot_in_domain_errors() { + let result = crate::util::exec(parse_input("user@localhost".to_string())); assert!(result.is_err()); } #[test] - fn test_parse_lightning_address_empty_parts() { - assert!(parse_input("@example.com".to_string()).is_err()); - assert!(parse_input("user@".to_string()).is_err()); + fn test_parse_input_empty_address_parts_errors() { + assert!(crate::util::exec(parse_input("@example.com".to_string())).is_err()); + assert!(crate::util::exec(parse_input("user@".to_string())).is_err()); } #[test] - fn test_existing_bolt11_still_works() { - // A known valid mainnet invoice - let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; - match parse_input(invoice.to_string()).unwrap() { - InputType::Bolt11 { invoice: parsed } => { - assert_eq!(parsed.amount_msat, Some(10)); - } - other => panic!("Expected Bolt11, got {:?}", variant_name(&other)), - } + fn test_parse_input_unrecognized_errors() { + assert!(crate::util::exec(parse_input("hello world".to_string())).is_err()); + assert!(crate::util::exec(parse_input("".to_string())).is_err()); + assert!(crate::util::exec(parse_input(" ".to_string())).is_err()); + assert!(crate::util::exec(parse_input( + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string() + )) + .is_err()); } #[test] - fn test_existing_node_id_still_works() { - // A compressed pubkey (starts with 02 or 03) - let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"; - match parse_input(node_id.to_string()).unwrap() { - InputType::NodeId { node_id: id } => assert_eq!(id, node_id), - other => panic!("Expected NodeId, got {:?}", variant_name(&other)), - } - } - - /// Helper for readable test failures. - fn variant_name(input: &InputType) -> &'static str { - match input { - InputType::Bolt11 { .. } => "Bolt11", - InputType::NodeId { .. } => "NodeId", - InputType::LnUrl { .. } => "LnUrl", - InputType::LnUrlAddress { .. } => "LnUrlAddress", - } + fn test_parse_input_invalid_node_id_errors() { + // 66 chars but starts with 0x04 (uncompressed pubkey prefix) + assert!(crate::util::exec(parse_input( + "04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619".to_string() + )) + .is_err()); + // 66 non-hex chars + assert!(crate::util::exec(parse_input( + "not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string() + )) + .is_err()); } } diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index dab3ac875..0d0b85061 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -48,7 +48,7 @@ pub use crate::{ lnurl::{ LnUrlErrorData, LnUrlPayRequest, LnUrlPayRequestData, LnUrlPayResult, LnUrlPaySuccessData, LnUrlWithdrawRequest, LnUrlWithdrawRequestData, - LnUrlWithdrawResult, LnUrlWithdrawSuccessData, ResolvedLnUrl, SuccessActionProcessed, + LnUrlWithdrawResult, LnUrlWithdrawSuccessData, SuccessActionProcessed, }, scheduler::Scheduler, signer::{Handle, Signer}, @@ -216,13 +216,17 @@ pub fn register_or_recover( } } -/// Parse a string and identify whether it's a BOLT11 invoice or a node ID. +/// Parse and resolve any supported input in one async call. +/// +/// For LNURL bech32 strings and Lightning Addresses this performs the +/// HTTP GET to the LNURL endpoint and returns typed pay or withdraw +/// request data. For BOLT11 invoices and node IDs it returns +/// immediately without I/O. /// /// Strips `lightning:` / `LIGHTNING:` prefixes automatically. -/// Works offline — no node connection needed. -#[uniffi::export] -pub fn parse_input(input: String) -> Result { - input::parse_input(input) +#[uniffi::export(async_runtime = "tokio")] +pub async fn parse_input(input: String) -> Result { + input::parse_input(input).await } #[derive(uniffi::Enum, Debug)] diff --git a/libs/gl-sdk/src/lnurl.rs b/libs/gl-sdk/src/lnurl.rs index 5ff01ecd3..53030c1f9 100644 --- a/libs/gl-sdk/src/lnurl.rs +++ b/libs/gl-sdk/src/lnurl.rs @@ -9,19 +9,11 @@ use gl_client::lnurl::models as wire; // ── Resolved endpoint data ────────────────────────────────────────── -/// Result of resolving an LNURL or lightning address via HTTP. -#[derive(Clone, uniffi::Enum)] -pub enum ResolvedLnUrl { - /// The endpoint is an LNURL-pay service (LUD-06). - Pay { data: LnUrlPayRequestData }, - /// The endpoint is an LNURL-withdraw service (LUD-03). - Withdraw { data: LnUrlWithdrawRequestData }, -} - /// Data from an LNURL-pay endpoint (LUD-06). /// /// Contains the service's accepted amount range and metadata. -/// Returned inside `ResolvedLnUrl::Pay` after resolving an LNURL. +/// Returned inside `InputType::LnUrlPay` after `parse_input` resolves +/// an LNURL or Lightning Address. #[derive(Clone, uniffi::Record)] pub struct LnUrlPayRequestData { /// The callback URL to request an invoice from. @@ -43,7 +35,8 @@ pub struct LnUrlPayRequestData { /// Data from an LNURL-withdraw endpoint (LUD-03). /// /// Contains the service's accepted withdrawal range and session key. -/// Returned inside `ResolvedLnUrl::Withdraw` after resolving an LNURL. +/// Returned inside `InputType::LnUrlWithdraw` after `parse_input` +/// resolves an LNURL. #[derive(Clone, uniffi::Record)] pub struct LnUrlWithdrawRequestData { /// The callback URL to submit the invoice to. @@ -67,7 +60,7 @@ pub struct LnUrlWithdrawRequestData { /// Combines the resolved service data with the user's chosen amount. #[derive(Clone, uniffi::Record)] pub struct LnUrlPayRequest { - /// The resolved pay request data from `resolve_lnurl()`. + /// The resolved pay request data from `parse_input()`. pub data: LnUrlPayRequestData, /// Amount to pay in millisatoshis. pub amount_msat: u64, @@ -89,7 +82,7 @@ pub struct LnUrlPayRequest { /// Combines the resolved service data with the user's chosen amount. #[derive(Clone, uniffi::Record)] pub struct LnUrlWithdrawRequest { - /// The resolved withdraw request data from `resolve_lnurl()`. + /// The resolved withdraw request data from `parse_input()`. pub data: LnUrlWithdrawRequestData, /// Amount to withdraw in millisatoshis. pub amount_msat: u64, @@ -215,19 +208,6 @@ impl From for SuccessActionProcessed { } } -impl From for ResolvedLnUrl { - fn from(r: gl_client::lnurl::LnUrlResponse) -> Self { - match r { - gl_client::lnurl::LnUrlResponse::Pay(data) => ResolvedLnUrl::Pay { - data: data.into(), - }, - gl_client::lnurl::LnUrlResponse::Withdraw(data) => ResolvedLnUrl::Withdraw { - data: data.into(), - }, - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 4032641f7..25ab1d813 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -454,62 +454,15 @@ impl Node { // ── LNURL methods ─────────────────────────────────────────── - /// Resolve an LNURL or Lightning Address to its endpoint data. - /// - /// Performs the HTTP GET to the LNURL endpoint and returns the - /// typed request data. The result tells you whether this is a - /// pay or withdraw request, and includes the service's parameters. - /// - /// Accepts an LNURL bech32 string, a decoded URL (from - /// `parse_input()`), or a Lightning Address (`user@domain`). - pub fn resolve_lnurl( - &self, - input: String, - ) -> Result { - use gl_client::lnurl::models::LnUrlHttpClearnetClient; - use gl_client::lnurl::LNURL; - - let lnurl_client = LNURL::new(LnUrlHttpClearnetClient::new()); - let trimmed = input.trim(); - - // Determine the URL to fetch - let url = if trimmed.contains('@') { - gl_client::lnurl::pay::parse_lightning_address(trimmed) - .map_err(|e| Error::Other(e.to_string()))? - } else if trimmed.to_uppercase().starts_with("LNURL1") { - gl_client::lnurl::utils::parse_lnurl(trimmed) - .map_err(|e| Error::Other(e.to_string()))? - } else { - // Assume it's already a decoded URL - trimmed.to_string() - }; - - let response = exec(lnurl_client.resolve(&url)) - .map_err(|e| Error::Other(e.to_string()))?; - - let mut resolved: crate::lnurl::ResolvedLnUrl = response.into(); - - // Preserve the original input as the lnurl field - match &mut resolved { - crate::lnurl::ResolvedLnUrl::Pay { data } => { - data.lnurl = trimmed.to_string(); - } - crate::lnurl::ResolvedLnUrl::Withdraw { data } => { - data.lnurl = trimmed.to_string(); - } - } - - Ok(resolved) - } - /// Execute an LNURL-pay flow (LUD-06). /// /// Sends the chosen amount (and optional comment) to the service's /// callback, receives and validates a BOLT11 invoice, pays it, and /// processes any success action (LUD-09/10). /// - /// Call `resolve_lnurl()` first to get the `LnUrlPayRequestData`, - /// then build an `LnUrlPayRequest` with the user's chosen amount. + /// Call the top-level `parse_input` first to obtain the + /// `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + /// user's chosen amount. pub fn lnurl_pay( &self, request: crate::lnurl::LnUrlPayRequest, @@ -608,8 +561,9 @@ impl Node { /// it to the service's callback URL, and the service pays it /// asynchronously. /// - /// Call `resolve_lnurl()` first to get the `LnUrlWithdrawRequestData`, - /// then build an `LnUrlWithdrawRequest` with the user's chosen amount. + /// Call the top-level `parse_input` first to obtain the + /// `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + /// with the user's chosen amount. pub fn lnurl_withdraw( &self, request: crate::lnurl::LnUrlWithdrawRequest, diff --git a/libs/gl-sdk/tests/test_lnurl.py b/libs/gl-sdk/tests/test_lnurl.py index 5c59b98bb..e80170b64 100644 --- a/libs/gl-sdk/tests/test_lnurl.py +++ b/libs/gl-sdk/tests/test_lnurl.py @@ -8,6 +8,8 @@ gl_sdk_node ── channel ── relay ── channel ── service_node (LNURL server) """ +import asyncio + from gltesting.fixtures import * # noqa: F401, F403 from pyln.testing.utils import wait_for @@ -62,57 +64,64 @@ def fund_and_connect(node_factory, bitcoind, lnurl_service): return relay -def test_resolve_lnurl_pay( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve an LNURL-pay endpoint via the SDK.""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) - - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) - data = resolved.data - assert data.min_sendable == lnurl_service.min_sendable - assert data.max_sendable == lnurl_service.max_sendable - assert len(data.description) > 0 - assert data.callback.startswith(lnurl_service.base_url) - finally: - sdk_node.disconnect() - - -def test_resolve_lnurl_withdraw( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve an LNURL-withdraw endpoint via the SDK.""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.withdraw_url) - - assert isinstance(resolved, glsdk.ResolvedLnUrl.WITHDRAW) - data = resolved.data - assert data.min_withdrawable == lnurl_service.min_withdrawable - assert data.max_withdrawable == lnurl_service.max_withdrawable - assert len(data.k1) > 0 - finally: - sdk_node.disconnect() - - -def test_resolve_lightning_address_url( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve a Lightning Address well-known URL (LUD-16).""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.lightning_address_url) +def test_parse_input_lnurl_pay(lnurl_service): + """parse_input on an LNURL-pay URL returns LnUrlPay with fetched data.""" + resolved = asyncio.run(glsdk.parse_input(lnurl_service.pay_url)) + + assert isinstance(resolved, glsdk.InputType.LN_URL_PAY) + data = resolved.data + assert data.min_sendable == lnurl_service.min_sendable + assert data.max_sendable == lnurl_service.max_sendable + assert len(data.description) > 0 + assert data.callback.startswith(lnurl_service.base_url) + assert data.lnurl == lnurl_service.pay_url + + +def test_parse_input_lnurl_withdraw(lnurl_service): + """parse_input on an LNURL-withdraw URL returns LnUrlWithdraw with fetched data.""" + resolved = asyncio.run(glsdk.parse_input(lnurl_service.withdraw_url)) + + assert isinstance(resolved, glsdk.InputType.LN_URL_WITHDRAW) + data = resolved.data + assert data.min_withdrawable == lnurl_service.min_withdrawable + assert data.max_withdrawable == lnurl_service.max_withdrawable + assert len(data.k1) > 0 + assert data.lnurl == lnurl_service.withdraw_url + + +def test_parse_input_lightning_address_url(lnurl_service): + """parse_input on a well-known LUD-16 URL returns LnUrlPay.""" + resolved = asyncio.run(glsdk.parse_input(lnurl_service.lightning_address_url)) + + assert isinstance(resolved, glsdk.InputType.LN_URL_PAY) + assert resolved.data.min_sendable == lnurl_service.min_sendable + assert resolved.data.lnurl == lnurl_service.lightning_address_url + + +def test_parse_input_bolt11_no_http(lnurl_service): + """parse_input on a BOLT11 invoice returns Bolt11 without touching HTTP.""" + invoice = ( + "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45t" + "qcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tn" + "k2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw" + "3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffc" + "rf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8" + "h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrz" + "jqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqq" + "yqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmu" + "wvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8" + "v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62" + "g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqq" + "qqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5" + "m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg" + ) + resolved = asyncio.run(glsdk.parse_input(invoice)) - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) - data = resolved.data - assert data.min_sendable == lnurl_service.min_sendable - finally: - sdk_node.disconnect() + assert isinstance(resolved, glsdk.InputType.BOLT11) + assert resolved.invoice.amount_msat == 10 + # No callback recorded on the LNURL service since we never hit it. + assert len(lnurl_service.pay_callbacks) == 0 + assert len(lnurl_service.withdraw_callbacks) == 0 def test_lnurl_pay_end_to_end( @@ -160,8 +169,8 @@ def test_lnurl_pay_end_to_end( try: # Resolve - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) + resolved = asyncio.run(glsdk.parse_input(lnurl_service.pay_url)) + assert isinstance(resolved, glsdk.InputType.LN_URL_PAY) pay_data = resolved.data amount_msat = 50_000 # 50 sats @@ -226,7 +235,8 @@ def test_lnurl_pay_with_message_success_action( sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) try: - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) + resolved = asyncio.run(glsdk.parse_input(lnurl_service.pay_url)) + assert isinstance(resolved, glsdk.InputType.LN_URL_PAY) pay_data = resolved.data result = sdk_node.lnurl_pay( diff --git a/libs/gl-sdk/tests/test_parse_input.py b/libs/gl-sdk/tests/test_parse_input.py index 30089b8d3..d08a41907 100644 --- a/libs/gl-sdk/tests/test_parse_input.py +++ b/libs/gl-sdk/tests/test_parse_input.py @@ -1,8 +1,12 @@ -"""Tests for the parse_input() free function. +"""Tests for the async parse_input() free function. -Verifies BOLT11 invoice and node ID parsing from arbitrary string input. +Covers BOLT11 invoice and node ID parsing — paths that complete without +HTTP. LNURL and Lightning Address parsing are exercised in +test_lnurl.py against the live LNURL fixture. """ +import asyncio + import pytest import glsdk @@ -19,6 +23,10 @@ VALID_NODE_ID = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" +def parse(input_str): + return asyncio.run(glsdk.parse_input(input_str)) + + class TestParseInputTypes: """Test that parse_input types exist in the bindings.""" @@ -33,69 +41,67 @@ def test_parse_input_function_exists(self): class TestParseInputNodeId: - """Test node ID parsing.""" + """Test node ID parsing — no HTTP required.""" def test_parse_valid_node_id(self): - result = glsdk.parse_input(VALID_NODE_ID) - assert isinstance(result, glsdk.InputType) - - def test_parse_node_id_returns_correct_value(self): - result = glsdk.parse_input(VALID_NODE_ID) - # Access the NodeId variant - assert result.is_node_id() if hasattr(result, 'is_node_id') else True - # UniFFI enums in Python: check the variant - assert hasattr(result, 'node_id') or hasattr(result, 'invoice') + result = parse(VALID_NODE_ID) + assert isinstance(result, glsdk.InputType.NODE_ID) + assert result.node_id == VALID_NODE_ID def test_invalid_hex_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input("not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx") + parse("not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx") def test_wrong_length_hex_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input("02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283") + parse("02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283") def test_wrong_prefix_hex_returns_error(self): # 04 prefix = uncompressed pubkey, not valid for Lightning with pytest.raises(glsdk.Error): - glsdk.parse_input("04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619") + parse("04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619") class TestParseInputBolt11: - """Test BOLT11 invoice parsing.""" + """Test BOLT11 invoice parsing — no HTTP required.""" def test_parse_valid_bolt11(self): - result = glsdk.parse_input(BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + result = parse(BOLT11_INVOICE) + assert isinstance(result, glsdk.InputType.BOLT11) def test_parse_bolt11_with_lightning_prefix(self): - result = glsdk.parse_input("lightning:" + BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + result = parse("lightning:" + BOLT11_INVOICE) + assert isinstance(result, glsdk.InputType.BOLT11) def test_parse_bolt11_with_uppercase_prefix(self): - result = glsdk.parse_input("LIGHTNING:" + BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + result = parse("LIGHTNING:" + BOLT11_INVOICE) + assert isinstance(result, glsdk.InputType.BOLT11) def test_parse_bolt11_with_whitespace(self): - result = glsdk.parse_input(" " + BOLT11_INVOICE + " ") - assert isinstance(result, glsdk.InputType) + result = parse(" " + BOLT11_INVOICE + " ") + assert isinstance(result, glsdk.InputType.BOLT11) class TestParseInputErrors: - """Test error cases.""" + """Test error cases that don't require HTTP.""" def test_empty_string_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input("") + parse("") def test_whitespace_only_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input(" ") + parse(" ") def test_garbage_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input("hello world") + parse("hello world") def test_bitcoin_address_returns_error(self): # We don't support bitcoin addresses yet with pytest.raises(glsdk.Error): - glsdk.parse_input("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + parse("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + + def test_invalid_lnurl_bech32_errors_before_http(self): + with pytest.raises(glsdk.Error): + parse("LNURL1INVALIDDATA") From be3ca81ba774f6a5361a14c6763fce5135d1a3d6 Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Mon, 27 Apr 2026 10:29:21 +0300 Subject: [PATCH 13/13] gl-sdk: Add LNURL-auth (LUD-04 / LUD-05) and unify Node entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Node::lnurl_auth(request) implementing LUD-04 / LUD-05. The LUD-05 BIP32 namespace xpriv at m/138' is derived once from the BIP39 seed at register/recover/connect time and stored on Node in Zeroizing>; the seed itself never persists. Because m/138' is hardened, the stored material cannot derive any other wallet key (lightning-channel keys, on-chain wallet) — the blast radius is restricted to LNURL-auth identities. disconnect() and Drop for Node both take() the option to scrub eagerly. The LUD-05 HMAC key is the 32-byte private key at m/138'/0, matching the mainstream wallet convention (Phoenix, Mutiny, Zeus, BlueWallet) for cross-wallet identity portability — locked in by a known-vector test against the "abandon...about" BIP39 mnemonic at example.com. InputType gains an LnUrlAuth { data: LnUrlAuthRequestData } variant. Detection is offline: tag=login URLs are classified from the URL query string without an HTTP fetch. New types LnUrlAuthRequestData and LnUrlCallbackStatus join the existing LnUrl* family. Removes the bare Node(credentials) constructor from both UniFFI and napi binding surfaces — there are now zero public Node constructors. The crate-private constructor is renamed with_signer → new (pub(crate)) and now requires lnurl_auth_xpriv: Zeroizing> at construction; only disconnect()/Drop produce None thereafter, and lnurl_auth errors with "LNURL-auth namespace key has been scrubbed" on the post-scrub path. Four uniform entry points across all bindings: register / recover / connect / register_or_recover. The napi binding gains those free fns plus a Config wrapper (withNetwork, withDeveloperCert) and Node.disconnect() / Node.credentials() — JS callers no longer manage an external Signer/Handle. Tests: 28 Rust unit tests cover offline classification, LUD-05 derivation determinism, signature verification against the derived xpub, callback-status JSON parsing, and the fixed pubkey vector. The LNURL fixture (gl-testing/lnurl_server.py) gains an /auth route with ECDSA verification via coincurve. Python integration tests cover classification-without-HTTP, the end-to-end auth flow, deterministic per-domain pubkey, and the disconnect-scrubs-key contract. The two LNURL-pay end-to-end tests are un-skipped via a _bip39_seed helper that aligns the gl-client clients fixture seed format with glsdk.connect's mnemonic-derived 64-byte seed (gl-testing's defensive len(secret) == 32 assert relaxed to (32, 64) accordingly). Android gains 5 new offline LnUrlAuth parse cases. napi tests rewritten to use the new register / recover / connect free fns instead of new Node(credentials); the README's quickstart sample follows suit. --- Cargo.lock | 10 + .../com/blockstream/glsdk/LnurlParseTest.kt | 55 ++- libs/gl-sdk-napi/README.md | 40 +-- libs/gl-sdk-napi/src/lib.rs | 237 ++++++++++++- libs/gl-sdk-napi/tests/basic.spec.ts | 18 +- libs/gl-sdk-napi/tests/eventstream.spec.ts | 113 +++---- libs/gl-sdk-napi/tests/misc.spec.ts | 108 +++--- libs/gl-sdk-napi/tests/node.spec.ts | 18 +- libs/gl-sdk-napi/tests/test.helper.ts | 40 ++- libs/gl-sdk/CHANGELOG.md | 9 +- libs/gl-sdk/Cargo.toml | 3 + libs/gl-sdk/glsdk/glsdk.py | 320 +++++++++++++++++- libs/gl-sdk/src/auth.rs | 256 ++++++++++++++ libs/gl-sdk/src/input.rs | 123 ++++++- libs/gl-sdk/src/lib.rs | 23 +- libs/gl-sdk/src/lnurl.rs | 30 ++ libs/gl-sdk/src/node.rs | 86 +++-- libs/gl-sdk/tests/test_auth_api.py | 18 - libs/gl-sdk/tests/test_basic.py | 14 +- libs/gl-sdk/tests/test_lnurl.py | 116 ++++++- libs/gl-testing/gltesting/clients.py | 8 +- libs/gl-testing/gltesting/lnurl_server.py | 56 +++ 22 files changed, 1430 insertions(+), 271 deletions(-) create mode 100644 libs/gl-sdk/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 98aa5e193..02589f5a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,12 +1655,15 @@ dependencies = [ "hex", "lightning-invoice", "once_cell", + "reqwest", + "serde_json", "thiserror 2.0.17", "tokio", "tonic 0.11.0", "tracing", "uniffi", "url", + "zeroize", ] [[package]] @@ -3468,6 +3471,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] @@ -5161,6 +5165,12 @@ dependencies = [ "untrusted 0.9.0", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "weedle2" version = "5.0.0" diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt index 0c3ddf431..9d6c319fa 100644 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt @@ -1,20 +1,25 @@ // Instrumented tests for parse_input() error-before-HTTP cases on -// LNURL / Lightning Address inputs. +// LNURL / Lightning Address inputs and the LUD-04 tag=login fast path. // -// Successful resolution requires a reachable LNURL service and is -// covered by gl-testing integration tests, not by Android instrumented -// tests. +// Successful resolution of LNURL-pay / LNURL-withdraw requires a +// reachable LNURL service and is covered by gl-testing integration +// tests, not by Android instrumented tests. package com.blockstream.glsdk import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.runBlocking +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LnurlParseTest { + // 32 zero bytes — a syntactically valid k1. + private val zeroK1 = + "0000000000000000000000000000000000000000000000000000000000000000" + @Test(expected = Exception::class) fun parse_invalid_lnurl_bech32_returns_error(): Unit = runBlocking { parseInput("LNURL1INVALIDDATA") @@ -34,4 +39,46 @@ class LnurlParseTest { fun parse_lightning_address_empty_domain_returns_error(): Unit = runBlocking { parseInput("user@") } + + // ============================================================ + // LUD-04 tag=login — classified offline (no HTTP fetch) + // ============================================================ + + @Test + fun parse_lnurl_auth_url_classifies_as_lnurlauth() = runBlocking { + val url = "https://service.example.com/auth?tag=login&k1=$zeroK1" + val result = parseInput(url) + assertTrue( + "Expected LnUrlAuth, got $result", + result is InputType.LnUrlAuth, + ) + val data = (result as InputType.LnUrlAuth).data + assertEquals(zeroK1, data.k1) + assertEquals("service.example.com", data.domain) + assertNull(data.action) + assertEquals(url, data.url) + } + + @Test + fun parse_lnurl_auth_url_captures_action() = runBlocking { + val url = "https://x.com/a?tag=login&k1=$zeroK1&action=register" + val result = parseInput(url) + assertTrue(result is InputType.LnUrlAuth) + assertEquals("register", (result as InputType.LnUrlAuth).data.action) + } + + @Test(expected = Exception::class) + fun parse_lnurl_auth_rejects_missing_k1(): Unit = runBlocking { + parseInput("https://x.com/a?tag=login") + } + + @Test(expected = Exception::class) + fun parse_lnurl_auth_rejects_short_k1(): Unit = runBlocking { + parseInput("https://x.com/a?tag=login&k1=deadbeef") + } + + @Test(expected = Exception::class) + fun parse_lnurl_auth_rejects_unknown_action(): Unit = runBlocking { + parseInput("https://x.com/a?tag=login&k1=$zeroK1&action=bogus") + } } diff --git a/libs/gl-sdk-napi/README.md b/libs/gl-sdk-napi/README.md index 6a6bf5e61..f620c9f91 100644 --- a/libs/gl-sdk-napi/README.md +++ b/libs/gl-sdk-napi/README.md @@ -86,46 +86,24 @@ gl-sdk-napi/ **Streaming**: streamNodeEvents() runs as a background task — call startEventStream(node) without await so it listens for events concurrently while your app continues calling other node methods. When you call node.stop(), next() returns null and the loop exits cleanly. ```typescript -import { Scheduler, Signer, Node, Credentials, OnchainReceiveResponse, NodeEvent, NodeEventStream } from '@greenlightcln/glsdk'; +import { Config, Node, NodeEvent, NodeEventStream, registerOrRecover } from '@greenlightcln/glsdk'; type Network = 'bitcoin' | 'regtest'; class GreenlightApp { - private credentials: Credentials | null = null; - private scheduler: Scheduler; - private signer: Signer; + private config: Config; + private mnemonic: string; private node: Node | null = null; constructor(phrase: string, network: Network) { - this.scheduler = new Scheduler(network); - this.signer = new Signer(phrase); - console.log(`✓ Signer created. Node ID: ${this.signer.nodeId().toString('hex')}`); + this.config = new Config().withNetwork(network); + this.mnemonic = phrase; } - async registerOrRecover(inviteCode?: string): Promise { - try { - console.log('Attempting to register node...'); - this.credentials = await this.scheduler.register(this.signer, inviteCode || ''); - console.log('✓ Node registered successfully'); - } catch (e: any) { - const match = e.message.match(/message: "([^"]+)"/); - console.error(`✗ Registration failed: ${match ? match[1] : e.message}`); - console.log('Attempting recovery...'); - try { - this.credentials = await this.scheduler.recover(this.signer); - console.log('✓ Node recovered successfully'); - } catch (recoverError) { - console.error('✗ Recovery failed:', recoverError); - throw recoverError; - } - } - } - - createNode(): Node { - if (!this.credentials) { throw new Error('Must register/recover before creating node'); } - console.log('Creating node instance...'); - this.node = new Node(this.credentials); - console.log('✓ Node created'); + async registerOrRecover(inviteCode?: string): Promise { + console.log('Attempting register-or-recover...'); + this.node = await registerOrRecover(this.mnemonic, inviteCode, this.config); + console.log('✓ Node ready'); return this.node; } diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index ac055b0e3..de8f8dcd2 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -7,10 +7,12 @@ use napi_derive::napi; use glsdk::{ // Enum types for conversion ChannelState as GlChannelState, + Config as GlConfig, Credentials as GlCredentials, DeveloperCert as GlDeveloperCert, Handle as GlHandle, InputType as GlInputType, + LnUrlCallbackStatus as GlLnUrlCallbackStatus, Network as GlNetwork, Node as GlNode, NodeEvent as GlNodeEvent, @@ -204,11 +206,11 @@ pub struct ParsedInvoice { } /// Parsed input. Discriminated by `type` field. Exactly one of the -/// variant fields (`bolt11`, `node_id`, `lnurl_pay`, `lnurl_withdraw`) -/// is populated based on the discriminant. +/// variant fields (`bolt11`, `node_id`, `lnurl_pay`, `lnurl_withdraw`, +/// `lnurl_auth`) is populated based on the discriminant. #[napi(object)] pub struct InputType { - /// "bolt11" | "node_id" | "lnurl_pay" | "lnurl_withdraw" + /// "bolt11" | "node_id" | "lnurl_pay" | "lnurl_withdraw" | "lnurl_auth" pub r#type: String, /// Present when type == "bolt11" pub bolt11: Option, @@ -218,6 +220,29 @@ pub struct InputType { pub lnurl_pay: Option, /// Present when type == "lnurl_withdraw" pub lnurl_withdraw: Option, + /// Present when type == "lnurl_auth" + pub lnurl_auth: Option, +} + +#[napi(object)] +pub struct LnUrlAuthRequestData { + /// Hex-encoded 32-byte challenge from the service. + pub k1: String, + /// One of "register" | "login" | "link" | "auth", or null. + pub action: Option, + /// Domain of the service (for user-facing confirmation prompts). + pub domain: String, + /// Full URL of the LNURL-auth service. + pub url: String, +} + +/// Result of an LNURL-auth callback (LUD-04). Discriminated by `type`. +#[napi(object)] +pub struct LnUrlCallbackStatus { + /// "ok" | "error" + pub r#type: String, + /// Present when type == "error" + pub error: Option, } // ============================================================================ @@ -358,6 +383,11 @@ pub struct Handle { inner: GlHandle, } +#[napi] +pub struct Config { + inner: std::sync::Arc, +} + #[napi] pub struct Node { inner: std::sync::Arc, @@ -585,19 +615,108 @@ impl NodeEventStream { } #[napi] -impl Node { - /// Create a new node connection - /// - /// # Arguments - /// * `credentials` - Device credentials +impl Config { + /// Create a `Config` with default settings: BITCOIN network, no + /// developer certificate. #[napi(constructor)] - pub fn new(credentials: &Credentials) -> Result { - // Constructor stays sync — connection is established lazily - let inner = - GlNode::new(&credentials.inner).map_err(|e| Error::from_reason(e.to_string()))?; + pub fn new() -> Self { + Self { inner: std::sync::Arc::new(GlConfig::new()) } + } - Ok(Self { inner: std::sync::Arc::new(inner) }) + /// Return a new Config with the given developer certificate. + #[napi] + pub fn with_developer_cert(&self, cert: &DeveloperCert) -> Config { + let updated = self.inner.with_developer_cert(&cert.inner); + // `Arc` from gl-sdk → unwrap and re-wrap so napi + // owns its own Arc. + Self { + inner: std::sync::Arc::new((*updated).clone()), + } + } + + /// Return a new Config with the given network ("bitcoin" | + /// "regtest"). + #[napi] + pub fn with_network(&self, network: String) -> Result { + let gl_network = match network.to_lowercase().as_str() { + "bitcoin" => GlNetwork::BITCOIN, + "regtest" => GlNetwork::REGTEST, + _ => { + return Err(Error::from_reason(format!( + "Invalid network: {}. Must be 'bitcoin' or 'regtest'", + network + ))) + } + }; + let updated = self.inner.with_network(gl_network); + Ok(Self { + inner: std::sync::Arc::new((*updated).clone()), + }) } +} + +/// Register a new Greenlight node and return a connected Node with +/// the SDK signer running. +#[napi] +pub async fn register( + mnemonic: String, + invite_code: Option, + config: &Config, +) -> Result { + let cfg = config.inner.clone(); + let arc = tokio::task::spawn_blocking(move || { + glsdk::register(mnemonic, invite_code, &cfg).map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + Ok(Node { inner: arc }) +} + +/// Recover credentials for an existing Greenlight node and return a +/// connected Node with the SDK signer running. +#[napi] +pub async fn recover(mnemonic: String, config: &Config) -> Result { + let cfg = config.inner.clone(); + let arc = tokio::task::spawn_blocking(move || { + glsdk::recover(mnemonic, &cfg).map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + Ok(Node { inner: arc }) +} + +/// Connect to an existing Greenlight node using saved credentials. +#[napi] +pub async fn connect(mnemonic: String, credentials: Buffer, config: &Config) -> Result { + let cfg = config.inner.clone(); + let creds_bytes = credentials.to_vec(); + let arc = tokio::task::spawn_blocking(move || { + glsdk::connect(mnemonic, creds_bytes, &cfg).map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + Ok(Node { inner: arc }) +} + +/// Try to recover an existing node; if none exists, register a new one. +#[napi] +pub async fn register_or_recover( + mnemonic: String, + invite_code: Option, + config: &Config, +) -> Result { + let cfg = config.inner.clone(); + let arc = tokio::task::spawn_blocking(move || { + glsdk::register_or_recover(mnemonic, invite_code, &cfg) + .map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + Ok(Node { inner: arc }) +} + +#[napi] +impl Node { /// Stop the node if it is currently running #[napi] @@ -612,6 +731,26 @@ impl Node { .map_err(|e| Error::from_reason(e.to_string()))? } + /// Disconnect from the node and stop the SDK signer if running. + /// Also scrubs the LNURL-auth namespace key from memory. Safe to + /// call multiple times. + #[napi] + pub fn disconnect(&self) -> Result<()> { + self.inner + .disconnect() + .map_err(|e| Error::from_reason(e.to_string())) + } + + /// Return the serialized credentials so the wallet can persist + /// them and reuse via `connect(mnemonic, credentials, config)`. + #[napi] + pub fn credentials(&self) -> Result { + self.inner + .credentials() + .map(Buffer::from) + .map_err(|e| Error::from_reason(e.to_string())) + } + /// Receive a payment (generate invoice with JIT channel support) /// /// # Arguments @@ -918,6 +1057,31 @@ impl Node { Ok(napi_lnurl_withdraw_result_from_gl(result)) } + + /// Execute an LNURL-auth (LUD-04) callback. + /// + /// Uses the LNURL-auth namespace xpriv (`m/138'`) that the Node + /// derived once from the BIP39 seed at register/recover/connect + /// time. `Node` never re-touches the seed; the namespace xpriv is + /// scrubbed on disconnect/drop. Returns an error when the Node + /// was constructed without a mnemonic. + #[napi] + pub async fn lnurl_auth( + &self, + request: LnUrlAuthRequestData, + ) -> Result { + let inner = self.inner.clone(); + let gl_request = gl_lnurl_auth_request_data_from_napi(request); + let status = tokio::task::spawn_blocking(move || { + inner + .lnurl_auth(gl_request) + .map_err(|e| Error::from_reason(e.to_string())) + }) + .await + .map_err(|e| Error::from_reason(e.to_string()))??; + + Ok(napi_lnurl_callback_status_from_gl(status)) + } } // ============================================================================ @@ -1020,6 +1184,7 @@ fn napi_input_type_from_gl(input: GlInputType) -> InputType { node_id: None, lnurl_pay: None, lnurl_withdraw: None, + lnurl_auth: None, }, GlInputType::NodeId { node_id } => InputType { r#type: "node_id".to_string(), @@ -1027,6 +1192,7 @@ fn napi_input_type_from_gl(input: GlInputType) -> InputType { node_id: Some(node_id), lnurl_pay: None, lnurl_withdraw: None, + lnurl_auth: None, }, GlInputType::LnUrlPay { data } => InputType { r#type: "lnurl_pay".to_string(), @@ -1034,6 +1200,7 @@ fn napi_input_type_from_gl(input: GlInputType) -> InputType { node_id: None, lnurl_pay: Some(napi_pay_request_data_from_gl(data)), lnurl_withdraw: None, + lnurl_auth: None, }, GlInputType::LnUrlWithdraw { data } => InputType { r#type: "lnurl_withdraw".to_string(), @@ -1041,6 +1208,50 @@ fn napi_input_type_from_gl(input: GlInputType) -> InputType { node_id: None, lnurl_pay: None, lnurl_withdraw: Some(napi_withdraw_request_data_from_gl(data)), + lnurl_auth: None, + }, + GlInputType::LnUrlAuth { data } => InputType { + r#type: "lnurl_auth".to_string(), + bolt11: None, + node_id: None, + lnurl_pay: None, + lnurl_withdraw: None, + lnurl_auth: Some(napi_lnurl_auth_request_data_from_gl(data)), + }, + } +} + +fn napi_lnurl_auth_request_data_from_gl( + data: glsdk::LnUrlAuthRequestData, +) -> LnUrlAuthRequestData { + LnUrlAuthRequestData { + k1: data.k1, + action: data.action, + domain: data.domain, + url: data.url, + } +} + +fn gl_lnurl_auth_request_data_from_napi( + data: LnUrlAuthRequestData, +) -> glsdk::LnUrlAuthRequestData { + glsdk::LnUrlAuthRequestData { + k1: data.k1, + action: data.action, + domain: data.domain, + url: data.url, + } +} + +fn napi_lnurl_callback_status_from_gl(status: GlLnUrlCallbackStatus) -> LnUrlCallbackStatus { + match status { + GlLnUrlCallbackStatus::Ok => LnUrlCallbackStatus { + r#type: "ok".to_string(), + error: None, + }, + GlLnUrlCallbackStatus::ErrorStatus { data } => LnUrlCallbackStatus { + r#type: "error".to_string(), + error: Some(LnUrlErrorData { reason: data.reason }), }, } } diff --git a/libs/gl-sdk-napi/tests/basic.spec.ts b/libs/gl-sdk-napi/tests/basic.spec.ts index 357834061..16aa1846e 100644 --- a/libs/gl-sdk-napi/tests/basic.spec.ts +++ b/libs/gl-sdk-napi/tests/basic.spec.ts @@ -1,21 +1,15 @@ import * as crypto from 'crypto'; import * as bip39 from 'bip39'; -import { Credentials, Scheduler, Signer, Node } from '../index.js'; +import { Config, register } from '../index.js'; describe('Greenlight node', () => { - it('can be setup', async () => { - const scheduler = new Scheduler('regtest'); + it('can be set up via register()', async () => { const rand: Buffer = crypto.randomBytes(16); - const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString("hex")); - const signer = new Signer(MNEMONIC); - const handle = await signer.start(); - const nodeId = signer.nodeId(); - expect(Buffer.isBuffer(nodeId)).toBe(true); - expect(nodeId.length).toBeGreaterThan(0); - const credentials = await scheduler.register(signer); - const node = new Node(credentials); + const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString('hex')); + const config = new Config().withNetwork('regtest'); + const node = await register(MNEMONIC, undefined, config); expect(node).toBeTruthy(); - handle.stop(); + node.disconnect(); await node.stop(); }); }); diff --git a/libs/gl-sdk-napi/tests/eventstream.spec.ts b/libs/gl-sdk-napi/tests/eventstream.spec.ts index b41fd4768..91b73e8b8 100644 --- a/libs/gl-sdk-napi/tests/eventstream.spec.ts +++ b/libs/gl-sdk-napi/tests/eventstream.spec.ts @@ -1,13 +1,14 @@ import * as crypto from 'crypto'; import * as bip39 from 'bip39'; -import { Credentials, Scheduler, Signer, Node, NodeEventStream, NodeEvent, InvoicePaidEvent, Handle } from '../index.js'; +import { Config, Node, NodeEventStream, NodeEvent, InvoicePaidEvent, register } from '../index.js'; import { fundWallet, getGLNode } from './test.helper.js'; +const REGTEST = () => new Config().withNetwork('regtest'); + describe('NodeEvent (type contract)', () => { it('NodeEvent and InvoicePaidEvent are assignable from NAPI-generated types', () => { - // This is a compile-time check only. If the NAPI bindings change the - // field names or types, tsc will fail here before Jest even runs. - // The runtime assertion is intentionally trivial. + // Compile-time check: if NAPI bindings change the field names or + // types, tsc will fail here before Jest even runs. const details: InvoicePaidEvent = { paymentHash: Buffer.from('deadbeef', 'hex'), bolt11: 'lnbcrt1...', @@ -17,8 +18,6 @@ describe('NodeEvent (type contract)', () => { }; const event: NodeEvent = { eventType: 'invoice_paid', invoicePaid: details }; - // Only assert what tsc cannot: that the import itself resolved and - // the constructed value is truthy (i.e. the module loaded correctly). expect(event).toBeDefined(); }); }); @@ -28,12 +27,10 @@ describe('NodeEvent (type contract)', () => { // ============================================================================ describe('NodeEventStream (integration)', () => { - let scheduler: Scheduler = new Scheduler('regtest'); let node: Node; - let handle: Handle; beforeAll(async () => { - ({node, handle } = await getGLNode(scheduler, false) as { node: Node; handle: Handle }); + ({ node } = await getGLNode(new (require('../index.js').Scheduler)('regtest'), false)); try { const probe = await node.streamNodeEvents(); void probe; @@ -45,12 +42,12 @@ describe('NodeEventStream (integration)', () => { afterAll(async () => { if (node) { - handle.stop(); - await node.stop(); + try { node.disconnect(); } catch {} + try { await node.stop(); } catch {} } }); - it('does not throw error on future unknown event type', () => { + it('does not throw on a future unknown event type', () => { const unknownEvent: NodeEvent = { eventType: 'new_future_event' as string, invoicePaid: undefined }; expect(() => { @@ -71,13 +68,12 @@ describe('NodeEventStream (integration)', () => { it('next resolves to null or a well-formed NodeEvent within 2 seconds', async () => { const stream: NodeEventStream = await node.streamNodeEvents(); - // Race next() against a 2 s timeout — no live events is fine here. const result = await Promise.race([ stream.next(), new Promise(resolve => setTimeout(() => resolve(null), 2_000)), ]); - if (result === null) return; // timed out, no events — acceptable + if (result === null) return; expect(result).toHaveProperty('eventType'); expect(typeof result.eventType).toBe('string'); @@ -92,60 +88,53 @@ describe('NodeEventStream (integration)', () => { it('next returns null after the node is stopped', async () => { const mnemonic2 = bip39.entropyToMnemonic(crypto.randomBytes(16).toString('hex')); - const signer2 = new Signer(mnemonic2); - const handle2 = await signer2.start(); - const credentials2 = await scheduler.register(signer2); - let node2 = new Node(credentials2); + let node2: Node | null = await register(mnemonic2, undefined, REGTEST()); const stream: NodeEventStream = await node2.streamNodeEvents(); await node2.stop(); const result = await stream.next(); expect(result).toBeNull(); - node2 = new Node(credentials2); - handle2.stop() - await node2.stop(); + try { node2.disconnect(); } catch {} + node2 = null; }); it.skip('receives real invoice_paid event', async () => { - await fundWallet(node, 500_000_000); - const { node: node2, handle: handle2 } = await getGLNode(scheduler, true) as { node: Node; handle: Handle }; - const stream: NodeEventStream = await node.streamNodeEvents(); - const label = `jest-${Date.now()}`; - const receiveRes = await node.receive(label, 'jest event stream test', 1_000); - const sendResponse = await node2.send(receiveRes.bolt11); - expect(sendResponse).toBeTruthy(); - - let paid: NodeEvent | null = null; - const deadline = Date.now() + 10_000; - - while (Date.now() < deadline) { - const event = await Promise.race([ - stream.next(), - new Promise(resolve => - setTimeout(() => resolve(null), deadline - Date.now()) - ), - ]); - - if (event === null) break; - if (event.eventType === 'invoice_paid') { paid = event; break; } - } - - expect(paid).not.toBeNull(); - expect(paid!.eventType).toBe('invoice_paid'); - - const p = paid!.invoicePaid!; - expect(p).toBeDefined(); - expect(Buffer.isBuffer(p.paymentHash)).toBe(true); - expect(p.paymentHash.length).toBeGreaterThan(0); - expect(Buffer.isBuffer(p.preimage)).toBe(true); - expect(p.preimage.length).toBeGreaterThan(0); - expect(p.bolt11).toBe(receiveRes.bolt11); - expect(p.label).toBe(label); - expect(typeof p.amountMsat).toBe('number'); - expect(p.amountMsat).toBeGreaterThan(0); - handle2.stop(); - await node2.stop(); - }, - 15_000 // extended timeout for payment round-trip - ); + await fundWallet(node, 500_000_000); + const { node: node2 } = await getGLNode(new (require('../index.js').Scheduler)('regtest'), true); + const stream: NodeEventStream = await node.streamNodeEvents(); + const label = `jest-${Date.now()}`; + const receiveRes = await node.receive(label, 'jest event stream test', 1_000); + const sendResponse = await node2.send(receiveRes.bolt11); + expect(sendResponse).toBeTruthy(); + + let paid: NodeEvent | null = null; + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + const event = await Promise.race([ + stream.next(), + new Promise(resolve => + setTimeout(() => resolve(null), deadline - Date.now()) + ), + ]); + + if (event === null) break; + if (event.eventType === 'invoice_paid') { paid = event; break; } + } + expect(paid).not.toBeNull(); + expect(paid!.eventType).toBe('invoice_paid'); + + const p = paid!.invoicePaid!; + expect(p).toBeDefined(); + expect(Buffer.isBuffer(p.paymentHash)).toBe(true); + expect(p.paymentHash.length).toBeGreaterThan(0); + expect(Buffer.isBuffer(p.preimage)).toBe(true); + expect(p.preimage.length).toBeGreaterThan(0); + expect(p.bolt11).toBe(receiveRes.bolt11); + expect(p.label).toBe(label); + expect(typeof p.amountMsat).toBe('number'); + expect(p.amountMsat).toBeGreaterThan(0); + try { node2.disconnect(); } catch {} + await node2.stop(); + }, 15_000); }); diff --git a/libs/gl-sdk-napi/tests/misc.spec.ts b/libs/gl-sdk-napi/tests/misc.spec.ts index bbccdfd50..81c5b5d06 100644 --- a/libs/gl-sdk-napi/tests/misc.spec.ts +++ b/libs/gl-sdk-napi/tests/misc.spec.ts @@ -1,6 +1,16 @@ import * as crypto from 'crypto'; import * as bip39 from 'bip39'; -import { Credentials, Scheduler, Signer, Node } from '../index.js'; +import { + Config, + Credentials, + Node, + Signer, + connect, + recover, + register, +} from '../index.js'; + +const REGTEST = () => new Config().withNetwork('regtest'); describe('Credentials', () => { it('can save and load raw credentials', async () => { @@ -19,14 +29,14 @@ describe('Credentials', () => { describe('Signer', () => { it('can be constructed from a mnemonic', async () => { const rand: Buffer = crypto.randomBytes(16); - const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString("hex")); + const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString('hex')); const signer = new Signer(MNEMONIC); expect(signer).toBeTruthy(); }); it('can return a node id', async () => { const rand: Buffer = crypto.randomBytes(16); - const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString("hex")); + const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString('hex')); const signer = new Signer(MNEMONIC); const nodeId = signer.nodeId(); @@ -36,7 +46,7 @@ describe('Signer', () => { it('returns consistent node id for same mnemonic', async () => { const rand: Buffer = crypto.randomBytes(16); - const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString("hex")); + const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString('hex')); const signer1 = new Signer(MNEMONIC); const signer2 = new Signer(MNEMONIC); @@ -45,61 +55,67 @@ describe('Signer', () => { expect(nodeId1.equals(nodeId2)).toBe(true); }); +}); - it('can be constructed with different mnemonics', async () => { - const rand2: Buffer = crypto.randomBytes(16); - const MNEMONIC2: string = bip39.entropyToMnemonic(rand2.toString("hex")); - const signer = new Signer(MNEMONIC2); - expect(signer).toBeTruthy(); - - const nodeId = signer.nodeId(); - expect(Buffer.isBuffer(nodeId)).toBe(true); +describe('Config', () => { + it('defaults to bitcoin network and has no developer cert', () => { + const config = new Config(); + expect(config).toBeTruthy(); }); -}); -describe('Scheduler', () => { - it('can be constructed for regtest', async () => { - const scheduler = new Scheduler('regtest'); - expect(scheduler).toBeTruthy(); + it('produces a regtest-network Config via withNetwork', () => { + const config = new Config().withNetwork('regtest'); + expect(config).toBeTruthy(); }); - it('can be constructed for bitcoin mainnet', async () => { - const scheduler = new Scheduler('bitcoin'); - expect(scheduler).toBeTruthy(); + it('rejects invalid network strings', () => { + expect(() => new Config().withNetwork('mars')).toThrow(); }); }); -describe('Integration: scheduler and signer', () => { - let scheduler: Scheduler; - let signer: Signer; - let credentials: Credentials; - let node: Node; +describe('Integration: register / recover / connect', () => { + let mnemonic: string; + let registeredNode: Node | null = null; - beforeAll(async () => { - const rand: Buffer = crypto.randomBytes(16); - const MNEMONIC: string = bip39.entropyToMnemonic(rand.toString("hex")); - scheduler = new Scheduler('regtest'); - signer = new Signer(MNEMONIC); - credentials = await scheduler.register(signer); - node = new Node(credentials); + beforeAll(() => { + mnemonic = bip39.entropyToMnemonic(crypto.randomBytes(16).toString('hex')); }); - it('can recover credentials', async () => { - if (node) { await node.stop(); } - const recovered = await scheduler.recover(signer); - expect(recovered).toBeInstanceOf(Credentials); - expect((await recovered.save()).length).toBeGreaterThan(0); + afterAll(async () => { + if (registeredNode) { + try { registeredNode.disconnect(); } catch {} + try { await registeredNode.stop(); } catch {} + } + }); + + it('register returns a connected Node and exposes credentials', async () => { + registeredNode = await register(mnemonic, undefined, REGTEST()); + expect(registeredNode).toBeTruthy(); + + const creds = registeredNode.credentials(); + expect(Buffer.isBuffer(creds)).toBe(true); + expect(creds.length).toBeGreaterThan(0); }); - it('handles registration of existing node (falls back to recovery)', async () => { - try { - if (node) { await node.stop(); } - // Trying to register the same signer again should throw an error, which we catch to then test recovery - const credentials2 = await scheduler.register(signer); - expect(credentials2).toBeInstanceOf(Credentials); - } catch (e) { - const recovered = await scheduler.recover(signer); - expect(recovered).toBeInstanceOf(Credentials); + it('recover returns a Node for an already-registered mnemonic', async () => { + if (registeredNode) { + registeredNode.disconnect(); + await registeredNode.stop(); + registeredNode = null; } + const recovered = await recover(mnemonic, REGTEST()); + expect(recovered).toBeTruthy(); + registeredNode = recovered; + }); + + it('connect works with saved credentials and the same mnemonic', async () => { + const savedCreds = registeredNode!.credentials(); + registeredNode!.disconnect(); + await registeredNode!.stop(); + registeredNode = null; + + const reconnected = await connect(mnemonic, savedCreds, REGTEST()); + expect(reconnected).toBeTruthy(); + registeredNode = reconnected; }); }); diff --git a/libs/gl-sdk-napi/tests/node.spec.ts b/libs/gl-sdk-napi/tests/node.spec.ts index d5df58c39..7944a7026 100644 --- a/libs/gl-sdk-napi/tests/node.spec.ts +++ b/libs/gl-sdk-napi/tests/node.spec.ts @@ -1,9 +1,9 @@ -import { Handle, Node, Scheduler } from '../index.js'; -import { getGLNode, fundWallet, getLspInvoice } from './test.helper'; +import { Node, Scheduler } from '../index.js'; +import { getGLNode, fundWallet, getLspInvoice, SdkNode } from './test.helper'; describe('Node', () => { let scheduler: Scheduler = new Scheduler('regtest'); - let glNodes: Array<{ node: Node; handle: Handle }> = []; + let glNodes: SdkNode[] = []; let node: Node; beforeEach(async () => { @@ -12,9 +12,9 @@ describe('Node', () => { }); afterEach(async () => { - for (const { node: n, handle: h } of glNodes) { - h.stop(); - await n.stop(); + for (const { node: n } of glNodes) { + try { n.disconnect(); } catch {} + try { await n.stop(); } catch {} } glNodes = []; }); @@ -110,7 +110,7 @@ describe('Node', () => { describe('calls onchainSend', () => { it('can send specific amount on-chain', async () => { await fundWallet(node, 500_000_000); - const extraGLNode = await getGLNode(scheduler, true) as { node: Node; handle: Handle }; + const extraGLNode = await getGLNode(scheduler, true); glNodes.push(extraGLNode); const destAddress = (await extraGLNode.node.onchainReceive()).bech32; const response = await node.onchainSend(destAddress, '10000sat'); @@ -119,7 +119,7 @@ describe('Node', () => { it('can attempt to send all funds on-chain', async () => { await fundWallet(node, 500_000_000); - const extraGLNode = await getGLNode(scheduler, true) as { node: Node; handle: Handle }; + const extraGLNode = await getGLNode(scheduler, true); glNodes.push(extraGLNode); const destAddress = (await extraGLNode.node.onchainReceive()).bech32; const response = await node.onchainSend(destAddress, 'all'); @@ -129,7 +129,7 @@ describe('Node', () => { describe('calls receive', () => { it('can create invoice with amount', async () => { - const extraGLNode = await getGLNode(scheduler, true) as { node: Node; handle: Handle }; + const extraGLNode = await getGLNode(scheduler, true); glNodes.push(extraGLNode); const label = `test-${Date.now()}`; const description = 'Test payment'; diff --git a/libs/gl-sdk-napi/tests/test.helper.ts b/libs/gl-sdk-napi/tests/test.helper.ts index 84da1b05d..a60d4a34e 100644 --- a/libs/gl-sdk-napi/tests/test.helper.ts +++ b/libs/gl-sdk-napi/tests/test.helper.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as crypto from 'crypto'; import * as bip39 from 'bip39'; -import { Credentials, Scheduler, Signer, Node, Handle } from '../index.js'; +import { Config, Credentials, Scheduler, Node, register, connect } from '../index.js'; export const lspInfo = () => ({ rpcSocket: process.env.LSP_RPC_SOCKET!, @@ -35,15 +35,31 @@ export async function fundWallet(node: Node, amountSats = 100_000_000): Promise< throw new Error('fundNode timed out waiting for node to detect funds'); } -export async function getGLNode(scheduler: Scheduler, connectToLSP: boolean = true): Promise<{ node: Node; handle: Handle }> { +/** A fully-connected SDK node with the SDK signer running. The SDK + * manages the signer internally; call `node.disconnect()` to stop + * it. The `mnemonic` is returned so callers that need cryptographic + * derivations (e.g. LNURL-auth happens internally on Node, but tests + * may want to assert against the same seed) can re-use it. */ +export interface SdkNode { + node: Node; + mnemonic: string; +} + +export async function getGLNode( + _scheduler: Scheduler, + connectToLSP: boolean = true, +): Promise { const mnemonic = bip39.entropyToMnemonic(crypto.randomBytes(16).toString('hex')); - const secret = bip39.mnemonicToSeedSync(mnemonic); - const signer = new Signer(mnemonic); - let credentials: Credentials; + const config = new Config().withNetwork('regtest'); + if (connectToLSP) { const testSetupServerUrl = process.env.TEST_SETUP_SERVER_URL!; if (!testSetupServerUrl) throw new Error('TEST_SETUP_SERVER_URL not set'); + // Test-setup server registers a node with the same seed and links + // it to the LSP. We then `connect` from the JS side using the + // mnemonic that derives that same seed. + const secret = bip39.mnemonicToSeedSync(mnemonic); const res = await fetch(`${testSetupServerUrl}/connect-to-lsp`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -51,11 +67,13 @@ export async function getGLNode(scheduler: Scheduler, connectToLSP: boolean = tr }); if (!res.ok) throw new Error(`Failed to connect node to LSP: ${await res.text()}`); const { creds_path } = await res.json(); - credentials = await Credentials.load(fs.readFileSync(creds_path)); - } else { - credentials = await scheduler.register(signer); + const credsBytes = fs.readFileSync(creds_path); + const node = await connect(mnemonic, credsBytes, config); + return { node, mnemonic }; } - return { node: new Node(credentials), handle: await signer.start() }; + + const node = await register(mnemonic, undefined, config); + return { node, mnemonic }; } export async function getLspInvoice(amountMsat: number = 0): Promise { @@ -79,3 +97,7 @@ export async function getBitcoinAddress(): Promise { const resJson = await res.json(); return resJson.address; } + +// Re-export `Credentials` so tests that need to load credentials +// directly (without going through register/connect) keep compiling. +export { Credentials }; diff --git a/libs/gl-sdk/CHANGELOG.md b/libs/gl-sdk/CHANGELOG.md index 43a7a21a3..deabcabeb 100644 --- a/libs/gl-sdk/CHANGELOG.md +++ b/libs/gl-sdk/CHANGELOG.md @@ -6,10 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Added + +- `Node::lnurl_auth(request)` implementing LNURL-auth (LUD-04 / LUD-05). Derives the per-domain linking key on-the-fly from a hardened `m/138'` xpriv that the Node derives once from the BIP39 seed at register/recover/connect time. The seed itself is never retained on `Node`; the stored namespace xpriv lives in `Zeroizing` and is scrubbed on `disconnect()` or `Drop`. `m/138'` is hardened, so the stored material cannot be used to derive any other wallet key (lightning channels, on-chain funds) — the blast radius is restricted to LNURL-auth identities. +- LUD-05 derivation uses the 32-byte private key at `m/138'/0` as the HMAC key, matching the mainstream wallet convention (Phoenix, Mutiny, Zeus, BlueWallet) for cross-wallet identity portability at LNURL-auth services. +- `InputType::LnUrlAuth { data: LnUrlAuthRequestData }` returned by `parse_input` when the URL carries `tag=login`. Detection is offline — no HTTP fetch is made for classification. +- `LnUrlCallbackStatus` and `LnUrlAuthRequestData` types. + ### Changed - `parse_input()` is now `async` and resolves LNURL bech32 strings and Lightning Addresses end-to-end over HTTP, returning typed pay or withdraw request data. BOLT11 invoices and node IDs still resolve without I/O. -- `InputType` variants now: `Bolt11`, `NodeId`, `LnUrlPay`, `LnUrlWithdraw`. Replaces the previous `LnUrl` / `LnUrlAddress` intermediate-state variants. +- `InputType` variants now: `Bolt11`, `NodeId`, `LnUrlPay`, `LnUrlWithdraw`, `LnUrlAuth`. Replaces the previous `LnUrl` / `LnUrlAddress` intermediate-state variants. ### Removed diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 1f2830b80..cfec3b17b 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -17,12 +17,15 @@ gl-client = { version = "0.4.0", path = "../gl-client" } hex = "0.4" lightning-invoice = "0.33" once_cell = "1.21.3" +reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] } +serde_json = "1" thiserror = "2.0.17" tokio = { version = "1", features = ["sync"] } tonic.workspace = true tracing = { version = "0.1.43", features = ["async-await", "log"] } uniffi = { version = "0.29.4", features = ["tokio"] } url = "2" +zeroize = "1.8" [build-dependencies] uniffi = { version = "0.29.4", features = [ "build" ] } diff --git a/libs/gl-sdk/glsdk/glsdk.py b/libs/gl-sdk/glsdk/glsdk.py index 9bf67f437..ea7195db3 100644 --- a/libs/gl-sdk/glsdk/glsdk.py +++ b/libs/gl-sdk/glsdk/glsdk.py @@ -481,7 +481,7 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_credentials() != 39165: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_disconnect() != 43626: + if lib.uniffi_glsdk_checksum_method_node_disconnect() != 35611: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_get_info() != 39460: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -497,6 +497,8 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_list_peers() != 29567: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_method_node_lnurl_auth() != 37374: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_lnurl_pay() != 61306: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_lnurl_withdraw() != 61467: @@ -533,8 +535,6 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_developercert_new() != 57793: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_constructor_node_new() != 7003: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_scheduler_new() != 15239: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_signer_new() != 62159: @@ -734,11 +734,6 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_free_node.restype = None -_UniffiLib.uniffi_glsdk_fn_constructor_node_new.argtypes = ( - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_constructor_node_new.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_method_node_credentials.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -798,6 +793,12 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_list_peers.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_auth.argtypes = ( + ctypes.c_void_p, + _UniffiRustBuffer, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_auth.restype = _UniffiRustBuffer _UniffiLib.uniffi_glsdk_fn_method_node_lnurl_pay.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -1285,6 +1286,9 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_list_peers.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_list_peers.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_auth.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_auth.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_pay.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_lnurl_pay.restype = ctypes.c_uint16 @@ -1339,9 +1343,6 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.restype = ctypes.c_uint16 @@ -2159,6 +2160,84 @@ def write(value, buf): _UniffiConverterSequenceTypePeer.write(value.peers, buf) +class LnUrlAuthRequestData: + """ + Data from an LNURL-auth endpoint (LUD-04). + + Returned inside `InputType::LnUrlAuth` after `parse_input` classifies + a `tag=login` LNURL. Because the tag is carried in the URL query + string, no HTTP fetch is needed for classification — the callback is + only hit when the user approves the auth via `Node::lnurl_auth`. + """ + + k1: "str" + """ + Hex-encoded 32-byte challenge from the service. + """ + + action: "typing.Optional[str]" + """ + One of `register`, `login`, `link`, `auth`. None if the service + did not specify an `action` query parameter. + """ + + domain: "str" + """ + Domain of the LNURL-auth service, to be shown to the user when + asking for auth confirmation per LUD-04. + """ + + url: "str" + """ + Full URL of the LNURL-auth service including its query string. + Used internally as the callback target after signing. + """ + + def __init__(self, *, k1: "str", action: "typing.Optional[str]", domain: "str", url: "str"): + self.k1 = k1 + self.action = action + self.domain = domain + self.url = url + + def __str__(self): + return "LnUrlAuthRequestData(k1={}, action={}, domain={}, url={})".format(self.k1, self.action, self.domain, self.url) + + def __eq__(self, other): + if self.k1 != other.k1: + return False + if self.action != other.action: + return False + if self.domain != other.domain: + return False + if self.url != other.url: + return False + return True + +class _UniffiConverterTypeLnUrlAuthRequestData(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + return LnUrlAuthRequestData( + k1=_UniffiConverterString.read(buf), + action=_UniffiConverterOptionalString.read(buf), + domain=_UniffiConverterString.read(buf), + url=_UniffiConverterString.read(buf), + ) + + @staticmethod + def check_lower(value): + _UniffiConverterString.check_lower(value.k1) + _UniffiConverterOptionalString.check_lower(value.action) + _UniffiConverterString.check_lower(value.domain) + _UniffiConverterString.check_lower(value.url) + + @staticmethod + def write(value, buf): + _UniffiConverterString.write(value.k1, buf) + _UniffiConverterOptionalString.write(value.action, buf) + _UniffiConverterString.write(value.domain, buf) + _UniffiConverterString.write(value.url, buf) + + class LnUrlErrorData: """ Error returned by an LNURL service endpoint. @@ -3679,7 +3758,9 @@ class InputType: """ The result of `parse_input`: a fully-resolved input ready for the caller's next action. LNURL bech32 strings and Lightning Addresses - are resolved over HTTP into typed pay or withdraw request data. + are resolved over HTTP into typed pay or withdraw request data; + LNURL-auth endpoints are classified from the URL query string + without an HTTP fetch. """ def __init__(self): @@ -3766,6 +3847,27 @@ def __eq__(self, other): return False return True + class LN_URL_AUTH: + """ + An LNURL-auth (LUD-04) challenge. No HTTP was performed — + classification comes from the `tag=login` URL query parameter. + """ + + data: "LnUrlAuthRequestData" + + def __init__(self,data: "LnUrlAuthRequestData"): + self.data = data + + def __str__(self): + return "InputType.LN_URL_AUTH(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_LN_URL_AUTH(): + return False + if self.data != other.data: + return False + return True + # For each variant, we have `is_NAME` and `is_name` methods for easily checking @@ -3786,6 +3888,10 @@ def is_LN_URL_WITHDRAW(self) -> bool: return isinstance(self, InputType.LN_URL_WITHDRAW) def is_ln_url_withdraw(self) -> bool: return isinstance(self, InputType.LN_URL_WITHDRAW) + def is_LN_URL_AUTH(self) -> bool: + return isinstance(self, InputType.LN_URL_AUTH) + def is_ln_url_auth(self) -> bool: + return isinstance(self, InputType.LN_URL_AUTH) # Now, a little trick - we make each nested variant class be a subclass of the main @@ -3795,6 +3901,7 @@ def is_ln_url_withdraw(self) -> bool: InputType.NODE_ID = type("InputType.NODE_ID", (InputType.NODE_ID, InputType,), {}) # type: ignore InputType.LN_URL_PAY = type("InputType.LN_URL_PAY", (InputType.LN_URL_PAY, InputType,), {}) # type: ignore InputType.LN_URL_WITHDRAW = type("InputType.LN_URL_WITHDRAW", (InputType.LN_URL_WITHDRAW, InputType,), {}) # type: ignore +InputType.LN_URL_AUTH = type("InputType.LN_URL_AUTH", (InputType.LN_URL_AUTH, InputType,), {}) # type: ignore @@ -3819,6 +3926,10 @@ def read(buf): return InputType.LN_URL_WITHDRAW( _UniffiConverterTypeLnUrlWithdrawRequestData.read(buf), ) + if variant == 5: + return InputType.LN_URL_AUTH( + _UniffiConverterTypeLnUrlAuthRequestData.read(buf), + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -3835,6 +3946,9 @@ def check_lower(value): if value.is_LN_URL_WITHDRAW(): _UniffiConverterTypeLnUrlWithdrawRequestData.check_lower(value.data) return + if value.is_LN_URL_AUTH(): + _UniffiConverterTypeLnUrlAuthRequestData.check_lower(value.data) + return raise ValueError(value) @staticmethod @@ -3851,6 +3965,9 @@ def write(value, buf): if value.is_LN_URL_WITHDRAW(): buf.write_i32(4) _UniffiConverterTypeLnUrlWithdrawRequestData.write(value.data, buf) + if value.is_LN_URL_AUTH(): + buf.write_i32(5) + _UniffiConverterTypeLnUrlAuthRequestData.write(value.data, buf) @@ -3946,6 +4063,111 @@ def write(value, buf): +class LnUrlCallbackStatus: + """ + Result of an LNURL-auth callback (LUD-04). + """ + + def __init__(self): + raise RuntimeError("LnUrlCallbackStatus cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class OK: + """ + The service accepted the signed challenge. + """ + + + def __init__(self,): + pass + + def __str__(self): + return "LnUrlCallbackStatus.OK()".format() + + def __eq__(self, other): + if not other.is_OK(): + return False + return True + + class ERROR_STATUS: + """ + The service rejected the signed challenge. + """ + + data: "LnUrlErrorData" + + def __init__(self,data: "LnUrlErrorData"): + self.data = data + + def __str__(self): + return "LnUrlCallbackStatus.ERROR_STATUS(data={})".format(self.data) + + def __eq__(self, other): + if not other.is_ERROR_STATUS(): + return False + if self.data != other.data: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_OK(self) -> bool: + return isinstance(self, LnUrlCallbackStatus.OK) + def is_ok(self) -> bool: + return isinstance(self, LnUrlCallbackStatus.OK) + def is_ERROR_STATUS(self) -> bool: + return isinstance(self, LnUrlCallbackStatus.ERROR_STATUS) + def is_error_status(self) -> bool: + return isinstance(self, LnUrlCallbackStatus.ERROR_STATUS) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +LnUrlCallbackStatus.OK = type("LnUrlCallbackStatus.OK", (LnUrlCallbackStatus.OK, LnUrlCallbackStatus,), {}) # type: ignore +LnUrlCallbackStatus.ERROR_STATUS = type("LnUrlCallbackStatus.ERROR_STATUS", (LnUrlCallbackStatus.ERROR_STATUS, LnUrlCallbackStatus,), {}) # type: ignore + + + + +class _UniffiConverterTypeLnUrlCallbackStatus(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return LnUrlCallbackStatus.OK( + ) + if variant == 2: + return LnUrlCallbackStatus.ERROR_STATUS( + _UniffiConverterTypeLnUrlErrorData.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_OK(): + return + if value.is_ERROR_STATUS(): + _UniffiConverterTypeLnUrlErrorData.check_lower(value.data) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_OK(): + buf.write_i32(1) + if value.is_ERROR_STATUS(): + buf.write_i32(2) + _UniffiConverterTypeLnUrlErrorData.write(value.data, buf) + + + + + + + class LnUrlPayResult: """ Result of an LNURL-pay operation. @@ -5602,6 +5824,10 @@ def disconnect(self, ): Disconnects from the node and stops the signer if running. After disconnect, all RPC methods will return an error. Safe to call multiple times. + + Also scrubs the LNURL-auth namespace xpriv from memory; a + subsequent `lnurl_auth` call on a disconnected Node will + error rather than silently using stale key material. """ raise NotImplementedError @@ -5666,6 +5892,29 @@ def list_peers(self, ): status. """ + raise NotImplementedError + def lnurl_auth(self, request: "LnUrlAuthRequestData"): + """ + Execute an LNURL-auth callback (LUD-04). + + Derives the LUD-05 linking key for `request.domain` from the + node's LNURL-auth namespace xpriv (`m/138'`), signs the + service's `k1` challenge, and posts the signed callback. + + The namespace xpriv is derived once at register/recover/connect + time from the BIP39 seed and stored in `Zeroizing` on the + Node. Because `m/138'` is a hardened path, this stored key + material cannot be used to derive any other wallet key + (lightning channels, on-chain funds) — it is restricted to + LNURL-auth identities. It is scrubbed when the Node is + disconnected or dropped. + + Returns an error when the Node was constructed directly from + credentials without a mnemonic (`Node::new(credentials)`) and + thus has no LNURL-auth namespace key, or when `disconnect()` + has already scrubbed the key. + """ + raise NotImplementedError def lnurl_pay(self, request: "LnUrlPayRequest"): """ @@ -5766,11 +6015,9 @@ class Node(): """ _pointer: ctypes.c_void_p - def __init__(self, credentials: "Credentials"): - _UniffiConverterTypeCredentials.check_lower(credentials) - - self._pointer = _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_constructor_node_new, - _UniffiConverterTypeCredentials.lower(credentials)) + + def __init__(self, *args, **kwargs): + raise ValueError("This class has no default constructor") def __del__(self): # In case of partial initialization of instances. @@ -5810,6 +6057,10 @@ def disconnect(self, ) -> None: Disconnects from the node and stops the signer if running. After disconnect, all RPC methods will return an error. Safe to call multiple times. + + Also scrubs the LNURL-auth namespace xpriv from memory; a + subsequent `lnurl_auth` call on a disconnected Node will + error rather than silently using stale key material. """ _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_disconnect,self._uniffi_clone_pointer(),) @@ -5972,6 +6223,39 @@ def list_peers(self, ) -> "ListPeersResponse": + def lnurl_auth(self, request: "LnUrlAuthRequestData") -> "LnUrlCallbackStatus": + """ + Execute an LNURL-auth callback (LUD-04). + + Derives the LUD-05 linking key for `request.domain` from the + node's LNURL-auth namespace xpriv (`m/138'`), signs the + service's `k1` challenge, and posts the signed callback. + + The namespace xpriv is derived once at register/recover/connect + time from the BIP39 seed and stored in `Zeroizing` on the + Node. Because `m/138'` is a hardened path, this stored key + material cannot be used to derive any other wallet key + (lightning channels, on-chain funds) — it is restricted to + LNURL-auth identities. It is scrubbed when the Node is + disconnected or dropped. + + Returns an error when the Node was constructed directly from + credentials without a mnemonic (`Node::new(credentials)`) and + thus has no LNURL-auth namespace key, or when `disconnect()` + has already scrubbed the key. + """ + + _UniffiConverterTypeLnUrlAuthRequestData.check_lower(request) + + return _UniffiConverterTypeLnUrlCallbackStatus.lift( + _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_lnurl_auth,self._uniffi_clone_pointer(), + _UniffiConverterTypeLnUrlAuthRequestData.lower(request)) + ) + + + + + def lnurl_pay(self, request: "LnUrlPayRequest") -> "LnUrlPayResult": """ Execute an LNURL-pay flow (LUD-06). @@ -6671,6 +6955,7 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "InputType", "InvoiceStatus", "ListIndex", + "LnUrlCallbackStatus", "LnUrlPayResult", "LnUrlWithdrawResult", "Network", @@ -6692,6 +6977,7 @@ def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",conf "ListPaysResponse", "ListPeerChannelsResponse", "ListPeersResponse", + "LnUrlAuthRequestData", "LnUrlErrorData", "LnUrlPayErrorData", "LnUrlPayRequest", diff --git a/libs/gl-sdk/src/auth.rs b/libs/gl-sdk/src/auth.rs new file mode 100644 index 000000000..9444f71c6 --- /dev/null +++ b/libs/gl-sdk/src/auth.rs @@ -0,0 +1,256 @@ +// LNURL-auth (LUD-04 / LUD-05) implementation. +// +// `Node` stores the encoded `m/138'` extended private key — derived +// once from the BIP39 seed at register/recover/connect time. The seed +// itself is never retained. From that namespace xpriv, both the +// hashing key (`m/138'/0`) and the per-domain linking key +// (`m/138'/`) are derived inside `lnurl_auth`. +// +// `m/138'` is a hardened path, so its xpriv cannot be used to derive +// any other wallet keys (lightning channels, on-chain). Compromise of +// the stored material affects only LNURL-auth identities. + +use gl_client::bitcoin::bip32::{ChildNumber, Xpriv}; +use gl_client::bitcoin::hashes::hmac::{Hmac, HmacEngine}; +use gl_client::bitcoin::hashes::{sha256, Hash, HashEngine}; +use gl_client::bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use gl_client::bitcoin::Network; +use zeroize::Zeroizing; + +use crate::lnurl::{LnUrlAuthRequestData, LnUrlCallbackStatus, LnUrlErrorData}; +use crate::Error; + +/// Derive the encoded `m/138'` extended private key from a BIP39 +/// mnemonic. The mnemonic is consumed only for the duration of this +/// call — the seed is wrapped in `Zeroizing` and scrubbed on return. +/// +/// The returned bytes are the standard 78-byte BIP32 serialization of +/// the xpriv at `m/138'`. Callers wrap them in `Zeroizing>` +/// when persisting on a `Node` so they are scrubbed on disconnect or +/// drop. +pub(crate) fn derive_lnurl_auth_namespace_xpriv(seed: &[u8]) -> Result, Error> { + // Network choice does not affect derived secret bytes — only the + // version prefix when the xpriv is serialised. We pick Bitcoin + // canonically; downstream `decode` accepts the same prefix. + let master = Xpriv::new_master(Network::Bitcoin, seed) + .map_err(|e| Error::Other(format!("BIP32 master derivation failed: {e}")))?; + let secp = Secp256k1::new(); + let path: [ChildNumber; 1] = [ChildNumber::from_hardened_idx(138) + .map_err(|e| Error::Other(format!("BIP32 child index error: {e}")))?]; + let namespace = master + .derive_priv(&secp, &path) + .map_err(|e| Error::Other(format!("BIP32 m/138' derivation failed: {e}")))?; + Ok(namespace.encode().to_vec()) +} + +/// Same as [`derive_lnurl_auth_namespace_xpriv`] but takes the +/// mnemonic directly and scrubs the intermediate seed on return. +pub(crate) fn derive_lnurl_auth_namespace_xpriv_from_mnemonic( + mnemonic: &str, +) -> Result, Error> { + use bip39::Mnemonic; + use std::str::FromStr; + let phrase = Mnemonic::from_str(mnemonic).map_err(|_| Error::PhraseCorrupted())?; + let seed = Zeroizing::new(phrase.to_seed_normalized("").to_vec()); + derive_lnurl_auth_namespace_xpriv(&seed) +} + +/// Sign the service's `k1` challenge using the LUD-05 linking key +/// derived from the supplied `m/138'` xpriv, and POST the callback. +pub(crate) async fn perform_lnurl_auth( + namespace_xpriv: &[u8], + request: &LnUrlAuthRequestData, +) -> Result { + let namespace = Xpriv::decode(namespace_xpriv) + .map_err(|e| Error::Other(format!("LNURL-auth namespace xpriv invalid: {e}")))?; + let secp = Secp256k1::new(); + + // Step 1: derive the LUD-05 hashing key at m/138'/0 (relative to + // the namespace, that's just /0). HMAC the domain with its + // 32-byte secret to produce 16 bytes of derivation tail. + let hashing_path: [ChildNumber; 1] = [ChildNumber::from(0)]; + let hashing_xpriv = namespace + .derive_priv(&secp, &hashing_path) + .map_err(|e| Error::Other(format!("BIP32 hashing-key derivation failed: {e}")))?; + let hashing_key = hashing_xpriv.private_key.secret_bytes(); + + let mut engine = HmacEngine::::new(&hashing_key); + engine.input(request.domain.as_bytes()); + let hmac = Hmac::::from_engine(engine); + let bytes = hmac.as_byte_array(); + + // Step 2: derive the linking key at m/138'//// + // (relative to the namespace, that's //.../). + let linking_path: Vec = vec![ + ChildNumber::from(u32::from_be_bytes(bytes[0..4].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[4..8].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[8..12].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[12..16].try_into().unwrap())), + ]; + let linking_xpriv = namespace + .derive_priv(&secp, &linking_path) + .map_err(|e| Error::Other(format!("BIP32 linking-key derivation failed: {e}")))?; + let linking_secret = linking_xpriv.private_key; + let linking_pubkey = PublicKey::from_secret_key(&secp, &linking_secret); + + // Step 3: sign k1 with the linking key. + let challenge = hex::decode(&request.k1) + .map_err(|e| Error::Other(format!("LNURL-auth k1 not hex: {e}")))?; + let message = Message::from_digest_slice(&challenge) + .map_err(|e| Error::Other(format!("LNURL-auth k1 not a 32-byte message: {e}")))?; + let sig = secp.sign_ecdsa(&message, &linking_secret); + + // Step 4: POST the signed callback per LUD-04. + let mut callback = url::Url::parse(&request.url) + .map_err(|e| Error::Other(format!("Invalid LNURL-auth URL: {e}")))?; + callback + .query_pairs_mut() + .append_pair("sig", &hex::encode(sig.serialize_der())) + .append_pair("key", &linking_pubkey.to_string()); + + let response = reqwest::get(callback) + .await + .map_err(|e| Error::Other(format!("LNURL-auth callback failed: {e}")))?; + let body = response + .text() + .await + .map_err(|e| Error::Other(format!("LNURL-auth response read failed: {e}")))?; + parse_callback_status(&body) +} + +fn parse_callback_status(body: &str) -> Result { + // LUD-04 success body is {"status":"OK"}; failure is + // {"status":"ERROR","reason":"..."}. + let value: serde_json::Value = serde_json::from_str(body) + .map_err(|e| Error::Other(format!("LNURL-auth response not JSON: {e}")))?; + let status = value + .get("status") + .and_then(|s| s.as_str()) + .ok_or_else(|| Error::Other(format!("LNURL-auth response missing status: {body}")))?; + match status { + "OK" => Ok(LnUrlCallbackStatus::Ok), + "ERROR" => { + let reason = value + .get("reason") + .and_then(|s| s.as_str()) + .unwrap_or("(no reason given)") + .to_string(); + Ok(LnUrlCallbackStatus::ErrorStatus { + data: LnUrlErrorData { reason }, + }) + } + other => Err(Error::Other(format!( + "LNURL-auth response unknown status '{other}'" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // BIP39 test vector (all "abandon" + "about"). + const MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + #[test] + fn namespace_xpriv_is_deterministic_for_same_mnemonic() { + let a = derive_lnurl_auth_namespace_xpriv_from_mnemonic(MNEMONIC).unwrap(); + let b = derive_lnurl_auth_namespace_xpriv_from_mnemonic(MNEMONIC).unwrap(); + assert_eq!(a, b); + assert_eq!(a.len(), 78); + } + + /// Fixed test vector: locks in the LUD-05 derivation convention + /// (32-byte private key as HMAC key, m/138' namespace) for the + /// "abandon ... about" BIP39 vector at domain "example.com". Any + /// change to the path computation or the HMAC-key choice breaks + /// this assertion — that is intentional, since either is a + /// one-way protocol break for any user already authenticated to a + /// service. + #[test] + fn linking_pubkey_matches_known_vector() { + let namespace = derive_lnurl_auth_namespace_xpriv_from_mnemonic(MNEMONIC).unwrap(); + let pubkey = compute_linking_pubkey_for_test(&namespace, "example.com").unwrap(); + assert_eq!( + pubkey.to_string(), + "039ae11fab821ec79815b327ba882e4dac5a046fe36bf6f5eed8b79c57f0b5d4fe", + ); + } + + #[test] + fn rejects_invalid_mnemonic() { + let err = derive_lnurl_auth_namespace_xpriv_from_mnemonic("not a real mnemonic") + .err() + .expect("expected error"); + matches!(err, Error::PhraseCorrupted()) + .then_some(()) + .expect("expected PhraseCorrupted"); + } + + #[test] + fn perform_rejects_invalid_namespace_xpriv() { + let req = LnUrlAuthRequestData { + k1: "00".repeat(32), + action: None, + domain: "x.com".to_string(), + url: "https://x.com/a?tag=login&k1=00".to_string(), + }; + let err = crate::util::exec(perform_lnurl_auth(&[0u8; 16], &req)) + .err() + .expect("expected error"); + match err { + Error::Other(msg) => assert!(msg.contains("namespace xpriv invalid")), + other => panic!("expected Other error, got {other:?}"), + } + } + + #[test] + fn parse_callback_status_ok() { + let s = parse_callback_status(r#"{"status":"OK"}"#).unwrap(); + matches!(s, LnUrlCallbackStatus::Ok) + .then_some(()) + .expect("expected Ok variant"); + } + + #[test] + fn parse_callback_status_error() { + let s = parse_callback_status(r#"{"status":"ERROR","reason":"nope"}"#).unwrap(); + match s { + LnUrlCallbackStatus::ErrorStatus { data } => assert_eq!(data.reason, "nope"), + _ => panic!("expected ErrorStatus"), + } + } + + #[test] + fn parse_callback_status_missing_field() { + assert!(parse_callback_status(r#"{"reason":"nope"}"#).is_err()); + assert!(parse_callback_status("not json").is_err()); + } + + /// Test helper that mirrors the derivation logic inside + /// `perform_lnurl_auth` so we can compute a stable vector without + /// performing an HTTP callback. + fn compute_linking_pubkey_for_test( + namespace_xpriv: &[u8], + domain: &str, + ) -> Result { + let namespace = Xpriv::decode(namespace_xpriv).unwrap(); + let secp = Secp256k1::new(); + let hashing = namespace + .derive_priv(&secp, &[ChildNumber::from(0)]) + .unwrap(); + let mut engine = HmacEngine::::new(&hashing.private_key.secret_bytes()); + engine.input(domain.as_bytes()); + let hmac = Hmac::::from_engine(engine); + let bytes = hmac.as_byte_array(); + let linking_path: Vec = vec![ + ChildNumber::from(u32::from_be_bytes(bytes[0..4].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[4..8].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[8..12].try_into().unwrap())), + ChildNumber::from(u32::from_be_bytes(bytes[12..16].try_into().unwrap())), + ]; + let linking = namespace.derive_priv(&secp, &linking_path).unwrap(); + Ok(PublicKey::from_secret_key(&secp, &linking.private_key)) + } +} diff --git a/libs/gl-sdk/src/input.rs b/libs/gl-sdk/src/input.rs index f8c9a1d64..f77c67b54 100644 --- a/libs/gl-sdk/src/input.rs +++ b/libs/gl-sdk/src/input.rs @@ -5,7 +5,7 @@ // returning a fully-typed `InputType` ready for the caller's next // action (display the invoice, build a pay/withdraw request, etc.). -use crate::lnurl::{LnUrlPayRequestData, LnUrlWithdrawRequestData}; +use crate::lnurl::{LnUrlAuthRequestData, LnUrlPayRequestData, LnUrlWithdrawRequestData}; use crate::Error; /// Parsed BOLT11 invoice with extracted fields. @@ -29,7 +29,9 @@ pub struct ParsedInvoice { /// The result of `parse_input`: a fully-resolved input ready for the /// caller's next action. LNURL bech32 strings and Lightning Addresses -/// are resolved over HTTP into typed pay or withdraw request data. +/// are resolved over HTTP into typed pay or withdraw request data; +/// LNURL-auth endpoints are classified from the URL query string +/// without an HTTP fetch. #[derive(Clone, uniffi::Enum)] pub enum InputType { /// A BOLT11 Lightning invoice. No HTTP was performed. @@ -40,6 +42,9 @@ pub enum InputType { LnUrlPay { data: LnUrlPayRequestData }, /// An LNURL-withdraw endpoint with the service's parameters fetched. LnUrlWithdraw { data: LnUrlWithdrawRequestData }, + /// An LNURL-auth (LUD-04) challenge. No HTTP was performed — + /// classification comes from the `tag=login` URL query parameter. + LnUrlAuth { data: LnUrlAuthRequestData }, } /// Classify the input string offline before deciding whether to make @@ -116,6 +121,14 @@ async fn resolve_lnurl_endpoint(url: &str, original: &str) -> Result Result Result, Error> { + let parsed = + url::Url::parse(raw_url).map_err(|e| Error::Other(format!("Invalid LNURL URL: {e}")))?; + + let mut tag = None; + let mut k1 = None; + let mut action = None; + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "tag" => tag = Some(value.into_owned()), + "k1" => k1 = Some(value.into_owned()), + "action" => action = Some(value.into_owned()), + _ => {} + } + } + + if tag.as_deref() != Some("login") { + return Ok(None); + } + + let k1 = k1.ok_or_else(|| Error::Other("LNURL-auth URL missing k1".to_string()))?; + let k1_bytes = + hex::decode(&k1).map_err(|e| Error::Other(format!("LNURL-auth k1 not hex: {e}")))?; + if k1_bytes.len() != 32 { + return Err(Error::Other( + "LNURL-auth k1 must be 32 bytes".to_string(), + )); + } + + if let Some(a) = action.as_deref() { + if !["register", "login", "link", "auth"].contains(&a) { + return Err(Error::Other(format!( + "LNURL-auth action '{a}' is not one of register/login/link/auth" + ))); + } + } + + let domain = parsed + .domain() + .ok_or_else(|| Error::Other("LNURL-auth URL has no domain".to_string()))? + .to_string(); + + Ok(Some(LnUrlAuthRequestData { + k1, + action, + domain, + url: raw_url.to_string(), + })) +} + /// Try parsing as an LNURL bech32 string (LUD-01). /// Returns None if the input doesn't look like an LNURL. fn try_parse_lnurl(input: &str) -> Option> { @@ -252,6 +320,7 @@ mod tests { InputType::NodeId { .. } => "NodeId", InputType::LnUrlPay { .. } => "LnUrlPay", InputType::LnUrlWithdraw { .. } => "LnUrlWithdraw", + InputType::LnUrlAuth { .. } => "LnUrlAuth", } } @@ -311,6 +380,56 @@ mod tests { .is_err()); } + // 32 zero bytes hex-encoded — a syntactically valid k1. + const ZERO_K1: &str = + "0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn test_try_parse_lnurl_auth_classifies_login_url_without_http() { + let url = format!("https://service.example.com/auth?tag=login&k1={ZERO_K1}"); + let parsed = try_parse_lnurl_auth(&url).unwrap().expect("expected Some"); + assert_eq!(parsed.k1, ZERO_K1); + assert_eq!(parsed.domain, "service.example.com"); + assert!(parsed.action.is_none()); + assert_eq!(parsed.url, url); + } + + #[test] + fn test_try_parse_lnurl_auth_captures_action() { + let url = format!("https://x.com/a?tag=login&k1={ZERO_K1}&action=register"); + let parsed = try_parse_lnurl_auth(&url).unwrap().expect("expected Some"); + assert_eq!(parsed.action.as_deref(), Some("register")); + } + + #[test] + fn test_try_parse_lnurl_auth_returns_none_for_non_login_tag() { + let url = format!("https://x.com/p?tag=payRequest&k1={ZERO_K1}"); + assert!(try_parse_lnurl_auth(&url).unwrap().is_none()); + } + + #[test] + fn test_try_parse_lnurl_auth_returns_none_when_no_tag() { + assert!(try_parse_lnurl_auth("https://x.com/something") + .unwrap() + .is_none()); + } + + #[test] + fn test_try_parse_lnurl_auth_rejects_missing_k1() { + assert!(try_parse_lnurl_auth("https://x.com/a?tag=login").is_err()); + } + + #[test] + fn test_try_parse_lnurl_auth_rejects_short_k1() { + assert!(try_parse_lnurl_auth("https://x.com/a?tag=login&k1=deadbeef").is_err()); + } + + #[test] + fn test_try_parse_lnurl_auth_rejects_unknown_action() { + let url = format!("https://x.com/a?tag=login&k1={ZERO_K1}&action=bogus"); + assert!(try_parse_lnurl_auth(&url).is_err()); + } + #[test] fn test_parse_input_invalid_node_id_errors() { // 66 chars but starts with 0x04 (uncompressed pubkey prefix) diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index 0d0b85061..c431666db 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -24,6 +24,7 @@ pub enum Error { Other(String), } +mod auth; mod config; mod credentials; mod input; @@ -46,9 +47,10 @@ pub use crate::{ }, input::{InputType, ParsedInvoice}, lnurl::{ - LnUrlErrorData, LnUrlPayRequest, LnUrlPayRequestData, LnUrlPayResult, - LnUrlPaySuccessData, LnUrlWithdrawRequest, LnUrlWithdrawRequestData, - LnUrlWithdrawResult, LnUrlWithdrawSuccessData, SuccessActionProcessed, + LnUrlAuthRequestData, LnUrlCallbackStatus, LnUrlErrorData, LnUrlPayRequest, + LnUrlPayRequestData, LnUrlPayResult, LnUrlPaySuccessData, LnUrlWithdrawRequest, + LnUrlWithdrawRequestData, LnUrlWithdrawResult, LnUrlWithdrawSuccessData, + SuccessActionProcessed, }, scheduler::Scheduler, signer::{Handle, Signer}, @@ -71,6 +73,13 @@ fn schedule_node( let network = config.network; let nobody = config.nobody(); + // Derive the LNURL-auth namespace xpriv (m/138') from the seed + // once, before the seed is moved into the Signer. The xpriv is + // wrapped in Zeroizing so it scrubs when the Node is dropped or + // disconnected. + let lnurl_auth_xpriv = + zeroize::Zeroizing::new(auth::derive_lnurl_auth_namespace_xpriv(&seed)?); + let seed_for_async = seed.clone(); let credentials = util::exec(async move { let signer = @@ -108,7 +117,7 @@ fn schedule_node( .map_err(|e| Error::Other(e.to_string()))?; let handle = signer::Handle::spawn(authenticated_signer); - let node = node::Node::with_signer(credentials, handle, network)?; + let node = node::Node::new(credentials, handle, network, lnurl_auth_xpriv)?; Ok(Arc::new(node)) } @@ -193,12 +202,15 @@ pub fn connect( let network = config.network; let creds = credentials::Credentials::load(credentials)?; + let lnurl_auth_xpriv = + zeroize::Zeroizing::new(auth::derive_lnurl_auth_namespace_xpriv(&seed)?); + let authenticated_signer = gl_client::signer::Signer::new(seed, network, creds.inner.clone()) .map_err(|e| Error::Other(e.to_string()))?; let handle = signer::Handle::spawn(authenticated_signer); - let node = node::Node::with_signer(creds, handle, network)?; + let node = node::Node::new(creds, handle, network, lnurl_auth_xpriv)?; Ok(Arc::new(node)) } @@ -229,6 +241,7 @@ pub async fn parse_input(input: String) -> Result { input::parse_input(input).await } + #[derive(uniffi::Enum, Debug)] pub enum Network { BITCOIN, diff --git a/libs/gl-sdk/src/lnurl.rs b/libs/gl-sdk/src/lnurl.rs index 53030c1f9..f101c5b88 100644 --- a/libs/gl-sdk/src/lnurl.rs +++ b/libs/gl-sdk/src/lnurl.rs @@ -9,6 +9,36 @@ use gl_client::lnurl::models as wire; // ── Resolved endpoint data ────────────────────────────────────────── +/// Data from an LNURL-auth endpoint (LUD-04). +/// +/// Returned inside `InputType::LnUrlAuth` after `parse_input` classifies +/// a `tag=login` LNURL. Because the tag is carried in the URL query +/// string, no HTTP fetch is needed for classification — the callback is +/// only hit when the user approves the auth via `Node::lnurl_auth`. +#[derive(Clone, uniffi::Record)] +pub struct LnUrlAuthRequestData { + /// Hex-encoded 32-byte challenge from the service. + pub k1: String, + /// One of `register`, `login`, `link`, `auth`. None if the service + /// did not specify an `action` query parameter. + pub action: Option, + /// Domain of the LNURL-auth service, to be shown to the user when + /// asking for auth confirmation per LUD-04. + pub domain: String, + /// Full URL of the LNURL-auth service including its query string. + /// Used internally as the callback target after signing. + pub url: String, +} + +/// Result of an LNURL-auth callback (LUD-04). +#[derive(Clone, uniffi::Enum)] +pub enum LnUrlCallbackStatus { + /// The service accepted the signed challenge. + Ok, + /// The service rejected the signed challenge. + ErrorStatus { data: LnUrlErrorData }, +} + /// Data from an LNURL-pay endpoint (LUD-06). /// /// Contains the service's accepted amount range and metadata. diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index 25ab1d813..0cb8abdbc 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -20,32 +20,17 @@ pub struct Node { signer_handle: Option, disconnected: AtomicBool, network: gl_client::bitcoin::Network, + /// LUD-05 namespace xpriv (`m/138'`) encoded as 78 bytes, derived + /// once from the BIP39 seed at register/recover/connect time. + /// `None` for nodes constructed directly from credentials without + /// a mnemonic — those cannot perform LNURL-auth. The + /// `Zeroizing` wrapper scrubs the bytes on drop, and `disconnect` + /// + `Drop for Node` take the Option to scrub eagerly. + lnurl_auth_xpriv: Mutex>>>, } #[uniffi::export] impl Node { - #[uniffi::constructor()] - pub fn new(credentials: &Credentials) -> Result { - let node_id = credentials - .inner - .node_id() - .map_err(|_e| Error::UnparseableCreds())?; - let inner = ClientNode::new(node_id, credentials.inner.clone()) - .expect("infallible client instantiation"); - - let cln_client = OnceCell::const_new(); - let gl_client = OnceCell::const_new(); - Ok(Node { - inner, - cln_client, - gl_client, - stored_credentials: Some(credentials.clone()), - signer_handle: None, - disconnected: AtomicBool::new(false), - network: gl_client::bitcoin::Network::Bitcoin, - }) - } - /// Stop the node if it is currently running. pub fn stop(&self) -> Result<(), Error> { self.check_connected()?; @@ -74,11 +59,17 @@ impl Node { /// Disconnects from the node and stops the signer if running. /// After disconnect, all RPC methods will return an error. /// Safe to call multiple times. + /// + /// Also scrubs the LNURL-auth namespace xpriv from memory; a + /// subsequent `lnurl_auth` call on a disconnected Node will + /// error rather than silently using stale key material. pub fn disconnect(&self) -> Result<(), Error> { self.disconnected.store(true, Ordering::Relaxed); if let Some(ref handle) = self.signer_handle { handle.try_stop(); } + // Take the option, dropping the Zeroizing>: scrubs. + let _ = self.lnurl_auth_xpriv.lock().unwrap().take(); Ok(()) } @@ -605,6 +596,44 @@ impl Node { }), } } + + /// Execute an LNURL-auth callback (LUD-04). + /// + /// Derives the LUD-05 linking key for `request.domain` from the + /// node's LNURL-auth namespace xpriv (`m/138'`), signs the + /// service's `k1` challenge, and posts the signed callback. + /// + /// The namespace xpriv is derived once at register/recover/connect + /// time from the BIP39 seed and stored in `Zeroizing` on the + /// Node. Because `m/138'` is a hardened path, this stored key + /// material cannot be used to derive any other wallet key + /// (lightning channels, on-chain funds) — it is restricted to + /// LNURL-auth identities. It is scrubbed when the Node is + /// disconnected or dropped, after which `lnurl_auth` errors. + pub fn lnurl_auth( + &self, + request: crate::lnurl::LnUrlAuthRequestData, + ) -> Result { + self.check_connected()?; + let guard = self.lnurl_auth_xpriv.lock().unwrap(); + let xpriv = guard.as_deref().ok_or_else(|| { + Error::Other("LNURL-auth namespace key has been scrubbed".to_string()) + })?; + exec(crate::auth::perform_lnurl_auth(xpriv, &request)) + } +} + +impl Drop for Node { + fn drop(&mut self) { + // Defensive scrub for callers who drop the Node without + // calling `disconnect()` first. The Zeroizing> in the + // Option scrubs as it is dropped; `take()` triggers that + // immediately rather than waiting for the Mutex itself to + // drop with the Node. + if let Ok(mut guard) = self.lnurl_auth_xpriv.lock() { + guard.take(); + } + } } /// Returns a human-readable reason if the invoice's BOLT-11 currency @@ -693,12 +722,18 @@ impl Node { Ok(()) } - /// Internal constructor used by the high-level register/recover/connect functions. - /// Creates a Node with credentials and signer handle attached. - pub(crate) fn with_signer( + /// Canonical constructor. + /// + /// Crate-private — UniFFI consumers reach this via the top-level + /// `register` / `recover` / `connect` / `register_or_recover` free + /// functions. Those resolve the credentials, spawn the SDK + /// signer, and derive the LNURL-auth namespace xpriv from the + /// seed before calling here. + pub(crate) fn new( credentials: Credentials, handle: Handle, network: gl_client::bitcoin::Network, + lnurl_auth_xpriv: zeroize::Zeroizing>, ) -> Result { let node_id = credentials .inner @@ -717,6 +752,7 @@ impl Node { signer_handle: Some(handle), disconnected: AtomicBool::new(false), network, + lnurl_auth_xpriv: Mutex::new(Some(lnurl_auth_xpriv)), }) } diff --git a/libs/gl-sdk/tests/test_auth_api.py b/libs/gl-sdk/tests/test_auth_api.py index 2a0b8e71f..f891403c6 100644 --- a/libs/gl-sdk/tests/test_auth_api.py +++ b/libs/gl-sdk/tests/test_auth_api.py @@ -239,21 +239,3 @@ def test_credentials_still_works_after_disconnect(self, scheduler, nobody_id): assert len(creds) > 0 -class TestLowLevelCredentials: - """Test that Node created via low-level API exposes credentials.""" - - def test_node_new_stores_credentials(self, scheduler, nobody_id): - """Node::new(creds) should allow calling node.credentials().""" - dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) - config = glsdk.Config().with_developer_cert(dev_cert) - - # Register to get valid credentials - node1 = glsdk.register(MNEMONIC, None, config) - saved_creds = node1.credentials() - node1.disconnect() - - # Create node via low-level API - creds_obj = glsdk.Credentials.load(saved_creds) - node2 = glsdk.Node(creds_obj) - roundtripped = node2.credentials() - assert len(roundtripped) > 0 diff --git a/libs/gl-sdk/tests/test_basic.py b/libs/gl-sdk/tests/test_basic.py index 1899b7ddc..954be1b6f 100644 --- a/libs/gl-sdk/tests/test_basic.py +++ b/libs/gl-sdk/tests/test_basic.py @@ -48,13 +48,15 @@ def test_credentials_multiple_loads(): assert all(isinstance(c, glsdk.Credentials) for c in [creds1, creds2, creds3]) -def test_node_creation_fails_with_empty_creds(): - """Test that creating a Node with empty credentials fails as expected.""" - creds = glsdk.Credentials.load(b"") - - # Node creation should fail with these invalid credentials +def test_connect_fails_with_empty_creds(): + """Connecting with empty credentials must error.""" + config = glsdk.Config() + mnemonic = ( + "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about" + ) with pytest.raises(glsdk.Error): - node = glsdk.Node(creds) + glsdk.connect(mnemonic, b"", config) def test_developer_cert_construction(): diff --git a/libs/gl-sdk/tests/test_lnurl.py b/libs/gl-sdk/tests/test_lnurl.py index e80170b64..be6b71953 100644 --- a/libs/gl-sdk/tests/test_lnurl.py +++ b/libs/gl-sdk/tests/test_lnurl.py @@ -9,6 +9,9 @@ """ import asyncio +import hashlib + +import pytest from gltesting.fixtures import * # noqa: F401, F403 from pyln.testing.utils import wait_for @@ -16,6 +19,20 @@ import glsdk +def _bip39_seed(mnemonic: str) -> bytes: + """Derive the BIP39 seed bytes (no passphrase) from a mnemonic. + + Matches `bip39::Mnemonic::to_seed_normalized("")` on the Rust side + used by `glsdk.register/recover/connect`. Lets us seed the + gl-client `clients.new(secret=...)` fixture with the same bytes + so credentials produced via that path authenticate with + `glsdk.connect(mnemonic, ...)`. + """ + return hashlib.pbkdf2_hmac( + "sha512", mnemonic.encode("utf-8"), b"mnemonic", 2048, 64 + ) + + MNEMONIC = ( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about" @@ -98,6 +115,74 @@ def test_parse_input_lightning_address_url(lnurl_service): assert resolved.data.lnurl == lnurl_service.lightning_address_url +def test_parse_input_lnurl_auth_no_http(lnurl_service): + """parse_input on a tag=login URL classifies as LnUrlAuth without HTTP.""" + auth_url = lnurl_service.auth_url(action="login") + resolved = asyncio.run(glsdk.parse_input(auth_url)) + + assert isinstance(resolved, glsdk.InputType.LN_URL_AUTH) + data = resolved.data + assert data.action == "login" + assert data.domain == lnurl_service.domain + assert data.url == auth_url + assert len(data.k1) == 64 # 32 bytes hex + # Server should not have logged a callback — classification is offline. + assert len(lnurl_service.auth_callbacks) == 0 + + +def test_lnurl_auth_end_to_end(scheduler, nobody_id, lnurl_service): + """Register an SDK node, parse an auth URL, sign + post, expect OK.""" + sdk_node, config = make_sdk_node(nobody_id, scheduler) + + try: + auth_url = lnurl_service.auth_url(action="login") + resolved = asyncio.run(glsdk.parse_input(auth_url)) + assert isinstance(resolved, glsdk.InputType.LN_URL_AUTH) + + result = sdk_node.lnurl_auth(resolved.data) + + assert isinstance(result, glsdk.LnUrlCallbackStatus.OK) + assert len(lnurl_service.auth_callbacks) == 1 + cb = lnurl_service.auth_callbacks[0] + assert cb["k1"] == resolved.data.k1 + # 33-byte compressed pubkey hex = 66 chars; DER sig is 70-72 bytes. + assert len(cb["key"]) == 66 + assert len(cb["sig"]) >= 140 # 70+ bytes hex + + +def test_lnurl_auth_yields_stable_pubkey_per_domain(scheduler, nobody_id, lnurl_service): + """Two auth calls against the same service yield the same linking pubkey.""" + sdk_node, _ = make_sdk_node(nobody_id, scheduler) + + try: + for _ in range(2): + url = lnurl_service.auth_url(action="login") + resolved = asyncio.run(glsdk.parse_input(url)) + result = sdk_node.lnurl_auth(resolved.data) + assert isinstance(result, glsdk.LnUrlCallbackStatus.OK) + + first_key = lnurl_service.auth_callbacks[0]["key"] + second_key = lnurl_service.auth_callbacks[1]["key"] + assert first_key == second_key, ( + "linking pubkey should be deterministic per (mnemonic, domain)" + ) + finally: + sdk_node.disconnect() + + +def test_lnurl_auth_after_disconnect_errors(scheduler, nobody_id, lnurl_service): + """Calling lnurl_auth after disconnect must error — namespace key scrubbed.""" + sdk_node, _ = make_sdk_node(nobody_id, scheduler) + auth_url = lnurl_service.auth_url(action="login") + resolved = asyncio.run(glsdk.parse_input(auth_url)) + + sdk_node.disconnect() + + with pytest.raises(glsdk.Error): + sdk_node.lnurl_auth(resolved.data) + assert len(lnurl_service.auth_callbacks) == 0 + + def test_parse_input_bolt11_no_http(lnurl_service): """parse_input on a BOLT11 invoice returns Bolt11 without touching HTTP.""" invoice = ( @@ -130,12 +215,16 @@ def test_lnurl_pay_end_to_end( """Full LNURL-pay flow: resolve → pay → verify. Uses a GL SDK node with outbound liquidity to pay an LNURL service. + Channel setup uses the low-level gl-client `clients` fixture + (gl-sdk doesn't expose `connect_peer`/`fund_channel`); afterwards + we hand over to the SDK-managed signer via `glsdk.connect`. """ - # Use the low-level client to set up channels, since the SDK node - # doesn't expose connect_peer / fund_channel directly. relay = fund_and_connect(node_factory, bitcoind, lnurl_service) - c = clients.new() + # Seed the gl-client clients fixture with the BIP39 seed of MNEMONIC + # so its credentials authenticate with `glsdk.connect(MNEMONIC, ...)` + # later on. + c = clients.new(secret=_bip39_seed(MNEMONIC)) c.register(configure=True) gl1 = c.node() s = c.signer().run_in_thread() @@ -163,19 +252,23 @@ def test_lnurl_pay_end_to_end( ) ) - # Now build an SDK-level Node for LNURL operations + # Hand over to the SDK signer: stop the gl-client signer first so + # only one signer is connected to the Greenlight node, then open + # the SDK Node via `connect` using the same mnemonic. creds_bytes = c.creds().to_bytes() - sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) + s.shutdown() + + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + sdk_node = glsdk.connect(MNEMONIC, creds_bytes, config) try: - # Resolve resolved = asyncio.run(glsdk.parse_input(lnurl_service.pay_url)) assert isinstance(resolved, glsdk.InputType.LN_URL_PAY) pay_data = resolved.data amount_msat = 50_000 # 50 sats - # Pay result = sdk_node.lnurl_pay( glsdk.LnUrlPayRequest( data=pay_data, @@ -187,7 +280,6 @@ def test_lnurl_pay_end_to_end( assert isinstance(result, glsdk.LnUrlPayResult.ENDPOINT_SUCCESS) assert len(result.data.payment_preimage) == 64 # hex-encoded 32 bytes - # Verify the LNURL server saw the callback assert len(lnurl_service.pay_callbacks) == 1 assert lnurl_service.pay_callbacks[0]["amount_msat"] == amount_msat finally: @@ -205,7 +297,7 @@ def test_lnurl_pay_with_message_success_action( relay = fund_and_connect(node_factory, bitcoind, lnurl_service) - c = clients.new() + c = clients.new(secret=_bip39_seed(MNEMONIC)) c.register(configure=True) gl1 = c.node() s = c.signer().run_in_thread() @@ -232,7 +324,11 @@ def test_lnurl_pay_with_message_success_action( ) creds_bytes = c.creds().to_bytes() - sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) + s.shutdown() + + dev_cert = glsdk.DeveloperCert(nobody_id.cert_chain, nobody_id.private_key) + config = glsdk.Config().with_developer_cert(dev_cert) + sdk_node = glsdk.connect(MNEMONIC, creds_bytes, config) try: resolved = asyncio.run(glsdk.parse_input(lnurl_service.pay_url)) diff --git a/libs/gl-testing/gltesting/clients.py b/libs/gl-testing/gltesting/clients.py index 3552e3ebe..aae325aa6 100644 --- a/libs/gl-testing/gltesting/clients.py +++ b/libs/gl-testing/gltesting/clients.py @@ -42,7 +42,13 @@ def __init__( if secret is not None: self.log.debug("Initializing hsm_secret with provided secret") - assert len(secret) == 32 + # gl-client's Signer accepts arbitrary-length seeds. We accept + # 32 bytes (legacy raw-secret callers) and 64 bytes (BIP39 + # standard seeds, so tests can share a seed with gl-sdk's + # mnemonic-derived flow). + assert len(secret) in (32, 64), ( + f"hsm_secret must be 32 or 64 bytes, got {len(secret)}" + ) with (self.directory / "hsm_secret").open(mode="wb") as f: f.write(secret) diff --git a/libs/gl-testing/gltesting/lnurl_server.py b/libs/gl-testing/gltesting/lnurl_server.py index e95366bd1..ac7c90fe3 100644 --- a/libs/gl-testing/gltesting/lnurl_server.py +++ b/libs/gl-testing/gltesting/lnurl_server.py @@ -26,6 +26,7 @@ class LnurlServer: GET /.well-known/lnurlp/{username} → LUD-16 (same payRequest) GET /lnurlw → LUD-03 withdrawRequest response GET /lnurlw/callback?k1=&pr= → service pays the invoice + GET /auth?tag=login&k1=&sig=&key= → LUD-04 verify signature """ def __init__( @@ -71,9 +72,13 @@ def __init__( # Each withdraw session issues a fresh k1 and remembers it until consumed self._pending_withdrawals: dict[str, dict] = {} + # LUD-04 auth challenges issued by `auth_url(...)`, indexed by k1 + self._pending_auth: dict[str, dict] = {} + # Logs of all incoming callback requests — tests inspect these self.pay_callbacks: list[dict] = [] self.withdraw_callbacks: list[dict] = [] + self.auth_callbacks: list[dict] = [] # ── URLs ────────────────────────────────────────────────── @@ -97,6 +102,20 @@ def lightning_address_url(self) -> str: def withdraw_url(self) -> str: return f"{self.base_url}/lnurlw" + def auth_url(self, action: str | None = None) -> str: + """Issue a fresh LUD-04 auth challenge and return its full URL. + + The k1 is generated here and remembered server-side so the + signed callback can be verified. `action` can be one of + register/login/link/auth (or None to omit). + """ + k1 = secrets.token_hex(32) # 32 bytes = 64 hex chars + self._pending_auth[k1] = {"used": False, "action": action} + url = f"{self.base_url}/auth?tag=login&k1={k1}" + if action is not None: + url += f"&action={action}" + return url + # ── Lifecycle ──────────────────────────────────────────── def start(self): @@ -188,6 +207,31 @@ def handle_withdraw_callback(self, k1: str, invoice: str) -> dict: return {"status": "OK"} + def handle_auth_callback(self, k1: str, sig_hex: str, key_hex: str) -> dict: + """Verify the LUD-04 ECDSA signature over k1 with the linking key.""" + self.auth_callbacks.append({"k1": k1, "sig": sig_hex, "key": key_hex}) + + session = self._pending_auth.get(k1) + if session is None: + return {"status": "ERROR", "reason": f"unknown k1: {k1}"} + if session["used"]: + return {"status": "ERROR", "reason": "k1 already used"} + + try: + from coincurve import PublicKey + + key_bytes = bytes.fromhex(key_hex) + sig_der = bytes.fromhex(sig_hex) + challenge = bytes.fromhex(k1) + pubkey = PublicKey(key_bytes) + if not pubkey.verify(sig_der, challenge, hasher=None): + return {"status": "ERROR", "reason": "invalid signature"} + except Exception as e: + return {"status": "ERROR", "reason": f"verify failed: {e}"} + + session["used"] = True + return {"status": "OK"} + def _handler_factory(server: LnurlServer): """Build a BaseHTTPRequestHandler class bound to a specific server. @@ -263,6 +307,18 @@ def do_GET(self): self._reply_json(200, server.handle_withdraw_callback(k1, pr)) return + if path == "/auth": + k1 = query.get("k1", [None])[0] + sig = query.get("sig", [None])[0] + key = query.get("key", [None])[0] + if not k1 or not sig or not key: + self._reply_json( + 200, {"status": "ERROR", "reason": "missing k1/sig/key"} + ) + return + self._reply_json(200, server.handle_auth_callback(k1, sig, key)) + return + self.send_response(404) self.end_headers() except Exception as e: