Skip to content

feat: add spark via breez sdk - #1697

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

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

Conversation

@ovitrif

@ovitrif ovitrif commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1805

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 through the restored transfer screens, 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.

The emergency exit lives in the stacked exit PR, #1782, and the Paykit route over Spark in #1754, both stacked on this one.

Description

What ships in 3.2

  • Spark via Breez SDK 0.28.0.
  • The restored transfer to Spending, with Instant or Later.
  • Paykit on Spark.
  • The Transfer to Savings selection.
  • Breez real-time sync and LNURL off.
  • Mainnet chain service https://bitkit.to/electrs.
  • The UI pass: bottom buttons stay at the bottom, content scrolls above them.
  • The VSS SPARK_EXIT backup category.
  • Not here: the emergency exit is in feat: spark emergency exit #1782, and the Paykit route over Spark in feat: pay contacts via spark #1754.

Transfer to Spending on Spark

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

Spark wallet and Lightning

  • Adds the Breez SDK on Spark (breez_sdk_spark:bindings-android:0.28.0) 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.
  • Moves to Breez SDK 0.28.0, the latest stable release: an expired Lightning invoice Spark refuses now shows "This Lightning invoice has expired." on the send error screen, and a failed Spark sync is logged; the LNURL and data-sync servers stay off.
  • Turns off Breez's real-time data sync (realTimeSyncServerUrl = null), its LNURL server (lnurlDomain = null) and its default external input parsers (useDefaultExternalInputParsers = false) in Spark's config on every network, so the app sends no traffic to Breez's data-sync or breez.tips LNURL servers, offers no Lightning address, and the SDK sends nothing the user scans to third-party parsers.
  • Builds the SDK on mainnet with Bitkit's own Esplora (https://bitkit.to/electrs) as its chain service, in place of the SDK's default mempool.space and blockstream.info; regtest and staging keep the SDK's default.
  • 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.
  • 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.
  • 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, because Spark receives without inbound liquidity; Receive's amount check no longer asks for more liquidity or a CJIT while Spark can receive.
  • 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 added BREEZ_API_KEY to the release and release-internal environments, so Android mainnet builds ship with Spark on.
  • Backs up Spark's unilateral exit data to VSS as its own category, SPARK_EXIT, encrypted like the other backups. It is exported once Spark has connected and synced, and again, at most once a minute, whenever the SDK reports UnilateralExitStateChanged; until Spark has synced the backup waits and is not marked as backed up. The first upload in each app session reads the stored backup and merges it into Spark before replacing it, and fails rather than replace a backup it could not read, so a failed or skipped restore never loses the stored exit data. 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; a failed restore of it shows as an incomplete restore. The category shows in Backup settings only where Spark runs.
  • Shows a Spark send whose outcome the SDK did not return as Payment Pending, not as failed, and lets Spark settle it once Payment Pending tracks it: its payment event, or Spark's record after a sync (retried every 30 s while Spark cannot list its payments), ends it as sent or failed, and failed when Spark holds no record of it. A payment event settles its send and records its activity even when the balance read after it fails. The same invoice is never sent twice: a send in flight or pending returns Payment Pending, and one Spark already completed shows as paid.
  • Shows Spark and SDK errors on the send error screen with the existing payment failed copy; their text goes to the log only.
  • Keeps a Max Lightning send over Spark within the balance: the SDK prices sending the whole balance with fees included, and Max leaves that routing and service fee.
  • Wipes Spark with the wallet: deletes its storage and any staged exit data restore, fails the wipe when that fails, clears Spark's in-memory state, and refuses a Spark start while a wipe runs.
  • Marks a Transfer to Spending deposit as added on the setting-up screen as soon as Spark lists it as claimed, for Later transfers and Instant ones that land after the 5-minute poll.
  • Opens Payment Pending for a Transfer to Spending funding that was signed but not accepted, or that an earlier retained funding blocks, so the original transaction can be inspected and retried at a new fee instead of a generic error.
  • Fails the restore of Spark exit data when Spark rejects it while connected, keeping it staged for the next start, so the restore reports it as incomplete.
  • Syncs Spark right after it connects, so a first sync the SDK finished before its listener existed is not missed, and starts Spark's event collector only once.
  • Rounds a sub-sat amount of a Spark invoice up to the next sat instead of leaving the invoice without an amount.
  • Refuses Spark sends while a wallet restore is still pending, before and after the payment is authorized, as master already does for channel sends.
  • Returns a Spark Lightning send as soon as it starts, like ldk-node, so its completion event reaches the send screen's watcher instead of firing before it starts.
  • 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.

