Skip to content

feat: add spark via breez sdk - #1128

Draft
ovitrif wants to merge 1 commit into
masterfrom
feat/breez-spark-lightning
Draft

ovitrif wants to merge 1 commit into
masterfrom
feat/breez-spark-lightning

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Twin: synonymdev/bitkit-android#1697

This PR keeps Lightning in Bitkit after Blocktank stopped selling channels: on top of the Blocktank sunset (#1127), Spark becomes the easy option where Transfer to Spending now leads to manual setup. Savings to Spending funds a Breez SDK on Spark wallet, and Lightning sends and receives fall back to it whenever no channel can carry the payment. Existing channels keep working until they close, and ldk-node keeps running the on-chain wallet.

Emergency exit, leaving Spark on-chain without its operators, moved to #1217, stacked on this PR.

Description

Transfer to Spending on Spark

  • Brings back the Blocktank-era Transfer to Spending flow on Spark, with its layout and copy: the Transfer intro (Home suggestion card → Funding) and Spending intro slides with their seen flags (backed up again), the amount screen with 25% / Max / the unit toggle, the confirm screen with swipe to transfer and Learn More, and the setting-up animation screen. While Spark is ready, the Funding screen leads with Transfer from Savings (a No Funds alert at a 0 Savings balance) and Use Other Wallet above Manual Setup, and every Transfer to Spending entry opens this flow instead of the Transfer Unavailable sheet. Without Spark, everything stays as the sunset left it. One SparkTransferFlow model drives the screens; the Spark-only amount, confirm and deposit screens are gone.
  • Amount: Max is the most Savings can send to the Spark deposit address after the fast-rate network fee; the Available row loads behind a spinner with the number pad held, an amount over Max snaps to it with the Spending Balance Maximum toast, a 0 balance caps at 0, and a failed fee estimate shows "Couldn't get the network fee. Tap to try again." instead of a guess. The screen shows the offline overlay.
  • Confirm: the four tiles stay. Network fees is the on-chain fee to the deposit address, priced on appear, whenever the amount or speed changes, every minute, and again at the swipe; a swipe whose fresh price costs more shows "Fees changed" and resets, so a stale quote is never sent. Spending fee replaces the service fee, To spending is the amount less the selected claim fee, and Total is what leaves Savings. Between the tiles and Learn More, two rows in the send flow's fee-speed style pick the speed: Instant, "Ready in a few minutes", up to the existing claim ceiling (fastest rate plus 2 sat/vB times 99 vB, plus 0.2%), the default; or Later, "After 3 confirmations, about 30 minutes", about the half-hour rate times 99 vB. The Advanced/Default buttons, the channel bar and the background-setup toggle are gone. A quote failure shows its reason with Retry.
  • Learn More keeps its layout (title, text, balance bar, Understood) with Spark copy: Instant pays the provider to add the transfer before it confirms, Later waits for 3 confirmations and costs less, plus the Spark trust note; the bar is the spending-only Spark bar for the balance after this transfer.
  • Claims: the SDK's deposit fee cap drops its proportional part (recommended rate plus 2 sat/vB), so it claims at maturity by itself but never early without the user's choice. After the broadcast, setting-up polls the deposit every 5 s for up to 5 minutes. Instant with an early offer within the ceiling claims it at once ("Spark is adding it to your spending balance", then the success screen); an early fee above the ceiling, or a repriced claim, shows "Instant now costs X" with Pay X and Wait (Wait falls back to Later), never swapping the fee silently; no early offer, or a declined one, says Instant isn't available and Spark adds it after 3 confirmations; an offer not yet available keeps polling; Later makes no claim call; a deposit Spark never lists in 5 minutes says it hasn't seen it yet and that the user can leave. The chosen speed shows under the text, and Continue goes Home.
  • Brings back Transfer to Spending from a paired hardware wallet (intro, amount from the device balance, sign on the device with the same tiles and speed rows, Transaction signed, then setting-up): master's HwSendCoordinator.signAndBroadcast already signs and broadcasts an on-chain send from the device to any address for Send, so the device pays the Spark deposit address, recorded as a transfer in the hardware wallet's activity.

Spark wallet and Lightning

  • Adds the Breez SDK on Spark (breez-sdk-spark-swift 0.27.2, exact) with SparkManager, so that a Spark wallet starts beside the node from the same recovery phrase and passphrase. Spark derives its own keys on m/8797555', so there is no new phrase to back up.
  • Turns off Breez real-time data sync (realTimeSyncServerUrl = nil) and the LNURL server (lnurlDomain = nil) in Spark's config on every network, so the app sends no traffic to Breez's data-sync or breez.tips LNURL servers, and no Lightning address is offered (aa01843).
  • Routes Lightning receives to Spark when no ready channel has the inbound capacity, so the receive sheet shows a Lightning invoice again without channels.
  • Routes Lightning sends and their fee estimates to Spark when no channel has the outbound capacity and the Spark balance covers the amount, so users without channels can pay invoices.
  • Raises Spark payments as the node's own payment events while the node runs and records them as Lightning activity, so the received sheet, send success screen and activity list work for both rails. Routing goes through a hook in LightningService that only the app sets, so the notification extension never loads Breez.
  • Adds the Spark balance to the Spending balance; the most a payment can send is the larger of the channel and Spark balances, since one payment cannot split across rails. Deposits Spark has seen but not yet claimed show as in transfer to Spending.
  • Withdraws the whole Spark balance to an on-chain address in Spending to Savings when Spark is selected, so that the transfer works for users with only a Spark balance.
  • Brings back the Advanced list on Transfer to Savings with Spark as one more row: every row starts selected, Spark withdraws only when its row is, and the confirm screen shows Spark's withdrawal fee only then.
  • Prices Spark's withdrawal fee once per balance and gives up after 15 s, and the swipe withdraws to the same Savings address with the quote the confirm screen showed, because a second full-balance quote can take minutes.
  • Shows a failed Spark withdrawal on the progress screen instead of success, with its error; when the selected channels still moved, it says only Spark's balance stayed in Spending.
  • Matches a Spark withdrawal's arrival in Savings by its txid, so it shows in Activity as a transfer from Spending and no longer opens the received sheet.
  • Reads BREEZ_API_KEY from an optional, gitignored Secrets.xcconfig through a new Bitkit.xcconfig base config; copy Secrets.xcconfig.template and paste the key (README, Build secrets). Without the key, mainnet builds keep Spark off; regtest builds use Lightspark's hosted regtest, which needs no key. TestFlight is archived locally (Docs/RELEASE.md §2), so the releaser adds the file first; a Release-only build phase fails the build when the key is empty, so Spark can't ship switched off, and Docs/RELEASE.md and the release command carry the step.
  • Shows Spark at the top of Lightning Connections like a channel: its balance and each deposit in progress with its confirmations and a link to the transaction. Its detail screen shows the provider with a short trust note, pending and claimed deposits with the claim fee, and Transfer to Savings.
  • Pays a Lightning invoice from another Spark wallet as a Spark transfer on regtest, where Spark has no Lightning network, so two dev builds can pay each other's invoices; dev builds also show Spark's deposit address on the Spark screen, to fund it from Lightspark's regtest faucet.
  • Lists a Transfer to Spending deposit as pending in Lightning Connections and the Spending balance as soon as the app broadcasts it, from the txid it already has, and asks Spark to sync at once; Spark's own deposit state takes over when the SDK lists it. Before, the deposit showed only after the SDK's next background sync, up to 60 seconds later.
  • Draws Spark's balance bar as one full-width spending bar and shows receiving as No limit in the Lightning Connections header, in the same style and with the same arrow as the spending figure, whenever Spark is ready, whatever its balance or channels, because Spark receives without inbound liquidity; Receive's amount check no longer asks for more liquidity or a CJIT while Spark can receive.
  • Pays a scanned Lightning invoice through Spark when there are no channels: the scan check no longer rejects it before Spark is considered, and the insufficient-balance toasts count Spark's balance. Drops the connection counter on Settings › Advanced › Lightning Connections, as on Android.
  • Settles a Spark payment from Spark's completion event, which now reaches the send screen and Paykit, and makes the remaining Lightning flows count Spark where they assumed channels (see Channel-only audit under QA Notes).
  • Records a Spark send's fee when Spark's next sync reports it, since its completion event carries a fee of 0.
  • Shows a Spark deposit and a Spark withdrawal's arrival in Savings as transfers, not as plain sent and received payments, lists each withdrawal with the fee Spark kept on the Spark screen, and draws the Spark bar empty at a 0 balance.
  • Finishes a Transfer to Savings that only withdraws Spark without waiting for a Lightning peer or a fixed pause, so the success screen shows as soon as the withdrawal is sent.
  • Settles a Spark send that finished inside the send call, as Spark-to-Spark payments do, by checking Spark's payment list once the send screen starts watching, so a paid Paykit request no longer stays on Payment Pending.

Paykit on Spark

  • Publishes the Spark invoice as Bitkit's Paykit Lightning endpoint when no channel can receive, so a Spark-only wallet still lists a Lightning endpoint; Paykit requests paid over Lightning go through Spark when no channel can carry them.
  • Republishes the Paykit endpoints with a fresh Lightning invoice when Spark becomes ready, so a Spark-only wallet that prepared Paykit before Spark connected offers Lightning to contacts again, and registers Spark as the Lightning rail as soon as it connects, before its first refresh marks it ready.
  • Treats a Spark payment refused before it leaves (not connected, not preparable, a failed fee estimate or a fee over the caller's limit) as never sent: its payment request fails and drops its proof instead of staying on Payment Pending, and an allowance attempt is recorded as failed rather than unknown.
  • Leaves room for Spark's 0.25% service fee in the most a Spark payment can send, so a payment near the full balance is not offered and then refused.
  • Stops loading a payment request the user opened when its payment details fail to resolve, and shows that the contact's payment details aren't ready yet, instead of spinning through 14 retries.

Exit data backup

  • Backs up Spark's unilateral exit data to VSS as its own category, SPARK_EXIT, encrypted like the other backups. It is exported again when Spark connects and whenever the SDK reports UnilateralExitStateChanged, and on restore it is imported right after Spark connects, without contacting the operators, so a wallet restored on a new device can still exit on-chain while the operators are unreachable. The category shows in Backup settings only where Spark runs.

Small screens

  • Scrolls the content of the Spending confirm, Learn More and Sign with your device screens above their bottom buttons, so headlines stay whole on 375 pt screens. The Spark detail keeps Transfer To Savings at the bottom under its scrolling content.
  • Renames the Learn More headline to "Transfer / speed", accenting one word like master's two-line headlines.
  • Shortens the Spark failure headline to "Transfer / failed", reusing each language's existing Transfer failed wording, so it fits two lines on the SE.
  • Centres the device picture on Sign with your device in the space left on tall screens; on the SE it scrolls with the content.

Out of Scope

  • Push notifications for Spark receives: Blocktank's wake notifications go away, and Breez's per-payment webhook is tied to its Lightning address server.
  • Spark status: the SDK reports Spark outages; the Spending card doesn't show them yet.
  • Emergency exit: in feat: spark emergency exit #1217.
  • Raising the wallet-wide claim fee cap so every deposit claims early by itself; the fee is shown and accepted per deposit instead.
  • Spark-specific copy and design across transfer and receive screens.
  • Lightning address, LNURL and BOLT12 through Spark.
  • bitkit-core: the integration lives in the app, since Breez ships its own Android and iOS bindings and both build beside ldk-node.

Risks for review

  • Trust model. Spark is not self-custody in the channel sense: funds are safe while at least one of three operators (Lightspark, Breez, Flashnet) deletes old keys, and 2 of 3 sign. The operator code has a per-wallet freeze switch, and Spark's terms let Lightspark suspend access.
  • Exit data backup size. A unilateral exit needs leaf data the SDK keeps on the device, and it can reach several MB. This PR backs it up to VSS as SPARK_EXIT; upstream VSS accepts request bodies up to 1 GiB by default, but a large wallet's upload against Synonym's VSS is untested.
  • Fees. Sending Lightning costs 0.25% plus routing; receiving is free for the user (the payer pays 0.15% through route hints). The SDK's own deposit claim is capped at the recommended fee plus 2 sat/vB; an Instant transfer accepts up to that plus 0.2% of the deposit, and a higher early fee is asked for first. Withdrawing costs 250 × sat/vB + 750 sats.
  • Savings to Spending costs more for small amounts. On mainnet (case 3), a 15,000 sat transfer paid 610 sats on-chain and Spark kept 297 sats to claim it, so Spending got 14,703 sats: 907 sats, about 6% of the transfer. The claim fee is the SDK's standard claim, the fastest recommended fee rate times a 99 vbyte claim transaction (CLAIM_TX_SIZE_VBYTES, deposits.rs), so it is a flat cost that weighs heavily on small transfers. The confirm screen shows it as Spending fee and shows what reaches Spending after it.
  • Slower Savings to Spending without an early claim (UX regression). Blocktank opened a zero-conf channel, so only the order payment needed 1 confirmation, about 10 minutes. Spark claims a deposit after 3 confirmations by default, about 30 minutes. Breez documents faster claims (Claiming on-chain deposits): an instant claim at 0 confirmations, offered for some deposits only, and an expedited claim at 1 or 2 confirmations, both fronted by the Spark Service Provider for a higher fee. The user picks Instant or Later before the swipe, and Instant claims the deposit early at a fee up to the ceiling shown. Whether a deposit gets an early quote, and at which depth, is the provider's call per deposit, so some top-ups still wait for 3 confirmations.
  • E2E on regtest. Dev builds connect Spark to Lightspark's hosted regtest with no key, so once this PR leaves draft, E2E specs that expect the Transfer Unavailable sheet take the Spark route whenever that regtest is reachable from CI.
  • Closed-app receive. A payment received while the app is closed is claimed on the next sync; it is not signalled by push, so no notification arrives until the app opens.
  • No BOLT12 on Spark yet.
  • App size. Breez ships as its own dynamic framework, about 9.3 MB for device arm64 uncompressed; it links beside ldk-node and bitkit-core without duplicate symbols.
  • API key and dependency. Mainnet needs a Breez API key, and Lightning goes through a Spark service provider (Lightspark), a dependency like Blocktank was.
  • Testing reach. Spark's regtest is Lightspark's hosted chain with no Lightning, so dev builds cannot test Lightning payments or deposits from Bitkit's regtest; on dev, Savings to Spending sends regtest coins to an address Spark never sees.

Design

N/A — no design available. Figma has no Spark or unlimited-receiving state for the Lightning Connections header, so No limit uses the same style and arrow as the spending figure beside it; @aldertnl, a design for this state is still open.

Preview

Transfer to Spending on a regtest simulator build of f63e613, with Savings funded on Bitkit's staging regtest. Spark never sees a staging-chain deposit, so the Instant claim states (adding, repriced, unavailable) are staged with a local hook that is not in this PR; Later and "hasn't seen" are the real app.

Spending intro, amount, confirm, Learn More, Later Funding, Instant (adding staged) Transfer intro, repriced and Pay (staged)
t1-transfer2x.mp4
t2-intro-instant2x.mp4
t3-intro-repriced2x.mp4

Setting-up after 5 minutes when Spark never lists the deposit, and Instant unavailable (staged).

Spark hasn't seen it Instant unavailable

Case 1 on an earlier regtest simulator build without channels (4b58c22): the Spending invoice comes from Spark.

ios-receive-spark2x.mp4

Lightning Connections and the Spark detail on f63e613, with 99,901 sats from Lightspark's regtest faucet: the claimed deposit with its 99 sat claim fee and Transfer To Savings.

c1-connections2x.mp4

Transfer to Savings selection at 674f0da against Spark's hosted regtest, with channels to Blocktank's staging regtest node: the Advanced list with every row on and with nothing selected, a partial pick, and both a channel and Spark.

Default and nothing selected One channel of two plus Spark Channel and Spark
1-default-and-nothing-selected2x.mp4
2-partial-one-channel2x.mp4
3-both-channel-and-spark2x.mp4

The single-item cases: Spark alone with no channels, and two channels with no Spark balance.

Spark only Channels only
4-spark-only-no-channels2x.mp4
5-channels-only-no-spark2x.mp4

UI pass on an iPhone SE (375 pt) and an iPhone 17 simulator, every changed screen rendered with stubbed state by a local hook that is not in this PR. On the SE, the Sign with your device headline was cut to one line, and the Spark detail's buttons scrolled away with its content. Content now scrolls above the buttons, which stay at the bottom. These Spark detail shots predate the split, so they still show Emergency exit, which is now in #1217.

Sign with your device, before Spark detail, before

The same screens after, on the SE.

Sign with your device Spark detail

And on the iPhone 17, where the device picture sits centred in the space under the speed rows.

Sign with your device Spark detail

The Spark failure headline now fits two lines on the SE.

Spark failed, before Spark failed

The Learn More headline now accents one word, like master's two-line headlines, and its text scrolls above the Spending balance bar.

Learn More, before Learn More, SE Learn More, iPhone 17

Screens

Every screen the PR adds or changes, on an iPhone 17 simulator in Bitkit's dark theme, rendered with stubbed state by a local hook that is not in this PR.

Transfer to Spending

Spending intro Funding options Transfer intro Amount
Confirm Learn More Setting up: waiting Setting up: adding
Setting up: repriced Setting up: Instant unavailable Setting up: Later Setting up: not seen
Funds moved

Transfer to Spending from a hardware wallet

Transfer intro Amount Sign with your device Transaction signed

Transfer to Savings

Selection with the Spark row Confirm Spark transfer failed

Lightning Connections

The Spark detail shot predates the split and still shows Emergency exit, now in #1217.

Spark row Spark detail

QA Notes

Setup

  • Mainnet build with BREEZ_API_KEY in Secrets.xcconfig (copy Secrets.xcconfig.template), and small amounts.
  • Dev build (regtest) needs no key (Testing and development). Spark's regtest is Lightspark's own chain, separate from Bitkit's staging regtest, and has no Lightning network, so on dev:
    • Spark start-up, Spark invoices, Spark connections and the Savings to Spending screens work.
    • Spark is funded from Lightspark's regtest faucet, sent to the deposit address on the Spark screen (Settings › Advanced › Lightning Connections › Spark); it is credited after 1 confirmation on regtest. The faucet's web page needs no account.
    • Two dev builds with Spark balances pay each other's Lightning invoices as Spark transfers.
    • Savings to Spending broadcasts on Bitkit's staging regtest, which Spark does not see, so the deposit is never claimed; Spending to Savings withdraws on Spark's chain, which Bitkit does not see.
    • Spark's chain exposes no public Electrum server, only an Esplora-compatible REST API at https://regtest-mempool.us-west-2.sparkinfra.net/api (the SDK's own regtest default, sdk_builder.rs). It lacks /fee-estimates, which ldk-node needs to start, so a dev build cannot point its node at Spark's chain as-is.

Journeys

  • J1: new transfer/spark-transfer-to-spending.xml — with Spark ready, the transfer buttons and Funding open the spending intro, then the amount and confirm screens; the confirm shows the four tiles and the Instant and Later rows, and Learn More opens the Spark copy. Clarified for the restored flow; earlier results ran on the Spark-only screens.
  • J2: updated transfer/transfer-unavailable-sheet.xml — now needs Spark not running, since a ready Spark replaces the sheet.
  • J3: restored amount-limits/transfer-spending-over-max.xml — the amount over Max snaps to it with the toast, and delete still works.
  • J4: restored transfer/spending-confirm-amount-change.xml — a changed amount re-prices the confirm screen, and a cheaper fee at the swipe pays at once.
  • J5: restored hardware-wallet/transfer-to-spending.xml — the device pays the Spark deposit address and the transfer lists once in the hardware wallet's activity.
  • J6: restored hardware-wallet/transfer-to-spending-over-max.xml — the hardware amount caps on both entries.
  • J7: restored hardware-wallet/passphrase-transfer-to-spending.xml — a hidden wallet asks for its passphrase again after the session drops, and refuses a wrong one.
  • J8: new transfer/savings-select-funds.xml — each channel and Spark are rows on Select funds, all on by default; the confirm amount and "Spark fee" follow the pick, and nothing selected disables Continue. Allow about 20 s for "Spark fee". Adapted from Android: a picked selection shows "Transfer all" in place of "Advanced", so reaching the list again takes "Transfer all" first.

Manual Tests

  • 16 to 19 are new for the rebuilt flow on the sunset shape and early claims; 20 to 24 for the restored flow; 34 for Paykit on Spark; 35 for the Transfer to Savings selection; 36 and 37 for its Spark withdrawal fixes. The Emergency exit tests (25 to 33) moved to feat: spark emergency exit #1217.
  • Retired: 17 and 18 drove the Spark-only deposit screen's Use now and Wait buttons, which the restored flow replaces with the Instant or Later choice before the swipe (20 to 22).
  • 1. Dev, new wallet with no channels → Receive shows Auto and Spending tabs, and the Spending invoice's payee is Spark's node, not the LDK node — Spark's regtest is not in the journey environment.
  • 2. Dev, fund Savings, Transfer to Spending → amount max is the on-chain budget, confirm shows Network fees, Spending fee, To spending and Total with the Instant and Later rows and no Advanced, swipe broadcasts to the Spark deposit address — same.
  • 3. Mainnet, Transfer to Spending with a small amount → after 3 confirmations the Spending balance shows the amount — mainnet funds.
  • 4. Mainnet, no channels, pay a Lightning invoice from another wallet → paid through Spark, activity shows it — mainnet funds.
  • 5. Mainnet, no channels, receive a Lightning payment → received sheet and activity show it — mainnet funds.
  • 6. Mainnet, receive while the app is closed, then open it → the payment is claimed and shown — mainnet funds.
  • 7. Mainnet, Spending to Savings with only a Spark balance → the balance moves on-chain — mainnet funds.
  • 8. Mainnet, restore the wallet on another device → the Spark balance comes back — mainnet funds.
  • 9. Mainnet, user with an existing channel → payments within channel capacity still go over the channel — mainnet channel.
  • 10. Dev or mainnet, Spark with a balance and a deposit in progress → Lightning Connections shows Spark first with its balance and the deposit's confirmations; its detail shows provider, trust note, deposits and Transfer to Savings — Spark state is not in the journey environment.
  • 11. Dev, fund Spark from the regtest faucet to the address on the Spark screen → after 1 confirmation Spending shows the amount — faucet is outside the journey environment.
  • 12. Dev, two devices with Spark balances, pay one's Spending invoice from the other → paid as a Spark transfer, both show the activity — two devices.
  • 13. Dev, wallet with Spark connected → Backup settings lists Spark exit data as synced; restore the same seed on a fresh install → the log shows Imported Spark exit data before Spark is used, and Spark's balance and deposits match — needs two installs.
  • 14. Mainnet, Spark send of a Lightning invoice → after Spark's next sync the activity shows the fee Spark charged, not 0 — needs mainnet routing.
  • 15. Mainnet, Transfer to Spending, then Transfer to Savings → both show as transfers in Activity, and the Spark screen lists the withdrawal with its fee and an empty bar — needs mainnet.
  • 16. Mainnet, Spark ready, no channels → Funding (Spending card) shows Transfer from Savings and Use Other Wallet above Manual Setup; Transfer from Savings opens the Spark amount screen; Savings → Transfer to Spending opens the same flow — mainnet.
  • 19. Build without a Breez API key (Spark off) → Funding and the transfer buttons behave as on master: Manual Setup only and the Transfer Unavailable sheet — any build.
  • 20. Mainnet, Transfer to Spending of a small amount with Instant → setting-up shows Speed: Instant, then either "Spark is adding it" and the success screen with the Spending balance credited before 3 confirmations, or "Instant isn't available" when the provider offers no early claim — mainnet funds.
  • 21. Mainnet, Instant when the provider's early fee is above the ceiling shown → setting-up shows "Instant now costs X" with Pay X and Wait; Pay X claims it early at X, Wait shows the 3-confirmation text — mainnet funds, and the provider's quote decides whether it happens.
  • 22. Mainnet, Transfer to Spending with Later → setting-up says Spark adds it after 3 confirmations, about 30 minutes, makes no claim call, and the balance arrives after 3 confirmations at about the Later fee — mainnet funds.
  • 23. Mainnet, a paired Trezor or Jade with funds, hardware wallet screen › Transfer To Spending → intro, amount, sign on the device, Transaction signed, setting-up; the transfer lists once under the hardware wallet — mainnet device funds.
  • 24. Leave the confirm screen open for over a minute while fee rates move, then swipe → the tiles re-price, and a higher fee at the swipe shows "Fees changed" and needs another swipe — fee movement is not a journey capability.
  • 34. Two dev wallets, both Spark-only and each other's Paykit contacts: cold-start one and open its contact on the other → a Lightning option is offered; pay a request with the payer offline → the request fails and is not left on Payment Pending — two devices.
  • 35. Mainnet or a channel and a Spark balance, Transfer to Savings › Advanced → Spark is a row beside the channels, all start selected; deselect Spark → it is not withdrawn and the confirm screen drops its withdrawal fee — needs a channel next to Spark.
  • 36. Dev, Spark and a channel selected, make the withdrawal fail (for example Spark offline at the swipe) → the progress screen shows Spark transfer failed with the error as a toast and says the channel funds are moving; with Spark alone it says the balance stays in Spending — a failing Spark withdrawal is not a journey capability.
  • 37. Mainnet, Transfer to Savings with Spark selected → when the withdrawal reaches Savings no received sheet opens, and Activity shows it as a transfer From Spending — on dev the withdrawal lands on Spark's chain, which Bitkit does not see.

Automated Checks

  • added SparkTransferFlowTests.swift — the claim decision (Instant within and over the ceiling, Standard, automatic, Declined, Later), the claim outcomes, the poll timeout, a listed-then-gone deposit, Waiting that keeps polling, Pay after a reprice, Wait after a reprice, and the quote's tiles
  • updated AppCacheDataTests.swift — the transfer intro flags round-trip through the backup again
  • added SparkClaimOfferTests.swift — a quote maps to the standard claim, an automatic early claim within the cap, or a choice above the cap or with automatic claiming off, and each claim outcome maps to claimed, waiting, repriced or declined
  • added ChannelFreeRailTests.swift — rail events before the node callback are delivered, Paykit proofs and private Paykit sets read Spark payments, QuickPay reconciles them, a Spark send honours the routing fee limit, a fee over the limit or a failed estimate is a refusal before sending, Spark's sendable amount leaves room for its fee and canSend uses it, and Spark becoming ready triggers the Paykit refresh once
  • updated PaykitPaymentProofServiceTests.swift — a Spark refusal before sending, bare or wrapped, ends the payment as failed and removes its proof
  • added SparkLightningSendTests.swift — a Lightning invoice with no channels opens the payment when Spark can pay, is rejected without Spark, and a Spark send returns before completion
  • added SparkTransferActivityTests.swift — Spark deposits and withdrawals are marked as transfers, failed ones are not, and withdrawals are listed with the fee Spark kept
  • added SparkExitBackupTests.swift — SPARK_EXIT uploads the exported state under its own key, a restored state is staged until Spark connects, and one from another network is skipped
  • added SavingsSelectionTests.swift — every channel and Spark move by default, Spark alone or channels alone, a deselected Spark is not withdrawn and Spark alone closes no channel, a partial channel pick, a channel no longer ready is dropped, nothing selected is empty, and committing resets the pick to all
  • added SparkConfigTests.swift — Spark connects with no Breez data-sync server and no LNURL domain on every network
  • added SparkWithdrawalTests.swift — a withdrawal quote holds for the same address and balance until 30 s before it expires and an estimate never does, the fee is priced once per balance to one address and again for a new balance, a slow quote gives up and prices again next time, a failed Spark withdrawal shows as failed and names whether channels moved, and a withdrawal's receipt is matched by its txid
  • updated SavingsSwapTests.swift — the commit takes whether Spark is withdrawn

Channel-only audit

Every place that assumed channels, checked for a Spark wallet with no channels and compared with Android:

  • Fixed:
    • AppViewModel scan check: rejected every Lightning invoice without channels before considering Spark; now pays through Spark when its balance covers the amount, and the toast counts Spark's balance.
    • SendSheet insufficient toasts: count Spark's balance.
    • SendSheet channel wait: no longer waits for a reconnecting channel when Spark can pay the amount.
    • SparkManager send: returns as soon as the payment starts, so its completion event reaches the send screen and Paykit settlement; it showed "Payment Pending" before. Same fix on Android.
    • LightningService.emitChannelFreeRailEvent: Spark events that arrive before the node's event callback is set are kept and delivered once it is; they were dropped.
    • PrivatePaykitService+Invoices settled and attempted payment sets: include Spark payments, so a private invoice Spark received isn't reused and a contact invoice Spark paid isn't paid again.
    • PaykitLightningPaymentProofLookup: settles a Paykit proof from Spark's record when its completion event was missed, also before the node lists its payments.
    • QuickPayPaymentCoordinator reconcile: includes Spark payments.
    • SparkManager activity: recorded through the same path as LDK payments, so a Paykit contact and seen state survive Spark syncs.
    • TransferViewModel swap to Savings: also withdraws Spark's balance when Spark is selected, as the confirm screen counts it.
    • PaykitAllowanceManager.lightningReady: Spark ready counts as ready.
    • LightningService.send on Spark: enforces the caller's routing fee limit (Paykit auto-pay's 1%) against Spark's fee estimate.
    • LnurlPayAmount: caps and shows the larger rail balance, not the sum of both.
    • AppStatusHelper.channelsStatus: a ready Spark wallet shows the Lightning connection as ready.
    • LNURL withdraw "no capacity", Receive amount liquidity and CJIT, Lightning Connections receiving capacity and counter: fixed earlier in this PR.
  • Channel-only by design:
    • Channel list and detail, close channel, Blocktank orders, node info, LNURL channel, manual channel funding and the multi-channel Savings path: they manage channels.
    • GiftLoading: gift claims open a Blocktank channel, which Blocktank no longer sells.
    • The notification extension: it wakes the LDK node only; Spark receives while closed are claimed on the next open (no push).
    • nodeLifecycleState gates on send and receive: the node runs beside Spark, as on Android.
    • WalletViewModel balance derivation while the node isn't up: keeps the last balances, which include Spark.
  • Already Spark-aware: maxSendLightningSats, receive invoice creation and tabs, Paykit Lightning endpoint publishing, the Spending card transfer button, the LNURL withdraw invoice.
  • Not covered: a regtest Spark-to-Spark transfer (preferSpark) has no Lightning details, so it maps to no event; mainnet sends are Lightning payments.
  • Android has the same gaps in the proof lookup, the Spark activity contact, the swap to Savings, the channel wait and App Status; its flows don't hit them in the cases run on device.

