Skip to content

gl-sdk: Split input handling into sync parse_input + async resolve_input#707

Open
angelix wants to merge 12 commits into2026w15-lnurlfrom
ave-lnurl-improvements
Open

gl-sdk: Split input handling into sync parse_input + async resolve_input#707
angelix wants to merge 12 commits into2026w15-lnurlfrom
ave-lnurl-improvements

Conversation

@angelix
Copy link
Copy Markdown
Contributor

@angelix angelix commented Apr 26, 2026

Two entry points with explicit cost contracts replace the single
async parse_input that absorbed both classification and HTTP
resolution:

  • parse_input(input) -> ParsedInput — synchronous, offline, no
    I/O. Identifies BOLT11 invoices, node IDs, LNURL bech32 strings
    (decoded to their underlying URL), and Lightning Addresses
    (returned as the unparsed user@host form). LNURL inputs are
    classified but NOT fetched.

  • resolve_input(input) -> ResolvedInput — asynchronous,
    network-touching. Internally calls parse_input, then for the
    LNURL / Lightning Address branches performs the HTTP GET to the
    service endpoint and returns typed pay or withdraw request data.

Wallets that want offline classification (clipboard validation,
invoice sanity checks on the send screen, debounced input
classification as the user types) call parse_input. Wallets handling
a QR scan that should proceed straight to a pay/withdraw screen call
resolve_input.

Public surface

  • ParsedInput { Bolt11, NodeId, LnUrl { url }, LnUrlAddress { address } }
    — offline classification result.
  • ResolvedInput { Bolt11, NodeId, LnUrlPay { data }, LnUrlWithdraw { data } }
    — fully-resolved result.
  • parse_input(input) — sync, no I/O.
  • resolve_input(input) — async, may HTTP, delegates to parse_input
    internally.

Foreign bindings mirror the same split:

  • Python: glsdk.parse_input (sync) + glsdk.resolve_input (async).
  • TypeScript: parseInput(input): ParsedInput +
    resolveInput(input): Promise.
  • Kotlin: parseInput is sync (no runBlocking / suspend fun for
    the offline classification anymore); resolveInput is suspend.

Tests

  • 19 Rust unit tests (was 9): split coverage of parse_input
    (BOLT11, NodeId, LNURL bech32 decoding, Lightning Address
    classification, all error cases) and resolve_input pass-through
    paths (BOLT11/NodeId without HTTP, error-before-HTTP for invalid
    LNURL).
  • gl-sdk Python: test_parse_input.py rewritten for the sync API,
    with new tests for offline LNURL/Lightning-Address classification.
    test_lnurl.py switched its HTTP-resolving cases to resolve_input
    and ResolvedInput.LN_URL_*.
  • gl-sdk-android: ParseInputTest.kt / LnurlParseTest.kt — runBlocking
    removed, parseInput called synchronously, with new offline LNURL
    classification cases.
  • gl-sdk-napi: parse-input.spec.ts — sync calls (expect(() => …) .toThrow() instead of await expect(...).rejects.toThrow()),
    with new LNURL bech32 / Lightning Address classification cases.

Verified: cargo build -p gl-sdk -p gl-sdk-node clean; cargo test
-p gl-sdk --lib 19/19 pass; Python and TypeScript bindings
regenerated and verified to expose both ParsedInput / ResolvedInput
types and both parse_input / resolve_input free fns.

cdecker and others added 11 commits April 13, 2026 16:15
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.
@cdecker
Copy link
Copy Markdown
Collaborator

cdecker commented Apr 27, 2026

I thought the whole point of parse_input was to be guaranteed to be a local operation, so people would not worry about calling it. With the introduction of the resolution this isn't as clear cut.

Do you think a parse_input and a resolve_input (which internally delegates BOLT11 and so on to parse_input, but also resolves things over the net) would be a better split maybe?

@angelix angelix force-pushed the ave-lnurl-improvements branch from 24e8032 to 2158773 Compare April 27, 2026 15:44
Two entry points with explicit cost contracts replace the single
async parse_input that absorbed both classification and HTTP
resolution:

  * parse_input(input) -> ParsedInput — synchronous, offline, no
    I/O. Identifies BOLT11 invoices, node IDs, LNURL bech32 strings
    (decoded to their underlying URL), and Lightning Addresses
    (returned as the unparsed `user@host` form). LNURL inputs are
    classified but NOT fetched.

  * resolve_input(input) -> ResolvedInput — asynchronous,
    network-touching. Internally calls parse_input, then for the
    LNURL / Lightning Address branches performs the HTTP GET to the
    service endpoint and returns typed pay or withdraw request data.

Wallets that want offline classification (clipboard validation,
invoice sanity checks on the send screen, debounced input
classification as the user types) call parse_input. Wallets handling
a QR scan that should proceed straight to a pay/withdraw screen call
resolve_input.

Public surface
- ParsedInput { Bolt11, NodeId, LnUrl { url }, LnUrlAddress { address } }
  — offline classification result.
- ResolvedInput { Bolt11, NodeId, LnUrlPay { data }, LnUrlWithdraw { data } }
  — fully-resolved result.
- parse_input(input) — sync, no I/O.
- resolve_input(input) — async, may HTTP, delegates to parse_input
  internally.

Foreign bindings mirror the same split:
- Python: glsdk.parse_input (sync) + glsdk.resolve_input (async).
- TypeScript: parseInput(input): ParsedInput +
  resolveInput(input): Promise<ResolvedInput>.
- Kotlin: parseInput is sync (no `runBlocking` / `suspend fun` for
  the offline classification anymore); resolveInput is suspend.

Tests
- 19 Rust unit tests (was 9): split coverage of parse_input
  (BOLT11, NodeId, LNURL bech32 decoding, Lightning Address
  classification, all error cases) and resolve_input pass-through
  paths (BOLT11/NodeId without HTTP, error-before-HTTP for invalid
  LNURL).
- gl-sdk Python: test_parse_input.py rewritten for the sync API,
  with new tests for offline LNURL/Lightning-Address classification.
  test_lnurl.py switched its HTTP-resolving cases to resolve_input
  and ResolvedInput.LN_URL_*.
- gl-sdk-android: ParseInputTest.kt / LnurlParseTest.kt — runBlocking
  removed, parseInput called synchronously, with new offline LNURL
  classification cases.
- gl-sdk-napi: parse-input.spec.ts — sync calls (`expect(() => …)
  .toThrow()` instead of `await expect(...).rejects.toThrow()`),
  with new LNURL bech32 / Lightning Address classification cases.

Verified: cargo build -p gl-sdk -p gl-sdk-node clean; cargo test
-p gl-sdk --lib 19/19 pass; Python and TypeScript bindings
regenerated and verified to expose both ParsedInput / ResolvedInput
types and both parse_input / resolve_input free fns.
@angelix angelix force-pushed the ave-lnurl-improvements branch from 2158773 to ab9ea0a Compare April 27, 2026 15:49
@angelix angelix changed the title gl-sdk: Make parse_input async and absorb LNURL resolution gl-sdk: Split input handling into sync parse_input + async resolve_input Apr 27, 2026
@angelix
Copy link
Copy Markdown
Contributor Author

angelix commented Apr 27, 2026

I thought the whole point of parse_input was to be guaranteed to be a local operation, so people would not worry about calling it. With the introduction of the resolution this isn't as clear cut.

Do you think a parse_input and a resolve_input (which internally delegates BOLT11 and so on to parse_input, but also resolves things over the net) would be a better split maybe?

Split into 2 in ab9ea0a

  • sync & offline parse_input
  • async resolve_input

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