feat(spark): LNURL pay and Lightning Address support for self-custodial sends#3765
Conversation
1d89661 to
8e3715b
Compare
0ae3fd6 to
f5d0a4a
Compare
f5d0a4a to
d091465
Compare
c930670 to
8646588
Compare
d091465 to
66963d9
Compare
8646588 to
5bf699b
Compare
grimen
left a comment
There was a problem hiding this comment.
Review — bugs + test coverage
Important
This PR (and the stack it sits in) needs a structural cleanup pass. Findings deferred from this round:
- Adapter-pattern leakage —
isSelfCustodialis special-cased at three different screen call sites in this PR alone (send-bitcoin-destination-screen.tsx:373for thelnurlDomainsgate,send-bitcoin-details-screen.tsx:365for therequestInvoiceskip,send-bitcoin-confirmation-screen.tsxfor thesaveLnAddressContactskip). The shape is "if SC, opt out of the custodial behaviour" rather than going through a uniform Port. This is the same leak the parent stack's adapter pattern was meant to eliminate. - SOLID violations —
lnurl.tsand the existinglightning.tsfactory both producePaymentDetail<T>but share zero code: each has its own error classifier path, its ownprepareOptionsbuilder, its own currency-conversion logic, its own success-action mapping. Bridge wrappers (prepareLnurl/executeLnurl/extractLnurlFee) mirror the lightning ones structurally but live as duplicated functions.PaymentSendExtraInfo(shared custodial+SC) now carries an optionalsuccessAction— a cross-cutting concern bleeding into a shared type. - High cyclomatic / closure complexity —
payment-details/lnurl.tsis one ~270 LOC file holdinglnurlParamsToPayRequest,extractMetadataStr,sdkSuccessActionToLib(4 variants),centsToTokenBaseUnits, the BTC-vs-USDprepareOptionsbranch,createGetFee(try/catch + classifier),sendPaymentMutation(try/catch + classifier + extraInfo build), plus fourset*immutable rebuilders that each close over the entire factory call. Three screens gain anotherisSelfCustodialbranch on top of the parent stack's already-noted 60+ mode branches across 11 files. - Code smells —
as unknown as WalletAmount<T>(I7);decipher: () => decrypted.plaintextis a callback designed never to be called (combined with #1, this is the bug); hard-codedcentsToTokenBaseUnits(cents, 6, 2)magic numbers (USDB decimals + cents decimals) inline in the factory rather than behind named constants;executeLnurl(..., idempotencyKey?)ships a parameter the only caller never passes (Critical #2 — the function signature implies a contract that isn't enforced). - Naming smells — inconsistent bridge verb pattern:
prepareSend/executeSend(verb+direction) vs newprepareLnurl/executeLnurl(verb+method);extractLightningFeeandextractLnurlFeeexist as separate functions despite returning identicalfeeSats: number;lnurlParamsToPayRequestreflects neither source (LnUrlPayServiceResponse) nor destination (LnurlPayRequestDetails) type name. Separately on the sharedPaymentDetailshape — having a uniform shape is correct (adapter mindset), but field names still carry custodial-flow vocabulary:sendPaymentMutation(Apollo'suseMutationreturn type — for SC it's just a closure, not a mutation);setInvoice(custodial pre-confirmation step where a BOLT11 invoice gets materialised — SC implements as a no-op solely to fit the contract). Rename to mode-neutral verbs (sendPayment,setResolvedRequest?), or modelsetInvoiceas optional rather than mandating a no-op. Keep the shape shared.
Why this is not in scope for this PR: #3765 is part of the same feat--spark-* stack as #3758 (bases on feat--spark-stable-receive-and-send-fixes, sits in the #3761–#3769 range). Doing the refactor here would force the same multi-day restack across the downstream PRs that #3758's review deferred. Refactoring against main after the stack settles is materially cheaper.
Decision: this review covers correctness + test coverage only. The structural / SOLID / complexity / naming review folds into the same cleanup ticket already opened against #3758 — adapter-pattern unification milestone, then renames, landed as a sequence of small focused PRs against main. Production rollout (#3768) should remain gated on the cleanup ticket reaching the adapter-unification milestone, otherwise the duplication ships and gets normalised.
Real strengths first: bridge-layer wrapper symmetry — prepareLnurl / executeLnurl / extractLnurlFee (bridge/send.ts) mirror the existing prepareSend / executeSend / extractLightningFee 1:1; lnurlParamsToPayRequest (payment-details/lnurl.ts:39-43) preserves rawData.metadata verbatim with a sound typeof === "string" guard so the SDK's LUD-06 description-hash check matches the server bytes; the BTC-vs-USD prepareOptions matrix is a single ?: on isUsdSend (no fall-through, no swap); the lnurlDomains: [] gate (send-bitcoin-destination-screen.tsx:373) correctly disables the intraledger optimisation only when isSelfCustodial; the lnurl.spec.ts 22-case spec is genuinely not padded — each test asserts a distinct branch (BTC vs USD shape, sat→millisat math, raw-vs-stringified metadata, comment matrix, fixed-vs-range amount, three SuccessAction variants, error classification). Two manual flows verified end-to-end (LN address from USD wallet + bech32 LNURL from @blink.sv) and 3438/3438 passing.
Severity:
- Critical — fund-loss, mis-attribution, or silent failure that misleads the user. Must fix before merge.
- Important — correctness/reliability that doesn't lose funds but degrades UX or hides incidents.
- Test — coverage gap or test that passes while a confirmed bug is alive.
Critical
1. AES success-action plaintext silently dropped on completed screen (SC only)
app/self-custodial/payment-details/lnurl.ts:91-101 + consumer app/screens/send-bitcoin-screen/send-bitcoin-completed-screen.tsx:75-92
The SC mapping stuffs the SDK-provided plaintext into the lnurl-pay decipher() callback while leaving ciphertext: null, iv: null. But useSuccessMessage does not call successAction.decipher(preimage) — it calls utils.decipherAES({ successAction, preimage }) from lnurl-pay, which short-circuits to null whenever ciphertext or iv is missing (verified at node_modules/lnurl-pay/dist/cjs/index.js:290-301). For SC AES success actions, decryptedMessage is therefore always null and textContent reduces to the public description — the actual decrypted secret / coupon / voucher payload (the whole point of the AES variant) is never shown to the user. Custodial users see it. Also: SC sendPaymentMutation doesn't propagate preimage in extraInfo, so even a future fix that called decipher() here would have nothing to pass it.
Fix: put the SDK plaintext into a real field (e.g. message) so the existing [message, description, decryptedMessage].filter(Boolean).join(" ") join captures it. Don't rely on a decipher() callback that is never invoked.
2. Idempotency key is dead at the only caller
app/self-custodial/payment-details/lnurl.ts:189 + app/self-custodial/bridge/send.ts (executeLnurl)
executeLnurl(sdk, prepared) is called with two args — the third positional idempotencyKey is always undefined. The bridge wrapper does forward an idempotency key when given one, and the bridge spec covers that forwarding correctly, so the surface looks supported and tested. But the only production caller never passes one. A network-flake retry that re-runs sendPaymentMutation against the same prepared response is therefore protected only by recipient-side replay logic. Compare the existing custodial lightning send for the expected pattern.
Fix: thread an idempotency key (paymentRequest hash, or a UUID held alongside paymentDetail) into createSelfCustodialLnurlPaymentDetails and pass it to executeLnurl. If intentionally deferred, comment the dead parameter and open a tracking issue — right now both the parameter and the test suggest it works.
Important
| # | Item | Location |
|---|---|---|
| I1 | sendPaymentMutation error classification — only LnurlError → InvalidInput is exercised; NetworkError and generic branches in the classifier are not asserted |
app/self-custodial/payment-details/lnurl.ts:198-206 |
| I2 | AES ErrorStatus branch in sdkSuccessActionToLib exists but is never exercised; a typo like result.inner.message instead of .reason would crash the success screen on real-world AES failures |
app/self-custodial/payment-details/lnurl.ts:104-117 |
| I3 | extractLnurlFee returns 0 for NaN/undefined via toNumber. Acceptable for the USD FeesIncluded path where fee genuinely is 0, but masks a SDK-side regression that would render "0 sats fee" instead of erroring |
app/self-custodial/bridge/send.ts (extractLnurlFee) |
| I4 | unitOfAccountAmount: toBtcMoneyAmount(destination.lnurlParams.min || 0) — works today, but a refactor to ?? semantics on a malformed min: undefined is silently absorbed; no test covers that input |
app/self-custodial/payment-details/wrap-destination.ts:80 |
| I5 | commentAllowed > 0 && memo === "" correctly yields comment: undefined in production but is not exercised by tests; only the two extremes (commentAllowed: 0 + memo, commentAllowed: 200 + memo) are covered |
app/self-custodial/payment-details/lnurl.ts:163 |
| I6 | SC sendPaymentMutation returns { status, extraInfo } only — no transaction or preimage. Completed screen lacks createdAt (no time field) and preimage (also blocks any future fix to #1 that wants to re-decrypt locally) |
app/self-custodial/payment-details/lnurl.ts (return shape of sendPaymentMutation) |
| I7 | as unknown as WalletAmount<T> in asBtcSettlementAmount. For USD wallet, fee comes back currency: Btc; downstream convertMoneyAmount dispatches on the runtime currency field so there's no miscalculation, but the type lie is a foot-gun for future readers |
app/self-custodial/payment-details/lnurl.ts |
| I8 | LnurlDestination Extract narrows on lnurlParams: LnUrlPayServiceResponse to exclude withdraw shapes — correct today, but the runtime guard at wrapDestinationForSC only checks direction. If upstream parsing ever returns a withdraw-shaped lnurlParams with paymentType: Lnurl + direction: Send, the factory reads min/max off a shape that doesn't have them |
app/self-custodial/payment-details/wrap-destination.ts |
Test coverage
Estimated behavioural coverage of the new LNURL surface: ~75%, strong on the bridge layer and lnurl.ts factory, sparse on the three screen edits (only one screen test added across them).
Tier 1 — production code with no test file (or no coverage of the new branch)
| File | LOC delta | Notes |
|---|---|---|
app/screens/send-bitcoin-screen/send-bitcoin-destination-screen.tsx:373 |
1-line gate | The lnurlDomains: isSelfCustodial ? [] : [lnAddressHostname, ...LNURL_DOMAINS] branch. No screen test asserts what parseDestination is called with. Regression silently misroutes Blink LN addresses (custodial loses intraledger fast path; SC re-introduces the original Not authorized failure) |
app/screens/send-bitcoin-screen/send-bitcoin-details-screen.tsx:365 |
1-line gate | The paymentDetail.paymentType === "lnurl" && !paymentDetail.sendPaymentMutation skip gate. No screen test on either side. Regression either runs requestInvoice against the SC SDK or skips it for custodial — both visible to users on the happy path |
app/screens/send-bitcoin-screen/send-bitcoin-confirmation-screen.tsx:212 |
1-line ?? |
The extraInfo?.successAction ?? paymentDetail?.successAction precedence. The single screen test only covers saveLnAddressContact skip; no assertion on what successAction is forwarded to navigation. Reversed ?? operands silently break the most user-visible LNURL feature in SC |
Tier 2 — tests that pass while a Critical bug is alive
| Test file | What it should catch but doesn't | Bug |
|---|---|---|
__tests__/self-custodial/payment-details/lnurl.spec.ts (AES case) |
Asserts decipher(preimage) === plaintext at the factory layer, but does not exercise the consumer (useSuccessMessage) which actually ignores decipher. The bug manifests at the consumer; the unit test is structurally blind to it |
#1 |
__tests__/self-custodial/bridge/send.spec.ts (idempotency forwarding) |
Asserts the bridge wrapper forwards a key when given one. The bug is at the caller in lnurl.ts:189 which never passes one. No integration-level test catches the dead parameter |
#2 |
__tests__/self-custodial/payment-details/lnurl.spec.ts (error classification) |
Only tests LnurlError tag. Any classifier shape regression on NetworkError / generic branches passes silently |
I1 |
__tests__/self-custodial/payment-details/lnurl.spec.ts (AES ErrorStatus) |
Only the result.tag === "Decrypted" branch is exercised; ErrorStatus is dead from the suite's perspective despite being mapped in production |
I2 |
Tier 3 — Critical bugs with no test coverage anywhere
| Critical | Recommended test |
|---|---|
| #1 | End-to-end test rendering send-bitcoin-completed-screen with an SC AES success action; assert the decrypted plaintext appears in textContent, not just the description |
| #2 | lnurl.ts factory test: spy on executeLnurl, assert it was called with a defined idempotency key. The test should fail today — write it, then thread the key in or document the deferral |
| Screen gates (×3) | One screen test per gate (destination lnurlDomains, details requestInvoice skip, confirmation successAction precedence) — assert both the SC-true and SC-false branches |
Suggested order of operations
- Fix #1 by populating
message(or the equivalent rendered field) directly with the SDK plaintext. Add a Tier 3 regression test drivinguseSuccessMessageend-to-end on an SC AES action. - Decide on #2 — thread the idempotency key in (preferred; the bridge already supports it) or comment the dead parameter and open a tracking issue. Add the Tier 3 spy test in either case.
- Add the three screen-level tests (Tier 1) — small surface, three independent regressions, highest leverage tests in this PR.
- Round out the lnurl spec matrices —
NetworkError+ generic branches (I1), AESErrorStatus(I2),commentAllowed > 0 + empty memo(I5). All small. - Important items I3–I8 are minor; fold into the same commits as the above or leave for the post-stack cleanup pass.
5bf699b to
77345a0
Compare
66963d9 to
2ad6b4e
Compare
77345a0 to
c655288
Compare
7898345 to
92ca1db
Compare
Critical1. AES success-action plaintext silently dropped on completed screen (SC only)
2. Idempotency key is dead at the only caller
ImportantI1.
|
grimen
left a comment
There was a problem hiding this comment.
Yet to be refactored to fully respect SOLID (i.e. ~adapter pattern), but approving since critical code/test issues were addressed so that we can get this into internal testing asap.
Merge activity
|
0f63f72 to
abee102
Compare
…onversion support
…nfo and skip contact-create in self-custodial mode
…k LN addresses in self-custodial mode
…as its own send mutation
… field for SC LNURL sends
…h executeLnurl for retry-safe SC sends
… lnurlDomains, details requestInvoice skip, confirmation successAction precedence)
…ds via SDK PaymentDetails.Lightning helper
5d734a2 to
9e4a0c3
Compare
0f63f72 to
c6bfcc1
Compare
…tination and bridge.send
9e4a0c3 to
98becec
Compare

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.