Test results

QA:
Tested on an iPhone 17 Pro simulator (iOS 27.0) against Spark's hosted regtest.

Device tests skipped ⚠️:

  • Paykit on Spark: needs two wallets that are each other's contacts (34).
  • Mainnet: see Still open.
🟢 Transfer to Spending with Later, Transfer to Spending with Instant, Claim states after the swipe
Transfer to Spending with Later

J1 and 2 at f63e613: Savings › Transfer To Spending opened the Spending intro, the amount screen's 25%, Max and unit toggle worked, the confirm screen showed the four tiles with Instant and Later, Learn More showed the Spark copy, and the swipe ended on setting-up with Speed: Later and the 3-confirmation text; recording under Preview.


Transfer to Spending with Instant

The Spend suggestion card opened the Transfer intro, then Funding with Transfer from Savings, Use Other Wallet and Manual Setup; with Instant, setting-up waited for Spark and after 5 minutes said Spark hasn't seen the transfer, since the deposit went to Bitkit's staging chain; recording and still under Preview.


Claim states after the swipe

With the claim offer staged by a local hook, Instant showed Spark adding it and then the success screen, a repriced offer showed Instant now costs ₿ 812 with Wait and Pay and Pay ended on success, and a declined one showed Instant isn't available; recordings and still under Preview.


