Repository navigation
Conversation
Moves the Spark balance on-chain without the Spark operators: quote, fee funding from Savings or another wallet, swipe to start, then a driver that builds, broadcasts and checks the SDK's recovery until it lands in Savings, after restarts and from background tasks too. Lost leaves are given up, fee funding left over is swept to Savings, and Spark stops sending, receiving and optimizing leaves while the exit runs.
|
QA on 5d18a238: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped, so every exit is unilateral.
Stale-state result, kept until seen: |
|
Fix for the leaf that came back in the next exit (QA comment), in 57118a6. After an older state claims a leaf, the operators keep listing it under the wallet. The lost leaf was only on the exit record, so the next exit quoted it again. It then rebuilt for each confirmation, lost the leaf a second time and notified twice.
Unit tests cover the list surviving a new exit, the quote and selection filters, an all-lost quote and the single notification. |
|
QA on 57118a69: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped. I installed it over the previous build, keeping the wallet.
Not covered: the later commits that move the mainnet chain service to Bitkit's electrs. This regtest build points the chain service at the local stack. |
- rebuilds over refused, unpaid or missing-input steps and a dead CPFP child at most 3 times while no step confirms, then says the exit is not moving - shows a step accepted but unconfirmed for over 6 blocks as underpriced, without rebuilding - never sends a unilateral Unverified step from the SDK path; rebuilds over it once a block - waits for leaves skipped as Unverified, FundsNotFound or recently stopped, retrying at 6 to 144 blocks and letting go after 1,008 - only a Redo rebuild adds lost-leaf strikes - keeps the exit data exported before the first build and imports it before every rebuild - rebuilds a leftover sweep refused for its fee, and replaces one unconfirmed for 144 blocks - keeps an exit whose record can't be read, rebuilding it from the chain with its funding - counts down along the longest path of steps left, shows what reached savings, and adds each extra payment's input fee - ends a background run whose record can't be read before the first unlock, and reschedules
The SPARK_EXIT backup is not refreshed while an exit runs: the operators may stop reporting a leaf once it is being exited, so a fresh export could lack the leaf a restored wallet needs.
Tapping a Spark exit notification opens the exit's progress once the wallet is unlocked.
- a refusal for a nonzero fee under the mempool's minimum (min relay fee not met, N < M with N > 0, or mempool min fee not met) shows fee_low at once, without a rebuild, and its reminder keeps its first date - a zero fee and a lost replacement race stay on the broken-chain path - an unpaid child is never a refusal, a step confirmed since is not stuck, and Done counts what landed before a Redo
- Exits prepare with per-node CPFP funding on SDK 0.28 and resume with the strategy they started with; one payment of the quote's funding, which the SDK splits per step. Fan-out outputs no longer count as new funding. - Progress offers "Pay a higher fee" when the exit is underpriced or the network's next-block rate passes its own: each higher rate with its cost, paid from savings, then the unconfirmed steps are rebuilt and replaced. - Cooperative leaves the operators moved on-chain but could not co-sign are retried for about a week instead of ending the exit, and the result says plainly what is left and that it is not on Spark. - The pre-start partial-payment hint includes the next input's fee.
- Done starts with the amount that reached savings and keeps the sweep's transaction ID after a failed-leaf retry replaced its generation. - Released leaves: "The operators can still release it to you." - Pay a higher fee says the amount next to each speed is what savings pays, and says savings can't cover it when no rate is affordable.
|
QA of the Glow hardening on df489ccd: dev build on an iOS simulator, against a local Spark regtest stack with the operators stopped except where a case needed them. The later 0.28.0 commits (per-step funding, fee bump) aren't covered.
|
|
QA on 3d871fbc: dev build on an iOS simulator, Spark on its hosted regtest, funded with 20,000 sats as a Spark transfer.
|
|
@pwltr on your review (#1217 (review)):
Fixed in 86432fa: a salvaged exit now imports its retained exit data before the first rebuild, so preparation can use the recovery information salvage kept. A test runs salvage followed by preparation; it failed before the fix. 0abf0ab makes the same change to the start as synonymdev/bitkit-android#1782's fix: a start cut short before its transfer stores no exit, and one cut short after it leaves only an orphan transfer to settle. I dismissed your six earlier changes requests on this PR, since every finding in them is fixed in this branch; each dismissal names its fix commits. This push also merges the updated base from #1128 (b4f3e04). With it, 4b4b975 keeps the balance shown after a failed withdrawal out of an exit, and 6818189 shows Spark as 0 in Spending and Lightning Connections during an exit, as Android does. The round also brings the Exit screens in line with Android, including the hourglass taken from Android's vector (8fcf7c5). Your review and @jvsena42's approval go stale only because of this push. |
jvsena42
left a comment
There was a problem hiding this comment.
Re-review of the 9 own commits since 64e62fc62: no findings.
- Interrupted start:
writeStart(SparkExitManager.swift:875-893) creates the transfer, setstransferId, then saves, so a record is never stored without its transfer. If the save fails, the transfer is settled. If the app is killed in between,settleOrphanedTransferssettles the leftover transfer on the next load. - Salvaged exit:
useExitStateSnapshot(SparkExitDriver.swift:446-453) imports the kept snapshot before the first rebuild. This matches Android'swithExitState. - Spending during an exit: the kept balance is cleared while exiting, and
shownSpendingSatsreads 0, so the exiting amount shows only as in-transfer. - Countdown: the driver keeps updating the claimable height while it waits for a top-up.
- The remaining commits are view and asset alignment, with no state writes.
No new device run: these commits change the exit screens' layout and the start/salvage bookkeeping, which unit tests cover (testAStartCutShortBeforeItsTransferStoresNoExit, …AfterItsTransferLeavesOnlyAnOrphanToSettle). The flow gate from the earlier approval still stands.
Reviewer model: claude-fable-5-1 (delta review), claude-opus-5-5 (coordination)
Reasoning effort: not exposed (Fable subagent), default (Opus)
|
Pushed 48b2252 with the iOS twins of the synonymdev/bitkit-android#1782 review round:
@jvsena42, your approval at 6818189 is stale only because of this push: 6818189...48b2252 |
jvsena42
left a comment
There was a problem hiding this comment.
Delta review of the commits since my approval at 6818189 (head 27b2ce1).
c1a7ee4 fixes the cancelled-exit kept balance in the common case. The reorg handling (172e564) and the fee-bump floor (48b2252) look correct. The UI fix, revert and fix trio adds up to a non-interactive bottom gradient on Progress.
New in this delta:
- MEDIUM, twin of Android #1782: since 1374063, a savings funding refused at start for a higher prepared fee has no way to retry (inline).
- LOW: the kept balance is saved after
isExitingflips, so a refresh in between loses it (inline).
Reviewer model: claude-fable-5-1 (delta review), claude-opus-5-5 (coordination and verification)
Reasoning effort: not exposed (Fable subagent), default (Opus)
# Conflicts: # Bitkit/Services/LightningService.swift
|
Pushed 5e9ddd7. It has @jvsena42's two findings from 5848662300, the wipe half of the Android LOW, and the latest Spark base (5a56f12) merged in.
In the merge, the exit still reads Spark's payments through its history rail, now behind the base's first-sync guard. @jvsena42, a re-review is needed only because of this push: 27b2ce1...5e9ddd7 |
# Conflicts: # Bitkit/Managers/SparkManager.swift
|
Pushed 2a203b4, with the iOS twins of @pwltr's synonymdev/bitkit-android#1782 review and the latest Spark base (cae1c29) merged in.
In the merge, @pwltr @jvsena42, a re-review is needed only because of this push: 5e9ddd7...2a203b4 |
|
QA for 2a203b4:
Recording and numbers are in the QA Notes under Test results. |





Closes #1242
Twin: synonymdev/bitkit-android#1782
Stacked on #1128
This PR adds Emergency exit, Spark's unilateral exit: if the Spark operators stop working, Spending moves to Savings on-chain without them. It builds on the Spark wallet in #1128, which already backs up Spark's exit data to VSS as
SPARK_EXIT.Description
What this PR does
Decisions for review
https://bitkit.to/electrsand broadcasts there first, with mempool.space and blockstream.info as fallbacks.m/84'/{coin}'/7797'/0/<index>, a fresh index per exit), so ldk-node never spends the exit's funding.Background run on iOS
BGAppRefreshTaskand aBGProcessingTaskrun the exit with Bitkit closed, when iOS grants them. Each run sends the signed steps, starts Spark alone (never ldk-node), and stops cleanly when the OS expires it.Notifications on iOS
Out of Scope
SPARK_EXITexit data backup: in feat: add spark via breez sdk #1128.Risks for review
Design
N/A — no design available.
Preview
Emergency exit at 6818189 on an iPhone 17 Pro 2x simulator (iOS 27.0) in Bitkit's dark theme. Start and Fund from another wallet run live against Spark's hosted regtest with notifications denied; another wallet pays the fee funding in two parts, and the waits for the payments are cut. Pay from savings is the earlier recording from dfc1716 and predates this PR's latest UI fixes (its step icons and inline transaction ID are the old ones): Savings on the simulator runs on a separate regtest chain, so a savings payment can't be recorded again here. Running exit, Redo and sweep, and Exit complete are staged from saved exit records on a debug build, each state shown in turn; Done with the leftover swept is shown through a local hook that is not in this PR.
01-start.mp4
p1217-2-savings2x.mp4
03-external.mp4
04-running.mp4
05-redo.mp4
06-complete.mp4
Screens
Every exit screen, at 6818189 on an iPhone 17 Pro 2x simulator (iOS 27.0) in Bitkit's dark theme. Start screens and the notification prompt are from the live exit above; Savings is empty there, so Funding choices shows Pay from savings as unavailable and Confirm follows Fund from another wallet. Progress and Result screens are staged from saved exit records on a debug build, with Needs funding, partial and Payment seen reading real payments on Spark's regtest chain. Done, leftover swept and the Pay a higher fee rates are shown through a local hook that is not in this PR; Pay a higher fee reads 0 sat/vB because the staged record saves no fee rate, and its speeds are unavailable because Savings is empty.
Start
Progress
Result
QA Notes
Setup
regtest/localsetup (the last public operator), with bitcoind, 3 operators, the SSP, electrs and mempool, mined on demand. Two patches make SDK 0.27.2 work with that operator: the operator skips theRETURNEDstatus filter it does not know, and the wallet turns fee ladders off, so every leaf there is an anchored leaf.prepareRecoverFundswithAll, thenSpecificon a rebuild,recoverFundswith a single-key CPFP signer,checkRecoverFunds, package broadcast through mempool), each step a separate process resuming from storage.Journeys
journeys/transfer/spark-emergency-exit.xml— Emergency exit up to the swipe: explainer, quote, funding choices and the funding address, with nothing started (ported from feat: spark emergency exit bitkit-android#1782). Needs Spark's regtest faucet for the balance.Manual Tests
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"to.bitkit.spark-exit"], continue → the log showsSpark exit background run: connected=truewith the time per step; repeat withto.bitkit.spark-exit.processing, then once more followed by_simulateExpirationForTaskWithIdentifier:→ the run stops, and the next run sends nothing already accepted. Turn Background App Refresh off for Bitkit → Progress shows the note; Low Power Mode on → its note — the simulator has noBGTaskScheduler.non-BIP68-finalnever counts toward the 3-refusal alert — same.Automated Checks
SparkExitReviewTestscovers cancel (off once paid or seen; funding at the address is swept back, an unreadable address keeps the exit), no height from a failed tip read, the live generation driven while a higher fee's top-up confirms, a dropped or stale savings payment brought back to an ask, a diverged rebuild's top-up sending nothing, the snapshot imported before fee-bump pricing, the reminder for an unfunded exit, fired reminders remembered, the sweep's bounded states, salvage keeping chained transactions, the connect timeout returning on time, cancel revalidating a built record, a confirmed partial sweep following what it left out, only-failed cooperative leaves waiting for the operators, fee-bump pricing past a refused leaf, per-exit reminder ids and the background chain's economy rate;SparkExitBackgroundRunTestscovers a failed pass handing back the saved record.SparkExitReviewTestsalso covers a refused savings payment shown until a payment is kept, and paying again retrying only the refused attempt.SparkExitFeeBumpTestsandSparkExitSweepTests, plus dark-mode screens rendered with stubbed state by a local hook not in this PR. Not run on a live chain yet.SparkExitTests.swift— the background entry point from a cold start sends stored steps and then checks with the SDK only, signs a build once funding confirmed, stays keyless when Spark does not connect or time is short, and after an expiry mid-broadcast leaves a whole record from which the next run sends nothing twice; reminders are planned at each unpublished timelock plus the grace, dropped once a step is published, sent at once for fee funding and repeated refusals, and after a stall while steps wait for parents; the record round-trips with every status, error and input variant; the recovery is written in spark-sdk's serde shape and a CLI-written one reads back; an unknown version is refused; the driver waits for a confirmed funding UTXO, saves before broadcasting, broadcasts ready steps once until due, finishes on Done with the leftover read, rebuilds a new generation on Redo after saving it, asks only for what the inputs lack onInsufficientCpfpFunds, adopts several small payments together and ignores CPFP change at the funding address, resumes after a restart without repeating broadcasts, retries cooperative leaves the operators could not sign, and broadcasts without the SDK only steps whose parents confirmed and timelocks matured; a leaf whose refund two rebuilds reproduce after its input was spent is lost, alone or with every leaf (the exit stops); a rebuild leaves behind a leaf that cannot pay its refund and retries, falls back toRecoverableOnlywhen the error names no leaf, and stops when none is left; the quote math, funding states, funding key path, the package and single broadcasts, path and host fallback, and the node answer classifierSparkExitSweepTests.swift— the leftover sweep spends every UTXO at the funding address to Savings less its fee, each input's signature verifies against its BIP143 digest with libsecp256k1, a leftover under dust after the fee is skipped, output scripts for every address type, an ended exit keeps its result reachable until the sweep confirms, and a background run sweeps without starting SparkSparkExitRegtestTests.swift— opt-in (TEST_RUNNER_SPARK_EXIT_REGTEST=1): a fresh wallet on Spark's hosted regtest connects and quotes an empty exit, the chain host serves the tip and funding UTXOs, a fake parent and child package reaches the live node and is read as retry later, and the background entry point runs from a cold start over an exit of the live run's shapeSparkExitTests.swiftalso covers a fee chain that lost a race, with the operators down and the check answering Valid (rebuilt from the same funding, the dead chain never resent), a child still in the mempool, the safety net's rebuild, which never counts as a refusal, the chain host's not-found lookup, and the needs-funding amount never under the 294-sat dust limit, and a replay of a single-leaf exit an older state claimed, which across its rebuilds delivers only the lost notificationSparkExitTests.swiftalso covers mainnet reading only frombitkit.to/electrsand broadcasting there first (packages on/txs/package) with mempool.space and blockstream.info as fallbacks, a transaction counted gone only on a 404 from the one read host (never on a 5xx or timeout), lost leaves from a stored record joining the lost-leaf list on load, ended exits included, and lost leaves kept out of the stays-on-Spark and can't-be-exited amounts whether the Spark balance still counts them or notSparkExitTests.swift,SparkExitSweepTests.swiftandSparkExitBackupTests.swift:SPARK_EXITbackup is not uploaded while an exit runs;SparkConfigTests.swift— leaf optimization is on, and off while an exit runsTest results
Full in-app exit on Spark's hosted regtest, at 2a203b4 on an iPhone 17 Pro 2x simulator (iOS 27.0), dark mode, a fresh dev wallet:
BGTaskScheduler. This line is updated at Done with the sweep and what reached the destination.exit1-start2x.mp4
QA of SDK 0.28.0 (comment), at 328b29a on an iOS simulator, local Spark regtest plus one exit on Spark's hosted regtest:
/txwith real codes. Unit tests cover it.OperatorsUnavailablewait, and the fee-change wait from high network fees. The changes from the independent review are unit-tested only.QA (earlier):
Tested on an iPhone 17 Pro simulator (iOS 27.0) against Spark's hosted regtest, and on an iPhone 17 Pro simulator (iOS 26.5) against a local Spark regtest stack (QA Notes, Setup).
Device tests skipped⚠️ :
BGTaskScheduler(4).🟢 Exit paid from savings, Exit funded from another wallet
Exit paid from savings
1 and 5 at aa01843: the quote read 90,112 to exit, 7,969 in fees, about 82,143 received, 8,257 of fee funding and 9,789 staying on Spark, named as balance it can't exit; Pay from savings sent 8,679 from Savings, and Progress showed it as paid with its transaction ID and no QR, with the notifications note after permission was denied; recording under Preview.
Exit funded from another wallet
6, and 2 up to publishing, at aa01843: the faucet paid 8,257 to the funding address; Funding showed Payment seen, Progress waited for it with its transaction ID, and once it confirmed the exit built 28 transactions and published the first two; recording under Preview.
🟢 Full exit to Savings, Resume after a kill
Full exit to Savings
On the local stack at 030de21, with the operators running: a 100,000 sat Spark balance quoted 90,112 to exit, 4,855 in fees and 6,031 of fee funding; Pay from savings sent 6,453 (the funding plus its fee). The app published each refund once the 1,900-block timelock matured, before the operators' 1,950-block version, and sweep
02c28284…confirmed at height 9,246, paying 91,288 sats to Savings (90,112 − 4,855 + 6,031). The result read "Exit complete. Your funds are in savings. 18 small amounts stayed on Spark."; recordings under Preview. That build still asked to send the funding after paying it, which 169ff91 fixes.Resume after a kill
Bitkit terminated at 6 of 22 transactions with one block mined while closed reconnected on relaunch and broadcast the next packages.
🟢 Rerun on the fixes, Failed exit
Rerun on the fixes
On the local stack at 674f0da, one simulator wallet, two exits, leaf optimization off in the test build:
Failed exit
At 41d8569: a single-leaf exit (32,768, received straight from a previous owner) with the operators down, where the previous owner's older refund won while the app was closed. About 16 minutes after relaunch the app dropped the leaf, swept the 626 leftover to Savings and showed two notifications. Emergency exit kept the result until it was opened once ("Exit stopped", "₿ 32,768 was claimed on-chain by an older owner of these funds and can't be recovered", and the leftover line); the next opening showed the fresh intro.
🟢 Operators stopped, Operators broadcast first, Funding short, Stale state
Headless runs through the harness, all at 2 sat/vB, each wallet funded with a 100,000 sat deposit (99,865 after the claim fee), which the SSP splits into 17 power-of-two leaves.
Operators stopped
With all 3 operators stopped, SDK 0.27.2 connected in 29 ms, quoted from local storage in 0.1 s and built 25 transactions (1 fan-out, 19 node packages, 4 refunds with a 1,900-block CSV, 1 sweep); every package was accepted. 4 leaves (90,112 sats) went out, 13 leaves (9,753 sats) were skipped as
FeeExceedsValue, and 91,680 sats reached the destination (90,112 plus 1,568 unused funding through the sweep) over 1,961 blocks, about 13.6 days on mainnet.Operators broadcast first
With the app asleep 60 blocks past maturity, the operators' watchtower broadcast its own refunds (1,950-block CSV, fee from the leaf); the next check returned
Redo(OnChainStateDiverged), theSpecificrebuild of the same 4 leaves needed no funding, and the exit reached Done with 85,749 sats. The 5,035 sats of first-generation change it left behind are what the leftover sweep now sends to Savings.Funding short
With 9,035 sats over 5 UTXOs against a 8,541 quote,
recoverFundsfailed withInsufficientCpfpFundsasking for 9,085 in total; a 1,000 sat top-up was adopted with the earlier UTXOs, and the exit reached Done. The app now asks for the 50 missing instead of 9,085.Stale state
A previous owner broadcast its older refund for a 16,384 sat leaf once it matured, 100 blocks after the current owner's, while the current owner slept, and swept it. The current owner's check returned
Redo, and each rebuild reproduced refundbe4def69…, refused asbad-txns-inputs-missingorspent. The app now drops such a leaf after two identical rebuilds (Rerun on the fixes). With the operators down, the 100 blocks after each maturity are the hard deadline, about 16 hours on mainnet.Evidence
df489ccd: the exit tests and the unit suite (CI's selection) pass on an iPhone 17 Pro simulator (iOS 27.0); SwiftFormat and the translation check pass. Not run on a regtest stack yet.5d18a238: the exit tests and the unit tests (CI's selection) pass on an iPhone 16 simulator (iOS 27.0); SwiftFormat passes.46ae7556: the exit tests and the unit suite (CI's selection) pass on an iPhone 16 simulator (iOS 27.0); SwiftFormat passes.174d9ed7: the exit tests and the unit suite (CI's selection) pass on an iPhone 17 Pro simulator (iOS 27.0); SwiftFormat and the translation check pass.022d3ed2: the exit tests and the unit suite pass on an iPhone 17 simulator (iOS 27.0).335182af: the unit suite and the exit tests pass on an iPhone 17 simulator (iOS 27.0). Against Spark's hosted regtest, the background entry point from a cold start, over a stored exit of 30 steps: keyless pass 1.03 s (7.00 s before status reads ran concurrently), SDK connect 0.03 s (before the SDK's own sync),checkRecoverFundson one real step 0.55 s (0.17 s warm), one package broadcast to the live node 0.17 s,prepareRecoverFunds0.55 s, whole run 1.26 s against a 25 s refresh budget; the process footprint grew by about 2 MB (25 to 27 MB, in a test host with no UI).f6d639cb:SparkExitRegtestTestspasses against Spark's hosted regtest (empty-wallet quote, chain reads, a live package answer).Open
BGTaskScheduleron a real device. The simulator has noBGTaskScheduler, so the OS granting and expiring the exit's tasks is not exercised (4).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.
Round 1
https://bitkit.to/electrs, Emergency exit opened up to the quote (Nothing can be exited right now), and the SDK's fee estimate and the exit's chain reads went tohttps://bitkit.to/electrsonly. Nothing was broadcast. On 7220fd0 on a simulator, with debug-only logging (result not posted).Round 2 (pending)
On the final heads, per the small mainnet exit: a fresh wallet, a low-fee week (about 2 sat/vB) and about 20,000 sats in Savings for the fees.
https://bitkit.to/electrs, and none is published by the operators first.Models used
claude-opus-5-5(reasoning:medium),claude-sonnet-5-5(reasoning:high)claude-opus-5-5(reasoning:medium)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-5planning runs were research and audit subagents.