Skip to content

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

Closed
sethforprivacy wants to merge 152 commits into
mainfrom
claude/unilateral-exit-advanced-settings-760372
Closed

sethforprivacy wants to merge 152 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).

sethforprivacy and others added 30 commits August 20, 2026 14:33
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.
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
Bump <Version> to 0.1.4 and date the changelog's Unreleased section,
which covers the sweep-label txid validation merged in #18 and the
Breez.Sdk.Spark 0.22.0 -> 0.22.2 bump merged in #20. No code changes.
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).
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.
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.
sethforprivacy and others added 27 commits September 7, 2026 19:07
Enforce Breez.Sdk.Spark release gating past the Dependabot ignore
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.
@sethforprivacy

Copy link
Copy Markdown
Owner Author

Superseded by #84, which reworked this unilateral exit for the Breez SDK's exit API (plus automatic exit-state backups) and was merged together with #85 and the review fixes in #88 for the 1.2.0 release.

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