You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat: channel balances move to spark at low fees - #1212
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.channelsOnlyoption toLightningService.sendandestimateRoutingFees, used throughWalletViewModel.sendOverChannels, so the move always goes over the channels and never falls back to Spark; the defaultshouldPayWithRailrouting is unchanged.criticalpriority, and Home checks again once channels and Spark have loaded after a cold start.Out of Scope
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.
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):
i1-sheet.mp4
i2-move.mp4
i3-close.mp4
i5-no-channel.mp4
QA Notes
Setup
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.SparkManagerandEnv, takes the stack's Spark config fromSPARK_LOCAL_CONFIG_B64at 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.Journeys
move-to-spark.xml— sheet once, entry button, move with routing fee, close afterwards, nothing for a wallet without a channelManual Tests
Automated Checks
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 SparkEvidence
Tested at
e9abd54a, this change before its rebase onto0d2dde55(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.ymlpasses 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.jspass.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) atfa535a9d: the build, SwiftFormat lint, the translation check and the unit test selection fromunit-tests.ymlpass. The device runs above ran before this rebase; the Move to Spark code is the same apart from dropping theisExitingchecks and using its own amount row.Gaps
Models used
claude-opus-5-5(reasoning:medium)claude-opus-5-5(reasoning:medium)