From 9abb69a5f5e24cfb91878dbf98bc1113f005d4bd Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 30 Jun 2026 19:25:48 +0200 Subject: [PATCH] fix: validate LNURL-pay amount --- src/lib.rs | 16 ++- src/modules/lnurl/errors.rs | 5 + src/modules/lnurl/implementation.rs | 105 +++++++++++++++--- src/modules/lnurl/mod.rs | 3 +- src/modules/lnurl/tests.rs | 160 +++++++++++++++++++++++++++- src/modules/scanner/errors.rs | 1 + 6 files changed, 275 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 62e2232..f8d162c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -63,7 +63,7 @@ use crate::onchain::{ pub use modules::activity; pub use modules::lnurl; pub use modules::onchain; -pub use modules::scanner::{DecodingError, Scanner}; +pub use modules::scanner::{DecodingError, LnurlPayData, Scanner}; use bip39::Mnemonic; use bitcoin::bip32::Xpriv; @@ -117,6 +117,20 @@ pub async fn get_lnurl_invoice( .unwrap() } +#[uniffi::export] +pub async fn get_lnurl_invoice_for_pay_data( + data: LnurlPayData, + amount_msats: u64, + comment: Option, +) -> Result { + let rt = ensure_runtime(); + rt.spawn( + async move { lnurl::get_lnurl_invoice_for_pay_data(data, amount_msats, comment).await }, + ) + .await + .unwrap() +} + #[uniffi::export] pub fn create_channel_request_url( k1: String, diff --git a/src/modules/lnurl/errors.rs b/src/modules/lnurl/errors.rs index eae58e2..81ae18b 100644 --- a/src/modules/lnurl/errors.rs +++ b/src/modules/lnurl/errors.rs @@ -19,6 +19,11 @@ pub enum LnurlError { }, #[error("Failed to generate invoice: {error_details}")] InvoiceCreationFailed { error_details: String }, + #[error("Invoice amount mismatch")] + AmountMismatch { + requested_msats: u64, + invoice_msats: u64, + }, #[error("LNURL authentication failed")] AuthenticationFailed, } diff --git a/src/modules/lnurl/implementation.rs b/src/modules/lnurl/implementation.rs index b08c6e0..d20a6c3 100644 --- a/src/modules/lnurl/implementation.rs +++ b/src/modules/lnurl/implementation.rs @@ -1,12 +1,20 @@ use crate::lnurl::{ChannelRequestParams, LnurlAuthParams, LnurlError, WithdrawCallbackParams}; +use crate::modules::scanner::LnurlPayData; use bitcoin::bip32::Xpriv; use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use lightning_invoice::Bolt11Invoice; use lnurl::lightning_address::LightningAddress; use lnurl::lnurl::LnUrl; use lnurl::{get_derivation_path, AsyncClient, Builder, LnUrlResponse, Response}; +use serde::Deserialize; use std::str::FromStr; use url::Url; +#[derive(Deserialize)] +struct LnurlPayCallbackResponse { + pr: Option, +} + pub async fn get_lnurl_invoice(address: &str, amount_satoshis: u64) -> Result { let ln_addr = match parse_lightning_address(address) { Ok(addr) => addr, @@ -23,6 +31,33 @@ pub async fn get_lnurl_invoice(address: &str, amount_satoshis: u64) -> Result, +) -> Result { + validate_amount_msats(amount_msats, data.min_sendable, data.max_sendable)?; + + let callback_url = + build_lnurl_pay_callback_url(&data.callback, amount_msats, comment.as_deref())?; + + let response = reqwest::get(callback_url) + .await + .map_err(|_| LnurlError::RequestFailed)? + .error_for_status() + .map_err(|_| LnurlError::RequestFailed)?; + + let callback_response = response + .json::() + .await + .map_err(|_| LnurlError::InvalidResponse)?; + let pr = callback_response.pr.ok_or(LnurlError::InvalidResponse)?; + + validate_lnurl_pay_invoice(&pr, amount_msats)?; + + Ok(pr) +} + fn parse_lightning_address(address: &str) -> Result { LightningAddress::from_str(address).map_err(|_| LnurlError::InvalidAddress) } @@ -56,23 +91,69 @@ async fn generate_invoice( let amount_msats = amount_satoshis * 1000; - // Validate amount range - if amount_msats < pay.min_sendable || amount_msats > pay.max_sendable { - return Err(LnurlError::InvalidAmount { - amount_satoshis, - min: pay.min_sendable / 1000, - max: pay.max_sendable / 1000, - }); - } + validate_amount_msats(amount_msats, pay.min_sendable, pay.max_sendable)?; - // Generate invoice - client + let invoice = client .get_invoice(pay, amount_msats, None, None) .await - .map(|invoice| invoice.pr) .map_err(|e| LnurlError::InvoiceCreationFailed { error_details: e.to_string(), - }) + })?; + + validate_lnurl_pay_invoice(&invoice.pr, amount_msats)?; + + Ok(invoice.pr) +} + +fn validate_amount_msats(amount_msats: u64, min: u64, max: u64) -> Result<(), LnurlError> { + if amount_msats < min || amount_msats > max { + return Err(LnurlError::InvalidAmount { + amount_satoshis: amount_msats.div_ceil(1000), + min: min / 1000, + max: max / 1000, + }); + } + + Ok(()) +} + +pub(crate) fn build_lnurl_pay_callback_url( + callback: &str, + amount_msats: u64, + comment: Option<&str>, +) -> Result { + let mut url = Url::parse(callback).map_err(|_| LnurlError::InvalidAddress)?; + + { + let mut query_pairs = url.query_pairs_mut(); + query_pairs.append_pair("amount", &amount_msats.to_string()); + if let Some(comment) = comment { + if !comment.is_empty() { + query_pairs.append_pair("comment", comment); + } + } + } + + Ok(url) +} + +pub(crate) fn validate_lnurl_pay_invoice(pr: &str, amount_msats: u64) -> Result<(), LnurlError> { + let invoice = Bolt11Invoice::from_str(pr).map_err(|_| LnurlError::InvalidResponse)?; + let invoice_msats = invoice + .amount_milli_satoshis() + .ok_or(LnurlError::AmountMismatch { + requested_msats: amount_msats, + invoice_msats: 0, + })?; + + if invoice_msats != amount_msats { + return Err(LnurlError::AmountMismatch { + requested_msats: amount_msats, + invoice_msats, + }); + } + + Ok(()) } pub fn create_channel_request_url(params: ChannelRequestParams) -> Result { diff --git a/src/modules/lnurl/mod.rs b/src/modules/lnurl/mod.rs index 7050d65..0ddbcc7 100644 --- a/src/modules/lnurl/mod.rs +++ b/src/modules/lnurl/mod.rs @@ -8,7 +8,8 @@ mod tests; pub use errors::LnurlError; pub use implementation::{ - create_channel_request_url, create_withdraw_callback_url, get_lnurl_invoice, lnurl_auth, + create_channel_request_url, create_withdraw_callback_url, get_lnurl_invoice, + get_lnurl_invoice_for_pay_data, lnurl_auth, }; pub use types::{ ChannelRequestParams, LightningAddressInvoice, LnurlAuthParams, WithdrawCallbackParams, diff --git a/src/modules/lnurl/tests.rs b/src/modules/lnurl/tests.rs index 7a3b4bd..80c8baa 100644 --- a/src/modules/lnurl/tests.rs +++ b/src/modules/lnurl/tests.rs @@ -1,12 +1,58 @@ #[cfg(test)] mod tests { use crate::lnurl::implementation::{ - create_channel_request_url, create_withdraw_callback_url, lnurl_auth, + build_lnurl_pay_callback_url, create_channel_request_url, create_withdraw_callback_url, + get_lnurl_invoice_for_pay_data, lnurl_auth, validate_lnurl_pay_invoice, }; use crate::lnurl::{ChannelRequestParams, LnurlAuthParams, LnurlError, WithdrawCallbackParams}; + use crate::LnurlPayData; + use bitcoin::hashes::{sha256, Hash as _}; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; use lnurl::get_derivation_path; const TEST_MNEMONIC: &str = "stable inch effort skull suggest circle charge lemon amazing clean giant quantum party grow visa best rule icon gown disagree win drop smile love"; + const TEST_METADATA: &str = "[[\"text/plain\",\"test payment\"]]"; + const TEST_AMOUNT_MSATS: u64 = 12_345_000; + + fn create_test_invoice(amount_msats: Option, metadata: &str, hashed: bool) -> String { + let secp = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[0xab; 32]).unwrap(); + + let mut builder = InvoiceBuilder::new(Currency::Bitcoin) + .payment_hash(sha256::Hash::from_byte_array([1u8; 32])) + .payment_secret(PaymentSecret([2u8; 32])) + .current_timestamp() + .min_final_cltv_expiry_delta(144); + + if let Some(amount_msats) = amount_msats { + builder = builder.amount_milli_satoshis(amount_msats); + } + + let builder = if hashed { + builder.description_hash(sha256::Hash::hash(metadata.as_bytes())) + } else { + builder.description(metadata.to_string()) + }; + + builder + .build_signed(|hash| secp.sign_ecdsa_recoverable(hash, &secret_key)) + .unwrap() + .to_string() + } + + fn test_pay_data() -> LnurlPayData { + LnurlPayData { + uri: "lnurl1test".to_string(), + callback: "https://example.com/callback?existing=1".to_string(), + min_sendable: 1_000, + max_sendable: 20_000_000, + metadata_str: TEST_METADATA.to_string(), + comment_allowed: Some(100), + allows_nostr: false, + nostr_pubkey: None, + } + } #[test] fn test_create_channel_request_url() { @@ -136,6 +182,118 @@ mod tests { assert!(matches!(result, Err(LnurlError::InvalidAddress))); } + #[test] + fn test_lnurl_pay_callback_url_preserves_existing_params() { + let url = build_lnurl_pay_callback_url( + "https://example.com/callback?existing=param", + TEST_AMOUNT_MSATS, + Some("hello"), + ) + .unwrap(); + + assert_eq!(url.scheme(), "https"); + assert!(url.as_str().contains("existing=param")); + assert!(url.as_str().contains("amount=12345000")); + assert!(url.as_str().contains("comment=hello")); + } + + #[test] + fn test_validate_lnurl_pay_invoice_exact_match() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), TEST_METADATA, false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_lnurl_pay_invoice_larger_mismatch() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS + 1_000), TEST_METADATA, false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(matches!( + result, + Err(LnurlError::AmountMismatch { + requested_msats: TEST_AMOUNT_MSATS, + invoice_msats + }) if invoice_msats == TEST_AMOUNT_MSATS + 1_000 + )); + } + + #[test] + fn test_validate_lnurl_pay_invoice_smaller_mismatch() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS - 1_000), TEST_METADATA, false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(matches!( + result, + Err(LnurlError::AmountMismatch { + requested_msats: TEST_AMOUNT_MSATS, + invoice_msats + }) if invoice_msats == TEST_AMOUNT_MSATS - 1_000 + )); + } + + #[test] + fn test_validate_lnurl_pay_invoice_amountless() { + let invoice = create_test_invoice(None, TEST_METADATA, false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(matches!( + result, + Err(LnurlError::AmountMismatch { + requested_msats: TEST_AMOUNT_MSATS, + invoice_msats: 0 + }) + )); + } + + #[test] + fn test_validate_lnurl_pay_invoice_malformed() { + let result = validate_lnurl_pay_invoice("lnbc1malformed", TEST_AMOUNT_MSATS); + + assert!(matches!(result, Err(LnurlError::InvalidResponse))); + } + + #[tokio::test] + async fn test_get_lnurl_invoice_for_pay_data_amount_outside_range() { + let data = test_pay_data(); + + let result = get_lnurl_invoice_for_pay_data(data, 999, None).await; + + assert!(matches!(result, Err(LnurlError::InvalidAmount { .. }))); + } + + #[test] + fn test_validate_lnurl_pay_invoice_matching_amount_with_hash_description() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), TEST_METADATA, true); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_lnurl_pay_invoice_matching_amount_with_text_description() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), "test payment", false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(result.is_ok()); + } + + #[test] + fn test_validate_lnurl_pay_invoice_matching_amount_with_different_description() { + let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), "other metadata", false); + + let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS); + + assert!(result.is_ok()); + } + #[test] fn test_get_derivation_path() { use url::Url; diff --git a/src/modules/scanner/errors.rs b/src/modules/scanner/errors.rs index 46f8198..7364b86 100644 --- a/src/modules/scanner/errors.rs +++ b/src/modules/scanner/errors.rs @@ -58,6 +58,7 @@ impl From for DecodingError { min, max, }, + LnurlError::AmountMismatch { .. } => DecodingError::InvalidResponse, LnurlError::AuthenticationFailed => DecodingError::InvalidResponse, } }