Skip to content

feat: add spark via breez sdk - #1697

Draft
ovitrif wants to merge 19 commits into
masterfrom
feat/breez-spark-lightning
Draft

ovitrif wants to merge 19 commits into
masterfrom
feat/breez-spark-lightning

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Twin: synonymdev/bitkit-ios#1128

This PR keeps Lightning in Bitkit after Blocktank stopped selling channels: on top of the Blocktank sunset (#1696), 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.

Description

  • Brings back the Transfer to Spending flow the Blocktank sunset removed, wired to Spark: while Spark is ready, the Spending suggestion card opens the Spending Balance intro (first time) and then Funding, which leads with Transfer from Savings and Use Other Wallet above Manual Setup; Transfer from Savings, and the Savings, Spending and hardware wallet transfer buttons, open the Transfer to Spending intro (first time), then the restored amount, confirm and setting-up screens. Without Spark, the entry points keep showing the Transfer Unavailable sheet. The Spark-only amount, confirm and deposit screens are gone, and one view model drives the restored screens.

  • Keeps the old amount screen: number pad, 25%, Max and Available, where Max is the most Savings can send to the Spark deposit address after the network fee. The pad and presets stay disabled with no Savings, Continue refuses an amount that does not cover the Instant fee or that Savings no longer cover, with their own copy, and the offline and node-sync states show as before. Funding shows the old No Available Funds dialog when Savings is empty.

  • Keeps the old confirm screen and copy, with the swipe and its rotating coin stack and Learn more. The four tiles become Network fees, Spending fee (the Spark claim fee for the chosen speed), To spending (the amount less that fee) and Total (what leaves Savings). Between the tiles and Learn more, two rows pick the speed: Instant, ready in a few minutes, up to the Instant ceiling; Later, after 3 confirmations, about 30 minutes, at the claim at 3 confirmations priced at the half-hour fee rate. Instant is the default. Advanced, the channel bar and the background-setup toggle are dropped, since Spark has no LSP balance and sends no setup push.

  • Prices the confirm screen when it appears, when the speed changes and again at swipe, and sends the transaction at the fee rates it showed. When the total rose or To spending fell since it was shown, the swipe pays nothing, the tiles refresh, and the old Fees changed toast asks for another swipe.

  • Brings back the setting-up screen with its animation and steps, driven by the Spark deposit. Instant polls the deposit every 5 s for up to 5 minutes: within the ceiling it claims early at once and says Spark is adding it, then shows the old success screen once the deposit is in the balance; above the ceiling, or when the provider reprices it, it shows what Instant costs now with Pay and Wait, never swapping the fee silently; with no early option, or a declined claim, it says Instant isn't available and Spark adds it after 3 confirmations; when Spark never lists the deposit, it says so and that the user can leave. Later makes no claim call. The poll runs in the app scope, so leaving the screen does not drop an Instant claim within the ceiling.

  • Restores the Liquidity screen's layout as the Learn more screen, since the Instant or Later choice is real decision information under Spark: it explains the two speeds, repeats the Spark trust note and shows the spending balance bar after this transfer, with Understood.

  • Keeps the SDK from claiming early on its own: the wallet-wide claim cap stays at the recommended fee plus 2 sat/vB and drops the 0.2% share. Lowering it below the spread keeps the deposit from being claimed early, and does not hold back its claim at maturity. The Instant ceiling keeps the old formula with the 0.2% share.

  • Brings back Transfer to Spending from a hardware wallet: master signs and broadcasts an on-chain send from the hardware wallet to any address (HwSendViewModel.signAndBroadcast takes the address from its caller), so the restored intro, amount, sign and signed screens send to the Spark deposit address, priced from the device's account, with the same speed choice and setting-up screen. The hardware amount step reuses the restored amount screen with its old test tags, instead of a second copy of it. The send is recorded as a transfer.

  • Restores hasSeenTransferIntro and hasSeenSpendingIntro in settings and their migration from the React Native app, removed by the sunset.

  • 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.

  • 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.

  • 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.

  • 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.

  • Refuses Spark sends while a wallet restore is still pending, before and after the payment is authorized, as master already does for channel sends, so no payment leaves before the restored wallet is in place.

  • Returns a Spark Lightning send as soon as it starts, like ldk-node, so its completion event reaches the send screen's watcher, which starts after the send returns, instead of firing before it.

  • Counts Spark's Lightning payments in Paykit's private invoice checks, so an invoice a contact already paid through Spark is replaced instead of published again, and an invoice this wallet already paid through Spark isn't paid twice. Before, a contact who had paid the Spark invoice once found no Lightning option on later requests.

  • Records a Spark send's fee when Spark's next sync reports it, since its completion event carries a fee of 0, and keeps the activity's contact and seen state when re-recording it.

  • Shows a Spark withdrawal's arrival in Savings as a transfer from Spending, not a plain received payment, and lists each withdrawal with the fee Spark kept on the Spark screen. The Spark bar reads as 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.

  • Keeps plain Paykit working on Spark. Recovery reads Spark's payments, so a Paykit request or allowance payment whose Spark completion event was missed settles from Spark's record and its proof reaches the payee, and an allowance attempt is never recorded as failed while Spark cannot list its payments yet. The Paykit endpoints are republished when Spark becomes ready, so a Spark-only wallet offers Lightning to contacts after a cold start. A Spark send refused before it leaves (Spark not connected, the payment not preparable, or its fee over the limit) ends a request as failed with its proof removed and records the allowance attempt as failed; a failure inside the send stays pending. Spark sends keep a request's deadline and an allowance's 1% fee cap, a Spark receive recorded while the app was closed gets its contact, and the Lightning send limit leaves room for Spark's 0.25% service fee.

  • 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.

  • Adds the Spark emergency exit, so Spending can still reach Savings on-chain if the Spark operators stop working, which is what Spark's trust model rests on. Lightning Connections › Spark shows Emergency exit under Transfer to Savings while Spark holds funds or has recoverable funds, and leads to the exit's progress while one runs. The flow is an explainer, a quote from the SDK's prepareRecoverFunds for every leaf at its fastest fee rate to a fresh Savings address (amount to exit, network fees, you receive about, fee funding needed, what stays on Spark; Continue is blocked when nothing can be exited or the fees exceed the value), fee funding (pay from Savings when it covers the funding and its fee, or fund a shown address from another wallet), a swipe that writes the exit record and the transfer before any payment, and a progress screen with the steps, the timelock countdown, rejections, a top-up address when more fees are needed, and the result.

  • Pays the exit's CPFP fees from a key on its own account, m/84'/{coin}'/7797'/0/0, derived from the wallet seed with bitkit-core, so ldk-node never spends the funding UTXO or the CPFP change chained back to it; only the path index is kept, never the key.

  • Drives the exit with a state machine that builds once the funding confirms, saves every build before broadcasting, broadcasts ready steps as packages (/v1/txs/package) or single transactions to the SDK's own chain hosts (mempool.space then blockstream.info on mainnet, Spark's mempool on regtest), rebuilds a diverged recovery as a new generation that never sends the old hex again, asks for a top-up when a rebuild needs more fees, and ends with leftover funding shown. Without the SDK (operators down at connect, or a background start) it still broadcasts the stored, signed steps whose parents confirmed and whose timelock matured.

  • Keeps the exit going across restarts: the record (spark-exit.json, the same JSON as iOS, with the recovery in spark-sdk's serde shape) is reloaded on launch, the exit ticks every minute while Bitkit is open, and WorkManager drives it every 15 minutes while it is closed, with a reminder notification when a timelock matures or more fees are needed.

  • Records the fee funding paid from Savings as an ordinary payment in Activity, not a transfer to Spending, and says the exit takes up to 4 weeks.

  • Takes Spark off Lightning during an exit: no sends, receives or deposits on Spark, its balance shows as in transfer to Savings with the countdown on Home and Savings, and Spark reconnects with leaf optimization off until the exit ends, so no optimization swap hands an exiting leaf to someone else.

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.
  • Sweeping leftover exit fee funding: change from a branch the operators' watchtower published stays at the funding address, which the result screen shows; sweeping it needs a P2WPKH signer for plain spends.
  • Emergency exit translations: the 44 new strings carry the English text in every locale, as on iOS.
  • A manual fee bump for a running exit: rebuilds use the fee rate chosen at the quote.
  • Raising the wallet-wide claim fee cap so every deposit claims early by itself; the user picks Instant or Later per transfer instead.
  • Spark-specific design for the restored transfer screens: they keep the Blocktank-era layout with the copy Spark makes untrue rewritten.
  • Funding's Transfer from HW wallet option: it stays disabled; the hardware transfer starts from the hardware wallet screen, as before the sunset.
  • 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). A claim the SDK makes by itself may pay up to the recommended fee plus 2 sat/vB; an Instant claim up to the fastest rate plus 2 sat/vB times 99 vB plus 0.2% of the deposit, which the user accepts on the confirm screen. 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, with To spending after it, for the speed the user picks.
  • 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 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. The user now picks Instant or Later before the swipe, and an Instant transfer claims early as soon as the provider offers it within the shown ceiling. 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. 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.
  • Emergency exit on mainnet. Package relay at mempool.space and blockstream.info is untested with a real exit: a refused package keeps its step ready, the next host is tried, and the progress screen shows the error after 3 rejections. The exit can take up to about 4 weeks (a regtest exit of fresh leaves waited about 3,300–3,900 blocks end to end), and about 50 blocks after a step matures the operators may publish their own version, paid from the leaf; the reminder notification at maturity is the main lever, and without notification permission only the 15-minute background work and opening Bitkit drive the exit. Whether Spark connects while the operators are down is undocumented; the exit's keyless pass broadcasts the stored steps without it.
  • 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

