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
Open
Conversation
This was referenced Apr 25, 2026
Collaborator
Author
1d89661 to
8e3715b
Compare
0ae3fd6 to
f5d0a4a
Compare
This was referenced Apr 29, 2026
f5d0a4a to
d091465
Compare
c930670 to
8646588
Compare
…onversion support
…nfo and skip contact-create in self-custodial mode
…k LN addresses in self-custodial mode
…as its own send mutation
d091465 to
66963d9
Compare
8646588 to
5bf699b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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 thelnurl-paylibrary +lnInvoicePaymentSendmutation, 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 nativeprepareLnurlPay/lnurlPaymethods, mapping the destination shape from the lnurl-pay library to the SDK contract, and propagates the SDK-returnedSuccessActionback to the completed screen.Bridge — new SDK wrappers
Three small wrappers added to
app/self-custodial/bridge/send.tsnext to the existingprepareSend/executeSendhelpers, following the same pattern (one wrapper per SDK method, plus an extract helper for the fee):prepareLnurl(sdk, options)wrapssdk.prepareLnurlPay. ThePrepareLnurlOptionstype only exposes the fields the app actually uses today (amount,payRequest, optionalcomment,tokenIdentifier,conversionOptions,feePolicy). The SDK's other knobs (validateSuccessActionUrl, etc.) are left at default.executeLnurl(sdk, prepared, idempotencyKey?)wrapssdk.lnurlPay.extractLnurlFee(prepared)returnsfeeSatsas a plain number, mirroringextractLightningFeefor symmetry at call sites.Payment-details — new self-custodial LNURL detail
New file
app/self-custodial/payment-details/lnurl.tsexportingcreateSelfCustodialLnurlPaymentDetails. It produces aPaymentDetail<T>that satisfies the shared screen contract (withpaymentType: 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:
amountunitstokenIdentifierconversionOptionsfeePolicyToBitcoinFeesIncludedFor USD wallets the SDK rejects any other
feePolicywith"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
lnurlParamsToPayRequestconvertsLnUrlPayServiceResponse(sats + parsed metadata array, from the lnurl-pay lib used by the existing custodial parser) intoLnurlPayRequestDetails(millisats + raw metadata string, the SDK shape). The metadata string is taken verbatim fromlnurlParams.rawData.metadatawhen present so the LUD-06 description-hash check inside the SDK matches the server's original payload byte-for-byte; ifrawData.metadataisn't a string it falls back toJSON.stringify(lnurlParams.metadata)which is good enough for endpoints that don't expose the raw form.SuccessActionProcessedreturned bylnurlPay(the SDK has already deciphered AES payloads server-side) is mapped back to theLNURLPaySuccessActionshape thatlnurl-payexposes, so the existing completed-screen code paths render it without changes. AESdecipher(preimage)returns the SDK-supplied plaintext directly since decryption already happened.Routing —
wrap-destination.tsThe
Lnurlbranch is split off fromLightning. Previously both sharedbuildLightningDetail, which feddestination.lnurl(a bech32 LNURL string or LN Address) intocreateSelfCustodialLightningPaymentDetailsas thepaymentRequest— the SDK can't decode that, so the very firstprepareSendcall failed withInvalidInput. The newbuildLnurlDetailcallscreateSelfCustodialLnurlPaymentDetailswithlnurl,lnurlParams,isMerchant, and seedsunitOfAccountAmountfromlnurlParams.min(minimum sendable in sats) so the screen can render an initial value.The
LnurlDestinationExtract type is narrowed bylnurlParams: LnUrlPayServiceResponseto excludeLnurlWithdrawDestination(Receive direction), keeping TypeScript happy on the runtime guard already enforced bywrapDestinationForSC.Screen integrations
Send-bitcoin destination screen
A single-line gate: when self-custodial, pass
lnurlDomains: []toparseDestinationso 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 withNot authorizedafter 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 thelnurl-paylibrary onNextto 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 ofprepareLnurlPay. One-line gate: only run the requestInvoice block whenpaymentDetail.sendPaymentMutationis undefined (custodial LNURL details start withcanSendPayment: falseuntilsetInvoiceis called; self-custodial LNURL details have a workingsendPaymentMutationfrom the moment the user picks an amount).Send-bitcoin confirmation screen
Two changes:
PaymentSendExtraInfonow carries an optionalsuccessAction. When the SC LNURLsendPaymentMutationresolves, it includes the SDK'ssuccessActioninextraInfo(mapped to the lnurl-pay shape). The confirmation screen prefersextraInfo?.successAction ?? paymentDetail?.successActionwhen navigating to the completed screen, so the post-payment message/URL/AES disclosure shows up for both flows. Custodial keeps populatingpaymentDetail.successActionahead ofsendPayment, so the fallback covers it.saveLnAddressContactis skipped whenuseActiveWallet().isSelfCustodialis true. That helper hits thecontactCreateGraphQL mutation, which requires backend auth; without the gate a successful self-custodial LNURL payment was followed by a visibleNot authorizederror toast even though the payment itself had completed. Custodial users keep the existing behavior.Fee semantics
For the BTC self-custodial wallet,
feePolicyis 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
FeesIncludedwhenever atokenIdentifier+conversionOptionscombination 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 coveringprepareLnurl(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 bycommentAllowed, 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 mocksuseActiveWalletto return{ isSelfCustodial: true }and assertssaveLnAddressContactis not called after a successful LNURL payment.Breaking notes
None for consumers. The shared
PaymentDetailtypes gain an optionalsuccessActionfield onPaymentSendExtraInfo. All in-tree senders are unaffected because the field is optional and the confirmation screen uses??fallback.Manual verification done
deepbassoon958@walletofsatoshi.comfor $1 — pays correctly, recipient receives the converted amount, fee shown ($0.00 / 5 SAT), completed screen renders with the description.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/feePolicyrequirements are documented at length in commit messages and tests.