Skip to content

gl-sdk: Add LNURL-auth (LUD-04 / LUD-05) and unify Node entry points#708

Open
angelix wants to merge 13 commits into2026w15-lnurlfrom
ave-lnurl-auth
Open

gl-sdk: Add LNURL-auth (LUD-04 / LUD-05) and unify Node entry points#708
angelix wants to merge 13 commits into2026w15-lnurlfrom
ave-lnurl-auth

Conversation

@angelix
Copy link
Copy Markdown
Contributor

@angelix angelix commented Apr 27, 2026

Summary

  • LNURL-auth (LUD-04 / LUD-05). New Node::lnurl_auth(request) signs the service's k1 challenge and posts the LUD-04 callback. Per-domain LUD-05 derivation happens internally; callers don't supply any keys.
  • Hardened-path security model. The m/138' xpriv is derived once from the BIP39 seed at register/recover/connect time and stored on Node in Zeroizing<Vec<u8>>. The seed itself never persists. m/138' is hardened, so the stored material cannot derive any other wallet key — blast radius is restricted to LNURL-auth identities. disconnect() and Drop for Node both take() the option to scrub eagerly.
  • Mainstream wallet HMAC convention. LUD-05 uses the 32-byte private key at m/138'/0 as the HMAC key (Phoenix / Mutiny / Zeus / BlueWallet form), enabling cross-wallet identity portability at LNURL-auth services. Locked in by a known-vector test.
  • InputType::LnUrlAuth { data }. parse_input classifies tag=login URLs offline (no HTTP fetch). New types LnUrlAuthRequestData and LnUrlCallbackStatus.
  • Zero public Node constructors. Removed the bare Node(credentials) from both UniFFI and napi surfaces. The crate-private constructor is renamed with_signernew (pub(crate)). All bindings expose the same four free functions: register / recover / connect / register_or_recover.
  • napi gains parity. Free fns register / recover / connect / registerOrRecover, a Config wrapper (withNetwork, withDeveloperCert), and Node.disconnect() / Node.credentials(). Tests and README rewritten — JS callers no longer manage an external Signer/Handle.

Test plan

  • cargo test -p gl-sdk --lib — 28/28 pass (offline classification, LUD-05 derivation determinism, signature verifies against derived xpub, callback-status JSON parsing, fixed pubkey vector for the BIP39 abandon-mnemonic at example.com)
  • cargo build -p gl-sdk and cargo build -p gl-sdk-node — both clean
  • Python bindings regenerated; glsdk.Node() raises ValueError (no constructor); register/recover/connect/register_or_recover/parse_input/lnurl_auth exposed
  • TypeScript bindings regenerated; register/recover/connect/registerOrRecover/parseInput async free fns and Node.lnurlAuth(request) exposed
  • Python integration tests (tests/test_lnurl.py, tests/test_parse_input.py) — locally blocked by Python 3.14 + coincurve wheel issue unrelated to this branch; should run in CI. New auth coverage: classification-without-HTTP, end-to-end success, deterministic per-domain pubkey, post-disconnect scrub. The two previously-skipped LNURL-pay end-to-end tests are un-skipped via a _bip39_seed helper that aligns the gl-client clients fixture with glsdk.connect's mnemonic-derived seed
  • Android instrumented tests (LnurlParseTest.kt) — 5 new offline LnUrlAuth parse cases; needs on-device or CI run
  • napi jest tests — rewritten on top of the new register/recover/connect surface; needs CI (depends on gltestserver)

Copy link
Copy Markdown
Collaborator

@cdecker cdecker left a comment

Choose a reason for hiding this comment

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

No, the signer is not guaranteed to be at the same location. If we want to have lnurl-auth we will need to run it through the CLN node, where we may have a signer present.

The reason for wanting signerless clients is because the signer is equivalent to root access to the node, i.e., the signer can self-certify any runes and TLS certs to escalate whatever privileges. We cannot assume the signer is present, and any operation that requires signing something, must involve the node.

Comment thread libs/gl-sdk/src/node.rs
/// Canonical constructor.
///
/// Crate-private — UniFFI consumers reach this via the top-level
/// `register` / `recover` / `connect` / `register_or_recover` free
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Notice that this runs into the same issue of signerless clients: register, recover and register_or_recover will require a signer, which may not always be present. So using them as a entrypoints to get a Node handle is not a good idea.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

And I just noticed that I overlooked that connect also is requiring the mnemonic, so this API just threw the signerless mode out of the window, which means everybody is running around with root privileges, for no apparent reason, other than simplicity.

We can keep it this way, but we'll want to allow signerless clients at some point, so we will need to bring back in.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in #709

@angelix angelix force-pushed the ave-lnurl-improvements branch 3 times, most recently from ab9ea0a to 6df08df Compare April 28, 2026 10:19
cdecker and others added 13 commits April 28, 2026 15:51
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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.
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.
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<Vec<u8>>; 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<Vec<u8>>
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.
@cdecker
Copy link
Copy Markdown
Collaborator

cdecker commented Apr 28, 2026

Rebased on top of main, but deferring as it requires a signer at the moment, and signerless clients will not work with this.

@cdecker cdecker changed the base branch from ave-lnurl-improvements to 2026w15-lnurl April 28, 2026 14:05
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.

2 participants