From 26b40ebacdd2ed12c3fb62ee1dc859c3bfc09d4e Mon Sep 17 00:00:00 2001 From: nicktee Date: Mon, 8 Jun 2026 20:35:10 -0500 Subject: [PATCH 1/2] getLightningAddress and nwc standard errors --- bindings/typescript/src/__tests__/nwc.test.ts | 97 +++++++++++++++ bindings/typescript/src/errors.ts | 44 ++++++- bindings/typescript/src/lnurl.ts | 117 ++++++++++++++++-- bindings/typescript/src/nodes/nwc.ts | 89 +++++++++++-- bindings/typescript/src/types.ts | 5 + crates/lni/lib.rs | 4 +- crates/lni/lnurl/mod.rs | 113 +++++++++++++++++ crates/lni/nwc/api.rs | 87 +++++++++++-- crates/lni/nwc/lib.rs | 45 +++++++ 9 files changed, 569 insertions(+), 32 deletions(-) diff --git a/bindings/typescript/src/__tests__/nwc.test.ts b/bindings/typescript/src/__tests__/nwc.test.ts index f02c245..735f53a 100644 --- a/bindings/typescript/src/__tests__/nwc.test.ts +++ b/bindings/typescript/src/__tests__/nwc.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const nwcMocks = vi.hoisted(() => ({ + Nip47Error: class Nip47Error extends Error { + code: string; + + constructor(message: string, code: string) { + super(message); + this.code = code; + } + }, payInvoice: vi.fn(), lookupInvoice: vi.fn(), listTransactions: vi.fn(), @@ -12,6 +20,7 @@ const bolt11Mocks = vi.hoisted(() => ({ })); vi.mock('@getalby/sdk/nwc', () => ({ + Nip47Error: nwcMocks.Nip47Error, NWCClient: Object.assign( vi.fn().mockImplementation(() => ({ payInvoice: nwcMocks.payInvoice, @@ -32,6 +41,7 @@ vi.mock('../decode.js', () => ({ })); import { NwcNode } from '../nodes/nwc.js'; +import { NwcError } from '../errors.js'; import { registerSha256DigestFallback } from '../internal/sha256.js'; const PAYMENT_HASH = '31b06bf9be4c938914030eb23d583a4fe6f6e2f3374293170f027be248ed6370'; @@ -40,6 +50,7 @@ const ZERO_PREIMAGE = '000000000000000000000000000000000000000000000000000000000 const ZERO_PREIMAGE_PAYMENT_HASH = '66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925'; const BOLT11_INVOICE = 'lnbc1testinvoice'; const NWC_URI = 'nostr+walletconnect://wallet?relay=wss://relay.example&secret=test'; +const NWC_URI_WITH_LUD16 = `${NWC_URI}&lud16=test%40example.com`; const originalConsoleError = console.error; const originalCryptoDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); @@ -65,6 +76,14 @@ function makeNode() { return new NwcNode({ nwcUri: NWC_URI }); } +function makeJsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + headers: { + 'content-type': 'application/json', + }, + }); +} + function mockBolt11Decode() { bolt11Mocks.decode.mockImplementation((invoice: string) => { if (invoice !== BOLT11_INVOICE) { @@ -105,6 +124,21 @@ afterEach(() => { }); describe('NwcNode.payInvoice', () => { + it('preserves typed NIP-47 wallet error codes from the SDK', async () => { + nwcMocks.payInvoice.mockRejectedValue(new nwcMocks.Nip47Error('quota spent', 'QUOTA_EXCEEDED')); + + await expect(makeNode().payInvoice({ invoice: BOLT11_INVOICE })).rejects.toMatchObject({ + name: 'NwcError', + code: 'NwcError', + nwcCode: 'QUOTA_EXCEEDED', + nwcMessage: 'quota spent', + operation: 'pay_invoice', + message: 'quota spent', + }); + + await expect(makeNode().payInvoice({ invoice: BOLT11_INVOICE })).rejects.toBeInstanceOf(NwcError); + }); + it('hashes returned preimages with a registered fallback when global crypto.subtle is absent', async () => { Object.defineProperty(globalThis, 'crypto', { configurable: true, @@ -145,6 +179,69 @@ describe('NwcNode.payInvoice', () => { }); }); +describe('NwcNode.getLightningAddress', () => { + it('returns the lud16 Lightning Address and true when LNURL verify succeeds', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + makeJsonResponse({ + callback: 'https://example.com/lnurl/callback', + maxSendable: 500_000_000, + minSendable: 1, + metadata: '[["text/plain","test"]]', + tag: 'payRequest', + }), + ) + .mockResolvedValueOnce( + makeJsonResponse({ + pr: 'lnbc1testinvoice', + verify: 'https://example.com/lnurl/verify', + }), + ) + .mockResolvedValueOnce(makeJsonResponse({ status: 'OK' })); + + const response = await new NwcNode({ nwcUri: NWC_URI_WITH_LUD16 }, { fetch: fetchMock }).getLightningAddress(); + + expect(response).toEqual({ + lightningAddress: 'test@example.com', + lnurlVerifySupported: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://example.com/.well-known/lnurlp/test'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://example.com/lnurl/callback?amount=100000'); + expect(fetchMock.mock.calls[2]?.[0]).toBe('https://example.com/lnurl/verify'); + }); + + it('returns false when the Lightning Address LNURL callback has no verify endpoint', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + makeJsonResponse({ + callback: 'https://example.com/lnurl/callback', + maxSendable: 500_000_000, + minSendable: 1, + metadata: '[["text/plain","test"]]', + tag: 'payRequest', + }), + ) + .mockResolvedValueOnce(makeJsonResponse({ pr: 'lnbc1testinvoice' })); + + const response = await new NwcNode({ nwcUri: NWC_URI_WITH_LUD16 }, { fetch: fetchMock }).getLightningAddress(); + + expect(response).toEqual({ + lightningAddress: 'test@example.com', + lnurlVerifySupported: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('throws clearly when the NWC URI has no lud16 value', async () => { + await expect(makeNode().getLightningAddress()).rejects.toThrow( + 'NWC URI does not include a lud16 Lightning Address.', + ); + }); +}); + function hexToBytesForTest(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i += 1) { diff --git a/bindings/typescript/src/errors.ts b/bindings/typescript/src/errors.ts index f291071..3b77891 100644 --- a/bindings/typescript/src/errors.ts +++ b/bindings/typescript/src/errors.ts @@ -4,7 +4,31 @@ export type LniErrorCode = | 'Json' | 'NetworkError' | 'InvalidInput' - | 'LnurlError'; + | 'LnurlError' + | 'NwcError'; + +export type NwcStandardErrorCode = + | 'RATE_LIMITED' + | 'NOT_IMPLEMENTED' + | 'INSUFFICIENT_BALANCE' + | 'PAYMENT_FAILED' + | 'NOT_FOUND' + | 'QUOTA_EXCEEDED' + | 'RESTRICTED' + | 'UNAUTHORIZED' + | 'INTERNAL' + | 'UNSUPPORTED_ENCRYPTION' + | 'OTHER'; + +export type NwcErrorCode = NwcStandardErrorCode | (string & {}); + +export type NwcErrorOperation = + | 'get_info' + | 'get_balance' + | 'make_invoice' + | 'pay_invoice' + | 'lookup_invoice' + | 'list_transactions'; export class LniError extends Error { public readonly code: LniErrorCode; @@ -20,6 +44,24 @@ export class LniError extends Error { } } +export class NwcError extends LniError { + public readonly nwcCode: NwcErrorCode; + public readonly nwcMessage: string; + public readonly operation?: NwcErrorOperation; + + constructor( + nwcCode: NwcErrorCode, + message: string, + options?: { operation?: NwcErrorOperation; cause?: unknown }, + ) { + super('NwcError', message, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = 'NwcError'; + this.nwcCode = nwcCode; + this.nwcMessage = message; + this.operation = options?.operation; + } +} + export function asLniError(error: unknown, fallbackCode: LniErrorCode = 'Api'): LniError { if (error instanceof LniError) { return error; diff --git a/bindings/typescript/src/lnurl.ts b/bindings/typescript/src/lnurl.ts index ee24887..a37b5f9 100644 --- a/bindings/typescript/src/lnurl.ts +++ b/bindings/typescript/src/lnurl.ts @@ -15,7 +15,7 @@ export interface LnurlResolverOptions { allowUnsafeUrls?: boolean; } -interface LnurlPayResponse { +export interface LnurlPayResponse { callback: string; maxSendable: number; minSendable: number; @@ -29,11 +29,32 @@ interface LnurlInvoiceResponse { pr: string; } +interface LnurlVerifyInvoiceResponse { + pr: string; + verify: string; +} + interface LnurlErrorResponse { status: string; reason: string; } +interface LnurlMessageErrorResponse { + error?: boolean; + message?: string; +} + +interface LnurlOkResponse { + status?: string; +} + +export class LnurlVerifyUnsupportedError extends Error { + constructor() { + super('LNURL-verify endpoint is not supported.'); + this.name = 'LnurlVerifyUnsupportedError'; + } +} + export function detectPaymentType(destination: string): PaymentDestinationType { const input = destination.trim(); const lower = input.toLowerCase(); @@ -206,9 +227,7 @@ async function fetchLnurlPay( }); const maybeError = payload as LnurlErrorResponse; - if (maybeError?.status === 'ERROR') { - throw new LniError('LnurlError', maybeError.reason); - } + handleLnurlErrorResponse(maybeError); return payload as LnurlPayResponse; } @@ -231,9 +250,7 @@ async function requestInvoice( }); const maybeError = response as LnurlErrorResponse; - if (maybeError.status === 'ERROR') { - throw new LniError('LnurlError', maybeError.reason); - } + handleLnurlErrorResponse(maybeError); const invoiceResponse = response as LnurlInvoiceResponse; if (!invoiceResponse.pr) { @@ -245,6 +262,38 @@ async function requestInvoice( return invoiceResponse.pr; } +function getLnurlErrorMessage(response: unknown): string | undefined { + if (!response || typeof response !== 'object') { + return undefined; + } + + const payload = response as Partial; + if (typeof payload.status === 'string' && payload.status.toUpperCase() === 'ERROR' && payload.reason) { + return payload.reason; + } + + if (payload.error === true && payload.message) { + return payload.message; + } + + return undefined; +} + +function handleLnurlErrorResponse(response: unknown): void { + const message = getLnurlErrorMessage(response); + if (message) { + throw new LniError('LnurlError', message); + } +} + +function handleLnurlOkResponse(response: unknown, endpointLabel: string): void { + handleLnurlErrorResponse(response); + const payload = response as LnurlOkResponse; + if (payload?.status !== 'OK') { + throw new LniError('InvalidInput', `${endpointLabel} response status is not OK`); + } +} + function invoiceAmountMsats(invoice: string): number | null { const decoded = decodeBolt11(invoice); if (decoded.amountMsats === undefined) { @@ -312,6 +361,60 @@ async function resolveViaLnurlPay( return requestInvoice(lnurlPay.callback, amountMsats, fetchFn, options); } +export async function verifyLightningAddressPayRequest( + lightningAddress: string, + options: LnurlResolverOptions = {}, +): Promise<{ wellKnown: LnurlPayResponse; verifyEndpoint: string }> { + const fetchFn = resolveFetch(options?.fetch); + const { user, domain } = parseLightningAddress(lightningAddress.trim()); + const wellKnown = await fetchLnurlPay(lightningAddressToUrl(user, domain), fetchFn, options); + const amountMsats = Math.min(Math.max(100_000, wellKnown.minSendable), wellKnown.maxSendable); + + if ( + !Number.isFinite(amountMsats) || + amountMsats < wellKnown.minSendable || + amountMsats > wellKnown.maxSendable + ) { + throw new LniError('InvalidInput', 'Invalid LNURL sendable amount range.'); + } + + const callback = parseLnurlUrl(wellKnown.callback, options.allowUnsafeUrls); + callback.searchParams.set('amount', String(amountMsats)); + + const callbackResponse = await requestJson( + fetchFn, + callback.toString(), + { + method: 'GET', + headers: { + accept: 'application/json', + }, + timeoutMs: 30_000, + }, + ); + handleLnurlErrorResponse(callbackResponse); + + const maybeVerify = callbackResponse as Partial; + if (typeof maybeVerify.pr !== 'string' || typeof maybeVerify.verify !== 'string') { + throw new LnurlVerifyUnsupportedError(); + } + + const verify = parseLnurlUrl(maybeVerify.verify, options.allowUnsafeUrls); + const verifyResponse = await requestJson(fetchFn, verify.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + }, + timeoutMs: 30_000, + }); + handleLnurlOkResponse(verifyResponse, 'LNURL verify'); + + return { + wellKnown, + verifyEndpoint: maybeVerify.verify, + }; +} + export async function resolveToBolt11( destination: string, amountMsats?: number, diff --git a/bindings/typescript/src/nodes/nwc.ts b/bindings/typescript/src/nodes/nwc.ts index dc880b2..aae9bc4 100644 --- a/bindings/typescript/src/nodes/nwc.ts +++ b/bindings/typescript/src/nodes/nwc.ts @@ -1,12 +1,13 @@ -import { NWCClient, type Nip47GetBalanceResponse, type Nip47GetInfoResponse, type Nip47ListTransactionsResponse, type Nip47Transaction } from '@getalby/sdk/nwc'; +import { NWCClient, Nip47Error, type Nip47GetBalanceResponse, type Nip47GetInfoResponse, type Nip47ListTransactionsResponse, type Nip47Transaction } from '@getalby/sdk/nwc'; import { decode as decodeBolt11, decodeBolt11ToJson, decodeOfferToJson } from '../decode.js'; -import { LniError } from '../errors.js'; +import { LniError, NwcError, type NwcErrorOperation } from '../errors.js'; import { hexToBytes } from '../internal/encoding.js'; import { NWC_METHOD_PERMISSIONS, normalizeNwcPermissions } from '../internal/permissions.js'; import { pollInvoiceEvents } from '../internal/polling.js'; import { sha256Hex } from '../internal/sha256.js'; import { emptyNodeInfo, emptyTransaction, matchesSearch, parseOptionalNumber } from '../internal/transform.js'; -import type { CreateInvoiceParams, CreateOfferParams, InvoiceEventCallback, LightningNode, ListTransactionsParams, LookupInvoiceParams, NodeInfo, NodeRequestOptions, NwcConfig, Offer, OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Permissions, Transaction } from '../types.js'; +import { verifyLightningAddressPayRequest } from '../lnurl.js'; +import type { CreateInvoiceParams, CreateOfferParams, InvoiceEventCallback, LightningAddressInfo, LightningNode, ListTransactionsParams, LookupInvoiceParams, NodeInfo, NodeRequestOptions, NwcConfig, Offer, OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, Permissions, Transaction } from '../types.js'; type NwcListTransaction = Partial> & { type?: Nip47Transaction['type']; @@ -18,6 +19,30 @@ type NwcListTransactionsResponse = Omit { const info = await this.client.getInfo().catch((error) => { - throw new LniError('Api', `Failed to get NWC permissions: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'get_info', 'Failed to get NWC permissions'); }); const methods = (info as Nip47GetInfoResponse & { methods?: string[] }).methods; return normalizeNwcPermissions(methods?.length ? methods : NWC_METHOD_PERMISSIONS); } + async getLightningAddress(): Promise { + const lightningAddress = extractLightningAddressFromNwcUri(this.config.nwcUri); + if (!lightningAddress) { + throw new LniError('InvalidInput', 'NWC URI does not include a lud16 Lightning Address.'); + } + + const lnurlVerifySupported = await verifyLightningAddressPayRequest(lightningAddress, { + fetch: this.options.fetch, + }).then( + () => true, + () => false, + ); + + return { + lightningAddress, + lnurlVerifySupported, + }; + } + async getInfo(): Promise { const balance = await this.client.getBalance().catch((error) => { - throw new LniError('Api', `Failed to get balance: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'get_balance', 'Failed to get balance'); }); const pubkeyFallback = extractPubkeyFromNwcUri(this.config.nwcUri); @@ -161,7 +230,7 @@ export class NwcNode implements LightningNode { expiry: params.expiry, }) .catch((error) => { - throw new LniError('Api', `Failed to create invoice: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'make_invoice', 'Failed to create invoice'); }); return nwcTransactionToLniTransaction(tx); @@ -174,7 +243,7 @@ export class NwcNode implements LightningNode { amount: params.amountMsats, }) .catch((error) => { - throw new LniError('Api', `Failed to pay invoice: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'pay_invoice', 'Failed to pay invoice'); }); let paymentHash = ''; @@ -239,7 +308,7 @@ export class NwcNode implements LightningNode { } replayConsoleErrors(errorLogs); - throw new LniError('Api', `Failed to lookup invoice: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'lookup_invoice', 'Failed to lookup invoice'); } } @@ -290,7 +359,7 @@ export class NwcNode implements LightningNode { limit: params.limit > 0 ? params.limit : undefined, }) .catch((error) => { - throw new LniError('Api', `Failed to list transactions: ${(error as Error)?.message ?? 'unknown error'}`); + throwNwcOrApiError(error, 'list_transactions', 'Failed to list transactions'); }); return this.filterTransactions(response as NwcListTransactionsResponse, params); diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts index cddbd81..4b049db 100644 --- a/bindings/typescript/src/types.ts +++ b/bindings/typescript/src/types.ts @@ -221,6 +221,11 @@ export interface NwcConfig { httpTimeout?: number; } +export interface LightningAddressInfo { + lightningAddress: string; + lnurlVerifySupported: boolean; +} + export interface StrikeConfig { baseUrl?: string; apiKey: string; diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 667f1ab..d1783dc 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -29,6 +29,8 @@ pub enum ApiError { InvalidInput(String), #[error("LnurlError: {0}")] LnurlError(String), + #[error("NwcError: {code}: {message}")] + Nwc { code: String, message: String }, } impl From for ApiError { fn from(e: serde_json::Error) -> Self { @@ -211,7 +213,7 @@ pub mod nwc { pub mod api; pub mod lib; pub mod types; - pub use lib::{NwcConfig, NwcNode}; + pub use lib::{NwcConfig, NwcLightningAddress, NwcNode}; } pub mod strike { diff --git a/crates/lni/lnurl/mod.rs b/crates/lni/lnurl/mod.rs index e13c7d1..bc164a1 100644 --- a/crates/lni/lnurl/mod.rs +++ b/crates/lni/lnurl/mod.rs @@ -32,6 +32,12 @@ pub struct LnurlInvoiceResponse { pub routes: Option>, } +#[derive(Debug, Deserialize)] +struct LnurlVerifyPayResponse { + pub pr: String, + pub verify: String, +} + /// Error response from LNURL service #[derive(Debug, Deserialize)] pub struct LnurlErrorResponse { @@ -180,6 +186,113 @@ pub async fn request_invoice(callback_url: &str, amount_msats: i64) -> Result Result<(), ApiError> { + let Some(object) = value.as_object() else { + return Ok(()); + }; + + if object + .get("status") + .and_then(|status| status.as_str()) + .map(|status| status.eq_ignore_ascii_case("ERROR")) + .unwrap_or(false) + { + if let Some(reason) = object.get("reason").and_then(|reason| reason.as_str()) { + return Err(ApiError::LnurlError(reason.to_string())); + } + } + + if object + .get("error") + .and_then(|error| error.as_bool()) + .unwrap_or(false) + { + if let Some(message) = object.get("message").and_then(|message| message.as_str()) { + return Err(ApiError::LnurlError(message.to_string())); + } + } + + Ok(()) +} + +fn handle_lnurl_ok_value(value: &serde_json::Value, endpoint_label: &str) -> Result<(), ApiError> { + handle_lnurl_error_value(value)?; + + let status_ok = value + .as_object() + .and_then(|object| object.get("status")) + .and_then(|status| status.as_str()) + .map(|status| status == "OK") + .unwrap_or(false); + if status_ok { + Ok(()) + } else { + Err(ApiError::InvalidInput(format!("{} response status is not OK", endpoint_label))) + } +} + +fn callback_url_with_amount(callback_url: &str, amount_msats: i64) -> Result { + let mut url = reqwest::Url::parse(callback_url) + .map_err(|e| ApiError::InvalidInput(format!("Invalid LNURL callback URL: {}", e)))?; + url.query_pairs_mut().append_pair("amount", &amount_msats.to_string()); + Ok(url.to_string()) +} + +async fn fetch_lnurl_json_value(url: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ApiError::NetworkError(e.to_string()))?; + + let response = client + .get(url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| ApiError::NetworkError(format!("Failed to fetch LNURL: {}", e)))?; + + let text = response + .text() + .await + .map_err(|e| ApiError::NetworkError(format!("Failed to read LNURL response: {}", e)))?; + + let value = serde_json::from_str::(&text) + .map_err(|e| ApiError::InvalidInput(format!("Invalid LNURL JSON response: {} - {}", e, &text[..text.len().min(200)])))?; + handle_lnurl_error_value(&value)?; + Ok(value) +} + +/// Verify whether a Lightning Address LNURL-pay endpoint supports LNURL-verify. +pub async fn verify_lightning_address_pay_request(lightning_address: &str) -> Result<(), ApiError> { + let PaymentDestination::LightningAddress { user, domain } = PaymentDestination::parse(lightning_address)? else { + return Err(ApiError::InvalidInput("Expected Lightning Address".to_string())); + }; + let well_known = fetch_lnurl_pay(&lightning_address_to_url(&user, &domain)).await?; + let amount_msats = std::cmp::min( + std::cmp::max(100_000, well_known.min_sendable), + well_known.max_sendable, + ); + + if amount_msats < well_known.min_sendable || amount_msats > well_known.max_sendable { + return Err(ApiError::InvalidInput("Invalid LNURL sendable amount range".to_string())); + } + + let callback_response = fetch_lnurl_json_value(&callback_url_with_amount(&well_known.callback, amount_msats)?).await?; + let verify_response: LnurlVerifyPayResponse = serde_json::from_value(callback_response) + .map_err(|_| ApiError::InvalidInput("LNURL-verify endpoint is not supported".to_string()))?; + + if verify_response.pr.is_empty() || verify_response.verify.is_empty() { + return Err(ApiError::InvalidInput("LNURL-verify endpoint is not supported".to_string())); + } + + let verify_result = fetch_lnurl_json_value(&verify_response.verify).await?; + handle_lnurl_ok_value(&verify_result, "LNURL verify") +} + +pub async fn lightning_address_lnurl_verify_supported(lightning_address: &str) -> bool { + verify_lightning_address_pay_request(lightning_address).await.is_ok() +} + fn validate_invoice_amount(invoice: &str, expected_amount_msats: i64) -> Result<(), ApiError> { if expected_amount_msats < 0 { return Err(ApiError::InvalidInput( diff --git a/crates/lni/nwc/api.rs b/crates/lni/nwc/api.rs index c826fd5..de31734 100644 --- a/crates/lni/nwc/api.rs +++ b/crates/lni/nwc/api.rs @@ -1,10 +1,11 @@ -use crate::nwc::NwcConfig; +use crate::nwc::{NwcConfig, NwcLightningAddress}; use crate::types::{OnInvoiceEventCallback, OnInvoiceEventParams}; use crate::{ ApiError, CreateInvoiceParams, ListTransactionsParams, NodeInfo, Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, }; use lightning_invoice::Bolt11Invoice; +use nwc::nostr::nips::nip47; use nwc::prelude::*; use sha2::{Digest, Sha256}; use std::str::FromStr; @@ -27,12 +28,30 @@ async fn create_nwc_client(config: &NwcConfig) -> Result { Ok(nwc) } +fn nwc_error_code_to_string(code: nip47::ErrorCode) -> String { + serde_json::to_string(&code) + .map(|value| value.trim_matches('"').to_string()) + .unwrap_or_else(|_| format!("{:?}", code)) +} + +fn map_nwc_error(error: nwc::Error, fallback_prefix: &str) -> ApiError { + match error { + nwc::Error::NIP47(nip47::Error::ErrorCode(nwc_error)) => ApiError::Nwc { + code: nwc_error_code_to_string(nwc_error.code), + message: nwc_error.message, + }, + other => ApiError::Api { + reason: format!("{}: {}", fallback_prefix, other), + }, + } +} + pub async fn get_info(config: NwcConfig) -> Result { let nwc = create_nwc_client(&config).await?; // Get balance first let balance = nwc.get_balance().await - .map_err(|e| ApiError::Api { reason: format!("Failed to get balance: {}", e) })?; + .map_err(|e| map_nwc_error(e, "Failed to get balance"))?; // Try to get more info using get_info method if available let info_result = nwc.get_info().await; @@ -89,9 +108,7 @@ pub async fn get_info(config: NwcConfig) -> Result { pub async fn get_permissions(config: NwcConfig) -> Result { let nwc = create_nwc_client(&config).await?; - let info = nwc.get_info().await.map_err(|e| ApiError::Api { - reason: format!("Failed to get NWC permissions: {}", e), - })?; + let info = nwc.get_info().await.map_err(|e| map_nwc_error(e, "Failed to get NWC permissions"))?; if info.methods.is_empty() { Ok(crate::permissions::nwc_method_permissions()) @@ -100,6 +117,38 @@ pub async fn get_permissions(config: NwcConfig) -> Result Result { + let normalized_uri = config + .nwc_uri + .replace("nostrwalletconnect://", "http://") + .replace("nostr+walletconnect://", "http://") + .replace("nostrwalletconnect:", "http://") + .replace("nostr+walletconnect:", "http://"); + let uri = reqwest::Url::parse(&normalized_uri) + .map_err(|e| ApiError::Api { reason: format!("Invalid NWC URI: {}", e) })?; + let lightning_address = uri + .query_pairs() + .find(|(key, _)| key == "lud16") + .map(|(_, value)| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ApiError::InvalidInput("NWC URI does not include a lud16 Lightning Address".to_string()))?; + + match crate::lnurl::PaymentDestination::parse(&lightning_address)? { + crate::lnurl::PaymentDestination::LightningAddress { .. } => Ok(lightning_address), + _ => Err(ApiError::InvalidInput("NWC lud16 value must be a Lightning Address".to_string())), + } +} + +pub async fn get_lightning_address(config: NwcConfig) -> Result { + let lightning_address = lightning_address_from_nwc_uri(&config)?; + let lnurl_verify_supported = crate::lnurl::lightning_address_lnurl_verify_supported(&lightning_address).await; + + Ok(NwcLightningAddress { + lightning_address, + lnurl_verify_supported, + }) +} + pub async fn create_invoice(config: NwcConfig, params: CreateInvoiceParams) -> Result { let nwc = create_nwc_client(&config).await?; @@ -111,7 +160,7 @@ pub async fn create_invoice(config: NwcConfig, params: CreateInvoiceParams) -> R }; let response = nwc.make_invoice(request).await - .map_err(|e| ApiError::Api { reason: format!("Failed to create invoice: {}", e) })?; + .map_err(|e| map_nwc_error(e, "Failed to create invoice"))?; Ok(Transaction { type_: "incoming".to_string(), @@ -136,7 +185,7 @@ pub async fn pay_invoice(config: NwcConfig, params: PayInvoiceParams) -> Result< let request = PayInvoiceRequest::new(params.invoice); let response = nwc.pay_invoice(request).await - .map_err(|e| ApiError::Api { reason: format!("Failed to pay invoice: {}", e) })?; + .map_err(|e| map_nwc_error(e, "Failed to pay invoice"))?; // Compute payment hash from preimage (payment_hash = SHA256(preimage)) let payment_hash = if !response.preimage.is_empty() { @@ -203,9 +252,7 @@ pub async fn lookup_invoice( return Ok(transaction); } - return Err(ApiError::Api { - reason: format!("Failed to lookup invoice: {}", error), - }); + return Err(map_nwc_error(error, "Failed to lookup invoice")); } }; @@ -321,9 +368,7 @@ async fn list_transactions_page_raw( let response = nwc .list_transactions(request) .await - .map_err(|e| ApiError::Api { - reason: format!("Failed to list transactions: {}", e), - })?; + .map_err(|e| map_nwc_error(e, "Failed to list transactions"))?; Ok(response .into_iter() @@ -546,6 +591,22 @@ mod tests { assert!(transaction.is_none()); } + #[test] + fn maps_nip47_error_codes_to_structured_api_errors() { + let error = nwc::Error::NIP47(nip47::Error::ErrorCode(nip47::NIP47Error { + code: nip47::ErrorCode::QuotaExceeded, + message: "quota spent".to_string(), + })); + + match map_nwc_error(error, "Failed to pay invoice") { + ApiError::Nwc { code, message } => { + assert_eq!(code, "QUOTA_EXCEEDED"); + assert_eq!(message, "quota spent"); + } + other => panic!("expected structured NWC error, got {:?}", other), + } + } + #[test] fn list_transactions_request_sets_offset_and_bounds() { let request = list_transactions_request( diff --git a/crates/lni/nwc/lib.rs b/crates/lni/nwc/lib.rs index 3c67b21..9a8f37a 100644 --- a/crates/lni/nwc/lib.rs +++ b/crates/lni/nwc/lib.rs @@ -22,6 +22,14 @@ pub struct NwcConfig { pub http_timeout: Option, } +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NwcLightningAddress { + pub lightning_address: String, + pub lnurl_verify_supported: bool, +} + impl std::fmt::Debug for NwcConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NwcConfig") @@ -67,6 +75,10 @@ impl NwcNode { crate::nwc::api::get_permissions(self.config.clone()).await } + pub async fn get_lightning_address(&self) -> Result { + crate::nwc::api::get_lightning_address(self.config.clone()).await + } + pub async fn get_info(&self) -> Result { crate::nwc::api::get_info(self.config.clone()).await } @@ -164,6 +176,39 @@ mod tests { }; } + fn nwc_uri_with_lud16(lud16: &str) -> String { + format!( + "nostr+walletconnect://wallet?secret=test&relay=wss%3A%2F%2Frelay.example&lud16={}", + lud16 + ) + } + + #[test] + fn test_lightning_address_from_nwc_uri() { + let config = NwcConfig { + nwc_uri: nwc_uri_with_lud16("test%40example.com"), + ..Default::default() + }; + + let lightning_address = crate::nwc::api::lightning_address_from_nwc_uri(&config) + .expect("lud16 should parse from NWC URI"); + + assert_eq!(lightning_address, "test@example.com"); + } + + #[test] + fn test_lightning_address_from_nwc_uri_requires_lud16() { + let config = NwcConfig { + nwc_uri: "nostr+walletconnect://wallet?secret=test&relay=wss%3A%2F%2Frelay.example".to_string(), + ..Default::default() + }; + + let error = crate::nwc::api::lightning_address_from_nwc_uri(&config) + .expect_err("missing lud16 should fail"); + + assert!(format!("{:?}", error).contains("lud16")); + } + #[tokio::test] async fn test_get_info() { match NODE.get_info().await { From 80376ffdcff1c39a874f010be2ed2e295f8697b8 Mon Sep 17 00:00:00 2001 From: nicktee Date: Mon, 8 Jun 2026 20:42:01 -0500 Subject: [PATCH 2/2] 0.2.12 --- bindings/typescript-arkade/package-lock.json | 8 ++++---- bindings/typescript-arkade/package.json | 4 ++-- .../examples/spark-expo-go/package-lock.json | 2 +- bindings/typescript-spark/package-lock.json | 8 ++++---- bindings/typescript-spark/package.json | 4 ++-- bindings/typescript/package-lock.json | 4 ++-- bindings/typescript/package.json | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/bindings/typescript-arkade/package-lock.json b/bindings/typescript-arkade/package-lock.json index 9b8cc18..94d39eb 100644 --- a/bindings/typescript-arkade/package-lock.json +++ b/bindings/typescript-arkade/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni-arkade", - "version": "0.2.11", + "version": "0.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni-arkade", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@arkade-os/boltz-swap": "^0.3.3", "@arkade-os/sdk": "^0.4.4" @@ -21,12 +21,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.11" + "@sunnyln/lni": "^0.2.12" } }, "../typescript": { "name": "@sunnyln/lni", - "version": "0.2.11", + "version": "0.2.12", "dev": true, "dependencies": { "@getalby/sdk": "^7.0.0", diff --git a/bindings/typescript-arkade/package.json b/bindings/typescript-arkade/package.json index 6e402f2..c60c1d2 100644 --- a/bindings/typescript-arkade/package.json +++ b/bindings/typescript-arkade/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni-arkade", - "version": "0.2.11", + "version": "0.2.12", "private": false, "description": "Optional Arkade Boltz adapter for @sunnyln/lni.", "type": "module", @@ -52,7 +52,7 @@ "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/arkade-boltz.real.test.ts" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.11" + "@sunnyln/lni": "^0.2.12" }, "dependencies": { "@arkade-os/boltz-swap": "^0.3.3", diff --git a/bindings/typescript-spark/examples/spark-expo-go/package-lock.json b/bindings/typescript-spark/examples/spark-expo-go/package-lock.json index 98b143f..b966f4b 100644 --- a/bindings/typescript-spark/examples/spark-expo-go/package-lock.json +++ b/bindings/typescript-spark/examples/spark-expo-go/package-lock.json @@ -8295,7 +8295,7 @@ "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", + "tinyglobby": "^0.2.12", "ts-interface-checker": "^0.1.9" }, "bin": { diff --git a/bindings/typescript-spark/package-lock.json b/bindings/typescript-spark/package-lock.json index a35e233..1ab43e5 100644 --- a/bindings/typescript-spark/package-lock.json +++ b/bindings/typescript-spark/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni-spark", - "version": "0.2.11", + "version": "0.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni-spark", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", "@frosts/core": "^0.2.2-alpha.3", @@ -29,12 +29,12 @@ "node": ">=20" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.11" + "@sunnyln/lni": "^0.2.12" } }, "../typescript": { "name": "@sunnyln/lni", - "version": "0.2.11", + "version": "0.2.12", "dev": true, "dependencies": { "@getalby/sdk": "^7.0.0", diff --git a/bindings/typescript-spark/package.json b/bindings/typescript-spark/package.json index af6b980..b763539 100644 --- a/bindings/typescript-spark/package.json +++ b/bindings/typescript-spark/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni-spark", - "version": "0.2.11", + "version": "0.2.12", "private": false, "description": "Optional Spark adapter for @sunnyln/lni with browser and Expo compatible pure TypeScript signer patching.", "type": "module", @@ -56,7 +56,7 @@ "test:integration": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --env-file=../../crates/lni/.env ./node_modules/vitest/vitest.mjs run src/__tests__/integration/spark.real.test.ts" }, "peerDependencies": { - "@sunnyln/lni": "^0.2.11" + "@sunnyln/lni": "^0.2.12" }, "dependencies": { "@buildonspark/spark-sdk": "^0.6.3", diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json index 8e1310c..aef17b5 100644 --- a/bindings/typescript/package-lock.json +++ b/bindings/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sunnyln/lni", - "version": "0.2.11", + "version": "0.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sunnyln/lni", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@getalby/sdk": "^7.0.0", "@scure/base": "^2.0.0", diff --git a/bindings/typescript/package.json b/bindings/typescript/package.json index 6c29901..3daf9d2 100644 --- a/bindings/typescript/package.json +++ b/bindings/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@sunnyln/lni", - "version": "0.2.11", + "version": "0.2.12", "private": false, "description": "Lightning Node Interface. Connect to CLN, LND, Phoenixd, NWC, Strike, Speed and Blink. Supports BOLT11, BOLT12, LNURL and Lightning Address.", "type": "module",