Add experimental unilateral exit (Phase 0) behind an environment gate - #19
Draft
sethforprivacy wants to merge 6 commits into
Draft
Add experimental unilateral exit (Phase 0) behind an environment gate#19sethforprivacy wants to merge 6 commits into
sethforprivacy wants to merge 6 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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).bitcoin-cli submitpackagelines 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 forCanModifyStoreSettings— view-only roles see counts, not broadcastable material.Hardening
Testing
postgres:17-alpine, covering the unique index, the CAS predicate, blob coalescing and the funding-key index allocation.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).