Skip to content

Commit e5af9ec

Browse files
committed
Add LUD-12 comment to dev fee LNURL-pay calls
Dev fee payments carried no identifying information, making it impossible to trace which order or Mostro node originated a given payment on the receiving end. Add an optional LUD-12 comment (order id + node pubkey) to the LNURL-pay callback, gated by the server-advertised commentAllowed limit. While rewriting the callback-building code to attach the comment, found that amount was concatenated onto the raw callback string before parsing it as a URL. Real LNURL servers can return a callback that already carries its own query string (e.g. ?id=...), so the second ? silently mangled the query and amount never reached the server as its own parameter. Extract build_callback_url, a pure helper that adds both amount and comment via query_pairs_mut, with a regression test for a callback with a pre-existing query string. Also thread the already-cached AppContext::keys() through the scheduler -> dev fee cycle instead of calling get_keys() (which re-parses the nsec on every call), matching the pattern the codebase already documents as preferred. Closes #694
1 parent 3bfad20 commit e5af9ec

4 files changed

Lines changed: 140 additions & 8 deletions

File tree

src/app/dev_fee.rs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ use mostro_core::error::MostroError;
7171
use mostro_core::error::MostroError::MostroInternalErr;
7272
use mostro_core::error::ServiceError;
7373
use mostro_core::order::Order;
74+
use nostr_sdk::Keys;
7475
use sqlx::SqlitePool;
7576
use std::collections::HashSet;
7677
use tokio::sync::mpsc::channel;
@@ -87,13 +88,14 @@ pub async fn run_dev_fee_cycle(
8788
pool: &SqlitePool,
8889
ln_client: &mut LndConnector,
8990
confirmed: &mut HashSet<uuid::Uuid>,
91+
keys: &Keys,
9092
) {
9193
info!("Checking for unpaid development fees");
9294

9395
cleanup_stale_pending_markers(pool).await;
9496
verify_confirmed_orders(pool, ln_client, confirmed).await;
9597
recover_partial_payments(pool, ln_client, confirmed).await;
96-
process_new_dev_fee_payments(pool, ln_client, confirmed).await;
98+
process_new_dev_fee_payments(pool, ln_client, confirmed, keys).await;
9799
}
98100

