From 692d379f1aca3920f7b9b2c3f1796cb87463592a Mon Sep 17 00:00:00 2001 From: Angelos Veglektsis Date: Mon, 27 Apr 2026 00:47:07 +0300 Subject: [PATCH] gl-sdk: Split input handling into sync parse_input + async resolve_input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 14 + .../com/blockstream/glsdk/LnurlParseTest.kt | 69 +- .../com/blockstream/glsdk/ParseInputTest.kt | 36 +- libs/gl-sdk-napi/src/lib.rs | 208 ++- libs/gl-sdk-napi/tests/parse-input.spec.ts | 99 ++ libs/gl-sdk/CHANGELOG.md | 11 + libs/gl-sdk/Cargo.toml | 2 +- libs/gl-sdk/glsdk/glsdk.py | 1278 ++++++++--------- libs/gl-sdk/src/input.rs | 289 ++-- libs/gl-sdk/src/lib.rs | 34 +- libs/gl-sdk/src/lnurl.rs | 32 +- libs/gl-sdk/src/node.rs | 58 +- libs/gl-sdk/tests/test_lnurl.py | 116 +- libs/gl-sdk/tests/test_parse_input.py | 95 +- 14 files changed, 1297 insertions(+), 1044 deletions(-) create mode 100644 libs/gl-sdk-napi/tests/parse-input.spec.ts diff --git a/Cargo.lock b/Cargo.lock index bc5fcc7a0..2350cc829 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -252,6 +252,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -4821,6 +4834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38a9a27529ccff732f8efddb831b65b1e07f7dea3fd4cacd4a35a8c4b253b98" dependencies = [ "anyhow", + "async-compat", "bytes", "once_cell", "static_assertions", diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt index d3384c891..fb59a2bbb 100644 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/LnurlParseTest.kt @@ -1,80 +1,23 @@ -// Instrumented tests for parse_input() LNURL handling. -// Covers LNURL bech32 strings, lightning addresses, prefix handling, -// and error cases. Pure parsing only — no node, no network. +// Instrumented tests for parse_input() error cases on LNURL / +// Lightning Address inputs. `parse_input` is offline — these all +// reject before any HTTP would be attempted. Successful HTTP +// resolution is covered by `resolveInput` in gl-testing integration +// tests against a live LNURL fixture. package com.blockstream.glsdk import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class LnurlParseTest { - // LNURL bech32 encoding of https://service.com/lnurl (LUD-01 example). - private val lnurlBech32 = - "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2" - - // ============================================================ - // LNURL bech32 parsing - // ============================================================ - - @Test - fun parse_lnurl_bech32_uppercase() { - val result = parseInput(lnurlBech32) - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - - @Test - fun parse_lnurl_bech32_lowercase() { - val result = parseInput(lnurlBech32.lowercase()) - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - - @Test - fun parse_lnurl_with_lightning_prefix() { - val result = parseInput("lightning:$lnurlBech32") - assertTrue( - "Expected LnUrl, got $result", - result is InputType.LnUrl, - ) - } - @Test(expected = Exception::class) fun parse_invalid_lnurl_bech32_returns_error() { parseInput("LNURL1INVALIDDATA") } - // ============================================================ - // Lightning address parsing - // ============================================================ - - @Test - fun parse_lightning_address_simple() { - val result = parseInput("user@example.com") - assertTrue( - "Expected LnUrlAddress, got $result", - result is InputType.LnUrlAddress, - ) - assertEquals("user@example.com", (result as InputType.LnUrlAddress).address) - } - - @Test - fun parse_lightning_address_with_symbols() { - val result = parseInput("sat.oshi-99@example.com") - assertTrue( - "Expected LnUrlAddress, got $result", - result is InputType.LnUrlAddress, - ) - } - @Test(expected = Exception::class) fun parse_lightning_address_no_dot_in_domain_returns_error() { parseInput("user@localhost") @@ -89,4 +32,4 @@ class LnurlParseTest { fun parse_lightning_address_empty_domain_returns_error() { parseInput("user@") } -} \ No newline at end of file +} diff --git a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt index eb27f57d0..c0d6ca8ba 100644 --- a/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt +++ b/libs/gl-sdk-android/lib/src/androidInstrumentedTest/kotlin/com/blockstream/glsdk/ParseInputTest.kt @@ -1,5 +1,6 @@ -// Instrumented tests for parse_input(). -// Tests BOLT11 invoice parsing, node ID parsing, and error cases. +// Instrumented tests for the synchronous parse_input(). +// `parse_input` is offline — no HTTP, no I/O. LNURL HTTP resolution +// is `resolveInput` and is covered by gl-testing integration tests. package com.blockstream.glsdk @@ -22,6 +23,10 @@ class ParseInputTest { "d2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy" + "22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz" + // Bech32-encoded "https://service.com/lnurl" + private val lnurlBech32 = + "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2" + // ============================================================ // Node ID parsing // ============================================================ @@ -29,7 +34,7 @@ class ParseInputTest { @Test fun parse_valid_node_id() { val result = parseInput(validNodeId) - assertNotNull(result) + assertTrue("Expected NodeId, got $result", result is ParsedInput.NodeId) } @Test(expected = Exception::class) @@ -49,19 +54,38 @@ class ParseInputTest { @Test fun parse_valid_bolt11() { val result = parseInput(bolt11Invoice) - assertNotNull(result) + assertTrue("Expected Bolt11, got $result", result is ParsedInput.Bolt11) } @Test fun parse_bolt11_with_lightning_prefix() { val result = parseInput("lightning:$bolt11Invoice") - assertNotNull(result) + assertTrue("Expected Bolt11, got $result", result is ParsedInput.Bolt11) } @Test fun parse_bolt11_with_uppercase_prefix() { val result = parseInput("LIGHTNING:$bolt11Invoice") - assertNotNull(result) + assertTrue("Expected Bolt11, got $result", result is ParsedInput.Bolt11) + } + + // ============================================================ + // LNURL bech32 / Lightning Address — offline classification + // ============================================================ + + @Test + fun parse_lnurl_bech32_decodes_url() { + val result = parseInput(lnurlBech32) + assertTrue("Expected LnUrl, got $result", result is ParsedInput.LnUrl) + val url = (result as ParsedInput.LnUrl).url + assertTrue("Expected decoded https URL, got $url", url.startsWith("https://")) + } + + @Test + fun parse_lightning_address() { + val result = parseInput("user@example.com") + assertTrue("Expected LnUrlAddress, got $result", result is ParsedInput.LnUrlAddress) + assertEquals("user@example.com", (result as ParsedInput.LnUrlAddress).address) } // ============================================================ diff --git a/libs/gl-sdk-napi/src/lib.rs b/libs/gl-sdk-napi/src/lib.rs index 73befa987..3e0749cff 100644 --- a/libs/gl-sdk-napi/src/lib.rs +++ b/libs/gl-sdk-napi/src/lib.rs @@ -10,11 +10,14 @@ use glsdk::{ Credentials as GlCredentials, DeveloperCert as GlDeveloperCert, Handle as GlHandle, + ParsedInput as GlParsedInput, + ResolvedInput as GlResolvedInput, Network as GlNetwork, Node as GlNode, NodeEvent as GlNodeEvent, NodeEventStream as GlNodeEventStream, OutputStatus as GlOutputStatus, + ParsedInvoice as GlParsedInvoice, Scheduler as GlScheduler, Signer as GlSigner, }; @@ -246,6 +249,58 @@ pub struct FundChannel { pub channel_id: Option, } +// ============================================================================ +// Input Parsing Types +// ============================================================================ + +#[napi(object)] +pub struct ParsedInvoice { + pub bolt11: String, + pub payee_pubkey: Option, + pub payment_hash: Buffer, + pub description: Option, + /// Amount in millisatoshis (i64 for JS), `None` for any-amount invoices. + pub amount_msat: Option, + /// Seconds from creation until the invoice expires. + pub expiry: i64, + /// Unix timestamp (seconds) when the invoice was created. + pub timestamp: i64, +} + +/// Result of `parseInput` — offline classification, no HTTP. +/// Discriminated by `type`. LNURL bech32 strings come back as their +/// decoded URL; Lightning Addresses as the unparsed `user@host` form. +#[napi(object)] +pub struct ParsedInput { + /// "bolt11" | "node_id" | "lnurl" | "lnurl_address" + pub r#type: String, + /// Present when type == "bolt11" + pub bolt11: Option, + /// Present when type == "node_id" + pub node_id: Option, + /// Present when type == "lnurl" — the decoded URL of the LNURL. + pub lnurl: Option, + /// Present when type == "lnurl_address" — `user@host` form. + pub lnurl_address: Option, +} + +/// Result of `resolveInput` — fully-resolved, may have performed HTTP. +/// Discriminated by `type`. Exactly one of the variant fields +/// (`bolt11`, `node_id`, `lnurl_pay`, `lnurl_withdraw`) is populated. +#[napi(object)] +pub struct ResolvedInput { + /// "bolt11" | "node_id" | "lnurl_pay" | "lnurl_withdraw" + pub r#type: String, + /// Present when type == "bolt11" + pub bolt11: Option, + /// Present when type == "node_id" + pub node_id: Option, + /// Present when type == "lnurl_pay" + pub lnurl_pay: Option, + /// Present when type == "lnurl_withdraw" + pub lnurl_withdraw: Option, +} + // ============================================================================ // LNURL Types // ============================================================================ @@ -275,17 +330,6 @@ pub struct LnUrlWithdrawRequestData { pub lnurl: String, } -/// Result of resolving an LNURL. Discriminated by `type` field. -#[napi(object)] -pub struct ResolvedLnUrl { - /// "pay" or "withdraw" - pub r#type: String, - /// Present when type == "pay" - pub pay: Option, - /// Present when type == "withdraw" - pub withdraw: Option, -} - #[napi(object)] pub struct LnUrlPayRequest { pub data: LnUrlPayRequestData, @@ -975,28 +1019,10 @@ impl Node { // ── LNURL methods ─────────────────────────────────────────── - /// Resolve an LNURL or Lightning Address to its endpoint data. - /// - /// Accepts an LNURL bech32 string, a decoded URL, or a Lightning - /// Address (user@domain). - #[napi] - pub async fn resolve_lnurl(&self, input: String) -> Result { - let inner = self.inner.clone(); - let resolved = tokio::task::spawn_blocking(move || { - inner - .resolve_lnurl(input) - .map_err(|e| Error::from_reason(e.to_string())) - }) - .await - .map_err(|e| Error::from_reason(e.to_string()))??; - - Ok(napi_resolved_lnurl_from_gl(resolved)) - } - /// Execute an LNURL-pay flow. /// - /// Call `resolve_lnurl()` first, then pass the pay data with a - /// chosen amount. + /// Build the request from `LnUrlPayRequestData` (obtained out of + /// band) and a chosen amount. #[napi] pub async fn lnurl_pay(&self, request: LnUrlPayRequest) -> Result { let inner = self.inner.clone(); @@ -1014,8 +1040,8 @@ impl Node { /// Execute an LNURL-withdraw flow. /// - /// Call `resolve_lnurl()` first, then pass the withdraw data with - /// a chosen amount. + /// Build the request from `LnUrlWithdrawRequestData` (obtained out + /// of band) and a chosen amount. #[napi] pub async fn lnurl_withdraw(&self, request: LnUrlWithdrawRequest) -> Result { let inner = self.inner.clone(); @@ -1081,9 +1107,21 @@ fn output_status_to_string(status: &GlOutputStatus) -> String { } // ============================================================================ -// LNURL Conversion Helpers +// Input Parsing Conversion Helpers // ============================================================================ +fn napi_parsed_invoice_from_gl(invoice: GlParsedInvoice) -> ParsedInvoice { + ParsedInvoice { + bolt11: invoice.bolt11, + payee_pubkey: invoice.payee_pubkey.map(Buffer::from), + payment_hash: Buffer::from(invoice.payment_hash), + description: invoice.description, + amount_msat: invoice.amount_msat.map(|v| v as i64), + expiry: invoice.expiry as i64, + timestamp: invoice.timestamp as i64, + } +} + fn napi_pay_request_data_from_gl(data: glsdk::LnUrlPayRequestData) -> LnUrlPayRequestData { LnUrlPayRequestData { callback: data.callback, @@ -1109,21 +1147,103 @@ fn napi_withdraw_request_data_from_gl( } } -fn napi_resolved_lnurl_from_gl(resolved: glsdk::ResolvedLnUrl) -> ResolvedLnUrl { - match resolved { - glsdk::ResolvedLnUrl::Pay { data } => ResolvedLnUrl { - r#type: "pay".to_string(), - pay: Some(napi_pay_request_data_from_gl(data)), - withdraw: None, +fn napi_parsed_input_from_gl(input: GlParsedInput) -> ParsedInput { + match input { + GlParsedInput::Bolt11 { invoice } => ParsedInput { + r#type: "bolt11".to_string(), + bolt11: Some(napi_parsed_invoice_from_gl(invoice)), + node_id: None, + lnurl: None, + lnurl_address: None, }, - glsdk::ResolvedLnUrl::Withdraw { data } => ResolvedLnUrl { - r#type: "withdraw".to_string(), - pay: None, - withdraw: Some(napi_withdraw_request_data_from_gl(data)), + GlParsedInput::NodeId { node_id } => ParsedInput { + r#type: "node_id".to_string(), + bolt11: None, + node_id: Some(node_id), + lnurl: None, + lnurl_address: None, + }, + GlParsedInput::LnUrl { url } => ParsedInput { + r#type: "lnurl".to_string(), + bolt11: None, + node_id: None, + lnurl: Some(url), + lnurl_address: None, + }, + GlParsedInput::LnUrlAddress { address } => ParsedInput { + r#type: "lnurl_address".to_string(), + bolt11: None, + node_id: None, + lnurl: None, + lnurl_address: Some(address), }, } } +fn napi_resolved_input_from_gl(input: GlResolvedInput) -> ResolvedInput { + match input { + GlResolvedInput::Bolt11 { invoice } => ResolvedInput { + r#type: "bolt11".to_string(), + bolt11: Some(napi_parsed_invoice_from_gl(invoice)), + node_id: None, + lnurl_pay: None, + lnurl_withdraw: None, + }, + GlResolvedInput::NodeId { node_id } => ResolvedInput { + r#type: "node_id".to_string(), + bolt11: None, + node_id: Some(node_id), + lnurl_pay: None, + lnurl_withdraw: None, + }, + GlResolvedInput::LnUrlPay { data } => ResolvedInput { + r#type: "lnurl_pay".to_string(), + bolt11: None, + node_id: None, + lnurl_pay: Some(napi_pay_request_data_from_gl(data)), + lnurl_withdraw: None, + }, + GlResolvedInput::LnUrlWithdraw { data } => ResolvedInput { + r#type: "lnurl_withdraw".to_string(), + bolt11: None, + node_id: None, + lnurl_pay: None, + lnurl_withdraw: Some(napi_withdraw_request_data_from_gl(data)), + }, + } +} + +/// Synchronously classify the input. **No HTTP, no I/O.** +/// +/// Recognises BOLT11 invoices, node IDs, LNURL bech32 strings, and +/// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes +/// automatically. LNURL inputs are decoded to their underlying URL +/// but **not fetched** — call `resolveInput` for that. +#[napi] +pub fn parse_input(input: String) -> Result { + let parsed = + glsdk::parse_input(input).map_err(|e| Error::from_reason(e.to_string()))?; + Ok(napi_parsed_input_from_gl(parsed)) +} + +/// Asynchronously classify and resolve the input. +/// +/// Internally calls `parseInput`. For LNURL bech32 strings and +/// Lightning Addresses performs the HTTP GET to the endpoint and +/// returns typed pay or withdraw request data. For BOLT11 invoices +/// and node IDs returns immediately without I/O. +#[napi] +pub async fn resolve_input(input: String) -> Result { + let resolved = glsdk::resolve_input(input) + .await + .map_err(|e| Error::from_reason(e.to_string()))?; + Ok(napi_resolved_input_from_gl(resolved)) +} + +// ============================================================================ +// LNURL Conversion Helpers +// ============================================================================ + fn gl_pay_request_data_from_napi(data: LnUrlPayRequestData) -> glsdk::LnUrlPayRequestData { glsdk::LnUrlPayRequestData { callback: data.callback, diff --git a/libs/gl-sdk-napi/tests/parse-input.spec.ts b/libs/gl-sdk-napi/tests/parse-input.spec.ts new file mode 100644 index 000000000..146566575 --- /dev/null +++ b/libs/gl-sdk-napi/tests/parse-input.spec.ts @@ -0,0 +1,99 @@ +import { parseInput } from '../index.js'; + +const VALID_NODE_ID = + '02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'; + +const BOLT11_INVOICE = + 'lnbc110n1p38q3gtpp5ypz09jrd8p993snjwnm68cph4ftwp22le34xd4r8ftspwshxhm' + + 'nsdqqxqyjw5qcqpxsp5htlg8ydpywvsa7h3u4hdn77ehs4z4e844em0apjyvmqfkzqhh' + + 'd2q9qgsqqqyssqszpxzxt9uuqzymr7zxcdccj5g69s8q7zzjs7sgxn9ejhnvdh6gqjcy' + + '22mss2yexunagm5r2gqczh8k24cwrqml3njskm548aruhpwssq9nvrvz'; + +const VALID_LNURL_BECH32 = + 'LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2'; + +describe('parseInput (synchronous, offline)', () => { + describe('BOLT11 invoices', () => { + it('classifies a valid BOLT11 invoice', () => { + const result = parseInput(BOLT11_INVOICE); + expect(result.type).toBe('bolt11'); + expect(result.bolt11).toBeDefined(); + expect(result.bolt11!.bolt11).toBe(BOLT11_INVOICE); + }); + + it('strips a lowercase lightning: prefix', () => { + const result = parseInput(`lightning:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + }); + + it('strips an uppercase LIGHTNING: prefix', () => { + const result = parseInput(`LIGHTNING:${BOLT11_INVOICE}`); + expect(result.type).toBe('bolt11'); + }); + }); + + describe('node IDs', () => { + it('classifies a valid compressed pubkey', () => { + const result = parseInput(VALID_NODE_ID); + expect(result.type).toBe('node_id'); + expect(result.nodeId).toBe(VALID_NODE_ID); + }); + + it('rejects a 66-char string that is not valid hex', () => { + expect(() => + parseInput('not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx'), + ).toThrow(); + }); + + it('rejects an uncompressed (0x04) pubkey', () => { + expect(() => + parseInput('04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619'), + ).toThrow(); + }); + }); + + describe('LNURL bech32 / Lightning Address', () => { + it('decodes an LNURL bech32 to its underlying URL', () => { + const result = parseInput(VALID_LNURL_BECH32); + expect(result.type).toBe('lnurl'); + expect(result.lnurl).toBeDefined(); + expect(result.lnurl!.startsWith('https://')).toBe(true); + }); + + it('returns a Lightning Address as the user@host form', () => { + const result = parseInput('user@example.com'); + expect(result.type).toBe('lnurl_address'); + expect(result.lnurlAddress).toBe('user@example.com'); + }); + }); + + describe('error cases', () => { + it('rejects empty input', () => { + expect(() => parseInput('')).toThrow(); + }); + + it('rejects whitespace-only input', () => { + expect(() => parseInput(' ')).toThrow(); + }); + + it('rejects unrecognized garbage', () => { + expect(() => parseInput('hello world')).toThrow(); + }); + + it('rejects an invalid LNURL bech32 string', () => { + expect(() => parseInput('LNURL1INVALIDDATA')).toThrow(); + }); + + it('rejects a malformed Lightning Address (no dot in domain)', () => { + expect(() => parseInput('user@localhost')).toThrow(); + }); + + it('rejects an empty local-part Lightning Address', () => { + expect(() => parseInput('@example.com')).toThrow(); + }); + + it('rejects an empty domain Lightning Address', () => { + expect(() => parseInput('user@')).toThrow(); + }); + }); +}); diff --git a/libs/gl-sdk/CHANGELOG.md b/libs/gl-sdk/CHANGELOG.md index 26b1fab85..e4d4ee890 100644 --- a/libs/gl-sdk/CHANGELOG.md +++ b/libs/gl-sdk/CHANGELOG.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## Unreleased +### Added + +- `parse_input()` — synchronous, offline classification of user input. Recognises 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; the cost contract is "no HTTP, no I/O." Use this for clipboard validation, invoice sanity checks, and any other path that must not touch the network. +- `resolve_input()` — asynchronous, network-touching classification. 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. BOLT11 invoices and node IDs pass through without I/O. Use this for the QR-scan flow that should proceed straight to a pay/withdraw screen. +- `ParsedInput` enum (offline result): `Bolt11`, `NodeId`, `LnUrl { url }`, `LnUrlAddress { address }`. +- `ResolvedInput` enum (resolved result): `Bolt11`, `NodeId`, `LnUrlPay { data }`, `LnUrlWithdraw { data }`. + +### Removed + +- `Node::resolve_lnurl()` and the `ResolvedLnUrl` enum. Use `parse_input()` (offline) or `resolve_input()` (HTTP) to obtain `LnUrlPayRequestData` / `LnUrlWithdrawRequestData`, then call `Node::lnurl_pay()` / `Node::lnurl_withdraw()`. + ## [0.2.0] - 2026-04-02 ### Added diff --git a/libs/gl-sdk/Cargo.toml b/libs/gl-sdk/Cargo.toml index 81f7e306b..03b908c2a 100644 --- a/libs/gl-sdk/Cargo.toml +++ b/libs/gl-sdk/Cargo.toml @@ -24,7 +24,7 @@ thiserror = "2.0.17" tokio = { version = "1", features = ["sync"] } tonic.workspace = true tracing = { version = "0.1.43", features = ["async-await", "log"] } -uniffi = { version = "0.29.4" } +uniffi = { version = "0.29.4", features = ["tokio"] } url = "2" [build-dependencies] diff --git a/libs/gl-sdk/glsdk/glsdk.py b/libs/gl-sdk/glsdk/glsdk.py index 44694fe2d..a85c957d0 100644 --- a/libs/gl-sdk/glsdk/glsdk.py +++ b/libs/gl-sdk/glsdk/glsdk.py @@ -27,6 +27,7 @@ import itertools import traceback import typing +import asyncio import platform # Used for default argument values @@ -460,7 +461,17 @@ def _uniffi_check_contract_api_version(lib): raise InternalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") def _uniffi_check_api_checksums(lib): - if lib.uniffi_glsdk_checksum_func_parse_input() != 12312: + if lib.uniffi_glsdk_checksum_func_connect() != 43555: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_parse_input() != 49187: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_recover() != 39257: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_register() != 39628: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_register_or_recover() != 65070: + raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + if lib.uniffi_glsdk_checksum_func_resolve_input() != 24844: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_func_set_log_level() != 52328: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -496,9 +507,9 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_list_peers() != 29567: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_lnurl_pay() != 2068: + if lib.uniffi_glsdk_checksum_method_node_lnurl_pay() != 61306: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_lnurl_withdraw() != 36503: + if lib.uniffi_glsdk_checksum_method_node_lnurl_withdraw() != 61467: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_node_state() != 41833: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -508,24 +519,12 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_receive() != 39761: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_node_resolve_lnurl() != 5774: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_send() != 4348: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_stop() != 20186: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_node_stream_node_events() != 5933: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodebuilder_connect() != 47474: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodebuilder_recover() != 46087: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodebuilder_register() != 49580: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover() != 5543: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener() != 56760: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_nodeeventstream_next() != 12635: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_scheduler_recover() != 55514: @@ -546,7 +545,7 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_developercert_new() != 57793: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_constructor_nodebuilder_new() != 34740: + if lib.uniffi_glsdk_checksum_constructor_node_new() != 7003: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_constructor_scheduler_new() != 15239: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") @@ -556,8 +555,6 @@ def _uniffi_check_api_checksums(lib): raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") if lib.uniffi_glsdk_checksum_method_loglistener_on_log() != 34844: raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") - if lib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event() != 17790: - raise InternalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") # A ctypes library to expose the extern-C FFI definitions. # This is an implementation detail which will be called internally by the public API. @@ -667,19 +664,11 @@ class _UniffiForeignFutureStructVoid(ctypes.Structure): _UNIFFI_CALLBACK_INTERFACE_LOG_LISTENER_METHOD0 = ctypes.CFUNCTYPE(None,ctypes.c_uint64,_UniffiRustBuffer,ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), ) -_UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0 = ctypes.CFUNCTYPE(None,ctypes.c_uint64,_UniffiRustBuffer,ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) class _UniffiVTableCallbackInterfaceLogListener(ctypes.Structure): _fields_ = [ ("on_log", _UNIFFI_CALLBACK_INTERFACE_LOG_LISTENER_METHOD0), ("uniffi_free", _UNIFFI_CALLBACK_INTERFACE_FREE), ] -class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): - _fields_ = [ - ("on_event", _UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0), - ("uniffi_free", _UNIFFI_CALLBACK_INTERFACE_FREE), - ] _UniffiLib.uniffi_glsdk_fn_clone_config.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -772,6 +761,11 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_free_node.restype = None +_UniffiLib.uniffi_glsdk_fn_constructor_node_new.argtypes = ( + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_constructor_node_new.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_method_node_credentials.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -873,12 +867,6 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_receive.restype = _UniffiRustBuffer -_UniffiLib.uniffi_glsdk_fn_method_node_resolve_lnurl.argtypes = ( - ctypes.c_void_p, - _UniffiRustBuffer, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_node_resolve_lnurl.restype = _UniffiRustBuffer _UniffiLib.uniffi_glsdk_fn_method_node_send.argtypes = ( ctypes.c_void_p, _UniffiRustBuffer, @@ -896,54 +884,6 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_method_node_stream_node_events.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder.argtypes = ( - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_free_nodebuilder.argtypes = ( - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_free_nodebuilder.restype = None -_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new.argtypes = ( - ctypes.c_void_p, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect.argtypes = ( - ctypes.c_void_p, - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover.argtypes = ( - ctypes.c_void_p, - _UniffiRustBuffer, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register.argtypes = ( - ctypes.c_void_p, - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover.argtypes = ( - ctypes.c_void_p, - _UniffiRustBuffer, - _UniffiRustBuffer, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover.restype = ctypes.c_void_p -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener.argtypes = ( - ctypes.c_void_p, - ctypes.c_uint64, - ctypes.POINTER(_UniffiRustCallStatus), -) -_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_clone_nodeeventstream.argtypes = ( ctypes.c_void_p, ctypes.POINTER(_UniffiRustCallStatus), @@ -1033,15 +973,42 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): ctypes.POINTER(_UniffiVTableCallbackInterfaceLogListener), ) _UniffiLib.uniffi_glsdk_fn_init_callback_vtable_loglistener.restype = None -_UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener.argtypes = ( - ctypes.POINTER(_UniffiVTableCallbackInterfaceNodeEventListener), +_UniffiLib.uniffi_glsdk_fn_func_connect.argtypes = ( + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), ) -_UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener.restype = None +_UniffiLib.uniffi_glsdk_fn_func_connect.restype = ctypes.c_void_p _UniffiLib.uniffi_glsdk_fn_func_parse_input.argtypes = ( _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.uniffi_glsdk_fn_func_parse_input.restype = _UniffiRustBuffer +_UniffiLib.uniffi_glsdk_fn_func_recover.argtypes = ( + _UniffiRustBuffer, + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_func_recover.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_func_register.argtypes = ( + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_func_register.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_func_register_or_recover.argtypes = ( + _UniffiRustBuffer, + _UniffiRustBuffer, + ctypes.c_void_p, + ctypes.POINTER(_UniffiRustCallStatus), +) +_UniffiLib.uniffi_glsdk_fn_func_register_or_recover.restype = ctypes.c_void_p +_UniffiLib.uniffi_glsdk_fn_func_resolve_input.argtypes = ( + _UniffiRustBuffer, +) +_UniffiLib.uniffi_glsdk_fn_func_resolve_input.restype = ctypes.c_uint64 _UniffiLib.uniffi_glsdk_fn_func_set_log_level.argtypes = ( _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus), @@ -1321,9 +1288,24 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): ctypes.POINTER(_UniffiRustCallStatus), ) _UniffiLib.ffi_glsdk_rust_future_complete_void.restype = None +_UniffiLib.uniffi_glsdk_checksum_func_connect.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_connect.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_func_parse_input.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_parse_input.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_func_recover.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_recover.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_func_register.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_register.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_func_register_or_recover.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_register_or_recover.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_func_resolve_input.argtypes = ( +) +_UniffiLib.uniffi_glsdk_checksum_func_resolve_input.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_func_set_log_level.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_func_set_log_level.restype = ctypes.c_uint16 @@ -1393,9 +1375,6 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_receive.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_receive.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_node_resolve_lnurl.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_node_resolve_lnurl.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_node_send.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_send.restype = ctypes.c_uint16 @@ -1405,21 +1384,6 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_node_stream_node_events.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_node_stream_node_events.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_connect.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_connect.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_recover.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_recover.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_register_or_recover.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodebuilder_with_event_listener.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_method_nodeeventstream_next.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_nodeeventstream_next.restype = ctypes.c_uint16 @@ -1450,9 +1414,9 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_developercert_new.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_constructor_nodebuilder_new.argtypes = ( +_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.argtypes = ( ) -_UniffiLib.uniffi_glsdk_checksum_constructor_nodebuilder_new.restype = ctypes.c_uint16 +_UniffiLib.uniffi_glsdk_checksum_constructor_node_new.restype = ctypes.c_uint16 _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_constructor_scheduler_new.restype = ctypes.c_uint16 @@ -1465,9 +1429,6 @@ class _UniffiVTableCallbackInterfaceNodeEventListener(ctypes.Structure): _UniffiLib.uniffi_glsdk_checksum_method_loglistener_on_log.argtypes = ( ) _UniffiLib.uniffi_glsdk_checksum_method_loglistener_on_log.restype = ctypes.c_uint16 -_UniffiLib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event.argtypes = ( -) -_UniffiLib.uniffi_glsdk_checksum_method_nodeeventlistener_on_event.restype = ctypes.c_uint16 _UniffiLib.ffi_glsdk_uniffi_contract_version.argtypes = ( ) _UniffiLib.ffi_glsdk_uniffi_contract_version.restype = ctypes.c_uint32 @@ -1625,8 +1586,6 @@ def write(value, buf): - - class FundChannel: peer_id: "str" """ @@ -2449,7 +2408,7 @@ class LnUrlPayRequest: data: "LnUrlPayRequestData" """ - The resolved pay request data from `resolve_lnurl()`. + The resolved pay request data from `parse_input()`. """ amount_msat: "int" @@ -2524,7 +2483,8 @@ class LnUrlPayRequestData: Data from an LNURL-pay endpoint (LUD-06). Contains the service's accepted amount range and metadata. - Returned inside `ResolvedLnUrl::Pay` after resolving an LNURL. + Returned inside `InputType::LnUrlPay` after `parse_input` resolves + an LNURL or Lightning Address. """ callback: "str" @@ -2682,7 +2642,7 @@ class LnUrlWithdrawRequest: data: "LnUrlWithdrawRequestData" """ - The resolved withdraw request data from `resolve_lnurl()`. + The resolved withdraw request data from `parse_input()`. """ amount_msat: "int" @@ -2739,7 +2699,8 @@ class LnUrlWithdrawRequestData: Data from an LNURL-withdraw endpoint (LUD-03). Contains the service's accepted withdrawal range and session key. - Returned inside `ResolvedLnUrl::Withdraw` after resolving an LNURL. + Returned inside `InputType::LnUrlWithdraw` after `parse_input` + resolves an LNURL. """ callback: "str" @@ -4427,190 +4388,6 @@ def write(value, buf): -class InputType: - """ - The result of parsing user input. - """ - - def __init__(self): - raise RuntimeError("InputType cannot be instantiated directly") - - # Each enum variant is a nested class of the enum itself. - class BOLT11: - """ - A BOLT11 Lightning invoice. - """ - - invoice: "ParsedInvoice" - - def __init__(self,invoice: "ParsedInvoice"): - self.invoice = invoice - - def __str__(self): - return "InputType.BOLT11(invoice={})".format(self.invoice) - - def __eq__(self, other): - if not other.is_BOLT11(): - return False - if self.invoice != other.invoice: - return False - return True - - class NODE_ID: - """ - A Lightning node public key (66 hex characters, 33 bytes compressed). - """ - - node_id: "str" - - def __init__(self,node_id: "str"): - self.node_id = node_id - - def __str__(self): - return "InputType.NODE_ID(node_id={})".format(self.node_id) - - def __eq__(self, other): - if not other.is_NODE_ID(): - return False - if self.node_id != other.node_id: - return False - return True - - class LN_URL: - """ - An LNURL bech32 string (LUD-01). The `url` field contains the - decoded URL. Call `Node::resolve_lnurl()` to determine whether - this is a pay or withdraw endpoint. - """ - - url: "str" - - def __init__(self,url: "str"): - self.url = url - - def __str__(self): - return "InputType.LN_URL(url={})".format(self.url) - - def __eq__(self, other): - if not other.is_LN_URL(): - return False - if self.url != other.url: - return False - return True - - class LN_URL_ADDRESS: - """ - A Lightning Address (LUD-16), e.g. `user@domain.com`. - Call `Node::resolve_lnurl()` to resolve it to a pay request. - """ - - address: "str" - - def __init__(self,address: "str"): - self.address = address - - def __str__(self): - return "InputType.LN_URL_ADDRESS(address={})".format(self.address) - - def __eq__(self, other): - if not other.is_LN_URL_ADDRESS(): - return False - if self.address != other.address: - return False - return True - - - - # For each variant, we have `is_NAME` and `is_name` methods for easily checking - # whether an instance is that variant. - def is_BOLT11(self) -> bool: - return isinstance(self, InputType.BOLT11) - def is_bolt11(self) -> bool: - return isinstance(self, InputType.BOLT11) - def is_NODE_ID(self) -> bool: - return isinstance(self, InputType.NODE_ID) - def is_node_id(self) -> bool: - return isinstance(self, InputType.NODE_ID) - def is_LN_URL(self) -> bool: - return isinstance(self, InputType.LN_URL) - def is_ln_url(self) -> bool: - return isinstance(self, InputType.LN_URL) - def is_LN_URL_ADDRESS(self) -> bool: - return isinstance(self, InputType.LN_URL_ADDRESS) - def is_ln_url_address(self) -> bool: - return isinstance(self, InputType.LN_URL_ADDRESS) - - -# Now, a little trick - we make each nested variant class be a subclass of the main -# enum class, so that method calls and instance checks etc will work intuitively. -# We might be able to do this a little more neatly with a metaclass, but this'll do. -InputType.BOLT11 = type("InputType.BOLT11", (InputType.BOLT11, InputType,), {}) # type: ignore -InputType.NODE_ID = type("InputType.NODE_ID", (InputType.NODE_ID, InputType,), {}) # type: ignore -InputType.LN_URL = type("InputType.LN_URL", (InputType.LN_URL, InputType,), {}) # type: ignore -InputType.LN_URL_ADDRESS = type("InputType.LN_URL_ADDRESS", (InputType.LN_URL_ADDRESS, InputType,), {}) # type: ignore - - - - -class _UniffiConverterTypeInputType(_UniffiConverterRustBuffer): - @staticmethod - def read(buf): - variant = buf.read_i32() - if variant == 1: - return InputType.BOLT11( - _UniffiConverterTypeParsedInvoice.read(buf), - ) - if variant == 2: - return InputType.NODE_ID( - _UniffiConverterString.read(buf), - ) - if variant == 3: - return InputType.LN_URL( - _UniffiConverterString.read(buf), - ) - if variant == 4: - return InputType.LN_URL_ADDRESS( - _UniffiConverterString.read(buf), - ) - raise InternalError("Raw enum value doesn't match any cases") - - @staticmethod - def check_lower(value): - if value.is_BOLT11(): - _UniffiConverterTypeParsedInvoice.check_lower(value.invoice) - return - if value.is_NODE_ID(): - _UniffiConverterString.check_lower(value.node_id) - return - if value.is_LN_URL(): - _UniffiConverterString.check_lower(value.url) - return - if value.is_LN_URL_ADDRESS(): - _UniffiConverterString.check_lower(value.address) - return - raise ValueError(value) - - @staticmethod - def write(value, buf): - if value.is_BOLT11(): - buf.write_i32(1) - _UniffiConverterTypeParsedInvoice.write(value.invoice, buf) - if value.is_NODE_ID(): - buf.write_i32(2) - _UniffiConverterString.write(value.node_id, buf) - if value.is_LN_URL(): - buf.write_i32(3) - _UniffiConverterString.write(value.url, buf) - if value.is_LN_URL_ADDRESS(): - buf.write_i32(4) - _UniffiConverterString.write(value.address, buf) - - - - - - - class InvoiceStatus(enum.Enum): UNPAID = 0 @@ -5089,6 +4866,24 @@ def __eq__(self, other): return False return True + class UNKNOWN: + """ + An unknown event type was received. This can happen if the + server sends a new event type that this client doesn't know about. + """ + + + def __init__(self,): + pass + + def __str__(self): + return "NodeEvent.UNKNOWN()".format() + + def __eq__(self, other): + if not other.is_UNKNOWN(): + return False + return True + # For each variant, we have `is_NAME` and `is_name` methods for easily checking @@ -5097,12 +4892,17 @@ def is_INVOICE_PAID(self) -> bool: return isinstance(self, NodeEvent.INVOICE_PAID) def is_invoice_paid(self) -> bool: return isinstance(self, NodeEvent.INVOICE_PAID) + def is_UNKNOWN(self) -> bool: + return isinstance(self, NodeEvent.UNKNOWN) + def is_unknown(self) -> bool: + return isinstance(self, NodeEvent.UNKNOWN) # Now, a little trick - we make each nested variant class be a subclass of the main # enum class, so that method calls and instance checks etc will work intuitively. # We might be able to do this a little more neatly with a metaclass, but this'll do. NodeEvent.INVOICE_PAID = type("NodeEvent.INVOICE_PAID", (NodeEvent.INVOICE_PAID, NodeEvent,), {}) # type: ignore +NodeEvent.UNKNOWN = type("NodeEvent.UNKNOWN", (NodeEvent.UNKNOWN, NodeEvent,), {}) # type: ignore @@ -5115,6 +4915,9 @@ def read(buf): return NodeEvent.INVOICE_PAID( _UniffiConverterTypeInvoicePaidEvent.read(buf), ) + if variant == 2: + return NodeEvent.UNKNOWN( + ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod @@ -5122,6 +4925,8 @@ def check_lower(value): if value.is_INVOICE_PAID(): _UniffiConverterTypeInvoicePaidEvent.check_lower(value.details) return + if value.is_UNKNOWN(): + return raise ValueError(value) @staticmethod @@ -5129,6 +4934,8 @@ def write(value, buf): if value.is_INVOICE_PAID(): buf.write_i32(1) _UniffiConverterTypeInvoicePaidEvent.write(value.details, buf) + if value.is_UNKNOWN(): + buf.write_i32(2) @@ -5190,6 +4997,193 @@ def write(value, buf): +class ParsedInput: + """ + The result of `parse_input`: an offline classification of the + input. No HTTP, no I/O. LNURL bech32 strings are returned as their + decoded URL; Lightning Addresses as the unparsed `user@host` form. + """ + + def __init__(self): + raise RuntimeError("ParsedInput cannot be instantiated directly") + + # Each enum variant is a nested class of the enum itself. + class BOLT11: + """ + A BOLT11 Lightning invoice. + """ + + invoice: "ParsedInvoice" + + def __init__(self,invoice: "ParsedInvoice"): + self.invoice = invoice + + def __str__(self): + return "ParsedInput.BOLT11(invoice={})".format(self.invoice) + + def __eq__(self, other): + if not other.is_BOLT11(): + return False + if self.invoice != other.invoice: + return False + return True + + class NODE_ID: + """ + A Lightning node public key. + """ + + node_id: "str" + + def __init__(self,node_id: "str"): + self.node_id = node_id + + def __str__(self): + return "ParsedInput.NODE_ID(node_id={})".format(self.node_id) + + def __eq__(self, other): + if not other.is_NODE_ID(): + return False + if self.node_id != other.node_id: + return False + return True + + class LN_URL: + """ + An LNURL bech32 string (LUD-01) decoded to its underlying URL. + Pass to `resolve_input` (or fetch yourself) to determine + whether it's a pay, withdraw, or auth endpoint. + """ + + url: "str" + + def __init__(self,url: "str"): + self.url = url + + def __str__(self): + return "ParsedInput.LN_URL(url={})".format(self.url) + + def __eq__(self, other): + if not other.is_LN_URL(): + return False + if self.url != other.url: + return False + return True + + class LN_URL_ADDRESS: + """ + A Lightning Address (LUD-16) in the form `user@host`. The + well-known URL is not constructed offline; call `resolve_input` + to fetch and classify. + """ + + address: "str" + + def __init__(self,address: "str"): + self.address = address + + def __str__(self): + return "ParsedInput.LN_URL_ADDRESS(address={})".format(self.address) + + def __eq__(self, other): + if not other.is_LN_URL_ADDRESS(): + return False + if self.address != other.address: + return False + return True + + + + # For each variant, we have `is_NAME` and `is_name` methods for easily checking + # whether an instance is that variant. + def is_BOLT11(self) -> bool: + return isinstance(self, ParsedInput.BOLT11) + def is_bolt11(self) -> bool: + return isinstance(self, ParsedInput.BOLT11) + def is_NODE_ID(self) -> bool: + return isinstance(self, ParsedInput.NODE_ID) + def is_node_id(self) -> bool: + return isinstance(self, ParsedInput.NODE_ID) + def is_LN_URL(self) -> bool: + return isinstance(self, ParsedInput.LN_URL) + def is_ln_url(self) -> bool: + return isinstance(self, ParsedInput.LN_URL) + def is_LN_URL_ADDRESS(self) -> bool: + return isinstance(self, ParsedInput.LN_URL_ADDRESS) + def is_ln_url_address(self) -> bool: + return isinstance(self, ParsedInput.LN_URL_ADDRESS) + + +# Now, a little trick - we make each nested variant class be a subclass of the main +# enum class, so that method calls and instance checks etc will work intuitively. +# We might be able to do this a little more neatly with a metaclass, but this'll do. +ParsedInput.BOLT11 = type("ParsedInput.BOLT11", (ParsedInput.BOLT11, ParsedInput,), {}) # type: ignore +ParsedInput.NODE_ID = type("ParsedInput.NODE_ID", (ParsedInput.NODE_ID, ParsedInput,), {}) # type: ignore +ParsedInput.LN_URL = type("ParsedInput.LN_URL", (ParsedInput.LN_URL, ParsedInput,), {}) # type: ignore +ParsedInput.LN_URL_ADDRESS = type("ParsedInput.LN_URL_ADDRESS", (ParsedInput.LN_URL_ADDRESS, ParsedInput,), {}) # type: ignore + + + + +class _UniffiConverterTypeParsedInput(_UniffiConverterRustBuffer): + @staticmethod + def read(buf): + variant = buf.read_i32() + if variant == 1: + return ParsedInput.BOLT11( + _UniffiConverterTypeParsedInvoice.read(buf), + ) + if variant == 2: + return ParsedInput.NODE_ID( + _UniffiConverterString.read(buf), + ) + if variant == 3: + return ParsedInput.LN_URL( + _UniffiConverterString.read(buf), + ) + if variant == 4: + return ParsedInput.LN_URL_ADDRESS( + _UniffiConverterString.read(buf), + ) + raise InternalError("Raw enum value doesn't match any cases") + + @staticmethod + def check_lower(value): + if value.is_BOLT11(): + _UniffiConverterTypeParsedInvoice.check_lower(value.invoice) + return + if value.is_NODE_ID(): + _UniffiConverterString.check_lower(value.node_id) + return + if value.is_LN_URL(): + _UniffiConverterString.check_lower(value.url) + return + if value.is_LN_URL_ADDRESS(): + _UniffiConverterString.check_lower(value.address) + return + raise ValueError(value) + + @staticmethod + def write(value, buf): + if value.is_BOLT11(): + buf.write_i32(1) + _UniffiConverterTypeParsedInvoice.write(value.invoice, buf) + if value.is_NODE_ID(): + buf.write_i32(2) + _UniffiConverterString.write(value.node_id, buf) + if value.is_LN_URL(): + buf.write_i32(3) + _UniffiConverterString.write(value.url, buf) + if value.is_LN_URL_ADDRESS(): + buf.write_i32(4) + _UniffiConverterString.write(value.address, buf) + + + + + + + class PayStatus(enum.Enum): COMPLETE = 0 @@ -5358,18 +5352,61 @@ def write(value, buf): -class ResolvedLnUrl: +class ResolvedInput: """ - Result of resolving an LNURL or lightning address via HTTP. + The result of `resolve_input`: a fully-resolved input ready for + the caller's next action. LNURL bech32 strings and Lightning + Addresses are resolved over HTTP into typed pay or withdraw + request data; BOLT11 and node IDs pass through unchanged. """ def __init__(self): - raise RuntimeError("ResolvedLnUrl cannot be instantiated directly") + raise RuntimeError("ResolvedInput cannot be instantiated directly") # Each enum variant is a nested class of the enum itself. - class PAY: + class BOLT11: + """ + A BOLT11 Lightning invoice. No HTTP was performed. + """ + + invoice: "ParsedInvoice" + + def __init__(self,invoice: "ParsedInvoice"): + self.invoice = invoice + + def __str__(self): + return "ResolvedInput.BOLT11(invoice={})".format(self.invoice) + + def __eq__(self, other): + if not other.is_BOLT11(): + return False + if self.invoice != other.invoice: + return False + return True + + class NODE_ID: """ - The endpoint is an LNURL-pay service (LUD-06). + A Lightning node public key. No HTTP was performed. + """ + + node_id: "str" + + def __init__(self,node_id: "str"): + self.node_id = node_id + + def __str__(self): + return "ResolvedInput.NODE_ID(node_id={})".format(self.node_id) + + def __eq__(self, other): + if not other.is_NODE_ID(): + return False + if self.node_id != other.node_id: + return False + return True + + class LN_URL_PAY: + """ + An LNURL-pay endpoint with the service's parameters fetched. """ data: "LnUrlPayRequestData" @@ -5378,18 +5415,18 @@ def __init__(self,data: "LnUrlPayRequestData"): self.data = data def __str__(self): - return "ResolvedLnUrl.PAY(data={})".format(self.data) + return "ResolvedInput.LN_URL_PAY(data={})".format(self.data) def __eq__(self, other): - if not other.is_PAY(): + if not other.is_LN_URL_PAY(): return False if self.data != other.data: return False return True - class WITHDRAW: + class LN_URL_WITHDRAW: """ - The endpoint is an LNURL-withdraw service (LUD-03). + An LNURL-withdraw endpoint with the service's parameters fetched. """ data: "LnUrlWithdrawRequestData" @@ -5398,10 +5435,10 @@ def __init__(self,data: "LnUrlWithdrawRequestData"): self.data = data def __str__(self): - return "ResolvedLnUrl.WITHDRAW(data={})".format(self.data) + return "ResolvedInput.LN_URL_WITHDRAW(data={})".format(self.data) def __eq__(self, other): - if not other.is_WITHDRAW(): + if not other.is_LN_URL_WITHDRAW(): return False if self.data != other.data: return False @@ -5411,56 +5448,86 @@ def __eq__(self, other): # For each variant, we have `is_NAME` and `is_name` methods for easily checking # whether an instance is that variant. - def is_PAY(self) -> bool: - return isinstance(self, ResolvedLnUrl.PAY) - def is_pay(self) -> bool: - return isinstance(self, ResolvedLnUrl.PAY) - def is_WITHDRAW(self) -> bool: - return isinstance(self, ResolvedLnUrl.WITHDRAW) - def is_withdraw(self) -> bool: - return isinstance(self, ResolvedLnUrl.WITHDRAW) + def is_BOLT11(self) -> bool: + return isinstance(self, ResolvedInput.BOLT11) + def is_bolt11(self) -> bool: + return isinstance(self, ResolvedInput.BOLT11) + def is_NODE_ID(self) -> bool: + return isinstance(self, ResolvedInput.NODE_ID) + def is_node_id(self) -> bool: + return isinstance(self, ResolvedInput.NODE_ID) + def is_LN_URL_PAY(self) -> bool: + return isinstance(self, ResolvedInput.LN_URL_PAY) + def is_ln_url_pay(self) -> bool: + return isinstance(self, ResolvedInput.LN_URL_PAY) + def is_LN_URL_WITHDRAW(self) -> bool: + return isinstance(self, ResolvedInput.LN_URL_WITHDRAW) + def is_ln_url_withdraw(self) -> bool: + return isinstance(self, ResolvedInput.LN_URL_WITHDRAW) # Now, a little trick - we make each nested variant class be a subclass of the main # enum class, so that method calls and instance checks etc will work intuitively. # We might be able to do this a little more neatly with a metaclass, but this'll do. -ResolvedLnUrl.PAY = type("ResolvedLnUrl.PAY", (ResolvedLnUrl.PAY, ResolvedLnUrl,), {}) # type: ignore -ResolvedLnUrl.WITHDRAW = type("ResolvedLnUrl.WITHDRAW", (ResolvedLnUrl.WITHDRAW, ResolvedLnUrl,), {}) # type: ignore +ResolvedInput.BOLT11 = type("ResolvedInput.BOLT11", (ResolvedInput.BOLT11, ResolvedInput,), {}) # type: ignore +ResolvedInput.NODE_ID = type("ResolvedInput.NODE_ID", (ResolvedInput.NODE_ID, ResolvedInput,), {}) # type: ignore +ResolvedInput.LN_URL_PAY = type("ResolvedInput.LN_URL_PAY", (ResolvedInput.LN_URL_PAY, ResolvedInput,), {}) # type: ignore +ResolvedInput.LN_URL_WITHDRAW = type("ResolvedInput.LN_URL_WITHDRAW", (ResolvedInput.LN_URL_WITHDRAW, ResolvedInput,), {}) # type: ignore -class _UniffiConverterTypeResolvedLnUrl(_UniffiConverterRustBuffer): +class _UniffiConverterTypeResolvedInput(_UniffiConverterRustBuffer): @staticmethod def read(buf): variant = buf.read_i32() if variant == 1: - return ResolvedLnUrl.PAY( - _UniffiConverterTypeLnUrlPayRequestData.read(buf), + return ResolvedInput.BOLT11( + _UniffiConverterTypeParsedInvoice.read(buf), ) if variant == 2: - return ResolvedLnUrl.WITHDRAW( + return ResolvedInput.NODE_ID( + _UniffiConverterString.read(buf), + ) + if variant == 3: + return ResolvedInput.LN_URL_PAY( + _UniffiConverterTypeLnUrlPayRequestData.read(buf), + ) + if variant == 4: + return ResolvedInput.LN_URL_WITHDRAW( _UniffiConverterTypeLnUrlWithdrawRequestData.read(buf), ) raise InternalError("Raw enum value doesn't match any cases") @staticmethod def check_lower(value): - if value.is_PAY(): + if value.is_BOLT11(): + _UniffiConverterTypeParsedInvoice.check_lower(value.invoice) + return + if value.is_NODE_ID(): + _UniffiConverterString.check_lower(value.node_id) + return + if value.is_LN_URL_PAY(): _UniffiConverterTypeLnUrlPayRequestData.check_lower(value.data) return - if value.is_WITHDRAW(): + if value.is_LN_URL_WITHDRAW(): _UniffiConverterTypeLnUrlWithdrawRequestData.check_lower(value.data) return raise ValueError(value) @staticmethod def write(value, buf): - if value.is_PAY(): + if value.is_BOLT11(): buf.write_i32(1) - _UniffiConverterTypeLnUrlPayRequestData.write(value.data, buf) - if value.is_WITHDRAW(): + _UniffiConverterTypeParsedInvoice.write(value.invoice, buf) + if value.is_NODE_ID(): buf.write_i32(2) + _UniffiConverterString.write(value.node_id, buf) + if value.is_LN_URL_PAY(): + buf.write_i32(3) + _UniffiConverterTypeLnUrlPayRequestData.write(value.data, buf) + if value.is_LN_URL_WITHDRAW(): + buf.write_i32(4) _UniffiConverterTypeLnUrlWithdrawRequestData.write(value.data, buf) @@ -5688,68 +5755,6 @@ def _uniffi_free(uniffi_handle): - -class NodeEventListener(typing.Protocol): - """ - Callback interface for receiving node events. - - `on_event` is invoked from the SDK's internal event-dispatch task. - Implementations should be cheap and non-blocking; to update UI, - dispatch to the main thread from inside the handler. - - Installed via `NodeBuilder::with_event_listener(...)` so events - emitted during node bring-up are captured. The polling-style - `Node::stream_node_events()` API is still available for callers - that prefer to drive events themselves. - """ - - def on_event(self, event: "NodeEvent"): - raise NotImplementedError - - -# Put all the bits inside a class to keep the top-level namespace clean -class _UniffiTraitImplNodeEventListener: - # For each method, generate a callback function to pass to Rust - - @_UNIFFI_CALLBACK_INTERFACE_NODE_EVENT_LISTENER_METHOD0 - def on_event( - uniffi_handle, - event, - uniffi_out_return, - uniffi_call_status_ptr, - ): - uniffi_obj = _UniffiConverterTypeNodeEventListener._handle_map.get(uniffi_handle) - def make_call(): - args = (_UniffiConverterTypeNodeEvent.lift(event), ) - method = uniffi_obj.on_event - return method(*args) - - - write_return_value = lambda v: None - _uniffi_trait_interface_call( - uniffi_call_status_ptr.contents, - make_call, - write_return_value, - ) - - @_UNIFFI_CALLBACK_INTERFACE_FREE - def _uniffi_free(uniffi_handle): - _UniffiConverterTypeNodeEventListener._handle_map.remove(uniffi_handle) - - # Generate the FFI VTable. This has a field for each callback interface method. - _uniffi_vtable = _UniffiVTableCallbackInterfaceNodeEventListener( - on_event, - _uniffi_free - ) - # Send Rust a pointer to the VTable. Note: this means we need to keep the struct alive forever, - # or else bad things will happen when Rust tries to access it. - _UniffiLib.uniffi_glsdk_fn_init_callback_vtable_nodeeventlistener(ctypes.byref(_uniffi_vtable)) - -# The _UniffiConverter which transforms the Callbacks in to Handles to pass to Rust. -_UniffiConverterTypeNodeEventListener = _UniffiCallbackInterfaceFfiConverter() - - - class _UniffiConverterOptionalUInt32(_UniffiConverterRustBuffer): @classmethod def check_lower(cls, value): @@ -6747,8 +6752,9 @@ def lnurl_pay(self, request: "LnUrlPayRequest"): callback, receives and validates a BOLT11 invoice, pays it, and processes any success action (LUD-09/10). - Call `resolve_lnurl()` first to get the `LnUrlPayRequestData`, - then build an `LnUrlPayRequest` with the user's chosen amount. + Call the top-level `parse_input` first to obtain the + `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + user's chosen amount. """ raise NotImplementedError @@ -6760,8 +6766,9 @@ def lnurl_withdraw(self, request: "LnUrlWithdrawRequest"): it to the service's callback URL, and the service pays it asynchronously. - Call `resolve_lnurl()` first to get the `LnUrlWithdrawRequestData`, - then build an `LnUrlWithdrawRequest` with the user's chosen amount. + Call the top-level `parse_input` first to obtain the + `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + with the user's chosen amount. """ raise NotImplementedError @@ -6814,19 +6821,6 @@ def receive(self, label: "str",description: "str",amount_msat: "typing.Optional[ to be created, and the incoming payment to be forwarded. """ - raise NotImplementedError - def resolve_lnurl(self, input: "str"): - """ - Resolve an LNURL or Lightning Address to its endpoint data. - - Performs the HTTP GET to the LNURL endpoint and returns the - typed request data. The result tells you whether this is a - pay or withdraw request, and includes the service's parameters. - - Accepts an LNURL bech32 string, a decoded URL (from - `parse_input()`), or a Lightning Address (`user@domain`). - """ - raise NotImplementedError def send(self, invoice: "str",amount_msat: "typing.Optional[int]"): raise NotImplementedError @@ -6858,9 +6852,11 @@ class Node(): """ _pointer: ctypes.c_void_p - - def __init__(self, *args, **kwargs): - raise ValueError("This class has no default constructor") + def __init__(self, credentials: "Credentials"): + _UniffiConverterTypeCredentials.check_lower(credentials) + + self._pointer = _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_constructor_node_new, + _UniffiConverterTypeCredentials.lower(credentials)) def __del__(self): # In case of partial initialization of instances. @@ -7091,8 +7087,9 @@ def lnurl_pay(self, request: "LnUrlPayRequest") -> "LnUrlPayResult": callback, receives and validates a BOLT11 invoice, pays it, and processes any success action (LUD-09/10). - Call `resolve_lnurl()` first to get the `LnUrlPayRequestData`, - then build an `LnUrlPayRequest` with the user's chosen amount. + Call the top-level `parse_input` first to obtain the + `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + user's chosen amount. """ _UniffiConverterTypeLnUrlPayRequest.check_lower(request) @@ -7114,8 +7111,9 @@ def lnurl_withdraw(self, request: "LnUrlWithdrawRequest") -> "LnUrlWithdrawResul it to the service's callback URL, and the service pays it asynchronously. - Call `resolve_lnurl()` first to get the `LnUrlWithdrawRequestData`, - then build an `LnUrlWithdrawRequest` with the user's chosen amount. + Call the top-level `parse_input` first to obtain the + `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + with the user's chosen amount. """ _UniffiConverterTypeLnUrlWithdrawRequest.check_lower(request) @@ -7222,29 +7220,6 @@ def receive(self, label: "str",description: "str",amount_msat: "typing.Optional[ - def resolve_lnurl(self, input: "str") -> "ResolvedLnUrl": - """ - Resolve an LNURL or Lightning Address to its endpoint data. - - Performs the HTTP GET to the LNURL endpoint and returns the - typed request data. The result tells you whether this is a - pay or withdraw request, and includes the service's parameters. - - Accepts an LNURL bech32 string, a decoded URL (from - `parse_input()`), or a Lightning Address (`user@domain`). - """ - - _UniffiConverterString.check_lower(input) - - return _UniffiConverterTypeResolvedLnUrl.lift( - _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_node_resolve_lnurl,self._uniffi_clone_pointer(), - _UniffiConverterString.lower(input)) - ) - - - - - def send(self, invoice: "str",amount_msat: "typing.Optional[int]") -> "SendResponse": _UniffiConverterString.check_lower(invoice) @@ -7321,246 +7296,6 @@ def read(cls, buf: _UniffiRustBuffer): @classmethod def write(cls, value: NodeProtocol, buf: _UniffiRustBuffer): buf.write_u64(cls.lower(value)) -class NodeBuilderProtocol(typing.Protocol): - """ - Configurable Node construction. See module docs. - """ - - def connect(self, credentials: "bytes",mnemonic: "typing.Optional[str]"): - """ - Connect to an existing node using saved credentials and return - a connected Node with any configured modifiers applied. - - If `mnemonic` is `Some(...)`, the SDK spawns a signer for the - connected Node. If `None`, the Node is signerless and signing - happens elsewhere (paired device, CLN node's local signer, - hardware signer). - """ - - raise NotImplementedError - def recover(self, mnemonic: "str"): - """ - Recover credentials for an existing node and return a - connected Node with any configured modifiers applied. - - `mnemonic` is required — recovery drives the signer to - authenticate. - """ - - raise NotImplementedError - def register(self, mnemonic: "str",invite_code: "typing.Optional[str]"): - """ - Register a new Greenlight node and return a connected Node - with the SDK signer running and any configured modifiers - applied. - - `mnemonic` is required — registration drives the signer to - sign the registration challenge, so the SDK must hold the - seed for this call. - """ - - raise NotImplementedError - def register_or_recover(self, mnemonic: "str",invite_code: "typing.Optional[str]"): - """ - Try to recover; if the node doesn't exist, register a new one. - - `mnemonic` is required — both recover and register drive the - signer. - """ - - raise NotImplementedError - def with_event_listener(self, listener: "NodeEventListener"): - """ - Install a node event listener. Events fire from the moment the - gRPC stream is established by the build call (`register` / - `recover` / `connect` / …), so attach the listener via the - builder rather than after the fact to capture events from the - very first moment. - - Returns the same builder for fluent chaining. - """ - - raise NotImplementedError -# NodeBuilder is a Rust-only trait - it's a wrapper around a Rust implementation. -class NodeBuilder(): - """ - Configurable Node construction. See module docs. - """ - - _pointer: ctypes.c_void_p - def __init__(self, config: "Config"): - """ - Create a builder for a Node with `config`. No I/O happens - until you call `connect` / `register` / `recover` / - `register_or_recover`. - """ - - _UniffiConverterTypeConfig.check_lower(config) - - self._pointer = _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_constructor_nodebuilder_new, - _UniffiConverterTypeConfig.lower(config)) - - def __del__(self): - # In case of partial initialization of instances. - pointer = getattr(self, "_pointer", None) - if pointer is not None: - _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_free_nodebuilder, pointer) - - def _uniffi_clone_pointer(self): - return _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_clone_nodebuilder, self._pointer) - - # Used by alternative constructors or any methods which return this type. - @classmethod - def _make_instance_(cls, pointer): - # Lightly yucky way to bypass the usual __init__ logic - # and just create a new instance with the required pointer. - inst = cls.__new__(cls) - inst._pointer = pointer - return inst - - - def connect(self, credentials: "bytes",mnemonic: "typing.Optional[str]") -> "Node": - """ - Connect to an existing node using saved credentials and return - a connected Node with any configured modifiers applied. - - If `mnemonic` is `Some(...)`, the SDK spawns a signer for the - connected Node. If `None`, the Node is signerless and signing - happens elsewhere (paired device, CLN node's local signer, - hardware signer). - """ - - _UniffiConverterBytes.check_lower(credentials) - - _UniffiConverterOptionalString.check_lower(mnemonic) - - return _UniffiConverterTypeNode.lift( - _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_connect,self._uniffi_clone_pointer(), - _UniffiConverterBytes.lower(credentials), - _UniffiConverterOptionalString.lower(mnemonic)) - ) - - - - - - def recover(self, mnemonic: "str") -> "Node": - """ - Recover credentials for an existing node and return a - connected Node with any configured modifiers applied. - - `mnemonic` is required — recovery drives the signer to - authenticate. - """ - - _UniffiConverterString.check_lower(mnemonic) - - return _UniffiConverterTypeNode.lift( - _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_recover,self._uniffi_clone_pointer(), - _UniffiConverterString.lower(mnemonic)) - ) - - - - - - def register(self, mnemonic: "str",invite_code: "typing.Optional[str]") -> "Node": - """ - Register a new Greenlight node and return a connected Node - with the SDK signer running and any configured modifiers - applied. - - `mnemonic` is required — registration drives the signer to - sign the registration challenge, so the SDK must hold the - seed for this call. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterOptionalString.check_lower(invite_code) - - return _UniffiConverterTypeNode.lift( - _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register,self._uniffi_clone_pointer(), - _UniffiConverterString.lower(mnemonic), - _UniffiConverterOptionalString.lower(invite_code)) - ) - - - - - - def register_or_recover(self, mnemonic: "str",invite_code: "typing.Optional[str]") -> "Node": - """ - Try to recover; if the node doesn't exist, register a new one. - - `mnemonic` is required — both recover and register drive the - signer. - """ - - _UniffiConverterString.check_lower(mnemonic) - - _UniffiConverterOptionalString.check_lower(invite_code) - - return _UniffiConverterTypeNode.lift( - _uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_register_or_recover,self._uniffi_clone_pointer(), - _UniffiConverterString.lower(mnemonic), - _UniffiConverterOptionalString.lower(invite_code)) - ) - - - - - - def with_event_listener(self, listener: "NodeEventListener") -> "NodeBuilder": - """ - Install a node event listener. Events fire from the moment the - gRPC stream is established by the build call (`register` / - `recover` / `connect` / …), so attach the listener via the - builder rather than after the fact to capture events from the - very first moment. - - Returns the same builder for fluent chaining. - """ - - _UniffiConverterTypeNodeEventListener.check_lower(listener) - - return _UniffiConverterTypeNodeBuilder.lift( - _uniffi_rust_call(_UniffiLib.uniffi_glsdk_fn_method_nodebuilder_with_event_listener,self._uniffi_clone_pointer(), - _UniffiConverterTypeNodeEventListener.lower(listener)) - ) - - - - - - -class _UniffiConverterTypeNodeBuilder: - - @staticmethod - def lift(value: int): - return NodeBuilder._make_instance_(value) - - @staticmethod - def check_lower(value: NodeBuilder): - if not isinstance(value, NodeBuilder): - raise TypeError("Expected NodeBuilder instance, {} found".format(type(value).__name__)) - - @staticmethod - def lower(value: NodeBuilderProtocol): - if not isinstance(value, NodeBuilder): - raise TypeError("Expected NodeBuilder instance, {} found".format(type(value).__name__)) - return value._uniffi_clone_pointer() - - @classmethod - def read(cls, buf: _UniffiRustBuffer): - ptr = buf.read_u64() - if ptr == 0: - raise InternalError("Raw pointer value was null") - return cls.lift(ptr) - - @classmethod - def write(cls, value: NodeBuilderProtocol, buf: _UniffiRustBuffer): - buf.write_u64(cls.lower(value)) class NodeEventStreamProtocol(typing.Protocol): """ A stream of node events. Call `next()` to receive the next event. @@ -7889,22 +7624,192 @@ def read(cls, buf: _UniffiRustBuffer): def write(cls, value: SignerProtocol, buf: _UniffiRustBuffer): buf.write_u64(cls.lower(value)) -# Async support +# Async support# RustFuturePoll values +_UNIFFI_RUST_FUTURE_POLL_READY = 0 +_UNIFFI_RUST_FUTURE_POLL_MAYBE_READY = 1 + +# Stores futures for _uniffi_continuation_callback +_UniffiContinuationHandleMap = _UniffiHandleMap() -def parse_input(input: "str") -> "InputType": +_UNIFFI_GLOBAL_EVENT_LOOP = None + +""" +Set the event loop to use for async functions + +This is needed if some async functions run outside of the eventloop, for example: + - A non-eventloop thread is spawned, maybe from `EventLoop.run_in_executor` or maybe from the + Rust code spawning its own thread. + - The Rust code calls an async callback method from a sync callback function, using something + like `pollster` to block on the async call. + +In this case, we need an event loop to run the Python async function, but there's no eventloop set +for the thread. Use `uniffi_set_event_loop` to force an eventloop to be used in this case. +""" +def uniffi_set_event_loop(eventloop: asyncio.BaseEventLoop): + global _UNIFFI_GLOBAL_EVENT_LOOP + _UNIFFI_GLOBAL_EVENT_LOOP = eventloop + +def _uniffi_get_event_loop(): + if _UNIFFI_GLOBAL_EVENT_LOOP is not None: + return _UNIFFI_GLOBAL_EVENT_LOOP + else: + return asyncio.get_running_loop() + +# Continuation callback for async functions +# lift the return value or error and resolve the future, causing the async function to resume. +@_UNIFFI_RUST_FUTURE_CONTINUATION_CALLBACK +def _uniffi_continuation_callback(future_ptr, poll_code): + (eventloop, future) = _UniffiContinuationHandleMap.remove(future_ptr) + eventloop.call_soon_threadsafe(_uniffi_set_future_result, future, poll_code) + +def _uniffi_set_future_result(future, poll_code): + if not future.cancelled(): + future.set_result(poll_code) + +async def _uniffi_rust_call_async(rust_future, ffi_poll, ffi_complete, ffi_free, lift_func, error_ffi_converter): + try: + eventloop = _uniffi_get_event_loop() + + # Loop and poll until we see a _UNIFFI_RUST_FUTURE_POLL_READY value + while True: + future = eventloop.create_future() + ffi_poll( + rust_future, + _uniffi_continuation_callback, + _UniffiContinuationHandleMap.insert((eventloop, future)), + ) + poll_code = await future + if poll_code == _UNIFFI_RUST_FUTURE_POLL_READY: + break + + return lift_func( + _uniffi_rust_call_with_error(error_ffi_converter, ffi_complete, rust_future) + ) + finally: + ffi_free(rust_future) + +def connect(mnemonic: "str",credentials: "bytes",config: "Config") -> "Node": + """ + Connect to an existing Greenlight node using previously saved credentials. """ - Parse a string and identify whether it's a BOLT11 invoice or a node ID. - Strips `lightning:` / `LIGHTNING:` prefixes automatically. - Works offline — no node connection needed. + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterBytes.check_lower(credentials) + + _UniffiConverterTypeConfig.check_lower(config) + + return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_connect, + _UniffiConverterString.lower(mnemonic), + _UniffiConverterBytes.lower(credentials), + _UniffiConverterTypeConfig.lower(config))) + + +def parse_input(input: "str") -> "ParsedInput": + """ + Synchronously classify the input. **No HTTP, no I/O.** + + Recognises BOLT11 invoices, node IDs, LNURL bech32 strings, and + Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes + automatically. LNURL inputs are decoded to their underlying URL + but **not fetched** — the caller chooses whether to resolve + further (via `resolve_input`) or to surface the URL to the user + as-is. + + Use this for offline operations like clipboard validation or + invoice sanity checks. Use `resolve_input` for the QR-scan flow + where you want the resolved pay/withdraw data in one call. """ _UniffiConverterString.check_lower(input) - return _UniffiConverterTypeInputType.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_parse_input, + return _UniffiConverterTypeParsedInput.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_parse_input, _UniffiConverterString.lower(input))) +def recover(mnemonic: "str",config: "Config") -> "Node": + """ + Recover credentials for an existing Greenlight node and return a connected Node. + + The app should call `node.credentials()` to get the credential bytes + and persist them for future `connect()` calls. + """ + + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterTypeConfig.check_lower(config) + + return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_recover, + _UniffiConverterString.lower(mnemonic), + _UniffiConverterTypeConfig.lower(config))) + + +def register(mnemonic: "str",invite_code: "typing.Optional[str]",config: "Config") -> "Node": + """ + Register a new Greenlight node and return a connected Node with signer running. + + The app should call `node.credentials()` to get the credential bytes + and persist them for future `connect()` calls. + """ + + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterOptionalString.check_lower(invite_code) + + _UniffiConverterTypeConfig.check_lower(config) + + return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_register, + _UniffiConverterString.lower(mnemonic), + _UniffiConverterOptionalString.lower(invite_code), + _UniffiConverterTypeConfig.lower(config))) + + +def register_or_recover(mnemonic: "str",invite_code: "typing.Optional[str]",config: "Config") -> "Node": + """ + Try to recover an existing node; if none exists, register a new one. + """ + + _UniffiConverterString.check_lower(mnemonic) + + _UniffiConverterOptionalString.check_lower(invite_code) + + _UniffiConverterTypeConfig.check_lower(config) + + return _UniffiConverterTypeNode.lift(_uniffi_rust_call_with_error(_UniffiConverterTypeError,_UniffiLib.uniffi_glsdk_fn_func_register_or_recover, + _UniffiConverterString.lower(mnemonic), + _UniffiConverterOptionalString.lower(invite_code), + _UniffiConverterTypeConfig.lower(config))) + +async def resolve_input(input: "str") -> "ResolvedInput": + + """ + Asynchronously classify and resolve the input. + + Internally calls `parse_input` for offline classification, then + for LNURL bech32 strings and Lightning Addresses performs the + HTTP GET to the LNURL endpoint and returns typed pay or withdraw + request data. For BOLT11 invoices and node IDs it returns + immediately without I/O. + + Strips `lightning:` / `LIGHTNING:` prefixes automatically. + """ + + _UniffiConverterString.check_lower(input) + + return await _uniffi_rust_call_async( + _UniffiLib.uniffi_glsdk_fn_func_resolve_input( + _UniffiConverterString.lower(input)), + _UniffiLib.ffi_glsdk_rust_future_poll_rust_buffer, + _UniffiLib.ffi_glsdk_rust_future_complete_rust_buffer, + _UniffiLib.ffi_glsdk_rust_future_free_rust_buffer, + # lift function + _UniffiConverterTypeResolvedInput.lift, + + # Error FFI converter +_UniffiConverterTypeError, + + ) + def set_log_level(level: "LogLevel") -> None: """ Change the log filter at runtime without reinstalling the listener. @@ -7941,7 +7846,6 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "ChannelSide", "ChannelState", "Error", - "InputType", "InvoiceStatus", "ListIndex", "LnUrlPayResult", @@ -7950,11 +7854,12 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "Network", "NodeEvent", "OutputStatus", + "ParsedInput", "PayStatus", "PaymentStatus", "PaymentType", "PaymentTypeFilter", - "ResolvedLnUrl", + "ResolvedInput", "SuccessActionProcessed", "FundChannel", "FundOutput", @@ -7986,7 +7891,12 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "PeerChannel", "ReceiveResponse", "SendResponse", + "connect", "parse_input", + "recover", + "register", + "register_or_recover", + "resolve_input", "set_log_level", "set_logger", "Config", @@ -7994,11 +7904,9 @@ def set_logger(level: "LogLevel",listener: "LogListener") -> None: "DeveloperCert", "Handle", "Node", - "NodeBuilder", "NodeEventStream", "Scheduler", "Signer", "LogListener", - "NodeEventListener", ] diff --git a/libs/gl-sdk/src/input.rs b/libs/gl-sdk/src/input.rs index 5ebf4e68e..439d675e6 100644 --- a/libs/gl-sdk/src/input.rs +++ b/libs/gl-sdk/src/input.rs @@ -1,7 +1,26 @@ // Input parsing for BOLT11 invoices, Lightning node IDs, LNURL // strings, and Lightning Addresses. -// Works offline — no node connection or HTTP calls needed. - +// +// Two entry points with explicit cost contracts: +// +// * `parse_input(input)` — synchronous, offline, no I/O. Returns +// `ParsedInput` identifying *what* the input is. LNURL bech32 +// strings are decoded to their underlying URL; Lightning +// Addresses are returned as the unparsed `user@host` form. The +// caller decides whether to resolve further. +// +// * `resolve_input(input)` — asynchronous, network-touching. Calls +// `parse_input` internally, then for the LNURL / Lightning +// Address branches fetches the endpoint to produce typed pay or +// withdraw request data. BOLT11 and node IDs pass through +// without I/O. +// +// Wallets that want offline classification (clipboard checks, +// invoice sanity-checks on the send screen) call `parse_input`. +// Wallets handling a QR scan that should proceed straight to the +// pay/withdraw screen call `resolve_input`. + +use crate::lnurl::{LnUrlPayRequestData, LnUrlWithdrawRequestData}; use crate::Error; /// Parsed BOLT11 invoice with extracted fields. @@ -23,28 +42,51 @@ pub struct ParsedInvoice { pub timestamp: u64, } -/// The result of parsing user input. +/// The result of `parse_input`: an offline classification of the +/// input. No HTTP, no I/O. LNURL bech32 strings are returned as their +/// decoded URL; Lightning Addresses as the unparsed `user@host` form. #[derive(Clone, uniffi::Enum)] -pub enum InputType { +pub enum ParsedInput { /// A BOLT11 Lightning invoice. Bolt11 { invoice: ParsedInvoice }, - /// A Lightning node public key (66 hex characters, 33 bytes compressed). + /// A Lightning node public key. NodeId { node_id: String }, - /// An LNURL bech32 string (LUD-01). The `url` field contains the - /// decoded URL. Call `Node::resolve_lnurl()` to determine whether - /// this is a pay or withdraw endpoint. + /// An LNURL bech32 string (LUD-01) decoded to its underlying URL. + /// Pass to `resolve_input` (or fetch yourself) to determine + /// whether it's a pay, withdraw, or auth endpoint. LnUrl { url: String }, - /// A Lightning Address (LUD-16), e.g. `user@domain.com`. - /// Call `Node::resolve_lnurl()` to resolve it to a pay request. + /// A Lightning Address (LUD-16) in the form `user@host`. The + /// well-known URL is not constructed offline; call `resolve_input` + /// to fetch and classify. LnUrlAddress { address: String }, } -/// Parse a string and identify its type. +/// The result of `resolve_input`: a fully-resolved input ready for +/// the caller's next action. LNURL bech32 strings and Lightning +/// Addresses are resolved over HTTP into typed pay or withdraw +/// request data; BOLT11 and node IDs pass through unchanged. +#[derive(Clone, uniffi::Enum)] +pub enum ResolvedInput { + /// A BOLT11 Lightning invoice. No HTTP was performed. + Bolt11 { invoice: ParsedInvoice }, + /// A Lightning node public key. No HTTP was performed. + NodeId { node_id: String }, + /// An LNURL-pay endpoint with the service's parameters fetched. + LnUrlPay { data: LnUrlPayRequestData }, + /// An LNURL-withdraw endpoint with the service's parameters fetched. + LnUrlWithdraw { data: LnUrlWithdrawRequestData }, +} + +/// Synchronously classify the input. **No HTTP, no I/O.** /// -/// Recognizes BOLT11 invoices, node IDs, LNURL bech32 strings, and +/// Recognises BOLT11 invoices, node IDs, LNURL bech32 strings, and /// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes -/// automatically. Works offline — no node connection needed. -pub fn parse_input(input: String) -> Result { +/// automatically. +/// +/// LNURL inputs are decoded to their underlying URL but **not +/// fetched** — the caller chooses whether to resolve further (via +/// `resolve_input`) or to surface the URL to the user as-is. +pub fn parse_input(input: String) -> Result { let trimmed = input.trim(); if trimmed.is_empty() { return Err(Error::Other("Empty input".to_string())); @@ -65,37 +107,84 @@ pub fn parse_input(input: String) -> Result { } // Try BOLT11 - if let Some(input_type) = try_parse_bolt11(stripped) { - return input_type; + if let Some(result) = try_parse_bolt11(stripped) { + return result; } // Try Lightning Address (user@domain) - if let Some(input_type) = try_parse_lightning_address(stripped) { - return Ok(input_type); + if let Some(result) = try_parse_lightning_address(stripped) { + return Ok(result); } // Try Node ID - if let Some(input_type) = try_parse_node_id(stripped) { - return Ok(input_type); + if let Some(result) = try_parse_node_id(stripped) { + return Ok(result); } Err(Error::Other("Unrecognized input".to_string())) } +/// Asynchronously classify and resolve the input. +/// +/// Internally calls `parse_input`. For BOLT11 and node IDs returns +/// immediately without I/O. For LNURL bech32 strings and Lightning +/// Addresses, performs the HTTP GET and returns the typed pay or +/// withdraw request data. +pub async fn resolve_input(input: String) -> Result { + use gl_client::lnurl::models::LnUrlHttpClearnetClient; + use gl_client::lnurl::{LnUrlResponse, LNURL}; + + // Capture the user's original input (post-trim) so that + // `data.lnurl` on the resolved response carries the exact string + // the caller handed us. + let original = input.trim().to_string(); + + // The two LNURL-shaped branches converge to a single HTTP fetch + // — the only branch-specific bit is how the URL is derived. + let url = match parse_input(input)? { + ParsedInput::Bolt11 { invoice } => return Ok(ResolvedInput::Bolt11 { invoice }), + ParsedInput::NodeId { node_id } => return Ok(ResolvedInput::NodeId { node_id }), + ParsedInput::LnUrl { url } => url, + ParsedInput::LnUrlAddress { address } => { + gl_client::lnurl::pay::parse_lightning_address(&address) + .map_err(|e| Error::Other(e.to_string()))? + } + }; + + let client = LNURL::new(LnUrlHttpClearnetClient::new()); + let response = client + .resolve(&url) + .await + .map_err(|e| Error::Other(e.to_string()))?; + + Ok(match response { + LnUrlResponse::Pay(d) => { + let mut data: LnUrlPayRequestData = d.into(); + data.lnurl = original; + ResolvedInput::LnUrlPay { data } + } + LnUrlResponse::Withdraw(d) => { + let mut data: LnUrlWithdrawRequestData = d.into(); + data.lnurl = original; + ResolvedInput::LnUrlWithdraw { data } + } + }) +} + /// Try parsing as an LNURL bech32 string (LUD-01). /// Returns None if the input doesn't look like an LNURL. -fn try_parse_lnurl(input: &str) -> Option> { +fn try_parse_lnurl(input: &str) -> Option> { if !input.to_uppercase().starts_with("LNURL1") { return None; } match gl_client::lnurl::utils::parse_lnurl(input) { - Ok(url) => Some(Ok(InputType::LnUrl { url })), + Ok(url) => Some(Ok(ParsedInput::LnUrl { url })), Err(e) => Some(Err(Error::Other(format!("Invalid LNURL: {}", e)))), } } /// Try parsing as a Lightning Address (LUD-16): `user@domain.tld`. -fn try_parse_lightning_address(input: &str) -> Option { +fn try_parse_lightning_address(input: &str) -> Option { let parts: Vec<&str> = input.split('@').collect(); if parts.len() != 2 { return None; @@ -116,14 +205,14 @@ fn try_parse_lightning_address(input: &str) -> Option { { return None; } - Some(InputType::LnUrlAddress { + Some(ParsedInput::LnUrlAddress { address: input.to_string(), }) } /// Try parsing as a BOLT11 invoice. Returns None if the input doesn't /// look like an invoice, or Some(Result) if it does (even if malformed). -fn try_parse_bolt11(input: &str) -> Option> { +fn try_parse_bolt11(input: &str) -> Option> { let lower = input.to_lowercase(); if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") { return None; @@ -157,7 +246,7 @@ fn try_parse_bolt11(input: &str) -> Option> { .unwrap_or_default() .as_secs(); - Some(Ok(InputType::Bolt11 { + Some(Ok(ParsedInput::Bolt11 { invoice: ParsedInvoice { bolt11: input.to_string(), payee_pubkey: Some(payee_pubkey), @@ -171,7 +260,7 @@ fn try_parse_bolt11(input: &str) -> Option> { } /// Try parsing as a node ID (66-char hex → 33-byte compressed pubkey). -fn try_parse_node_id(input: &str) -> Option { +fn try_parse_node_id(input: &str) -> Option { if input.len() != 66 { return None; } @@ -183,7 +272,7 @@ fn try_parse_node_id(input: &str) -> Option { if bytes[0] != 0x02 && bytes[0] != 0x03 { return None; } - Some(InputType::NodeId { + Some(ParsedInput::NodeId { node_id: input.to_string(), }) } @@ -192,105 +281,131 @@ fn try_parse_node_id(input: &str) -> Option { mod tests { use super::*; - #[test] - fn test_parse_lnurl_string() { - let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - match parse_input(lnurl.to_string()).unwrap() { - InputType::LnUrl { url } => { - assert!(url.starts_with("https://")); - } - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + fn parsed_variant_name(t: &ParsedInput) -> &'static str { + match t { + ParsedInput::Bolt11 { .. } => "Bolt11", + ParsedInput::NodeId { .. } => "NodeId", + ParsedInput::LnUrl { .. } => "LnUrl", + ParsedInput::LnUrlAddress { .. } => "LnUrlAddress", + } + } + + fn resolved_variant_name(t: &ResolvedInput) -> &'static str { + match t { + ResolvedInput::Bolt11 { .. } => "Bolt11", + ResolvedInput::NodeId { .. } => "NodeId", + ResolvedInput::LnUrlPay { .. } => "LnUrlPay", + ResolvedInput::LnUrlWithdraw { .. } => "LnUrlWithdraw", } } + // ── parse_input (sync) ────────────────────────────────────── + #[test] - fn test_parse_lnurl_lowercase() { - let lnurl = "lnurl1dp68gurn8ghj7cmfwp5x2unsw4hxktnrdakj7ctsdyhhvvf0d3h82unv9ucsaxqze2"; - match parse_input(lnurl.to_string()).unwrap() { - InputType::LnUrl { .. } => {} - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + fn test_parse_input_bolt11() { + let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; + match parse_input(invoice.to_string()).unwrap() { + ParsedInput::Bolt11 { invoice: parsed } => assert_eq!(parsed.amount_msat, Some(10)), + other => panic!("Expected Bolt11, got {}", parsed_variant_name(&other)), } } #[test] - fn test_parse_lnurl_with_lightning_prefix() { - let input = "lightning:LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; - match parse_input(input.to_string()).unwrap() { - InputType::LnUrl { .. } => {} - other => panic!("Expected LnUrl, got {:?}", variant_name(&other)), + fn test_parse_input_bolt11_with_lightning_prefix() { + let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; + let result = parse_input(format!("lightning:{}", invoice)).unwrap(); + assert!(matches!(result, ParsedInput::Bolt11 { .. })); + } + + #[test] + fn test_parse_input_node_id() { + let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"; + match parse_input(node_id.to_string()).unwrap() { + ParsedInput::NodeId { node_id: id } => assert_eq!(id, node_id), + other => panic!("Expected NodeId, got {}", parsed_variant_name(&other)), } } #[test] - fn test_parse_invalid_lnurl() { - let result = parse_input("LNURL1INVALIDDATA".to_string()); - assert!(result.is_err()); + fn test_parse_input_lnurl_decodes_url() { + // Bech32-encoded "https://service.com/lnurl" + let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2"; + match parse_input(lnurl.to_string()).unwrap() { + ParsedInput::LnUrl { url } => assert!(url.starts_with("https://")), + other => panic!("Expected LnUrl, got {}", parsed_variant_name(&other)), + } } #[test] - fn test_parse_lightning_address() { + fn test_parse_input_lightning_address_returns_address() { match parse_input("user@example.com".to_string()).unwrap() { - InputType::LnUrlAddress { address } => { - assert_eq!(address, "user@example.com"); - } - other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), + ParsedInput::LnUrlAddress { address } => assert_eq!(address, "user@example.com"), + other => panic!("Expected LnUrlAddress, got {}", parsed_variant_name(&other)), } } #[test] - fn test_parse_lightning_address_with_symbols() { - // LUD-16 allows a-z0-9-_. - match parse_input("sat.oshi-99@example.com".to_string()).unwrap() { - InputType::LnUrlAddress { address } => { - assert_eq!(address, "sat.oshi-99@example.com"); - } - other => panic!("Expected LnUrlAddress, got {:?}", variant_name(&other)), - } + fn test_parse_input_invalid_lnurl_errors() { + assert!(parse_input("LNURL1INVALIDDATA".to_string()).is_err()); } #[test] - fn test_parse_lightning_address_no_dot_in_domain() { - // "user@localhost" is not a valid Lightning Address - let result = parse_input("user@localhost".to_string()); - // Should fall through to "Unrecognized input" - assert!(result.is_err()); + fn test_parse_input_address_no_dot_in_domain_errors() { + assert!(parse_input("user@localhost".to_string()).is_err()); } #[test] - fn test_parse_lightning_address_empty_parts() { + fn test_parse_input_empty_address_parts_errors() { assert!(parse_input("@example.com".to_string()).is_err()); assert!(parse_input("user@".to_string()).is_err()); } #[test] - fn test_existing_bolt11_still_works() { - // A known valid mainnet invoice + fn test_parse_input_unrecognized_errors() { + assert!(parse_input("hello world".to_string()).is_err()); + assert!(parse_input("".to_string()).is_err()); + assert!(parse_input(" ".to_string()).is_err()); + assert!(parse_input("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string()).is_err()); + } + + #[test] + fn test_parse_input_invalid_node_id_errors() { + // 66 chars but starts with 0x04 (uncompressed pubkey prefix) + assert!(parse_input( + "04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619".to_string() + ) + .is_err()); + // 66 non-hex chars + assert!(parse_input( + "not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string() + ) + .is_err()); + } + + // ── resolve_input pass-through paths (no HTTP needed) ─────── + + #[test] + fn test_resolve_input_bolt11_passes_through() { let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg"; - match parse_input(invoice.to_string()).unwrap() { - InputType::Bolt11 { invoice: parsed } => { - assert_eq!(parsed.amount_msat, Some(10)); + match crate::util::exec(resolve_input(invoice.to_string())).unwrap() { + ResolvedInput::Bolt11 { invoice: parsed } => { + assert_eq!(parsed.amount_msat, Some(10)) } - other => panic!("Expected Bolt11, got {:?}", variant_name(&other)), + other => panic!("Expected Bolt11, got {}", resolved_variant_name(&other)), } } #[test] - fn test_existing_node_id_still_works() { - // A compressed pubkey (starts with 02 or 03) + fn test_resolve_input_node_id_passes_through() { let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619"; - match parse_input(node_id.to_string()).unwrap() { - InputType::NodeId { node_id: id } => assert_eq!(id, node_id), - other => panic!("Expected NodeId, got {:?}", variant_name(&other)), + match crate::util::exec(resolve_input(node_id.to_string())).unwrap() { + ResolvedInput::NodeId { node_id: id } => assert_eq!(id, node_id), + other => panic!("Expected NodeId, got {}", resolved_variant_name(&other)), } } - /// Helper for readable test failures. - fn variant_name(input: &InputType) -> &'static str { - match input { - InputType::Bolt11 { .. } => "Bolt11", - InputType::NodeId { .. } => "NodeId", - InputType::LnUrl { .. } => "LnUrl", - InputType::LnUrlAddress { .. } => "LnUrlAddress", - } + #[test] + fn test_resolve_input_invalid_lnurl_errors_before_http() { + assert!(crate::util::exec(resolve_input("LNURL1INVALIDDATA".to_string())).is_err()); } } diff --git a/libs/gl-sdk/src/lib.rs b/libs/gl-sdk/src/lib.rs index ec3772553..c5a97e050 100644 --- a/libs/gl-sdk/src/lib.rs +++ b/libs/gl-sdk/src/lib.rs @@ -46,12 +46,12 @@ pub use crate::{ OnchainSendResponse, OutputStatus, Pay, PayStatus, Payment, PaymentStatus, PaymentType, PaymentTypeFilter, Peer, PeerChannel, ReceiveResponse, SendResponse, }, - input::{InputType, ParsedInvoice}, + input::{ParsedInput, ParsedInvoice, ResolvedInput}, logging::{LogEntry, LogLevel, LogListener}, lnurl::{ LnUrlErrorData, LnUrlPayRequest, LnUrlPayRequestData, LnUrlPayResult, LnUrlPaySuccessData, LnUrlWithdrawRequest, LnUrlWithdrawRequestData, - LnUrlWithdrawResult, LnUrlWithdrawSuccessData, ResolvedLnUrl, SuccessActionProcessed, + LnUrlWithdrawResult, LnUrlWithdrawSuccessData, SuccessActionProcessed, }, node_builder::NodeBuilder, scheduler::Scheduler, @@ -233,15 +233,37 @@ pub(crate) fn connect_signerless_internal( Ok(Arc::new(node)) } -/// Parse a string and identify whether it's a BOLT11 invoice or a node ID. +/// Synchronously classify the input. **No HTTP, no I/O.** /// -/// Strips `lightning:` / `LIGHTNING:` prefixes automatically. -/// Works offline — no node connection needed. +/// Recognises BOLT11 invoices, node IDs, LNURL bech32 strings, and +/// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes +/// automatically. LNURL inputs are decoded to their underlying URL +/// but **not fetched** — the caller chooses whether to resolve +/// further (via `resolve_input`) or to surface the URL to the user +/// as-is. +/// +/// Use this for offline operations like clipboard validation or +/// invoice sanity checks. Use `resolve_input` for the QR-scan flow +/// where you want the resolved pay/withdraw data in one call. #[uniffi::export] -pub fn parse_input(input: String) -> Result { +pub fn parse_input(input: String) -> Result { input::parse_input(input) } +/// Asynchronously classify and resolve the input. +/// +/// Internally calls `parse_input` for offline classification, then +/// for LNURL bech32 strings and Lightning Addresses performs the +/// HTTP GET to the LNURL endpoint and returns typed pay or withdraw +/// request data. For BOLT11 invoices and node IDs it returns +/// immediately without I/O. +/// +/// Strips `lightning:` / `LIGHTNING:` prefixes automatically. +#[uniffi::export(async_runtime = "tokio")] +pub async fn resolve_input(input: String) -> Result { + input::resolve_input(input).await +} + /// Set up SDK logging. Call once before any other SDK function. /// /// The listener receives all log messages from the SDK and the diff --git a/libs/gl-sdk/src/lnurl.rs b/libs/gl-sdk/src/lnurl.rs index 5ff01ecd3..53030c1f9 100644 --- a/libs/gl-sdk/src/lnurl.rs +++ b/libs/gl-sdk/src/lnurl.rs @@ -9,19 +9,11 @@ use gl_client::lnurl::models as wire; // ── Resolved endpoint data ────────────────────────────────────────── -/// Result of resolving an LNURL or lightning address via HTTP. -#[derive(Clone, uniffi::Enum)] -pub enum ResolvedLnUrl { - /// The endpoint is an LNURL-pay service (LUD-06). - Pay { data: LnUrlPayRequestData }, - /// The endpoint is an LNURL-withdraw service (LUD-03). - Withdraw { data: LnUrlWithdrawRequestData }, -} - /// Data from an LNURL-pay endpoint (LUD-06). /// /// Contains the service's accepted amount range and metadata. -/// Returned inside `ResolvedLnUrl::Pay` after resolving an LNURL. +/// Returned inside `InputType::LnUrlPay` after `parse_input` resolves +/// an LNURL or Lightning Address. #[derive(Clone, uniffi::Record)] pub struct LnUrlPayRequestData { /// The callback URL to request an invoice from. @@ -43,7 +35,8 @@ pub struct LnUrlPayRequestData { /// Data from an LNURL-withdraw endpoint (LUD-03). /// /// Contains the service's accepted withdrawal range and session key. -/// Returned inside `ResolvedLnUrl::Withdraw` after resolving an LNURL. +/// Returned inside `InputType::LnUrlWithdraw` after `parse_input` +/// resolves an LNURL. #[derive(Clone, uniffi::Record)] pub struct LnUrlWithdrawRequestData { /// The callback URL to submit the invoice to. @@ -67,7 +60,7 @@ pub struct LnUrlWithdrawRequestData { /// Combines the resolved service data with the user's chosen amount. #[derive(Clone, uniffi::Record)] pub struct LnUrlPayRequest { - /// The resolved pay request data from `resolve_lnurl()`. + /// The resolved pay request data from `parse_input()`. pub data: LnUrlPayRequestData, /// Amount to pay in millisatoshis. pub amount_msat: u64, @@ -89,7 +82,7 @@ pub struct LnUrlPayRequest { /// Combines the resolved service data with the user's chosen amount. #[derive(Clone, uniffi::Record)] pub struct LnUrlWithdrawRequest { - /// The resolved withdraw request data from `resolve_lnurl()`. + /// The resolved withdraw request data from `parse_input()`. pub data: LnUrlWithdrawRequestData, /// Amount to withdraw in millisatoshis. pub amount_msat: u64, @@ -215,19 +208,6 @@ impl From for SuccessActionProcessed { } } -impl From for ResolvedLnUrl { - fn from(r: gl_client::lnurl::LnUrlResponse) -> Self { - match r { - gl_client::lnurl::LnUrlResponse::Pay(data) => ResolvedLnUrl::Pay { - data: data.into(), - }, - gl_client::lnurl::LnUrlResponse::Withdraw(data) => ResolvedLnUrl::Withdraw { - data: data.into(), - }, - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/libs/gl-sdk/src/node.rs b/libs/gl-sdk/src/node.rs index e3b9b2fa6..bdd8637ba 100644 --- a/libs/gl-sdk/src/node.rs +++ b/libs/gl-sdk/src/node.rs @@ -649,62 +649,15 @@ impl Node { // ── LNURL methods ─────────────────────────────────────────── - /// Resolve an LNURL or Lightning Address to its endpoint data. - /// - /// Performs the HTTP GET to the LNURL endpoint and returns the - /// typed request data. The result tells you whether this is a - /// pay or withdraw request, and includes the service's parameters. - /// - /// Accepts an LNURL bech32 string, a decoded URL (from - /// `parse_input()`), or a Lightning Address (`user@domain`). - pub fn resolve_lnurl( - &self, - input: String, - ) -> Result { - use gl_client::lnurl::models::LnUrlHttpClearnetClient; - use gl_client::lnurl::LNURL; - - let lnurl_client = LNURL::new(LnUrlHttpClearnetClient::new()); - let trimmed = input.trim(); - - // Determine the URL to fetch - let url = if trimmed.contains('@') { - gl_client::lnurl::pay::parse_lightning_address(trimmed) - .map_err(|e| Error::Other(e.to_string()))? - } else if trimmed.to_uppercase().starts_with("LNURL1") { - gl_client::lnurl::utils::parse_lnurl(trimmed) - .map_err(|e| Error::Other(e.to_string()))? - } else { - // Assume it's already a decoded URL - trimmed.to_string() - }; - - let response = exec(lnurl_client.resolve(&url)) - .map_err(|e| Error::Other(e.to_string()))?; - - let mut resolved: crate::lnurl::ResolvedLnUrl = response.into(); - - // Preserve the original input as the lnurl field - match &mut resolved { - crate::lnurl::ResolvedLnUrl::Pay { data } => { - data.lnurl = trimmed.to_string(); - } - crate::lnurl::ResolvedLnUrl::Withdraw { data } => { - data.lnurl = trimmed.to_string(); - } - } - - Ok(resolved) - } - /// Execute an LNURL-pay flow (LUD-06). /// /// Sends the chosen amount (and optional comment) to the service's /// callback, receives and validates a BOLT11 invoice, pays it, and /// processes any success action (LUD-09/10). /// - /// Call `resolve_lnurl()` first to get the `LnUrlPayRequestData`, - /// then build an `LnUrlPayRequest` with the user's chosen amount. + /// Call the top-level `parse_input` first to obtain the + /// `LnUrlPayRequestData`, then build an `LnUrlPayRequest` with the + /// user's chosen amount. pub fn lnurl_pay( &self, request: crate::lnurl::LnUrlPayRequest, @@ -803,8 +756,9 @@ impl Node { /// it to the service's callback URL, and the service pays it /// asynchronously. /// - /// Call `resolve_lnurl()` first to get the `LnUrlWithdrawRequestData`, - /// then build an `LnUrlWithdrawRequest` with the user's chosen amount. + /// Call the top-level `parse_input` first to obtain the + /// `LnUrlWithdrawRequestData`, then build an `LnUrlWithdrawRequest` + /// with the user's chosen amount. pub fn lnurl_withdraw( &self, request: crate::lnurl::LnUrlWithdrawRequest, diff --git a/libs/gl-sdk/tests/test_lnurl.py b/libs/gl-sdk/tests/test_lnurl.py index 5c59b98bb..acd526b3c 100644 --- a/libs/gl-sdk/tests/test_lnurl.py +++ b/libs/gl-sdk/tests/test_lnurl.py @@ -8,6 +8,8 @@ gl_sdk_node ── channel ── relay ── channel ── service_node (LNURL server) """ +import asyncio + from gltesting.fixtures import * # noqa: F401, F403 from pyln.testing.utils import wait_for @@ -62,57 +64,64 @@ def fund_and_connect(node_factory, bitcoind, lnurl_service): return relay -def test_resolve_lnurl_pay( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve an LNURL-pay endpoint via the SDK.""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) - - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) - data = resolved.data - assert data.min_sendable == lnurl_service.min_sendable - assert data.max_sendable == lnurl_service.max_sendable - assert len(data.description) > 0 - assert data.callback.startswith(lnurl_service.base_url) - finally: - sdk_node.disconnect() - - -def test_resolve_lnurl_withdraw( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve an LNURL-withdraw endpoint via the SDK.""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.withdraw_url) - - assert isinstance(resolved, glsdk.ResolvedLnUrl.WITHDRAW) - data = resolved.data - assert data.min_withdrawable == lnurl_service.min_withdrawable - assert data.max_withdrawable == lnurl_service.max_withdrawable - assert len(data.k1) > 0 - finally: - sdk_node.disconnect() - - -def test_resolve_lightning_address_url( - scheduler, nobody_id, node_factory, bitcoind, lnurl_service -): - """Resolve a Lightning Address well-known URL (LUD-16).""" - sdk_node, config = make_sdk_node(nobody_id, scheduler) - - try: - resolved = sdk_node.resolve_lnurl(lnurl_service.lightning_address_url) +def test_parse_input_lnurl_pay(lnurl_service): + """parse_input on an LNURL-pay URL returns LnUrlPay with fetched data.""" + resolved = asyncio.run(glsdk.resolve_input(lnurl_service.pay_url)) + + assert isinstance(resolved, glsdk.ResolvedInput.LN_URL_PAY) + data = resolved.data + assert data.min_sendable == lnurl_service.min_sendable + assert data.max_sendable == lnurl_service.max_sendable + assert len(data.description) > 0 + assert data.callback.startswith(lnurl_service.base_url) + assert data.lnurl == lnurl_service.pay_url + + +def test_parse_input_lnurl_withdraw(lnurl_service): + """parse_input on an LNURL-withdraw URL returns LnUrlWithdraw with fetched data.""" + resolved = asyncio.run(glsdk.resolve_input(lnurl_service.withdraw_url)) + + assert isinstance(resolved, glsdk.ResolvedInput.LN_URL_WITHDRAW) + data = resolved.data + assert data.min_withdrawable == lnurl_service.min_withdrawable + assert data.max_withdrawable == lnurl_service.max_withdrawable + assert len(data.k1) > 0 + assert data.lnurl == lnurl_service.withdraw_url + + +def test_parse_input_lightning_address_url(lnurl_service): + """parse_input on a well-known LUD-16 URL returns LnUrlPay.""" + resolved = asyncio.run(glsdk.resolve_input(lnurl_service.lightning_address_url)) + + assert isinstance(resolved, glsdk.ResolvedInput.LN_URL_PAY) + assert resolved.data.min_sendable == lnurl_service.min_sendable + assert resolved.data.lnurl == lnurl_service.lightning_address_url + + +def test_parse_input_bolt11_no_http(lnurl_service): + """parse_input on a BOLT11 invoice returns Bolt11 without touching HTTP.""" + invoice = ( + "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45t" + "qcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tn" + "k2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw" + "3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffc" + "rf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8" + "h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrz" + "jqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqq" + "yqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmu" + "wvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8" + "v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62" + "g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqq" + "qqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5" + "m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg" + ) + resolved = asyncio.run(glsdk.resolve_input(invoice)) - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) - data = resolved.data - assert data.min_sendable == lnurl_service.min_sendable - finally: - sdk_node.disconnect() + assert isinstance(resolved, glsdk.ResolvedInput.BOLT11) + assert resolved.invoice.amount_msat == 10 + # No callback recorded on the LNURL service since we never hit it. + assert len(lnurl_service.pay_callbacks) == 0 + assert len(lnurl_service.withdraw_callbacks) == 0 def test_lnurl_pay_end_to_end( @@ -160,8 +169,8 @@ def test_lnurl_pay_end_to_end( try: # Resolve - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) - assert isinstance(resolved, glsdk.ResolvedLnUrl.PAY) + resolved = asyncio.run(glsdk.resolve_input(lnurl_service.pay_url)) + assert isinstance(resolved, glsdk.ResolvedInput.LN_URL_PAY) pay_data = resolved.data amount_msat = 50_000 # 50 sats @@ -226,7 +235,8 @@ def test_lnurl_pay_with_message_success_action( sdk_node = glsdk.Node(glsdk.Credentials.load(creds_bytes)) try: - resolved = sdk_node.resolve_lnurl(lnurl_service.pay_url) + resolved = asyncio.run(glsdk.resolve_input(lnurl_service.pay_url)) + assert isinstance(resolved, glsdk.ResolvedInput.LN_URL_PAY) pay_data = resolved.data result = sdk_node.lnurl_pay( diff --git a/libs/gl-sdk/tests/test_parse_input.py b/libs/gl-sdk/tests/test_parse_input.py index 30089b8d3..46fd806c8 100644 --- a/libs/gl-sdk/tests/test_parse_input.py +++ b/libs/gl-sdk/tests/test_parse_input.py @@ -1,6 +1,9 @@ -"""Tests for the parse_input() free function. +"""Tests for the synchronous parse_input() free function. -Verifies BOLT11 invoice and node ID parsing from arbitrary string input. +`parse_input` is offline — no HTTP, no I/O. Tests cover BOLT11 +invoices, node IDs, and the offline LNURL / Lightning Address +classification (not the HTTP resolution — that's `resolve_input`, +exercised in test_lnurl.py against the live LNURL fixture). """ import pytest @@ -18,12 +21,20 @@ # Valid compressed secp256k1 public key (starts with 02 or 03, 33 bytes = 66 hex chars) VALID_NODE_ID = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" +# Bech32-encoded "https://service.com/lnurl" (LUD-01 example) +VALID_LNURL_BECH32 = ( + "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2" +) + class TestParseInputTypes: """Test that parse_input types exist in the bindings.""" - def test_input_type_enum_exists(self): - assert hasattr(glsdk, "InputType") + def test_parsed_input_enum_exists(self): + assert hasattr(glsdk, "ParsedInput") + + def test_resolved_input_enum_exists(self): + assert hasattr(glsdk, "ResolvedInput") def test_bolt11_invoice_type_exists(self): assert hasattr(glsdk, "ParsedInvoice") @@ -31,24 +42,23 @@ def test_bolt11_invoice_type_exists(self): def test_parse_input_function_exists(self): assert hasattr(glsdk, "parse_input") + def test_resolve_input_function_exists(self): + assert hasattr(glsdk, "resolve_input") + class TestParseInputNodeId: - """Test node ID parsing.""" + """Test node ID parsing — no HTTP required.""" def test_parse_valid_node_id(self): result = glsdk.parse_input(VALID_NODE_ID) - assert isinstance(result, glsdk.InputType) - - def test_parse_node_id_returns_correct_value(self): - result = glsdk.parse_input(VALID_NODE_ID) - # Access the NodeId variant - assert result.is_node_id() if hasattr(result, 'is_node_id') else True - # UniFFI enums in Python: check the variant - assert hasattr(result, 'node_id') or hasattr(result, 'invoice') + assert isinstance(result, glsdk.ParsedInput.NODE_ID) + assert result.node_id == VALID_NODE_ID def test_invalid_hex_returns_error(self): with pytest.raises(glsdk.Error): - glsdk.parse_input("not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx") + glsdk.parse_input( + "not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx" + ) def test_wrong_length_hex_returns_error(self): with pytest.raises(glsdk.Error): @@ -57,31 +67,60 @@ def test_wrong_length_hex_returns_error(self): def test_wrong_prefix_hex_returns_error(self): # 04 prefix = uncompressed pubkey, not valid for Lightning with pytest.raises(glsdk.Error): - glsdk.parse_input("04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619") + glsdk.parse_input( + "04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619" + ) class TestParseInputBolt11: - """Test BOLT11 invoice parsing.""" + """Test BOLT11 invoice parsing — no HTTP required.""" def test_parse_valid_bolt11(self): result = glsdk.parse_input(BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + assert isinstance(result, glsdk.ParsedInput.BOLT11) def test_parse_bolt11_with_lightning_prefix(self): result = glsdk.parse_input("lightning:" + BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + assert isinstance(result, glsdk.ParsedInput.BOLT11) def test_parse_bolt11_with_uppercase_prefix(self): result = glsdk.parse_input("LIGHTNING:" + BOLT11_INVOICE) - assert isinstance(result, glsdk.InputType) + assert isinstance(result, glsdk.ParsedInput.BOLT11) def test_parse_bolt11_with_whitespace(self): result = glsdk.parse_input(" " + BOLT11_INVOICE + " ") - assert isinstance(result, glsdk.InputType) + assert isinstance(result, glsdk.ParsedInput.BOLT11) + + +class TestParseInputLnUrl: + """Test LNURL bech32 / Lightning Address classification — offline.""" + + def test_lnurl_bech32_decodes_to_url(self): + result = glsdk.parse_input(VALID_LNURL_BECH32) + assert isinstance(result, glsdk.ParsedInput.LN_URL) + assert result.url.startswith("https://") + + def test_lnurl_bech32_lowercase(self): + result = glsdk.parse_input(VALID_LNURL_BECH32.lower()) + assert isinstance(result, glsdk.ParsedInput.LN_URL) + + def test_lnurl_with_lightning_prefix(self): + result = glsdk.parse_input("lightning:" + VALID_LNURL_BECH32) + assert isinstance(result, glsdk.ParsedInput.LN_URL) + + def test_lightning_address_returns_address_form(self): + result = glsdk.parse_input("user@example.com") + assert isinstance(result, glsdk.ParsedInput.LN_URL_ADDRESS) + assert result.address == "user@example.com" + + def test_lightning_address_with_symbols(self): + # LUD-16 allows a-z0-9-_. + result = glsdk.parse_input("sat.oshi-99@example.com") + assert isinstance(result, glsdk.ParsedInput.LN_URL_ADDRESS) class TestParseInputErrors: - """Test error cases.""" + """Test error cases that don't require HTTP.""" def test_empty_string_returns_error(self): with pytest.raises(glsdk.Error): @@ -99,3 +138,17 @@ def test_bitcoin_address_returns_error(self): # We don't support bitcoin addresses yet with pytest.raises(glsdk.Error): glsdk.parse_input("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + + def test_invalid_lnurl_bech32_errors(self): + with pytest.raises(glsdk.Error): + glsdk.parse_input("LNURL1INVALIDDATA") + + def test_lightning_address_no_dot_in_domain_errors(self): + with pytest.raises(glsdk.Error): + glsdk.parse_input("user@localhost") + + def test_lightning_address_empty_parts_error(self): + with pytest.raises(glsdk.Error): + glsdk.parse_input("@example.com") + with pytest.raises(glsdk.Error): + glsdk.parse_input("user@")