Restored transfer screens, on the latest Bitkit - Handoff page of the Bitkit Figma file (frame names from docs/screens-map.md; the Spark speed rows, Spending fee and setting-up states have no frame yet):

  • Lightning Onboarding From Suggestion Card › Lightning onboarding Intro
  • Transfer to Spending Balance From Savings (Channel Opening) › Transfer to Spending Intro, Transfer to Spending Input, Transfer to Spending Confirm, Transfer to Spending Receiving Capacity (now Learn more)
  • Funds In Transfer › Transfer to Spending Progress 1
  • Transfer to Spending Balance From HW Device › Transfer to Spending Confirm HW, Transfer to Spending Confirmed HW

The emergency exit screens have no design yet; they follow the Transfer to Savings screens (docs/screens-map.md lists them as todo).

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

  • J1: updated transfer/spark-transfer-to-spending.xml — with Spark ready, the suggestion card opens the Spending Balance intro and Funding, Transfer from Savings opens the Transfer to Spending intro and the amount screen; 25% and Max fill the amount, confirm shows the four tiles with Instant and Later, Later updates the tiles, Learn more opens and returns; the intros show once.
  • 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 pad caps at Max with the Spending Balance Maximum toast.
  • J4: restored amount-limits/transfer-spending-preset-delete.xml — after 25%, Max or a unit switch, each delete removes one digit.
  • J5: restored transfer/spending-confirm-amount-change.xml — a lower price at swipe pays at once; a higher one asks for another swipe.
  • J6: restored hardware-wallet/transfer-to-spending.xml — a paired Trezor transfers into Spark through amount, sign and signed to the setting-up screen.
  • J7: restored hardware-wallet/passphrase-transfer-to-spending.xml — a hidden wallet signs with its live session, re-prompts after the session drops, and refuses a wrong passphrase.
  • J8: restored hardware-wallet/transfer-to-spending-node-warmup.xml — a hardware transfer started during node warm-up reaches the sign screen.
  • J9: new transfer/spark-emergency-exit.xml — with a Spark balance, Emergency exit opens the explainer, the quote lists amount, fees and what arrives, and funding shows Pay from savings and a funding address from another wallet; stops before the swipe.

Manual Tests

  • 18 and 21 cover the entry points; 19 and 20 are retired: they drove the Spark-only deposit screen's Use now and Wait, which the restored setting-up screen replaces (22 to 26).
  • 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.
  • 15. Mainnet, two Bitkit contacts, one Spark-only: the other pays its Paykit request over Lightning, then the Spark-only wallet sends a second Lightning-only request → the second request offers Spending and pays — needs two devices with Paykit.
  • 16. Mainnet, Spark send of a Lightning invoice → after Spark's next sync the activity shows the fee Spark charged, not 0 — needs mainnet routing.
  • 17. Mainnet, Spark with a balance → Transfer to Savings; the arrival in Savings shows as a transfer, and the Spark screen lists the withdrawal with its fee and an empty bar — needs mainnet.
  • 18. Mainnet, Spark ready, no channels → Funding (Spending card) shows Transfer from Savings and Use Other Wallet above Manual Setup; Transfer from Savings opens the Transfer to Spending intro the first time, then the amount screen; Savings → Transfer to Spending opens the same flow — mainnet.
  • 21. 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.
  • 22. Mainnet, Transfer to Spending of a small amount with Instant → the setting-up screen says Spark is adding it, then shows Funds moved to spending before 3 confirmations; when the provider asks more than the ceiling, it shows Instant now costs X with Pay and Wait — mainnet funds; the provider decides per deposit.
  • 23. Mainnet, same as 22 with Later → the setting-up screen says Spark adds it after 3 confirmations, about 30 minutes, and makes no early claim; the deposit lands after 3 confirmations at about the shown fee — mainnet funds.
  • 24. Mainnet, Instant, then on Instant now costs X tap Pay → Spark adds it at X; on Wait → it lands after 3 confirmations — mainnet funds and a repriced quote.
  • 25. Mainnet, Instant, leave the setting-up screen at once → the Instant claim still happens within the ceiling — mainnet funds.
  • 26. Mainnet, paired Trezor with funds → Hardware wallet → Transfer To Spending, sign on the device → the setting-up screen follows the deposit as in 22 — mainnet funds and a device.
  • 27. Dev on Spark's regtest chain, Spark funded from the faucet → Emergency exit, fund from another wallet with the faucet, swipe → Progress shows fee funding confirmed, then publishing with confirmed steps counting up and Ready in a countdown; kill and reopen the app mid-exit → it continues without sending a step twice; at the end Savings receives about the quoted amount and the transfer settles — faucet credentials and hours of regtest blocks.
  • 28. Same as 27 with Pay from savings → the funding payment shows as an ordinary payment, not a transfer to Spending, and the exit starts once it confirms — faucet credentials.
  • 29. During an exit → Spending shows the Spark balance as in transfer to Savings, Lightning receives and sends do not use Spark, and Transfer to Spending shows the Transfer Unavailable sheet — a running exit.
  • 30. Exit started but not funded → Cancel exit on Progress puts Spark back as it was — faucet-free, but needs a Spark balance.
  • 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 SparkExitRecordTest.kt — the record round trips with every phase and status, the recovery keeps spark-sdk's serde shape, a recovery written by the Breez CLI and a record written by Bitkit iOS are read, an unknown version is refused, and the store saves atomically and sets an unreadable record aside
  • added SparkExitDriverTest.kt — waits for confirmed funding, builds and saves before broadcasting, Valid broadcasts ready steps once until due, rejections surface after 3, Done ends with leftover funding, Redo rebuilds as a new generation that never sends the old hex (also without the SDK), a rebuild short of fees asks for a top-up that ignores CPFP change, cooperative failures retry, an unverified leaf waits, a restart mid-exit resumes from the file without repeating broadcasts, and the keyless pass broadcasts only steps whose parents confirmed and timelocks matured
  • added SparkExitFundingTest.kt — what arrives, the fees and the fee funding of a quote, the uneconomic gate, the funding shortfall, the countdown height, the funding UTXO choice, whether Savings can pay, the ETA per network, the funding path, WIF decoding and CPFP child txids
  • added SparkExitChainTest.kt — packages go out as a raw JSON array and single steps as plain hex, the unversioned path and the next host are tried, a node's refusal wins over an unavailable host, tip and UTXOs are read, and node answers are classified
  • added SparkExitRepoTest.kt — loading settles exit transfers no running exit owns, including the transfer of a record set aside as unreadable
  • added SparkExitRegtestTest.kt (unit, opt-in with SPARK_EXIT_REGTEST=1) — Spark's regtest mempool answers the tip and a funding address, and its node's per-transaction answer to a package is read as retry later
  • added SparkExitRegtestTest.kt (instrumented, opt-in with -e sparkExitRegtest true) — on a device against Spark's regtest, a new wallet's quote has nothing to exit, and the funding key derives with bitkit-core into a working CPFP signer
  • added SparkRepoTest.kt claim cases — Instant claims early within the ceiling and reaches Claimed, asks above it, shows the repriced fee from a repriced claim, pays a repriced claim at the new fee, is unavailable with only the standard claim or a declined claim, says Spark has not seen a deposit never listed, and Later makes no claim call; the claim step follows the offer and ceiling
  • added SparkFundingViewModelTest.kt — Max is Savings less the network fee and 25% a quarter of it, Max is 0 without Savings, Continue refuses an amount under the Instant fee, prices and opens confirm, the swipe asks again when the fee rose and refuses when Savings no longer cover it, sends at the quoted fee rates and follows the claim at the chosen speed, and a hardware transfer prices from the device and hands signing a transfer request
  • updated SparkServiceTest.kt — the Later fee prices the mature claim at the half-hour rate (198 sats at 2 sat/vB)
  • updated ContentViewTest.kt — the spending start route shows the intro until seen, for Savings and hardware wallets, and setting up drops the confirm screen from the back stack
  • added SparkClaimOfferTest.kt — 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 SparkServiceTest.kt — Lightning sends return before completion
  • added SparkRepoTest.kt cases — a restored exit backup is imported once Spark connects, one from another network is skipped, a send recorded with fee 0 is re-recorded with the fee a later sync reports, and a withdrawal is listed with its fee and its arrival marked as a transfer
  • updated PrivatePaykitRepoTest.kt — a private invoice paid through Spark is replaced on the next refresh
  • 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 TransferViewModelTest.kt — Transfer to Savings with only Spark doesn't wait for a Lightning peer
  • updated LightningRepoTest.kt — a Spark send is refused while a wallet restore is pending, or when one begins during authorization
  • updated LightningRepoTest.kt, WalletRepoTest.kt, WalletViewModelTest.kt — construct the new Spark dependency, idle, so existing channel paths are unchanged

