Skip to content

feat: add spark via breez sdk - #1697

Draft
ovitrif wants to merge 14 commits into
fix/blocktank-sunset-sheetfrom
feat/breez-spark-lightning
Draft

ovitrif wants to merge 14 commits into
fix/blocktank-sunset-sheetfrom
feat/breez-spark-lightning

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1696
Twin: synonymdev/bitkit-ios#1128

This PR keeps Lightning in Bitkit after Blocktank stopped selling channels: Savings to Spending now 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.

Description

  • Adds the Breez SDK on Spark (breez_sdk_spark:bindings-android:0.27.2) with SparkService and SparkRepo, 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.
  • 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 and records them as Lightning activity, so the received sheet, send success screen and activity list work for both rails.
  • 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.
  • Funds Spark instead of buying a channel in Savings to Spending, so that it sends on-chain to the Spark deposit address with no service fee and no channel options. Spark claims the deposit after 3 confirmations, and the setup progress completes then.
  • Withdraws the whole Spark balance to an on-chain address in Spending to Savings before closing channels, so that the transfer works for users with only a Spark balance.
  • Reads a BREEZ_API_KEY build property, so that mainnet builds without a key keep Spark off; regtest builds use Lightspark's hosted regtest, which needs no key.
  • 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.
  • Adds an ESPLORA_URL build property for regtest builds, which puts the node's chain source on an Esplora endpoint, plus scripts/spark-regtest-esplora.py and scripts/spark-regtest-fund.py, so a dev build runs on Spark's regtest chain and QA can fund it and test Savings to Spending and the claim without mainnet.
  • 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.
  • Shows the expected Spark claim fee on the Transfer to Spending confirm screen in place of the service fee, and what reaches Spending after it, so the user sees the full cost before the swipe.
  • 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; hides "Set up in background" (Blocktank's order-paid push) and Learn More (channel liquidity costs) on Spark's Transfer to Spending confirm.
  • 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 already go through Spark when no channel can carry them.
  • Reads BREEZ_API_KEY from the release environment in the release workflow and fails the release when it is missing, so Spark can't ship switched off; internal release builds pass it through when the release-internal environment has it. A repo admin has to add BREEZ_API_KEY to the release environment (and to release-internal for internal builds with Spark).
  • 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.

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.
  • Running a unilateral exit: the exit data is backed up and restored, but no screen runs the SDK's prepare_recover_funds / recover_funds yet.
  • SettingUpScreen: copy still describes a channel opening during Spark funding.
  • 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 deposit claim may pay up to the recommended fee plus 2 sat/vB plus 0.2% of the deposit. 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 now shows it as Claim fee and shows what reaches Spending after it.
  • Slower Savings to Spending (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 SDK takes the fastest claim whose fee fits the max deposit claim fee (config); the default is sized for the standard claim, and this PR raises it to the recommended rate plus 2 sat/vB plus 0.2% of the deposit. On mainnet that cap did not buy an early claim: case 3's deposit was claimed at 3 confirmations. Spark's 0-conf static deposits are gated and need Lightspark to enable them (0-Conf Deposits); a single deposit can also be given its own higher cap.
  • 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. libbreez_sdk_spark_bindings.so adds about 11 MB per ABI (arm64, uncompressed).
  • 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

Cases 1 and 2 on a dev build without channels: the Spending invoice comes from Spark, and Transfer to Spending funds the Spark deposit address.

Receive (1) Transfer to Spending (2)
r1-receive2x.mp4
r3-transfer2x.mp4

Case 10 on a dev build, staged with a local hook (not in this PR) for the balance and the claimed deposit; the pending deposit is a real 35,000 sats transfer with its real confirmations.

Lightning Connections → Spark (10) Spark detail (10)
r72x.mp4

A new dev wallet with no Spark balance: Spark shows in Lightning Connections on regtest, and its screen shows the deposit address to copy for Lightspark's regtest faucet.

r8-regtest-address2x.mp4

Case 2 confirm on a dev build: the 20,000 sat transfer shows a 99 sat claim fee (1 sat/vB × 99 on regtest) and 19,901 sats to Spending.

On a dev build with a staged 14,703 sat Spark balance: Lightning Connections and the Spark detail with the full-width bar and receiving shown as No limit, and the Spark confirm screen without the background toggle and Learn More.

Lightning Connections Spark detail Spark confirm

QA Notes

Setup

  • Mainnet build with BREEZ_API_KEY=<key> in local.properties, 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, with no Lightning network and no public Electrum server. A dev build runs on it end to end like this:
    1. On the machine running the emulator: python3 scripts/spark-regtest-esplora.py --port 3999. It fronts Spark's regtest mempool API (the SDK's own regtest default, sdk_builder.rs) and adds the two Esplora calls it lacks and ldk-node needs: /fee-estimates at start and /block/<hash>/status for Lightning sync.
    2. Build with ESPLORA_URL=http://10.0.2.2:3999 ./gradlew assembleDevDebug, so the node's chain source is Esplora on Spark's chain instead of Electrum on Blocktank's staging chain. For a phone, run the proxy with --bind 0.0.0.0 and use the host's address.
    3. Fund Savings: FAUCET_USERNAME=… FAUCET_PASSWORD=… python3 scripts/spark-regtest-fund.py <Savings address> 100000, through Lightspark's API faucet (credentials issued by Lightspark; Breez's CI uses the same, faucet.rs). Without credentials, use the web faucet by hand.
    4. Savings to Spending, the deposit claim (after 1 confirmation on regtest), Spending to Savings and Spark payments then run on regtest. Two such dev builds pay each other's Lightning invoices as Spark transfers.
  • A dev build without ESPLORA_URL stays on Blocktank's staging chain: Spark start-up, invoices and screens work, but its Savings to Spending deposit is never seen by Spark.

