Skip to content

feat(spark): LNURL pay and Lightning Address support for self-custodial sends#3765

Open
esaugomez31 wants to merge 5 commits intofeat--spark-stable-receive-and-send-fixesfrom
feat--spark-lnurl-pay-and-address
Open

feat(spark): LNURL pay and Lightning Address support for self-custodial sends#3765
esaugomez31 wants to merge 5 commits intofeat--spark-stable-receive-and-send-fixesfrom
feat--spark-lnurl-pay-and-address

Conversation

@esaugomez31
Copy link
Copy Markdown
Collaborator

@esaugomez31 esaugomez31 commented Apr 25, 2026

Spark: LNURL pay and Lightning Address support for self-custodial sends

What this PR does

Adds first-class support for paying to LNURL-pay endpoints and Lightning Addresses (user@domain.com) from a self-custodial Spark wallet. The custodial flow already handled these via the lnurl-pay library + lnInvoicePaymentSend mutation, but in self-custodial mode the destination resolver was incorrectly routing both formats to the Bolt11 path (which the SDK can't decode), so the prepare step always failed. This PR routes LNURL through the SDK's native prepareLnurlPay / lnurlPay methods, mapping the destination shape from the lnurl-pay library to the SDK contract, and propagates the SDK-returned SuccessAction back to the completed screen.

Bridge — new SDK wrappers

Three small wrappers added to app/self-custodial/bridge/send.ts next to the existing prepareSend / executeSend helpers, following the same pattern (one wrapper per SDK method, plus an extract helper for the fee):

  • prepareLnurl(sdk, options) wraps sdk.prepareLnurlPay. The PrepareLnurlOptions type only exposes the fields the app actually uses today (amount, payRequest, optional comment, tokenIdentifier, conversionOptions, feePolicy). The SDK's other knobs (validateSuccessActionUrl, etc.) are left at default.
  • executeLnurl(sdk, prepared, idempotencyKey?) wraps sdk.lnurlPay.
  • extractLnurlFee(prepared) returns feeSats as a plain number, mirroring extractLightningFee for symmetry at call sites.

Payment-details — new self-custodial LNURL detail

New file app/self-custodial/payment-details/lnurl.ts exporting createSelfCustodialLnurlPaymentDetails. It produces a PaymentDetail<T> that satisfies the shared screen contract (with paymentType: PaymentType.Lnurl, lnurlParams, setInvoice, setSuccessAction, isMerchant) and internally talks to the SDK via the new bridge wrappers.

The piece that took the longest to get right is the prepare-options shape, because the SDK enforces a specific combination depending on the source asset:

Source wallet amount units tokenIdentifier conversionOptions feePolicy
BTC sats undefined undefined undefined
USD (USDB) USDB base units (cents × 10⁴) configured USDB id ToBitcoin FeesIncluded

For USD wallets the SDK rejects any other feePolicy with "Token conversion with token_identifier requires FeesIncluded fee policy", so this is mandatory. The behavioral consequence is documented under "Fee semantics" below.

A small helper lnurlParamsToPayRequest converts LnUrlPayServiceResponse (sats + parsed metadata array, from the lnurl-pay lib used by the existing custodial parser) into LnurlPayRequestDetails (millisats + raw metadata string, the SDK shape). The metadata string is taken verbatim from lnurlParams.rawData.metadata when present so the LUD-06 description-hash check inside the SDK matches the server's original payload byte-for-byte; if rawData.metadata isn't a string it falls back to JSON.stringify(lnurlParams.metadata) which is good enough for endpoints that don't expose the raw form.

SuccessActionProcessed returned by lnurlPay (the SDK has already deciphered AES payloads server-side) is mapped back to the LNURLPaySuccessAction shape that lnurl-pay exposes, so the existing completed-screen code paths render it without changes. AES decipher(preimage) returns the SDK-supplied plaintext directly since decryption already happened.

Routing — wrap-destination.ts

The Lnurl branch is split off from Lightning. Previously both shared buildLightningDetail, which fed destination.lnurl (a bech32 LNURL string or LN Address) into createSelfCustodialLightningPaymentDetails as the paymentRequest — the SDK can't decode that, so the very first prepareSend call failed with InvalidInput. The new buildLnurlDetail calls createSelfCustodialLnurlPaymentDetails with lnurl, lnurlParams, isMerchant, and seeds unitOfAccountAmount from lnurlParams.min (minimum sendable in sats) so the screen can render an initial value.

The LnurlDestination Extract type is narrowed by lnurlParams: LnUrlPayServiceResponse to exclude LnurlWithdrawDestination (Receive direction), keeping TypeScript happy on the runtime guard already enforced by wrapDestinationForSC.

Screen integrations

Send-bitcoin destination screen

A single-line gate: when self-custodial, pass lnurlDomains: [] to parseDestination so Blink-owned LN addresses (user@blink.sv) are not silently optimized into the Intraledger payment type. That optimization is custodial-specific (the destination becomes an internal-ledger transfer that requires backend auth, which the self-custodial wallet does not have); without this gate paying to a Blink LN Address from a self-custodial wallet would fail with Not authorized after pressing Next. With the gate, the same input flows through the regular LNURL path and resolves through the new SC LNURL detail.

Send-bitcoin details screen

The existing custodial flow runs requestInvoice(...) from the lnurl-pay library on Next to materialize a Bolt11 invoice before showing the confirmation screen. Self-custodial doesn't need that step because the SDK fetches the invoice internally as part of prepareLnurlPay. One-line gate: only run the requestInvoice block when paymentDetail.sendPaymentMutation is undefined (custodial LNURL details start with canSendPayment: false until setInvoice is called; self-custodial LNURL details have a working sendPaymentMutation from the moment the user picks an amount).

Send-bitcoin confirmation screen

Two changes:

  1. PaymentSendExtraInfo now carries an optional successAction. When the SC LNURL sendPaymentMutation resolves, it includes the SDK's successAction in extraInfo (mapped to the lnurl-pay shape). The confirmation screen prefers extraInfo?.successAction ?? paymentDetail?.successAction when navigating to the completed screen, so the post-payment message/URL/AES disclosure shows up for both flows. Custodial keeps populating paymentDetail.successAction ahead of sendPayment, so the fallback covers it.

  2. saveLnAddressContact is skipped when useActiveWallet().isSelfCustodial is true. That helper hits the contactCreate GraphQL mutation, which requires backend auth; without the gate a successful self-custodial LNURL payment was followed by a visible Not authorized error toast even though the payment itself had completed. Custodial users keep the existing behavior.

Fee semantics

For the BTC self-custodial wallet, feePolicy is left as the SDK default (FeesExcluded): the recipient receives the exact sats amount the user picked and the routing fee is added on top. This matches the custodial Lightning behavior.

For the USD self-custodial wallet, the SDK requires FeesIncluded whenever a tokenIdentifier + conversionOptions combination is used, so the user spends an exact USDB amount and the recipient receives the post-conversion, post-routing-fee BTC value. In practical terms the recipient gets ~1% less than the headline amount; that gap is the Spark conversion-pool spread (invisible in the UI) plus a few sats of LN routing (visible). This is a hard SDK constraint, not a design choice — a manual two-step (convert USDB → BTC first, then pay LNURL with BTC) could match custodial semantics but would require a separate transaction and rollback handling, which is out of scope for this PR.

Tests

All specs scoped to the affected files; full suite passes (3438/3438).

  • __tests__/self-custodial/bridge/send.spec.ts — added 14 cases covering prepareLnurl (forwarding amount/payRequest/comment, USD-wallet shape with tokenIdentifier+conversionOptions+feePolicy, BTC-wallet shape with neither), extractLnurlFee (number coercion + zero), executeLnurl (idempotency key forwarding).
  • __tests__/self-custodial/payment-details/lnurl.spec.ts — new, 22 cases covering payment type, amount handling (fixed via min===max vs range, setAmount), memo handling (description vs sender memo, setMemo), the per-currency prepareOptions matrix, comment gating by commentAllowed, getFee success/failure, sendPaymentMutation success with all three SuccessAction variants (Message, URL, AES decipher passthrough), error classification, raw metadata preservation, and millisat conversion.
  • __tests__/self-custodial/payment-details/wrap-destination.spec.ts — updated the existing Lnurl case to assert routing through the new SC LNURL detail (not the lightning detail), plus a merchant-flag propagation case.
  • __tests__/screens/send-confirmation.spec.tsx — added a case that mocks useActiveWallet to return { isSelfCustodial: true } and asserts saveLnAddressContact is not called after a successful LNURL payment.

Breaking notes

None for consumers. The shared PaymentDetail types gain an optional successAction field on PaymentSendExtraInfo. All in-tree senders are unaffected because the field is optional and the confirmation screen uses ?? fallback.

Manual verification done

  • Lightning Address from a USD wallet: deepbassoon958@walletofsatoshi.com for $1 — pays correctly, recipient receives the converted amount, fee shown ($0.00 / 5 SAT), completed screen renders with the description.
  • Lightning Address from a BTC wallet (500 sats) — pays correctly, recipient receives the exact 500 sats, fee charged on top.
  • Bech32 LNURL from esaudeveloper@blink.sv — pays correctly via the new SC LNURL path (Intraledger optimization disabled in SC mode).

The original implementation iterations and the SDK's exact tokenIdentifier / conversionOptions / feePolicy requirements are documented at length in commit messages and tests.

@esaugomez31 esaugomez31 changed the title feat(self-custodial): add bridge wrappers for prepareLnurlPay and lnurlPay Spark: LNURL pay and Lightning Address support for self-custodial sends Apr 25, 2026
@esaugomez31 esaugomez31 marked this pull request as ready for review April 25, 2026 15:17
@esaugomez31 esaugomez31 self-assigned this Apr 25, 2026
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-receive-and-send-fixes branch from 1d89661 to 8e3715b Compare April 26, 2026 18:14
@esaugomez31 esaugomez31 force-pushed the feat--spark-lnurl-pay-and-address branch 2 times, most recently from 0ae3fd6 to f5d0a4a Compare April 28, 2026 16:27
@grimen grimen changed the title Spark: LNURL pay and Lightning Address support for self-custodial sends feat(spark): LNURL pay and Lightning Address support for self-custodial sends Apr 28, 2026
@esaugomez31 esaugomez31 force-pushed the feat--spark-lnurl-pay-and-address branch from f5d0a4a to d091465 Compare May 6, 2026 02:05
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-receive-and-send-fixes branch from c930670 to 8646588 Compare May 6, 2026 02:05
@esaugomez31 esaugomez31 force-pushed the feat--spark-lnurl-pay-and-address branch from d091465 to 66963d9 Compare May 7, 2026 15:54
@esaugomez31 esaugomez31 force-pushed the feat--spark-stable-receive-and-send-fixes branch from 8646588 to 5bf699b Compare May 7, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant