Skip to content

Unilateral exit reworked for the Breez SDK 0.25 flow (with automatic exit-state backups) - #84

Merged
sethforprivacy merged 23 commits into
mainfrom
rebase/pr-19-breez-0.25
Sep 28, 2026
Merged

sethforprivacy merged 23 commits into
mainfrom
rebase/pr-19-breez-0.25

Conversation

@sethforprivacy

Copy link
Copy Markdown
Owner

Draft tracking branch: PR #19's unilateral exit rebased onto the Breez SDK 0.25.0 binding, plus the automatic exit-state backup work from the 2026-09-14..16 sessions and today's review follow-ups.

Intended to supersede/rebase PR #19 once the SDK 0.26.0 bump lands (in flight on another branch); topology decided then.

State:

  • Full suite locally: 1613 total, 1480 passed, 0 failed, 133 env-gated skips.
  • Fresh-context verifier confirmed the backup follow-ups end to end.
  • Deployed as 1.1.0.0 to btcpay-dev (mainnet test host) on 2026-09-16; the automatic backup provably fired unattended there.

Today's commits:

  • c2a1aa0 NewDeposits events now request the backup refresh their claim gets.
  • ba102c6 The four review follow-ups: scheduler belief tracked through the store seam (decorator), idle-pass cadence for persistently empty exports, streamed download, owner-only backup file.