Journeys

N/A — not drivable; see Manual Tests.

Manual Tests

  • 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 0 service fee 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 build with ESPLORA_URL on Spark's regtest chain, Savings funded with spark-regtest-fund.py → Transfer to Spending; after 1 confirmation Spark claims the deposit and Spending shows it; Lightning Connections lists it as claimed — faucet credentials.
  • 14. 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.

Automated Checks

  • added SparkRepoTest.kt cases — a restored exit backup is imported once Spark connects, and one from another network is skipped
  • updated BackupRepoTest.kt — SPARK_EXIT uploads the exported state and restores it, uploads nothing while Spark is not connected, and uploads when Spark was ready before observing started
  • updated EditInvoiceVMTest.kt — Receive asks for no liquidity or CJIT while Spark can receive
  • added DeriveBalanceStateUseCaseTest.kt — Spark balance counts toward Spending, caps max send, and pending deposits count as in transfer
  • updated LightningRepoTest.kt, WalletRepoTest.kt, TransferViewModelTest.kt, WalletViewModelTest.kt — construct the new Spark dependency, idle, so existing channel paths are unchanged

Evidence

Dev build of 7aa44f6 (before the rebase: a9cc9a3), Android 15 emulator, Spark's regtest:

  • 1: new wallet without channels; Spark connected on regtest from the wallet's seed. Receive shows Auto and Spending; the Spending invoice's payee is 02fe4b…, not the node 03401e…, with a Spark route hint.

  • 2: funded Savings with 100,000 sats from Blocktank's staging regtest; Transfer to Spending offered 99,671 sats, confirm showed 458 sats network fee and 0 service fee without Advanced, and the swipe broadcast 50,000 sats to the Spark deposit address. No Blocktank order was created.

  • 10 on 07a1df7 (now 2126254): Lightning Connections listed Spark with 20,000 sats and the 35,000 sats deposit at 1/3 confirmations read from the wallet's own transaction; the detail showed provider, trust note, pending and claimed deposits with a 312 sat claim fee, and Transfer to Savings opened the savings transfer. The Home total counted the Spark balance and the pending deposit.

  • 3 passed on mainnet (Ovi's phone, ef7130e): 15,000 sats broadcast to the Spark deposit address showed as in transfer to Spending within a minute; Spark claimed it with the standard claim after 3 confirmations (no early claim at 1 confirmation under this PR's claim fee cap), and the Spending balance then showed the Spark funds.

  • Regtest address on be8df34: a new wallet with no Spark balance listed Spark in Lightning Connections, and its screen showed the deposit address bcrt1peqm25…z0uj.

  • Spark's chain on 5f88733 (now 01334a9): a dev build with ESPLORA_URL through the proxy started its node on Spark's regtest (Esplora chain source logged), synced the on-chain and Lightning wallets to height 3,658,177 with no failed calls at the proxy, and Spark connected beside it.

  • 2 on 9a9ba8c (now 4ed36e08c): confirm showed network fee 458, Claim fee 99, To spending 19,901, Total 20,458 for 20,000 sats.

  • 10 and 2 on 9383434 (now 60e079f): the Spark row and detail drew a full purple bar for 14,703 sats with no receiving side, the header read Receiving capacity No limit, and the Spark confirm showed Claim fee 99 and To spending 19,901 with no background toggle or Learn More.

  • 10 and 2 on a93f3dc88 (Android 15 emulator, Blocktank's staging regtest, where Spark never sees the deposit): 26 seconds after the swipe, Lightning Connections listed the 10,000 sat deposit as pending at 1/3 confirmations from the app's own broadcast, and the header showed No limit (since restyled to match the spending figure).

Gaps

  • Exit data backup on 2695c93af: unit tests only (upload, restore, Spark ready before observing, staged import on connect); not yet driven on a device.
  • 4 to 9: not run yet.
  • 11, 12 and 13: not run; funding needs Lightspark faucet credentials, or the web faucet by hand.
  • Spark transfers for invoices between two dev builds (12) are built but not driven.
  • 2: Spark never claims the deposit on dev, since Spark's regtest is a different chain from Bitkit's.
  • The wip-71 sheet bypass (Transfer Unavailable only while Spark is not ready) is not driven; staging is not geo-blocked.

Models used

  • Planning/scoping: claude-opus-5-5 (reasoning: low)
  • Implementation: claude-opus-5-5 (reasoning: low)
  • Review: Not performed

confirm-claim-fee-web

@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 bf469f4 to ef7130e Compare October 26, 2026 13:49
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 4 times, most recently from 01334a9 to 4ed36e0 Compare October 26, 2026 17:20
@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from a1412d5 to a1c333c Compare October 26, 2026 17:23
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch 2 times, most recently from 60e079f to 9982a55 Compare October 26, 2026 18:20
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from 9982a55 to d964a5f 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

This branch has not been deployed

No deployments
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