Skip to content

feat: channel balances move to spark at low fees - #1212

Draft
ovitrif wants to merge 7 commits into
feat/breez-spark-lightningfrom
feat/move-to-spark
Draft

ovitrif wants to merge 7 commits into
feat/breez-spark-lightningfrom
feat/move-to-spark

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1206

Twin: synonymdev/bitkit-android#1774
Dependency: #1128

This PR adds Move to Spark: a one-time sheet and an action that pays the balance of a Lightning channel to the user's own Spark wallet over the channel, then offers to close the emptied channel. Blocktank is closing its channels, and moving the balance over Lightning costs only the routing fee, where a close followed by a deposit costs on-chain and claim fees and 3 confirmations. It stacks on #1128 and stays a draft until that merges.

Description

  • Adds MoveToSparkManager, which quotes the move against a new amountless Spark invoice: it starts from what the channels can send, leaves room for the routing fee LDK estimates, and caps the payment's routing fee at the fee shown.
  • Adds a channelsOnly option to LightningService.send and estimateRoutingFees, used through WalletViewModel.sendOverChannels, so the move always goes over the channels and never falls back to Spark; the default shouldPayWithRail routing is unchanged.
  • Adds the Move to Spark screen: quote, swipe to move, then "Moved to Spark" with Close Connection, which opens the existing cooperative close; after a partial move it offers Move The Rest instead.
  • Shows "Amount too large" with a Move-half button when a payment cannot route, down to a 1,000 sat minimum, so a large balance can move in parts.
  • Adds a timed sheet that tells channel holders Blocktank is closing its connections and opens Move to Spark; it shows once, at a new top critical priority, and Home checks again once channels and Spark have loaded after a cold start.
  • Adds a Move To Spark button to Spending and Lightning Connections while a channel can still send at least 1,000 sats to a usable Spark wallet.
  • Keeps the move's own send and receive from raising the usual payment sent, received or failed notices, since the screen reports the outcome.
  • Fits the smallest screens: button labels stay on one line at 360 dp and on an iPhone SE (Later / Move Now, Cancel / Move Half, Later / Move Rest, Later / Close), the buttons sit at the bottom with the illustration taking the flexible height, and headlines put the white words first and the shorter accent last, as master's screens do.

Out of Scope

  • Activity: the move shows as one received payment, since the LDK send and the Spark receive share a payment hash; how a move should appear in Activity is left to the design pass.
  • Design: an AI-made first version from existing components; a Figma design and designer review follow.

Design

N/A — no design available.

Screens

iPhone SE (3rd generation) simulator, in flow order. Bitkit draws dark only, its system bars included, and no screen here shows a keyboard or system sheet.

Post-update sheet Spending entry Connections entry Finding a route
Too large Confirm: amount and fee Moving Done: part moved
Done: close offer Close confirmation After the close Could not move

Preview

The recordings below predate the small-screen pass, so they show earlier labels and headlines; the Screens section shows the current ones.

Sheet, move, close and a wallet without a channel on the local Spark regtest (2x):

Sheet on Home Move from the sheet Close afterwards No channel, no sheet
i1-sheet.mp4
i2-move.mp4
i3-close.mp4
i5-no-channel.mp4

QA Notes

Setup

  • A local Spark regtest: spark-sdk regtest/local (docker compose -f regtest/local/docker-compose.yml up), which runs the operators, the SSP with its Lightning node, an LNURL server, data sync, electrs and a chain API.
  • A debug build pointed at that stack. This PR has no switch for it; the tested build carried a local-only patch that, in SparkManager and Env, takes the stack's Spark config from SPARK_LOCAL_CONFIG_B64 at launch and sets its chain service, LNURL domain, sync URL and Electrum. Missing prerequisite for reviewers: that patch, or a shared way to point debug builds at a local Spark stack.
  • In the app, open Lightning Connections, Manual Setup, and open a channel to the stack's SSP Lightning node (the node URI the stack prints) from a funded Savings balance; wait for it to confirm.

Journeys

  • J1: new move-to-spark.xml — sheet once, entry button, move with routing fee, close afterwards, nothing for a wallet without a channel