Transfer to Savings selection

  • Brings back master's Select funds step (Advanced on the confirm screen) with Spark as one more row beside the channels, in the channel row style, every row on by default, so users can move only their channels or only Spark's balance.
  • Moves only what is selected: selected channels close cooperatively as before, Spark withdraws its whole balance to Savings only when its row is on, a Spark-only selection closes no channel, and Continue stays disabled with nothing selected.
  • Offers Advanced once there are two items to choose from, counting Spark as one; with Spark alone, or one channel and no Spark balance, confirm moves everything, as master does with one channel.
  • Shows on confirm only what is selected: the selected channels' amount plus Spark's balance, and a Spark fee row (Spark's on-chain withdrawal fee) only while Spark is selected. The withdrawal reuses the quote confirm priced, since a second quote from Spark's provider took minutes on regtest.
  • Moves Spark's balance only at the fee confirm showed: while Spark is selected, the swipe and Close channel instead wait for the fee and stay off when it can't be priced, with the Spark failure copy, or when the balance less the fee would be dust, with "Amount is too low to transfer to savings."; the withdrawal reads Spark's balance afresh and refuses a fee above the one shown, and a sent withdrawal counts as sent even when the balance read after it fails. It runs once per transfer in the view model, so leaving the progress screen does not cut it short.
  • Sizes the swap to savings by what the selected channels can send, not the Spending total that includes Spark, and never swaps when no channel is selected.
  • Reports a failed Spark withdrawal instead of the success screen: an error says Spark's balance stayed in Spending, and that the connections are still moving to savings when channels were selected too.
  • Marks a Spark withdrawal's arrival in Savings as a transfer from Spending by the txid its send returned, so Home shows no Received Bitcoin sheet for it.

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, and republishes the Paykit endpoints when Spark becomes ready, so a Spark-only wallet offers Lightning to contacts after a cold start. Paykit requests paid over Lightning go through Spark when no channel can carry them.
  • 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.
  • 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.
  • Reads Spark's payments in Paykit recovery (the Lightning payment status and the proof reconcile), so a request or allowance payment whose Spark completion event was missed settles from Spark's record and its proof reaches the payee. Recovery waits for Spark's first sync before it reads a send as unsent, so an allowance attempt is never recorded as failed while Spark cannot list its payments yet.
  • Settles each Lightning send from the rail it went out on: a failure from an earlier Spark attempt never ends a retry over a channel, and the node's record of an earlier failed attempt never overwrites a Spark retry's succeeded activity.
  • Asks Spark before treating a Lightning payment the node failed as final, in the proof reconcile and the allowance payment status, since a Spark retry of the same invoice shares its hash and decides it.
  • Ends a request as failed with its proof removed, and records the allowance attempt as failed, when a Spark send is refused before it leaves (Spark not connected, the payment not preparable, or its fee over the limit); 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.
  • Receives a contact's amountless Paykit invoice on Spark unless the ready channels' inbound covers 1,000,000 sats, so a contact can pay more than a small channel's inbound. A plain amountless Receive keeps going to a channel with inbound, and invoices with an amount still go to a channel whose inbound covers it.
  • Auto-pays a request an allowance covers over Spark when the node is stopped or no channel is usable, as long as Spark's balance covers it.
  • Runs the whole Paykit payment check (the request still payable, its context and identity current, the contact not blocked) after Spark prepares a payment, right before it sends, so a cancel, block or identity change that lands while Spark prepares sends nothing.

Screen layout

  • Keeps the bottom buttons and swipes of the Spark, Transfer to Spending and Transfer to Savings screens at the bottom, with the content scrolling above them on short screens. On short screens the Transfer to Spending number pad shrinks its keys so Continue stays visible.

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.
  • 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 (Mainnet tests, round 1), 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. On mainnet, the round 1 deposit was claimed at 3 confirmations under the earlier automatic cap. 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.
  • 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 unless the build uses ESPLORA_URL.

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
  • Transfer to Savings (Channel Closure) › Transfer to savings select funds, Transfer all funds to savings review (the Spark row and Spark fee have no frame yet)

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.

UI pending design: design was requested in the #design-ux Spark 3.2 thread, and some screens may change after the designers answer. The exit UI polish list isn't part of this PR.

Preview

Transfer to Spending, Receive and Transfer to Savings on 9f44c5b, on an Android emulator at 896×1994 (2 px per dp) in dark mode, with Savings and Spark on Lightspark's regtest chain, at 2x speed. The Instant claim's adding, repriced and unavailable states and the two channel rows are staged with local debug-only hooks that are not in this PR; Instant, Later, the Pay after repricing and the Spark-only Transfer to Savings run for real.

Transfer to Spending: intro, amount, confirm, Learn More, Later Transfer to Spending: Instant Instant repriced, then Pay
p1.mp4
p2.mp4
p3.mp4
Instant unavailable Receive with no channel Lightning Connections → Spark detail
p4.mp4
p5.mp4
p6.mp4
Transfer to Savings: Spark only Transfer to Savings: channels only Transfer to Savings: one channel plus Spark
p7.mp4
p8.mp4
p9.mp4
Transfer to Savings: nothing selected Transfer to Savings: no Spark balance
p10.mp4
p11.mp4

Screens

Every screen the PR adds or changes, on the same emulator and build in dark mode. Staged with local debug-only hooks that are not in this PR: the Instant adding, repriced, unavailable and not-seen states, the channel rows on Transfer to Savings and the Spark transfer failure. The hardware wallet screens use a Trezor emulator on a local regtest chain.

Transfer to Spending
Spending intro Funding Transfer intro Amount
Confirm, Instant Confirm, Later Learn More (transfer speed)
Funds in transfer
Setting up, waiting for Spark Setting up, adding Setting up, repriced Setting up, Instant unavailable
Setting up, Later Setting up, not seen yet Funds moved
Transfer to Spending from a hardware wallet
Transfer intro Amount Sign with your device Transaction signed
Transfer to Savings
Select funds with Spark Confirm with Spark fee Spark transfer failed
Lightning Connections
Spark row Spark detail

QA Notes

Setup

  • 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 from Lightspark's web faucet, which needs no credentials, or with 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).
    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.
    • On 4 Nov, Spark's regtest mempool answered every plain POST /tx with sendrawtransaction RPC error: {"code":-1}, while POST /v1/txs/package accepted the same transaction. If a Savings send fails that way, forward /tx to the package endpoint (a one-element JSON array) in the proxy.
  • Transfer to Savings selection (J10 and the recordings above): Spark's regtest has no Lightning peers, so the channel rows were staged with a local hook, not in this PR, that adds two open channels to the Savings screens. The channel cases stop before the swipe; channel closes and the swap are covered by TransferViewModelTest.kt. Channels with no Spark balance was opened through the debug screen link bitkit://screen/savings-availability, since Spending was 0.
  • 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.
  • J10: new transfer/savings-select-funds.xml — Select funds lists Spark and each channel, all on; confirm shows only what is selected, with Spark fee only while Spark is selected; Transfer all restores everything; nothing selected disables Continue; stops before the swipe.

Manual Tests

  • 19 and 20 are retired: they drove the Spark-only deposit screen's Use now and Wait, which the restored setting-up screen replaces. Mainnet cases are under Mainnet 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 the network fee, the Spending fee for the chosen speed and no Advanced, swipe broadcasts to the Spark deposit address — same.
  • 10. Dev, 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 from the faucet → Transfer to Spending; after 1 confirmation Spark claims the deposit and Spending shows it; Lightning Connections lists it as claimed — Spark's regtest chain.
  • 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.
  • 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.
  • 37. Dev on Spark's regtest chain with a Spark balance, Transfer to Savings with Spark selected and the withdrawal failing (Wi-Fi and data off before the swipe) → an error says Spark's balance stayed in Spending and no success screen shows; with channels selected too, it says the connections are still moving — needs a failing Spark withdrawal.
  • 38. Dev on Spark's regtest chain, Transfer to Savings with Spark selected → when the withdrawal reaches Savings, Home shows no Received Bitcoin sheet and Activity lists it as a transfer — Spark's regtest chain.
  • 40. Dev on Spark's regtest chain, Spark balance below its withdrawal fee plus dust → Transfer to Savings with Spark selected shows "Amount is too low to transfer to savings." and the swipe stays off — needs a tiny Spark balance.
  • 41. Dev, wallet with Spark exit data backed up → restore the seed on a fresh install with VSS unreachable during the restore: the restore reports Spark exit data as incomplete; once VSS is back, the next upload logs Merged stored Spark exit data before it replaces the stored backup — needs two installs and control of VSS reachability.

Automated Checks

  • added SparkRepoTest.kt 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; 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, a withdrawal is listed with its fee and its arrival marked as a transfer, and a Spark send refused before it leaves (fee over the limit, past its deadline) is a typed refusal while a failure inside the send is pending
  • 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
  • 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; the config turns off Breez data sync, the Lightning address server and the default external input parsers on every network; the Later fee prices the mature claim at the half-hour rate (198 sats at 2 sat/vB); connect is refused during a wallet wipe; wipe deletes Spark's storage and a staged exit data restore
  • 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
  • updated PrivatePaykitRepoTest.kt — a private invoice paid through Spark is replaced on the next refresh
  • updated PaykitPaymentProofRepoTest.kt, PaykitAllowancePayerTest.kt, PaykitAllowanceRepoTest.kt, AppViewModelSendFlowTest.kt — a Lightning proof settles from Spark when its event was missed and is removed when Spark failed the send, a send neither rail knows was never made, a Spark send is never read as unsent before Spark has synced, Spark becoming ready or syncing settles open allowance attempts and republishes the Paykit endpoints, Spark sends keep the deadline and fee limit, a receive recorded without its event gets its contact, and the send limit leaves room for Spark's service fee
  • updated BackupRepoTest.kt — SPARK_EXIT uploads the exported state and restores it, defers without uploading until Spark has connected and synced, merges the stored backup into Spark before the first upload, never replaces a stored backup it could not read, reports a failed restore as incomplete, and uploads when Spark was ready and synced 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 — a selection moves only the picked channels and Spark: Spark only closes no channel and doesn't wait for a peer, channels only leave Spark untouched, both move both; a swap skips Spark when it is deselected and is sized by the selected channels; a quote left from a channel selection never swaps a Spark-only transfer; Spark's fee is priced once per balance and gives up after 15 s; a failed Spark withdrawal is reported while the selected channels still close; Spark is never withdrawn at a fee the user was not shown, and is withdrawn once per confirmed transfer
  • updated SparkRepoTest.kt — a Spark withdrawal pays the quote its fee estimate priced while it holds and asks again once it expired or the address changed; its arrival is marked a transfer by the txid its send returned; a withdrawal above the confirmed fee, or leaving dust, never leaves; a failure inside a send ends as failed once Spark has synced and holds no record of it; an invoice in flight, pending or paid is not sent again; the exit backup waits for Spark's first sync; a wipe fails when Spark's storage is not deleted and clears Spark's state; a Max send keeps the routing fee Spark quotes for its whole balance; a Later deposit reads as claimed once Spark claims it; a payment event settles even when the balance read fails; a restore whose exit data Spark rejects fails and keeps it staged; a sent withdrawal succeeds when the refresh after it fails
  • updated PaykitPaymentProofRepoTest.kt, PaykitAllowancePayerTest.kt — a Spark retry decides a Lightning payment the node failed
  • updated SparkFundingViewModelTest.kt — a funding signed but not accepted opens its recovery instead of an error
  • updated LightningRepoTest.kt, AppViewModelSendFlowTest.kt — an earlier Spark failure never settles a retry the node is paying, a Spark retry of an invoice the node failed settles it, and the node's earlier failure never overwrites the Spark retry's success
  • added PaymentFailureReasonExtTest.kt case — Spark and SDK failures show the existing payment failed copy
  • updated NotifyPaymentReceivedHandlerTest.kt — an on-chain receive from a Spark withdrawal is a transfer with no received sheet
  • 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

QA

Each result at the revision it ran on. Dev builds ran on Android 15 and 16 emulators on Spark's hosted regtest.

  • ✅ J1 spark-transfer-to-spending.xml on 4e22b9c, Breez SDK 0.28.0: passed, with Savings funded with 100,000 sats on staging regtest. It passed before on ab1b09d, and by hand on 1adfc47 (recording in Preview).
  • ✅ 1. Receive without channels, on 7aa44f6: the Spending invoice's payee was Spark's node with a Spark route hint.
  • ✅ 2 and 13. Transfer to Spending on Spark's chain, on 1adfc47: 20,000 sats with Instant were claimed after 1 confirmation (99 sat standard claim, no early offer), and 10,000 sats with Later showed about 99 Spending fee and were claimed at 99 sats.
  • ✅ 10. Spark in Lightning Connections, on 07a1df7 and a93f3dc (recording in Preview).
  • ✅ J10's cases by hand, on b0169b2 and 5edd5a8 with two staged channels: Select funds, partial and empty selections and the Spark fee row as listed; Spark alone swiped reached Funds moved to savings with no channel close, and Savings received 97,711 of 99,901 less the 2,190 fee (recordings in Preview).
  • ⏭️ 14 and 15: run on mainnet only, under Mainnet tests.
  • ⏭️ 21: needs a build without a Breez key.
  • ⏭️ 12: needs two devices.
  • ⏭️ 37 and 38: covered by TransferViewModelTest.kt and NotifyPaymentReceivedHandlerTest.kt, not run on a device.
  • ⏭️ 40 and 41, and the funding recovery and claim-state fixes in b29e9e5 and the per-rail settlement in 07440f6: the pending handling of a Spark send, the Transfer to Savings fee gate and its dust copy, and the SPARK_EXIT merge before the first upload; covered by SparkRepoTest.kt, TransferViewModelTest.kt and BackupRepoTest.kt only. The default external input parsers change is not run on a device either.

Mainnet tests (run by the author)

These run on real devices with mainnet funds, by the author. Reviewers and review bots don't need to run them, and they don't block review.
Setup: a mainnet build with BREEZ_API_KEY=<key> in local.properties. Round 1 ran on a Pixel 10 Pro.

Round 1

  • Transfer to Spending: Spark claimed the deposit after 3 confirmations, on ef7130e (result).
  • Receive over Lightning with no channel and an empty Spark balance, then pay from it two minutes later, on 567e1a8 (result).
  • Transfer to Savings of the Spark balance, on a head before the 27 Oct rebase (result); again on a QA build of 171b250 with master merged (result not posted).
  • Instant claim: the top-up was spendable before its first confirmation, on a QA build of 171b250 with master merged, through the earlier Use now screen (result not posted).
  • Spark's fee estimate read from https://bitkit.to/electrs/fee-estimates, read-only check (result not posted).
  • Wipe and restore on the same phone: the balance and claimed deposits came back, but 0 exit-data leaves were imported because the exit data was restored after Spark had synced; fixed in f96bae9, not rerun (result).
  • Restore on a QA build of 171b250: Spending balance error, from a race between the restore and the network graph reset; the same code is on master, so it's not Spark-specific (result not posted).
  • Paykit request paid over Spark: the payment arrived at once, but the iPhone payer stayed on Payment Pending; fixed in 024b6bb and synonymdev/bitkit-ios@735e972e, not rerun (result).

Round 2 (pending)

On the final heads, with a mainnet build and small amounts.

  • Transfer to Spending with Instant: the setting-up screen says Spark is adding it, then 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.
  • Instant repriced: Pay claims at X; Wait lands after 3 confirmations.
  • Instant, leave the setting-up screen at once: the claim still happens within the ceiling.
  • Transfer to Spending with Later: no early claim; the deposit lands after 3 confirmations at about the shown fee.
  • Hardware wallet Transfer to Spending: sign on the device, the setting-up screen follows the deposit, and the transfer lists once under the hardware wallet.
  • Spark ready, no channels: Funding shows Transfer from Savings and Use Other Wallet above Manual Setup, and both transfer entries open the same flow.
  • No channels, receive over Lightning: the received sheet and activity show it.
  • No channels, pay a Lightning invoice: paid through Spark; after Spark's next sync the activity shows Spark's fee, not 0.
  • Receive while the app is closed, then open it: the payment is claimed and shown.
  • Pay an invoice and drop the connection right after the swipe: Payment Pending shows, then it settles as sent or failed; paying the same invoice again sends nothing twice.
  • Transfer to Savings with only a Spark balance: the arrival shows as a transfer From Spending with no received sheet, and the Spark screen lists the withdrawal with its fee.
  • Transfer to Savings with a channel next to Spark: deselecting Spark keeps it in Spending and drops its withdrawal fee.
  • Existing channel: payments within its capacity still go over the channel.
  • Restore on another device: Spark exit data is imported before Spark is used, and the Spark balance and deposits come back.
  • Two Bitkit contacts, one Spark-only: a Paykit request paid over Spark ends on Sent for the payer and paid for the payee; a second Lightning-only request offers Spending and pays.
  • Spark's fee estimate and chain reads go to https://bitkit.to/electrs.

Models used

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

Taken from the run logs of every session and subagent that planned, wrote or reviewed this PR, which record the model and reasoning effort per turn. The claude-sonnet-5-5 planning runs were research and audit subagents.

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

@pwltr pwltr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rechecked commit 9f44c5b. A Spark send that failed while the app was closed now releases its held invoice when the activity sync records it, and a Spark funding kept for Payment Pending now completes the transfer from the funding screen once it lands, so dismissing Payment Pending no longer strands the confirm screen.

Low candidate — With Payment Pending open, two handlers settle a kept Spark funding and race (app/src/main/java/to/bitkit/ui/screens/transfer/SparkFundingViewModel.kt:344).

watchRetainedFunding follows the deposit at the confirm screen's speed and moves to Setting Up. If Payment Pending is still open when the attempt completes, its onRecoveredTransfer also runs followRecoveredSparkFunding (waitForMatureClaim) and navigates Home. Each call cancels the other's claim job, so the deposit ends Instant or Later, and the user lands on Setting Up or Home, depending on which runs last. Letting one path own the recovered funding would make the outcome deterministic.

Low candidate — The speed caption says Instant while the text says the transfer waits for confirmations (app/src/main/java/to/bitkit/ui/screens/transfer/SettingUpScreen.kt:230).

Unchanged: for Unavailable and NotSeen, whose text says the transfer is added after 3 confirmations, the caption still reads "Speed: Instant".

Review details

General review
Model: Claude Opus 5.5
Reasoning effort: high

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

@pwltr all four items from your two reviews on 9f44c5b (review, recheck) are fixed:

Preserve stored payment notes when replaying Spark activities.

Fixed in fd854d3: the replay keeps a non-empty message already stored on the activity, such as an LNURL comment, and falls back to the invoice description only when there is none. Test: replaying a send keeps the payment note stored on its activity in SparkRepoTest.kt.

Do not overwrite a successful retry with an older failed Spark attempt.

Fixed in a88a943: a replay that isn't COMPLETED is skipped when the stored activity is already SUCCEEDED, whichever rail paid it. Test: replaying a failed spark attempt never downgrades a payment that succeeded in SparkRepoTest.kt.

With Payment Pending open, two handlers settle a kept Spark funding and race

Fixed in aa61e5b: the open funding screen owns the kept funding, registering it when it starts watching and releasing it when the watch ends or the screen closes. While it owns it, onRecoveredTransfer only closes Payment Pending; once the screen is gone, the recovery settles it as before. fa02b81 also keeps the claim the screen chose. Tests: a recovered spark funding the funding screen settles only closes payment pending in AppViewModelSendFlowTest.kt, and the ownership checks added to a retained funding that completes later leaves the confirm screen for setting up in SparkFundingViewModelTest.kt.

The speed caption says Instant while the text says the transfer waits for confirmations

Fixed in aaafd92: Unavailable and NotSeen now read "Speed: Later". Tests: a transfer added after 3 confirmations shows the later speed and a transfer spark has not seen yet shows the later speed in SettingUpScreenTest.kt.

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Two more fixes, from checking this PR against issues found on its iOS twin synonymdev/bitkit-ios#1128:

  • 439d163: QuickPay's payment lookup read only the node's payments. After a restart, an older failed channel attempt could mark a QuickPay payment failed and release its daily budget while a Spark retry was still pending or had paid. The lookup now merges both rails into one row per invoice (paid, then pending, then failed), and reports unavailable, so the payment and its budget stay held, while Spark is disconnected or not yet synced. Twin of this iOS review. Tests: QuickPayPaymentLookupTest.kt.
  • 767f0eb: a fixed-amount LNURL withdrawal passes its msat amount as is, but a Spark invoice holds whole sats, so the Spark branch rounded 222,538 msat up to 223,000 and 999 msat up to 1,000, which the endpoint rejects. A sub-sat amount now goes to the channels when they can receive it, and is otherwise refused before the callback; whole-sat amounts still use Spark at the exact amount. Tests: a sub-sat withdrawal amount never gets a spark invoice for a different amount and a whole-sat withdrawal amount is received on spark at its exact amount in LightningRepoTest.kt.

@ovitrif
ovitrif requested review from jvsena42 and pwltr November 9, 2026 16:27

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⚠️ Partial: the PR now conflicts with master, so I posted the code review only and didn't re-approve. I'll re-approve after the rebase if nothing above LOW remains.

Delta review of 8dee167..767f0eb:

  • My two LOWs are fixed.
    • 8dee167: syncActivities releases a hash only when latestSend(hash) is FAILED, and a live failure first reads Spark's history again.
    • fa02b81: followRecoveredSparkFunding leaves an existing claim alone.
  • One new LOW is inline: the QuickPay startup reconcile is lost when Spark syncs after the node.
  • A residual of aa61e5b is in a reply on the LightningRepo.kt:2047 thread.
  • Checked and clean:
    • fd854d3 keeps the payment note on a replay.
    • a88a943 blocks the replay downgrade of a paid activity.
    • aaafd92 is caption-only.
    • 767f0eb refuses a sub-sat LNURL withdrawal before it requests anything.
    • The merged QuickPay rows only settle; they are never used to decide a dispatch, so they can't cause a double payment.

Reviewer model: Claude Fable 5.1 (claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@jvsena42

jvsena42 commented Nov 9, 2026

Copy link
Copy Markdown
Member

@ovitrif this conflicts with master; please rebase. I'll review it again once it merges cleanly.

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

@jvsena42 about your rebase ask: I merged master with a signed merge commit, 2de7b6b, instead of rebasing, so the branch needed no force-push. This PR merges cleanly now.

This push also has:

  • a180df6 and 984a21b, the fixes for your two new LOW threads (QuickPay reconcile, funding ownership), answered in each thread.
  • 7b7bdc0, a cross-check from your iOS review. The release in syncActivities read only the snapshot it recorded, so a Spark retry started during the activity loop could lose its hold. It now skips a hash whose send is still in flight, and reads Spark's sends again just before releasing. Test: a retry spark is still sending keeps its hash held through the activity sync in SparkRepoTest.kt.

No Spark test calls the Blocktank regtest helpers that synonymdev/bitkit-android#1817 test: drop blocktank-dependent tests removed, so the merge needed no test changes. The E2E run on 767f0eb passed every suite.

@ovitrif
ovitrif requested a review from jvsena42 November 9, 2026 17:48

@pwltr pwltr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed 7b7bdc0.

Medium — Pending Spark retry overwritten by an older failed channel attempt. CoreService.processBolt11 protects succeeded activity but allows bulk LDK synchronization to overwrite a pending Spark retry with the earlier channel failure at the same payment hash. Keep pending retry status authoritative until it settles.

High candidate — Channel retry admitted before Spark history has synchronized. SparkRepo.earlierLightningSend can treat a readable empty pre-sync list as permission to send through channels while a restored Spark payment may still be live. Require complete history or retained in-flight evidence before authorizing that retry. This identifies a duplicate-dispatch admission risk; duplicate settlement was not established.

Review details

General review
Model: GPT-6.1 Sol
Reasoning effort: high

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta review of a180df6, 984a21b, 7b7bdc0 and 665641e, after the rebase onto master: no findings. Both my items are fixed.

  • a180df6: QuickPayRepo also reconciles on isSparkSynced turning true, so the startup reconcile runs once both rails can list. A reconcile running at the same time can't release a record that is still pending, since a live op or a non-FAILED row keeps it.
  • 984a21b: the funding screen keeps ownership until onCleared. The ViewModel lives as long as the SparkFundingNav graph, so while Setting Up is shown, onRecoveredTransfer only hides the sheet. No path leaves the graph without clearing it.
  • 7b7bdc0: syncActivities reads Spark's list again before releasing. It skips hashes in sendsInFlight and releases only those still FAILED, so a retry started during the sync keeps its hold. This matches iOS #1128's fix.
  • 665641e: drops the empty super.onCleared().

The device gate passed earlier (gate_spark_1697). These commits change only the reconcile trigger, screen ownership and the release check, and unit tests cover each one.

Reviewer model: Claude Fable 5.1 (claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

@pwltr both items from your review on 7b7bdc010 are fixed:

Pending Spark retry overwritten by an older failed channel attempt.

Fixed in f811bca: CoreService.processBolt11 now leaves a PENDING row alone when the node's FAILED record is older than the row's last update, so a pending Spark retry stays until it settles. The node's own pending attempt still fails as before. Tests in ActivityServiceLightningMessageTest.kt:

  • payment sync keeps a newer pending spark retry over the channel's earlier failure
  • payment sync still fails the node's own earlier pending attempt (control)

Channel retry admitted before Spark history has synchronized.

Fixed in ecf9e53: SparkRepo.earlierLightningSend reads Spark's history only once Spark has finished its first sync (canListPayments). Before that, it decides from the persisted record of the Spark sends started on this wallet (sparkSendHashes, which the metadata backup also restores), and holds the node off any invoice that record names. Test: an empty history spark has not synced yet holds the node off an invoice spark started sending in SparkRepoTest.kt.

@jvsena42 this push makes your approval at 665641e stale. A re-approval is needed only for:

  • f811bca and ecf9e53 above.
  • d31e9f0: the regtest proxy fix from synonymdev/bitkit-android#1782 feat: spark emergency exit, ported to this branch. scripts/spark-regtest-esplora.py now forwards a POST that has no Content-Type as text/plain, so Lightspark's POST /tx accepts raw transactions. I checked it against a local upstream: before the change the proxy forwarded application/x-www-form-urlencoded, after it text/plain.

Your iOS cross-check on the live failure release does not affect Android: the in-flight guard from 7b7bdc0 covers only the sync path, and the live paymentFailed release in onPayment does not check sends in flight.

@ovitrif
ovitrif requested review from jvsena42 and pwltr November 9, 2026 18:53

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta review of f811bca, ecf9e53 and d31e9f0. They answer pwltr's review on 7b7bdc0.

  • ecf9e53 also holds a node retry while Spark is connected but hasn't synced its history. Spark that is unsupported, failed to start or is disconnected already took that path, so nothing new becomes permanently blocked, and the hold applies only to hashes in sparkSendHashes. The cross-rail duplicate checks are unchanged.
  • f811bca: the one LOW is inline. Its time-based guard can also hide a node-only failure. Reproduced in a unit test.
  • d31e9f0 only touches the regtest Esplora proxy script.

Reviewer model: Claude Fable 5.1 (claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

return
}
// A newer pending attempt, such as a Spark retry, stays until it settles; an older failure can't end it.
if (existingActivity is Activity.Lightning && existingActivity.v1.status == PaymentState.PENDING &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LOW: this guard compares a database write time with the payment's time, so a node-only failure can stay "Pending" until the invoice is retried (from f811bca)

bitkit-core 0.8.0-rc11 never stores the app's updatedAt. The column defaults to now on insert, and activities_update_trigger sets it to now on every UPDATE, including mark_activity_as_seen. So existingActivity.v1.updatedAt is when the row was last written, not when the payment changed. Any write to a PENDING node send's row that lands at least 1 s after ldk-node stamps it FAILED, but before the FAILED sync, makes this return. Such a write can be a late same-status resync, setContact, a comment write, or the restore seen sweep. The FAILED payment's latestUpdateTimestamp never changes, so every later sync drops it too, and the row shows Pending until the invoice is retried. Before this commit, a status change bypassed the timestamp check. No funds are at risk, because earlierNodeSend reads LDK's own list. The trigger is a timing race, so LOW.

Reproduced in ActivityServiceLightningMessageTest: a PENDING node send with updatedAt = 300 and empty sparkSendHashes, synced as FAILED with latestUpdateTimestamp = 200, fails with expected:<FAILED> but was:<PENDING>. This setup matches the commit's own "keeps a newer pending spark retry" test except for timestamp, which the guard doesn't read, so the guard can't tell the two cases apart.

Suggested fix: hold on Spark evidence instead of time: payment.id.lowercase() in cacheStore.data.first().sparkSendHashes. Spark adds the hash before sending and removes it only when it reports FAILED or NotFound.

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.

feat: spark lightning via breez sdk

6 participants