99101
// ── Phase 1: Stale PENDING cleanup ──────────────────────────────────────
@@ -392,6 +394,7 @@ async fn process_new_dev_fee_payments(
392394
pool: &SqlitePool,
393395
ln_client: &mut LndConnector,
394396
confirmed: &mut HashSet<uuid::Uuid>,
397+
keys: &Keys,
395398
) {
396399
let unpaid_orders = match find_unpaid_dev_fees(pool).await {
397400
Ok(orders) => orders,
@@ -434,7 +437,7 @@ async fn process_new_dev_fee_payments(
434437

435438
let (payment_request, payment_hash_hex) = match tokio::time::timeout(
436439
std::time::Duration::from_secs(20),
437-
resolve_dev_fee_invoice(&order),
440+
resolve_dev_fee_invoice(&order, keys),
438441
)
439442
.await
440443
{
@@ -883,7 +886,10 @@ fn parse_pending_timestamp(marker: &str) -> Option<u64> {
883886
///
884887
/// # Timeouts
885888
/// - LNURL resolution: 15 seconds
886-
pub async fn resolve_dev_fee_invoice(order: &Order) -> Result<(String, String), MostroError> {
889+
pub async fn resolve_dev_fee_invoice(
890+
order: &Order,
891+
keys: &Keys,
892+
) -> Result<(String, String), MostroError> {
887893
info!(
888894
"Resolving dev fee invoice for order {} - amount: {} sats to {}",
889895
order.id, order.dev_fee, DEV_FEE_LIGHTNING_ADDRESS
@@ -893,9 +899,21 @@ pub async fn resolve_dev_fee_invoice(order: &Order) -> Result<(String, String),
893899
return Err(MostroInternalErr(ServiceError::WrongAmountError));
894900
}
895901

902+
// LUD-12 comment so the receiving end can trace which order/node a dev
903+
// fee payment came from.
904+
let comment = format!(
905+
"mostro-dev-fee order={} node={}",
906+
order.id,
907+
keys.public_key()
908+
);
909+
896910
let payment_request = tokio::time::timeout(
897911
std::time::Duration::from_secs(15),
898-
resolv_ln_address(DEV_FEE_LIGHTNING_ADDRESS, order.dev_fee as u64),
912+
resolv_ln_address(
913+
DEV_FEE_LIGHTNING_ADDRESS,
914+
order.dev_fee as u64,
915+
Some(comment.as_str()),
916+
),
899917
)
900918
.await
901919
.map_err(|_| {

src/app/release.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ pub async fn do_payment(
496496
return Err(MostroInternalErr(ServiceError::InvoiceInvalidError));
497497
}
498498
let payment_request = if let Ok(addr) = ln_addr {
499-
resolv_ln_address(&addr.to_string(), amount)
499+
resolv_ln_address(&addr.to_string(), amount, None)
500500
.await
501501
.map_err(|_| MostroInternalErr(ServiceError::LnAddressParseError))?
502502
} else {

src/lnurl.rs

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,40 @@ pub async fn ln_exists(address: &str) -> Result<(), MostroError> {
6969
}
7070
}
7171

72-
pub async fn resolv_ln_address(address: &str, amount: u64) -> Result<String, MostroError> {
72+
/// LUD-12: returns the comment truncated to `max_len` chars, or `None` if
73+
/// there's no comment to send or the server advertises no support for one
74+
/// (`max_len == 0`).
75+
fn fit_comment(comment: Option<&str>, max_len: usize) -> Option<String> {
76+
let comment = comment.filter(|_| max_len > 0)?;
77+
Some(comment.chars().take(max_len).collect())
78+
}
79+
80+
/// Builds the LNURL-pay callback URL, adding `amount` (and `comment`, per
81+
/// LUD-12, when the server allows it) as proper query parameters via
82+
/// `query_pairs_mut` — never by string-concatenating onto `callback`, which
83+
/// silently mangles the query if `callback` already carries one (a real LNURL
84+
/// server behavior, e.g. `https://host/cb?id=abc`).
85+
fn build_callback_url(
86+
callback: &str,
87+
amount_msat: u64,
88+
comment: Option<&str>,
89+
comment_allowed: usize,
90+
) -> Result<reqwest::Url, MostroError> {
91+
let mut url = reqwest::Url::parse(callback)
92+
.map_err(|_| MostroInternalErr(ServiceError::LnAddressParseError))?;
93+
url.query_pairs_mut()
94+
.append_pair("amount", &amount_msat.to_string());
95+
if let Some(value) = fit_comment(comment, comment_allowed) {
96+
url.query_pairs_mut().append_pair("comment", &value);
97+
}
98+
Ok(url)
99+
}
100+
101+
pub async fn resolv_ln_address(
102+
address: &str,
103+
amount: u64,
104+
comment: Option<&str>,
105+
) -> Result<String, MostroError> {
73106
// Get the url from the str - could be a LNURL or a Lightning Address
74107
let url = extract_lnurl(address).await?;
75108
// Convert the amount to msat
@@ -99,7 +132,9 @@ pub async fn resolv_ln_address(address: &str, amount: u64) -> Result<String, Mos
99132
return Ok("".to_string());
100133
}
101134
let callback = body["callback"].as_str().unwrap_or("");
102-
let callback = format!("{callback}?amount={amount_msat}");
135+
let comment_allowed = body["commentAllowed"].as_u64().unwrap_or(0) as usize;
136+
let callback =
137+
build_callback_url(callback, amount_msat, comment, comment_allowed)?.to_string();
103138
let res = HTTP_CLIENT
104139
.get(callback)
105140
.send()
@@ -122,3 +157,81 @@ pub async fn resolv_ln_address(address: &str, amount: u64) -> Result<String, Mos
122157
Ok("".to_string())
123158
}
124159
}
160+
161+
#[cfg(test)]
162+
mod tests {
163+
use super::{build_callback_url, fit_comment};
164+
165+
#[test]
166+
fn build_callback_url_adds_amount_as_its_own_param() {
167+
let url = build_callback_url("https://pay.example.com/cb", 100_000, None, 0).unwrap();
168+
assert_eq!(
169+
url.query_pairs().collect::<Vec<_>>(),
170+
vec![("amount".into(), "100000".into())]
171+
);
172+
}
173+
174+
#[test]
175+
fn build_callback_url_preserves_existing_query_params() {
176+
// Regression test: callback already carries its own query string
177+
// (common in the wild, e.g. LNbits-style `?id=...`). The old
178+
// `format!("{callback}?amount={amount_msat}")` approach produced a
179+
// second `?`, which is not a delimiter, so `amount` got swallowed
180+
// into the `id` value instead of becoming its own parameter.
181+
let url =
182+
build_callback_url("https://pay.example.com/cb?id=abc", 100_000, None, 0).unwrap();
183+
let pairs = url.query_pairs().collect::<Vec<_>>();
184+
assert_eq!(
185+
pairs,
186+
vec![
187+
("id".into(), "abc".into()),
188+
("amount".into(), "100000".into())
189+
]
190+
);
191+
}
192+
193+
#[test]
194+
fn build_callback_url_adds_comment_when_allowed() {
195+
let url =
196+
build_callback_url("https://pay.example.com/cb", 100_000, Some("order=1"), 50).unwrap();
197+
let pairs = url.query_pairs().collect::<Vec<_>>();
198+
assert_eq!(pairs[0], ("amount".into(), "100000".into()));
199+
assert_eq!(pairs[1], ("comment".into(), "order=1".into()));
200+
}
201+
202+
#[test]
203+
fn build_callback_url_omits_comment_when_not_allowed() {
204+
let url =
205+
build_callback_url("https://pay.example.com/cb", 100_000, Some("order=1"), 0).unwrap();
206+
assert_eq!(
207+
url.query_pairs().collect::<Vec<_>>(),
208+
vec![("amount".into(), "100000".into())]
209+
);
210+
}
211+
212+
#[test]
213+
fn fit_comment_none_when_not_allowed() {
214+
assert_eq!(fit_comment(Some("order=1"), 0), None);
215+
}
216+
217+
#[test]
218+
fn fit_comment_none_when_no_comment() {
219+
assert_eq!(fit_comment(None, 50), None);
220+
}
221+
222+
#[test]
223+
fn fit_comment_passes_through_when_short_enough() {
224+
assert_eq!(
225+
fit_comment(Some("order=1 node=abc"), 50),
226+
Some("order=1 node=abc".to_string())
227+
);
228+
}
229+
230+
#[test]
231+
fn fit_comment_truncates_to_server_limit() {
232+
assert_eq!(
233+
fit_comment(Some("order=1 node=abc"), 7),
234+
Some("order=1".to_string())
235+
);
236+
}
237+
}

src/scheduler.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -784,8 +784,9 @@ async fn job_process_dev_fee_payment(ctx: AppContext) {
784784

785785
tokio::spawn(async move {
786786
let pool = ctx.pool();
787+
let keys = ctx.keys();
787788
loop {
788-
run_dev_fee_cycle(pool, &mut ln_client, &mut confirmed).await;
789+
run_dev_fee_cycle(pool, &mut ln_client, &mut confirmed, keys).await;
789790
tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await;
790791
}
791792
});

0 commit comments

Comments
 (0)