Manual Tests

  • 1. With a route that cannot carry the balance, open Move To Spark → "Amount too large" offers to move half; repeating it moves a part that routes, and Done offers Move The Rest — needs a route limit the local stack cannot set

Automated Checks

  • added MoveToSparkMathTests.swift — first quote leaves room for a guessed fee, the amount fits under the limit with the fee, the fee cap covers the quoted fee, halving stops at the minimum, no move without a usable Spark

Evidence

Tested at e9abd54a, this change before its rebase onto 0d2dde55 (same diff; debug build, local Spark regtest stack, iPhone 17 Pro simulator on iOS 26.5), with the local-only patch from Setup and one 400,000 sat channel to the SSP:

  • J1 sheet: after a cold launch with a funded channel, the sheet shows on Home.

  • J1 move: from the sheet, ₿ 394,967 moved with a ₿ 0 routing fee (direct channel to the SSP); Done offered Close Connection.

  • J1 close: Close Connection, then Close, returned to Lightning Connections with no open connection and Spark at ₿ 394,967.

  • J1 no channel: a new wallet showed no Move To Spark button in Spending and no sheet on Home.

  • The unit test selection from unit-tests.yml passes on an iPhone 17 Pro simulator (TEST SUCCEEDED), on the same pre-rebase source.

  • The rebased branch builds; SwiftFormat lint of the changed files and node scripts/validate-translations.js pass.

  • Small-screen pass at 0dc18beb: the sheet, confirm and Done screens captured on an iPhone SE simulator; button labels stay on one line, the buttons sit at the bottom and the headline stays whole. The build and SwiftFormat lint pass.

  • Rebased onto the rebuilt Spark base 0eed9c2a (without the emergency exit) at fa535a9d: the build, SwiftFormat lint, the translation check and the unit test selection from unit-tests.yml pass. The device runs above ran before this rebase; the Move to Spark code is the same apart from dropping the isExiting checks and using its own amount row.

Gaps

  • 1 was run on Android only (feat: channel balances move to spark at low fees bitkit-android#1774, with a local route-limit hook); the iOS too-large and Move The Rest states share the logic and copy but were not driven on the simulator.
  • J1's "not again after relaunch" step was not repeated on iOS.
  • Only direct channels to the SSP were tested; a multi-hop route with a real routing fee was not set up locally.
  • Mainnet runs on phones come at polish time.

Models used

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

@ovitrif

ovitrif commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 543d414d: the Move To Spark button in Lightning Connections now sits below the open channels instead of above the Spark section, next to the channel it moves and clear of the Spark row and detail that the Spark exit work is changing on the base branch. Layout only; the build and SwiftFormat lint pass.

@ovitrif

ovitrif commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed e8356ce3..0dc18beb, a small-screen pass on the Move to Spark screens:

  • Button labels stay on one line on an iPhone SE: Later / Move Now, Cancel / Move Half, Later / Move Rest, Later / Close.
  • Bottom buttons sit at the screen bottom; the sheet's illustration shrinks so its headline and copy stay whole.
  • Headlines put the white words first and the shorter accent last, as master's screens do.
  • All locales follow the same headline shape and short labels.

Screenshots on an iPhone SE are in Preview. The build, SwiftFormat lint and the translation check pass.

@ovitrif
ovitrif force-pushed the feat/breez-spark-lightning branch from ffba7c7 to 0eed9c2 Compare November 5, 2026 02:10
@ovitrif
ovitrif force-pushed the feat/move-to-spark branch from 0dc18be to fa535a9 Compare November 5, 2026 02:31
@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto the rebuilt Spark base 0eed9c2a, which drops the emergency exit, and force-pushed with a lease (this PR is a draft): 0dc18beb → fa535a9d.

  • MainNavView and NavigationViewModel conflicts kept the new base's routes plus only .moveToSpark.
  • fa535a9d adapts to the removed exit: Spark readiness is SparkManager.isReady alone (no isExiting), and the screen uses its own MoveToSparkAmountRow instead of the exit's row.

The build, SwiftFormat lint, the translation check and the unit test selection pass. The device runs and screens in the description were taken before the rebase on the same Move to Spark code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant