Skip to content

feat: spark emergency exit - #1782

Open
ovitrif wants to merge 77 commits into
feat/breez-spark-lightningfrom
feat/spark-emergency-exit
Open

ovitrif wants to merge 77 commits into
feat/breez-spark-lightningfrom
feat/spark-emergency-exit

Conversation

@ovitrif

@ovitrif ovitrif commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • Lightning Connections › Spark shows Emergency exit under Transfer to Savings while Spark holds funds or has recoverable funds, and leads to the exit's progress while one runs or its result is still unseen.
  • The flow is an explainer, a quote, fee funding, a confirm swipe and a progress screen:
    • The quote comes from the SDK's prepareRecoverFunds for 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.
    • Fee funding: pay from Savings when it covers the funding and its fee, or fund a shown address from another wallet.
    • The swipe writes the exit record and the transfer before any payment.
    • Progress shows the steps, the timelock countdown, rejections, a top-up address when more fees are needed, and the result.

Driving the exit

  • Drives the exit with a state machine that builds once the funding confirms, saves every build before broadcasting, broadcasts ready steps as packages or single transactions (on mainnet to Bitkit's Esplora https://bitkit.to/electrs through /txs/package, then mempool.space through /v1/txs/package and blockstream.info as fallbacks; on regtest to Spark's mempool), reads the chain on mainnet from https://bitkit.to/electrs alone, 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.
  • Publishes a step without the SDK only once its relative timelock matured from its parent's confirmation, stops resending a step whose input someone else spent (the next check rebuilds it), and never counts a not-yet-final answer as a rejection.
  • Rebuilds from the same funding when the exit's fee chain lost a race: when another wallet whose leaves share a tree node gets its package for that node confirmed, our CPFP child for it is dropped and every later step spending its change can never confirm, while the SDK check still answers Valid. The exit detects that child (node confirmed, child unknown to every chain host) before broadcasting and rebuilds, never resending the dead transactions. As a safety net, a step refused 3 times, or whose parents confirmed and that comes back unpaid (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.
  • Takes Spark off Lightning during an exit: no sends, receives or deposits on Spark, its balance shows as in transfer to Savings with the countdown on Home and Savings, and Spark reconnects with leaf optimization off until the exit ends, so no optimization swap hands an exiting leaf to someone else.
  • Waits, before quoting an exit, until no leaf optimization runs and the counter-swap of one cut short is claimed (the SDK's 300 s grace), then syncs, so value in flight is not left out of the exit; the quote screen says it is waiting. Starting the exit also waits for a running optimization before Spark reconnects. While an exit runs, Progress shows Spark's balance beyond the quote's leaf total as outside the exit.

Background run on Android

  • Runs the exit to completion with Bitkit closed, so users need not open it until it finishes. The record (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.
  • Adds the SparkExitWorker WorkManager job and a BOOT_COMPLETED/MY_PACKAGE_REPLACED receiver 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

  • Notifies only when the background run falls behind: an alarm 12 blocks into the operators' ~50-block window of the next step, pushed back by every run that keeps up, fires when none did and names why (steps refused again and again, no network, background restriction, battery saver) and the time to open Bitkit by; needing more fee funding is notified at once. The swipe asks for notification permission, and Progress notes background restriction, battery saver, battery optimization and notifications off. The explainer and Progress say the exit runs in the background and Bitkit notifies you if it needs you.
  • Pays the exit's CPFP fees from a key on its own account, 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.
  • Asks, when a rebuild needs more fees, for a top-up of only what the SDK's required total lacks beyond what the funding address still holds (adopted inputs and the CPFP change of every generation count as held; several small payments count as new funding), instead of the whole total again.
  • Shows Paid from savings, waiting for confirmation, with the funding transaction, on Progress once the app has paid the fee funding, and Payment seen, waiting for confirmation, for a payment from another wallet seen in the mempool, instead of asking for the funding again; it asks only for what is still missing once that confirms, at least the funding output's 294-sat dust limit, also on the first tick after a build ends short, and shows what arrived so far (₿ X of ₿ Y received, send ₿ Z more) on Progress and the funding screen, where Continue waits until the funding is covered.
  • Gives each exit its own funding key index, kept in the record and the app cache, so a later exit never reuses an earlier one's funding address or mixes in its leftover; restarts sign with the record's index, and records written with index 0 still load.
  • Records the fee funding paid from Savings as an ordinary payment in Activity, not a transfer to Spending, and says the exit takes up to 4 weeks.
  • Sweeps fee funding an exit leaves at its funding address (CPFP change after a rebuild, or a branch the operators published) to Savings when it ends, background runs included, signed by the SDK's single-key signer over a PSBT Bitkit builds, at the exit's fee rate once every UTXO there confirmed. It is saved before it is sent and sent again each run until a node accepts it; nothing is sent when what arrives would be under the output's dust limit. Background runs keep going after the exit ends until the sweep is accepted, without starting Spark.
  • Keeps a finished exit's result reachable from Emergency exit and the Spark screen until its leftover sweep confirms or is under dust, and shows the sweep's state there: sending, sent with its transaction ID, or kept as dust at the funding address. An exit that failed or lost a leaf stays reachable until its result was opened once. Only then does Emergency exit open a fresh explainer.
  • A failed or partly lost exit keeps its result and notifies until the user opens it.

Lost leaves and fee-ladder leaves

  • Leaves out a leaf an older owner of these funds already claimed once two rebuilds reproduce its spent refund, says on Progress what can't be recovered and notifies once with the amount, as it does when an exit fails, also while Bitkit is open; a leaf the SDK refuses to exit is left behind and the rest rebuilt.
  • Names on the quote what it can't exit: leaves too small to pay their own fees and Spark's balance beyond them.
  • Warns 12 blocks after a step matures, inside half the 50-block window on every network.
  • Drops a leaf the SDK refuses to exit, such as a fee-ladder leaf whose refund cannot pay its own fee at the rebuild's rate, and prepares the rest again; when the refused leaf can't be told from the error, it prepares the recoverable leaves instead and leaves out the rest. The exit records how many leaves it left behind.

Breez SDK 0.28.0: per-node funding, a higher fee, failed leaves

  • Funds each fee-bumped transaction from a UTXO of its own (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.
  • Adds Pay a higher fee on Progress while the exit is underpriced (a step refused under the fee floor, or accepted and unconfirmed for more than 6 blocks) or the network asks more for the next block than the exit's rate, with a one-line hint for the latter. Its screen offers the send flow's Fast and Normal rows, each strictly above the exit's rate, with what it costs from Savings: the exit is built at that rate with the funding it holds, nothing broadcast, and what its funding address still lacks plus one input's fee is the extra. Paying sends the extra from Savings like the first funding; the exit then rebuilds at the new rate, replacing what has not confirmed. The deadline alarm of an underpriced exit names the fee instead of a missed background run.
  • Keeps cooperative leaves the operators moved on-chain but could not co-sign part of the exit after a Done verdict: they are retried as a recovery of their own at once, then at doubling gaps from 6 to 144 blocks, and let go after 1,008 blocks (about a week), as Glow does (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.
  • Sweeps each step's change at the network's cheapest rate once it is worth more than its fee, waiting up to about 4 weeks for fees to drop. After a higher fee is paid, Progress shows no low-fee line and nothing asks for funding while the payment confirms.
  • The pre-start funding hint adds 68 vB × rate for each payment beyond the first and for the one still to send, as Progress's top-up does. OperatorsUnavailable, new in 0.28.0, waits like FundsNotFound, and a salvaged record that had ended keeps its leftover to sweep.

Independent review addressed

  • Pay a higher fee keeps driving the exit while its payment confirms: the live generation is checked and its ready steps sent (without the SDK too), the deadline alarm stays armed, and a payment no host knows for a block, or not adopted within 144 blocks, is forgotten so the ask and its notification come back.
  • Cancel only runs while nothing was paid or seen, under the tick lock, after a confirm, and reads the funding address first: an unreadable address refuses ("Couldn't check the funding address. Try again."), and funds already there keep the record as a cancelled exit whose leftover sweep sends them back to Savings.
  • Starting holds Spark (no sends, receives or spending balance) in the same step its transfer starts counting, before the optimization wait, then quotes again and takes the new leaves if an optimization swapped them. Start and Pay a higher fee run one at a time, and a higher fee paid for an exit that ended or started rebuilding meanwhile is not applied (the payment goes back with the leftover).
  • A tick that fails still runs the alerts, and a failed SDK check falls back to the keyless broadcast.
  • The leftover sweep reads the address again when its inputs went elsewhere or no node took it in a day, holds for unconfirmed outputs for at most a day, keeps every input when it replaces a sweep, and keeps as dust at once what even 1 sat/vB can't send.
  • Broadcast answers keep only the node's message or RPC code (at most 200 characters), never a host's page.
  • Salvage keeps the leftover, the sweep, what landed and the funding txids; the unreadable original is copied aside first. Records and the lost-leaf list are synced to disk before the atomic move, a cold background run reads the wallet index from config, a rebuild short of fees waits for an address reading, and a fee-bump rate the SDK refuses a leaf at is not offered.

Review bots and team review addressed

  • Start and cancel can't interleave: after the optimization wait, the start checks under the tick lock that its exit is still the running one, and pays from Savings under that lock. A cancel during the wait ends the exit with nothing paid and the transfer released.
  • A Savings funding send with an unknown result counts as paid: cancel stays off, and the exit keeps watching its address, also after it ends, until the payment arrives or is forgotten. Cancel also stays off while a Savings send to the address is unresolved.
  • A higher-fee rate at which any leaf can't pay its own exit is not offered. With none left, the screen says the exit confirms once fees drop.
  • Balances: the exit's transfer counts only what is still on its way, never funding nobody paid or value Savings already has, and Spending keeps Spark's value outside the exit.
  • Deadlines: the alarm stays armed while a higher fee's generation is driven, and it is no longer pushed back while a due step reaches no host.
  • Endings: a keyless pass whose every step confirmed ends the exit as done. A first build with only failed cooperative leaves is kept and retried. A wipe closes the record's writes and waits for the tick in flight before deleting.
  • Sweeps: a sweep that confirms while more funding is at the address is kept, and the rest goes in a sweep of its own. Sweeps spend only confirmed outputs after the day's wait, and every wait and rebuild keeps looking for versions a node accepted.
  • Funding takes the fewest coins that cover the need and never a coin worth less than its input. The funding reminder fires once per exit, and the sweep's activity becomes a transfer even after the transfer settled.

SDK facts this relies on

  • prepareRecoverFunds(All), used for the quote, can't abort: leaves that can't pay their own exit are listed under skipped (FeeExceedsValue) and the rest is quoted.
  • A Specific rebuild 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).

  • Splits the no-progress detector by cause. Rebuilds over a broken chain (refused, missing-input or unpaid steps, or a dead CPFP child) stop after 3 that confirmed nothing; Progress then says the exit is not moving, and the deadline alarm is no longer pushed back. A step the network accepted but has not confirmed for more than 6 blocks is not rebuilt: Progress says the network is busier than the exit's fee rate and offers Pay a higher fee, and the alarm is no longer pushed back. A refusal for a nonzero fee under a node's or the mempool's floor (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 on bitkit.to/electrs alone; the cap guards against a stale index.
  • Never broadcasts a unilateral step the SDK could not read (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). Cooperative Unverified steps still go out. The keyless pass still sends such a step, since only the SDK can rebuild.
  • Waits instead of ending when an empty rebuild skipped one of the exit's leaves as Unverified or FundsNotFound, or as NotRecoverable while exitChainState.stoppedLeafIds has 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.
  • Keeps the SPARK_EXIT cloud backup from before the exit while one runs. Before the first build it keeps the SDK's exit data in the record, and imports it before every rebuild (building.ts, rebuild.ts).
  • Rebuilds a leftover sweep that was refused for its fee at the next block's rate, and replaces one still unconfirmed after 144 blocks (RBF, at least 1 sat/vB more). Any version it replaced confirming also ends the sweep. A sweep refused for good names the funding address's derivation path.
  • Keeps a running exit whose record can't be read, or has an unknown version: the file is set aside unchanged, and the exit rebuilds from the chain with the leaves, rate, funding and exit data it kept. Progress says so, and there is a hook for a future version converter.
  • Counts down along the longest path of timelocks and blocks left, so the countdown no longer jumps (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

  • Emergency exit translations: its strings carry the English text in every locale, as on iOS; the Spark strings in feat: add spark via breez sdk #1697 are translated.
  • Funding one Savings payment per step: ldk-node sends to one address only, so the SDK fans out the single payment.

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.md lists them as todo. UI polish is pending design.

Preview

Emergency exit at b68bdea on an Android emulator (896×1994, 320 dpi) in Bitkit's dark theme. Start, Pay from savings and Fund from another wallet run live against Spark's hosted regtest with notifications denied; Pay from savings sends from Savings, but Spark's regtest chain API refuses every broadcast, so that exit keeps waiting for its fee funding. Fund from another wallet is paid in two parts by another wallet, and the wait for the payments is cut. Running exit, Redo and sweep, and Exit complete are rendered with stubbed state by a local hook that is not in this PR.

Start: intro, quote, funding choices, confirm Pay from savings Fund from another wallet
a1782-p1-start.mp4
a1782-p2-savings.mp4
a1782-p3-external.mp4
Running exit: timelock and sweep Redo and sweep Exit complete
a1782-p4-timelock.mp4
a1782-p5-redo.mp4
a1782-p6-complete.mp4

Screens

Every exit screen, on an Android emulator (896×1994, 320 dpi) in Bitkit's dark theme. Start, Paid from savings and Running are from the live exits above; the other Progress and Result screens are rendered with stubbed state by a local hook that is not in this PR.

Start
Spark detail during an exit Intro Quote with the can't-exit note Funding choices
Funding address Partial payment hint Notification permission Confirm, paying from savings
Progress
Needs funding Needs funding, partial Paid from savings Payment seen
Running Pay a higher fee
Result
Leftover sending Done, leftover swept Stopped Stopped, balance stays on Spark
Lost to an older owner

QA Notes

Setup

  • A dev build on Spark's regtest chain, set up as in feat: add spark via breez sdk #1697's QA Notes (ESPLORA_URL and 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.
  • The full exits ran on a local regtest stack: spark-sdk 0.27.2 against the last public Spark operator (1 Oct), patched to accept 0.27.2's status filters and with fee ladders off, since the current operator image is private. Every leaf there is an anchored, pre-ladder leaf.

Journeys

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

  • 27. Dev on Spark's regtest chain, Spark funded from the faucet → Emergency exit, fund from another wallet with the faucet, swipe → Progress shows fee funding confirmed, then publishing with confirmed steps counting up and Ready in a countdown; kill and reopen the app mid-exit → it continues without sending a step twice; at the end Savings receives about the quoted amount and the transfer settles — hours of regtest blocks.
  • 28. Same as 27 with Pay from savings → the funding payment shows as an ordinary payment, not a transfer to Spending, and the exit starts once it confirms — Spark's regtest chain.
  • 29. During an exit → Spending shows the Spark balance as in transfer to Savings, Lightning receives and sends do not use Spark, and Transfer to Spending shows the Transfer Unavailable sheet — a running exit.
  • 30. Exit started but not funded → Cancel exit on Progress puts Spark back as it was — needs a Spark balance.
  • 31. Dev, an exit running → kill Bitkit from the background (not Force stop) → the next background run logs 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.
  • 32. Dev on Spark's regtest chain, Emergency exit with Pay from savings → right after the swipe Progress shows Paid from savings, waiting for confirmation, with the transaction ID and no address or QR; with Fund from another wallet, a payment seen in the mempool shows Payment seen, waiting for confirmation — Spark's regtest chain.
  • 33. An exit that ends with leftover fee funding (a Redo that needed no funding) → Emergency exit and the Spark screen still open the result, which shows the sweep sending, then sent with its transaction ID, or kept as dust; a fresh explainer shows only after that — needs an exit to end, about 2,000 regtest blocks.
  • 34. A second exit on the same wallet → its funding address differs from the first exit's — two exits.
  • 35. Relaunch Bitkit with an exit whose refunds wait for their timelock → before Spark reconnects the log shows no non-BIP68-final or missingorspent broadcast — a running exit near its timelock.
  • 37. Dev, an exit running and Bitkit closed → a Spark exit notification (deadline alarm, or a lost or failed result) → tapping it opens the exit's Progress inside the Spark screen, and OK goes back to Spark — a running exit, also after the app process was killed.
  • 38. An exit that ends Done → Progress shows the amount that reached Savings and the sweep's transaction ID — needs an exit to end, about 2,000 regtest blocks.
  • 39. An exit whose step is accepted but unconfirmed for more than 6 blocks, or whose rate the network's next-block rate passed → Progress shows the fee line and Pay a higher fee; its screen lists Fast and Normal above the exit's rate with what each costs from Savings (rows disabled and a note when Savings can't cover them); paying shows Paid from savings, waiting for confirmation, and once it confirms the waiting steps go out again at the new rate — a running exit with fees above its rate.
  • 40. An exit whose Done verdict lists cooperative leaves the operators could not co-sign → the exit stays in progress and says they wait for the operators; it retries them, and after 1,008 blocks Done names the amount the operators moved on-chain and still hold — operators that refuse to co-sign.

Automated Checks

  • added 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 aside
  • added SparkExitDriverTest.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 matured
  • added SparkExitFundingTest.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 txids
  • added SparkExitChainTest.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 classified
  • added SparkExitRepoTest.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 Spark
  • added SparkExitSweepTest.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 refused
  • added SparkExitAlertsTest.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 user
  • added SparkExitRegtestTest.kt (unit, opt-in with SPARK_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 later
  • added SparkExitRegtestTest.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's singleKeyCpfpSigner
  • updated SparkExitDriverTest.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 one recoverFunds refuses 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 network
  • updated SparkExitDriverTest.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 read
  • updated SparkExitViewModelTest.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 left
  • added SparkRepoTest.kt case — an exit quote waits for a running leaf optimization and out the counter-swap grace of one cut short before it syncs
  • added SparkExitViewModelTest.kt — after Fund from another wallet, Back and Continue offer both funding choices again
  • updated LightningConnectionsViewModelTest.kt, AppViewModelSendFlowTest.kt — construct the exit repository
  • updated SparkExitDriverTest.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, while 0 < and a lost replacement race do not; a unilateral Unverified step 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 a FundsNotFound leaf 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 runs
  • updated SparkExitDriverTest.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; OperatorsUnavailable waits; 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 leftover
  • updated SparkExitChainTest.kt — mainnet reads only bitkit.to/electrs and broadcasts there first, then mempool.space and blockstream.info; a package on bitkit.to/electrs goes 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 throws

QA

Breez SDK 0.28.0 rerun on d9d3c87, local Spark regtest plus one exit on Spark's hosted regtest (QA comment):

  • 🟢 Pay from savings with per-node funding, the fee-change sweep of all 5 leftover outputs, and the partial top-up with no second round.
  • 🟢 Pay a higher fee (paid and covered by the funding), the evicted fee-change sweep retried, a 0.27.2 record resumed to Done.
  • 🟢 Lost race on a per-node node, stale state with one notification, the rebuilt-from-chain note, and "not moving" with its notification once.
  • 🟢 Hosted regtest: fan-out and the first node packages accepted.
  • 🟠 Lone-transaction package retry not reached: the hosted host now answers /tx normally; unit tests cover it.
  • ⏭️ The failed cooperative leaf retry and OperatorsUnavailable wait 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):

Case Result
Quote with all 3 operators stopped ✅ 90,112 to exit, 4,537 fees, 5,713 funding, 9,888 staying on Spark
Fee funding from another wallet ✅ exactly 5,713
Kill mid-exit, relaunch ✅ continued without sending a step twice
Full exit to Savings ✅ 91,288 swept, 1,907 blocks from the first broadcast (about 13 days at mainnet block times)
Operators publish the refunds first (Redo) ✅ generation 2, no funding needed, 93,200 swept
Top-up after a short build (on 5edd5a8ce) ✅ asked only for the 68 sats missing, with a notification
Leftover sweep (on 5edd5a8ce) ✅ 1,626 and 626 back to Savings
Pay from savings, then new funding address per exit (on 5edd5a8ce) ✅ paid, waiting for confirmation, with the txid
Partial funding (on f1507f29d) ✅ ₿ 500 of ₿ 1,329 received, send ₿ 829 more; Continue waits
Single leaf lost to an older owner (on f1507f29d) ✅ leaf dropped, 558 swept back, Failed, one notification, result kept until opened

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.kt covers 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) by SparkExitFundingTest.kt.


Open

  • Hosted-regtest exit: A full exit by a harness on SDK 0.27.2, making the same calls as the app's driver, reached Done on Spark's hosted regtest: all 19 steps confirmed and the sweep confirmed. The app itself has not run a full exit there.
  • Mainnet exit and fee-ladder leaves: under Mainnet tests.
  • Translations: the emergency exit strings are English in every locale.

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

  • Read-only check on a fresh wallet with nothing funded: Spark connected and synced on 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 to https://bitkit.to/electrs only. Nothing was broadcast. On 30f87e5, 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.

  • Read-only check again on the final head.
  • Deposit about 50,000 sats into Spark and wait for it to settle; the quote lists the leaves it moves, what stays on Spark and the total fee.
  • Start, with the fees paid from Savings: Home shows the amount in transfer with a countdown, and Spark stops sending and receiving.
  • Each step confirms, with packages accepted at https://bitkit.to/electrs, and none is published by the operators first.
  • Reminders: a notification when a step needs the app opened, in time for its window of about 50 blocks.
  • Restart the app mid-exit: it resumes and nothing is broadcast twice.
  • Fee-ladder leaves: their refunds go out and confirm (unit tests only so far).
  • The leaves are swept to Savings, Savings gains the exited amount minus the fees, and leftover fee funding returns to Savings.
  • The exit screen shows Done, with one completion notification.

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.

@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from 49a93c5 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@ovitrif

ovitrif commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 spark-transfer-to-spending.xml from feat: add spark via breez sdk #1697, to check the exit's gating leaves the transfer flow alone: every step held, with the same figures as on feat: add spark via breez sdk #1697 (Max 199,635, up to 696 Instant, ≈ 99 Later), and Savings › Transfer To Spending opened the amount screen with no intro.
  • 🟠 spark-emergency-exit.xml: stopped at step 6. With a ₿ 0 Spark balance the Spark screen hides Emergency exit and disables Transfer to Savings, as designed; the rest needs a Spark balance from Lightspark's regtest faucet.

J1 on this PR, and the exit journey up to the Spark screen with no balance (2x speed).

J1 flow No intro on the second entry Exit journey, ₿ 0 on Spark
j1-exit-a2x.mp4
j1-exit-b2x.mp4
exit-journey2x.mp4

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

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

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.

  • 🟢 Lost race on a shared tree node: another owner's CPFP child replaced this wallet's child on the node cad843ee (the competing child spends the node's anchor at a higher fee). The exit carried on by itself: its next node 2fe35690 was accepted 9.7 min after the loss, and the exit swept 66,775 sats to savings (689d3b9d).
  • 🟢 Stale state (an older owner publishes its refund first): the exit ends Failed with exactly one notification, "₿ 65 536 of your exit can't be recovered".
  • 🟢 The stopped screen now says "Nothing could be recovered. ₿ 3 392 stays on Spark." It stays until it's seen, and reopening Emergency exit shows the intro.
  • 🔴 A leaf lost to an older state comes back in the next exit, on iOS too (feat: spark emergency exit bitkit-ios#1217):
    • The operators still list it as ON_CHAIN under this wallet, so the next quote counted it: ₿ 131 072 over 2 leaves instead of ₿ 65 536.
    • Its refund fails with "inputs missing" on every tick, and the driver logs "diverged on-chain" and rebuilds after each confirmation, about 10 min per rebuild.
    • It then drops the leaf again as "claimed by an older state" and sends a second "can't be recovered" notification for a loss the user was already told about.
    • The exit itself finished Done. Leaves already reported as lost should be left out of later quotes and exits.

Stale-state result, kept until seen:

@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

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 ON_CHAIN under the wallet. The lost leaves lived only on the exit record, so the next exit quoted the leaf again, rebuilt around its spent refund, and reported the same loss a second time.

What changed:

  • Leaves lost to an older state are now kept per wallet and network, next to the exit record, and outlive it. They're cleared only when the wallet is wiped. They aren't in the backup: a restored wallet finds the loss again in one rebuild.
  • The quote leaves them out. When prepareRecoverFunds(All) lists one, the rest are prepared again with Specific, so the amount, leaf count, fees and funding come from the SDK without it. When every listed leaf is lost, the quote shows the existing "nothing to exit" state and no exit can start.
  • When an exit's unexitable-leaf fallback (RecoverableOnly) lists a leaf outside the exit, a lost one included, only the exit's own leaves are prepared and built.
  • The "can't be recovered" notification posts only for a leaf not already reported lost.

The leaf id the app logs for a dropped leaf is the refund's node_id, which spark-sdk 0.27.2 sets to the leaf id, and the app logs it only for leaves of its own quote. In the regtest operator DB, case 5's older owner is a third wallet that sent one of the two leaves to each app wallet. The two ids differ in one hex digit (01a1aa9e-4d2a-…, 01a1aa9e-4d2d-…), so the crossed reading is most likely a misread, not an app bug.

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.

@ovitrif

ovitrif commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

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.

  • 🟢 Case 5 again (an older owner claims the leaf first): the exit ends Failed with exactly one notification, and spark-exit-lost-leaves.json records the leaf.
  • 🟢 With only lost leaves left, the quote shows ₿ 0 and "Nothing can be exited right now.", and Continue is disabled.
  • 🟢 A new exit after that quotes only the live leaf (₿ 65 536 over 1 leaf). It logs no "diverged" or "inputs missing", sends no second notification, and swept 65,830 sats to Done (dd202b0c).
  • 🟢 A leaf lost before this update was already in the file at the first quote after the update, so it never came back.

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.

@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 34be64c, 653a881 and 9b21c89 (the head has since only merged the base branch; SparkExitDriver.kt is unchanged at 194504d).

  • 34be64c closes my residual. savingsFundingFee prices with allowFallback = false, so LDK InsufficientFunds now fails the fee. The funding screen then disables Pay from savings and shows fund_insufficient.
  • 9b21c89 keeps the priced feeRates on the fee-bump option and passes them through sendFunding → sendOnChain, so the bump is paid at the rate it was priced at.
  • 653a881 introduces the MEDIUM inline: a waiting leaf's fee ask never expires. There is also one LOW.

Both reproduce in SparkExitDriverTest at 9b21c89.

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

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

Medium — Fee-bump confirmation can display a placeholder as the actual total. In app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt:515, calculateTotalFee retains its default fallback: estimation failure returns a successful 1,000-sat placeholder. The UI uses that value for price and affordability, but sending computes the real transaction fee without enforcing the displayed total. Disable fallback, as initial funding already does, and surface estimation failure.

Medium — Early startup failure can leave Spark held without a usable exit state. In app/src/main/java/to/bitkit/repositories/SparkExitRepo.kt:687-700, the durable record is saved and Spark is held before transfer insertion and the next record save. Either failure can return before publishing or scheduling the exit. The cached in-memory state remains empty, so the ordinary foreground session cannot drive or cancel that record although Spark sends/receives are disabled. Roll back consistently or expose and schedule the durable record before returning; cover both failures.

Review details

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

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Re the waiting-leaf retry review: fixed in 653a881. A retry that hits InsufficientCpfpFunds now moves the exit to NeedsFunding with the same shortfall build() asks for, keeps waitingLeafIds, and once the funding is adopted builds only those leaves. b68bdea adds the week's release while that ask is unpaid. Covered by a skipped leaf whose retry needs more fees asks for them and retries once they come in SparkExitDriverTest.kt.

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Re the fee bump placeholder review: fixed in f413c2d. The fee bump prices its savings payment with allowFallback = false, so a failed estimate fails that rate's option instead of offering a 1,000 sat placeholder; with no rate left, the screen shows the pricing error. Covered by a fee bump whose savings payment can't be priced is not offered at a placeholder fee in SparkExitRepoTest.kt.

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

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Re the fee bump placeholder review at 194504d27: this is the item fixed in f413c2d, which landed after that commit. The fee bump prices its savings payment with allowFallback = false, so a failed estimate drops that rate's option, and with none left the screen shows the pricing error.

@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 f413c2d, b68bdea, d27796f and e790537: no findings. Both my findings are fixed and their threads resolved.

  • f413c2d: fee-bump options are priced with allowFallback = false, so a rate that can't be priced is dropped instead of offered at the placeholder fee.
  • d27796f: a build of only failed cooperative leaves keeps its skipped leaves waiting. A failed leaf that a retry skips is counted once.
  • e790537: leaves let go after the week are added to skippedLeafCount. Quote skips and exit leaves never overlap, so nothing is counted twice.

The emergency-exit device journey passed earlier on a8e1462. These commits change only the driver's retry and end paths, and unit tests cover them.

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

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

Medium — Leftover sweeping stops while recoverable funding remains unconfirmed. SparkExitDriver.sweepLeftover can drop unconfirmed funding after 144 blocks and classify only the confirmed subset as dust. That clears needsDriving and cancels background work, so later confirmation of the larger output never restarts recovery. Preserve the unconfirmed outputs and continued watching until all funding has been accounted for.

Review details

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

@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 398b60e, which closes the gap the media capture found: a refused Pay from savings left the exit waiting with nothing said.

  • When a node refuses the start's savings payment, Progress now says "Your savings payment didn't go through. Pay again, or fund from another wallet." (English only; the other locales come through the translation flow) and offers Pay from savings again. The funding address stays shown for paying from another wallet, and Cancel stays hidden while that payment is unresolved, as before.
  • Paying again re-signs the same send attempt at the rate already approved, over the inputs it already spends (retryOriginalOnchainSend), so the refused version and the new one conflict and at most one can confirm. It never picks fresh coins and never needs the refused one to be known gone from the mempool first.
  • 397f14d sends raw transactions through the Spark regtest proxy as text/plain; the capture's refusal came from the proxy's form-urlencoded default, which Spark's regtest API refuses.
  • Covered by a refused savings payment is shown, and a payment still unanswered is not and paying again from savings re-signs the refused payment over its own inputs in SparkExitRepoTest.kt.

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

@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 397f14d and 398b60e.

  • 397f14d only touches the regtest Esplora proxy script; no app code changes.
  • 398b60e ("pay again") is fine on amounts, fees, address and double pay. It re-signs the same attempt over the same inputs and rate to the stored funding address, and returns an already-landed txid instead of rebroadcasting.
  • MEDIUM inline: the send runs outside tickMutex, so a cancel in that window drops a paid funding. Reproduced in SparkExitRepoTest.
  • LOW inline: the new string isn't translated.

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

Comment thread app/src/main/res/values/strings.xml Resolved
@ovitrif

ovitrif commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed. @jvsena42, this makes your approval at c3f9368 stale; a re-approval is needed only for these and for 398b60e (the refused-savings notice that closes the media capture's gap, posted above):

  • e9c59c8 answers the leftover sweep review: confirmed dust beside funding still unconfirmed is no longer kept as dust. The exit keeps every output in its leftover and looks again after a recheck, so it keeps driving until that funding confirms and is swept. Covered by dust confirmed beside unconfirmed funding keeps watching until that funding confirms, which failed before the fix.
  • c9b3354 is the twin of iOS's failed-leaf fee-bump finding: a failed-leaf retry that built a recovery of its own can be sped up again, and one waiting only on the operators stays refused. Covered in pay a higher fee is offered while the exit runs underpriced or below the network, which failed before the fix.
  • 9224747 merges feat/breez-spark-lightning at d31e9f0; its proxy change is the same as 397f14d.

@ovitrif
ovitrif requested a review from jvsena42 November 9, 2026 19:02

@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 e9c59c8 and c9b3354: no new findings.

  • e9c59c8: keeps watching an unconfirmed output beside dust. The confirmed subset is kept as a waiting sweep and rechecked every 144 blocks.
  • c9b3354: allows the fee bump once a failed-leaf retry has built steps, following pwltr's design.

My MEDIUM (retrySavingsPayment sends outside tickMutex) and the untranslated savings_refused LOW are still open at this head, so the changes request stands.

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

@ovitrif
ovitrif requested a review from jvsena42 November 9, 2026 19:18

@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 311caae and 49a93c5: no findings. Both my findings are fixed and their threads resolved.

  • 311caae: retrySavingsPayment holds tickMutex across the check, the send and the txid write, as payFunding does, so a cancel can no longer drop a paid funding.
  • 49a93c5: translates savings_refused in all 15 locales.

The emergency-exit device journey passed earlier. The new "pay again" flow re-signs the stored attempt with the same inputs, rate and address, and unit tests cover it.

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

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.

5 participants