Skip to content

Commit 9abb69a

Browse files
committed
fix: validate LNURL-pay amount
1 parent b0b873d commit 9abb69a

6 files changed

Lines changed: 275 additions & 15 deletions

File tree

src/lib.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ use crate::onchain::{
6363
pub use modules::activity;
6464
pub use modules::lnurl;
6565
pub use modules::onchain;
66-
pub use modules::scanner::{DecodingError, Scanner};
66+
pub use modules::scanner::{DecodingError, LnurlPayData, Scanner};
6767

6868
use bip39::Mnemonic;
6969
use bitcoin::bip32::Xpriv;
@@ -117,6 +117,20 @@ pub async fn get_lnurl_invoice(
117117
.unwrap()
118118
}
119119

120+
#[uniffi::export]
121+
pub async fn get_lnurl_invoice_for_pay_data(
122+
data: LnurlPayData,
123+
amount_msats: u64,
124+
comment: Option<String>,
125+
) -> Result<String, lnurl::LnurlError> {
126+
let rt = ensure_runtime();
127+
rt.spawn(
128+
async move { lnurl::get_lnurl_invoice_for_pay_data(data, amount_msats, comment).await },
129+
)
130+
.await
131+
.unwrap()
132+
}
133+
120134
#[uniffi::export]
121135
pub fn create_channel_request_url(
122136
k1: String,

src/modules/lnurl/errors.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ pub enum LnurlError {
1919
},
2020
#[error("Failed to generate invoice: {error_details}")]
2121
InvoiceCreationFailed { error_details: String },
22+
#[error("Invoice amount mismatch")]
23+
AmountMismatch {
24+
requested_msats: u64,
25+
invoice_msats: u64,
26+
},
2227
#[error("LNURL authentication failed")]
2328
AuthenticationFailed,
2429
}

src/modules/lnurl/implementation.rs

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
use crate::lnurl::{ChannelRequestParams, LnurlAuthParams, LnurlError, WithdrawCallbackParams};
2+
use crate::modules::scanner::LnurlPayData;
23
use bitcoin::bip32::Xpriv;
34
use bitcoin::secp256k1::{Message, PublicKey, Secp256k1};
5+
use lightning_invoice::Bolt11Invoice;
46
use lnurl::lightning_address::LightningAddress;
57
use lnurl::lnurl::LnUrl;
68
use lnurl::{get_derivation_path, AsyncClient, Builder, LnUrlResponse, Response};
9+
use serde::Deserialize;
710
use std::str::FromStr;
811
use url::Url;
912

13+
#[derive(Deserialize)]
14+
struct LnurlPayCallbackResponse {
15+
pr: Option<String>,
16+
}
17+
1018
pub async fn get_lnurl_invoice(address: &str, amount_satoshis: u64) -> Result<String, LnurlError> {
1119
let ln_addr = match parse_lightning_address(address) {
1220
Ok(addr) => addr,
@@ -23,6 +31,33 @@ pub async fn get_lnurl_invoice(address: &str, amount_satoshis: u64) -> Result<St
2331
generate_invoice(&client, &pay_response, amount_satoshis).await
2432
}
2533

34+
pub async fn get_lnurl_invoice_for_pay_data(
35+
data: LnurlPayData,
36+
amount_msats: u64,
37+
comment: Option<String>,
38+
) -> Result<String, LnurlError> {
39+
validate_amount_msats(amount_msats, data.min_sendable, data.max_sendable)?;
40+
41+
let callback_url =
42+
build_lnurl_pay_callback_url(&data.callback, amount_msats, comment.as_deref())?;
43+
44+
let response = reqwest::get(callback_url)
45+
.await
46+
.map_err(|_| LnurlError::RequestFailed)?
47+
.error_for_status()
48+
.map_err(|_| LnurlError::RequestFailed)?;
49+
50+
let callback_response = response
51+
.json::<LnurlPayCallbackResponse>()
52+
.await
53+
.map_err(|_| LnurlError::InvalidResponse)?;
54+
let pr = callback_response.pr.ok_or(LnurlError::InvalidResponse)?;
55+
56+
validate_lnurl_pay_invoice(&pr, amount_msats)?;
57+
58+
Ok(pr)
59+
}
60+
2661
fn parse_lightning_address(address: &str) -> Result<LightningAddress, LnurlError> {
2762
LightningAddress::from_str(address).map_err(|_| LnurlError::InvalidAddress)
2863
}
@@ -56,23 +91,69 @@ async fn generate_invoice(
5691

5792
let amount_msats = amount_satoshis * 1000;
5893

59-
// Validate amount range
60-
if amount_msats < pay.min_sendable || amount_msats > pay.max_sendable {
61-
return Err(LnurlError::InvalidAmount {
62-
amount_satoshis,
63-
min: pay.min_sendable / 1000,
64-
max: pay.max_sendable / 1000,
65-
});
66-
}
94+
validate_amount_msats(amount_msats, pay.min_sendable, pay.max_sendable)?;
6795

68-
// Generate invoice
69-
client
96+
let invoice = client
7097
.get_invoice(pay, amount_msats, None, None)
7198
.await
72-
.map(|invoice| invoice.pr)
7399
.map_err(|e| LnurlError::InvoiceCreationFailed {
74100
error_details: e.to_string(),
75-
})
101+
})?;
102+
103+
validate_lnurl_pay_invoice(&invoice.pr, amount_msats)?;
104+
105+
Ok(invoice.pr)
106+
}
107+
108+
fn validate_amount_msats(amount_msats: u64, min: u64, max: u64) -> Result<(), LnurlError> {
109+
if amount_msats < min || amount_msats > max {
110+
return Err(LnurlError::InvalidAmount {
111+
amount_satoshis: amount_msats.div_ceil(1000),
112+
min: min / 1000,
113+
max: max / 1000,
114+
});
115+
}
116+
117+
Ok(())
118+
}
119+
120+
pub(crate) fn build_lnurl_pay_callback_url(
121+
callback: &str,
122+
amount_msats: u64,
123+
comment: Option<&str>,
124+
) -> Result<Url, LnurlError> {
125+
let mut url = Url::parse(callback).map_err(|_| LnurlError::InvalidAddress)?;
126+
127+
{
128+
let mut query_pairs = url.query_pairs_mut();
129+
query_pairs.append_pair("amount", &amount_msats.to_string());
130+
if let Some(comment) = comment {
131+
if !comment.is_empty() {
132+
query_pairs.append_pair("comment", comment);
133+
}
134+
}
135+
}
136+
137+
Ok(url)
138+
}
139+
140+
pub(crate) fn validate_lnurl_pay_invoice(pr: &str, amount_msats: u64) -> Result<(), LnurlError> {
141+
let invoice = Bolt11Invoice::from_str(pr).map_err(|_| LnurlError::InvalidResponse)?;
142+
let invoice_msats = invoice
143+
.amount_milli_satoshis()
144+
.ok_or(LnurlError::AmountMismatch {
145+
requested_msats: amount_msats,
146+
invoice_msats: 0,
147+
})?;
148+
149+
if invoice_msats != amount_msats {
150+
return Err(LnurlError::AmountMismatch {
151+
requested_msats: amount_msats,
152+
invoice_msats,
153+
});
154+
}
155+
156+
Ok(())
76157
}
77158