Evidence

  • Merged master at cb59453ce (f7836bea4), no rebase, so stacked branches keep their ancestry: unit tests for backup, Paykit, wallet and Spark, and detekt pass locally; detekt and lint pass in CI (run). Draft CI skips the build and unit suite.
  • Rebased on master at fa49b5afd, keeping master's restore guards and connection recovery beside Spark: the build and unit suite pass in CI (run), and detekt and lint pass; the rebuilt flow and early claims (18 to 21) are not yet driven. Results below ran on the earlier flow before the rebuild; their revisions are kept as run.

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 (a 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).

  • Transfer flow restore on 1196c954d: just compile, the unit suite, detekt and Android lint pass locally. The screens were not driven on a device: the states after the swipe are shown by the SpendingConfirmScreen (Instant, Later, loading, small screen) and SettingUpScreen (looking, adding, repriced, unavailable, later, not seen, success) previews.

  • Emergency exit on e79929c8a: just compile, the unit suite, detekt and Android lint pass locally. Against Spark's live regtest mempool, the chain client read the tip and a funding address, and a fake parent with its child sent as a package came back per transaction as bad-txns-inputs-missingorspent, which the client reads as retry later.

  • Emergency exit on regtest, Android 16 emulator, Spark's regtest (326a6aac6): a new wallet funded Spark with 100,000 sats from Lightspark's web faucet (99,901 after the claim). Emergency exit quoted 90,112 sats to exit, 8,923 network fees, about 81,189 to receive, 9,211 fee funding and 9,789 staying on Spark as dust. The funding address shown by Fund from another wallet got 10,000 sats from the faucet, the screen said Payment seen, and the swipe built 30 transactions (1 fan-out, 24 node, 4 refund, 1 sweep). The fan-out and 20 node packages (TRUC parent with CPFP child) were accepted by Spark's mempool and confirmed, with no rejection, across three app restarts that resumed from the saved record without sending a step twice. It then waited for the leaf nodes' 1,900–2,000 block timelocks (about 17 hours on regtest); the refunds and sweep were not reached. Home showed the 90,400 sats in transfer with Spending at 0. The run found three bugs, fixed in 326a6aac6: the SDK's check never saw the fan-out confirm on Spark's regtest, whose mempool reports no spender for an outpoint, so the driver now marks steps the chain shows in a block before each check; the foreground loop never started, since Android Bitkit removes the startup provider ProcessLifecycleOwner needs, so ContentView drives it; and the timelock step showed done while the refunds still waited.

  • Emergency exit on e79929c8a, Android 16 emulator, Spark's regtest: the instrumented SparkExitRegtestTest.kt connected a new wallet with leaf optimization off, quoted it with prepareRecoverFunds (nothing to exit), derived the funding key at m/84'/1'/7797'/0/0 with bitkit-core into a bcrt1q address and a working CPFP signer, and read the funding address on Spark's mempool. The app launched without errors.