🟢 Fund Spark from the faucet
Fund Spark from the faucet

10 and 11 at f63e613: 100,000 sats from Lightspark's regtest faucet to the address on the Spark screen arrived as 99,901 after a 99 sat claim fee, and Lightning Connections and the Spark detail listed them; recording under Preview.


🟢 Default and nothing selected, Partial, Channel and Spark, Spark only, Channels only
Default and nothing selected

With two channels and 99,901 sats on Spark, the confirm screen showed 535,993 sats with a 2,190 sat Spark fee and Advanced. The list showed Connection 1, Connection 2 and Spark, all on; with all three off, the total read 0 and Continue was disabled.


Partial

With Connection 2 and Spark off, the confirm showed only Connection 1's 186,780 sats, no Spark fee, and Transfer all. After the swipe, Spending went from 535,425 to 348,929: Connection 1 closed, the other channel and Spark's 99,901 stayed.


Channel and Spark

The remaining channel and Spark, all selected, showed 349,213 sats with the Spark fee; the swipe closed the channel and withdrew Spark, and Spending read 0.


Spark only

With Spark refunded from the faucet and no channels, the confirm showed 99,901 sats with the Spark fee and no Advanced button; the swipe withdrew it and Spending read 0.


Channels only

With two new channels and no Spark balance, the confirm showed Advanced and no Spark fee, the list had only the two connections, and the swipe closed both.


Evidence

  • Split at 0eed9c2a: the emergency exit moved to feat: spark emergency exit #1217, and this PR is now one commit on master. The unit and UI suites (CI's selection) pass on an iPhone 17 Pro simulator (iOS 27.0), SwiftFormat shows no new findings and the translation check passes; J1 passes on an iPhone 17 Pro simulator (iOS 26.5) against Spark's hosted regtest with Savings funded on Bitkit's staging regtest. J8 needs a Spark balance from Lightspark's web faucet and channels, and was not rerun.
  • Media and the test results above at f63e613 (transfer, Spark detail): Debug simulator builds on an iPhone 17 Pro simulator (iOS 27.0), Savings funded on Bitkit's staging regtest, Spark funded from Lightspark's regtest faucet.
  • Privacy config at aa01843: SparkConfigTests checks that Spark connects with no data-sync server and no LNURL domain on every network.
  • Paykit on Spark fixes at 58fa5fe1: the Debug device build passes, and ChannelFreeRailTests, PaykitPaymentProofServiceTests, PaykitAllowanceExecutorTests, SparkLightningSendTests and SparkTransferFlowTests pass on an iPhone 17 simulator. Not driven on a device.
  • Restored flow at d20fc7c8: the Debug device build passes, and SparkTransferFlowTests, AppCacheDataTests and HwWalletVendorPresentationTests pass on an iPhone 17 simulator; SwiftFormat and the translation check pass locally. Previews cover every confirm state (Instant, Later, loading) and every setting-up state (looking, adding, done, repriced, Instant unavailable, Later, not seen); the screens were not driven on a device.
  • Merged master at 4ac08000 (6d32091a), no rebase, so stacked branches keep their ancestry: one follow-up fix in BackupService for master's class-based subscription store; the simulator build and the Spark unit tests pass locally; SwiftFormat and validate pass in CI (run). Draft CI skips the build and unit suite.
  • Rebased on master at 92a03765: the mainnet Release build for device, SwiftFormat and the unit suite pass (run). The rebuilt flow and early claims (16 to 19) are not yet driven. Results below ran on the earlier flow before the rebuild; their revisions are kept as run.