78159
pub fn create_channel_request_url(params: ChannelRequestParams) -> Result<String, LnurlError> {

src/modules/lnurl/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ mod tests;
88

99
pub use errors::LnurlError;
1010
pub use implementation::{
11-
create_channel_request_url, create_withdraw_callback_url, get_lnurl_invoice, lnurl_auth,
11+
create_channel_request_url, create_withdraw_callback_url, get_lnurl_invoice,
12+
get_lnurl_invoice_for_pay_data, lnurl_auth,
1213
};
1314
pub use types::{
1415
ChannelRequestParams, LightningAddressInvoice, LnurlAuthParams, WithdrawCallbackParams,

src/modules/lnurl/tests.rs

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,58 @@
11
#[cfg(test)]
22
mod tests {
33
use crate::lnurl::implementation::{
4-
create_channel_request_url, create_withdraw_callback_url, lnurl_auth,
4+
build_lnurl_pay_callback_url, create_channel_request_url, create_withdraw_callback_url,
5+
get_lnurl_invoice_for_pay_data, lnurl_auth, validate_lnurl_pay_invoice,
56
};
67
use crate::lnurl::{ChannelRequestParams, LnurlAuthParams, LnurlError, WithdrawCallbackParams};
8+
use crate::LnurlPayData;
9+
use bitcoin::hashes::{sha256, Hash as _};
10+
use bitcoin::secp256k1::{Secp256k1, SecretKey};
11+
use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
712
use lnurl::get_derivation_path;
813

914
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";
15+
const TEST_METADATA: &str = "[[\"text/plain\",\"test payment\"]]";
16+
const TEST_AMOUNT_MSATS: u64 = 12_345_000;
17+
18+
fn create_test_invoice(amount_msats: Option<u64>, metadata: &str, hashed: bool) -> String {
19+
let secp = Secp256k1::new();
20+
let secret_key = SecretKey::from_slice(&[0xab; 32]).unwrap();
21+
22+
let mut builder = InvoiceBuilder::new(Currency::Bitcoin)
23+
.payment_hash(sha256::Hash::from_byte_array([1u8; 32]))
24+
.payment_secret(PaymentSecret([2u8; 32]))
25+
.current_timestamp()
26+
.min_final_cltv_expiry_delta(144);
27+
28+
if let Some(amount_msats) = amount_msats {
29+
builder = builder.amount_milli_satoshis(amount_msats);
30+
}
31+
32+
let builder = if hashed {
33+
builder.description_hash(sha256::Hash::hash(metadata.as_bytes()))
34+
} else {
35+
builder.description(metadata.to_string())
36+
};
37+
38+
builder
39+
.build_signed(|hash| secp.sign_ecdsa_recoverable(hash, &secret_key))
40+
.unwrap()
41+
.to_string()
42+
}
43+
44+
fn test_pay_data() -> LnurlPayData {
45+
LnurlPayData {
46+
uri: "lnurl1test".to_string(),
47+
callback: "https://example.com/callback?existing=1".to_string(),
48+
min_sendable: 1_000,
49+
max_sendable: 20_000_000,
50+
metadata_str: TEST_METADATA.to_string(),
51+
comment_allowed: Some(100),
52+
allows_nostr: false,
53+
nostr_pubkey: None,
54+
}
55+
}
1056

1157
#[test]
1258
fn test_create_channel_request_url() {
@@ -136,6 +182,118 @@ mod tests {
136182
assert!(matches!(result, Err(LnurlError::InvalidAddress)));
137183
}
138184

185+
#[test]
186+
fn test_lnurl_pay_callback_url_preserves_existing_params() {
187+
let url = build_lnurl_pay_callback_url(
188+
"https://example.com/callback?existing=param",
189+
TEST_AMOUNT_MSATS,
190+
Some("hello"),
191+
)
192+
.unwrap();
193+
194+
assert_eq!(url.scheme(), "https");
195+
assert!(url.as_str().contains("existing=param"));
196+
assert!(url.as_str().contains("amount=12345000"));
197+
assert!(url.as_str().contains("comment=hello"));
198+
}
199+
200+
#[test]
201+
fn test_validate_lnurl_pay_invoice_exact_match() {
202+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), TEST_METADATA, false);
203+
204+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
205+
206+
assert!(result.is_ok());
207+
}
208+
209+
#[test]
210+
fn test_validate_lnurl_pay_invoice_larger_mismatch() {
211+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS + 1_000), TEST_METADATA, false);
212+
213+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
214+
215+
assert!(matches!(
216+
result,
217+
Err(LnurlError::AmountMismatch {
218+
requested_msats: TEST_AMOUNT_MSATS,
219+
invoice_msats
220+
}) if invoice_msats == TEST_AMOUNT_MSATS + 1_000
221+
));
222+
}
223+
224+
#[test]
225+
fn test_validate_lnurl_pay_invoice_smaller_mismatch() {
226+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS - 1_000), TEST_METADATA, false);
227+
228+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
229+
230+
assert!(matches!(
231+
result,
232+
Err(LnurlError::AmountMismatch {
233+
requested_msats: TEST_AMOUNT_MSATS,
234+
invoice_msats
235+
}) if invoice_msats == TEST_AMOUNT_MSATS - 1_000
236+
));
237+
}
238+
239+
#[test]
240+
fn test_validate_lnurl_pay_invoice_amountless() {
241+
let invoice = create_test_invoice(None, TEST_METADATA, false);
242+
243+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
244+
245+
assert!(matches!(
246+
result,
247+
Err(LnurlError::AmountMismatch {
248+
requested_msats: TEST_AMOUNT_MSATS,
249+
invoice_msats: 0
250+
})
251+
));
252+
}
253+
254+
#[test]
255+
fn test_validate_lnurl_pay_invoice_malformed() {
256+
let result = validate_lnurl_pay_invoice("lnbc1malformed", TEST_AMOUNT_MSATS);
257+
258+
assert!(matches!(result, Err(LnurlError::InvalidResponse)));
259+
}
260+
261+
#[tokio::test]
262+
async fn test_get_lnurl_invoice_for_pay_data_amount_outside_range() {
263+
let data = test_pay_data();
264+
265+
let result = get_lnurl_invoice_for_pay_data(data, 999, None).await;
266+
267+
assert!(matches!(result, Err(LnurlError::InvalidAmount { .. })));
268+
}
269+
270+
#[test]
271+
fn test_validate_lnurl_pay_invoice_matching_amount_with_hash_description() {
272+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), TEST_METADATA, true);
273+
274+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
275+
276+
assert!(result.is_ok());
277+
}
278+
279+
#[test]
280+
fn test_validate_lnurl_pay_invoice_matching_amount_with_text_description() {
281+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), "test payment", false);
282+
283+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
284+
285+
assert!(result.is_ok());
286+
}
287+
288+
#[test]
289+
fn test_validate_lnurl_pay_invoice_matching_amount_with_different_description() {
290+
let invoice = create_test_invoice(Some(TEST_AMOUNT_MSATS), "other metadata", false);
291+
292+
let result = validate_lnurl_pay_invoice(&invoice, TEST_AMOUNT_MSATS);
293+
294+
assert!(result.is_ok());
295+
}
296+
139297
#[test]
140298
fn test_get_derivation_path() {
141299
use url::Url;

src/modules/scanner/errors.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ impl From<LnurlError> for DecodingError {
5858
min,
5959
max,
6060
},
61+
LnurlError::AmountMismatch { .. } => DecodingError::InvalidResponse,
6162
LnurlError::AuthenticationFailed => DecodingError::InvalidResponse,
6263
}
6364
}

0 commit comments

Comments
 (0)