Gaps

  • 27: run on regtest up to the timelocks, as Evidence says; the refunds, sweep and settle wait about 30 hours of regtest blocks. 28 to 30 and J9: not run as listed cases; J9's steps were driven in the regtest run.
  • Emergency exit on 4771ead5d: just compile and the exit unit tests pass.
  • Emergency exit on mainnet: not run; the package endpoint at mempool.space is untested with a real exit.
  • J1 to J8 and 22 to 26: not run yet on the restored flow.
  • 18 to 21: not run yet; they need a mainnet build on a phone.
  • Early claims: the provider decides per deposit whether to offer one, so 19 may show only the standard claim; that is the flow working, not a failure.
  • 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; Lightspark's web faucet funds regtest addresses without credentials.
  • 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.

Models used

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

@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 3 times, most recently 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
@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from eab67dd to 567e1a8 Compare October 27, 2026 01:46
@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 a Pixel 10 Pro, mainnet, by @ovitrif, 26–27 Oct; amounts are sats. Balances, names, addresses and txids are masked.

🟢 Restore Spark exit data
Exit data backs up as its own category. After a wipe and restore, the Pixel's Spark balance and claimed deposits came back, but the log counted 0 imported leaves because the exit data was restored after Spark had already synced. Ran on 567e1a8; the order is fixed in f96bae9, and a rerun is pending.

🟢 Claim a deposit
Transfer to Spending: Spark claimed each deposit after 3 confirmations, keeping 297 sats of 15,000 and 198 of 2,500. Ran on an earlier head of this PR, before the 27 Oct rebase.
🟢 Receive with zero balance, then pay
With no channel and a 0 Spark balance, the Pixel received 1,024 sats over Lightning and paid 128 from them two minutes later: Lightning received into Spark is spendable at once. The send's activity showed a fee of 0, fixed in 972e5ec. Ran on 567e1a8.

🟢 Move Spending to Savings
Transfer to Savings withdrew 12,973 sats from Spark for a 1,470 sat fee; the withdrawal showed in Savings unconfirmed after 22 seconds and confirmed after 31 minutes. Ran on an earlier head of this PR, before the 27 Oct rebase; a later run on a build of #1710 lists the withdrawal as a Transfer from Spending.

🟢 Fees against Blocktank
On the same wallet, a 15,000 sat Transfer to Spending cost 907 sats in all through Spark (6.0%) and 1,584 through Blocktank (10.6%). Moving back to Savings cost 1,470 sats through Spark, where Blocktank channel closes cost 183 to 1,113.
🔴 Pay a Paykit request over Spark
The iPhone paid this Pixel's 204 sat Paykit request over Spark and it arrived at once, but the iPhone stayed on Payment Pending; fixed in 024b6bb and synonymdev/bitkit-ios@735e972e, not rerun on a device yet. An earlier attempt failed because this Pixel republished a private invoice already paid through Spark, fixed in 972e5ec.

@ovitrif
ovitrif force-pushed the fix/blocktank-sunset-sheet branch from 901ecd8 to 2ba664e Compare October 27, 2026 17:35
@greptile-apps greptile-apps Bot mentioned this pull request Oct 27, 2026
3 tasks
@ovitrif
ovitrif changed the base branch from fix/blocktank-sunset-sheet to master October 28, 2026 08:29
@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 force-pushed the feat/breez-spark-lightning branch from 024b6bb to d7be600 Compare October 28, 2026 14:35
@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

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

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detekt found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@ovitrif

ovitrif commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator Author

Rebuilt this branch on master after the Blocktank sunset (#1696). 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 commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

I restored this branch to 171b250, its head before my push at 14:33 CET. That push had put the commits of a stacked branch here by mistake: the Paykit Spark route, 28a63f8 and fea5a92. That work now lives on its own branch, feat/paykit-spark-route, and nothing else on this branch changed.

@ovitrif ovitrif mentioned this pull request Nov 2, 2026
1 task
…ghtning

# Conflicts:
#	app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt
#	app/src/test/java/to/bitkit/repositories/PrivatePaykitRepoTest.kt
#	app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt
…ghtning

# Conflicts:
#	app/src/main/java/to/bitkit/repositories/LightningRepo.kt
#	app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsScreen.kt
#	app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 4771ead (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovitrif ovitrif added this to the 3.2.0 milestone Nov 3, 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.

2 participants