Debug simulator build of 4b58c22 and f79c9f5 (before the rebase onto 55b8a47), iOS simulator, Spark's regtest:

  • 1: new wallet without channels; the log shows Connected Spark wallet on 'regtest'. Receive shows Auto and Spending with a Lightning QR whose invoice names an explicit payee and a route hint to 0202f87a…bb76, which an LDK node without channels does not add.

  • The rebased head aed1f9f builds for the simulator (arm64).

  • 10 on 34dfdef (now bb09eeb): Lightning Connections listed Spark first with its balance and the pending deposit with live confirmations; the detail showed provider, trust note, pending and claimed deposits with the claim fee, and Transfer To Savings.

  • 10 and 2 on 5c3b6f7 (now cbc59e9): the Spark row and detail drew a full purple bar with no receiving side, the header read Receiving capacity No limit, and a 20,000 sat Spark transfer confirm showed network fee 458, Claim fee 99, To spending 19,901 and Total 20,458, with no background toggle, Learn More or Advanced.

  • Transfer to Savings selection at 674f0da (35, J8's cases): the five recordings in Preview, on an iPhone 17 Pro simulator (iOS 27.0) with Spark on its hosted regtest and channels on Blocktank's staging regtest. At f01de3b the unit suite (CI's selection) passes on the same simulator, with SwiftFormat and the translation check.

Still open

  • Translations. The new transfer and note keys are English in every locale file; they need translating.
  • Restored flow: J3 to J7 and 20 to 24 not run yet; the claim states other than Later and "hasn't seen" are reached on dev only through a local hook, since Spark never sees a staging-chain deposit.
  • 2 to 9, 14 to 16 and 20 to 23 need mainnet and a Breez API key; 12 needs two devices.
  • Paykit on Spark is not driven on a device: its fixes change what Paykit publishes and how a payment settles, which needs two wallets that are each other's contacts.
  • A payment received while the app is in the background is recorded as activity, but the received sheet does not show.
  • Transfer to Savings Spark fixes at f01de3b (36, 37): unit tests only. The fee quote reuse, the failed withdrawal screen and the withdrawal receipt were not driven: on dev a failure needs staging and the withdrawal lands on Spark's chain, which Bitkit does not see.
  • Exit data backup, the channel-free Lightning send and the audit fixes on a9aa36fd: the unit suite passes except SendConfirmationLifecycleTests.testCustomFeeWalletSwitchSurvivesFeeScreenNavigation, which fails the same way on the base branch at 77b01a4a. Not yet driven on a device.
  • Pending at broadcast on 8f6de109: built for the simulator only, not driven; the twin Android change was driven on an emulator.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: low)
  • Implementation: claude-opus-5-5 (reasoning: low)
  • Review: claude-sonnet-5-5 (reasoning: high)

@ovitrif ovitrif added this to the 3.1.0 milestone Oct 26, 2026
@ovitrif ovitrif self-assigned this Oct 26, 2026
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from aed1f9f to 82d6014 Compare October 26, 2026 14:04
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 2 times, most recently from bb09eeb to bf0e9cd Compare October 26, 2026 17:18
@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from 9d5e276 to 1883357 Compare October 26, 2026 17:25
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 3 times, most recently from 856799e to 7897f16 Compare October 26, 2026 22:47
@ovitrif ovitrif changed the title feat: keep lightning through breez sdk on spark feat: add spark via breez sdk Oct 26, 2026
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from 4534525 to a9aa36f Compare October 27, 2026 02:18
@ovitrif ovitrif mentioned this pull request Oct 27, 2026
5 of 7 tasks
@ovitrif

ovitrif commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

QA:
Tested on an iPhone, mainnet, by @ovitrif, 26–27 Oct; amounts are sats. Balances, names, addresses and txids are masked.

🟢 Pay a Lightning invoice from iOS
The iPhone paid a plain Lightning invoice from the Pixel through Spark with no channel, and the Pixel received it at once. Ran on a9aa36f.

🟢 Restore Spark exit data
Exit data backs up as its own category, and after a wipe the iPhone imported 16 leaves of Spark exit data before using Spark; its balance and claimed deposit came back. Ran on a9aa36f.

🟢 Move Spending to Savings
Transfer to Savings withdrew the Spark balance for a 1,470 sat fee and 4,917 sats arrived in Savings; it was listed as a plain received payment, fixed in 93aec43 to show as a transfer. Ran on a9aa36f.

🟢 Claim a deposit
Spark claimed the 7,500 sat deposit after 3 confirmations, keeping 198 sats, and Spending showed 7,302. Ran on an earlier head of this PR, before the 27 Oct rebase.
🟢 Transfer to Spending
Transfer to Spending sent 9,632 sats to Spark and Spending showed it as an incoming transfer. The confirm screen estimated a 99 sat claim fee where claims so far took 198 to 297; 7388559 now shows the most Spark may keep. Ran on f25edfe, a build of #1145 on an earlier head of this PR.

🔴 Pay a Paykit request over Spark
The iPhone paid the Pixel's 204 sat Paykit request over Spark and it arrived at once, but the iPhone stayed on Payment Pending: the payment settled inside the send call, before the send screen started watching for it. Fixed in 735e972 and synonymdev/bitkit-android@024b6bb85, not rerun on a device yet. Ran on 56d7adf, a build of #1145 on 9ddd96b. An earlier attempt kept loading for minutes while Paykit's shared state stayed locked; since a5d94ed the sheet stops at once and says the contact's payment details aren't ready, and the lock itself is a Paykit issue reported separately.

@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from 6104b95 to 23b8b66 Compare October 27, 2026 17:39
@ovitrif ovitrif removed this from the 3.1.0 milestone Oct 28, 2026
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Removed from the 3.1.0 milestone: this draft is the deliverable of a finished Spark experiment and does not ship in 3.1.0.

@ovitrif
ovitrif changed the base branch from fix/blocktank-sunset-sheet to master October 28, 2026 13:25
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Retargeted this draft to master before merging its base, #1127, so deleting that branch would not close it.

@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from 735e972 to a77cb76 Compare October 28, 2026 14:42
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebuilt this branch on master after the Blocktank sunset (#1127). Master removed the old Transfer to Spending flow, so Spark now has its own amount, confirm and deposit screens, reached from Funding and the transfer buttons while Spark is ready. The deposit screen offers Breez's early claim at the fee the provider quotes for that deposit.

The history was rewritten into new commits, so the shas under Evidence predate the rebuild. This PR stays a draft.

@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 2 times, most recently from 3808d9d to 92a0376 Compare October 29, 2026 12:03
@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Cancelled the unit-tests run on 782d2ff (run): that head was replaced by 92a0376, which folds a SwiftFormat fix into the same change, and a new run is going on it (run).

@ovitrif ovitrif added this to the 3.2.0 milestone Nov 3, 2026
@ovitrif

ovitrif commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed ae8f1b3: the Spark connection row on Lightning Connections now opens its detail from a tap anywhere on the row. Its button uses the plain style, which hit-tests only drawn content, so taps in the empty middle between the name and the chevron, or between the amounts, did nothing. Exact-point taps on an iOS 26.5 simulator: on 3eb00ec those two points did nothing while the name and the bar opened the detail; with the fix all four open it. The same gap on master's pending rows is #1202.

Spending keeps Lightning without channels through a Spark wallet: transfer to spending funds it from Savings or another wallet, Lightning payments and Paykit run on it, and transfer to savings picks channels, Spark or both. Breez data sync and the Lightning address server are off, and the Spark exit data is backed up.
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from ffba7c7 to 0eed9c2 Compare November 5, 2026 02:10
@ovitrif ovitrif mentioned this pull request Nov 5, 2026
9 tasks
@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Force-pushed: this PR is now one commit, 0eed9c2a, on master, without the emergency exit, which moved to #1217 (stacked on this PR, its tree equal to the old head ffba7c72). The old head is kept as backup/breez-spark-lightning-20261105. The unit and UI suites pass on the new head; the description is updated.

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