Repository navigation
Conversation
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
|
QA on a92ee43: dev build on an Android 15 emulator, Spark on its hosted regtest, Savings funded with 200,000 sats on staging regtest.
J1 on this PR, and the exit journey up to the Spark screen with no balance (2x speed).
|
When another wallet's package confirms a shared tree node, our CPFP child for it is dropped and every later step that spends its change can never confirm, while the SDK check still answers Valid. Detect that child on a Valid check and rebuild from the same funding. As a safety net, a step unpaid or missing inputs 3 times in a row rebuilds once, and those sends count as no rejection until then.
Unpaid sends no longer count as rejections, so without the SDK a step whose fee chain lost a race never surfaced and every background run looked on track. A step unpaid 3 times in a row now lets the deadline alarm fire.
The funding state was read before the tick, so a tick that ended short of fees showed the raw shortfall (68 sats on qa-03) until the next tick. Read it again after a tick that changed the phase, and floor the fallback ask at the 294-sat dust limit.
The stopped result read the amount outside the exit, which holds only while the exit runs, from a balance kept only during SDK ticks. It now reads Spark's live balance beyond the exit's leaves in any phase, as iOS does.
|
QA on 4df5c75f4: dev build on an Android 15 emulator, against a local Spark regtest stack with the operators stopped, so every exit is unilateral.
Stale-state result, kept until seen: |
|
Fix for the lost leaf that came back in the next exit (QA comment), in 8eac6db. Why: after an older state claims a leaf on-chain, the operators keep listing it as What changed:
The leaf id the app logs for a dropped leaf is the refund's Unit tests cover the store outliving a new exit record, the lost leaf left out of the quote and the selection, an all-lost quote with nothing to exit, and no second notification. Not rerun on a device yet. |
|
QA on 8eac6db67: dev build on an Android 15 emulator, against a local Spark regtest stack with the operators stopped. I installed it over the previous build, keeping the wallet.
The 🔴 lost-leaf bug from my QA comment on 4df5c75 is fixed. 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. |
# Conflicts: # app/src/main/java/to/bitkit/repositories/SparkRepo.kt
|
QA of Breez SDK 0.28.0 on d9d3c873d: dev build on an Android 15 emulator, against a local Spark regtest stack, plus one exit on Spark's hosted regtest. The commits since then up to ec38baa change no exit code.
|
|
Triage: in 3.2.0. This PR closed no issue, so I filed #1806 to carry the milestone and added |
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Ready for review: the emergency exit for Spark funds. It's stacked on #1697, which is also in review. Before marking it ready:
QA notes list each case with its revision and what is unit-tested only. The exit UI polish is pending design and is not part of this PR. |
[Critical impact] The PR adds an on-chain recovery path that moves users’ Spark funds into Savings. Fix the exit lifecycle, funding, and deadline failures before merging. Findings
SummaryAdds Spark emergency exits with quotes, fee funding, saved signed steps, background driving, deadline notifications, fee increases, and leftover sweeps.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
Quote[Quote exit] --> Funding[Wait for fee funding]
Funding --> Build[Build and save signed recovery]
Build --> Drive[Check chain and send ready steps]
Drive --> Wait[Wait for parents and timelocks]
Wait --> Drive
Drive --> Rebuild[Rebuild changed recovery]
Rebuild --> Funding
Drive --> End[End exit]
End --> Sweep[Sweep leftover funding to Savings]
Background[Foreground loop or background work] --> Drive
Alarm[Deadline alarm] --> Warn[Notify user and queue catch-up]
Reviews (1) · Last reviewed commit: "fix: spark exit bot review findings" · Reviewed by Greptile |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9ec930731c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Adds a unilateral exit for the Spark balance. It quotes every leaf through the Breez SDK, funds the CPFP fees from a dedicated BIP84 account (from savings or another wallet), and drives the exit through restarts, WorkManager and boot. When the exit ends, a hand-built PSBT sweeps the leftover fee funding back to savings. Stacked on #1697 and reviewed as the delta against feat/breez-spark-lightning. Reviewed 9ec93073, oversized tier (78 files, +13,579): the key, signing, transaction-building, broadcast and lifecycle paths read in full, the rest not read. Not built here: the sandboxed runner refused this PR, because it could not read the author's permission with the token it uses on this org. CI is the gate.
What I checked, and 6 candidates I ruled out
Read in full: SparkExitRepo.kt, SparkExitChain.kt, SparkExitSweep.kt, SparkExitFundingKey and SparkExitQuote in SparkExitFunding.kt, the manifest receiver, SparkExitReceiver, and the App.kt, CacheStore.kt, Env.kt, TransferDao.kt and TransferType.kt hunks.
Read in part: SparkExitDriver.kt: tick, build, prepare, withExitState, the funding and own-payment paths, and the whole leftover sweep (sweepLeftover through sendSweep).
Not read: SparkExitRecord.kt, most of SparkExitWorker.kt, the driver's check/rebuild/broadcastWithoutSdk section, the Compose screens and view model, and the tests.
CI: build, build-and-test, build-local, compose-ui / ui-tests, lint, detekt and Greptile pass. The e2e-tests-local shards were still pending when I read them.
Ruled out
- Funding key colliding with the wallet's own keys:
m/84'/{0|1}'/7797'/0/nis a hardened account of its own. It is derived through bitkit-core from the same mnemonic and passphrase, so a restore recovers it. - Funding index reused across exits:
nextFundingIndextakes the larger of the cached counter and one past the stored record's highest index.startLockedsaves the record before bumping the counter, so a crash between the two still moves the next quote past it. - Sweep PSBT malformed or overpaying: the unsigned transaction carries empty scriptSigs and a
witness_utxoper input.signedTxHexre-serializes the same body with marker, flag and eachfinal_scriptwitnessin input order.planreturns null when the total does not cover the fee plus the destination's dust limit. - Double payment from savings:
startandpayHigherFeesharepaymentMutexviatryLock.startLockedrefuses while an exit is active, and a fee bump landing after the exit ended or started rebuilding goes to the leftover sweep and is not applied. - Broadcast misread as success: only
success, already-known/in-chain messages and RPC -27 count as accepted. A child's missing-inputs error on a package is retried, never taken as a verdict on the parent, and a node's rejection outranks an unreachable host. - Boot receiver: it is not exported, the system still delivers
BOOT_COMPLETEDandMY_PACKAGE_REPLACEDto it, and its handler only re-queues the work. This is the same pattern asAppWidgetRefreshReceiver.
Merge confidence: 3/5, the oversized-tier ceiling: the record model, the worker, the UI and the tests were not read, and the e2e shards had not finished on this head.
Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the runtime does not report a reasoning setting for this session)

Closes #1806
Stacked on #1697
Twin: synonymdev/bitkit-ios#1217
This PR adds the Spark emergency exit on top of Spark (#1697): if the Spark operators stop working, Spending moves to Savings on-chain without them, which is what Spark's trust model rests on. It was split out of #1697 so that Spark can be reviewed and merged on its own; the code is the same as #1697's head before the split, merged with master.
Description
Screens and flow
prepareRecoverFundsfor every leaf at its fastest fee rate to a fresh Savings address: amount to exit, network fees, you receive about, fee funding needed and what stays on Spark. Continue is blocked when nothing can be exited or the fees exceed the value.Driving the exit
https://bitkit.to/electrsthrough/txs/package, then mempool.space through/v1/txs/packageand blockstream.info as fallbacks; on regtest to Spark's mempool), reads the chain on mainnet fromhttps://bitkit.to/electrsalone, rebuilds a diverged recovery as a new generation that never sends the old hex again, and ends, done or failed, by sweeping leftover fee funding to Savings. Without the SDK (operators down at connect, or a background start) it still broadcasts the stored, signed steps whose parents confirmed and whose timelock matured.min relay fee not met, 0 < …) or with missing inputs 3 times in a row, rebuilds behind the 10-minute gate; unpaid is never a rejection, and without the SDK a step stuck that way lets the deadline alarm fire, since nothing rebuilds it.Background run on Android
spark-exit.json, the same JSON as iOS, with the recovery in spark-sdk's serde shape) is reloaded on launch and the exit ticks every minute while Bitkit is open. While it is closed, a WorkManager job runs every 15 minutes from a cold process, also after the app is killed or the phone restarts: it publishes the stored steps, connects Spark alone (never the node) to check, sign and rebuild, and saves after each broadcast, so a killed run sends nothing twice. App start, boot and app updates queue the job again when an exit runs.SparkExitWorkerWorkManager job and aBOOT_COMPLETED/MY_PACKAGE_REPLACEDreceiver that queue it again while an exit runs, and hooks the foreground loop to the app's start and stop.Notifications, funding and the leftover sweep
m/84'/{coin}'/7797'/0/<index>, derived from the wallet seed with bitkit-core, so ldk-node never spends the funding UTXO or the CPFP change chained back to it; only the path index is kept, never the key.Lost leaves and fee-ladder leaves
Breez SDK 0.28.0: per-node funding, a higher fee, failed leaves
CpfpFundingStrategy::PerNode), so one lost fee race strands one step's funding instead of the chain after it. New exits keep the strategy in the record and resume with it on every prepare; exits started on 0.27.2 resume per branch. Savings, like another wallet, still pays one amount (single_utxo_sats), which the SDK fans out per step, since ldk-node sends to one address only; the funding screen says it is split into N parts. Each step's change stays at the funding address and the leftover sweep collects it at the end.leftovers.ts). Progress says they wait for the operators; afterwards Done says the amount was moved on-chain by the operators, is not on Spark, and that they can still release it. Done leads with the amount that reached Savings.OperatorsUnavailable, new in 0.28.0, waits likeFundsNotFound, and a salvaged record that had ended keeps its leftover to sweep.Independent review addressed
Review bots and team review addressed
SDK facts this relies on
prepareRecoverFunds(All), used for the quote, can't abort: leaves that can't pay their own exit are listed underskipped(FeeExceedsValue) and the rest is quoted.Specificrebuild can abort as a whole on an anchorless fee-ladder leaf; the app drops that leaf and retries with the rest.Glow comparison
Compared against Breez's reference wallet Glow (breez/glow-web
src/features/unilateral-exit/) and hardened where Glow is more correct; our own fixes stay where Glow has the bug (it has no detection for a lost CPFP race on a shared node).min relay fee not met, 5 < 17,mempool min fee not met) shows the same at once; an unpaid step (… 0 < 17) and a lost replacement race stay on the broken-chain path. Mainnet reads stay onbitkit.to/electrsalone; the cap guards against a stale index.Unverified). Once no unilateral step is ready, it rebuilds over it once a block at the same rate and funding, as Glow does (rebuild.ts,broadcast.ts). CooperativeUnverifiedsteps still go out. The keyless pass still sends such a step, since only the SDK can rebuild.UnverifiedorFundsNotFound, or asNotRecoverablewhileexitChainState.stoppedLeafIdshas listed it for under 6 blocks. It tries again after 6 blocks, at doubling gaps up to 144, and lets the leaf go after 1,008 blocks (about a week), as Glow does (quote.ts,leftovers.ts). Progress shows the wait.OperatorsUnavailable(0.28.0) waits the same way. Only Redo rebuilds strike a lost leaf, and never a stopped leaf still inside its wait.building.ts,rebuild.ts).model/progress.ts). The Done screen shows the amount received and the sweep's transaction ID. A top-up adds 68 vB × rate for each extra funding coin (exitFee.ts) and keeps the 294-sat floor. The background job reads the active wallet, and tapping an exit notification opens the exit.Out of Scope
Risks for review
Emergency exit on mainnet. No exit has run on mainnet. Mainnet leaves carry fee ladders, a different refund path that Spark's hosted regtest and the public operator code used for the local runs do not have, and package relay at
bitkit.to/electrs, mempool.space and blockstream.info is untested with a real exit: a refused package keeps its step ready, the next host is tried, and after 3 refusals the exit rebuilds, up to 3 times without progress before Progress says it is not moving. The exit can take up to about 4 weeks (local regtest exits took about 1,900–2,000 blocks from the first broadcast to the sweep, about 13–14 days at mainnet block times), and about 50 blocks after a step matures the operators may publish their own version, paid from the leaf; the 15-minute background work publishes inside that window, with the deadline alarm as fallback when Doze or the standby bucket holds it back. A user who restricted Bitkit's background use, or force-stopped it, gets neither until the next launch (Progress says so), and without notification permission the fallback is silent. At 2 sat/vB, leaves under about 4,096 sats cost more to exit than they hold and stay on Spark, so an exit of a typical balance recovers about 75% of it after fees.Per-node change waits for low fees. Each per-node funding entry is its child's fee plus the 294-sat dust limit, and that change stays at the funding address. The leftover sweep runs at the network's cheapest rate (never above the exit's), takes only coins worth more than their 68 vB input fee, and waits while what it would send is dust, checking again about once a day; after about 4 weeks (4,032 blocks) the leftover is kept at the funding key's derivation path, which the result names. Done says the fee change reaches Savings when network fees drop.
Design
N/A — no design yet. The emergency exit screens follow the Transfer to Savings screens;
docs/screens-map.mdlists them astodo. UI polish is pending design.Preview
Emergency exit on Spark's regtest: the explainer, quote and Fund from another wallet (
1adfc47); Pay from savings, the swipe, Progress waiting for the paid funding and then publishing (3e60f04); and the earlier exit waiting for its timelock with the background notes and the countdown on Savings (3e60f04).e1-explainer-quote-funding2x.mp4
e2-pay-savings-progress2x.mp4
e3-running-exit2x.mp4
A full exit on a local regtest stack (
4771ead, older copy): relaunch after the operators published the refunds first, the rebuild and the sweep to Savings, and the result of the first exit.q3-redo-sweep2x.mp4
Screens
Dark mode, default size.
QA Notes
Setup
ESPLORA_URLand the faucet), runs the exit from the explainer to Progress; the timelocks of about 1,900 blocks keep a full exit out of reach there.Journeys
transfer/spark-emergency-exit.xml— with a Spark balance, Emergency exit opens the explainer, the quote lists amount, fees and what arrives, and funding shows Pay from savings and a funding address from another wallet; stops before the swipe.Manual Tests
Driving the Spark exit in background, connects Spark and checks the exit with no screen open; with Wi-Fi and data off past the deadline alarm, a notification names the time to open Bitkit by — a running exit.non-BIP68-finalormissingorspentbroadcast — a running exit near its timelock.Automated Checks
SparkExitRecordTest.kt— the record round trips with every phase and status, the recovery keeps spark-sdk's serde shape, a recovery written by the Breez CLI and a record written by Bitkit iOS are read, an unknown version is refused, and the store saves atomically and sets an unreadable record asideSparkExitDriverTest.kt— waits for confirmed funding, builds and saves before broadcasting, Valid broadcasts ready steps once until due, rejections surface after 3, Done ends with leftover funding, Redo rebuilds as a new generation that never sends the old hex (also without the SDK), a rebuild short of fees asks only for what the SDK's total lacks beyond what the address holds (50 sats on the local regtest run's numbers, 2,000 after a Redo whose input lives on as 13,000 of change) and adopts several small top-ups together, the tick that ends the exit sweeps its leftover until a node accepts it, dust is not swept, a top-up ignores CPFP change, cooperative failures retry, an unverified leaf waits, a restart mid-exit resumes from the file without repeating broadcasts, and the keyless pass broadcasts only steps whose parents confirmed and timelocks maturedSparkExitFundingTest.kt— what arrives, the fees and the fee funding of a quote, the uneconomic gate, the funding shortfall, the countdown height, the funding UTXO choice, whether Savings can pay, the ETA per network, the funding path, WIF decoding and CPFP child txidsSparkExitChainTest.kt— packages go out as a raw JSON array and single steps as plain hex, the unversioned path and the next host are tried, a node's refusal wins over an unavailable host, tip and UTXOs are read, and node answers are classifiedSparkExitRepoTest.kt— loading settles exit transfers no running exit owns, including the transfer of a record set aside as unreadable; a background run from a cold start (Spark not connected, no UI) publishes from the stored recovery against a mock regtest mempool, connects Spark and checks, and a restarted process sends nothing twice; runs whose steps keep being refused stop pushing the deadline alarm back; a background run of an ended exit with leftover to sweep keeps going without starting SparkSparkExitSweepTest.kt— bech32, bech32m, P2PKH and P2SH addresses decode to their scripts, dust limits follow Bitcoin Core per output type, a sweep pays its inputs less the fee and skips dust, the signed transaction carries the signer's witnesses and keeps the unsigned txid, and an unsigned input is refusedSparkExitAlertsTest.kt— a stalled exit's notification names refused steps, no network, background restriction or battery saver (battery optimization alone names no cause), asks for fee funding, and stays quiet when nothing needs the userSparkExitRegtestTest.kt(unit, opt-in withSPARK_EXIT_REGTEST=1) — Spark's regtest mempool answers the tip and a funding address, and its node's per-transaction answer to a package is read as retry laterSparkExitRegtestTest.kt(instrumented, opt-in with-e sparkExitRegtest true) — on a device against Spark's regtest, a new wallet's quote has nothing to exit, the funding key derives with bitkit-core into a working CPFP signer, and a leftover sweep PSBT is signed and finalized by the SDK'ssingleKeyCpfpSignerSparkExitDriverTest.kt,SparkExitFundingTest.kt,SparkExitRecordTest.kt,SparkExitRepoTest.kt,SparkExitChainTest.kt(cc2641c) — Done after a Redo that needed no funding sweeps the earlier generation's change in one transaction, an exit that ended while its funding address could not be read ends again once it can, each exit takes a funding key index no earlier exit used and a restart signs with its own record's, without the SDK a refund waits for its CSV from its parent's confirmation and a step whose input someone else spent is not sent again, a refund an older state spent ends the exit after two rebuilds reproduce it, a lost leaf or onerecoverFundsrefuses is left out and the rest rebuilt, a rebuild whose every leaf cannot be exited fails, Progress waits for a payment from savings or one seen in the mempool and asks only for what is missing, the quote names what it skips, and the deadline warns within half the 50-block window on every networkSparkExitDriverTest.kt,SparkExitChainTest.kt,SparkExitRecordTest.kt,SparkExitRepoTest.kt,SparkExitViewModelTest.kt(2ed4673, f541952, 0baad3f) — two packages for one shared node where theirs confirms and ours is dropped rebuild from the same funding without resending the dead transactions, and wait for the rebuild interval sending nothing meanwhile; a child still in the mempool is not dead; a step unpaid 3 times in a row rebuilds once and then surfaces, and any other answer resets the count; a transaction is unknown only when every host answers 404; only a zero-fee refusal reads as unpaid; the new record fields round trip; a background run whose step keeps going unpaid without Spark lets the deadline alarm fire; a tick that ends short of 68 sats asks for 294 at once, and Progress asks for at least 294 before the funding address was readSparkExitViewModelTest.kt(4df5c75) — a stopped exit that lost a 32,768-sat leaf and could not exit 1,696 says ₿ 1 696 stays on Spark, from Spark's live balance, and claims nothing when only the lost leaf is leftSparkRepoTest.ktcase — an exit quote waits for a running leaf optimization and out the counter-swap grace of one cut short before it syncsSparkExitViewModelTest.kt— after Fund from another wallet, Back and Continue offer both funding choices againLightningConnectionsViewModelTest.kt,AppViewModelSendFlowTest.kt— construct the exit repositorySparkExitDriverTest.kt,SparkExitFundingTest.kt,SparkExitRecordTest.kt,SparkExitRepoTest.kt,SparkExitAlertsTest.kt,SparkRepoTest.kt(Glow comparison) — a refused step rebuilds until 3 rebuilds confirmed nothing, then reads as not moving, and a confirmation starts the count over; an unpaid step rebuilds and never counts as a rejection; a step accepted but unconfirmed for more than 6 blocks, or refused for a nonzero fee under a node's or the mempool's floor, reads as underpriced and does not rebuild, while0 <and a lost replacement race do not; a unilateralUnverifiedstep is not sent and rebuilds once a block, but waits while another unilateral step is ready, and the keyless pass still sends it; an empty rebuild waits for aFundsNotFoundleaf at 6/12/24-block gaps, lets it go after 1,008 blocks, waits 6 blocks for a stopped leaf, ends at once for an unrecoverable leaf that is not stopped, and never strikes a stopped leaf; the first build keeps the SDK's exit data and a rebuild imports it; a sweep refused for its fee is rebuilt at the next block's rate, becomes dust when nothing else is left, stays final for any other refusal, is replaced after 144 blocks unless the replacement would be dust, and ends when a replaced version confirms; an unknown-version record is salvaged with its funding, exit data and transfer, an ended one stays ended, and the repo keeps such an exit running; the critical-path countdown; the amount received over several generations; per-input top-up sizing and pacing; the exit data backup is skipped while an exit runsSparkExitDriverTest.kt,SparkExitFundingTest.kt,SparkExitRecordTest.kt,SparkExitAlertsTest.kt,SparkExitViewModelTest.kt(SDK 0.28.0) — every prepare passes the exit's funding strategy; fan-out outputs are not new funding; the funding split counts the quote's entries; the pre-start hint adds each payment's input fee;OperatorsUnavailablewaits; a higher fee the funding covers needs nothing more, otherwise the SDK's shortfall less what the address holds plus one input's fee, at least dust; a paid higher fee rebuilds at the new rate once its payment confirms, and one with nothing to pay rebuilds on the next tick; the rates offered sit strictly above the exit's; the offer holds only while underpriced or below the network; leaves the operators moved on-chain retry at once and at doubling gaps, are let go after a week, and a co-signed retry becomes the next generation that ends the exit; Done keeps the sweep that landed before the retry; the alarm names a low fee before any missed-run cause; a salvaged ended record keeps its leftoverSparkExitChainTest.kt— mainnet reads onlybitkit.to/electrsand broadcasts there first, then mempool.space and blockstream.info; a package onbitkit.to/electrsgoes once to/txs/package; reads never reach a broadcast-only host; with one read host a 404 means unknown and a 503 or an unreachable host throwsQA
Breez SDK 0.28.0 rerun on d9d3c87, local Spark regtest plus one exit on Spark's hosted regtest (QA comment):
/txnormally; unit tests cover it.OperatorsUnavailablewait need a watchtower-moved leaf, which neither stack produced in time.The independent review fixes after it rest on the unit tests below.
Local regtest exits, from the QA write-up (spark-sdk 0.27.2 against the last public operator, Android emulator):
5edd5a8ce)5edd5a8ce)5edd5a8ce)f1507f29d)f1507f29d)The same write-up's headless runs on the SDK's calls show a stale refund from an older owner winning when the app sleeps past the 100 blocks after a maturity with the operators down, which the lost-leaf handling above now ends with a Failed exit instead of an endless Redo.
🟢 Exit start and funding from another wallet, Pay from savings, Running exit in the background
Exit start and funding from another wallet
On 1adfc47 on Spark's regtest chain (J9's steps by hand): with 79,703 on Spark the quote showed 65,536 to exit, 8,923 network fees, about 56,613 to receive, 9,211 fee funding and 14,167 staying on Spark, and Fund from another wallet showed the funding address and amount. Recording in Preview.
Pay from savings
On 3e60f04, a fresh wallet with 69,802 on Spark (part of 32: Pay from savings): the quote named the 12,458 it can't exit, the swipe asked for notification permission, Progress showed Paid from savings, waiting for confirmation, with the transaction ID and no QR until the funding confirmed about a minute later, then published 1 of 23 steps. Recording in Preview.
Running exit in the background
On 3e60f04, the 4-leaf exit started on 326a6aa (21 of 30 steps confirmed, waiting for the timelock) loaded on the new build: Progress showed the background, battery optimization and notifications-off notes, and Savings showed From Spending (±4d) 90,400 (part of 29: the countdown). On bd931ac, with Bitkit's process killed, the periodic job published nothing due, connected Spark alone, checked the exit as Valid, saved the record and ended in 9 seconds; after a reboot the job and the deadline alarm came back, and background restriction held both (part of 31: the background run). The 326a6aa run found three bugs, fixed there: the fan-out confirm Spark's regtest mempool never reported, the foreground loop that never started, and the timelock step shown as done too early. Recording in Preview.
🟢 Full exit with the operators down, Redo after the operators publish first, Funding short then topped up
Full exit with the operators down
On 4771ead, with a local config patch not in this PR, on a local regtest stack (spark-sdk 0.27.2 against the last public Spark operator, fee ladders off, since the current operator image is private): with the 3 operators stopped the quote showed 90,112 to exit, 4,537 fees and 5,713 funding; funded exactly from another wallet, the exit built 25 transactions, resumed after a force-stop without sending a step twice, published each refund within a minute of maturity, and swept 91,288 to Savings 1,907 blocks after the first broadcast, ending on Exit complete with 18 small amounts left on Spark. Screenshot in Preview.
Redo after the operators publish first
On 4771ead, a second exit paid from savings was force-stopped until 2,000 blocks past maturity, the operators' watchtower published all 5 refunds, and the relaunch rebuilt as generation 2 with no funding and swept 93,200 to Savings. It left 3,514 sats of funding change unswept and hidden after Done, on the first exit's funding address, which cc2641c fixes (33, 34). Recording in Preview.
Funding short then topped up
On the same stack, a harness making Bitkit's SDK calls showed a rebuild needing 9,085 sats with 9,035 held over 5 UTXOs, then reaching Done after a 1,000 sat top-up; Bitkit asked for the whole total instead of the 50 missing, fixed in aec3163.
🟢 Pay from savings after viewing the funding address
Pay from savings after viewing the funding address
On 1adfc47, after Fund from another wallet, Back and Continue reopened the funding address with no Pay from savings button. Fixed in b4ec299, which resets the choice on Back;
SparkExitViewModelTest.ktcovers it, not rerun on a device.🟠 Exit end and notifications on the new build
Exit end and notifications on the new build
Not run: on Spark's regtest the running exits wait for timelocks of about 1,900 blocks, so the result kept reachable with its sweep state (33), a second exit's new funding address (34) and the relaunch without early refunds (35) rest on the unit tests; the deadline and funding notifications (rest of 31) are covered by
SparkExitAlertsTest.kt, and Payment seen for an outside payment (rest of 32) bySparkExitFundingTest.kt.Mainnet read-only check (30f87e5): a local build with debug-only logging, which also shows the Emergency exit entry at a zero balance. On a fresh mainnet wallet with nothing funded, Spark connected and synced on
https://bitkit.to/electrswith a balance of 0. Opening Emergency exit up to the quote read the SDK's fee estimate fromhttps://bitkit.to/electrs/fee-estimates, and the quote showed Nothing can be exited right now. The exit's own reads (/blocks/tip/height,/tx/{txid}/hex), run from a debug hook at launch, went tohttps://bitkit.to/electrsonly. Nothing was broadcast.Open
Models used
claude-opus-5-5(reasoning:low)claude-opus-5-5(reasoning:low)Unknown(reasoning:Not exposed)