PrepareUnilateralExitAsync quotes which leaves are worth forcing on-chain
and what the exit costs; UnilateralExitAsync quotes, lets the caller veto,
and builds the signed transaction set in one call, because exit quotes go
stale silently as the wallet's tree moves. Both are mapped against the
Breez.Sdk.Spark 0.22.0 binding (verified by reflection), with funding
shortfall and spent-outpoint conflicts surfaced as typed exceptions and
unknown SDK enum variants failing loudly rather than mislabeling broadcast
instructions. Nothing here broadcasts; the SDK signs, the caller carries.
UnilateralExitSettings carries the disclosure acknowledgement (enforced
server-side, the Stable Balance pattern) and an optional esplora override
for funding discovery. The feature is gated by the
FLINT_EXPERIMENTAL_UNILATERAL_EXIT environment variable so it exists only
on hosts that opted in, and the funding key derivation constant (account
4607060', "FLT") is pinned here with the reasoning: a hardened non-standard
account can never collide with BTCPay's own hot-wallet BIP84 account when
the seed is shared.
UnilateralExitRecord persists an exit across its multi-day life: the quote
the operator funded against (immutable identity columns), the per-exit
funding key index, and the signed transaction set. A partial unique index
enforces one active exit per store at the database level - the in-memory
single-flight is an optimization, not the invariant - and updates are
compare-and-set on the expected status with the JSON blobs coalesced, so a
stale abandon can never clobber a build's only copy of the signed
transactions. Contract tests run against the production EF store on a real
Postgres.
SparkUnilateralExitService holds every guard: the disclosure gate, fee-rate
bounds, destination validation (shared with the sweep path so the two can
never drift), one exit at a time, and the recoverable-exceeds-fee rule
re-checked against a fresh quote inside the build's veto. Each exit gets
its own P2WPKH funding key at m/84'/{coin}'/4607060'/0/{index} so two exits
can never sign trees over the same funding outpoint; funding is discovered
through an esplora endpoint (mempool.space by default on mainnet,
configurable) without touching key material on the read path; and the build
re-quotes and re-persists the requirement before selecting funding, so a
top-up meeting the displayed number is always sufficient. A signed set is
persisted non-cancellably: a closed browser tab must not be able to discard
the only copy. The provisioner now carries the section across seed changes
like every other settings block.
One page, driven by the record's state: disclosure, quote form, funding
(largest single confirmed output judged against the requirement, since the
fees are paid from one output and a sum that adds up does not fund an
exit), and the built transaction set with per-package submitpackage lines
and broadcast-ordering instructions. The signed hex and commands render
only for CanModifyStoreSettings - broadcasting is a money-moving
capability, so view-only roles see counts, not hex. The controller holds
zero policy and no JSON: the service hands the page typed data. Every
route answers NotFound when the environment gate is off.
The trust model, limitations, sweeping docs and README no longer claim the
plugin has no unilateral-exit path anywhere; they now scope the truth:
every automated flow remains a cooperative exit, and the one unilateral
path is the experimental, environment-gated, manually broadcast flow on
the Advanced page. The limitations entry states the four hard limits
plainly - the plugin never broadcasts, the pinned SDK still needs the
operators reachable, the CPFP funding is hand-supplied, and settlement
waits out multi-day CSV timelocks - plus the explorer disclosure caveat.
Sweep-engine, sweep-record and Greenfield comments are rescoped the same
way; the API remains deliberately exit-free.
…tus model, backup

The 0.25 exit API inverts the flow PR 19 was written against: prepare now takes the
funding kind and returns the document the build consumes, transactions carry a status
union rather than a flat confirmation enum, and a wallet can be checked against the
chain and backed up. This reworks the seam, the record and service, the page and the
suite onto it.

Also fixes two 0.23->0.25 breakages that were not PR 19's: GetSparkStatus now takes a
request, and CrossChainRoutePair.supportedSources became acceptedAssets.
check_unilateral_exit reads the chain and nothing else, so the leaf values it is
handed are not an input to the verdict. Say so where the placeholder is built, and
say why it is zero rather than a plausible number: a zero cannot be mistaken for a
real leaf value by a later reader.
The Advanced page, the exit controller's banner and the CHANGELOG all told an
operator that a stored backup is imported automatically on the next wallet start.
Nothing did it: ExitStateBackup was written by SetExitStateBackupAsync and read
only to render a 'Stored' badge, so the blob was data the plugin recorded and
never used. The backup is the recovery path for a wallet whose own storage is lost
while the Spark operators are gone, so an operator was told their exit data was
secured and would find out otherwise only when they needed it.

The import now runs on the warm-up path, fire-and-forget like the first sync and
behind the same exception boundary, so it cannot hold up BTCPay's startup. It is
ordered before the sync rather than after, because the sync needs the operators and
the import is for when they are gone. Nothing about it can fail a connect: a wallet
whose backup will not import still has a working Lightning wallet, and the failure
is logged rather than raised. The blob itself is never logged on either path.
The backup is imported on connect, and a failure there is deliberately not fatal,
which leaves the server log as the only place it is visible. Tell the operator that
rather than letting them assume a stored backup was applied.
…ented

The comment said the import ran before the first sync and the code ran it after.
The ordering the comment describes is the correct one: the SDK collects a leaf's
exit data as it learns about the leaf, so importing first means a leaf whose chain
existed only in the backup is present before anything asks the operators about it.
The old order spent a round trip confirming a leaf set the import might have
expanded, in the situation where the operators are least likely to answer.
Restoring this project alone rewrote 86 transitive pins downward relative to main,
because BTCPay's dependency graph resolves differently for a single project than for
the solution. Those unrelated downgrades had ridden along under a commit message
about a code comment. This branch should change exactly one thing in these files —
the Breez.Sdk.Spark pin, which is what the work is about — so both are restored from
main and only that pin is re-applied. The diff is now the SDK bump and nothing else.
The exit surface was verified against the SDK's contract, the seam, a fake and unit
tests, and none of that broadcasts anything — because the SDK never does, and the
plugin's design is that the operator pushes the transactions out by hand. So the one
thing no test had done is the thing the feature is for: force a real balance on chain
through the real statechain tree.

This drives the plugin's own code — SparkExitFundingKey for the funding key, the
seam's quote/build/check — and broadcasts the result exactly as the exit page
instructs: fan-out and sweep alone, tree nodes as packages via submitpackage, mining
between rounds so each CSV timelock matures. Progress is read from the SDK's verdict
and per-transaction readiness, the same two things the page renders.

Verified passing against the fixture, and the assertions were mutation-tested: making
the WaitingForDependencies status map to Ready instead of Waiting fails it at build
time, naming the transaction that would be broadcast before its parent confirmed.

Three things the runs taught, each now encoded:

- The readiness invariant is 'at least one step Ready, nothing Unverified, and no
  step with an unconfirmed dependency claiming Ready'. Asserting 'all Ready' failed
  against correct behaviour, because an exit is a chain.
- What arrives is recoverable + unspent funding - total fee, not recoverable - fee.
  Measured 149,901 + 2,914 - 2,620 = 150,195, which looks wrong and is the documented
  arithmetic: the CPFP and fan-out fees come out of the funding output, not the
  recovered value.
- A multi-leaf exit on a shared chain hit the SDK's Redo verdict when another wallet
  mined concurrently, so the test pins to one leaf. That is the documented single-leaf
  shape and it keeps the test honest about what it proves.

The test is its own trait and its own collection. It cannot share the suite's wallet,
because it exits the balance and would starve the other tests, and it cannot share the
stack, because a second wallet funded from the same SSP while the suite ran produced a
measured failure in the suite's Lightning send. local-regtest.yml runs it as a
separate step, after the suite and after an explicit SSP top-up: an exit converts its
wallet to on-chain Bitcoin, which the fixture's return leg cannot undo, so each run
permanently costs the SSP a wallet's worth.

Also regenerates both lock files with --force-evaluate. CI restores in locked mode and
the previous commit's restore from main left them inconsistent.
…ocal one

CI restores in locked mode and was failing with NU1004 on the plugin project: the
lock had been generated against the btcpayserver working tree on this machine, which
sits at v2.4.2 because v2.4.4 does not build with the local .NET 10.0.400 Razor
toolchain, while CI checks out the commit the repo actually records (v2.4.4). Two
different dependency graphs, one lock file, and locked mode refuses the mismatch.

Restore does not compile anything, so the locks can be generated against the right
submodule even where that submodule cannot be built: checked out v2.4.4, regenerated
both with --force-evaluate, and confirmed the exact two commands ci.yml runs now pass
in locked mode.

The diff is large and that is expected rather than drift: bumping Breez.Sdk.Spark from
0.23.0 to 0.25.0 changes that package's own dependency set, so the whole resolved graph
downstream of it moves with it.
Category=LocalRegtestExit is the one category that is not revenue-neutral: an exit turns
its wallet into on-chain Bitcoin, which the return leg cannot undo, so each run costs
the SSP a wallet's worth and repeated runs empty it. Also records why it is its own CI
step rather than part of Category=LocalRegtest - it cannot share the wallet, and a
measured run showed it cannot share the stack either.
Copy, at the operator's direction:
 - the fee-rate hint now says what the rate is for and that it must clear the mempool
   minimum, because one rate prices the whole chain over hours or days;
 - the destination hint says the funds end up there and that changing it later is costly;
 - the 'Quote an exit' sub-copy is gone entirely;
 - the empty-selection refusal is now 'No leaves are large enough to exit properly at
   the selected fee-rate...', which tells the operator what to do rather than describing
   what Spark decided.

The fee rate itself now defaults to mempool.space's half-hour recommendation, fetched
through the explorer client the exit already has, clamped to the service's bounds,
cached five minutes, and never throwing — an unreachable API or a regtest chain with no
mempool.space leaves the field at a floor of 2 sat/vB instead. The recommendation is
only fetched when the quote form will actually render, and a record's own rate still
wins over it.

Also enables the exit gate on the e2e BTCPay stack, which is where the product is
exercised against real operators and was the one place the exit could not be seen.

The Advanced page's backup paragraph now answers the question it invited: a backup is
not an alternative to exiting, it is what keeps exiting possible, because collecting
that data needs the operators up and the moment you need an exit is the moment you can
no longer collect it.
The exit is the one screen in this plugin where the wording is load-bearing — it is read by
someone whose operators have stopped answering — and it is also the hardest screen to look
at: the routes 404 unless the experimental gate is on, and the states that matter (a quote
that refused, an exit waiting on funding) only exist after real money has moved.

So render them directly instead. Seven facts execute the plugin's own compiled pages over
the view models SparkController actually projects, with the real view components, and write
standalone HTML carrying BTCPay's own stylesheets. That makes every state reviewable in a
browser with no login, no funding and no gate, and re-reviewable after the next copy edit
without re-deriving any of this.

Its assertions are only 'HTML was produced and is non-empty' — it is a review tool, not a
behavioural test, and it should be read that way. Delete it if the maintenance weight ever
outweighs that.
The backup was the operator's job: press Export, keep the copy, remember to do it again
after anything arrives. That is exactly the job an always-on server should be doing, and
the moment it matters most is the moment nobody is there to do it — the data an exit is
built from needs Spark's operators to be reachable to collect, so the moment you need an
exit is the moment you can no longer collect it.

The plugin was already being told when leaves appear and throwing it away: the SDK event
listener maps NewDeposits and ClaimedDeposits and the consumer only logged ClaimedDeposits.
Those, plus any inbound payment, now request a refresh; a two-minute debounce coalesces the
bursts an event stream produces, and an hourly safety net covers the channel being bounded
and the code already documenting events as unreliable in both directions. Nothing is
rewritten when the exported bytes are unchanged, because each write is multi-megabyte.

The backup is a secret that grows with the wallet and it now gets written repeatedly, so it
no longer lives in the store's JSON settings column, which is read in full on every settings
read. It is one owner-only file per store under the data directory, written temp-then-rename
so a half-written backup can never be the thing that gets imported, and a store upgrading
from the old plugin has its stored blob imported, moved to the file, and the setting cleared
— in that order, so the only copy of an exit cannot be lost to the migration.

The Advanced page stops asking the operator to do it and starts showing them how current the
automation is: the time it was last written, and a Download that hands over the stored copy.
That copy is on the same server as the wallet, and the copy says so — a backup that dies with
the machine is not a backup, and the page should not imply otherwise.

Exporting by hand still works and now also refreshes the stored copy, so the bytes in the
operator's clipboard and the bytes on disk cannot disagree.
A fresh-context pass over 1f02f4d returned CONFIRMED with six findings. Four were real;
two were correct as designed and one of those was the verifier being careful rather than
the code being wrong.

The one that mattered: adoption cleared the deprecated settings slot in the database but
not in the service's settings cache, and both whole-settings writers rebuild their payload
from that cache. So for a store upgrading from the old plugin, the first settings action
after a successful adoption — acknowledging the disclosure, setting the explorer URL —
would write the multi-megabyte blob straight back into the settings column the move
existed to get it out of. No loss and no new exposure, but the cost came back permanently,
since adoption is never consulted again once the file is non-empty. Clearing now empties
the cached instance too, and the clear is a seam of its own rather than a SetAsync, because
a clear must not reconcile the running wallet.

Second: clearing a backup only deleted the file. For a store whose legacy value had not
been adopted yet, the next connect adopted it back and imported exactly the leaves the
operator had asked to discard, while the page told them a restart would import nothing.
A clear now clears both locations.

Third: only the rename was guarded, so a failure inside the write itself — disk full, an
IO error part-way through several megabytes — left a partial copy of the secret beside the
real backup with nothing to remove it. The whole write path is guarded now; the target file
is still only ever reached by the rename.

The download's cache control needed no code: this controller already carries
[ResponseCache(NoStore = true)] for every action, so the advisory was the verifier being
right to ask and wrong about the answer. That is now pinned by a test rather than by an
argument, along with the download's bytes and its nothing-stored redirect.

The remaining advisory is recorded, not fixed: the whole automation is behind
FLINT_EXPERIMENTAL_UNILATERAL_EXIT and is inert on a default deployment, like the rest of
the exit feature.
The automatic-backup commit's own message says NewDeposits and
ClaimedDeposits both request a backup refresh, but only the claim was
given a case: a NewDeposits envelope falls through the consumer's
default arm and is logged at trace, so money detected on-chain before
its claim requested nothing. The listener maps it; the consumer threw
it away.

NewDeposits is the claim's precursor, not a leaf yet, so today's exit
state does not cover it — but the claim usually lands well inside the
two-minute debounce, and a refresh requested here means the pass that
runs after it exports the leaf even when the claim event itself is one
of the drops the event channel is documented as producing. Logged at
debug: the claim's own operator-level line follows it, and a precursor
that duplicates it would double-log every deposit.

The harness now carries the real scheduler, so the test reads the
pending mark directly instead of waiting on a debug log line.
The scheduler's belief about what is stored now moves with every write
through the store seam, not just the scheduled pass. A decorator
(TrackedExitStateBackupStore) composes the file store with the
scheduler in DI, so the manual export, the paste, the clear and the
legacy adoption cannot leave the belief stale behind their writes, and
no future fifth writer can forget the bookkeeping — at a call site,
WriteAsync looks complete without it. Reads stay pure pass-throughs:
the pass seeds its own belief from a ReadAsync it makes itself, and
incidental reads must not re-seed it. A delete notes null
unconditionally once it returns, so a cleared backup self-heals on the
next due pass — the page already promises the plugin keeps the backup
current on its own.

An export that comes back empty with nothing pending now records an
idle pass. The pass ran and learned nothing, and recording that is the
only thing that stops a wallet with no exit state yet from being asked
at the task's one-minute cadence forever; the safety net re-asks it on
schedule. A request that IS pending records nothing, exactly as
before: it was earned by a real event, an empty answer does not serve
it, and the next pass asks again on the event's behalf
(ExitStateBackupScheduler.MarkIdlePass).

The download streams instead of holding the secret twice in memory: a
string read re-encoded to a byte array was the whole multi-megabyte
blob twice over to produce one pass-through copy
(IExitStateBackupStore.OpenReadAsync, FileShare.Read because a
concurrent temp-rename replace is safe on POSIX — the open handle
keeps the old inode).

The backup file itself is now owner-only, not just the directory
holding it: the temp is SetUnixFileMode(0600) before the rename, so no
umask window ever carries a readable copy of the wallet's exit data,
independent of the directory's 0700.

The download's type pin moved FileContentResult to FileStreamResult
with every asserted fact kept; the two new OpenReadAsync tests pin the
absent-vs-stored answers and the rename-while-open semantics against
the real filesystem. A fresh-context verifier confirmed the full
acceptance: decorator truth incl. the seeding-after-restart path,
streamed bytes byte-for-byte, 0600 mode, both cadence behaviors, and
the whole suite (1613 total, 1480 passed, 0 failed, 133 env-gated
skips).
Stacks #84 on #85 so the two merge and release together: #84 was built
on #85's first three commits, and this brings in the rest of #85
(network icons, the Greenfield switch, docs, and its merge of main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sethforprivacy
sethforprivacy changed the base branch from main to sfp/flint-usdc-usdt-btcpay-03e659 September 28, 2026 10:57
@sethforprivacy
sethforprivacy marked this pull request as ready for review September 28, 2026 13:35
@sethforprivacy
sethforprivacy changed the base branch from sfp/flint-usdc-usdt-btcpay-03e659 to main September 28, 2026 13:36
@sethforprivacy
sethforprivacy merged commit ab9dc38 into main Sep 28, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant