Skip to content

Add experimental unilateral exit (Phase 0) behind an environment gate - #19

Draft
sethforprivacy wants to merge 6 commits into
mainfrom
claude/unilateral-exit-advanced-settings-760372
Draft

Add experimental unilateral exit (Phase 0) behind an environment gate#19
sethforprivacy wants to merge 6 commits into
mainfrom
claude/unilateral-exit-advanced-settings-760372

Conversation

@sethforprivacy

Copy link
Copy Markdown
Owner

What this is

Phase 0 of unilateral exit: the UI and scaffolding for forcing a store's Spark balance on-chain without the operators' cooperative signature, built on the exit API that already ships in the pinned Breez SDK 0.22.0 (breez/spark-sdk#374, #992).

Draft on purpose — this does not ship until the next SDK release. Two building blocks merged upstream on 2026-08-20 and are not yet released: the persistent ancestor store (#1010, exit from local state with operators offline) and exit-state export/import for backups (#1029). Until that bump, building an exit still requires the operators reachable, so this flow defends against operators who stop cooperating, not operators who are gone. Phase 1 (backup export/import) lands after the bump.

Everything is gated by FLINT_EXPERIMENTAL_UNILATERAL_EXIT=1 — without it the routes answer NotFound and the Advanced page shows nothing.

The flow

  1. Disclosure — persisted, server-enforced acknowledgement (Stable Balance pattern).
  2. Quote — auto-selects the leaves worth exiting at the chosen fee rate; refuses when the fee exceeds what it recovers. Leaf ids are pinned on the record so a resume re-quotes those exact leaves.
  3. Fund — each exit gets its own P2WPKH funding address derived from the store's seed at m/84'/{0|1}'/4607060'/0/{index} (0x464C54 = "FLT", a hardened non-standard account that can never collide with BTCPay's hot wallet on a shared seed; per-exit index so two exits can never sign over the same outpoint). Funding is one single confirmed output covering the quoted amount, discovered via esplora (mempool.space default on mainnet, configurable from the page).
  4. Build — re-quotes fresh, persists the requirement, selects funding, and the SDK signs the full transaction set. The persist is non-cancellable: a closed tab cannot discard the only copy.
  5. Broadcast by hand — the plugin never broadcasts. The page renders per-package bitcoin-cli submitpackage lines with ordering instructions (fan-out first and alone; tree packages per branch waiting for confirmations; refunds after CSV timelocks; sweep last). Signed hex renders only for CanModifyStoreSettings — view-only roles see counts, not broadcastable material.

Hardening

  • One active exit per store enforced by a partial unique index in Postgres, not just in-process single-flight.
  • Compare-and-set status transitions with coalesced JSON columns, so a stale abandon can never null a build's signed set.
  • The provisioner carries the new settings section across seed re-entry like every other block.
  • An adversarial review (8 finder angles, 22 verified candidates) confirmed 10 findings; all 10 are fixed in these commits.

Testing

  • 1,301 tests: 1,200 passed, 0 failed, 101 skipped (Postgres/regtest infra gates).
  • The Postgres contract set (94 tests) was additionally run against a live postgres:17-alpine, covering the unique index, the CAS predicate, blob coalescing and the funding-key index allocation.
  • Not yet exercised end-to-end against a live regtest Spark stack — that is the next validation step before un-drafting.

Out of scope (deliberately)

Export/import of exit state (Phase 1, needs the SDK bump); automated broadcasting or chain monitoring (Phase 2, if ever — NBXplorer's RPC proxy whitelists no submitpackage, so it would need mempool.space's package endpoint or a user-supplied bitcoind); Greenfield endpoints (the API remains exit-free, stated in its docs); CHANGELOG/version (bumped at release).

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