Skip to content

Add LUD-12 comment to dev fee LNURL-pay calls#820

Merged
grunch merged 4 commits into
MostroP2P:mainfrom
ToRyVand:fix/694-lnurl-dev-fee-comment
Jul 24, 2026
Merged

Add LUD-12 comment to dev fee LNURL-pay calls#820
grunch merged 4 commits into
MostroP2P:mainfrom
ToRyVand:fix/694-lnurl-dev-fee-comment

Conversation

@ToRyVand

@ToRyVand ToRyVand commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #694.

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. This adds an optional LUD-12 comment (mostro-dev-fee order=<id> node=<npub>) to the LNURL-pay callback, gated by the server-advertised commentAllowed limit — servers that don't support it are left untouched.

Bug found and fixed while implementing this

The existing callback-building code concatenated ?amount={amount_msat} onto the raw callback string returned by the LNURL server, before parsing it as a URL:

let callback = format!("{callback}?amount={amount_msat}");

Real LNURL servers can return a callback that already carries its own query string (e.g. https://host/cb?id=abc, common with LNbits and others). In that case the second ? is not a delimiter — everything after it, including the literal ?, becomes part of the previous parameter's value. So amount silently never reaches the server as its own parameter; the previous key absorbs it instead (verified by actually running this against the exact url crate version pinned in this repo). This predates this PR, but since the same line was being rewritten to attach the comment, it's fixed here too rather than left next to new code. Extracted build_callback_url, a pure helper that adds amount and comment via query_pairs_mut (never by string concatenation), with a regression test (build_callback_url_preserves_existing_query_params) covering exactly this case.

Other changes

resolve_dev_fee_invoice now receives keys: &Keys instead of calling crate::util::get_keys() internally (which re-parses the nsec — an EC key derivation — on every call). AppContext::keys() already caches this and is documented as the preferred alternative (src/app/context.rs). Threaded through scheduler.rs -> run_dev_fee_cycle -> process_new_dev_fee_payments -> resolve_dev_fee_invoice; ctx was already available at the scheduler spawn site, just not passed down. This also removes the only fallible path around building the comment, so there's no error to silently swallow.

Response to review

@grunch's review caught real gaps — all addressed in 860bcac:

  • cargo fmt failure (Critical) — tree is now rustfmt-clean; cargo fmt --all -- --check passes.
  • Silent truncation (Major)fit_comment now returns None instead of cutting a comment mid-pubkey when it exceeds commentAllowed; a warn! fires so a dropped comment is visible in logs instead of silently producing a mutilated-but-plausible identifier.
  • No scheme validation / SSRF (Major)extract_lnurl and build_callback_url now reject any scheme other than http/https, closing the path a buyer-supplied lightning address could use to point mostrod at javascript:/file:/internal addresses.
  • Duplicate amount/comment params (Minor) — any pre-existing amount/comment pair on callback is dropped before appending ours, so there's no ambiguity about which value the server honors.
  • LNURL error reason discarded (Major) — both resolv_ln_address requests (address resolution and callback) now check for {"status":"ERROR","reason":...} and log the server's actual reason instead of silently returning an empty invoice.
  • Hex pubkey instead of npub (Minor) — the comment now renders the node identifier as npub1... via to_bech32(), both more idiomatic for a nostr project and 63 chars shorter.
  • Untested comment string (Minor) — extracted dev_fee_comment into its own pure function with a dedicated test asserting the exact mostro-dev-fee order=... node=... shape, so the format can't silently drift again.
  • use super::Keys (Trivial) — test module now imports Keys directly from nostr_sdk.

Left open, both flagged as optional in the review rather than blocking: retrying once without the comment on LNURL rejection, and a settings toggle to opt out of sending the comment entirely. Happy to file follow-ups if useful.

Test plan

$ cargo fmt --all -- --check
(clean)

$ cargo clippy --all-targets --all-features -- -D warnings
cargo clippy: No issues found

$ cargo test
test result: FAILED. 1031 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

The 1 failure (lightning::invoice::tests::test_lnurl_validation_with_test_server) is pre-existing and unrelated — it binds a mock server to a fixed port (8080) that's already occupied in my environment; confirmed neither this commit nor any other commit on this branch touches src/lightning/invoice.rs.

New/changed test coverage (src/lnurl.rs and src/app/dev_fee.rs):

  • amount added as its own query parameter (base case)
  • Regression: callback with a pre-existing query string — amount is added as its own pair instead of merging into the existing one
  • comment attached when the server allows it (commentAllowed > 0)
  • comment omitted when the server doesn't advertise support
  • comment omitted (not truncated) when it exceeds the server-advertised length
  • extract_lnurl/build_callback_url reject non-http(s) schemes
  • Pre-existing amount/comment query params are dropped before appending ours
  • dev_fee_comment produces the exact mostro-dev-fee order=... node=npub1... shape

Summary by CodeRabbit

  • New Features
    • Added optional LUD-12 user comments for LNURL-pay resolution when permitted by the server.
    • Dev-fee payment comments now include the order ID and node public key.
    • LNURL callback URLs preserve existing query parameters while adding amount (and comments when allowed).
  • Bug Fixes
    • Enforced HTTP(S)-only LNURL targets and HTTP(S)-only callback URLs to prevent invalid/non-web schemes.
    • Added graceful handling for LNURL error responses by returning an empty result.
  • Tests
    • Updated/added coverage for comment truncation rules and callback query parameter preservation.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f74ef522-4c15-4e7d-9731-78bfdc7a52af

📥 Commits

Reviewing files that changed from the base of the PR and between 860bcac and 57f1172.

📒 Files selected for processing (1)
  • src/lnurl.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lnurl.rs

Walkthrough

Dev-fee LNURL payments now include order and node-key comments. LNURL callback construction supports optional comments, preserves existing parameters, validates HTTP(S) endpoints, and handles reported errors. Scheduler and regular payment callers pass the updated arguments.

Changes

Dev-fee LNURL comment flow

Layer / File(s) Summary
LNURL comment resolution and callback construction
src/lnurl.rs
resolv_ln_address accepts optional comments, preserves callback query parameters, adds amounts, applies server comment limits, validates HTTP(S) URLs, handles error responses, and includes unit tests.
Dev-fee comment propagation
src/app/dev_fee.rs
Dev-fee processing receives Keys and sends the order ID plus node public key as the LNURL comment, with updated tests.
Scheduler and payment integration
src/scheduler.rs, src/app/release.rs, src/util.rs, src/config/util.rs
The scheduler passes keys into dev-fee cycles, regular payments pass None for the optional comment, and Cashu URL validation uses a shared HTTP(S) predicate.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Suggested reviewers: grunch

Sequence Diagram(s)

sequenceDiagram
  participant Scheduler
  participant DevFeeCycle
  participant InvoiceResolver
  participant LNURLService
  Scheduler->>DevFeeCycle: provide current Keys
  DevFeeCycle->>InvoiceResolver: resolve order with Keys
  InvoiceResolver->>LNURLService: request invoice with order ID and public key comment
  LNURLService-->>InvoiceResolver: return invoice or empty result on reported error
Loading

Poem

A bunny carries keys through the night,
With order and node in comments bright.
Queries stay safe, long comments trim,
LNURL guides the payment swim.
The invoice returns in moonlit flight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a LUD-12 comment to dev fee LNURL-pay calls.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/lnurl.rs (1)

101-105: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add Rust doc comments to modified public APIs.

As per coding guidelines, non-obvious public APIs must be documented with Rust doc comments (///). The signatures of these public functions were modified or expanded in this PR, making this an ideal time to add documentation clarifying their purpose, parameters, and return shapes.

  • src/lnurl.rs#L101-L105: Add a doc comment for resolv_ln_address.
  • src/app/dev_fee.rs#L91-L92: Add a doc comment for run_dev_fee_cycle.
  • src/app/dev_fee.rs#L889-L892: Add a doc comment for resolve_dev_fee_invoice.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lnurl.rs` around lines 101 - 105, Add Rust doc comments describing the
purpose, parameters, and return value for the public functions resolv_ln_address
in src/lnurl.rs (lines 101-105), run_dev_fee_cycle in src/app/dev_fee.rs (lines
91-92), and resolve_dev_fee_invoice in src/app/dev_fee.rs (lines 889-892). Use
/// comments directly above each function.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/lnurl.rs`:
- Around line 101-105: Add Rust doc comments describing the purpose, parameters,
and return value for the public functions resolv_ln_address in src/lnurl.rs
(lines 101-105), run_dev_fee_cycle in src/app/dev_fee.rs (lines 91-92), and
resolve_dev_fee_invoice in src/app/dev_fee.rs (lines 889-892). Use /// comments
directly above each function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6b3d5a65-2132-4ca7-b84c-07bd5dcaeda5

📥 Commits

Reviewing files that changed from the base of the PR and between 3bfad20 and e5af9ec.

📒 Files selected for processing (4)
  • src/app/dev_fee.rs
  • src/app/release.rs
  • src/lnurl.rs
  • src/scheduler.rs

ToRyVand added a commit to ToRyVand/mostro that referenced this pull request Jul 20, 2026
Addresses CodeRabbit nitpick on PR MostroP2P#820. run_dev_fee_cycle and
resolve_dev_fee_invoice were already documented; only resolv_ln_address
was missing one.
@grunch

grunch commented Jul 20, 2026

Copy link
Copy Markdown
Member

@ToRyVand thanks for your contribution, can you please solve conflicts?

ToRyVand added 2 commits July 20, 2026 17:20
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 MostroP2P#694
Addresses CodeRabbit nitpick on PR MostroP2P#820. run_dev_fee_cycle and
resolve_dev_fee_invoice were already documented; only resolv_ln_address
was missing one.
@ToRyVand
ToRyVand force-pushed the fix/694-lnurl-dev-fee-comment branch from eebcdd0 to 0af1019 Compare July 20, 2026 22:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/app/dev_fee.rs (1)

1525-1540: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Clean up temporary certificates to prevent /tmp bloat.

The dead_lnd test helper creates a new UUID-named directory in /tmp containing test certificates on every invocation, but never deletes it. Over time, running tests locally will accumulate these orphaned directories.

Since fedimint_tonic_lnd::connect reads the certificate files eagerly during initialization, you can safely remove the temporary directory immediately after the connection attempt.

♻️ Proposed refactor
     async fn dead_lnd() -> LndConnector {
         let dir = std::env::temp_dir().join(format!("mostro-test-lnd-{}", uuid::Uuid::new_v4()));
         std::fs::create_dir_all(&dir).unwrap();
         let cert = dir.join("tls.cert");
         let mac = dir.join("admin.macaroon");
         std::fs::write(&cert, b"").unwrap();
         std::fs::write(&mac, [1u8, 2u8]).unwrap();
         let client = fedimint_tonic_lnd::connect(
             "https://127.0.0.1:1".to_string(),
             cert.to_str().unwrap().to_string(),
             mac.to_str().unwrap().to_string(),
         )
         .await
         .expect("lazy connect never dials");
+        let _ = std::fs::remove_dir_all(&dir);
         LndConnector { client }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/dev_fee.rs` around lines 1525 - 1540, Update the dead_lnd test helper
to remove its UUID-named temporary directory immediately after
fedimint_tonic_lnd::connect completes, before returning LndConnector. Preserve
the connection result and ensure cleanup occurs after the eager certificate
read, including when the connection attempt returns an error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/app/dev_fee.rs`:
- Around line 1525-1540: Update the dead_lnd test helper to remove its
UUID-named temporary directory immediately after fedimint_tonic_lnd::connect
completes, before returning LndConnector. Preserve the connection result and
ensure cleanup occurs after the eager certificate read, including when the
connection attempt returns an error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7574f3d8-7157-4edc-b0cd-155b6c9fd47d

📥 Commits

Reviewing files that changed from the base of the PR and between eebcdd0 and 0af1019.

📒 Files selected for processing (4)
  • src/app/dev_fee.rs
  • src/app/release.rs
  • src/lnurl.rs
  • src/scheduler.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/scheduler.rs
  • src/app/release.rs
  • src/lnurl.rs

@ToRyVand

Copy link
Copy Markdown
Contributor Author

@grunch conflicts resolved (rebased onto main). Ready for another look, thanks!

@arkanoider

Copy link
Copy Markdown
Collaborator

uTACK for me, good job @ToRyVand , let's wait @grunch opinion

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🗂️ Review context

Reviewed 16cf6e6..0af1019 against main (branch is up to date, 0 commits behind). Findings below were verified by execution, not inspection: cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, cargo test lnurl:: dev_fee::, a live curl of the production dev-fee LNURL endpoint, and throwaway probes of build_callback_url against the pinned url 2.5.8.

Verdict

The design is sound and the incidental bug find is genuine — but this cannot merge as-is: CI will fail on cargo fmt, and the feature ships with zero observability and no test on the one string the PR exists to produce.

What's right

  • The pre-existing bug is real and correctly diagnosed. ? is a legal character inside a query string (RFC 3986: query = *( pchar / "/" / "?" )), so format!("{callback}?amount={amount_msat}") on a callback that already carried a query did cause amount to be absorbed into the preceding parameter's value. Good catch, good regression test, and correct to fix it here rather than leave it adjacent to new code.
  • Characters vs. bytes: you got this right. LUD-12 states "The value of commentAllowed should be the number of characters accepted for the comment query parameter." chars().take(max_len) matches the spec; a fair number of implementations count bytes and get this wrong.
  • Passing &Keys instead of re-deriving the EC key via get_keys() on every cycle is a legitimate improvement, cleanly threaded.
  • cargo clippy --all-targets --all-features -- -D warnings is clean. All 14 lnurl:: and 36 dev_fee:: tests pass.

Sizing note on the comment

The comment is 127 characters:

mostro-dev-fee order=58bb28ce-de34-4f3c-9bcc-c73bf1756e13 node=96f7d5278ba0892c6379c00fb085d8b5e876e0ca53dfc4125fe8197582800b33

The production recipient currently accommodates it:

$ curl -s https://walletofsatoshi.com/.well-known/lnurlp/pivotaldeborah52
{"callback":"https://livingroomofsatoshi.com/api/v1/lnurl/payreq/...","commentAllowed":255,...}

So this works today. The concern raised inline is the failure behaviour when it ever stops fitting, which is currently silent and produces a truncated-but-plausible-looking pubkey.

⚠️ Repository-level finding (outside this PR)

.github/workflows/ci.yml triggers on on: push only — no pull_request. The fmt, clippy and test jobs therefore ran on the contributor's fork and are not reported as checks on this PR (gh pr checks 820 shows only mint-integration, mutation-baseline, mutation-pr — all skipped — plus CodeRabbit). That is exactly why the formatting failure below reached review unnoticed. Worth a follow-up issue: on: [push, pull_request].

Comment thread src/app/dev_fee.rs Outdated
.await
.unwrap();
assert!(resolve_dev_fee_invoice(&order).await.is_err());
assert!(resolve_dev_fee_invoice(&order, &Keys::generate()).await.is_err());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

cargo fmt was not run — the fmt CI job fails on this line.

The PR description lists $ cargo fmt in the test plan, but the tree is not rustfmt-clean:

$ cargo fmt --all -- --check
Diff in src/app/dev_fee.rs:1974:
-        assert!(resolve_dev_fee_invoice(&order, &Keys::generate()).await.is_err());
+        assert!(resolve_dev_fee_invoice(&order, &Keys::generate())
+            .await
+            .is_err());

.github/workflows/ci.yml:16 runs cargo fmt --all -- --check, and the test job declares needs: [fmt, clippy] — so this blocks the whole pipeline. This is the only formatting violation in the diff.

Suggested change
assert!(resolve_dev_fee_invoice(&order, &Keys::generate()).await.is_err());
assert!(resolve_dev_fee_invoice(&order, &Keys::generate())
.await
.is_err());

Comment thread src/lnurl.rs Outdated
Comment on lines +72 to +78
/// LUD-12: returns the comment truncated to `max_len` chars, or `None` if
/// there's no comment to send or the server advertises no support for one
/// (`max_len == 0`).
fn fit_comment(comment: Option<&str>, max_len: usize) -> Option<String> {
let comment = comment.filter(|_| max_len > 0)?;
Some(comment.chars().take(max_len).collect())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Silent truncation produces a plausible-looking but wrong identifier — defeating the purpose of the feature.

The comment is 127 chars. If commentAllowed is ever below that (a different dev-fee address, or the provider lowering its limit), chars().take(max_len) cuts through the middle of the node pubkey and emits something that still looks like a valid hex key. Whoever reconciles payments on the receiving end has no way to tell a complete identifier from a mutilated one, and nothing is logged.

For a feature whose entire purpose is traceability, a partial trace is worse than none: omit rather than truncate.

Suggested change
/// LUD-12: returns the comment truncated to `max_len` chars, or `None` if
/// there's no comment to send or the server advertises no support for one
/// (`max_len == 0`).
fn fit_comment(comment: Option<&str>, max_len: usize) -> Option<String> {
let comment = comment.filter(|_| max_len > 0)?;
Some(comment.chars().take(max_len).collect())
}
/// LUD-12: returns the comment when it fits within `max_len` chars, or
/// `None` if there's no comment to send, the server advertises no support
/// for one (`max_len == 0`), or the comment would have to be truncated —
/// a half-written order id or pubkey is a worse trace than no trace.
fn fit_comment(comment: Option<&str>, max_len: usize) -> Option<String> {
let comment = comment.filter(|c| max_len > 0 && c.chars().count() <= max_len)?;
Some(comment.to_string())
}

Note this inverts fit_comment_truncates_to_server_limit (line 285), which would become fit_comment_none_when_too_long. If you prefer to keep truncating, then please at least warn! when it happens — the current behaviour is undetectable in production either way.

Comment thread src/lnurl.rs
Comment on lines +91 to +92
let mut url = reqwest::Url::parse(callback)
.map_err(|_| MostroInternalErr(ServiceError::LnAddressParseError))?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Security | 🟠 Major

No scheme validation on an attacker-controlled URL.

callback comes straight out of a remote LNURL server's JSON. Verified against the pinned url 2.5.8 — any scheme parses and gets the query appended:

build_callback_url("ftp://h/cb", ...)          => Ok("ftp://h/cb?amount=100000")
build_callback_url("javascript:alert(1)", ...) => Ok("javascript:alert(1)?amount=100000")

This matters because the address is not always trusted: in src/app/release.rs:499, do_payment feeds resolv_ln_address a buyer-supplied lightning address (order.buyer_invoice). A hostile buyer can stand up their own LNURL server and return callback: "http://127.0.0.1:8332/..." or http://169.254.169.254/latest/meta-data/, and mostrod will issue the GET. Only pr is read back, so it's blind SSRF — but internal port scanning and GET-triggered side effects are real.

This weakness pre-dates the PR. Flagging it because build_callback_url is the choke point this PR introduces, and is now the only place in the codebase that parses this value — the check is four lines and belongs here:

Suggested change
let mut url = reqwest::Url::parse(callback)
.map_err(|_| MostroInternalErr(ServiceError::LnAddressParseError))?;
let mut url = reqwest::Url::parse(callback)
.map_err(|_| MostroInternalErr(ServiceError::LnAddressParseError))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(MostroInternalErr(ServiceError::LnAddressParseError));
}

Blocking private/link-local ranges is a larger change and better tracked as a separate issue.

Comment thread src/lnurl.rs
Comment on lines +93 to +94
url.query_pairs_mut()
.append_pair("amount", &amount_msat.to_string());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🟡 Minor

A callback that already carries amount yields a duplicate parameter.

Verified: build_callback_url("https://h/cb?amount=5", 100_000, ...) produces https://h/cb?amount=5&amount=100000. Which one the server honours is undefined.

Not exploitable for overpayment — src/lightning/mod.rs:269-278 compares the decoded invoice amount against the requested one and aborts with "Wrong amount". But the result is another opaque, permanently-retrying dev-fee failure (see the comment on line 141). Consider dropping any pre-existing amount/comment keys before appending, so the value you compute is the one that's sent.

Comment thread src/lnurl.rs
}
let callback = body["callback"].as_str().unwrap_or("");
let callback = format!("{callback}?amount={amount_msat}");
let comment_allowed = body["commentAllowed"].as_u64().unwrap_or(0) as usize;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

New failure mode lands in a path that discards the server's error reason, and then retries forever.

Two problems compound here.

(a) The reason is thrown away. LNURL errors are returned as HTTP 200 with {"status":"ERROR","reason":"..."}. That body has no pr, so body["pr"].as_str().unwrap_or("") (line 157) yields "" and the function returns Ok(""). src/app/dev_fee.rs:934 then logs the generic "returned empty invoice" and the server's reason is never surfaced. This PR introduces a brand-new class of rejection into that path — comment too long, comment unsupported, comment rejected — all of which will now be invisible.

Related: as_u64() returns None if a server encodes commentAllowed as 255.0 or as a string, silently collapsing to 0 and dropping the comment with no trace.

(b) It retries forever with identical input. On resolution failure, src/app/dev_fee.rs:450 calls release_pending_claim(...) and continues, so the next cycle retries — with the same comment — producing a permanent failure loop and no diagnostic pointing at the comment.

Minimum fix, surfacing the reason:

if body["status"].as_str() == Some("ERROR") {
    let reason = body["reason"].as_str().unwrap_or("unknown");
    error!("LNURL callback rejected: {reason}");
}

Worth considering as well: retry once without the comment before giving up. The dev fee is real money and shouldn't be blocked indefinitely by an optional field.

Comment thread src/app/dev_fee.rs
Comment on lines +902 to +909
// LUD-12 comment so the receiving end can trace which order/node a dev
// fee payment came from.
let comment = format!(
"mostro-dev-fee order={} node={}",
order.id,
keys.public_key()
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🟡 Minor

Three small things on the comment string itself:

  1. The PR description doesn't match the code. The summary says the comment is order=<id> node=<pubkey>; the code emits mostro-dev-fee order=… node=…. The prefix is useful — keep it and update the description.

  2. keys.public_key() renders hex, not npub. Confirmed output: node=96f7d5278ba0892c6379c00fb085d8b5e876e0ca53dfc4125fe8197582800b33. In a nostr project, an operator-facing trace identifier reads better as a self-describing npub1…; consider .to_bech32(). (It also shaves 63 chars off the 127-char total.)

  3. No opt-out. Every operator's node pubkey and order ids are now unconditionally disclosed to a third-party custodial provider. Both are already public on relays, so the marginal exposure is correlation rather than secrets — but it's a choice some operators will want to make. A settings toggle would be reasonable.

Comment thread src/lnurl.rs
Comment on lines +284 to 290
#[test]
fn fit_comment_truncates_to_server_limit() {
assert_eq!(
fit_comment(Some("order=1 node=abc"), 7),
Some("order=1".to_string())
);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Test coverage | 🟡 Minor

The string this PR exists to produce is never asserted.

All eight new tests exercise the generic helpers (fit_comment, build_callback_url). Nothing asserts that resolve_dev_fee_invoice builds mostro-dev-fee order=… node=…, and nothing covers the wiring from resolv_ln_address(comment) through to the comment pair on the callback URL.

Concretely: you could delete the format! at src/app/dev_fee.rs:903-908 entirely and all eight tests would still pass.

Extracting the format into a pure function makes it testable without a live LNURL server:

fn dev_fee_comment(order_id: &Uuid, node: &PublicKey) -> String {
    format!("mostro-dev-fee order={order_id} node={node}")
}

Then assert both the shape and that it stays within a sane budget — the 127-char figure is what makes issue #694's traceability claim hold.

Comment thread src/app/dev_fee.rs Outdated
- reject non-http(s) URL schemes in extract_lnurl (covers every outbound
  GET: ln_exists, both resolv_ln_address requests, and the callback),
  closing an SSRF vector reachable via buyer-supplied lightning addresses
- warn when a comment is dropped for exceeding the server's commentAllowed
  instead of silently truncating or silently disappearing
- drop pre-existing amount/comment query params before appending ours
- surface the LNURL server's rejection reason on both requests, not just
  the callback
- render the node identifier as npub instead of hex
- extract dev_fee_comment and is_http_or_https into pure, tested functions,
  the latter shared with the existing Cashu mint_url scheme check
- run cargo fmt (was failing CI)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lnurl.rs (1)

144-148: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the expanded public resolver API.

Add /// docs covering that amount is in sats, the optional LUD-12 comment, and its result/error behavior.

As per coding guidelines, “Document non-obvious public Rust APIs with /// documentation comments.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lnurl.rs` around lines 144 - 148, Add Rust /// documentation to the
public resolv_ln_address function describing that amount is denominated in
satoshis, comment is an optional LUD-12 comment, and the function returns the
resolved address on success or MostroError on failure.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lnurl.rs`:
- Around line 168-172: Stop logging raw LNURL rejection reasons in both
rejection branches at src/lnurl.rs:168-172 and src/lnurl.rs:199-203. Replace the
reason text with a fixed rejection message and safe metadata such as its length,
while preserving the existing return behavior.

---

Nitpick comments:
In `@src/lnurl.rs`:
- Around line 144-148: Add Rust /// documentation to the public
resolv_ln_address function describing that amount is denominated in satoshis,
comment is an optional LUD-12 comment, and the function returns the resolved
address on success or MostroError on failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f6c1c59-71e8-4b37-94eb-53c92341d234

📥 Commits

Reviewing files that changed from the base of the PR and between 0af1019 and 860bcac.

📒 Files selected for processing (4)
  • src/app/dev_fee.rs
  • src/config/util.rs
  • src/lnurl.rs
  • src/util.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/dev_fee.rs

Comment thread src/lnurl.rs
@arkanoider
arkanoider requested a review from grunch July 22, 2026 12:48
…ess docs

CodeRabbit flagged that error!() logged the untrusted 'reason' field from
external LNURL servers verbatim — a log-injection vector on the same
buyer-supplied-address path already flagged as SSRF-sensitive. Log the
reason's byte length instead. Also adds the missing /// docs on
resolv_ln_address per the nitpick.

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked every prior review comment against current head 57f1172c332b3831040d1a1042ce4383366f8b8e.

All blocking and substantive PR issues are fixed: the tree is rustfmt-clean; oversized comments are omitted with a warning rather than truncated; initial and callback URLs enforce HTTP(S); pre-existing amount/comment parameters are replaced; LNURL rejections are explicitly logged without exposing the untrusted raw reason; the trace uses npub; the exact comment shape is tested; and the public resolver documentation/import cleanup are present.

Two non-blocking suggestions remain intentionally out of scope: retrying once without the optional comment / adding an operator opt-out. CodeRabbit's temporary test-directory cleanup nit is also still present, but that helper is unchanged from the PR base and is not a regression in this PR.

Exact-head local verification with Rust 1.94.0:

  • cargo fmt --all -- --check
  • cargo test lnurl:: — 17 passed
  • cargo test dev_fee:: — 37 passed
  • cargo clippy --all-targets --all-features -- -D warnings

No blocking issues remain. Approved.

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed locally on the branch — build, lints and the full suite, plus a couple of things I verified by running rather than reading.

Verification

Check Result
cargo fmt --all -- --check clean
cargo clippy --all-targets --all-features -- -D warnings clean
cargo test --bins 1032 passed, 0 failed

The failure noted in the PR description (lightning::invoice::tests::test_lnurl_validation_with_test_server) passes here — it was the fixed port 8080 being occupied on your machine, not the code. The suite is fully green. (Filed as part of #839: that harness should bind port 0 instead.)

What I checked beyond reading the diff

The ?amount= bug and its fix are real. Ran build_callback_url against the url version pinned in this repo:

https://pay.example.com/cb?id=abc&amount=100000&comment=mostro-dev-fee+order%3D550e8400-...+node%3Dnpub1abc

amount lands as its own pair, and build_callback_url_preserves_existing_query_params covers exactly the case that used to break. Good catch, and right call fixing it here instead of leaving it next to new code.

The feature works against the real endpoint. Queried the live dev-fee address (walletofsatoshi.com/.well-known/lnurlp/pivotaldeborah52): it advertises commentAllowed: 255, and the comment is ~126 chars, so it fits with room to spare. No silent degradation in production.

One accuracy note for the record: that endpoint's callback is https://livingroomofsatoshi.com/api/v1/lnurl/payreq/<uuid>, with no query string — so the ?amount= bug never affected dev-fee payments. Where it bit is the buyer payout path (do_payment, with buyer-supplied addresses such as LNbits-style callbacks). The fix is valuable, just on a different code path than the description implies.

Findings, all filed as follow-ups

Nothing blocking. The design is sound, the extracted pure functions are well tested, and the scheme allow-list closes a real vector.

  • #837MEDIUM, host-based SSRF. This is the "known gap, tracked separately" your build_callback_url doc comment promises; there was no issue behind it, so now there is. I confirmed it is reachable from user input: user@169.254.169.254 and user@localhost both pass LightningAddress::from_str and produce an outbound GET. Pre-existing and explicitly out of scope for this PR.
  • #838LOW, reason length: N bytes isn't actionable for an operator. CodeRabbit's log-injection concern was right, but sanitize-and-truncate is the middle ground rather than discarding the reason entirely.
  • #839LOW, the two new status: "ERROR" branches and the commentAllowed wiring have no test coverage. The pure helpers are well covered; it's the plumbing inside resolv_ln_address that isn't.
  • #840LOW, minor cleanups: the comment-fit rule is duplicated between fit_comment and the warn! branch, the warning can repeat per scheduler tick, and spaces are form-urlencoded as +.
  • Commented on #814 rather than opening a duplicate — it already covers resolv_ln_address returning Ok(""), and this PR adds two more paths that reach it. Added the concrete detail that do_payment (src/app/release.rs:499) never guards against the empty string, while resolve_dev_fee_invoice does.

One thing to fix before merge

The PR description still says the LNURL error reason is logged — "log the server's actual reason instead of silently returning an empty invoice". Commit 57f1172 changed that to logging only the byte length. Worth updating the body so it matches the code that ships.

Nice work on this one — the incidental bug find and the regression test around it are the best part of the PR.

@grunch
grunch merged commit a99799e into MostroP2P:main Jul 24, 2026
4 checks passed
@ToRyVand

ToRyVand commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Merged — technical summary

Merged by @grunch in a99799e. Recap of what shipped, for the record:

Problem. The dev-fee LNURL-pay callback only sent amount — no way for a receiving node to tell which Mostro instance or trade a payment came from.

Solution. LUD-12 comment support: resolv_ln_address reads the server's commentAllowed limit and attaches "mostro-dev-fee order=<id> node=<pubkey>" (truncated to fit), silently omitted when the server doesn't support it.

Review round (2026-07-21, 8 comments, "cannot merge as-is") — all addressed in 860bcac:

  • cargo fmt failing CI (Critical)
  • fit_comment silently truncating mid-pubkey instead of omitting (Major)
  • No scheme validation on the LNURL callback URL — SSRF surface via buyer-supplied lightning address (Major)
  • Duplicate amount/comment query params possible (Minor) — fixed with Url::query_pairs_mut() instead of string concatenation, which also fixed a pre-existing bug where servers returning a callback with its own query string silently corrupted amount
  • LNURL server's reason on {"status":"ERROR"} discarded (Major)
  • Node id logged as hex instead of npub (Minor)
  • Untested log string, import nitpick (Minor)

Proactive follow-up (57f1172): CodeRabbit's re-review flagged the same reason field being logged verbatim via error!() — log-injection risk (CWE-117) on the same buyer-controlled path. Fixed by logging byte length instead of raw text rather than waiting for another review round, since it overlapped with what grunch had already scrutinized.

Final: 4 commits, 6 files, +274/-16. cargo fmt/clippy -D warnings/cargo test clean throughout.

Closes #694.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add LNURL-pay comment to dev fee invoices

3 participants