Add experimental unilateral exit (Phase 0) behind an environment gate - #19
Closed
sethforprivacy wants to merge 152 commits into
Closed
sethforprivacy wants to merge 152 commits into
sethforprivacy wants to merge 152 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.
--- updated-dependencies: - dependency-name: Breez.Sdk.Spark dependency-version: 0.22.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com>
…eadme-9b095d Add AI disclosure section to README
The interrupted-sweep recovery test counted Withdraw payments through a Limit: 200 send listing. The funded wallet's history crossed 200 send payments on 2026-08-20, so each new withdrawal pushed an old one off the window and the count froze — after == before + 1 became unsatisfiable and the suite has been red since, despite the engine resolving correctly. Snapshot the withdrawal payment ids instead and assert the only id that appears between the snapshots is the staged send's own. The listing is newest-first, so a withdrawal made between the snapshots is always in the window regardless of how large the wallet history grows, and the failure messages now distinguish a re-send from the send never appearing.
…rawal-identity Fix funded regtest failure: diff withdrawal ids instead of counting a saturated window
Blocking on PRs, still advisory on schedule and push: a human is present on a PR to judge a failure and re-run a transient one, while a drained CI wallet or an SSP outage on a scheduled run stays an operations signal instead of turning main red. This is the gate the suite just demonstrated the need for — it ran red for two days behind a green checkmark (#22) before anyone noticed. Fork PRs are unaffected: without the seed secret the gate step skips the suite and the job passes trivially.
…Breez.Sdk.Spark-0.22.2
Make the funded regtest job block pull requests
…ver.Plugins.Flint/Breez.Sdk.Spark-0.22.2 chore(deps): Bump Breez.Sdk.Spark from 0.22.0 to 0.22.2
Release 0.1.4
Each finding verified against the source before changing anything: - Sweep grace write-off (review #1, P2): force an explicit SyncWallet and repeat the payment lookup before any write-off, and gate sats-funded write-offs on the synced balance still holding the sweep's amount. A row the gate refuses keeps blocking. - Cross-chain recipient echo (review #2, P2): the prepared payment's provider-echoed recipient is now compared to the requested destination; mismatch refuses the send. - Value guard fail-open (review #5, P3): base-unit-to-dollar overflow is an explicit CrossChainValueUnverifiable refusal instead of a zero that skipped the guard. - Payment key rotation (review rec 3): every provision mints a fresh key and rewrites the Lightning wiring with it, so re-running setup revokes every previously issued connection string. - Deposit-address cache (review #6, P3): an empty wallet identity bypasses the cache in both directions instead of keying it. - Directory umask window (review #13, P3): storage/log directories are created 0700 atomically, and the storage lock file owner-only. - Breez.Sdk.Spark 0.22.2 -> 0.22.3 (review rec 1): the one upstream change is a cross-chain accounting fix on the rail this plugin uses. Trust-model doc updated for the rotation behaviour; new unit tests for every behavioural change (1064 pass).
- The write-off shortfall gate is bounded (ShortfallWriteOffAge, 1h) rather than absolute: its observation is also produced by payouts and Stable Balance conversions near a never-sent sweep, and unbounded it would wedge the store's sweeping permanently with no operator escape. Past the bound the row is written off with a reason stating what was observed and what to verify. The false only-spender premise in the comment is gone with it. - A throwing Lightning-configuration write during provisioning now rolls the settings back exactly as a refused write does — with the key rotating, a half-applied provision is a store whose checkout fails. A process crash inside the SetAsync/EnableAsync window remains detectable and one-click repairable from the status page.
Address the v0.1.4 security review findings
Bump <Version> to 0.1.4.1 and date the changelog's Unreleased section, which covers the security-review fixes and the Spark SDK 0.22.3 bump merged in #25. A four-component point release is new here, so the version tests and package.yml's tag guard learn to accept an authored fourth component (unauthored assembly padding is still stripped).
Release 0.1.4.1
Blockers, all verified in source before fixing: - B1: provider u64 fee components saturate at long.MaxValue instead of wrapping negative past every <= fee ceiling; both fee approvers also refuse a negative fee outright as a backstop. - B2: [BindNever] moved back onto AlreadyConfigured — it had drifted onto EnableSweeping, silently discarding the setup page's sweeping opt-in and leaving the flag overpostable. Reflection test pins it. - B3 (adjudicated down): the dropped-push log line claimed recovery by the reconciliation task, which only scans Unpaid rows. The actual recovery is BTCPay's LightningListener polling GetInvoice (1-minute timer) over the durable Paid row, so nothing is lost; the log now says so. No outbox needed. - B4: NOTICE and LICENSE are copied into the build output and packaged into the .btcpay (it redistributes Breez's MIT binaries), and package.yml refuses to ship an artifact missing either. High: - H1: token-funded write-offs get the same bounded balance gate as sats rows — a token re-send is keyless and nothing upstream can dedupe it. - H2: cross-chain Sent rows with an undecided conversion are exempt from the 24h recovery cutoff (the poll is the only place a refund need is ever learned). - H3: the invoice reconciliation cursor carries across passes instead of re-examining the same oldest 1000 invoices forever. Medium: stable-balance token switch refused while the old token holds a balance; ProviderOrderId persisted through SweepResolution; msat ceiling no longer wraps into an amountless invoice; tag guard rejects v0.1.4 for a 0.1.4.1 build; funded suite hard-fails on a seed leak; Postgres race test only swallows unique-key violations. Docs: type=flint, Plugins/Flint paths, registry name, built-against 2.4.2, clone directory, key-rotation remarks. Passed on: settlement outbox (covered by core polling + durable rows), whole-blob settings CAS (real, architectural, deferred), rewriting the token-idempotency fake test (the production guard lives in the SDK wrapper, exercised by the live suites).
The quorum refuted this branch's B3 adjudication: BTCPay's one-minute LightningListener timer only runs CheckConnections (expire stale sessions, EnsureListening) — it never re-polls listened invoices, and PollPayment runs only when an invoice enters listening. Verified against the submodule. So a settlement push dropped on a saturated queue really did stay undelivered until a restart, and the log line this branch had just rewritten asserted a recovery mechanism that does not exist. - The broadcaster now holds refused pushes in a per-subscription bounded pending map (cap 64, keyed by payment hash) and re-delivers on a lazy 10s timer; past a 2-minute deadline or the cap it gives up with a log naming the real remaining recovery (the next read of the invoice, typically a restart). Class docs and CHANGELOG rewritten to match reality. - The crash-recovery resolution path also persists the provider order id — the initial-send path got the fix, but the recovery poll built its own SweepResolution without it, which is exactly the crash-before-first-resolution window that needs it most. Fixes authored by the review panel; reviewed, and verified here: 1,076 unit tests pass (2 new: saturated-push retry after the consumer drains, give-up after the deadline).
Address the third external review pass
Bump <Version> to 0.1.5 and date the changelog's Unreleased section, which covers the third external review pass and its quorum follow-ups merged in #27. No code changes.
Release 0.1.5
SparkSettings.ApiKeyOverride has existed since the shared embedded key shipped, but had no write path — reachable only by editing the store's settings blob. Per Breez's suggestion, give it a field on the Advanced page (with a link to their API-key request form) so a merchant can hold their own key and lose nothing if the shared plugin-wide key is ever revoked or rate-limited. The save goes through the settings store, whose reconciliation restarts the store's wallet with the new key; an unchanged key writes nothing (no wallet bounce), and a key the wallet cannot start with is rolled back to the previous settings so a dead configuration is never left stored behind a success-looking form. Stale doc comments claiming the override has no write path updated.
Let a store set its own Breez API key on the Advanced page
Bump <Version> to 0.1.5.1 and date the changelog's Unreleased section, which covers the Breez API key override on the Advanced page merged in #29. No code changes.
Enforce Breez.Sdk.Spark release gating past the Dependabot ignore
Release 1.1.0
Both live suites so far run against Lightspark's hosted regtest, where the far side of an invoice is not ours: nothing pays an invoice the plugin mints, nothing mines on demand, and the funded suite needs a faucet-filled wallet behind a repository secret. This adds a stack we own end to end. - e2e/local-regtest/: up.sh clones callebtc/cashu-regtest at a pinned SHA (its --spark profile builds open-ssp, three Spark operators, Electrs and ldk-server alongside LND and CLN nodes) and starts it; write-network.sh discovers the live SSP identity, operator certificates and container names into a JSON descriptor; down.sh tears it down. On macOS up.sh writes a compose override moving the CLN nodes' SQLite onto named volumes, since Docker Desktop bind mounts refuse lightningd's writes. - Sdk/SparkCustomNetwork.cs + SparkConnectOptions.CustomNetwork: the SDK is repointed at the descriptor's signing set, SSP and Esplora, regtest only, with every hosted Breez service switched off by name. Verified against the Breez.Sdk.Spark 0.23.0 binding by reflection; no binding change was needed. - Tests/LocalRegtest (Category=LocalRegtest, gated on SPARK_LOCAL_REGTEST_NETWORK): lnd-1 pays a Flint invoice and it settles from the Receive leg with no preimage in the scrubbed log; Flint pays an lnd-1 invoice and lnd shows it settled; a cooperative exit through the sweep engine reaches Confirmed with the exact net amount on-chain. - .github/workflows/local-regtest.yml: daily, on PRs and on dispatch, advisory for now (no flake data; ~40 min cold because the fixture builds its images from source on every run). Validated: 1250 unit tests green (22 new); the full down/up/descriptor/test cycle from a fresh clone green on Apple Silicon, 3/3 LocalRegtest in 19 s; the fixture's own acceptance tests green during that start. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…CPay e2e layer Three follow-ups to the LocalRegtest suite, wired so that every v* tag build runs the whole stack before packaging. Release gate. local-regtest.yml gains a workflow_call trigger with a `blocking` input (default true) and is blocking when called or when the PR head is release/*; package.yml calls it on every tag and its package job needs it, so a release cannot be built over a red stack. Advisory on ordinary PRs, the daily schedule and dispatch until there is a flake rate. Prebuilt images. local-regtest-images.yml builds the four source-built fixture images (Spark operator, open-ssp, Electrs, ldk-server; linux/amd64) with buildx bake over the fixture's own compose file and pushes them to ghcr.io/sethforprivacy/flint-regtest/<image>:<fixture sha>. up.sh pulls and retags them so compose skips the ~40-minute source build, falling back to building when a pull fails. Leaner, steadier init. up.sh now runs the fixture's init chain itself instead of start.sh: no acceptance suites (which rebuild the Rust Breez test client and mine dozens of blocks), and no cashu-fees-init, whose fee-hub gossip wait timed out on this workflow's first ubuntu-latest run and in two of the fixture's own three preceding CI runs; nothing here routes through that hub. The chain is retried once from a clean stop, since "LDK wallet funding sync timed out" was seen once in five local starts. Readiness is asserted explicitly afterwards. BTCPay e2e layer (Category=BtcpayE2E, gated on FLINT_BTCPAY_E2E). e2e/btcpay/up.sh starts the official btcpayserver 2.4.4 image, NBXplorer and Postgres on the fixture's network, side-loads the Release build as a plugin directory, provisions admin, store, API key and Flint wallet over Greenfield, funds the wallet, and writes btcpay.json. Three tests through the Greenfield API: LND pays a store invoice and BTCPay settles it; the store's Lightning API pays an LND invoice; a manual sweep exits on-chain and is recorded Confirmed. The SSP's leaf liquidity is topped up automatically to five times the funding amount, because one 500,000-sat leaf could not serve a second deposit plus a 3,000-sat send. Plugin: SparkService passes SparkCustomNetworkFile.TryLoadFromEnvironment() into SparkConnectOptions on regtest only, so a BTCPay inside the stack connects through the plugin's real store-connect path. Not read off regtest; refused by the factory off regtest regardless; a descriptor that exists but cannot be read stops the wallet rather than falling back. Also documents that Greenfield wallet provisioning needs btcpay.server.canmodifyserversettings on the API key, since BTCPay's hot-wallet check judges the key, not its owner. Validated on Apple Silicon: full down/up/descriptor/LocalRegtest/BTCPay up/BtcpayE2E cycle green in 4.5 min with cached images (3/3 + 3/3); unit suite 1253 passed, 0 failed; actionlint and shellcheck clean. Left open: one unreproduced BtcpayE2E receive timeout (LND paid, BTCPay stayed New for 4 minutes) with no logs captured; the workflow now uploads BTCPay's logs on failure for that case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…files
hashFiles('**/*.csproj') is evaluated again in actions/cache's post step,
when e2e/ holds the fixture checkout and BTCPay data written by containers
as root. The walk failed there on the first green Linux run, losing the
cache save and marking a passing job with a red step. Excluding e2e/**
leaves the hashed set (and so the key shared with ci.yml) unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lease-gate Release gate on the local Spark stack, prebuilt fixture images, BTCPay e2e layer
… git context docker/bake-action defaults `source` to the repository's git context, so the first run on main tried to read docker-compose.yml from flint itself and failed. `source: .` makes the definition and build contexts resolve inside the cashu-regtest checkout under `workdir`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fix the image-publish workflow's bake source
bake-action v7 has no workdir input (the run warned and ignored it), and a local source is the working directory. source: cashu-regtest is the one shape that resolves docker-compose.yml and the override where they are. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Point bake-action's source at the fixture checkout
bake-action splits targets on commas and newlines only; the space-joined list arrived as a single nonexistent target. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Pass bake-action a comma-separated target list
The cache action's post step re-hashes every csproj at the end of the job, when e2e/ holds root-owned files the containers wrote, and fails even with a !e2e/** exclusion (the walker descends before filtering). ci.yml saves this exact key on every push and PR, so this job only needs the restore entry point. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tore-only Restore the NuGet cache without a post-save step in local-regtest
Rebased onto main with Breez.Sdk.Spark bumped to 0.25.0.
…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.
ClaimDepositResponse.payment is nullable in 0.25 and every other response on this class carries a payment that is always present, so the shape invites the assumption that it cannot be null. SparkPaymentMapper.Map throws on null, so a claim that returned no payment would surface as an argument exception rather than as the incomplete claim it is. Reported instead: the money is still in the deposit and the merchant can retry at a different ceiling.
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.
Owner
Author
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).