From 3463265a588264ed15e175a2629f356939666714 Mon Sep 17 00:00:00 2001
From: sethforprivacy <40500387+sethforprivacy@users.noreply.github.com>
Date: Thu, 20 Aug 2026 14:33:46 -0400
Subject: [PATCH 1/6] Add unilateral exit quote/build methods to the SDK seam
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.
---
.../Fakes/FakeSparkSdkClient.cs | 184 +++++++++++
.../SparkSettlementReconcilerTests.cs | 20 ++
.../SparkUnilateralExitSeamTests.cs | 301 ++++++++++++++++++
.../Sdk/ISparkSdkClient.cs | 102 ++++++
BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs | 114 +++++++
.../Sdk/SparkExitModel.cs | 283 ++++++++++++++++
.../Sdk/SparkSdkClient.cs | 289 +++++++++++++++++
7 files changed, 1293 insertions(+)
create mode 100644 BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs
create mode 100644 BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs
diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs
index c5d2371..d098097 100644
--- a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs
@@ -987,6 +987,190 @@ public sealed record CrossChainCall(
#endregion
+ #region Unilateral exit
+
+ ///
+ /// The leaves an automatic selection would pick, and their values.
+ ///
+ ///
+ /// Empty by default is not laziness. A unilateral-exit quote with Auto selection returns no
+ /// leaves whenever nothing clears the requested fee rate, and that is a normal answer the caller has to
+ /// report as "nothing worth exiting" rather than as a fault — so the fake's default state is the one that
+ /// catches a caller treating an empty quote as success.
+ ///
+ public List ExitLeaves { get; } = [];
+
+ /// Total fee the quote reports, in satoshi.
+ public long ExitTotalFeeSat { get; set; } = 3_000;
+
+ /// The fan-out's share of .
+ public long ExitFanoutFeeSat { get; set; } = 500;
+
+ ///
+ /// The single confirmed output the exit must be funded with, in satoshi.
+ ///
+ ///
+ /// Deliberately larger than , as the real quote's is: the funding UTXO has to
+ /// cover every fee plus the fan-out's own outputs, so a caller that funds against the fee total alone is
+ /// under-funded and this default is what catches it.
+ ///
+ public long ExitSingleUtxoFundingSat { get; set; } = 4_200;
+
+ /// Every prepare this fake has been asked for, in order.
+ public List ExitQuoteCalls { get; } = [];
+
+ /// Every build this fake has been asked for, in order.
+ public List ExitBuildCalls { get; } = [];
+
+ /// Thrown by a prepare when set, before any quote is produced.
+ public Exception? FailExitQuoteWith { get; set; }
+
+ /// Thrown by a build when set, after the quote has been approved.
+ public Exception? FailExitBuildWith { get; set; }
+
+ ///
+ /// Run after each prepare, so a test can move the wallet's tree between the quote a page showed and the
+ /// quote a build commits to.
+ ///
+ ///
+ /// The hazard this exists for is the sharpest one on the exit surface, and it is not the
+ /// cooperative-exit one. A unilateral-exit quote never expires and carries no id, so a stale one is not
+ /// rejected by anything — it simply describes a different set of leaves than the wallet now has, and a build
+ /// against it commits to leaves the operator did not fund for. Mutating from here
+ /// is how a test proves the caller re-quotes inside the build.
+ ///
+ public Action? WhenExitQuoted { get; set; }
+
+ public Task PrepareUnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfConfigured();
+ ExitQuoteCalls.Add(new ExitQuoteCall(feeRateSatPerVbyte, destinationAddress, leafIds?.ToList()));
+
+ if (FailExitQuoteWith is not null)
+ throw FailExitQuoteWith;
+
+ var quote = BuildExitQuote(feeRateSatPerVbyte, destinationAddress, leafIds);
+ WhenExitQuoted?.Invoke();
+ return Task.FromResult(quote);
+ }
+
+ public Task UnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ IReadOnlyList fundingUtxos,
+ byte[] fundingSecretKey,
+ Func approveQuote,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfConfigured();
+ ArgumentNullException.ThrowIfNull(fundingUtxos);
+ ArgumentNullException.ThrowIfNull(approveQuote);
+
+ // Quoted inside the build, exactly as the real client does, so the veto sees the fresh quote rather than
+ // whatever the caller last looked at.
+ ExitQuoteCalls.Add(new ExitQuoteCall(feeRateSatPerVbyte, destinationAddress, leafIds?.ToList()));
+ if (FailExitQuoteWith is not null)
+ throw FailExitQuoteWith;
+
+ var quote = BuildExitQuote(feeRateSatPerVbyte, destinationAddress, leafIds);
+ WhenExitQuoted?.Invoke();
+
+ var rejection = approveQuote(quote);
+ ExitBuildCalls.Add(new ExitBuildCall(
+ feeRateSatPerVbyte,
+ destinationAddress,
+ leafIds?.ToList(),
+ fundingUtxos.ToList(),
+ fundingSecretKey?.Length ?? 0,
+ rejection));
+
+ if (rejection is not null)
+ throw new SparkExitRefusedException(rejection);
+
+ if (FailExitBuildWith is not null)
+ throw FailExitBuildWith;
+
+ // The funding check the real SDK makes, reproduced rather than stipulated: the shortfall is discovered at
+ // build time and names the amount that would have worked.
+ var funded = fundingUtxos.Sum(utxo => utxo.ValueSat);
+ if (funded < ExitSingleUtxoFundingSat)
+ throw new SparkExitFundingShortfallException(ExitSingleUtxoFundingSat);
+
+ // Signed and inert. Nothing in this fake, and nothing in the real SDK, broadcasts any of it.
+ var sweepDependsOn = quote.Leaves.Select(leaf => $"txid:node:{leaf.LeafId}").ToList();
+ var transactions = new List
+ {
+ new(SparkExitTxKind.Fanout, null, "txid:fanout", "0200fanout", null, null, [],
+ SparkExitTxStatus.Unconfirmed)
+ };
+
+ transactions.AddRange(quote.Leaves.Select(leaf => new SparkExitTransaction(
+ SparkExitTxKind.TreeNode,
+ $"node:{leaf.LeafId}",
+ $"txid:node:{leaf.LeafId}",
+ $"0200node{leaf.LeafId}",
+ // A CPFP child, because a tree node pays no fee of its own and must go out as a package. A fake
+ // that left this null would let a caller ship single-transaction broadcast instructions.
+ $"0200cpfp{leaf.LeafId}",
+ 1_008,
+ ["txid:fanout"],
+ SparkExitTxStatus.Unconfirmed)));
+
+ transactions.Add(new SparkExitTransaction(
+ SparkExitTxKind.Sweep, null, "txid:sweep", "0200sweep", null, null, sweepDependsOn,
+ SparkExitTxStatus.Unconfirmed));
+
+ return Task.FromResult(new SparkExitResult(
+ quote.RecoverableValueSat, quote.TotalFeeSat, transactions, quote.Leaves));
+ }
+
+ ///
+ /// A pinned selection is honoured by filtering, and an id that is no longer in the tree simply does not come
+ /// back — which is how a test reproduces the case a resume has to survive: the operator funded for a leaf
+ /// set that has since changed under them.
+ ///
+ private SparkExitQuote BuildExitQuote(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds)
+ {
+ var selected = leafIds is null || leafIds.Count == 0
+ ? ExitLeaves.ToList()
+ : ExitLeaves.Where(leaf => leafIds.Contains(leaf.LeafId)).ToList();
+
+ return new SparkExitQuote(
+ selected.Sum(leaf => leaf.ValueSat),
+ selected.Count == 0 ? 0 : ExitTotalFeeSat,
+ selected.Count == 0 ? 0 : ExitSingleUtxoFundingSat,
+ selected,
+ selected.Count == 0 ? 0 : ExitFanoutFeeSat,
+ selected
+ .Select(leaf => new SparkExitBranchFunding(leaf.LeafId, ExitSingleUtxoFundingSat / selected.Count))
+ .ToList(),
+ feeRateSatPerVbyte,
+ destinationAddress);
+ }
+
+ public sealed record ExitQuoteCall(
+ ulong FeeRateSatPerVbyte,
+ string DestinationAddress,
+ List? LeafIds);
+
+ public sealed record ExitBuildCall(
+ ulong FeeRateSatPerVbyte,
+ string DestinationAddress,
+ List? LeafIds,
+ List FundingUtxos,
+ int FundingSecretKeyLength,
+ string? Rejection);
+
+ #endregion
+
public Task DisconnectAsync()
{
Disconnected = true;
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs
index 8a6d4f4..ff85945 100644
--- a/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs
@@ -589,6 +589,26 @@ public Task SendCrossChainAsync(
_inner.SendCrossChainAsync(
route, recipientAddress, amount, maxSlippageBps, idempotencyKey, approveQuote, cancellationToken);
+ public Task PrepareUnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ CancellationToken cancellationToken = default) =>
+ _inner.PrepareUnilateralExitAsync(
+ feeRateSatPerVbyte, destinationAddress, leafIds, cancellationToken);
+
+ public Task UnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ IReadOnlyList fundingUtxos,
+ byte[] fundingSecretKey,
+ Func approveQuote,
+ CancellationToken cancellationToken = default) =>
+ _inner.UnilateralExitAsync(
+ feeRateSatPerVbyte, destinationAddress, leafIds, fundingUtxos, fundingSecretKey, approveQuote,
+ cancellationToken);
+
public Task DisconnectAsync() => _inner.DisconnectAsync();
public void Dispose() => _inner.Dispose();
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs
new file mode 100644
index 0000000..2fe9761
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs
@@ -0,0 +1,301 @@
+using System;
+using System.Linq;
+using Breez.Sdk.Spark;
+using BTCPayServer.Plugins.Flint.Sdk;
+using Xunit;
+
+namespace BTCPayServer.Plugins.Flint.Tests;
+
+///
+/// The unilateral-exit seam's translation layer, which is pure and therefore the only part of that surface a
+/// test can reach without a funded wallet and reachable operators.
+///
+///
+/// Everything asserted here is a place where the SDK's shape and the plugin's disagree, and where getting it
+/// wrong is silent: two enums ordered differently, an optional selection whose empty case means the opposite of
+/// what it looks like, a quote that echoes the request back, and two typed errors whose whole value is the
+/// numbers they carry.
+///
+public class SparkUnilateralExitSeamTests
+{
+ private const string Destination = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080";
+
+ [Fact]
+ public void No_leaf_ids_selects_automatically()
+ {
+ Assert.IsType(SparkSdkClient.ToSdkLeafSelection(null));
+ Assert.IsType(SparkSdkClient.ToSdkLeafSelection([]));
+ }
+
+ [Fact]
+ public void Leaf_ids_pin_the_selection_in_order()
+ {
+ var selection = Assert.IsType(
+ SparkSdkClient.ToSdkLeafSelection(["leaf-b", "leaf-a"]));
+
+ Assert.Equal(["leaf-b", "leaf-a"], selection.leafIds);
+ }
+
+ ///
+ /// Rejected rather than filtered. A hole in a persisted leaf list would quote a smaller exit than
+ /// the one the operator has already funded a UTXO for, and nothing downstream could tell.
+ ///
+ [Fact]
+ public void A_blank_leaf_id_is_refused()
+ {
+ Assert.Throws(() => SparkSdkClient.ToSdkLeafSelection(["leaf-a", " "]));
+ }
+
+ [Fact]
+ public void Transaction_kinds_are_mapped_by_name()
+ {
+ Assert.Equal(SparkExitTxKind.Fanout, SparkSdkClient.MapExitTxKind(UnilateralExitTxKind.FanOut));
+ Assert.Equal(SparkExitTxKind.TreeNode, SparkSdkClient.MapExitTxKind(UnilateralExitTxKind.Node));
+ Assert.Equal(SparkExitTxKind.Refund, SparkSdkClient.MapExitTxKind(UnilateralExitTxKind.Refund));
+ Assert.Equal(SparkExitTxKind.Sweep, SparkSdkClient.MapExitTxKind(UnilateralExitTxKind.Sweep));
+ }
+
+ [Fact]
+ public void Confirmation_statuses_are_mapped_by_name()
+ {
+ Assert.Equal(SparkExitTxStatus.Confirmed, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Confirmed));
+ Assert.Equal(
+ SparkExitTxStatus.Unconfirmed, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Unconfirmed));
+ Assert.Equal(SparkExitTxStatus.Unverified, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Unverified));
+ }
+
+ ///
+ /// Guards the reason the status mapping is written out rather than cast.
+ ///
+ ///
+ /// The SDK orders its enum Confirmed = 0, Unconfirmed = 1 and the plugin's is the other way round, so
+ /// a numeric cast reports every unmined transaction as confirmed. This asserts the two orderings still
+ /// disagree, so that an SDK bump which aligned them cannot quietly make a future cast look harmless.
+ ///
+ [Fact]
+ public void A_numeric_cast_between_the_status_enums_would_be_wrong()
+ {
+ Assert.NotEqual((int)ConfirmationStatus.Confirmed, (int)SparkExitTxStatus.Confirmed);
+ Assert.Equal(0, (int)SparkExitTxStatus.Unconfirmed);
+ }
+
+ [Fact]
+ public void A_quote_carries_every_figure_the_binding_reports()
+ {
+ var quote = SparkSdkClient.MapExitQuote(new PrepareUnilateralExitResponse(
+ leaves: [new UnilateralExitLeaf("leaf-a", 40_000), new UnilateralExitLeaf("leaf-b", 10_000)],
+ recoverableValueSat: 50_000,
+ totalFeeSat: 3_000,
+ fanoutFeeSat: 500,
+ singleUtxoFundingSat: 4_200,
+ perBranchFunding: [new PerBranchFunding("leaf-a", 3_000), new PerBranchFunding("leaf-b", 1_200)],
+ feeRateSatPerVbyte: 7,
+ destination: Destination));
+
+ Assert.Equal(50_000, quote.RecoverableValueSat);
+ Assert.Equal(3_000, quote.TotalFeeSat);
+ Assert.Equal(500, quote.FanoutFeeSat);
+ Assert.Equal(4_200, quote.SingleUtxoFundingSat);
+ Assert.Equal(7UL, quote.FeeRateSatPerVbyte);
+ Assert.Equal(Destination, quote.Destination);
+ Assert.Equal(["leaf-a", "leaf-b"], quote.Leaves.Select(leaf => leaf.LeafId));
+ Assert.Equal(40_000, quote.Leaves[0].ValueSat);
+ Assert.Equal(["leaf-a", "leaf-b"], quote.PerBranchFunding.Select(branch => branch.LeafId));
+ Assert.Equal(1_200, quote.PerBranchFunding[1].FundingSat);
+ Assert.False(quote.IsEmpty);
+ }
+
+ ///
+ /// The case a caller must be able to report as "nothing worth exiting at this fee rate" rather than as a
+ /// failure: automatic selection legitimately comes back with nothing.
+ ///
+ [Fact]
+ public void An_empty_selection_is_a_quote_rather_than_a_fault()
+ {
+ var quote = SparkSdkClient.MapExitQuote(new PrepareUnilateralExitResponse(
+ leaves: [],
+ recoverableValueSat: 0,
+ totalFeeSat: 0,
+ fanoutFeeSat: 0,
+ singleUtxoFundingSat: 0,
+ perBranchFunding: [],
+ feeRateSatPerVbyte: 1,
+ destination: Destination));
+
+ Assert.True(quote.IsEmpty);
+ Assert.Empty(quote.Leaves);
+ Assert.Empty(quote.PerBranchFunding);
+ }
+
+ ///
+ /// Every amount on this surface is a u64. Clamping rather than wrapping is what keeps an absurd value
+ /// from arriving as a negative fee, which would pass every "is this worth exiting" comparison.
+ ///
+ [Fact]
+ public void Amounts_beyond_long_range_are_clamped_rather_than_wrapped()
+ {
+ var quote = SparkSdkClient.MapExitQuote(new PrepareUnilateralExitResponse(
+ leaves: [new UnilateralExitLeaf("leaf-a", ulong.MaxValue)],
+ recoverableValueSat: ulong.MaxValue,
+ totalFeeSat: ulong.MaxValue,
+ fanoutFeeSat: ulong.MaxValue,
+ singleUtxoFundingSat: ulong.MaxValue,
+ perBranchFunding: [],
+ feeRateSatPerVbyte: 1,
+ destination: Destination));
+
+ Assert.Equal(long.MaxValue, quote.RecoverableValueSat);
+ Assert.Equal(long.MaxValue, quote.TotalFeeSat);
+ Assert.Equal(long.MaxValue, quote.Leaves[0].ValueSat);
+ }
+
+ [Fact]
+ public void A_tree_node_keeps_its_child_its_timelock_and_its_dependencies()
+ {
+ var mapped = SparkSdkClient.MapExitTransaction(new UnilateralExitTransaction(
+ UnilateralExitTxKind.Node,
+ nodeId: "node-1",
+ txid: "aa",
+ txHex: "0200aa",
+ cpfpTxHex: "0200cpfp",
+ csvTimelockBlocks: 1_008,
+ dependsOn: ["fanout"],
+ status: ConfirmationStatus.Unconfirmed));
+
+ Assert.Equal(SparkExitTxKind.TreeNode, mapped.Kind);
+ Assert.Equal("node-1", mapped.NodeId);
+ Assert.Equal("0200cpfp", mapped.CpfpTxHex);
+ Assert.Equal(1_008u, mapped.CsvTimelockBlocks!.Value);
+ Assert.Equal(["fanout"], mapped.DependsOn);
+ Assert.True(mapped.RequiresPackageBroadcast);
+ }
+
+ ///
+ /// The fan-out and the sweep belong to no node and pay their own fee, so all three optional fields are null
+ /// and the transaction is broadcast alone. Asserted because packaging is read off
+ /// rather than off the kind.
+ ///
+ [Fact]
+ public void A_standalone_transaction_needs_no_package()
+ {
+ var mapped = SparkSdkClient.MapExitTransaction(new UnilateralExitTransaction(
+ UnilateralExitTxKind.Sweep,
+ nodeId: null,
+ txid: "bb",
+ txHex: "0200bb",
+ cpfpTxHex: null,
+ csvTimelockBlocks: null,
+ dependsOn: null!,
+ status: ConfirmationStatus.Unverified));
+
+ Assert.Null(mapped.NodeId);
+ Assert.Null(mapped.CpfpTxHex);
+ Assert.Null(mapped.CsvTimelockBlocks);
+ Assert.Empty(mapped.DependsOn);
+ Assert.False(mapped.RequiresPackageBroadcast);
+ Assert.Equal(SparkExitTxStatus.Unverified, mapped.Status);
+ }
+
+ [Fact]
+ public void A_funding_output_is_offered_to_the_SDK_as_P2WPKH()
+ {
+ var input = Assert.IsType(SparkSdkClient.ToSdkFundingInput(
+ new SparkExitFundingUtxo("cc", 3, 5_000, "02aabb")));
+
+ Assert.Equal("cc", input.txid);
+ Assert.Equal(3u, input.vout);
+ Assert.Equal(5_000UL, input.value);
+ Assert.Equal("02aabb", input.pubkey);
+ }
+
+ [Fact]
+ public void A_worthless_funding_output_is_refused()
+ {
+ Assert.Throws(() =>
+ SparkSdkClient.ToSdkFundingInput(new SparkExitFundingUtxo("cc", 0, 0, "02aabb")));
+ }
+
+ ///
+ /// The echo check that stands between a quote and a signed sweep.
+ ///
+ ///
+ /// The prepared response is handed straight back to the build, which signs the sweep against
+ /// its destination rather than against the argument the caller passed — so a response describing a
+ /// different address would hand an operator transactions paying somewhere else.
+ ///
+ [Fact]
+ public void A_quote_for_a_different_destination_is_refused()
+ {
+ Assert.Throws(() => SparkSdkClient.RequireQuoteEchoesRequest(
+ Response(Destination, 7), 7, "bcrt1qsomewhereelse0000000000000000000000000"));
+ }
+
+ [Fact]
+ public void A_quote_at_a_different_fee_rate_is_refused()
+ {
+ Assert.Throws(() => SparkSdkClient.RequireQuoteEchoesRequest(
+ Response(Destination, 9), 7, Destination));
+ }
+
+ ///
+ /// bech32 and bech32m are case-insensitive, so an address pasted in upper case is the same address. The
+ /// check exists to catch a different destination, not a differently spelled one.
+ ///
+ [Fact]
+ public void A_bech32_address_in_another_case_is_the_same_destination()
+ {
+ SparkSdkClient.RequireQuoteEchoesRequest(
+ Response(Destination.ToUpperInvariant(), 7), 7, Destination);
+ }
+
+ [Fact]
+ public void A_CPFP_shortfall_becomes_a_typed_error_carrying_the_amount_that_would_work()
+ {
+ var translated = Assert.IsType(
+ SparkErrors.TranslateUnilateralExit(new SdkException.InsufficientCpfpFunds(9_500)));
+
+ Assert.Equal(9_500, translated.RequiredSat);
+ Assert.Contains("9,500", translated.Message);
+ Assert.DoesNotContain("@v1=", translated.Message);
+ }
+
+ [Fact]
+ public void A_funding_conflict_becomes_a_typed_error_naming_the_outpoint()
+ {
+ var translated = Assert.IsType(
+ SparkErrors.TranslateUnilateralExit(new SdkException.FundingUtxoConflict("dd", 2)));
+
+ Assert.Equal("dd:2", translated.OutPoint);
+ Assert.Contains("dd:2", translated.Message);
+ }
+
+ ///
+ /// Null rather than the original exception, so the client can use it as an exception filter and let anything
+ /// else escape with its own stack rather than re-throwing a copy.
+ ///
+ [Fact]
+ public void Any_other_failure_is_left_alone()
+ {
+ Assert.Null(SparkErrors.TranslateUnilateralExit(new SdkException.NetworkException("@v1=offline")));
+ }
+
+ [Fact]
+ public void The_exit_errors_never_reach_a_merchant_with_a_UniFFI_prefix()
+ {
+ Exception[] errors =
+ [
+ new SdkException.InsufficientCpfpFunds(1_234),
+ new SdkException.FundingUtxoConflict("ee", 1)
+ ];
+
+ foreach (var error in errors)
+ {
+ var described = SparkErrors.Describe(error);
+ Assert.False(string.IsNullOrWhiteSpace(described));
+ Assert.DoesNotContain("@v1=", described);
+ }
+ }
+
+ private static PrepareUnilateralExitResponse Response(string destination, ulong feeRate) =>
+ new([], 0, 0, 0, 0, [], feeRate, destination);
+}
diff --git a/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs b/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs
index 2642239..cd7661f 100644
--- a/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs
+++ b/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs
@@ -365,6 +365,108 @@ Task SendCrossChainAsync(
#endregion
+ #region Unilateral exit
+
+ ///
+ /// Quotes a unilateral exit — what a forced, non-cooperative withdrawal from the statechain would recover
+ /// and cost — without building or signing anything.
+ ///
+ ///
+ /// The rate every transaction in the exit is built at. It is a single rate for the whole tree, so it also
+ /// decides which leaves are worth exiting at all, and there is no per-level override.
+ ///
+ ///
+ /// Where the final sweep pays. Validated by the SDK, not here; the caller is still expected to have parsed
+ /// it for the store's own network first, because a mainnet-shaped address is a valid regtest string.
+ ///
+ ///
+ /// Null or empty selects automatically (the SDK's ExitLeafSelection.Auto): the SDK picks whichever
+ /// leaves are worth exiting at this fee rate. Anything else pins the selection to exactly those leaves
+ /// (Specific), which is how a resume re-quotes the same exit — see
+ /// .
+ ///
+ ///
+ ///
+ /// An empty result is a normal answer. With automatic selection the SDK returns no leaves at all when
+ /// nothing clears the fee rate, and that must reach the merchant as "nothing worth exiting right now"
+ /// rather than as a failure.
+ ///
+ ///
+ /// This still needs the Spark operators to be reachable in the pinned SDK version. Quoting an exit
+ /// walks the wallet's tree, which is not held locally, so the one situation a unilateral exit exists for —
+ /// operators gone — is the situation in which this call cannot answer. Exiting from local state is a later
+ /// SDK feature.
+ ///
+ ///
+ /// Cheap and free of side effects: nothing is reserved, nothing expires, and no quote id is minted. Unlike
+ /// it does not touch the service provider's fee-quote machinery at all.
+ ///
+ ///
+ Task PrepareUnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Quotes and then builds a unilateral exit in one call, giving the caller a veto on the quote in between.
+ /// Returns signed transactions and broadcasts nothing.
+ ///
+ ///
+ /// As on . A build resuming a previously quoted exit passes the ids
+ /// that quote returned, because the funding UTXO an operator has already paid for was sized for that leaf
+ /// set and automatic selection is free to choose a different one.
+ ///
+ ///
+ /// Confirmed P2WPKH outputs that will pay every fee in the exit. Must be non-empty. The SDK accepts
+ /// several and judges their combined value, but the reliable shape for a fresh exit is what
+ /// quotes: one output of at least that amount,
+ /// which the SDK fans out across branches — the service layer passes exactly one for that reason. A
+ /// shortfall surfaces as .
+ ///
+ ///
+ /// The private key for those outputs, used to build a one-shot signer for the CPFP transactions. Held only
+ /// for the duration of this call and never logged. The array is the caller's to own and is not cleared here.
+ ///
+ ///
+ /// Called with the quote this build is about to commit to, and before anything is built. Return null to
+ /// proceed or a human-readable refusal, which is raised as . Must not
+ /// throw. This is where the "is this still worth doing" guard belongs: the quote passed here is the fresh
+ /// one, not whatever a page rendered minutes ago.
+ ///
+ ///
+ ///
+ /// Quote and build are one call for the same reason the send paths are — a quote must never be held
+ /// across a request or task boundary. The reason differs in kind, though, and is worse here: this quote does
+ /// not expire, it goes stale silently. The leaf set is a function of the wallet's tree, which moves
+ /// as payments settle, so a build against a quote taken earlier can commit to a different set of leaves than
+ /// the operator funded for, with nothing rejecting it.
+ ///
+ ///
+ /// Nothing is broadcast, by the SDK or by this plugin. The returned transactions are signed and
+ /// inert; an operator pushes them out by hand, fan-out first and alone, then each tree node packaged with
+ /// its CPFP child in dependency order, then the sweep. See . That is also
+ /// what makes the failure modes here benign: every exception this can throw has moved no coins.
+ ///
+ ///
+ /// returned a refusal.
+ ///
+ /// The funding outputs do not cover the exit's fees. Carries what the SDK said was needed.
+ ///
+ ///
+ /// One of the funding outputs is already spent by, or committed to, another transaction.
+ ///
+ Task UnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ IReadOnlyList fundingUtxos,
+ byte[] fundingSecretKey,
+ Func approveQuote,
+ CancellationToken cancellationToken = default);
+
+ #endregion
+
///
/// Detaches the event listener and stops the background sync loop.
///
diff --git a/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs b/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs
index 92e59e2..a374d7a 100644
--- a/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs
+++ b/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs
@@ -1,4 +1,5 @@
using System;
+using System.Globalization;
using Breez.Sdk.Spark;
namespace BTCPayServer.Plugins.Flint.Sdk;
@@ -35,6 +36,8 @@ public static string Describe(Exception exception)
SdkException.Signer signer => $"Spark signer error: {Strip(signer.v1)}",
SdkException.InvalidUuid uuid => $"Invalid identifier: {Strip(uuid.v1)}",
SdkException.Generic generic => Strip(generic.v1),
+ SdkException.InsufficientCpfpFunds shortfall => DescribeCpfpShortfall(ToSats(shortfall.requiredSat)),
+ SdkException.FundingUtxoConflict conflict => DescribeUtxoConflict(conflict.txid, conflict.vout),
// MissingUtxo and MaxDepositClaimFeeExceeded carry several named fields rather than a
// single v1, so there is nothing better to do than strip the synthesised prefix.
SdkException => Strip(exception.Message),
@@ -127,6 +130,58 @@ public static bool IsNotFound(Exception exception)
storage.v1?.Contains("no rows", StringComparison.OrdinalIgnoreCase) is true;
}
+ ///
+ /// Turns the two unilateral-exit-specific SDK errors into typed plugin exceptions, or returns null when the
+ /// failure is something else.
+ ///
+ ///
+ ///
+ /// These two are lifted out of the generic error path because a caller has to act differently on
+ /// them, and the action needs the numbers. InsufficientCpfpFunds names the amount that would have
+ /// worked, which is exactly the figure to put in front of an operator who has to top up a funding address;
+ /// FundingUtxoConflict names the output that is already committed elsewhere, which is what
+ /// distinguishes "your funding UTXO was spent" from "the exit is impossible". Neither reads as anything
+ /// useful through alone, and neither can be matched on without touching SDK types —
+ /// which above this seam nothing may do.
+ ///
+ ///
+ /// Returns null rather than the original exception so a call site can use it as an exception filter and let
+ /// everything else escape unchanged, with its original stack.
+ ///
+ ///
+ public static Exception? TranslateUnilateralExit(Exception exception)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ return exception switch
+ {
+ SdkException.InsufficientCpfpFunds shortfall =>
+ new SparkExitFundingShortfallException(ToSats(shortfall.requiredSat), shortfall),
+ SdkException.FundingUtxoConflict conflict =>
+ new SparkExitFundingUtxoConflictException(conflict.txid, conflict.vout, conflict),
+ _ => null
+ };
+ }
+
+ internal static string DescribeCpfpShortfall(long requiredSat) => string.Format(
+ CultureInfo.InvariantCulture,
+ "There is not enough confirmed Bitcoin on the exit funding address to pay the exit's on-chain fees. "
+ + "Spark needs at least {0:N0} sat available there, as a single confirmed output.",
+ requiredSat);
+
+ internal static string DescribeUtxoConflict(string? txid, uint vout) => string.Format(
+ CultureInfo.InvariantCulture,
+ "The funding output {0}:{1} is already spent or committed to another transaction, so it cannot pay for "
+ + "this exit. Send fresh funds to the funding address and try again once they confirm.",
+ string.IsNullOrWhiteSpace(txid) ? "(unknown)" : txid,
+ vout);
+
+ ///
+ /// Every amount on the exit surface is a u64 of satoshi — no tokens, no base units, no
+ /// BigInteger — so the only conversion hazard is the width, and it is clamped rather than wrapped:
+ /// an absurd value must not come out the other side as a negative fee.
+ ///
+ private static long ToSats(ulong value) => (long)Math.Min(value, long.MaxValue);
+
private static string Strip(string? message)
{
if (string.IsNullOrEmpty(message))
@@ -134,3 +189,62 @@ private static string Strip(string? message)
return message.StartsWith("@v1=", StringComparison.Ordinal) ? message[4..] : message;
}
}
+
+///
+/// Raised when a unilateral exit could not be built because its funding outputs do not cover the fees.
+///
+///
+///
+/// Recoverable, and the fix is a number. A unilateral exit pays every one of its own on-chain fees from a
+/// separate confirmed UTXO the operator supplies, because the coins being recovered are locked behind timelocks
+/// and cannot pay for their own release. Under-funding it therefore fails the build rather than producing a
+/// cheaper exit — and the SDK says what would have been enough, which is carried here so an operator is told
+/// how much to add instead of being told to guess.
+///
+///
+/// Nothing was built, signed or broadcast, so retrying after topping the address up is safe.
+///
+///
+public sealed class SparkExitFundingShortfallException : InvalidOperationException
+{
+ public SparkExitFundingShortfallException(long requiredSat, Exception? innerException = null)
+ : base(SparkErrors.DescribeCpfpShortfall(requiredSat), innerException)
+ {
+ RequiredSat = requiredSat;
+ }
+
+ /// What the SDK said the exit needs, in satoshi, as a single confirmed output.
+ public long RequiredSat { get; }
+}
+
+///
+/// Raised when a funding output offered to a unilateral exit is already spent or otherwise committed.
+///
+///
+///
+/// Almost always means the discovery step raced the chain: the output was unspent when the plugin listed the
+/// funding address and is not by the time the SDK builds against it. It can also mean the same funding UTXO is
+/// being used by a second exit attempt, which is why the outpoint is carried rather than folded into prose —
+/// an operator comparing it against a previous attempt's record is how that gets diagnosed.
+///
+///
+/// Nothing was built, signed or broadcast. Re-discovering the funding outputs and trying again is safe.
+///
+///
+public sealed class SparkExitFundingUtxoConflictException : InvalidOperationException
+{
+ public SparkExitFundingUtxoConflictException(string? txid, uint vout, Exception? innerException = null)
+ : base(SparkErrors.DescribeUtxoConflict(txid, vout), innerException)
+ {
+ Txid = txid;
+ Vout = vout;
+ }
+
+ public string? Txid { get; }
+
+ public uint Vout { get; }
+
+ /// The conflicting output as txid:vout, for comparison against a persisted record.
+ public string OutPoint =>
+ $"{Txid ?? "(unknown)"}:{Vout.ToString(CultureInfo.InvariantCulture)}";
+}
diff --git a/BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs b/BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs
new file mode 100644
index 0000000..d731d15
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs
@@ -0,0 +1,283 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace BTCPayServer.Plugins.Flint.Sdk;
+
+///
+/// What one transaction in a unilateral exit is for, which is what decides how it may be broadcast.
+///
+///
+///
+/// This is not decoration — broadcast order and packaging are read off it. The SDK builds and signs the
+/// whole exit and then never broadcasts anything, so an operator (or a later phase of this plugin) has
+/// to push the transactions out by hand in the right shape: the fan-out alone, then each tree node together
+/// with its own CPFP child as a package, waiting for the CSV timelock between levels, and the sweep alone at
+/// the end. Sending a tree node without its child leaves an unconfirmable transaction paying no fee.
+///
+///
+/// Mapped explicitly from the SDK's UnilateralExitTxKind rather than cast: the SDK spells the first two
+/// FanOut and Node, so name-based mapping is what survives an SDK bump that inserts a variant.
+///
+///
+public enum SparkExitTxKind
+{
+ ///
+ /// The one transaction that splits the CPFP funding UTXO into a fee output per branch. Broadcast first, on
+ /// its own, and confirmed before anything else goes out — every other transaction's fee comes from it.
+ ///
+ Fanout,
+
+ ///
+ /// A statechain tree node, unrolling one level of the tree toward a leaf. Carries a CSV timelock and a CPFP
+ /// child, and must be broadcast as a package with that child.
+ ///
+ TreeNode,
+
+ /// A refund transaction claiming a leaf once its timelock has expired.
+ Refund,
+
+ ///
+ /// The final transaction moving the recovered coins to the operator's destination address. Broadcast alone,
+ /// after everything it depends on has confirmed.
+ ///
+ Sweep
+}
+
+///
+/// Whether the chain has seen a given exit transaction yet, as the SDK's chain service reports it.
+///
+///
+/// The member order is deliberately not the SDK's. ConfirmationStatus is ordered
+/// Confirmed = 0, Unconfirmed = 1, Unverified = 2; this enum puts at 0 so that
+/// a default-initialised value, a missing JSON field, or a column added to an existing row all read as "not
+/// confirmed" rather than as "confirmed". That also means a numeric cast between the two would swap exactly the
+/// pair whose confusion matters most, which is why maps them by name.
+///
+public enum SparkExitTxStatus
+{
+ /// Broadcast (or buildable) but not yet mined.
+ Unconfirmed,
+
+ /// Mined.
+ Confirmed,
+
+ ///
+ /// The SDK could not reach a chain service to say either way. Not a failure and not a confirmation — an
+ /// operator must check the transaction themselves before treating it as either.
+ ///
+ Unverified
+}
+
+///
+/// One statechain leaf a quoted exit would recover.
+///
+///
+///
+/// The leaf ids are the resumable identity of an exit and must be persisted. A quote taken with
+/// Auto selection picks whichever leaves are worth exiting at that moment and at that fee rate; asking
+/// again later can select a different set, which would build a different exit against a funding UTXO sized for
+/// the first one. Re-quoting with these exact ids (Specific) is what makes a resume mean the same exit.
+///
+///
+/// The binding's UnilateralExitLeaf carries only an id and a value — there is no per-leaf fee field, so
+/// there is none here. Fees are reported for the exit as a whole on and per branch
+/// on .
+///
+///
+public sealed record SparkExitLeaf(string LeafId, long ValueSat);
+
+///
+/// How much of the CPFP funding one branch of the tree needs.
+///
+///
+/// The breakdown behind . Shown to an operator so a partially
+/// funded exit is legible — the fan-out creates one fee output per branch, so a shortfall does not fail evenly
+/// across the tree — and deliberately not used for any funding decision: the plugin funds from a
+/// single UTXO, and the amount to check against is the single-UTXO total.
+///
+public sealed record SparkExitBranchFunding(string LeafId, long FundingSat);
+
+///
+/// What a unilateral exit would recover and what it would cost, before any transaction exists.
+///
+///
+///
+/// An empty list is a normal answer, not an error. With Auto selection the
+/// SDK returns nothing at all when no leaf is worth exiting at the requested fee rate, and that has to be
+/// reported to a merchant as "nothing worth exiting right now" rather than as a fault.
+///
+///
+/// Unlike the cooperative-exit quote this one has no expiry and no id: it is a local computation over the
+/// wallet's tree plus a fee rate, so nothing server-side is being held. It is still not carried across a
+/// request boundary, because the tree changes as payments settle and the leaf set would drift — see
+/// , which re-quotes inside the build for that reason.
+///
+///
+/// Every amount here is satoshi. There is no token or base-unit ambiguity anywhere on the exit surface — the
+/// SDK types them all as u64 sats — so none of the machinery applies.
+///
+///
+///
+/// The gross value of the selected leaves. Fees are not netted out of it, so a caller deciding whether
+/// an exit is worth doing must compare this against itself.
+///
+/// Every on-chain fee the exit will pay, fan-out included.
+///
+/// The amount that must sit on the funding address as one UTXO. This is the number an operator funds
+/// against: the plugin spends a single P2WPKH output, so two outputs each half this size do not qualify.
+///
+///
+/// The fan-out transaction's own fee, part of . Called out separately because it
+/// is the one fee that is spent before any coin has been recovered.
+///
+///
+/// The rate the SDK quoted at, echoed back from the request. Carried so a UI shows the rate the numbers
+/// actually belong to rather than the one a form field happens to hold.
+///
+///
+/// The address the sweep will pay, echoed back from the request. asserts this
+/// matches what was asked for before it builds anything, because the built sweep is signed against whatever
+/// this says.
+///
+public sealed record SparkExitQuote(
+ long RecoverableValueSat,
+ long TotalFeeSat,
+ long SingleUtxoFundingSat,
+ IReadOnlyList Leaves,
+ long FanoutFeeSat,
+ IReadOnlyList PerBranchFunding,
+ ulong FeeRateSatPerVbyte,
+ string Destination)
+{
+ /// True when the quote selected nothing — see the remarks on this type.
+ public bool IsEmpty => Leaves.Count == 0;
+}
+
+///
+/// One confirmed on-chain output that will pay the exit's fees.
+///
+///
+///
+/// P2WPKH only, matching the single CpfpFundingKind the plugin asks for. The SDK also supports P2TR and
+/// an arbitrary script, and neither is offered: the funding key is derived on a fixed BIP84 path, so the script
+/// type is not a choice a merchant makes, and a mismatch between the funding kind quoted and the input actually
+/// supplied produces a signature that does not verify.
+///
+///
+/// is the compressed public key for the output's script, not the script itself. It is
+/// passed to the SDK so it can build the witness it will later ask the signer to sign; the private half never
+/// leaves the plugin except as the seed for the one-shot signer.
+///
+///
+public sealed record SparkExitFundingUtxo(string Txid, uint Vout, long ValueSat, string PubkeyHex)
+{
+ /// A stable key for one output, for a form post and for de-duplication.
+ public string OutPoint => $"{Txid}:{Vout.ToString(CultureInfo.InvariantCulture)}";
+}
+
+///
+/// One signed, unbroadcast transaction of an exit.
+///
+///
+///
+/// Nothing here has been sent anywhere. The SDK builds and signs the whole exit and stops; broadcasting
+/// is entirely manual in this phase. That is what makes the accompanying fields load-bearing rather than
+/// informational: says which confirmations to wait for,
+/// says how long a wait stands between one level and the next, and being non-null means
+/// this transaction pays no fee of its own and is unconfirmable unless the two go out together as a package.
+///
+///
+/// is raw transaction hex and safe to display and copy. It contains no key material.
+///
+///
+///
+/// The statechain node this transaction unrolls, or null for the transactions that belong to no single node —
+/// the fan-out and the sweep.
+///
+///
+/// The child that pays this transaction's fee, or null when it pays its own. When set, both must be broadcast
+/// in one package (bitcoin-cli submitpackage); broadcasting the parent alone gets it rejected or leaves
+/// it stuck at zero fee.
+///
+///
+/// Blocks that must pass after the parent confirms before this transaction is valid, or null when there is no
+/// timelock. This is where the multi-day cost of a unilateral exit lives, and it is per level rather than
+/// once for the whole exit.
+///
+///
+/// Txids that must confirm before this transaction may be broadcast. Not the ordering — the SDK returns
+/// the list in a valid topological broadcast order already, and that is the upstream contract this plugin
+/// relies on rather than something re-derived here. What this field is for is the waiting: it names
+/// which confirmations to check for before pushing this one out, which is what turns a correct order into a
+/// correct schedule. It has to survive persistence for the same reason the hex does — the operator broadcasts
+/// from the stored row, possibly days later.
+///
+public sealed record SparkExitTransaction(
+ SparkExitTxKind Kind,
+ string? NodeId,
+ string Txid,
+ string TxHex,
+ string? CpfpTxHex,
+ uint? CsvTimelockBlocks,
+ IReadOnlyList DependsOn,
+ SparkExitTxStatus Status)
+{
+ ///
+ /// True when this transaction and must be submitted together as a package.
+ ///
+ ///
+ /// Read off the presence of the child rather than off . The kinds that need a package
+ /// today are the tree nodes, but the SDK decides which transactions carry a CPFP child, and hard-coding the
+ /// correspondence would silently drop a child the SDK started attaching elsewhere.
+ ///
+ public bool RequiresPackageBroadcast => CpfpTxHex is not null;
+}
+
+///
+/// A built exit: the quote it committed to, plus every transaction an operator has to broadcast.
+///
+///
+/// The totals are re-reported by the SDK from the build rather than copied from the quote, so they are the
+/// figures the signed transactions actually implement. is likewise the set the build used;
+/// it should match the ids the quote was pinned to, and persisting it is what lets a later reconciliation say
+/// which leaves are now committed to on-chain transactions.
+///
+///
+/// Every transaction of the exit, in a valid topological broadcast order — the SDK's own ordering, kept as it
+/// came. Persisted and rendered in this order, so nothing above the seam sorts or re-derives it; each entry's
+/// says which confirmations to wait for before pushing it out.
+///
+public sealed record SparkExitResult(
+ long RecoverableValueSat,
+ long TotalFeeSat,
+ IReadOnlyList Transactions,
+ IReadOnlyList Leaves);
+
+///
+/// Raised when the caller's quote approval callback vetoed an exit, so nothing was built.
+///
+///
+///
+/// An exception rather than a field on , which is the opposite of what the send
+/// paths do — and the difference is deliberate. A vetoed SendBolt11Async has to be reported as a value
+/// because "we chose not to pay" and "the payment failed" are different outcomes for a payout, and a caller
+/// that treated a refusal as an error would retry it. Here there is nothing to distinguish: the SDK broadcasts
+/// nothing, so a veto has moved no money and changed no state, and a result type with an empty transaction
+/// list would invite a caller to persist it as a successful build.
+///
+///
+/// The message is the callback's own, so it is already fit to show a merchant.
+///
+///
+public sealed class SparkExitRefusedException : InvalidOperationException
+{
+ public SparkExitRefusedException(string reason)
+ : base(reason)
+ {
+ Reason = reason;
+ }
+
+ /// The refusal the approval callback returned, verbatim.
+ public string Reason { get; }
+}
diff --git a/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs b/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs
index d89b5c8..6634915 100644
--- a/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs
+++ b/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs
@@ -797,6 +797,295 @@ private static DateTimeOffset ParseExpiry(string? expiresAt) =>
#endregion
+ #region Unilateral exit
+
+ public async Task PrepareUnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+
+ var prepared = await PrepareExitAsync(feeRateSatPerVbyte, destinationAddress, leafIds)
+ .ConfigureAwait(false);
+ return MapExitQuote(prepared);
+ }
+
+ public async Task UnilateralExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds,
+ IReadOnlyList fundingUtxos,
+ byte[] fundingSecretKey,
+ Func approveQuote,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ ArgumentNullException.ThrowIfNull(fundingUtxos);
+ ArgumentNullException.ThrowIfNull(approveQuote);
+
+ // Asserted rather than left to the SDK. An empty funding list would be quoted and then fail somewhere
+ // inside the build with no indication that the caller simply never found a UTXO, and a zero-length key
+ // produces a signer that signs nothing.
+ if (fundingUtxos.Count == 0)
+ {
+ throw new ArgumentException(
+ "A unilateral exit needs at least one confirmed funding output to pay its on-chain fees.",
+ nameof(fundingUtxos));
+ }
+
+ if (fundingSecretKey is null || fundingSecretKey.Length == 0)
+ {
+ throw new ArgumentException(
+ "A unilateral exit needs the private key for its funding outputs so the CPFP transactions can "
+ + "be signed.",
+ nameof(fundingSecretKey));
+ }
+
+ var inputs = fundingUtxos.Select(ToSdkFundingInput).ToArray();
+
+ // Re-quoted here rather than accepted from the caller. See ISparkSdkClient.UnilateralExitAsync: this
+ // quote does not expire, it goes stale silently, so the only safe quote is one taken inside the call
+ // that consumes it.
+ var prepared = await PrepareExitAsync(feeRateSatPerVbyte, destinationAddress, leafIds)
+ .ConfigureAwait(false);
+ var quote = MapExitQuote(prepared);
+
+ var rejection = approveQuote(quote);
+ if (rejection is not null)
+ {
+ _logger.LogInformation(
+ "Store {StoreId}: refused to build a unilateral exit recovering {RecoverableSat} sat for "
+ + "{FeeSat} sat in fees: {Reason}",
+ _storeId, quote.RecoverableValueSat, quote.TotalFeeSat, rejection);
+ throw new SparkExitRefusedException(rejection);
+ }
+
+ // A one-shot signer over the funding key. Created after the veto so a refused exit never materialises
+ // key material, and disposed in a finally because the binding's implementation owns a native handle.
+ var signer = BreezSdkSparkMethods.SingleKeyCpfpSigner(fundingSecretKey);
+ try
+ {
+ UnilateralExitResponse response;
+ try
+ {
+ response = await _sdk
+ .UnilateralExit(new UnilateralExitRequest(prepared, inputs), signer)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex) when (SparkErrors.TranslateUnilateralExit(ex) is { } typed)
+ {
+ // Both translated failures mean the funding outputs were wrong, not that the exit is
+ // impossible, and neither built or broadcast anything. Raised as typed exceptions so the
+ // service above can put the SDK's own numbers in front of an operator.
+ throw typed;
+ }
+
+ var transactions = MapExitTransactions(response.transactions);
+ _logger.LogInformation(
+ "Store {StoreId}: built a unilateral exit over {LeafCount} leaves recovering {RecoverableSat} "
+ + "sat for {FeeSat} sat in fees, as {TxCount} signed transactions. Nothing has been broadcast",
+ _storeId, response.leaves?.Length ?? 0, ToLong(response.recoverableValueSat),
+ ToLong(response.totalFeeSat), transactions.Count);
+
+ return new SparkExitResult(
+ ToLong(response.recoverableValueSat),
+ ToLong(response.totalFeeSat),
+ transactions,
+ MapExitLeaves(response.leaves));
+ }
+ finally
+ {
+ // The interface the binding exposes is not IDisposable; its generated implementation is.
+ if (signer is IDisposable disposable)
+ disposable.Dispose();
+ }
+ }
+
+ ///
+ /// One PrepareUnilateralExit, with the response's own echo of the request checked.
+ ///
+ ///
+ /// The check is the same discipline applies to feePolicy, and it
+ /// matters more here: the prepared response is handed straight back to UnilateralExit, which builds
+ /// and signs the sweep against its destination and rate rather than against the arguments passed
+ /// here. If those ever disagreed, the operator would be handed signed transactions paying somewhere else.
+ ///
+ private async Task PrepareExitAsync(
+ ulong feeRateSatPerVbyte,
+ string destinationAddress,
+ IReadOnlyList? leafIds)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(destinationAddress);
+ ArgumentOutOfRangeException.ThrowIfZero(feeRateSatPerVbyte);
+
+ var prepared = await _sdk.PrepareUnilateralExit(new PrepareUnilateralExitRequest(
+ feeRateSatPerVbyte,
+ // P2WPKH is the only funding kind offered. The SDK also accepts P2TR and an arbitrary script,
+ // and neither is a choice a merchant makes: the funding key is derived on one fixed path, and a
+ // funding kind that disagrees with the input supplied later produces an invalid witness.
+ new CpfpFundingKind.P2wpkh(),
+ destinationAddress,
+ ToSdkLeafSelection(leafIds)))
+ .ConfigureAwait(false);
+
+ RequireQuoteEchoesRequest(prepared, feeRateSatPerVbyte, destinationAddress);
+ return prepared;
+ }
+
+ ///
+ /// Refuses a quote that does not describe the exit that was asked for.
+ ///
+ ///
+ ///
+ /// Split out as a static so the rule is testable without a live SDK, like
+ /// .
+ ///
+ ///
+ /// The destination comparison ignores case because bech32 and bech32m are case-insensitive and an
+ /// operator's address may be pasted in either form, while still catching the failure this exists for: a
+ /// response describing a different address. The fee rate is a plain integer echo with no
+ /// normalisation possible, so it is compared exactly.
+ ///
+ ///
+ internal static void RequireQuoteEchoesRequest(
+ PrepareUnilateralExitResponse prepared,
+ ulong feeRateSatPerVbyte,
+ string destinationAddress)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+
+ if (!string.Equals(prepared.destination, destinationAddress, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ "Spark quoted the unilateral exit against a different destination address than the one "
+ + "requested. The sweep transaction is signed against the quote, so this would pay somewhere "
+ + "else; refusing to build.");
+ }
+
+ if (prepared.feeRateSatPerVbyte != feeRateSatPerVbyte)
+ {
+ throw new InvalidOperationException(
+ $"Spark quoted the unilateral exit at {prepared.feeRateSatPerVbyte} sat/vB rather than the "
+ + $"requested {feeRateSatPerVbyte} sat/vB. Every fee and the funding amount follow from the "
+ + "rate, so refusing to build rather than funding against the wrong figure.");
+ }
+ }
+
+ ///
+ /// Null and empty are both automatic selection, because a caller that has no leaf ids and a caller that has
+ /// an empty list mean the same thing, and Specific([]) would be a request to exit nothing. Blank ids
+ /// are rejected rather than filtered: a hole in a persisted leaf list means the resume would silently pin a
+ /// smaller exit than the one the operator funded.
+ ///
+ internal static ExitLeafSelection ToSdkLeafSelection(IReadOnlyList? leafIds)
+ {
+ if (leafIds is null || leafIds.Count == 0)
+ return new ExitLeafSelection.Auto();
+
+ if (leafIds.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException(
+ "A pinned leaf selection contains a blank leaf id, which would quote a different exit than the "
+ + "one it is resuming.",
+ nameof(leafIds));
+ }
+
+ return new ExitLeafSelection.Specific(leafIds.ToArray());
+ }
+
+ ///
+ /// P2WPKH only, matching the funding kind the prepare asks for. The value is clamped rather than wrapped on
+ /// the way to the SDK's u64; a negative one is a caller bug and is refused, because an output the
+ /// SDK believes is worth zero would be signed for a fee it cannot pay.
+ ///
+ internal static CpfpInput ToSdkFundingInput(SparkExitFundingUtxo utxo)
+ {
+ ArgumentNullException.ThrowIfNull(utxo);
+ ArgumentException.ThrowIfNullOrWhiteSpace(utxo.Txid);
+ ArgumentException.ThrowIfNullOrWhiteSpace(utxo.PubkeyHex);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(utxo.ValueSat);
+
+ return new CpfpInput.P2wpkh(utxo.Txid, utxo.Vout, ToUlong(utxo.ValueSat), utxo.PubkeyHex);
+ }
+
+ internal static SparkExitQuote MapExitQuote(PrepareUnilateralExitResponse prepared)
+ {
+ ArgumentNullException.ThrowIfNull(prepared);
+
+ return new SparkExitQuote(
+ ToLong(prepared.recoverableValueSat),
+ ToLong(prepared.totalFeeSat),
+ ToLong(prepared.singleUtxoFundingSat),
+ MapExitLeaves(prepared.leaves),
+ ToLong(prepared.fanoutFeeSat),
+ prepared.perBranchFunding is null
+ ? []
+ : prepared.perBranchFunding
+ .Select(branch => new SparkExitBranchFunding(branch.leafId, ToLong(branch.fundingSat)))
+ .ToList(),
+ prepared.feeRateSatPerVbyte,
+ prepared.destination);
+ }
+
+ private static IReadOnlyList MapExitLeaves(UnilateralExitLeaf[]? leaves) =>
+ leaves is null
+ ? []
+ : leaves.Select(leaf => new SparkExitLeaf(leaf.leafId, ToLong(leaf.value))).ToList();
+
+ private static IReadOnlyList MapExitTransactions(
+ UnilateralExitTransaction[]? transactions) =>
+ transactions is null ? [] : transactions.Select(MapExitTransaction).ToList();
+
+ internal static SparkExitTransaction MapExitTransaction(UnilateralExitTransaction transaction)
+ {
+ ArgumentNullException.ThrowIfNull(transaction);
+
+ return new SparkExitTransaction(
+ MapExitTxKind(transaction.kind),
+ transaction.nodeId,
+ transaction.txid,
+ transaction.txHex,
+ transaction.cpfpTxHex,
+ transaction.csvTimelockBlocks,
+ transaction.dependsOn is null ? [] : transaction.dependsOn.ToList(),
+ MapExitTxStatus(transaction.status));
+ }
+
+ ///
+ /// Mapped by name, and an unknown variant is a hard failure rather than a fallback. What a transaction is
+ /// decides how it may be broadcast — alone, or packaged with a CPFP child — so a kind this plugin does not
+ /// understand cannot be given broadcast instructions, and guessing would be instructions to lose money.
+ /// Failing here costs nothing: the SDK has broadcast none of it.
+ ///
+ internal static SparkExitTxKind MapExitTxKind(UnilateralExitTxKind kind) => kind switch
+ {
+ UnilateralExitTxKind.FanOut => SparkExitTxKind.Fanout,
+ UnilateralExitTxKind.Node => SparkExitTxKind.TreeNode,
+ UnilateralExitTxKind.Refund => SparkExitTxKind.Refund,
+ UnilateralExitTxKind.Sweep => SparkExitTxKind.Sweep,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(kind), kind,
+ "Spark returned a unilateral-exit transaction of a kind this plugin does not know how to broadcast.")
+ };
+
+ ///
+ /// Mapped explicitly rather than cast, for the reason given on : the SDK
+ /// orders its enum Confirmed = 0, Unconfirmed = 1 and the plugin's is the other way round, so a
+ /// numeric cast would report every unmined transaction as confirmed and every confirmed one as pending.
+ ///
+ internal static SparkExitTxStatus MapExitTxStatus(ConfirmationStatus status) => status switch
+ {
+ ConfirmationStatus.Confirmed => SparkExitTxStatus.Confirmed,
+ ConfirmationStatus.Unconfirmed => SparkExitTxStatus.Unconfirmed,
+ ConfirmationStatus.Unverified => SparkExitTxStatus.Unverified,
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(status), status, "Unknown Spark confirmation status.")
+ };
+
+ #endregion
+
private static long ToLong(ulong value) => (long)Math.Min(value, long.MaxValue);
private static ulong ToUlong(long value) => value < 0 ? 0UL : (ulong)value;
From 37d17c90438eb93afa83d71838c422b845b49afa Mon Sep 17 00:00:00 2001
From: sethforprivacy <40500387+sethforprivacy@users.noreply.github.com>
Date: Thu, 20 Aug 2026 14:34:03 -0400
Subject: [PATCH 2/6] Add unilateral exit settings section and experimental
feature gate
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.
---
.../SparkSettingsSerializationTests.cs | 93 +++++++++++++
BTCPayServer.Plugins.Flint/Constants.cs | 71 ++++++++++
BTCPayServer.Plugins.Flint/SparkSettings.cs | 123 ++++++++++++++++--
3 files changed, 279 insertions(+), 8 deletions(-)
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs
index df4d610..d34bb84 100644
--- a/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using Newtonsoft.Json;
using Xunit;
@@ -48,6 +49,11 @@ public void A_blob_written_by_the_current_shape_round_trips_unchanged()
DrainWhenSweeping = false,
DestinationMode = SweepDestinationMode.StaticAddress,
StaticAddress = "bcrt1qtxwcjjvf4ny9wsw9emgnpazey2vde3xhnyqpw0"
+ },
+ UnilateralExit = new UnilateralExitSettings
+ {
+ DisclosureAcknowledged = true,
+ EsploraApiUrl = "http://localhost:3002/api"
}
};
@@ -67,6 +73,8 @@ public void A_blob_written_by_the_current_shape_round_trips_unchanged()
Assert.False(read.Sweep.DrainWhenSweeping);
Assert.Equal(SweepDestinationMode.StaticAddress, read.Sweep.DestinationMode);
Assert.Equal("bcrt1qtxwcjjvf4ny9wsw9emgnpazey2vde3xhnyqpw0", read.Sweep.StaticAddress);
+ Assert.True(read.UnilateralExit.DisclosureAcknowledged);
+ Assert.Equal("http://localhost:3002/api", read.UnilateralExit.EsploraApiUrl);
}
[Fact]
@@ -152,6 +160,91 @@ public void A_null_settings_blob_is_null_rather_than_a_default_configuration()
Assert.Null(Deserialize("null"));
}
+ [Fact]
+ public void A_blob_with_no_unilateral_exit_section_gets_an_unacknowledged_default()
+ {
+ // Every blob written before this section existed looks like this, and there are a lot of them. The default
+ // that matters is DisclosureAcknowledged: it must read false, because a store that never saw the disclosure
+ // has not accepted it, and it is the server-side gate on producing signed exit transactions.
+ var read = Deserialize(
+ """{"ProtectedMnemonic":"protected-blob","PaymentKey":"key","Sweep":{"Enabled":true}}""")!;
+
+ Assert.NotNull(read.UnilateralExit);
+ Assert.False(read.UnilateralExit.DisclosureAcknowledged);
+ Assert.Null(read.UnilateralExit.EsploraApiUrl);
+ }
+
+ [Fact]
+ public void An_explicit_null_unilateral_exit_section_deserialises_to_null_despite_the_initialiser()
+ {
+ // The same language behaviour that produced the NullReferenceException out of a scheduler pass, pinned for
+ // the new section too: an explicit null beats a property initialiser, so every reader coalesces. A reader
+ // that dereferenced this unguarded would throw on the exit page rather than showing an unacknowledged one.
+ var read = Deserialize(
+ """{"ProtectedMnemonic":"protected-blob","UnilateralExit":null}""")!;
+
+ Assert.Null(read.UnilateralExit);
+ }
+
+ [Fact]
+ public void A_null_unilateral_exit_section_clones_into_an_unacknowledged_one()
+ {
+ // Clone() is on the path a store's settings take out of the service's cache, so it has to survive the blob
+ // above rather than propagating the null — and it must not invent an acknowledgement while doing so.
+ var clone = Deserialize("""{"UnilateralExit":null}""")!.Clone();
+
+ Assert.NotNull(clone.UnilateralExit);
+ Assert.False(clone.UnilateralExit.DisclosureAcknowledged);
+ }
+
+ [Fact]
+ public void Cloning_carries_the_unilateral_exit_section_and_leaves_the_original_alone()
+ {
+ // The reason the parent Clone() is deep: an aliased section would make an edit to the copy silently edit the
+ // cached settings, and for DisclosureAcknowledged that means an acknowledgement appearing on a store whose
+ // operator never gave one — or disappearing from one who did, when a save is rolled back.
+ var source = new SparkSettings
+ {
+ UnilateralExit = new UnilateralExitSettings
+ {
+ DisclosureAcknowledged = true,
+ EsploraApiUrl = "http://esplora.internal/api"
+ }
+ };
+
+ var clone = source.Clone();
+
+ Assert.True(clone.UnilateralExit.DisclosureAcknowledged);
+ Assert.Equal("http://esplora.internal/api", clone.UnilateralExit.EsploraApiUrl);
+ Assert.NotSame(source.UnilateralExit, clone.UnilateralExit);
+
+ clone.UnilateralExit.DisclosureAcknowledged = false;
+ clone.UnilateralExit.EsploraApiUrl = "http://elsewhere/api";
+
+ Assert.True(source.UnilateralExit.DisclosureAcknowledged);
+ Assert.Equal("http://esplora.internal/api", source.UnilateralExit.EsploraApiUrl);
+ }
+
+ [Fact]
+ public void The_unilateral_exit_section_has_exactly_the_properties_this_file_covers()
+ {
+ // A tripwire, not a tautology. The asserts above are hand-written, so a property added later would round-trip
+ // and clone untested — and a section property missed by Clone() is a setting that silently reverts on the
+ // next read out of the settings cache. Adding one has to mean coming here, which is the point.
+ Assert.Equal(
+ new[]
+ {
+ nameof(UnilateralExitSettings.DisclosureAcknowledged),
+ nameof(UnilateralExitSettings.EsploraApiUrl)
+ },
+ typeof(UnilateralExitSettings)
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .Where(p => p.CanRead)
+ .Select(p => p.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToArray());
+ }
+
[Fact]
public void The_effective_threshold_only_substitutes_for_a_non_positive_value()
{
diff --git a/BTCPayServer.Plugins.Flint/Constants.cs b/BTCPayServer.Plugins.Flint/Constants.cs
index 0716bf4..39a5f2f 100644
--- a/BTCPayServer.Plugins.Flint/Constants.cs
+++ b/BTCPayServer.Plugins.Flint/Constants.cs
@@ -173,6 +173,77 @@ public static class Constants
/// Rows per page on the sweep history table.
public const int SweepHistoryPageSize = 25;
+ #region Unilateral exit
+
+ ///
+ /// Whether the experimental unilateral-exit flow exists on this host at all.
+ ///
+ ///
+ ///
+ /// Off unless the operator sets FLINT_EXPERIMENTAL_UNILATERAL_EXIT=1 (or true) in the BTCPay
+ /// process's environment. With it unset the Advanced page renders no link and every exit route returns
+ /// NotFound: not disabled-looking, absent. A merchant who cannot tell a feature from a
+ /// broken one will try the broken one, and this particular one produces signed transactions they then have
+ /// to broadcast themselves.
+ ///
+ ///
+ /// Environment rather than a store setting, because the decision is not the merchant's: on the
+ /// pinned SDK the flow needs the operators reachable to even quote, needs an on-chain UTXO the operator
+ /// funds by hand, and settles over multi-day CSV timelocks. That is a whole-deployment judgement by whoever
+ /// runs the server, and it must be revocable without touching any store's settings blob — unsetting the
+ /// variable takes the feature away from every store at once, leaving the acknowledgements in place for if it
+ /// comes back.
+ ///
+ ///
+ /// A property, not a const or a static readonly. It is read on every request so a
+ /// change takes effect on process restart rather than on rebuild, and so a test can set the variable and
+ /// exercise both sides of the gate in one run — a cached static readonly would freeze whichever
+ /// value the first test to touch this class happened to see, which is exactly the kind of ordering-dependent
+ /// green suite that hides a gate that does not gate.
+ ///
+ ///
+ internal static bool UnilateralExitEnabled =>
+ Environment.GetEnvironmentVariable("FLINT_EXPERIMENTAL_UNILATERAL_EXIT") is "1" or "true";
+
+ ///
+ /// BIP32 hardened account index for the on-chain key that funds a unilateral exit —
+ /// m/84'/{coin}'/4607060'/0/{index}, with coin 0 on mainnet and 1 on regtest, and
+ /// index allocated per exit (see ).
+ ///
+ ///
+ ///
+ /// 4,607,060 is 0x464C54, the ASCII bytes of FLT. The number is not the point; being nowhere
+ /// near anybody else's account index is.
+ ///
+ ///
+ /// Why an odd account at all. The exit's tree transactions cannot pay their own fees, so they are
+ /// bumped by CPFP from an ordinary on-chain UTXO, and the plugin has to hold the key to that UTXO to sign
+ /// the child. The only seed it has is the store's Spark mnemonic — which, for a store set up with
+ /// , is also BTCPay's own hot-wallet seed. Deriving the funding
+ /// key at BIP84 account 0 there would put the plugin's addresses inside the store's own wallet: NBXplorer
+ /// would track them, an operator's coin selection could spend the funding UTXO out from under a
+ /// half-broadcast exit, and a plugin-generated change output could appear in the merchant's balance from a
+ /// wallet they never told about it. A hardened account index no wallet software generates on its own makes
+ /// that collision impossible rather than unlikely.
+ ///
+ ///
+ /// BIP84 purpose (84') rather than something exotic, because the funding output has to be
+ /// native SegWit: Phase 0 supports exactly one CPFP funding kind, P2WPKH, so a Taproot or legacy funding
+ /// address is not a stylistic difference, it is an exit that cannot be built. Depth and layout follow BIP84
+ /// so the path is recoverable in any standard wallet — an operator who needs to reclaim leftover funding
+ /// sats after an exit, or after abandoning one, can import the mnemonic elsewhere and find them at a path
+ /// they can read off this comment.
+ ///
+ ///
+ /// Fixed forever, like every other derivation constant: change it and the funding UTXOs of every exit
+ /// already in flight are at an address the plugin no longer looks at. Spark's own keys are unaffected
+ /// either way — the SDK derives at a hardened m/8797555'/…, disjoint from this and from BIP84/86.
+ ///
+ ///
+ public const uint UnilateralExitFundingAccount = 4607060;
+
+ #endregion
+
///
/// Default ceiling on a Lightning send fee, as a percentage of the amount, when the caller sets none.
///
diff --git a/BTCPayServer.Plugins.Flint/SparkSettings.cs b/BTCPayServer.Plugins.Flint/SparkSettings.cs
index 59285a5..2339c01 100644
--- a/BTCPayServer.Plugins.Flint/SparkSettings.cs
+++ b/BTCPayServer.Plugins.Flint/SparkSettings.cs
@@ -64,6 +64,20 @@ public class SparkSettings
///
public StableBalanceSettings StableBalance { get; set; } = new();
+ ///
+ /// Experimental unilateral-exit configuration. Inert on any host that has not set
+ /// FLINT_EXPERIMENTAL_UNILATERAL_EXIT (). Coalesce
+ /// before use, as with .
+ ///
+ ///
+ /// Present on every settings blob written from this version on, whether or not the host has the gate set,
+ /// because the alternative — writing the section only when the feature is enabled — would mean a store's
+ /// acknowledgement silently disappearing from the blob the first time an operator saved settings with the
+ /// gate off. The section existing is not the feature being available; see
+ /// .
+ ///
+ public UnilateralExitSettings UnilateralExit { get; set; } = new();
+
///
/// An independent copy, nested settings included. Every property added to this class must be added here too.
///
@@ -77,10 +91,11 @@ public class SparkSettings
/// own.
///
///
- /// Deep for the three nested objects, because a shallow copy would defeat the whole point: the edits that
- /// matter all land on , or rather
- /// than on the scalars here. Each is coalesced, because an explicit null in a stored blob defeats
- /// the property initialiser — the same hazard every reader of these three has to handle.
+ /// Deep for the four nested objects, because a shallow copy would defeat the whole point: the edits that
+ /// matter all land on , , or
+ /// rather than on the scalars here. Each is coalesced, because an explicit
+ /// null in a stored blob defeats the property initialiser — the same hazard every reader of these
+ /// four has to handle.
///
///
public SparkSettings Clone() => new()
@@ -91,7 +106,8 @@ public class SparkSettings
ApiKeyOverride = ApiKeyOverride,
Sweep = (Sweep ?? new SweepSettings()).Clone(),
Deposits = (Deposits ?? new SparkDepositSettings()).Clone(),
- StableBalance = (StableBalance ?? new StableBalanceSettings()).Clone()
+ StableBalance = (StableBalance ?? new StableBalanceSettings()).Clone(),
+ UnilateralExit = (UnilateralExit ?? new UnilateralExitSettings()).Clone()
};
}
@@ -433,6 +449,94 @@ public class StableBalanceSettings
};
}
+///
+/// Experimental unilateral exit: recovering the store's Spark balance on-chain without the operators
+/// cooperating on an exit transaction.
+///
+///
+///
+/// Nothing in this section is reachable unless the host sets
+/// FLINT_EXPERIMENTAL_UNILATERAL_EXIT — see . With the
+/// gate off the Advanced page shows no entry point and every controller action returns 404, so a blob carrying
+/// an acknowledgement is inert rather than dangerous. The section is still written and still cloned, because a
+/// setting that only exists while a feature flag is on is a setting that vanishes the first time somebody saves
+/// with the flag off.
+///
+///
+/// What it is, stated plainly, because the word oversells it. A cooperative exit — every sweep this
+/// plugin makes — asks the operators to build and broadcast one Bitcoin transaction, and it lands in seconds
+/// for a flat fee. A unilateral exit walks the store's own leaves out through the statechain's timelocked
+/// transaction tree: the SDK builds and signs, the plugin never broadcasts, and an operator has to
+/// push the transactions by hand in dependency order, waiting on confirmations and on CSV timelocks measured
+/// in days. It is a last resort for the case the exit path this plugin actually uses stops working, not a
+/// privacy or cost option, and the copy on the page says so.
+///
+///
+/// Three traps that are the reason this is experimental rather than a feature. First, on the pinned SDK
+/// (Breez.Sdk.Spark 0.22.0) preparing an exit still requires the operators to be reachable: the
+/// scenario a merchant most wants this for — operators gone — is the one it cannot serve until the SDK ships
+/// exit-from-local-state. Second, the tree transactions cannot pay their own fees, so the exit is funded by
+/// CPFP from an on-chain UTXO the operator has to send to a plugin-derived native-SegWit address first (see
+/// ); too little there and the build refuses. Third, the
+/// funds are not spendable when the transactions are built — they are spendable when the last timelock
+/// expires.
+///
+///
+/// Deliberately two properties. Everything else about an exit — fee rate, destination, which leaves — belongs
+/// to one attempt and lives on the exit record, not in the store's configuration: a persisted quote is a stale
+/// quote, and a persisted destination is an address nobody re-read before money moved.
+///
+///
+public class UnilateralExitSettings
+{
+ ///
+ /// The operator has been shown what a unilateral exit costs them in time and attention, and accepted it.
+ /// Quoting and building are refused without it.
+ ///
+ ///
+ /// Stored rather than treated as a form-only checkbox, for the same reason
+ /// is: a checkbox enforced in a view is enforced
+ /// nowhere. The service re-reads this before every operation, so the acknowledgement is a server-side gate
+ /// on an action that produces signed transactions spending the store's balance — and one an operator has to
+ /// have made deliberately, because the alternative is discovering the multi-day timelocks after starting.
+ ///
+ public bool DisclosureAcknowledged { get; set; }
+
+ ///
+ /// Base URL of the esplora-compatible API used to discover the funding UTXO. Null uses the default for the
+ /// store's network.
+ ///
+ ///
+ ///
+ /// The plugin has to see the funding UTXO before it can build anything, and it cannot ask the SDK: the
+ /// funding output is an ordinary on-chain UTXO on an address derived outside Spark's key tree, so nothing in
+ /// the wallet knows about it. BTCPay's own NBXplorer does not either, because the address is not in any of
+ /// the store's derivation schemes. That leaves a block explorer, and an override so an operator can point at
+ /// their own instance rather than a third party that learns which address is funding their exit.
+ ///
+ ///
+ /// There is no usable default off mainnet. mempool.space has no regtest, so a regtest exit without
+ /// this set is refused with a message saying to set it, rather than silently reporting no funding found —
+ /// which reads identically to "your UTXO has not confirmed yet" and would have an operator waiting on a
+ /// confirmation that already happened.
+ ///
+ ///
+ /// Nothing here is trusted with a decision. A wrong or hostile explorer can make the build refuse (no UTXO
+ /// found) or fail at broadcast (a UTXO that does not exist), which is why the operator sees the funding
+ /// figure the explorer reported before pressing Build; it cannot redirect money, because the destination is
+ /// in the transactions the SDK signs.
+ ///
+ ///
+ public string? EsploraApiUrl { get; set; }
+
+ /// An independent copy. Every property added to this class must be added here too.
+ public UnilateralExitSettings Clone() => new()
+ {
+ DisclosureAcknowledged = DisclosureAcknowledged,
+ EsploraApiUrl = EsploraApiUrl
+ };
+}
+
///
/// Origin of the store's Spark wallet seed — the three sources the setup wizard offers, and the only three.
///
@@ -491,8 +595,9 @@ public enum SweepDestinationMode
///
/// Not a Bitcoin address and not a cooperative exit: the wallet transfers to the bridge provider's Spark
/// deposit address and the provider settles on the destination chain. It is still an ordinary Spark
- /// transfer at the point money leaves this wallet, so the exit-path policy is untouched — there is no
- /// unilateral exit here either.
+ /// transfer at the point money leaves this wallet, so the exit-path policy is untouched — no exit of
+ /// either kind happens here. A unilateral exit is reachable only from the experimental, env-gated flow on
+ /// the Advanced page (), never from a sweep.
///
///
/// Mainnet only, hard-gated by the SDK: a connect that carries a cross-chain configuration on
@@ -647,7 +752,9 @@ public class SweepSettings
///
/// On by default, and this is a cooperative exit either way. "Drain" here means only that the fee
/// is netted out of the amount, so the balance lands on exactly ; it has nothing
- /// to do with a unilateral exit, which this plugin does not implement anywhere, by owner decision. It
+ /// to do with a unilateral exit. Sweeping — automatic or manual — is cooperative, always; the only
+ /// unilateral-exit path in the plugin is the experimental, env-gated, manually-broadcast one on the
+ /// Advanced page (), which no sweep setting can reach. It
/// defaults on because the default is zero, and with
/// the fee charged on top a zero reserve leaves nothing to charge it against.
///
From 06ac276703346eb1ac54034891f0acbfaa9d46db Mon Sep 17 00:00:00 2001
From: sethforprivacy <40500387+sethforprivacy@users.noreply.github.com>
Date: Thu, 20 Aug 2026 14:34:03 -0400
Subject: [PATCH 3/6] Add the unilateral exit record store with a
one-active-exit constraint
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.
---
.../Postgres/PostgresTestDatabase.cs | 3 +-
.../UnilateralExitRecordStoreContractTests.cs | 395 ++++++++++++++++++
.../Data/EfUnilateralExitRecordStore.cs | 247 +++++++++++
.../Data/IUnilateralExitRecordStore.cs | 134 ++++++
.../Data/SparkPluginDbContext.cs | 39 ++
.../Data/UnilateralExitRecord.cs | 244 +++++++++++
...20175701_UnilateralExitRecords.Designer.cs | 292 +++++++++++++
.../20260820175701_UnilateralExitRecords.cs | 70 ++++
.../SparkPluginDbContextModelSnapshot.cs | 68 +++
9 files changed, 1491 insertions(+), 1 deletion(-)
create mode 100644 BTCPayServer.Plugins.Flint.Tests/UnilateralExitRecordStoreContractTests.cs
create mode 100644 BTCPayServer.Plugins.Flint/Data/EfUnilateralExitRecordStore.cs
create mode 100644 BTCPayServer.Plugins.Flint/Data/IUnilateralExitRecordStore.cs
create mode 100644 BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs
create mode 100644 BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.Designer.cs
create mode 100644 BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.cs
diff --git a/BTCPayServer.Plugins.Flint.Tests/Postgres/PostgresTestDatabase.cs b/BTCPayServer.Plugins.Flint.Tests/Postgres/PostgresTestDatabase.cs
index 3ca2bc6..3bb28ce 100644
--- a/BTCPayServer.Plugins.Flint.Tests/Postgres/PostgresTestDatabase.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/Postgres/PostgresTestDatabase.cs
@@ -103,7 +103,8 @@ await context.Database.ExecuteSqlRawAsync(
TRUNCATE TABLE
"{Constants.DatabaseSchema}"."InvoiceRecords",
"{Constants.DatabaseSchema}"."OutgoingPayments",
- "{Constants.DatabaseSchema}"."SweepRecords";
+ "{Constants.DatabaseSchema}"."SweepRecords",
+ "{Constants.DatabaseSchema}"."UnilateralExitRecords";
""");
return factory;
}
diff --git a/BTCPayServer.Plugins.Flint.Tests/UnilateralExitRecordStoreContractTests.cs b/BTCPayServer.Plugins.Flint.Tests/UnilateralExitRecordStoreContractTests.cs
new file mode 100644
index 0000000..7965ad1
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint.Tests/UnilateralExitRecordStoreContractTests.cs
@@ -0,0 +1,395 @@
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Tests.Postgres;
+using Xunit;
+
+namespace BTCPayServer.Plugins.Flint.Tests;
+
+///
+/// The contract, asserted against the production EF store and the
+/// in-memory one the service tests run on.
+///
+///
+///
+/// The exit service's own tests run entirely against the in-memory store, so they mean nothing if the two
+/// implementations disagree — and on this table a disagreement is expensive. A column missing from the model
+/// reads back as its default rather than failing, which silently discards the merchant's only copy of a signed
+/// transaction set; a compare-and-set that is really a blind write lets an abandon clobber a build; and a store
+/// that permits two active exits permits two signed transaction sets over the same statechain nodes.
+///
+///
+/// Every test scopes itself to its own store id rather than relying on a truncated table. The shared Postgres
+/// fixture does truncate this table between tests, but the isolation here deliberately comes from the store
+/// scope instead, which is also what every production read is scoped by.
+///
+///
+public abstract class UnilateralExitRecordStoreContractTests
+{
+ private const string Destination = "bcrt1qtxwcjjvf4ny9wsw9emgnpazey2vde3xhnyqpw0";
+ private const string Funding = "bcrt1q9wpzfrqx3l9dhwvpvsrjgnd8x9tfkgdhkfxpu6";
+
+ protected abstract Task CreateStoreAsync();
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ private static readonly DateTimeOffset Origin = new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
+
+ /// A store id no other test shares. See the remarks on the class.
+ private readonly string _storeId = "store-" + Guid.NewGuid().ToString("N");
+
+ private readonly string _otherStoreId = "store-" + Guid.NewGuid().ToString("N");
+
+ private UnilateralExitRecord NewRecord(
+ string id,
+ string? storeId = null,
+ UnilateralExitStatus status = UnilateralExitStatus.AwaitingFunding,
+ int minutesOld = 0,
+ long fundingKeyIndex = 0) => new()
+ {
+ Id = id,
+ StoreId = storeId ?? _storeId,
+ Status = status,
+ CreatedUtc = Origin.AddMinutes(-minutesOld),
+ UpdatedUtc = Origin.AddMinutes(-minutesOld),
+ DestinationAddress = Destination,
+ FeeRateSatPerVbyte = 12,
+ LeafIdsJson = """["leaf-a","leaf-b"]""",
+ RecoverableValueSat = 480_000,
+ TotalFeeSat = 31_000,
+ SingleUtxoFundingSat = 44_000,
+ FundingAddress = Funding,
+ FundingKeyIndex = fundingKeyIndex
+ };
+
+ [Fact]
+ public async Task A_record_round_trips_with_every_field()
+ {
+ // Not an assertion that a property setter works: the round trip goes through the store, so this is what
+ // proves the entity is mapped. An unmapped column reads back as its default, and on this table that means
+ // signed transactions nobody can broadcast any more.
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1", fundingKeyIndex: 7);
+ record.FundingUtxosJson = """[{"Txid":"aa","Vout":0,"ValueSat":44000,"PubkeyHex":"02ff"}]""";
+ record.TransactionsJson = """[{"Kind":"Fanout","Txid":"bb","TxHex":"0200"}]""";
+ record.LastError = "nothing in particular";
+
+ Assert.True(await store.CreateAsync(record, Ct));
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+
+ Assert.NotNull(read);
+ Assert.Equal(_storeId, read.StoreId);
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, read.Status);
+ Assert.Equal(Origin, read.CreatedUtc);
+ Assert.Equal(Origin, read.UpdatedUtc);
+ Assert.Equal(Destination, read.DestinationAddress);
+ Assert.Equal(12, read.FeeRateSatPerVbyte);
+ Assert.Equal("""["leaf-a","leaf-b"]""", read.LeafIdsJson);
+ Assert.Equal(480_000, read.RecoverableValueSat);
+ Assert.Equal(31_000, read.TotalFeeSat);
+ Assert.Equal(44_000, read.SingleUtxoFundingSat);
+ Assert.Equal(Funding, read.FundingAddress);
+ Assert.Equal(7, read.FundingKeyIndex);
+ Assert.Equal(record.FundingUtxosJson, read.FundingUtxosJson);
+ Assert.Equal(record.TransactionsJson, read.TransactionsJson);
+ Assert.Equal("nothing in particular", read.LastError);
+ }
+
+ [Fact]
+ public async Task Reusing_an_id_is_refused()
+ {
+ // An exception rather than a false: a reused id is a programming error, and reporting it as the ordinary
+ // "this store already has an exit" refusal would let the caller believe its row was stored.
+ var store = await CreateStoreAsync();
+ Assert.True(await store.CreateAsync(
+ NewRecord("exit-1", status: UnilateralExitStatus.Completed), Ct));
+
+ await Assert.ThrowsAnyAsync(() => store.CreateAsync(
+ NewRecord("exit-1", status: UnilateralExitStatus.Completed), Ct));
+ }
+
+ [Fact]
+ public async Task A_record_is_not_readable_from_another_store()
+ {
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(NewRecord("exit-1"), Ct);
+
+ Assert.Null(await store.GetAsync(_otherStoreId, "exit-1", Ct));
+ Assert.Null(await store.GetActiveForStoreAsync(_otherStoreId, Ct));
+ Assert.Empty(await store.ListTerminalForStoreAsync(_otherStoreId, 10, Ct));
+ Assert.Equal(0, await store.NextFundingKeyIndexAsync(_otherStoreId, Ct));
+ }
+
+ [Fact]
+ public async Task A_second_active_exit_for_one_store_is_refused()
+ {
+ // The durable half of the single-flight rule. The service checks for an active exit before quoting, but
+ // that check and the insert are two statements — so the store has to be the one that says no, or two
+ // exits end up committing the same statechain nodes to two different sets of signed transactions.
+ var store = await CreateStoreAsync();
+ Assert.True(await store.CreateAsync(NewRecord("exit-1"), Ct));
+
+ Assert.False(await store.CreateAsync(NewRecord("exit-2", fundingKeyIndex: 1), Ct));
+ Assert.Null(await store.GetAsync(_storeId, "exit-2", Ct));
+
+ // Another store is unaffected, and a terminal row is outside the index's filter entirely.
+ Assert.True(await store.CreateAsync(NewRecord("exit-3", _otherStoreId), Ct));
+ Assert.True(await store.CreateAsync(
+ NewRecord("exit-4", status: UnilateralExitStatus.Abandoned, fundingKeyIndex: 1), Ct));
+ }
+
+ [Fact]
+ public async Task Finishing_an_exit_lets_the_store_quote_another()
+ {
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1");
+ await store.CreateAsync(record, Ct);
+
+ record.Status = UnilateralExitStatus.Completed;
+ Assert.True(await store.UpdateAsync(record, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ Assert.True(await store.CreateAsync(NewRecord("exit-2", fundingKeyIndex: 1), Ct));
+ }
+
+ [Fact]
+ public async Task An_update_writes_the_build_result_and_leaves_the_exit_s_identity_alone()
+ {
+ // The identity columns are what the operator approved and funded against, so a caller that hands back a
+ // mutated copy must not be able to rewrite the exit into a different one.
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(NewRecord("exit-1"), Ct);
+
+ var record = NewRecord("exit-1");
+ record.Status = UnilateralExitStatus.Built;
+ record.UpdatedUtc = Origin.AddMinutes(90);
+ record.TotalFeeSat = 33_500;
+ record.TransactionsJson = """[{"Kind":"Fanout","Txid":"bb"}]""";
+ record.DestinationAddress = "bcrt1qsomewhereelse";
+ record.LeafIdsJson = """["leaf-c"]""";
+ record.FeeRateSatPerVbyte = 400;
+ record.CreatedUtc = Origin.AddYears(1);
+ record.FundingKeyIndex = 99;
+
+ Assert.True(await store.UpdateAsync(record, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+ Assert.NotNull(read);
+ Assert.Equal(UnilateralExitStatus.Built, read.Status);
+ Assert.Equal(Origin.AddMinutes(90), read.UpdatedUtc);
+ Assert.Equal(33_500, read.TotalFeeSat);
+ Assert.Equal("""[{"Kind":"Fanout","Txid":"bb"}]""", read.TransactionsJson);
+ Assert.Equal(Destination, read.DestinationAddress);
+ Assert.Equal("""["leaf-a","leaf-b"]""", read.LeafIdsJson);
+ Assert.Equal(12, read.FeeRateSatPerVbyte);
+ Assert.Equal(Origin, read.CreatedUtc);
+ Assert.Equal(0, read.FundingKeyIndex);
+ }
+
+ [Fact]
+ public async Task An_update_from_an_unexpected_status_changes_nothing()
+ {
+ // The compare-and-set, and the case it exists for: an abandon that read the row while it was awaiting
+ // funding must not land after a build has filled it with signed transactions.
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(NewRecord("exit-1"), Ct);
+
+ var built = NewRecord("exit-1");
+ built.Status = UnilateralExitStatus.Built;
+ built.TransactionsJson = """[{"Kind":"Fanout","Txid":"bb"}]""";
+ Assert.True(await store.UpdateAsync(built, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ var stale = NewRecord("exit-1");
+ stale.Status = UnilateralExitStatus.Abandoned;
+
+ Assert.False(await store.UpdateAsync(stale, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+ Assert.Equal(UnilateralExitStatus.Built, read!.Status);
+ Assert.Equal("""[{"Kind":"Fanout","Txid":"bb"}]""", read.TransactionsJson);
+ }
+
+ [Fact]
+ public async Task An_update_that_says_nothing_about_the_blobs_does_not_erase_them()
+ {
+ // Abandoning, recording a failure, or writing back a history row projected without its blobs all pass a
+ // record whose JSON columns are null. Those columns are the exit's only copy of its signed transactions
+ // and the outpoint they spend, so null has to mean "nothing new to say".
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1");
+ record.FundingUtxosJson = """[{"Txid":"aa","Vout":0,"ValueSat":44000,"PubkeyHex":"02ff"}]""";
+ record.TransactionsJson = """[{"Kind":"Fanout","Txid":"bb","TxHex":"0200"}]""";
+ record.Status = UnilateralExitStatus.Built;
+ await store.CreateAsync(record, Ct);
+
+ var abandoning = NewRecord("exit-1", status: UnilateralExitStatus.Abandoned);
+ Assert.True(await store.UpdateAsync(abandoning, UnilateralExitStatus.Built, Ct));
+
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+ Assert.Equal(UnilateralExitStatus.Abandoned, read!.Status);
+ Assert.Equal(record.FundingUtxosJson, read.FundingUtxosJson);
+ Assert.Equal(record.TransactionsJson, read.TransactionsJson);
+ }
+
+ [Fact]
+ public async Task An_update_clears_a_previous_error()
+ {
+ // Null is an assignment here rather than "nothing new to say": a build that got further must not leave the
+ // failed attempt's complaint on the page next to its own result.
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1");
+ record.LastError = "not enough on the funding address";
+ await store.CreateAsync(record, Ct);
+
+ record.LastError = null;
+ record.Status = UnilateralExitStatus.Built;
+ Assert.True(await store.UpdateAsync(record, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+ Assert.Null(read!.LastError);
+ }
+
+ [Fact]
+ public async Task An_update_from_another_store_changes_nothing()
+ {
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(NewRecord("exit-1"), Ct);
+
+ var impostor = NewRecord("exit-1", _otherStoreId);
+ impostor.Status = UnilateralExitStatus.Abandoned;
+
+ Assert.False(await store.UpdateAsync(impostor, UnilateralExitStatus.AwaitingFunding, Ct));
+
+ var read = await store.GetAsync(_storeId, "exit-1", Ct);
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, read!.Status);
+ }
+
+ [Fact]
+ public async Task An_update_to_an_unknown_exit_reports_that_it_did_nothing()
+ {
+ var store = await CreateStoreAsync();
+
+ Assert.False(await store.UpdateAsync(
+ NewRecord("exit-missing"), UnilateralExitStatus.AwaitingFunding, Ct));
+ }
+
+ [Fact]
+ public async Task The_active_exit_is_the_one_that_has_not_finished()
+ {
+ // Both non-terminal statuses count, which is the single-flight guard: an exit holding unbroadcast
+ // transactions occupies the store just as much as one waiting for its funding.
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(NewRecord("exit-done", status: UnilateralExitStatus.Completed), Ct);
+ await store.CreateAsync(
+ NewRecord("exit-gone", status: UnilateralExitStatus.Abandoned, fundingKeyIndex: 1), Ct);
+ await store.CreateAsync(NewRecord("exit-built", status: UnilateralExitStatus.Built, fundingKeyIndex: 2), Ct);
+
+ Assert.Equal("exit-built", (await store.GetActiveForStoreAsync(_storeId, Ct))!.Id);
+
+ var built = NewRecord("exit-built", status: UnilateralExitStatus.Completed, fundingKeyIndex: 2);
+ await store.UpdateAsync(built, UnilateralExitStatus.Built, Ct);
+ await store.CreateAsync(NewRecord("exit-waiting", fundingKeyIndex: 3), Ct);
+
+ Assert.Equal("exit-waiting", (await store.GetActiveForStoreAsync(_storeId, Ct))!.Id);
+ }
+
+ [Fact]
+ public async Task Abandoning_the_last_active_exit_frees_the_store()
+ {
+ // The whole reason Abandoned exists: an exit with no way forward would otherwise block every later one.
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1");
+ await store.CreateAsync(record, Ct);
+
+ record.Status = UnilateralExitStatus.Abandoned;
+ await store.UpdateAsync(record, UnilateralExitStatus.AwaitingFunding, Ct);
+
+ Assert.Null(await store.GetActiveForStoreAsync(_storeId, Ct));
+ }
+
+ [Fact]
+ public async Task History_lists_finished_exits_newest_first_and_honours_the_limit()
+ {
+ var store = await CreateStoreAsync();
+ await store.CreateAsync(
+ NewRecord("exit-1", status: UnilateralExitStatus.Completed, minutesOld: 30), Ct);
+ await store.CreateAsync(
+ NewRecord("exit-2", status: UnilateralExitStatus.Abandoned, minutesOld: 20, fundingKeyIndex: 1), Ct);
+ await store.CreateAsync(
+ NewRecord("exit-3", status: UnilateralExitStatus.Completed, minutesOld: 10, fundingKeyIndex: 2), Ct);
+ // Active, so it belongs to the page's own panel and not to the history table.
+ await store.CreateAsync(NewRecord("exit-live", fundingKeyIndex: 3), Ct);
+
+ var page = await store.ListTerminalForStoreAsync(_storeId, 2, Ct);
+
+ Assert.Equal(["exit-3", "exit-2"], page.Select(r => r.Id).ToArray());
+ }
+
+ [Fact]
+ public async Task History_rows_do_not_carry_the_json_columns()
+ {
+ // The history table renders scalars. Dragging every signed transaction set in a store's past out of the
+ // database to render a date and a status is the cost this projection exists to avoid, so the absence is
+ // asserted rather than assumed.
+ var store = await CreateStoreAsync();
+ var record = NewRecord("exit-1", status: UnilateralExitStatus.Completed);
+ record.FundingUtxosJson = """[{"Txid":"aa","Vout":0,"ValueSat":44000,"PubkeyHex":"02ff"}]""";
+ record.TransactionsJson = """[{"Kind":"Fanout","Txid":"bb","TxHex":"0200"}]""";
+ record.LastError = "something worth reading";
+ await store.CreateAsync(record, Ct);
+
+ var row = Assert.Single(await store.ListTerminalForStoreAsync(_storeId, 10, Ct));
+
+ Assert.Null(row.FundingUtxosJson);
+ Assert.Null(row.TransactionsJson);
+ Assert.Equal(string.Empty, row.LeafIdsJson);
+ // Everything the table actually shows is there.
+ Assert.Equal(UnilateralExitStatus.Completed, row.Status);
+ Assert.Equal(Origin, row.CreatedUtc);
+ Assert.Equal(Destination, row.DestinationAddress);
+ Assert.Equal(480_000, row.RecoverableValueSat);
+ Assert.Equal("something worth reading", row.LastError);
+ }
+
+ [Fact]
+ public async Task A_non_positive_limit_is_refused()
+ {
+ var store = await CreateStoreAsync();
+
+ await Assert.ThrowsAsync(
+ () => store.ListTerminalForStoreAsync(_storeId, 0, Ct));
+ }
+
+ [Fact]
+ public async Task The_next_funding_key_index_is_one_past_every_index_ever_issued()
+ {
+ // Terminal rows count. Reusing an index re-issues a funding address that may still hold sats from an
+ // abandoned exit, and the next build would then select that stale output as if the operator had just sent
+ // it.
+ var store = await CreateStoreAsync();
+ Assert.Equal(0, await store.NextFundingKeyIndexAsync(_storeId, Ct));
+
+ await store.CreateAsync(
+ NewRecord("exit-1", status: UnilateralExitStatus.Abandoned, fundingKeyIndex: 0), Ct);
+ Assert.Equal(1, await store.NextFundingKeyIndexAsync(_storeId, Ct));
+
+ await store.CreateAsync(
+ NewRecord("exit-2", status: UnilateralExitStatus.Completed, fundingKeyIndex: 4), Ct);
+ Assert.Equal(5, await store.NextFundingKeyIndexAsync(_storeId, Ct));
+
+ // Per store, not per server: another store's indexes are its own.
+ await store.CreateAsync(NewRecord("exit-3", _otherStoreId, fundingKeyIndex: 40), Ct);
+ Assert.Equal(5, await store.NextFundingKeyIndexAsync(_storeId, Ct));
+ Assert.Equal(41, await store.NextFundingKeyIndexAsync(_otherStoreId, Ct));
+ }
+}
+
+/// The contract against the production EF store and a real Postgres database.
+[Trait("Category", "Postgres")]
+[Collection(PostgresTestDatabase.CollectionName)]
+public class PostgresUnilateralExitRecordStoreTests : UnilateralExitRecordStoreContractTests
+{
+ private readonly PostgresTestDatabase _database;
+
+ public PostgresUnilateralExitRecordStoreTests(PostgresTestDatabase database) => _database = database;
+
+ protected override async Task CreateStoreAsync() =>
+ new EfUnilateralExitRecordStore(await _database.CreateFactoryAsync());
+}
diff --git a/BTCPayServer.Plugins.Flint/Data/EfUnilateralExitRecordStore.cs b/BTCPayServer.Plugins.Flint/Data/EfUnilateralExitRecordStore.cs
new file mode 100644
index 0000000..e39b145
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Data/EfUnilateralExitRecordStore.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace BTCPayServer.Plugins.Flint.Data;
+
+///
+/// over the plugin's own Postgres schema.
+///
+///
+/// As in , nothing here may open an explicit transaction: the shared context
+/// factory enables retry-on-failure, and EF's retrying execution strategy refuses user-initiated transactions.
+/// Atomicity comes from single conditional statements and from one unique index.
+///
+public class EfUnilateralExitRecordStore : IUnilateralExitRecordStore
+{
+ ///
+ /// Postgres collation used for the id tie-break.
+ ///
+ ///
+ /// Named explicitly for the same reason as 's: ordering has to be byte order
+ /// so that this implementation and any in-memory one agree on hyphenated UUIDs, which an ICU default
+ /// collation would not. "C" is always present in Postgres.
+ ///
+ private const string ByteOrderCollation = "C";
+
+ ///
+ /// SQLSTATE for unique_violation.
+ ///
+ ///
+ /// Matched on the code and then on the constraint name, not on the message: the message is localised by the
+ /// server's lc_messages and would make this behave differently on a non-English database.
+ ///
+ private const string UniqueViolation = "23505";
+
+ private readonly SparkPluginDbContextFactory _contextFactory;
+
+ public EfUnilateralExitRecordStore(SparkPluginDbContextFactory contextFactory)
+ {
+ _contextFactory = contextFactory;
+ }
+
+ public async Task CreateAsync(
+ UnilateralExitRecord record,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentException.ThrowIfNullOrEmpty(record.Id);
+ ArgumentException.ThrowIfNullOrEmpty(record.StoreId);
+
+ await using var context = _contextFactory.CreateContext();
+ context.UnilateralExitRecords.Add(record);
+
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken);
+ return true;
+ }
+ catch (DbUpdateException ex) when (IsActiveExitCollision(ex))
+ {
+ // The store already has an exit awaiting funding or built. An ordinary race rather than a fault —
+ // see the interface — so it comes back as a refusal the service can word for a merchant. Note that
+ // this deliberately does not catch a primary-key collision: a reused id is a programming error.
+ return false;
+ }
+ }
+
+ public async Task UpdateAsync(
+ UnilateralExitRecord record,
+ UnilateralExitStatus expectedStatus,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentException.ThrowIfNullOrEmpty(record.Id);
+ ArgumentException.ThrowIfNullOrEmpty(record.StoreId);
+
+ // Read out of the entity before the query, so the expression tree closes over values rather than over a
+ // tracked instance the provider would then try to translate.
+ var id = record.Id;
+ var storeId = record.StoreId;
+ var from = expectedStatus;
+ var status = record.Status;
+ var updatedUtc = record.UpdatedUtc;
+ var recoverable = record.RecoverableValueSat;
+ var totalFee = record.TotalFeeSat;
+ var funding = record.SingleUtxoFundingSat;
+ var fundingUtxosJson = record.FundingUtxosJson;
+ var transactionsJson = record.TransactionsJson;
+ var lastError = record.LastError;
+
+ await using var context = _contextFactory.CreateContext();
+
+ // One conditional UPDATE, store-scoped and guarded on the status the caller read, touching only the
+ // mutable half of the row: the identity columns are what the operator approved and funded against, and
+ // the signed transactions are only meaningful relative to them, so they are not in the setter list.
+ var updated = await context.UnilateralExitRecords
+ .Where(r => r.Id == id && r.StoreId == storeId && r.Status == from)
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(r => r.Status, status)
+ .SetProperty(r => r.UpdatedUtc, updatedUtc)
+ // Assigned rather than coalesced: a build re-quotes with the pinned leaf set, and the second
+ // quote's figures are the ones the operator is funding against from then on.
+ .SetProperty(r => r.RecoverableValueSat, recoverable)
+ .SetProperty(r => r.TotalFeeSat, totalFee)
+ .SetProperty(r => r.SingleUtxoFundingSat, funding)
+ // Coalesced, not assigned. These two are the exit itself — the signed transactions and the
+ // outpoint they spend — and every caller that writes a status or an error is entitled to
+ // know nothing about them, including a history row that was projected without them.
+ .SetProperty(r => r.FundingUtxosJson, r => fundingUtxosJson ?? r.FundingUtxosJson)
+ .SetProperty(r => r.TransactionsJson, r => transactionsJson ?? r.TransactionsJson)
+ // An assignment, so a build that gets further clears the previous attempt's complaint
+ // instead of leaving it on the page next to a successful result.
+ .SetProperty(r => r.LastError, lastError),
+ cancellationToken);
+
+ return updated == 1;
+ }
+
+ public async Task GetAsync(
+ string storeId,
+ string id,
+ CancellationToken cancellationToken = default)
+ {
+ await using var context = _contextFactory.CreateContext();
+ return await context.UnilateralExitRecords
+ .AsNoTracking()
+ .FirstOrDefaultAsync(r => r.Id == id && r.StoreId == storeId, cancellationToken);
+ }
+
+ public async Task GetActiveForStoreAsync(
+ string storeId,
+ CancellationToken cancellationToken = default)
+ {
+ await using var context = _contextFactory.CreateContext();
+ return await context.UnilateralExitRecords
+ .AsNoTracking()
+ // The two non-terminal statuses, spelled out because EF cannot translate
+ // UnilateralExitRecord.IsActive. Adding a status means changing this, the index filter in
+ // SparkPluginDbContext, and the property.
+ .Where(r => r.StoreId == storeId
+ && (r.Status == UnilateralExitStatus.AwaitingFunding
+ || r.Status == UnilateralExitStatus.Built))
+ .OrderByDescending(r => r.CreatedUtc)
+ .ThenByDescending(r => EF.Functions.Collate(r.Id, ByteOrderCollation))
+ .FirstOrDefaultAsync(cancellationToken);
+ }
+
+ public async Task> ListTerminalForStoreAsync(
+ string storeId,
+ int limit,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
+
+ await using var context = _contextFactory.CreateContext();
+
+ // Projected into an anonymous type first and assembled below, rather than selected into the entity: EF
+ // will not construct a mapped entity inside a query, and the point of the projection is to keep the three
+ // JSON columns out of the SELECT list. See the interface for why that matters on this table.
+ var rows = await context.UnilateralExitRecords
+ .AsNoTracking()
+ .Where(r => r.StoreId == storeId
+ && (r.Status == UnilateralExitStatus.Completed
+ || r.Status == UnilateralExitStatus.Abandoned))
+ // The id breaks ties: two exits created in the same tick would otherwise be free to swap places
+ // between reads, so one could appear twice and another never.
+ .OrderByDescending(r => r.CreatedUtc)
+ .ThenByDescending(r => EF.Functions.Collate(r.Id, ByteOrderCollation))
+ .Take(limit)
+ .Select(r => new
+ {
+ r.Id,
+ r.StoreId,
+ r.Status,
+ r.CreatedUtc,
+ r.UpdatedUtc,
+ r.DestinationAddress,
+ r.FeeRateSatPerVbyte,
+ r.RecoverableValueSat,
+ r.TotalFeeSat,
+ r.SingleUtxoFundingSat,
+ r.FundingAddress,
+ r.FundingKeyIndex,
+ r.LastError
+ })
+ .ToListAsync(cancellationToken);
+
+ return rows
+ .Select(row => new UnilateralExitRecord
+ {
+ Id = row.Id,
+ StoreId = row.StoreId,
+ Status = row.Status,
+ CreatedUtc = row.CreatedUtc,
+ UpdatedUtc = row.UpdatedUtc,
+ DestinationAddress = row.DestinationAddress,
+ FeeRateSatPerVbyte = row.FeeRateSatPerVbyte,
+ // Not loaded, and empty rather than null so a caller reading it gets a well-formed "no ids"
+ // instead of a NullReferenceException far from here.
+ LeafIdsJson = string.Empty,
+ RecoverableValueSat = row.RecoverableValueSat,
+ TotalFeeSat = row.TotalFeeSat,
+ SingleUtxoFundingSat = row.SingleUtxoFundingSat,
+ FundingAddress = row.FundingAddress,
+ FundingKeyIndex = row.FundingKeyIndex,
+ LastError = row.LastError
+ })
+ .ToList();
+ }
+
+ public async Task NextFundingKeyIndexAsync(
+ string storeId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ await using var context = _contextFactory.CreateContext();
+
+ // MAX over a nullable projection, so an empty set comes back as null rather than throwing — EF's
+ // MaxAsync over a non-nullable long has no answer for "no rows".
+ var highest = await context.UnilateralExitRecords
+ .Where(r => r.StoreId == storeId)
+ .Select(r => (long?)r.FundingKeyIndex)
+ .MaxAsync(cancellationToken);
+
+ return (highest ?? -1) + 1;
+ }
+
+ ///
+ /// Whether a save failed on the "one active exit per store" index rather than on anything else.
+ ///
+ ///
+ /// Matched by constraint name, because the primary key raises the same SQLSTATE and means something entirely
+ /// different — a reused id, which must not be reported to a merchant as "you already have an exit running".
+ ///
+ private static bool IsActiveExitCollision(DbUpdateException exception) =>
+ exception.InnerException is PostgresException
+ {
+ SqlState: UniqueViolation,
+ ConstraintName: SparkPluginDbContext.ActiveUnilateralExitIndexName
+ };
+}
diff --git a/BTCPayServer.Plugins.Flint/Data/IUnilateralExitRecordStore.cs b/BTCPayServer.Plugins.Flint/Data/IUnilateralExitRecordStore.cs
new file mode 100644
index 0000000..640e35d
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Data/IUnilateralExitRecordStore.cs
@@ -0,0 +1,134 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BTCPayServer.Plugins.Flint.Data;
+
+///
+/// Durable storage for s.
+///
+///
+/// An interface rather than a direct DbContext dependency for the same reason as the sweep store's: the
+/// exit service decides whether real money is recoverable and has to be unit-testable without a Postgres server.
+/// The production implementation is .
+///
+public interface IUnilateralExitRecordStore
+{
+ ///
+ /// Inserts a freshly quoted exit, unless the store already has an active one.
+ ///
+ ///
+ ///
+ /// Called after the quote and before the operator is shown a funding address, so a failure here means they
+ /// are never told to send sats towards an exit that was not recorded — the correct failure direction, because
+ /// sats on an unrecorded funding address are only recoverable by re-deriving the key by hand.
+ ///
+ ///
+ /// "One active exit per store" is a database guarantee, not a convention. The service checks for an
+ /// active row before quoting, but that check and this insert are two statements: a second server, or a second
+ /// request that slipped past the in-process gate, could pass the check and then insert. The unique index over
+ /// the store's active exits closes that window, and this method reports the collision as a refusal rather
+ /// than letting a provider exception reach the service — which would otherwise turn a perfectly ordinary race
+ /// into "the quote could not be recorded".
+ ///
+ ///
+ /// A duplicate id still throws. That is a programming error rather than a race, and swallowing it would let a
+ /// caller reusing an id believe its record was stored.
+ ///
+ ///
+ ///
+ /// True when the row was inserted; false when the store already has an exit awaiting funding or built.
+ ///
+ Task CreateAsync(UnilateralExitRecord record, CancellationToken cancellationToken = default);
+
+ ///
+ /// Writes back the mutable half of a row, but only while it is still in the status the caller read.
+ ///
+ ///
+ ///
+ /// Store-scoped, guarded on the id and on , so this is a
+ /// compare-and-set rather than a read-modify-write — the same discipline
+ /// applies to a sweep. The status the caller read is the
+ /// status its whole decision was made against: an abandon that started from a row awaiting funding must not
+ /// land on the same row after a build has filled it with signed transactions, and a build that started from
+ /// an awaiting row must not land after the operator abandoned it.
+ ///
+ ///
+ /// The identity of an exit is not writable. Its store, destination, fee rate, creation time, funding
+ /// address, funding key index and leaf set are fixed at quote time and this method leaves them alone even if
+ /// the passed record disagrees — those are the values the operator approved and funded against, and the
+ /// signed transactions are only meaningful relative to them.
+ ///
+ ///
+ /// The two JSON blobs are coalesced rather than assigned: a null means "nothing new to say" and never "clear
+ /// it". Those columns hold the exit's only copy of its signed transactions and the outpoint they spend, and
+ /// the paths that write a status or an error — abandoning, recording a failure, a history row projected
+ /// without its blobs — have no business erasing them.
+ /// is the one exception and is assigned, because a build that
+ /// gets further must be able to clear the previous attempt's complaint.
+ ///
+ ///
+ /// The status the caller read, and the only one this update may overwrite.
+ ///
+ /// True when a row was updated; false when the store has no such exit or it has since moved out of
+ /// .
+ ///
+ Task UpdateAsync(
+ UnilateralExitRecord record,
+ UnilateralExitStatus expectedStatus,
+ CancellationToken cancellationToken = default);
+
+ /// One exit, whole, scoped to a store so one store cannot read another's.
+ Task GetAsync(
+ string storeId,
+ string id,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// The store's exit that has not reached a terminal state, or null when there is none.
+ ///
+ ///
+ /// This is the single-flight guard the service reads before quoting: two exits would compete for the same
+ /// leaves, so the second would build a tree over statechain nodes the first has already committed to signed
+ /// transactions — and neither operator would know which set to broadcast. There can be at most one such row
+ /// (see ); the query is still ordered newest-first so that a database somehow holding
+ /// two — restored from a backup taken before the index existed, say — describes the one the operator is
+ /// looking at rather than an arbitrary one.
+ ///
+ Task GetActiveForStoreAsync(
+ string storeId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Newest-first page of a store's finished exits, for the history list on the exit page.
+ ///
+ ///
+ ///
+ /// Terminal statuses only — completed and abandoned. The active exit has its own panel on the page, and
+ /// listing it twice invites an operator to read the history row's status as a second exit.
+ ///
+ ///
+ /// The JSON columns are deliberately not loaded. The history table renders scalars; a store with
+ /// twenty past exits would otherwise pull twenty signed transaction sets — the largest text in this schema —
+ /// out of the database to render a date and a status. The returned rows therefore carry an empty
+ /// and null blobs, which is what
+ /// 's coalescing makes harmless.
+ ///
+ ///
+ /// Maximum rows to return. Must be positive.
+ Task> ListTerminalForStoreAsync(
+ string storeId,
+ int limit,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// The funding-key index a new exit for this store should use: one past the highest ever issued.
+ ///
+ ///
+ /// Computed over every row of the store, terminal ones included, so an index is never reused. Reusing
+ /// one would re-issue a funding address that may still hold sats from an abandoned exit, and the next build
+ /// would then select a stale output as if the operator had just sent it — see
+ /// . Zero for a store with no exits yet.
+ ///
+ Task NextFundingKeyIndexAsync(string storeId, CancellationToken cancellationToken = default);
+}
diff --git a/BTCPayServer.Plugins.Flint/Data/SparkPluginDbContext.cs b/BTCPayServer.Plugins.Flint/Data/SparkPluginDbContext.cs
index 0169b4b..2bafc7d 100644
--- a/BTCPayServer.Plugins.Flint/Data/SparkPluginDbContext.cs
+++ b/BTCPayServer.Plugins.Flint/Data/SparkPluginDbContext.cs
@@ -13,6 +13,18 @@ public class SparkPluginDbContext : DbContext
public DbSet InvoiceRecords { get; set; } = null!;
public DbSet OutgoingPayments { get; set; } = null!;
public DbSet SweepRecords { get; set; } = null!;
+ public DbSet UnilateralExitRecords { get; set; } = null!;
+
+ ///
+ /// Name of the partial unique index that enforces one active unilateral exit per store.
+ ///
+ ///
+ /// Named explicitly rather than left to EF's convention because
+ /// matches Postgres's unique-violation by constraint name: the
+ /// primary key raises the same SQLSTATE and means something entirely different. Renaming this index without
+ /// renaming it there turns an ordinary race into an unhandled exception on a money-moving page.
+ ///
+ public const string ActiveUnilateralExitIndexName = "UX_UnilateralExitRecords_ActiveStore";
public SparkPluginDbContext(DbContextOptions options) : base(options)
{
@@ -57,6 +69,33 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
// Every pass of the sweep engine opens by looking for this store's in-flight rows.
entity.HasIndex(record => new { record.StoreId, record.Status });
});
+
+ modelBuilder.Entity(entity =>
+ {
+ // The plugin-generated UUID is the primary key. Unlike the sweep table's, it is not an SDK
+ // idempotency key and guarantees nothing beyond uniqueness — a unilateral exit has no SDK-side
+ // identity to be idempotent on, because the SDK never broadcasts it.
+ entity.HasKey(record => record.Id);
+ // The exit page reads the store's history newest-first. Store-leading, so it also serves the plain
+ // "this store's exits" scan — including the MAX(FundingKeyIndex) a new quote allocates from —
+ // without a second single-column index.
+ entity.HasIndex(record => new { record.StoreId, record.CreatedUtc });
+ // Every entry to the page opens by looking for the store's one active exit, which is the
+ // single-flight guard on quoting.
+ entity.HasIndex(record => new { record.StoreId, record.Status });
+ // And that guard is enforced here rather than only in the service. The service's "does this store
+ // already have an active exit?" read and the insert that follows it are two statements, so a second
+ // server — or a request that slipped past the in-process gate — could pass the check and still
+ // insert. Two active exits would compete for the same leaves, so the database refuses the second.
+ //
+ // The filter names the two non-terminal statuses by their persisted numbers (AwaitingFunding = 0,
+ // Built = 1) because it is raw SQL and cannot see the enum. Adding a status means changing this, the
+ // store's queries and UnilateralExitRecord.IsActive together.
+ entity.HasIndex(record => record.StoreId)
+ .HasDatabaseName(ActiveUnilateralExitIndexName)
+ .IsUnique()
+ .HasFilter("\"Status\" IN (0, 1)");
+ });
}
}
diff --git a/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs b/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs
new file mode 100644
index 0000000..87d8e5b
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs
@@ -0,0 +1,244 @@
+using System;
+
+namespace BTCPayServer.Plugins.Flint.Data;
+
+///
+/// Durable record of one unilateral-exit attempt: the quote it was built from, the funding UTXOs it consumed,
+/// and the signed transactions the operator still has to broadcast by hand.
+///
+///
+///
+/// This row is not a log. It is the only copy of the exit. A cooperative exit (see
+/// ) is resolvable after a crash because the SDK holds it: the idempotency key becomes
+/// a Payment.id and GetPayment answers definitively. A unilateral exit has no such backstop — the
+/// SDK builds and signs the tree, hands the transactions back, and never broadcasts. Until every one of
+/// them is confirmed, the signed hex in is the merchant's claim on their own
+/// money, and losing it means re-quoting and re-funding from scratch.
+///
+///
+/// The row is written at quote time, before any funding exists, because the funding step is the part that takes
+/// human time. An exit is quoted, then the operator sends sats to — possibly hours
+/// later, possibly after a restart — and only then is it built. The leaf set is pinned across that gap by
+/// : the build re-quotes with ExitLeafSelection.Specific naming exactly the
+/// leaves the operator was shown a price for, so the second quote cannot silently become a different exit than
+/// the one they funded.
+///
+///
+/// The three JSON columns are plain text holding the seam DTOs (SparkExitFundingUtxo[],
+/// SparkExitTransaction[]) and a bare string[] of leaf ids. Serialisation is deliberately the
+/// caller's job rather than this entity's: the data layer stays free of the seam types, so nothing here has to
+/// change when the SDK's exit shapes move under the next version bump. Exactly one caller does it — the exit
+/// service — so the write format has a single owner and no other layer reads the blobs.
+///
+///
+/// An instance may be a partial row.
+/// projects the history list without the three JSON columns, because a five-column table has no business
+/// dragging every signed transaction set in a store's past out of the database. Such an instance carries an
+/// empty and null blobs, which is safe to write back only because the store's update
+/// coalesces those two blobs rather than assigning them — see
+/// .
+///
+///
+public class UnilateralExitRecord
+{
+ /// Plugin-generated UUID, and this row's primary key.
+ ///
+ /// Plugin-generated rather than taken from the SDK because the row exists before the SDK has been asked to
+ /// build anything, and nothing in the exit flow is idempotent on an SDK-side identifier.
+ ///
+ public string Id { get; set; } = null!;
+
+ /// Store this exit belongs to. Indexed, and part of every read, so one store cannot see another's.
+ public string StoreId { get; set; } = null!;
+
+ /// How far this exit has got.
+ public UnilateralExitStatus Status { get; set; } = UnilateralExitStatus.AwaitingFunding;
+
+ /// When the exit was quoted.
+ public DateTimeOffset CreatedUtc { get; set; }
+
+ ///
+ /// When the row last changed — funding discovered, transactions built, exit abandoned.
+ ///
+ ///
+ /// Kept separate from because the gap between them is the operator's own waiting
+ /// time, and an exit stuck in for a week is a different
+ /// situation from one quoted a minute ago. Stamped by the caller, which owns the clock.
+ ///
+ public DateTimeOffset UpdatedUtc { get; set; }
+
+ /// On-chain address the exited funds are swept to.
+ ///
+ /// Recorded rather than re-resolved at build time: the destination is baked into the signed transactions, so
+ /// it must be the address the operator was shown when they approved the exit, not whatever the settings say
+ /// by the time the funding lands.
+ ///
+ public string DestinationAddress { get; set; } = null!;
+
+ /// Fee rate the tree was quoted at, in sat/vB.
+ ///
+ /// A long rather than the seam's ulong, because Npgsql has no unsigned integer types and a
+ /// negative rate is refused by the service's guard long before it reaches here.
+ ///
+ public long FeeRateSatPerVbyte { get; set; }
+
+ ///
+ /// The leaf ids from the first quote, as a JSON string[].
+ ///
+ ///
+ /// The reason this row is durable at all. The first quote runs with ExitLeafSelection.Auto, and
+ /// Auto is free to pick a different set on the next call — the wallet's leaves move under the SDK's
+ /// background optimisation. Replaying Auto at build time would therefore price and sign an exit of a
+ /// different set of leaves than the one whose funding requirement the operator satisfied. The build resumes
+ /// with Specific naming these ids instead.
+ ///
+ public string LeafIdsJson { get; set; } = null!;
+
+ /// What the quote said would come back to the destination, in satoshi.
+ public long RecoverableValueSat { get; set; }
+
+ /// Total fee the quote attributed to the whole tree, in satoshi.
+ ///
+ /// Held next to because the guard that matters is the comparison between
+ /// them: an exit that costs more than it recovers is refused, at quote time and again inside the build's
+ /// approval callback, since the second quote can come back worse than the first.
+ ///
+ public long TotalFeeSat { get; set; }
+
+ ///
+ /// Sats the operator must put on in a single UTXO.
+ ///
+ ///
+ /// Single is the SDK's requirement, not a simplification: CPFP funding spends one P2WPKH outpoint per
+ /// package, so two UTXOs adding up to this figure do not fund the exit. The funding instructions shown to the
+ /// operator have to say so, which is why the figure is stored per-row rather than recomputed.
+ ///
+ public long SingleUtxoFundingSat { get; set; }
+
+ ///
+ /// P2WPKH address whose UTXOs pay the CPFP fees, derived from the store's Spark seed at the plugin's own
+ /// hardened account and this row's .
+ ///
+ ///
+ /// Stored rather than re-derived on every page load so the address the operator sent to is provably the one
+ /// the build will spend from, even if the derivation path or the seed source changes later. It is a
+ /// deliberately non-standard account so it can never collide with BTCPay's own BIP84 hot wallet on a shared
+ /// seed — see Constants.UnilateralExitFundingAccount.
+ ///
+ public string FundingAddress { get; set; } = null!;
+
+ ///
+ /// Address index of this exit's funding key inside the plugin's hardened account:
+ /// m/84'/{coin}'/4607060'/0/{index}.
+ ///
+ ///
+ ///
+ /// One address per exit, not one per store. A fixed index would hand every exit a store ever quotes
+ /// the same funding address, and that is a trap rather than a convenience: sats left behind by an abandoned
+ /// exit sit on the address the next exit tells the operator to fund, so the next build would select
+ /// a leftover output — which may be the wrong size, and is in any case money the operator did not mean to
+ /// commit. Worse, an old output large enough to satisfy a new requirement makes a build succeed against
+ /// funding nobody just sent, which reads as the plugin spending stale coins on its own initiative.
+ ///
+ ///
+ /// Identity, not state: set once at create time and never rewritten, because the address the operator funded
+ /// is derived from it. Allocated as the store's highest existing index plus one — over every row including
+ /// terminal ones, so an index is never reused even after an exit is abandoned. Two concurrent allocations
+ /// could pick the same number; only one of them can insert, because
+ /// is guarded by a unique index over the store's active
+ /// exits.
+ ///
+ ///
+ /// A long rather than a uint because Npgsql has no unsigned integer types. BIP32 non-hardened
+ /// indexes stop at , and the service refuses a row outside that range rather than
+ /// wrapping it into a different key.
+ ///
+ ///
+ public long FundingKeyIndex { get; set; }
+
+ ///
+ /// The funding UTXOs actually spent at build time, as a JSON SparkExitFundingUtxo[]. Null until the
+ /// build runs.
+ ///
+ ///
+ /// Recorded because the SDK reports FundingUtxoConflict by outpoint, and a merchant reading that error
+ /// needs to be able to see which outpoint this exit already committed to. Never cleared once written: the
+ /// signed transactions in spend exactly this outpoint, so losing it would
+ /// leave a set of transactions whose input nobody can identify. The store's update coalesces it for that
+ /// reason.
+ ///
+ public string? FundingUtxosJson { get; set; }
+
+ ///
+ /// The signed transactions from the build, as a JSON SparkExitTransaction[]. Null until the build runs.
+ ///
+ ///
+ /// The valuable column. Nothing broadcasts these — not the plugin, not the SDK — so this text is the
+ /// exit until the operator has pushed every package through submitpackage and the CSV timelocks have
+ /// matured. Kept as the SDK returned it, including the CPFP child hex and the dependsOn ordering,
+ /// because a package broadcast out of order is rejected and there is no second copy to re-derive it from.
+ /// That is also why the store's update coalesces this column instead of assigning it: abandoning an exit,
+ /// or recording why an attempt on it failed, must not be able to write a null over the only copy.
+ ///
+ public string? TransactionsJson { get; set; }
+
+ ///
+ /// Why the last attempt on this row failed, in words fit for a merchant. Never contains secrets.
+ ///
+ ///
+ /// Set on a failed build and left in place, so an exit that is still
+ /// carries the explanation of why it is not yet — underfunded,
+ /// conflicting outpoint, operators unreachable. Cleared by a build that gets further.
+ ///
+ public string? LastError { get; set; }
+
+ ///
+ /// True while this exit still occupies the store — it is either waiting for funding or holding signed
+ /// transactions nobody has finished broadcasting.
+ ///
+ ///
+ /// This is what makes an exit single-flight per store, and it is enforced in the database rather than only in
+ /// the service: a unique index over filtered to these two statuses means a second
+ /// active row cannot be inserted even by a second server. The store's own queries repeat the status list
+ /// rather than calling this, because EF cannot translate a computed property into SQL — if a status is ever
+ /// added, the queries, the index filter and this property all have to be updated together.
+ ///
+ public bool IsActive =>
+ Status is UnilateralExitStatus.AwaitingFunding or UnilateralExitStatus.Built;
+}
+
+///
+/// How far a unilateral exit has got.
+///
+///
+/// Values are persisted, so existing members must never be renumbered; new ones may only be appended. Note that
+/// the two non-terminal states are both "active" for the purposes of
+/// — see .
+///
+public enum UnilateralExitStatus
+{
+ ///
+ /// Quoted, and waiting for the operator to put sats
+ /// on in one UTXO. Nothing has been signed.
+ ///
+ AwaitingFunding = 0,
+
+ ///
+ /// Built and signed. holds transactions that
+ /// nothing has broadcast; the operator does that by hand, in dependsOn order, and the exit is
+ /// not finished until they have.
+ ///
+ Built = 1,
+
+ ///
+ /// The operator has confirmed they are done with this exit. Terminal, and recorded on their word rather than
+ /// observed on-chain: Phase 0 watches no chain, so nothing here can verify a broadcast.
+ ///
+ Completed = 2,
+
+ ///
+ /// Abandoned by the operator. Terminal, and it frees the store for a fresh quote — which is the only reason
+ /// it exists, since an exit with no path forward would otherwise block every later attempt.
+ ///
+ Abandoned = 3
+}
diff --git a/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.Designer.cs b/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.Designer.cs
new file mode 100644
index 0000000..fe743ff
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.Designer.cs
@@ -0,0 +1,292 @@
+//
+using System;
+using BTCPayServer.Plugins.Flint.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BTCPayServer.Plugins.Flint.Migrations
+{
+ [DbContext(typeof(SparkPluginDbContext))]
+ [Migration("20260820175701_UnilateralExitRecords")]
+ partial class UnilateralExitRecords
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("BTCPayServer.Plugins.Flint")
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BTCPayServer.Plugins.Flint.Data.InvoiceRecord", b =>
+ {
+ b.Property("PaymentHash")
+ .HasColumnType("text");
+
+ b.Property("AmountMsat")
+ .HasColumnType("bigint");
+
+ b.Property("AmountReceivedMsat")
+ .HasColumnType("bigint");
+
+ b.Property("Bolt11")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Preimage")
+ .HasColumnType("text");
+
+ b.Property("SdkPaymentId")
+ .HasColumnType("text");
+
+ b.Property("SettledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StoreId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("PaymentHash");
+
+ b.HasIndex("StoreId", "CreatedAt");
+
+ b.HasIndex("StoreId", "Status");
+
+ b.ToTable("InvoiceRecords", "BTCPayServer.Plugins.Flint");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Plugins.Flint.Data.OutgoingPaymentRecord", b =>
+ {
+ b.Property("StoreId")
+ .HasColumnType("text");
+
+ b.Property("PaymentHash")
+ .HasColumnType("text");
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer");
+
+ b.Property("Bolt11")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FirstAttemptAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IdempotencyKey")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ReportedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("StoreId", "PaymentHash");
+
+ b.HasIndex("StoreId", "FirstAttemptAt");
+
+ b.ToTable("OutgoingPayments", "BTCPayServer.Plugins.Flint");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Plugins.Flint.Data.SweepRecord", b =>
+ {
+ b.Property("IdempotencyKey")
+ .HasColumnType("text");
+
+ b.Property("AmountSats")
+ .HasColumnType("bigint");
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer");
+
+ b.Property("BalanceAtDecisionSats")
+ .HasColumnType("bigint");
+
+ b.Property("CompletedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConfirmationSpeed")
+ .HasColumnType("integer");
+
+ b.Property("ConversionStatus")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeliveredAmountBaseUnits")
+ .HasColumnType("text");
+
+ b.Property("DestinationAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DestinationAsset")
+ .HasColumnType("text");
+
+ b.Property("DestinationAssetDecimals")
+ .HasColumnType("integer");
+
+ b.Property("DestinationChain")
+ .HasColumnType("text");
+
+ b.Property("DestinationKind")
+ .HasColumnType("integer");
+
+ b.Property("DestinationMode")
+ .HasColumnType("integer");
+
+ b.Property("Error")
+ .HasColumnType("text");
+
+ b.Property("EstimatedOutBaseUnits")
+ .HasColumnType("text");
+
+ b.Property("FeeSats")
+ .HasColumnType("bigint");
+
+ b.Property("FeesIncluded")
+ .HasColumnType("boolean");
+
+ b.Property("IdempotencyKeyAccepted")
+ .HasColumnType("boolean");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Provider")
+ .HasColumnType("integer");
+
+ b.Property("ProviderOrderId")
+ .HasColumnType("text");
+
+ b.Property("ProviderQuoteId")
+ .HasColumnType("text");
+
+ b.Property("QuotedFeeSats")
+ .HasColumnType("bigint");
+
+ b.Property("RefusalCode")
+ .HasColumnType("integer");
+
+ b.Property("SourceAmountBaseUnits")
+ .HasColumnType("text");
+
+ b.Property("SourceTokenDecimals")
+ .HasColumnType("integer");
+
+ b.Property("SourceTokenIdentifier")
+ .HasColumnType("text");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StoreId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Trigger")
+ .HasColumnType("integer");
+
+ b.Property("TxId")
+ .HasColumnType("text");
+
+ b.HasKey("IdempotencyKey");
+
+ b.HasIndex("StoreId", "CreatedAt");
+
+ b.HasIndex("StoreId", "Status");
+
+ b.ToTable("SweepRecords", "BTCPayServer.Plugins.Flint");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Plugins.Flint.Data.UnilateralExitRecord", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DestinationAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FeeRateSatPerVbyte")
+ .HasColumnType("bigint");
+
+ b.Property("FundingAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FundingKeyIndex")
+ .HasColumnType("bigint");
+
+ b.Property("FundingUtxosJson")
+ .HasColumnType("text");
+
+ b.Property("LastError")
+ .HasColumnType("text");
+
+ b.Property("LeafIdsJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RecoverableValueSat")
+ .HasColumnType("bigint");
+
+ b.Property("SingleUtxoFundingSat")
+ .HasColumnType("bigint");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StoreId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalFeeSat")
+ .HasColumnType("bigint");
+
+ b.Property("TransactionsJson")
+ .HasColumnType("text");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoreId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnilateralExitRecords_ActiveStore")
+ .HasFilter("\"Status\" IN (0, 1)");
+
+ b.HasIndex("StoreId", "CreatedUtc");
+
+ b.HasIndex("StoreId", "Status");
+
+ b.ToTable("UnilateralExitRecords", "BTCPayServer.Plugins.Flint");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.cs b/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.cs
new file mode 100644
index 0000000..48e5911
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Migrations/20260820175701_UnilateralExitRecords.cs
@@ -0,0 +1,70 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BTCPayServer.Plugins.Flint.Migrations
+{
+ ///
+ public partial class UnilateralExitRecords : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "UnilateralExitRecords",
+ schema: "BTCPayServer.Plugins.Flint",
+ columns: table => new
+ {
+ Id = table.Column(type: "text", nullable: false),
+ StoreId = table.Column(type: "text", nullable: false),
+ Status = table.Column(type: "integer", nullable: false),
+ CreatedUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ UpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ DestinationAddress = table.Column(type: "text", nullable: false),
+ FeeRateSatPerVbyte = table.Column(type: "bigint", nullable: false),
+ LeafIdsJson = table.Column(type: "text", nullable: false),
+ RecoverableValueSat = table.Column(type: "bigint", nullable: false),
+ TotalFeeSat = table.Column(type: "bigint", nullable: false),
+ SingleUtxoFundingSat = table.Column(type: "bigint", nullable: false),
+ FundingAddress = table.Column(type: "text", nullable: false),
+ FundingKeyIndex = table.Column(type: "bigint", nullable: false),
+ FundingUtxosJson = table.Column(type: "text", nullable: true),
+ TransactionsJson = table.Column(type: "text", nullable: true),
+ LastError = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_UnilateralExitRecords", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UnilateralExitRecords_StoreId_CreatedUtc",
+ schema: "BTCPayServer.Plugins.Flint",
+ table: "UnilateralExitRecords",
+ columns: new[] { "StoreId", "CreatedUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_UnilateralExitRecords_StoreId_Status",
+ schema: "BTCPayServer.Plugins.Flint",
+ table: "UnilateralExitRecords",
+ columns: new[] { "StoreId", "Status" });
+
+ migrationBuilder.CreateIndex(
+ name: "UX_UnilateralExitRecords_ActiveStore",
+ schema: "BTCPayServer.Plugins.Flint",
+ table: "UnilateralExitRecords",
+ column: "StoreId",
+ unique: true,
+ filter: "\"Status\" IN (0, 1)");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "UnilateralExitRecords",
+ schema: "BTCPayServer.Plugins.Flint");
+ }
+ }
+}
diff --git a/BTCPayServer.Plugins.Flint/Migrations/SparkPluginDbContextModelSnapshot.cs b/BTCPayServer.Plugins.Flint/Migrations/SparkPluginDbContextModelSnapshot.cs
index 9cf13e1..c142dc0 100644
--- a/BTCPayServer.Plugins.Flint/Migrations/SparkPluginDbContextModelSnapshot.cs
+++ b/BTCPayServer.Plugins.Flint/Migrations/SparkPluginDbContextModelSnapshot.cs
@@ -215,6 +215,74 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("SweepRecords", "BTCPayServer.Plugins.Flint");
});
+
+ modelBuilder.Entity("BTCPayServer.Plugins.Flint.Data.UnilateralExitRecord", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DestinationAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FeeRateSatPerVbyte")
+ .HasColumnType("bigint");
+
+ b.Property("FundingAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("FundingKeyIndex")
+ .HasColumnType("bigint");
+
+ b.Property("FundingUtxosJson")
+ .HasColumnType("text");
+
+ b.Property("LastError")
+ .HasColumnType("text");
+
+ b.Property("LeafIdsJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RecoverableValueSat")
+ .HasColumnType("bigint");
+
+ b.Property("SingleUtxoFundingSat")
+ .HasColumnType("bigint");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("StoreId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TotalFeeSat")
+ .HasColumnType("bigint");
+
+ b.Property("TransactionsJson")
+ .HasColumnType("text");
+
+ b.Property("UpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoreId")
+ .IsUnique()
+ .HasDatabaseName("UX_UnilateralExitRecords_ActiveStore")
+ .HasFilter("\"Status\" IN (0, 1)");
+
+ b.HasIndex("StoreId", "CreatedUtc");
+
+ b.HasIndex("StoreId", "Status");
+
+ b.ToTable("UnilateralExitRecords", "BTCPayServer.Plugins.Flint");
+ });
#pragma warning restore 612, 618
}
}
From 4d8004d188ec89cf7f7d66765aa1d8cf0ca79c21 Mon Sep 17 00:00:00 2001
From: sethforprivacy <40500387+sethforprivacy@users.noreply.github.com>
Date: Thu, 20 Aug 2026 14:34:23 -0400
Subject: [PATCH 4/6] Add the unilateral exit service: quoting, funding
discovery, and signing
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.
---
.../InMemoryUnilateralExitRecordStore.cs | 215 ++
.../SparkPluginStartupTests.cs | 7 +-
.../SparkStoreProvisionerTests.cs | 24 +-
.../SparkUnilateralExitServiceTests.cs | 1814 +++++++++++++++++
.../Services/ISparkUnilateralExitService.cs | 147 ++
.../Services/SparkExitFundingExplorer.cs | 439 ++++
.../Services/SparkExitFundingKey.cs | 195 ++
.../Services/SparkStoreProvisioner.cs | 15 +-
.../Services/SparkUnilateralExitService.cs | 1311 ++++++++++++
BTCPayServer.Plugins.Flint/SparkPlugin.cs | 33 +
10 files changed, 4194 insertions(+), 6 deletions(-)
create mode 100644 BTCPayServer.Plugins.Flint.Tests/Fakes/InMemoryUnilateralExitRecordStore.cs
create mode 100644 BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs
create mode 100644 BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs
create mode 100644 BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs
create mode 100644 BTCPayServer.Plugins.Flint/Services/SparkExitFundingKey.cs
create mode 100644 BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs
diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/InMemoryUnilateralExitRecordStore.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/InMemoryUnilateralExitRecordStore.cs
new file mode 100644
index 0000000..f1d0365
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/InMemoryUnilateralExitRecordStore.cs
@@ -0,0 +1,215 @@
+using BTCPayServer.Plugins.Flint.Data;
+
+namespace BTCPayServer.Plugins.Flint.Tests.Fakes;
+
+///
+/// In-memory with the same observable semantics as the EF one.
+///
+///
+///
+/// Held to UnilateralExitRecordStoreContractTests alongside the production store, because the exit
+/// service's tests run against this and mean nothing if the two disagree. Three divergences would matter most,
+/// and each is reproduced deliberately below: must leave the identity columns alone,
+/// or a service test would happily "prove" that a build can rewrite the destination the operator approved; it
+/// must honour the expected-from status, or a service test could not distinguish a compare-and-set from a
+/// blind write; and must refuse a second active exit, because in production that is a
+/// unique index rather than a service-side check.
+///
+///
+/// Records are copied on the way in and on the way out. The service mutates its own copy of a record before
+/// handing it to — that is the intended usage — and a store handing out live references
+/// would let those mutations land in storage without any write at all, hiding an update that never happened.
+///
+///
+public sealed class InMemoryUnilateralExitRecordStore : IUnilateralExitRecordStore
+{
+ private readonly WriteLog? _writeLog;
+ private readonly Dictionary _records = [];
+
+ public InMemoryUnilateralExitRecordStore(WriteLog? writeLog = null)
+ {
+ _writeLog = writeLog;
+ }
+
+ /// Thrown by when set: the quote could not be recorded.
+ public Exception? FailCreateWith { get; set; }
+
+ /// Makes report that it changed nothing, as a vanished row would.
+ public bool RefuseUpdates { get; set; }
+
+ /// The live rows. Read them; do not mutate through them.
+ public IReadOnlyDictionary Records => _records;
+
+ public UnilateralExitRecord? Single() => _records.Count == 1 ? Copy(_records.Values.First()) : null;
+
+ public Task CreateAsync(UnilateralExitRecord record, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ // The EF store's own guards. Omitting them would let this one insert a row with an empty store id where
+ // the real one throws, which is exactly the divergence the shared contract exists to catch.
+ ArgumentException.ThrowIfNullOrEmpty(record.Id);
+ ArgumentException.ThrowIfNullOrEmpty(record.StoreId);
+
+ // Observed, as Npgsql observes it: a cancelled token means the write does not happen. The service relies
+ // on that being true, which is why it passes CancellationToken.None for the one write it must never skip.
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (FailCreateWith is not null)
+ throw FailCreateWith;
+
+ // The partial unique index, in memory: unique on the store, filtered to the two non-terminal statuses.
+ // A refusal rather than an exception, matching how the EF store translates Postgres's unique violation.
+ if (record.IsActive &&
+ _records.Values.Any(r => r.StoreId == record.StoreId && r.IsActive))
+ {
+ return Task.FromResult(false);
+ }
+
+ if (!_records.TryAdd(record.Id, Copy(record)))
+ throw new InvalidOperationException($"A unilateral exit already exists with id {record.Id}.");
+
+ _writeLog?.Record($"exit:create:{record.Id}");
+ return Task.FromResult(true);
+ }
+
+ public Task UpdateAsync(
+ UnilateralExitRecord record,
+ UnilateralExitStatus expectedStatus,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentException.ThrowIfNullOrEmpty(record.Id);
+ ArgumentException.ThrowIfNullOrEmpty(record.StoreId);
+
+ // See CreateAsync: a cancelled token means no write, which is what makes the service's use of
+ // CancellationToken.None after a successful build load-bearing rather than decorative.
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (RefuseUpdates ||
+ !_records.TryGetValue(record.Id, out var stored) ||
+ stored.StoreId != record.StoreId ||
+ // The compare-and-set. Whatever the caller read is the only status this write may overwrite.
+ stored.Status != expectedStatus)
+ {
+ return Task.FromResult(false);
+ }
+
+ // The mutable half only, matching the EF store's setter list. Everything absent from it — store, creation
+ // time, destination, fee rate, leaf ids, funding address, funding key index — is what the operator funded
+ // against.
+ stored.Status = record.Status;
+ stored.UpdatedUtc = record.UpdatedUtc;
+ stored.RecoverableValueSat = record.RecoverableValueSat;
+ stored.TotalFeeSat = record.TotalFeeSat;
+ stored.SingleUtxoFundingSat = record.SingleUtxoFundingSat;
+ // Coalesced, matching the EF store: these two hold the exit's only copy of its signed transactions and
+ // the outpoint they spend, and a caller writing a status or an error knows nothing about them.
+ stored.FundingUtxosJson = record.FundingUtxosJson ?? stored.FundingUtxosJson;
+ stored.TransactionsJson = record.TransactionsJson ?? stored.TransactionsJson;
+ // An assignment and not a coalesce: a build that gets further has to be able to clear the previous
+ // attempt's complaint.
+ stored.LastError = record.LastError;
+
+ _writeLog?.Record($"exit:update:{record.Id}:{record.Status}");
+ return Task.FromResult(true);
+ }
+
+ public Task GetAsync(
+ string storeId,
+ string id,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(
+ _records.TryGetValue(id, out var record) && record.StoreId == storeId ? Copy(record) : null);
+
+ public Task GetActiveForStoreAsync(
+ string storeId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(Newest(_records.Values.Where(r => r.StoreId == storeId && r.IsActive)));
+
+ public Task> ListTerminalForStoreAsync(
+ string storeId,
+ int limit,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit);
+
+ return Task.FromResult>(Ordered(
+ _records.Values.Where(r => r.StoreId == storeId && !r.IsActive))
+ .Take(limit)
+ .Select(Project)
+ .ToList());
+ }
+
+ public Task NextFundingKeyIndexAsync(
+ string storeId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ // Every row of the store, terminal ones included, so an index is never reused — see the interface.
+ var rows = _records.Values.Where(r => r.StoreId == storeId).ToList();
+ return Task.FromResult(rows.Count == 0 ? 0 : rows.Max(r => r.FundingKeyIndex) + 1);
+ }
+
+ private static UnilateralExitRecord? Newest(IEnumerable candidates)
+ {
+ var found = Ordered(candidates).FirstOrDefault();
+ return found is null ? null : Copy(found);
+ }
+
+ ///
+ /// Newest first, ties broken by id in byte order — — to match
+ /// the "C" collation the EF store names for exactly this reason. An ICU-style comparison would order
+ /// hyphenated UUIDs differently and the two implementations would disagree on nothing that matters until they
+ /// did.
+ ///
+ private static IOrderedEnumerable Ordered(
+ IEnumerable candidates) =>
+ candidates
+ .OrderByDescending(r => r.CreatedUtc)
+ .ThenByDescending(r => r.Id, StringComparer.Ordinal);
+
+ ///
+ /// A detached copy of a row.
+ ///
+ ///
+ /// Hand-written, so it can silently drop a column — and on this table a dropped column is a merchant's only
+ /// copy of signed transactions. The contract's round-trip test is what catches that.
+ ///
+ internal static UnilateralExitRecord Copy(UnilateralExitRecord source) => new()
+ {
+ Id = source.Id,
+ StoreId = source.StoreId,
+ Status = source.Status,
+ CreatedUtc = source.CreatedUtc,
+ UpdatedUtc = source.UpdatedUtc,
+ DestinationAddress = source.DestinationAddress,
+ FeeRateSatPerVbyte = source.FeeRateSatPerVbyte,
+ LeafIdsJson = source.LeafIdsJson,
+ RecoverableValueSat = source.RecoverableValueSat,
+ TotalFeeSat = source.TotalFeeSat,
+ SingleUtxoFundingSat = source.SingleUtxoFundingSat,
+ FundingAddress = source.FundingAddress,
+ FundingKeyIndex = source.FundingKeyIndex,
+ FundingUtxosJson = source.FundingUtxosJson,
+ TransactionsJson = source.TransactionsJson,
+ LastError = source.LastError
+ };
+
+ ///
+ /// A history row: everything except the three JSON columns, which the EF store does not select.
+ ///
+ ///
+ /// Reproduced rather than glossed over. If this handed back the blobs, a service test could read a
+ /// transaction set off a history row that production would report as null — and, worse, hand that row back to
+ /// without discovering that the coalescing is what makes it safe.
+ ///
+ private static UnilateralExitRecord Project(UnilateralExitRecord source)
+ {
+ var row = Copy(source);
+ row.LeafIdsJson = string.Empty;
+ row.FundingUtxosJson = null;
+ row.TransactionsJson = null;
+ return row;
+ }
+}
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkPluginStartupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkPluginStartupTests.cs
index 3cbf4cb..2390aad 100644
--- a/BTCPayServer.Plugins.Flint.Tests/SparkPluginStartupTests.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkPluginStartupTests.cs
@@ -171,10 +171,13 @@ public void Every_singleton_the_plugin_registers_resolves()
typeof(SparkSweepSettingsService),
typeof(SweepDestinationResolver),
typeof(ISweepAddressSource),
- // Reaches core's graph for IHttpClientFactory, and is the only thing in the plugin that
- // does. It is registered alongside its own named client, so this fails if that registration
+ // Reaches core's graph for IHttpClientFactory (as does SparkExitFundingExplorer below).
+ // It is registered alongside its own named client, so this fails if that registration
// is ever dropped in favour of assuming core made one.
typeof(CrossChainCatalog),
+ typeof(IUnilateralExitRecordStore),
+ typeof(SparkExitFundingExplorer),
+ typeof(ISparkUnilateralExitService),
typeof(SparkReconciliationTask),
typeof(SweepTask),
typeof(SparkConnectionStringHandler),
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkStoreProvisionerTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkStoreProvisionerTests.cs
index 62db446..7c48d6a 100644
--- a/BTCPayServer.Plugins.Flint.Tests/SparkStoreProvisionerTests.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkStoreProvisionerTests.cs
@@ -272,17 +272,24 @@ public async Task Provision_restores_the_previous_configuration_when_a_replaceme
}
[Fact]
- public async Task Provision_keeps_the_payment_key_and_sweep_settings_across_a_seed_change()
+ public async Task Provision_keeps_the_payment_key_and_every_settings_block_across_a_seed_change()
{
// The payment key is a store-binding token, not a secret that ages, and rotating it would invalidate a
- // Lightning configuration that is already live. The sweep settings are the merchant's, and changing a
- // seed is not a request to lose them — this is what lets Wave 4 hang its settings off the same blob.
+ // Lightning configuration that is already live. The settings blocks are the merchant's, and changing a
+ // seed is not a request to lose them — every nested block has to be carried, and the one that goes
+ // missing when a new block is added is the one nobody asserted.
var h = Create();
Assert.True((await h.Provisioner.ProvisionAsync(StoreId, ValidMnemonic, SeedSource.Generated)).Succeeded);
var first = h.Settings.Settings[StoreId]!;
first.Sweep.Enabled = true;
first.Sweep.BalanceThresholdSats = 100_000;
+ first.Deposits.ClaimFeeLeewaySatPerVbyte = 9;
+ first.StableBalance.DisclosureAcknowledged = true;
+ // Infrastructure configuration, which has nothing to do with which seed the store runs on — and off
+ // mainnet losing it means the next unilateral exit refuses for want of a block explorer.
+ first.UnilateralExit.DisclosureAcknowledged = true;
+ first.UnilateralExit.EsploraApiUrl = "https://explorer.test/api";
first.ApiKeyOverride = "merchant-key";
var replacement = new Mnemonic(Wordlist.English, WordCount.Twelve).ToString();
@@ -292,8 +299,19 @@ public async Task Provision_keeps_the_payment_key_and_sweep_settings_across_a_se
Assert.Equal(first.PaymentKey, second.PaymentKey);
Assert.True(second.Sweep.Enabled);
Assert.Equal(100_000, second.Sweep.BalanceThresholdSats);
+ Assert.Equal(9, second.Deposits.ClaimFeeLeewaySatPerVbyte);
+ Assert.True(second.StableBalance.DisclosureAcknowledged);
+ Assert.True(second.UnilateralExit.DisclosureAcknowledged);
+ Assert.Equal("https://explorer.test/api", second.UnilateralExit.EsploraApiUrl);
Assert.Equal("merchant-key", second.ApiKeyOverride);
Assert.Equal(SeedSource.Imported, second.SeedSource);
+
+ // Copied, not aliased. Sharing a block with the object the caller still holds would make a later edit to
+ // one silently edit the other — including the copy a failed attempt is supposed to roll back to.
+ Assert.NotSame(first.Sweep, second.Sweep);
+ Assert.NotSame(first.Deposits, second.Deposits);
+ Assert.NotSame(first.StableBalance, second.StableBalance);
+ Assert.NotSame(first.UnilateralExit, second.UnilateralExit);
}
[Fact]
diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs
new file mode 100644
index 0000000..080e8e7
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs
@@ -0,0 +1,1814 @@
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Sdk;
+using BTCPayServer.Plugins.Flint.Services;
+using BTCPayServer.Plugins.Flint.Tests.Fakes;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging.Abstractions;
+using NBitcoin;
+using System.Globalization;
+using System.Net;
+using System.Text.Json;
+using Xunit;
+
+namespace BTCPayServer.Plugins.Flint.Tests;
+
+///
+/// The unilateral-exit service: the guards in front of a signed exit, and what gets persisted when one is built.
+///
+///
+///
+/// Nothing this service does can be undone by the plugin, and nothing it does can be redone by the SDK.
+/// The transactions come back signed and unbroadcast, they exist only in the record's TransactionsJson, and
+/// the on-chain fees have to be paid up front out of a funding UTXO the operator sends by hand. So the tests here
+/// are almost entirely about refusals — the ones that stop an exit that costs more than it recovers, that stop a
+/// second exit committing the same leaves twice, and that stop "the explorer did not answer" reading as "no
+/// funding has arrived".
+///
+///
+/// Why the whole class is one non-parallel collection. The feature gate is an environment variable, which
+/// is process-global state: a test that toggles it while another class reads it would make both flaky in a way
+/// that reproduces once a week. Every test here therefore owns the variable for its duration through
+/// , and the collection is serialised against the rest of the suite.
+///
+///
+[Collection(UnilateralExitTestCollection.Name)]
+public class SparkUnilateralExitServiceTests
+{
+ private const string StoreId = "store-1";
+
+ /// The BIP39 test vector, so the derived funding address below is a reproducible pin.
+ private const string Mnemonic =
+ "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
+
+ ///
+ /// m/84'/1'/4607060'/0/0 of on regtest.
+ ///
+ ///
+ /// Hard-coded rather than re-derived in the test, which would only assert that NBitcoin agrees with itself.
+ /// Pinned, because changing the derivation path silently is how an operator ends up funding an address the
+ /// plugin can no longer spend from — and the funding key's whole reason for living at an absurd account index
+ /// is that it must never move. See Constants.UnilateralExitFundingAccount.
+ ///
+ private const string FundingAddress = "bcrt1qluxw544vs8huwqyxvwqx4x75x5v7mgfkamt2pd";
+
+ private const string Destination = "bcrt1qtxwcjjvf4ny9wsw9emgnpazey2vde3xhnyqpw0";
+ private const string MainnetDestination = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
+
+ private const string FundingTxid =
+ "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1";
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ #region The feature gate
+
+ ///
+ /// With the gate off the service behaves as if the feature does not exist, on every method.
+ ///
+ ///
+ /// The controller's 404 is a courtesy and not the enforcement: a Greenfield endpoint, a scheduled task or a
+ /// second controller added later would each have to remember the gate, and this is the one place that cannot
+ /// forget it. The read reports an absent feature rather than the store's real acknowledgement, so nothing
+ /// leaks through a surface the gate is supposed to have closed.
+ ///
+ [Fact]
+ public async Task Every_entry_point_behaves_as_if_the_feature_does_not_exist_when_the_gate_is_off()
+ {
+ using var harness = Harness.Create(featureEnabled: false);
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+ Assert.False(page.WalletRunning);
+ Assert.False(page.DisclosureAcknowledged);
+ Assert.Equal(0, page.BalanceSats);
+ Assert.Null(page.ActiveRecord);
+ Assert.Empty(page.History);
+ Assert.Null(page.FundingReceivedSat);
+ Assert.Null(page.FundingLargestOutputSat);
+ Assert.Null(page.LeafCount);
+ Assert.Null(page.FundingKeyPath);
+ Assert.Null(page.Transactions);
+ Assert.False(page.TransactionsUnreadable);
+
+ foreach (var attempt in new[]
+ {
+ await harness.Service.AcknowledgeDisclosureAsync(StoreId, Ct),
+ await harness.Service.SetExplorerUrlAsync(StoreId, "https://explorer.test/api", Ct),
+ await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct),
+ await harness.Service.BuildAsync(StoreId, "whatever", Ct),
+ await harness.Service.MarkCompletedAsync(StoreId, "whatever", Ct),
+ await harness.Service.AbandonAsync(StoreId, "whatever", Ct)
+ })
+ {
+ Assert.False(attempt.Success);
+ Assert.Equal(SparkUnilateralExitService.FeatureDisabled, attempt.Error);
+ }
+
+ // And nothing reached the wallet or the database on the way to those refusals.
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ Assert.Empty(harness.Records.Records);
+ Assert.Empty(harness.Settings.Writes);
+ }
+
+ #endregion
+
+ #region The disclosure gate
+
+ ///
+ /// Quoting is refused until the acknowledgement is stored, and so is building.
+ ///
+ ///
+ /// Both, deliberately. The build is the call that produces signed transactions, and it is reachable directly
+ /// from its own POST — so a gate enforced only on the quote would be a gate with a documented bypass for
+ /// anybody holding an exit id.
+ ///
+ [Fact]
+ public async Task Quoting_and_building_are_refused_until_the_disclosure_is_stored()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: false);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var quote = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(quote.Success);
+ Assert.Equal(SparkUnilateralExitService.DisclosureRequired, quote.Error);
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ Assert.Empty(harness.Records.Records);
+
+ // A record that exists from before the acknowledgement was revoked cannot be built either.
+ var record = harness.Seed();
+ var build = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(build.Success);
+ Assert.Equal(SparkUnilateralExitService.DisclosureRequired, build.Error);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ /// The acknowledgement is stored in the store's settings, and is idempotent.
+ [Fact]
+ public async Task Acknowledging_the_disclosure_stores_it_once()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: false);
+
+ var first = await harness.Service.AcknowledgeDisclosureAsync(StoreId, Ct);
+
+ Assert.True(first.Success);
+ Assert.True(harness.Settings.Settings[StoreId]!.UnilateralExit.DisclosureAcknowledged);
+ var writes = harness.Settings.Writes.Count;
+
+ var second = await harness.Service.AcknowledgeDisclosureAsync(StoreId, Ct);
+
+ Assert.True(second.Success);
+ // No second write: storing settings tears down and reconnects the store's wallet, which is not something
+ // to do on a button press that changes nothing.
+ Assert.Equal(writes, harness.Settings.Writes.Count);
+ }
+
+ /// An unconfigured store is refused rather than provisioned by a side effect.
+ [Fact]
+ public async Task A_store_without_Flint_is_refused()
+ {
+ using var harness = Harness.Create();
+
+ var ack = await harness.Service.AcknowledgeDisclosureAsync(StoreId, Ct);
+ var quote = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.Equal(SparkUnilateralExitService.NotConfigured, ack.Error);
+ Assert.Equal(SparkUnilateralExitService.NotConfigured, quote.Error);
+ Assert.Empty(harness.Settings.Writes);
+ }
+
+ #endregion
+
+ #region Quoting
+
+ ///
+ /// The fee rate has to be inside the documented band, however it arrived.
+ ///
+ ///
+ /// The rate multiplies across every transaction in the tree, so a mistyped one is not one expensive
+ /// transaction — it is an expensive exit and a funding requirement to match. Zero and negative are checked as
+ /// well as absurd, because the value is cast to an unsigned rate on the way to the SDK and a negative would
+ /// arrive there as an astronomical one.
+ ///
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(-1L)]
+ [InlineData(long.MinValue)]
+ [InlineData(501L)]
+ [InlineData(long.MaxValue)]
+ public async Task A_fee_rate_outside_the_band_is_refused(long feeRate)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var result = await harness.Service.QuoteAsync(StoreId, feeRate, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("between", result.Error);
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ }
+
+ [Theory]
+ [InlineData(1L)]
+ [InlineData(500L)]
+ public async Task The_band_ends_are_accepted(long feeRate)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var result = await harness.Service.QuoteAsync(StoreId, feeRate, Destination, Ct);
+
+ Assert.True(result.Success, result.Error);
+ Assert.Equal((ulong)feeRate, Assert.Single(harness.Sdk.ExitQuoteCalls).FeeRateSatPerVbyte);
+ }
+
+ ///
+ /// The destination is parsed for this server's network, and this is the last place it can be.
+ ///
+ ///
+ /// A mainnet-shaped address is a perfectly valid string on regtest and vice versa, and the destination is
+ /// baked into the signed sweep — so a wrong-network address that got past here would produce a transaction
+ /// that can never be broadcast, discovered days into a multi-level exit.
+ ///
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not-an-address")]
+ [InlineData(MainnetDestination)]
+ public async Task A_destination_that_is_not_valid_here_is_refused(string destination)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.NotNull(result.Error);
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ }
+
+ ///
+ /// An empty automatic selection is reported as "nothing worth exiting", not as a failure.
+ ///
+ ///
+ /// The SDK returns no leaves whenever none of them clears the requested fee rate. That is the normal answer
+ /// for a small balance at a busy fee market, and a merchant told "the exit failed" would retry it for ever.
+ ///
+ [Fact]
+ public async Task An_empty_selection_says_there_is_nothing_worth_exiting()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Equal(SparkUnilateralExitService.NothingWorthExiting, result.Error);
+ // Nothing recorded: there is no exit here to fund or abandon later.
+ Assert.Empty(harness.Records.Records);
+ }
+
+ /// An exit that costs more than it recovers is refused, and nothing is recorded.
+ [Fact]
+ public async Task A_quote_whose_fee_exceeds_what_it_recovers_is_refused()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Sdk.ExitTotalFeeSat = 4_000;
+ harness.WithLeaves(("leaf-a", 3_500));
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("more than it recovers", result.Error);
+ Assert.Empty(harness.Records.Records);
+ }
+
+ ///
+ /// A successful quote pins the leaf set and issues the funding address.
+ ///
+ ///
+ /// The leaf ids are the reason the row is durable at all: the build re-quotes these leaves, so the
+ /// operator cannot fund one exit and build another after the wallet's tree has moved under them.
+ ///
+ [Fact]
+ public async Task A_successful_quote_persists_the_leaf_ids_the_funding_address_and_the_figures()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 300_000), ("leaf-b", 200_000));
+
+ var result = await harness.Service.QuoteAsync(StoreId, 12, Destination, Ct);
+
+ Assert.True(result.Success, result.Error);
+ var record = Assert.IsType(result.Record);
+
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, record.Status);
+ Assert.Equal(StoreId, record.StoreId);
+ Assert.Equal(Destination, record.DestinationAddress);
+ Assert.Equal(12, record.FeeRateSatPerVbyte);
+ Assert.Equal(500_000, record.RecoverableValueSat);
+ Assert.Equal(harness.Sdk.ExitTotalFeeSat, record.TotalFeeSat);
+ Assert.Equal(harness.Sdk.ExitSingleUtxoFundingSat, record.SingleUtxoFundingSat);
+ Assert.Equal(FundingAddress, record.FundingAddress);
+ Assert.Equal(0, record.FundingKeyIndex);
+ Assert.Equal(harness.Now, record.CreatedUtc);
+ Assert.Null(record.TransactionsJson);
+ Assert.Null(record.FundingUtxosJson);
+
+ Assert.Equal(
+ ["leaf-a", "leaf-b"],
+ JsonSerializer.Deserialize(record.LeafIdsJson)!);
+
+ // Quoted automatically: the first quote is the SDK's choice of what is worth exiting.
+ Assert.Null(Assert.Single(harness.Sdk.ExitQuoteCalls).LeafIds);
+
+ // And the row is in storage, not only in the result.
+ Assert.Equal(record.Id, harness.Records.Single()!.Id);
+ }
+
+ ///
+ /// Each exit gets its own funding address, so one exit's leftovers can never fund the next.
+ ///
+ ///
+ /// A fixed address per store would be a trap rather than a saving. Sats left behind by an abandoned
+ /// exit sit on the address the next exit tells the operator to fund, so the next build selects a leftover —
+ /// wrong size at best, and at worst large enough to satisfy the new requirement, which makes a build succeed
+ /// against funding nobody just sent. The index is allocated one past every index the store has ever issued,
+ /// terminal exits included, so it is never reused either.
+ ///
+ [Fact]
+ public async Task Each_exit_gets_its_own_funding_address()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var first = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+ Assert.True(first.Success, first.Error);
+ Assert.Equal(FundingAddress, first.Record!.FundingAddress);
+ Assert.Equal(0, first.Record.FundingKeyIndex);
+
+ Assert.True((await harness.Service.AbandonAsync(StoreId, first.Record.Id, Ct)).Success);
+
+ var second = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+ Assert.True(second.Success, second.Error);
+ Assert.Equal(1, second.Record!.FundingKeyIndex);
+ Assert.NotEqual(first.Record.FundingAddress, second.Record.FundingAddress);
+
+ // And the address is the one the pinned path derives, which is what makes stranded funding recoverable.
+ Assert.True(SparkExitFundingKey.TryDerive(Mnemonic, Network.RegTest, 1, out var key, out _));
+ using (key)
+ {
+ Assert.Equal(key!.Address, second.Record.FundingAddress);
+ }
+ }
+
+ ///
+ /// A store with an exit in flight cannot quote a second one, and is shown the first.
+ ///
+ ///
+ /// Both non-terminal statuses hold the store. Two exits would compete for the same leaves, and the SDK
+ /// reports that as a conflict only after one of them has committed — too late to be a useful
+ /// refusal.
+ ///
+ [Theory]
+ [InlineData(UnilateralExitStatus.AwaitingFunding)]
+ [InlineData(UnilateralExitStatus.Built)]
+ public async Task A_store_with_an_exit_in_flight_cannot_quote_another(UnilateralExitStatus status)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var existing = harness.Seed(status: status);
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("already has an exit in progress", result.Error);
+ Assert.Equal(existing.Id, result.Record?.Id);
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ }
+
+ ///
+ /// One exit operation at a time per store, whatever calls arrive.
+ ///
+ ///
+ /// Driven from inside the SDK's own prepare rather than by racing two threads, which is what makes it a test
+ /// rather than a coin flip: the second call happens while the first is provably mid-flight.
+ ///
+ [Fact]
+ public async Task A_second_operation_is_refused_while_one_is_in_flight()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ UnilateralExitOpResult? reentrant = null;
+ harness.Sdk.WhenExitQuoted = () =>
+ {
+ // Everything the nested call touches answers synchronously, so this completes before returning.
+ var nested = harness.Service.QuoteAsync(StoreId, 10, Destination, CancellationToken.None);
+ Assert.True(nested.IsCompleted, "the re-entrant quote should not have reached anything awaitable");
+ reentrant = nested.Result;
+ };
+
+ var outer = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.True(outer.Success, outer.Error);
+ Assert.NotNull(reentrant);
+ Assert.False(reentrant.Success);
+ Assert.Equal(SparkUnilateralExitService.OperationInFlight, reentrant.Error);
+ // Exactly one record: the re-entrant attempt created nothing.
+ Assert.NotNull(harness.Records.Single());
+ }
+
+ /// A seed this server can no longer decrypt is refused before the SDK is asked anything.
+ [Fact]
+ public async Task A_store_whose_seed_cannot_be_read_is_refused()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Settings.Settings[StoreId]!.ProtectedMnemonic = "not something this keyring can unprotect";
+ harness.WithLeaves(("leaf-a", 500_000));
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("recovery phrase", result.Error);
+ Assert.Empty(harness.Sdk.ExitQuoteCalls);
+ Assert.Empty(harness.Records.Records);
+ }
+
+ /// A store with no running wallet cannot quote.
+ [Fact]
+ public async Task A_stopped_wallet_cannot_quote()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true, walletRunning: false);
+
+ var result = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.False(result.Success);
+ Assert.Equal(SparkUnilateralExitService.WalletNotRunning, result.Error);
+ }
+
+ #endregion
+
+ #region Funding discovery
+
+ ///
+ /// A funding address short of the requirement refuses the build and says so on the record.
+ ///
+ ///
+ /// The status deliberately stays AwaitingFunding: the exit is not broken, it is underfunded, and the
+ /// operator's next step is a top-up rather than a new quote. The explanation lives on the row so it is next to
+ /// the funding instructions instead of in a log.
+ ///
+ [Fact]
+ public async Task An_underfunded_exit_is_refused_and_the_reason_is_recorded()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(1_000));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("4,200", result.Error);
+
+ var stored = harness.Records.Records[record.Id];
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, stored.Status);
+ Assert.Equal(result.Error, stored.LastError);
+ Assert.Null(stored.TransactionsJson);
+ // Nothing was built, so nothing was signed.
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ ///
+ /// Unconfirmed outputs do not count towards the funding requirement.
+ ///
+ ///
+ /// Not conservatism. Every transaction in the exit is a CPFP child of this output, so funding from an
+ /// unconfirmed one makes the whole tree a package descending from an unconfirmed parent — and mempool policy
+ /// bounds how deep and how large such a package may be. The exit would be rejected as non-relayable somewhere
+ /// in the middle, after the fan-out had been broadcast and paid for.
+ ///
+ [Fact]
+ public async Task An_unconfirmed_funding_output_does_not_count()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(50_000, confirmed: false));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("no confirmed output", result.Error);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+
+ // And the page says the same thing: zero confirmed, not fifty thousand.
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+ Assert.Equal(0, page.FundingReceivedSat);
+ }
+
+ ///
+ /// Several outputs that add up are still not one output that suffices.
+ ///
+ ///
+ /// The SDK spends a single P2WPKH outpoint for CPFP, so a total is not a qualification — and the refusal has
+ /// to say that, because "the address holds more than you asked for and the build still refuses" is otherwise
+ /// indistinguishable from a bug.
+ ///
+ [Fact]
+ public async Task Two_outputs_that_add_up_do_not_fund_an_exit()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(3_000, vout: 0), Utxo(3_000, vout: 1));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("single output", result.Error);
+ Assert.Contains("6,000", result.Error);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ ///
+ /// An explorer that cannot be read leaves the funding unknown, never zero.
+ ///
+ ///
+ /// The distinction is the point of the nullable. An operator who has already sent the funding sats
+ /// reads "0 sat received" as "my transaction has not confirmed yet" and waits — on a confirmation that
+ /// happened hours ago, because the explorer URL was wrong.
+ ///
+ [Fact]
+ public async Task An_unreachable_explorer_reports_unknown_rather_than_zero()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed();
+ harness.ExplorerOffline();
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Null(page.FundingReceivedSat);
+ Assert.NotNull(page.ActiveRecord);
+ }
+
+ /// And a build against an unreadable explorer refuses with something an operator can act on.
+ [Fact]
+ public async Task An_unreachable_explorer_refuses_the_build_readably()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed();
+ harness.ExplorerFails();
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("block explorer could not be read", result.Error);
+ Assert.Equal(result.Error, harness.Records.Records[record.Id].LastError);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ ///
+ /// Off mainnet, an unset explorer URL is a refusal naming the setting.
+ ///
+ ///
+ /// mempool.space has no regtest, so falling back to it there would answer every lookup with "nothing found"
+ /// — which reads exactly like an unconfirmed funding transaction.
+ ///
+ [Fact]
+ public async Task A_regtest_store_with_no_explorer_configured_is_told_to_set_one()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true, esploraApiUrl: null);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed();
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+ Assert.Null(page.FundingReceivedSat);
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("esplora API URL", result.Error);
+ }
+
+ /// The page reports what the explorer confirmed, in satoshi.
+ [Fact]
+ public async Task The_page_reports_the_confirmed_funding_balance()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed();
+ harness.Explorer(Utxo(5_000, vout: 0), Utxo(2_500, vout: 1), Utxo(9_000, vout: 2, confirmed: false));
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Equal(7_500, page.FundingReceivedSat);
+ }
+
+ #endregion
+
+ #region Building
+
+ ///
+ /// A funded exit builds, and everything the operator needs to broadcast it is on the row.
+ ///
+ ///
+ /// The transactions are the whole product of this feature and they exist nowhere else — the SDK will not hand
+ /// them back without a fresh build — so this asserts they round-trip out of the column, CPFP child and
+ /// dependency order included.
+ ///
+ [Fact]
+ public async Task A_funded_exit_builds_and_persists_its_transactions_and_totals()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 300_000), ("leaf-b", 200_000));
+ var record = harness.Seed(
+ leafIds: ["leaf-a", "leaf-b"],
+ singleUtxoFundingSat: 4_200,
+ lastError: "not enough on the funding address");
+ harness.Explorer(Utxo(10_000));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.True(result.Success, result.Error);
+
+ var stored = harness.Records.Records[record.Id];
+ Assert.Equal(UnilateralExitStatus.Built, stored.Status);
+ Assert.Equal(500_000, stored.RecoverableValueSat);
+ Assert.Equal(harness.Sdk.ExitTotalFeeSat, stored.TotalFeeSat);
+ // Cleared by a build that got further: the previous attempt's complaint must not sit next to the result.
+ Assert.Null(stored.LastError);
+
+ var funding = JsonSerializer.Deserialize(stored.FundingUtxosJson!)!;
+ var spent = Assert.Single(funding);
+ Assert.Equal(FundingTxid, spent.Txid);
+ Assert.Equal(10_000, spent.ValueSat);
+ Assert.False(string.IsNullOrWhiteSpace(spent.PubkeyHex));
+
+ // Default serializer options both ways, so any reader deserialising the seam records plainly gets them
+ // back — which is what the exit page does with this column.
+ var transactions = JsonSerializer.Deserialize(stored.TransactionsJson!)!;
+ Assert.Equal(4, transactions.Length);
+ Assert.Equal(SparkExitTxKind.Fanout, transactions[0].Kind);
+ Assert.Equal(SparkExitTxKind.Sweep, transactions[^1].Kind);
+ Assert.Equal(SparkExitTxStatus.Unconfirmed, transactions[0].Status);
+
+ var node = transactions.First(tx => tx.Kind is SparkExitTxKind.TreeNode);
+ Assert.True(node.RequiresPackageBroadcast);
+ Assert.Equal(1_008u, node.CsvTimelockBlocks!.Value);
+ Assert.Equal(["txid:fanout"], node.DependsOn);
+
+ // The build spent exactly the one output it was funded with, and it had a key for it.
+ var call = Assert.Single(harness.Sdk.ExitBuildCalls);
+ Assert.Equal(FundingTxid, Assert.Single(call.FundingUtxos).Txid);
+ Assert.Equal(32, call.FundingSecretKeyLength);
+ Assert.Null(call.Rejection);
+ }
+
+ ///
+ /// The build re-quotes the leaves the record was pinned to, not whatever the SDK would pick now.
+ ///
+ ///
+ /// Automatic selection is free to choose a different set on every call, and the funding output the operator
+ /// paid for was sized for the first set. Re-quoting with the pinned ids is what makes a resume mean the same
+ /// exit — so the assertion is on the arguments, not on the outcome.
+ ///
+ [Fact]
+ public async Task The_build_re_quotes_the_pinned_leaves()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 300_000), ("leaf-b", 200_000), ("leaf-c", 100_000));
+ var record = harness.Seed(leafIds: ["leaf-a", "leaf-b"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.True(result.Success, result.Error);
+ // Both quotes the build takes — the one that prices the funding requirement and the one the SDK takes
+ // inside the atomic build — name the pinned ids. Neither may fall back to automatic selection.
+ Assert.Equal(2, harness.Sdk.ExitQuoteCalls.Count);
+ Assert.All(harness.Sdk.ExitQuoteCalls, call => Assert.Equal(["leaf-a", "leaf-b"], call.LeafIds));
+ // leaf-c was never funded for, so it is not in the built exit however attractive it looks.
+ Assert.Equal(500_000, harness.Records.Records[record.Id].RecoverableValueSat);
+ }
+
+ ///
+ /// A quote that went stale between the funding and the build is refused, and nothing is signed.
+ ///
+ ///
+ /// A unilateral-exit quote has no expiry and no id: it goes stale silently as the wallet's tree moves
+ /// under it. So the guard cannot live on the persisted figures — the build re-prices the pinned leaves before
+ /// it looks at funding at all, and the SDK's own approval callback checks again against the quote it takes
+ /// inside the build.
+ ///
+ [Fact]
+ public async Task A_build_whose_leaves_have_vanished_is_refused_before_anything_is_signed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ // The wallet's tree moves before the build's own quote is taken.
+ harness.Sdk.ExitLeaves.Clear();
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("no longer in this wallet", result.Error);
+
+ var stored = harness.Records.Records[record.Id];
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, stored.Status);
+ Assert.Equal(result.Error, stored.LastError);
+ Assert.Null(stored.TransactionsJson);
+ // Refused by the re-quote, so the SDK was never asked to build and the funding key was never handed over.
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ ///
+ /// The build's own veto still fires when the quote moves between the re-price and the build.
+ ///
+ ///
+ /// The re-price and the atomic build are two SDK calls, so the tree can move between them — which is the
+ /// whole reason the seam takes a veto rather than trusting a quote handed in from outside. This drives the
+ /// change from inside the SDK's own prepare, so the second quote provably differs from the first.
+ ///
+ [Fact]
+ public async Task A_wallet_that_moves_between_the_re_price_and_the_build_is_vetoed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ var quotes = 0;
+ harness.Sdk.WhenExitQuoted = () =>
+ {
+ // After the re-price has been answered, and before the build's own quote is taken.
+ if (++quotes == 1)
+ harness.Sdk.ExitLeaves.Clear();
+ };
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("no longer in this wallet", result.Error);
+ // The SDK recorded the veto and built nothing.
+ Assert.NotNull(Assert.Single(harness.Sdk.ExitBuildCalls).Rejection);
+ Assert.Null(harness.Records.Records[record.Id].TransactionsJson);
+ }
+
+ ///
+ /// A funding requirement that grew since the quote is re-priced first, so a correct top-up is selectable.
+ ///
+ ///
+ /// This is the deadlock the build's ordering exists to prevent. The requirement moves with the fee
+ /// market. If the funding output were selected against the figure the record was created with, an operator
+ /// who sent exactly what the refusal asked for would find that output ignored — selection would keep taking
+ /// the smaller one that satisfied the stale figure, the veto would keep refusing it, and the exit would never
+ /// build however much was sent. So the re-price comes first, its requirement is persisted, and the number on
+ /// the page is the number the selection uses.
+ ///
+ [Fact]
+ public async Task A_requirement_that_grew_is_re_priced_before_the_funding_is_selected()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+
+ // The fee market moved: the same leaves now need a much larger funding output than the record says.
+ harness.Sdk.ExitSingleUtxoFundingSat = 40_000;
+ harness.Explorer(Utxo(4_200, vout: 0));
+
+ var refused = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(refused.Success);
+ Assert.Contains("40,000", refused.Error);
+ // The fresh requirement is on the row, so the page asks for the amount the next attempt will judge by.
+ Assert.Equal(40_000, harness.Records.Records[record.Id].SingleUtxoFundingSat);
+
+ // The operator sends exactly what they were asked for, as a single new output. The old 4,200 output is
+ // still there and is still the smallest — which is what used to make this unbuildable for ever.
+ harness.Explorer(Utxo(4_200, vout: 0), Utxo(40_000, vout: 1));
+
+ var built = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.True(built.Success, built.Error);
+ var spent = Assert.Single(
+ JsonSerializer.Deserialize(
+ harness.Records.Records[record.Id].FundingUtxosJson!)!);
+ Assert.Equal(40_000, spent.ValueSat);
+ Assert.Equal(1u, spent.Vout);
+ }
+
+ /// A fresh quote that no longer pays for itself is vetoed too.
+ [Fact]
+ public async Task A_build_that_would_now_cost_more_than_it_recovers_is_vetoed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ // The fee market moved: the same leaves now cost more to force on-chain than they hold.
+ harness.Sdk.ExitTotalFeeSat = 900_000;
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("costs more than it recovers", result.Error);
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, harness.Records.Records[record.Id].Status);
+ Assert.Null(harness.Records.Records[record.Id].TransactionsJson);
+ }
+
+ ///
+ /// A requirement that grew inside the build names the largest output on the address, not the chosen one.
+ ///
+ ///
+ /// The two are different numbers whenever an operator has funded more than once: the build spends the
+ /// smallest output that covers the requirement, so telling them "the address holds X in its largest
+ /// output" while quoting the one that was picked is simply false — and it is false in the direction that
+ /// makes them send sats they did not need to.
+ ///
+ [Fact]
+ public async Task A_veto_for_a_grown_requirement_reports_the_largest_output_on_the_address()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000, vout: 0), Utxo(12_000, vout: 1));
+
+ var quotes = 0;
+ harness.Sdk.WhenExitQuoted = () =>
+ {
+ // The requirement grows between the re-price (which picks the 10,000 output) and the build's own
+ // quote, so the veto is the thing that refuses.
+ if (++quotes == 1)
+ harness.Sdk.ExitSingleUtxoFundingSat = 20_000;
+ };
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("20,000", result.Error);
+ Assert.Contains("12,000", result.Error);
+ Assert.DoesNotContain("10,000", result.Error);
+ // The requirement the veto judged by is on the row, so the next attempt selects against the same number.
+ Assert.Equal(20_000, harness.Records.Records[record.Id].SingleUtxoFundingSat);
+ }
+
+ ///
+ /// A request abandoned after the SDK has signed still gets its transactions written.
+ ///
+ ///
+ /// This is the one write in the plugin that must not be cancellable. The signed set exists in this
+ /// process and nowhere else, and the SDK will not hand it back without a fresh build against a fresh funding
+ /// output — so a merchant who closed the tab must not lose the exit they just paid the fan-out fee for.
+ ///
+ [Fact]
+ public async Task A_build_cancelled_after_signing_still_persists_its_transactions()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ using var cancelled = new CancellationTokenSource();
+ var quotes = 0;
+ harness.Sdk.WhenExitQuoted = () =>
+ {
+ // The second quote is the one the SDK takes inside the build, so this cancels the request while the
+ // exit is about to be signed.
+ if (++quotes == 2)
+ cancelled.Cancel();
+ };
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, cancelled.Token);
+
+ Assert.True(result.Success, result.Error);
+ var stored = harness.Records.Records[record.Id];
+ Assert.Equal(UnilateralExitStatus.Built, stored.Status);
+ Assert.NotNull(stored.TransactionsJson);
+ }
+
+ ///
+ /// The SDK's own funding failures arrive as readable copy on the record rather than as an exception.
+ ///
+ ///
+ /// Both of these mean the operator has something to do — top up, or send fresh funds because the output was
+ /// spent from under the exit — and both leave the exit exactly where it was, because nothing was built.
+ ///
+ [Fact]
+ public async Task The_SDK_s_funding_failures_land_on_the_record_as_words()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var shortfallRecord = harness.Seed(id: "exit-shortfall", leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+ harness.Sdk.FailExitBuildWith = new SparkExitFundingShortfallException(99_000);
+
+ var shortfall = await harness.Service.BuildAsync(StoreId, shortfallRecord.Id, Ct);
+
+ Assert.False(shortfall.Success);
+ Assert.Contains("99,000", shortfall.Error);
+ Assert.Equal(shortfall.Error, harness.Records.Records[shortfallRecord.Id].LastError);
+ Assert.Equal(
+ UnilateralExitStatus.AwaitingFunding,
+ harness.Records.Records[shortfallRecord.Id].Status);
+
+ harness.Sdk.FailExitBuildWith = new SparkExitFundingUtxoConflictException(FundingTxid, 0);
+
+ var conflict = await harness.Service.BuildAsync(StoreId, shortfallRecord.Id, Ct);
+
+ Assert.False(conflict.Success);
+ Assert.Contains(FundingTxid, conflict.Error);
+ Assert.Contains("already spent", conflict.Error);
+ }
+
+ /// A build against an unknown exit, or one that is finished, is refused.
+ [Fact]
+ public async Task Building_an_unknown_or_finished_exit_is_refused()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ harness.Explorer(Utxo(10_000));
+
+ var missing = await harness.Service.BuildAsync(StoreId, "exit-nowhere", Ct);
+ Assert.Equal(SparkUnilateralExitService.ExitNotFound, missing.Error);
+
+ var finished = harness.Seed(id: "exit-done", status: UnilateralExitStatus.Completed);
+ var result = await harness.Service.BuildAsync(StoreId, finished.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("finished", result.Error);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ ///
+ /// An exit whose funding address the store's seed no longer derives is refused, with the path to recover it.
+ ///
+ ///
+ /// Reachable by replacing a store's seed between the quote and the build. The plugin cannot sign for the
+ /// output the operator funded, and the honest answer includes where their sats still are.
+ ///
+ [Fact]
+ public async Task An_exit_whose_funding_key_no_longer_derives_is_refused()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(fundingAddress: "bcrt1qsomeotheraddressentirely");
+ harness.Explorer(Utxo(10_000));
+
+ var result = await harness.Service.BuildAsync(StoreId, record.Id, Ct);
+
+ Assert.False(result.Success);
+ Assert.Contains("no longer derives", result.Error);
+ Assert.Contains("m/84'/1'/4607060'/0/0", result.Error);
+ Assert.Empty(harness.Sdk.ExitBuildCalls);
+ }
+
+ #endregion
+
+ #region Abandoning
+
+ ///
+ /// Abandoning frees the store for a fresh quote, which is the only reason the status exists.
+ ///
+ ///
+ /// It moves no money and cancels nothing on-chain — a point the page has to make out loud — but without it an
+ /// exit with no way forward would block every later one for ever.
+ ///
+ [Fact]
+ public async Task Abandoning_an_exit_frees_the_store()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed();
+
+ var abandoned = await harness.Service.AbandonAsync(StoreId, record.Id, Ct);
+
+ Assert.True(abandoned.Success, abandoned.Error);
+ Assert.Equal(UnilateralExitStatus.Abandoned, harness.Records.Records[record.Id].Status);
+
+ // Idempotent: a second press is not an error.
+ Assert.True((await harness.Service.AbandonAsync(StoreId, record.Id, Ct)).Success);
+
+ var quote = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+
+ Assert.True(quote.Success, quote.Error);
+ Assert.NotEqual(record.Id, quote.Record!.Id);
+ }
+
+ /// An exit belonging to another store is invisible, not merely unmodifiable.
+ [Fact]
+ public async Task Another_store_s_exit_cannot_be_built_or_abandoned()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ harness.Explorer(Utxo(10_000));
+ var victim = harness.Seed(storeId: "store-2");
+
+ Assert.Equal(
+ SparkUnilateralExitService.ExitNotFound,
+ (await harness.Service.BuildAsync(StoreId, victim.Id, Ct)).Error);
+ Assert.Equal(
+ SparkUnilateralExitService.ExitNotFound,
+ (await harness.Service.AbandonAsync(StoreId, victim.Id, Ct)).Error);
+
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, harness.Records.Records[victim.Id].Status);
+ }
+
+ ///
+ /// Abandoning cannot land on a row a build filled with signed transactions while it was being read.
+ ///
+ ///
+ /// Two browser tabs, or two servers behind one database. The abandon read the row while it was awaiting
+ /// funding; by the time it writes, a build has put the exit's only copy of its signed transactions on it. The
+ /// compare-and-set is what makes that write miss rather than clobber.
+ ///
+ [Fact]
+ public async Task An_abandon_that_read_a_stale_row_does_not_clobber_a_build()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+
+ UnilateralExitOpResult? abandoned = null;
+ harness.Sdk.WhenExitQuoted = () =>
+ {
+ // Inside the build, so the abandon provably reads the row before the build has written to it. The
+ // single-flight gate refuses it, which is the first line of defence.
+ harness.Sdk.WhenExitQuoted = null;
+ abandoned = harness.Service.AbandonAsync(StoreId, record.Id, CancellationToken.None)
+ .GetAwaiter().GetResult();
+ };
+
+ Assert.True((await harness.Service.BuildAsync(StoreId, record.Id, Ct)).Success);
+
+ Assert.False(abandoned!.Success);
+ Assert.Equal(SparkUnilateralExitService.OperationInFlight, abandoned.Error);
+
+ var stored = harness.Records.Records[record.Id];
+ Assert.Equal(UnilateralExitStatus.Built, stored.Status);
+ Assert.NotNull(stored.TransactionsJson);
+
+ // And the durable half: an abandon carrying the row as it looked before the build is refused outright.
+ var stale = InMemoryUnilateralExitRecordStore.Copy(record);
+ stale.Status = UnilateralExitStatus.Abandoned;
+ Assert.False(await harness.Records.UpdateAsync(
+ stale, UnilateralExitStatus.AwaitingFunding, Ct));
+ Assert.NotNull(harness.Records.Records[record.Id].TransactionsJson);
+ }
+
+ #endregion
+
+ #region Finishing
+
+ ///
+ /// Marking a built exit completed frees the store, and it is the right verb for a finished exit.
+ ///
+ ///
+ /// Nothing here watches the chain, so this is the operator's statement rather than an observation. Without it
+ /// abandoning would be the only way a finished exit ever left the active state — and telling a merchant to
+ /// "abandon" the exit that recovered their money is a lie the page would have to keep telling.
+ ///
+ [Fact]
+ public async Task Marking_a_built_exit_completed_frees_the_store()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(status: UnilateralExitStatus.Built);
+
+ var completed = await harness.Service.MarkCompletedAsync(StoreId, record.Id, Ct);
+
+ Assert.True(completed.Success, completed.Error);
+ Assert.Equal(UnilateralExitStatus.Completed, harness.Records.Records[record.Id].Status);
+
+ // Idempotent, like abandoning: a second press is not an error.
+ Assert.True((await harness.Service.MarkCompletedAsync(StoreId, record.Id, Ct)).Success);
+
+ var quote = await harness.Service.QuoteAsync(StoreId, 10, Destination, Ct);
+ Assert.True(quote.Success, quote.Error);
+ }
+
+ ///
+ /// An exit that was never built, or was abandoned, cannot be declared finished.
+ ///
+ ///
+ /// The abandoned branch is what makes abandoning's own "already finished" refusal reachable: the two terminal
+ /// states each refuse the other's verb, so a stale form post cannot rewrite which one happened.
+ ///
+ [Fact]
+ public async Task Only_a_built_exit_can_be_marked_completed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+
+ var waiting = harness.Seed(id: "exit-waiting");
+ var notBuilt = await harness.Service.MarkCompletedAsync(StoreId, waiting.Id, Ct);
+ Assert.False(notBuilt.Success);
+ Assert.Contains("not been built", notBuilt.Error);
+ Assert.Equal(UnilateralExitStatus.AwaitingFunding, harness.Records.Records[waiting.Id].Status);
+
+ Assert.True((await harness.Service.AbandonAsync(StoreId, waiting.Id, Ct)).Success);
+ var abandoned = await harness.Service.MarkCompletedAsync(StoreId, waiting.Id, Ct);
+ Assert.False(abandoned.Success);
+ Assert.Contains("abandoned", abandoned.Error);
+
+ Assert.Equal(
+ SparkUnilateralExitService.ExitNotFound,
+ (await harness.Service.MarkCompletedAsync(StoreId, "exit-nowhere", Ct)).Error);
+ }
+
+ /// A completed exit refuses to be abandoned, which is the branch that used to be unreachable.
+ [Fact]
+ public async Task A_completed_exit_cannot_be_abandoned()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ var record = harness.Seed(status: UnilateralExitStatus.Built);
+ Assert.True((await harness.Service.MarkCompletedAsync(StoreId, record.Id, Ct)).Success);
+
+ var abandoned = await harness.Service.AbandonAsync(StoreId, record.Id, Ct);
+
+ Assert.False(abandoned.Success);
+ Assert.Contains("already recorded as finished", abandoned.Error);
+ Assert.Equal(UnilateralExitStatus.Completed, harness.Records.Records[record.Id].Status);
+ }
+
+ #endregion
+
+ #region The explorer setting
+
+ ///
+ /// The explorer override is settable from the page that reports it missing, and validated here.
+ ///
+ ///
+ /// It is the feature's one piece of real configuration, and off mainnet nothing works without it — so it
+ /// belongs on the page that refuses for want of it rather than three clicks away. The validation lives in the
+ /// service because the controller holds no policy.
+ ///
+ [Fact]
+ public async Task The_explorer_url_is_stored_validated_and_clearable()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true, esploraApiUrl: null);
+
+ var set = await harness.Service.SetExplorerUrlAsync(StoreId, " https://explorer.test/api/ ", Ct);
+
+ Assert.True(set.Success, set.Error);
+ // Trimmed, and the trailing slash removed so the path built onto it is never doubled.
+ Assert.Equal(
+ "https://explorer.test/api",
+ harness.Settings.Settings[StoreId]!.UnilateralExit.EsploraApiUrl);
+
+ var writes = harness.Settings.Writes.Count;
+
+ // No write for a press that changes nothing: storing settings tears down and reconnects the wallet.
+ Assert.True((await harness.Service.SetExplorerUrlAsync(StoreId, "https://explorer.test/api", Ct)).Success);
+ Assert.Equal(writes, harness.Settings.Writes.Count);
+
+ // Blank clears it, which is the only way back to the mainnet default.
+ Assert.True((await harness.Service.SetExplorerUrlAsync(StoreId, " ", Ct)).Success);
+ Assert.Null(harness.Settings.Settings[StoreId]!.UnilateralExit.EsploraApiUrl);
+ }
+
+ [Theory]
+ [InlineData("not a url")]
+ [InlineData("ftp://explorer.example/api")]
+ [InlineData("/relative/api")]
+ public async Task An_unusable_explorer_url_is_refused_before_it_is_stored(string candidate)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true, esploraApiUrl: "https://good.test/api");
+
+ var result = await harness.Service.SetExplorerUrlAsync(StoreId, candidate, Ct);
+
+ Assert.False(result.Success);
+ Assert.NotNull(result.Error);
+ // The working value is untouched: a rejected edit must not take the store off its explorer.
+ Assert.Equal("https://good.test/api", harness.Settings.Settings[StoreId]!.UnilateralExit.EsploraApiUrl);
+ Assert.Empty(harness.Settings.Writes);
+ }
+
+ [Fact]
+ public async Task Setting_the_explorer_url_on_an_unconfigured_store_is_refused()
+ {
+ using var harness = Harness.Create();
+
+ var result = await harness.Service.SetExplorerUrlAsync(StoreId, "https://explorer.test/api", Ct);
+
+ Assert.Equal(SparkUnilateralExitService.NotConfigured, result.Error);
+ Assert.Empty(harness.Settings.Writes);
+ }
+
+ #endregion
+
+ #region The page read
+
+ /// The read reports the wallet, the balance, the active exit and the history in one pass.
+ [Fact]
+ public async Task The_page_read_reports_the_wallet_the_balance_and_the_history()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Sdk.BalanceSats = 640_000;
+ harness.Seed(id: "exit-old", status: UnilateralExitStatus.Abandoned, minutesOld: 60);
+ var active = harness.Seed(id: "exit-live", leafIds: ["leaf-a", "leaf-b"], fundingKeyIndex: 3);
+ harness.Explorer(Utxo(4_200));
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.True(page.WalletRunning);
+ Assert.True(page.DisclosureAcknowledged);
+ Assert.Equal(640_000, page.BalanceSats);
+ Assert.Equal(active.Id, page.ActiveRecord?.Id);
+ // Terminal rows only: the active exit has its own panel, and listing it twice invites an operator to read
+ // the history row as a second exit.
+ Assert.Equal(["exit-old"], page.History.Select(r => r.Id).ToArray());
+ Assert.Equal(4_200, page.FundingReceivedSat);
+ Assert.Equal(4_200, page.FundingLargestOutputSat);
+ Assert.Equal(2, page.LeafCount);
+ // The path an operator needs to sweep the funding address by hand if they abandon this exit.
+ Assert.Equal("m/84'/1'/4607060'/0/3", page.FundingKeyPath);
+ Assert.Null(page.Transactions);
+ Assert.False(page.TransactionsUnreadable);
+ }
+
+ ///
+ /// The page is told both what the funding address holds and what its largest single output holds.
+ ///
+ ///
+ /// The sum is the misleading figure: an exit is funded from one output, so an address holding twice the
+ /// requirement across two outputs funds nothing. A page reporting only the total would tell an operator they
+ /// were done while every build refused.
+ ///
+ [Fact]
+ public async Task The_page_read_separates_the_funding_total_from_its_largest_output()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed(singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(2_500, vout: 0), Utxo(3_000, vout: 1));
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Equal(5_500, page.FundingReceivedSat);
+ Assert.Equal(3_000, page.FundingLargestOutputSat);
+ }
+
+ ///
+ /// Rendering the page derives no key, so nothing unprotects the merchant's seed on a page load.
+ ///
+ ///
+ /// Measuring an address takes no key at all — only a build needs one — and a read path that unprotected the
+ /// seed on every load would be paying a real risk for nothing. Asserted through a store whose seed cannot be
+ /// decrypted: the funding figures still come back, which they could not if the read derived anything.
+ ///
+ [Fact]
+ public async Task The_page_read_reports_funding_without_the_store_s_seed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed(fundingKeyIndex: 2);
+ harness.Explorer(Utxo(4_200));
+ harness.Settings.Settings[StoreId]!.ProtectedMnemonic = "not something this keyring can unprotect";
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Equal(4_200, page.FundingReceivedSat);
+ Assert.Equal(4_200, page.FundingLargestOutputSat);
+ // The path is arithmetic on the record's index, not a derivation, so it survives too.
+ Assert.Equal("m/84'/1'/4607060'/0/2", page.FundingKeyPath);
+ }
+
+ ///
+ /// A built exit's transactions come back typed, deserialised by the one layer that writes them.
+ ///
+ ///
+ /// The page is the only reader, and it reads them from here rather than from the column: one owner for the
+ /// write format means the controller and the view cannot disagree with the service about what is in it.
+ ///
+ [Fact]
+ public async Task The_page_read_hands_back_a_built_exit_s_transactions_typed()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.WithLeaves(("leaf-a", 500_000));
+ var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200);
+ harness.Explorer(Utxo(10_000));
+ Assert.True((await harness.Service.BuildAsync(StoreId, record.Id, Ct)).Success);
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.False(page.TransactionsUnreadable);
+ Assert.NotNull(page.Transactions);
+ Assert.Equal(SparkExitTxKind.Fanout, page.Transactions[0].Kind);
+ Assert.Equal(SparkExitTxKind.Sweep, page.Transactions[^1].Kind);
+ Assert.True(page.Transactions.Any(tx => tx.RequiresPackageBroadcast));
+ // A built exit is not waiting on funding, so nothing asks the explorer about it any more.
+ Assert.Null(page.FundingReceivedSat);
+ }
+
+ ///
+ /// A transaction column that cannot be read back becomes an explanation, never an exception.
+ ///
+ ///
+ /// Both shapes matter. Malformed JSON is the obvious one; the subtle one is JSON that parses into a record
+ /// with null members, because System.Text.Json applies no null checks to a positional record's
+ /// parameters — so [{}] yields a transaction with a null txid and a null dependency list, which the
+ /// page would render as broadcast instructions.
+ ///
+ [Theory]
+ [InlineData("not json at all")]
+ [InlineData("[]")]
+ [InlineData("[{}]")]
+ [InlineData("""[{"Txid":"aa","TxHex":"0200","DependsOn":[],"Kind":99,"Status":0}]""")]
+ public async Task An_unreadable_transaction_column_is_reported_rather_than_thrown(string stored)
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ var record = harness.Seed(status: UnilateralExitStatus.Built);
+ harness.Records.Records[record.Id].TransactionsJson = stored;
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.True(page.TransactionsUnreadable);
+ Assert.Null(page.Transactions);
+ // And the record itself is still on the page, so the operator can abandon it.
+ Assert.Equal(record.Id, page.ActiveRecord?.Id);
+ }
+
+ ///
+ /// A wallet that is down still renders the page, and a balance that cannot be read is not an exception.
+ ///
+ ///
+ /// The history and the active exit are precisely what an operator came to look at when the wallet is in
+ /// trouble, so nothing about reading the balance may take the page down with it.
+ ///
+ [Fact]
+ public async Task The_page_read_survives_a_wallet_that_is_down()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed(id: "exit-live");
+ harness.Sdk.FailWith = new InvalidOperationException("the wallet is wedged");
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.True(page.WalletRunning);
+ Assert.Equal(0, page.BalanceSats);
+ Assert.Equal("exit-live", page.ActiveRecord?.Id);
+
+ harness.Runtime.Clients.Remove(StoreId);
+ var stopped = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.False(stopped.WalletRunning);
+ Assert.Equal("exit-live", stopped.ActiveRecord?.Id);
+ }
+
+ /// A built exit does not keep asking the explorer about funding it has already committed.
+ [Fact]
+ public async Task A_built_exit_reports_no_funding_balance()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed(status: UnilateralExitStatus.Built);
+ harness.Explorer(Utxo(4_200));
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Null(page.FundingReceivedSat);
+ Assert.Equal(0, harness.ExplorerRequests);
+ }
+
+ #endregion
+
+ #region The funding key
+
+ ///
+ /// The funding key is derived at the plugin's own hardened account, and it is a pinned path.
+ ///
+ ///
+ /// Both halves of this are load-bearing. The account index has to stay away from BIP84 account 0,
+ /// because on a store provisioned from the BTCPay hot wallet that account is the merchant's own
+ /// wallet — their coin selection could spend the funding UTXO out from under a half-broadcast exit. And the
+ /// path has to stay put, because funding already sent to an address derived from the old path is only
+ /// recoverable by hand.
+ ///
+ [Fact]
+ public void The_funding_key_is_derived_at_the_plugin_s_own_hardened_account()
+ {
+ Assert.Equal("84'/1'/4607060'/0/0", SparkExitFundingKey.KeyPathFor(Network.RegTest, 0).ToString());
+ Assert.Equal("84'/0'/4607060'/0/0", SparkExitFundingKey.KeyPathFor(Network.Main, 0).ToString());
+ Assert.Equal("84'/1'/4607060'/0/7", SparkExitFundingKey.KeyPathFor(Network.RegTest, 7).ToString());
+
+ // BIP32 reserves the top bit of a child number for hardening, so an index above int.MaxValue is not an
+ // address index at all — refused rather than wrapped into a key for a different address.
+ Assert.Throws(
+ () => SparkExitFundingKey.KeyPathFor(Network.RegTest, (uint)int.MaxValue + 1));
+
+ Assert.True(SparkExitFundingKey.TryDerive(Mnemonic, Network.RegTest, 0, out var regtest, out var error));
+ Assert.Null(error);
+ using (regtest)
+ {
+ Assert.Equal(FundingAddress, regtest!.Address);
+ // Compressed, and the public half only: 33 bytes as hex.
+ Assert.Equal(66, regtest.PubkeyHex.Length);
+ Assert.Equal(32, regtest.Secret.Length);
+ }
+
+ // Mainnet derives a different key as well as a different address: the coin type is part of the path.
+ Assert.True(SparkExitFundingKey.TryDerive(Mnemonic, Network.Main, 0, out var mainnet, out _));
+ using (mainnet)
+ {
+ Assert.StartsWith("bc1q", mainnet!.Address);
+ Assert.NotEqual(regtest!.PubkeyHex, mainnet.PubkeyHex);
+ }
+
+ // And a different address index is a different key, which is the whole reason one exit's leftovers
+ // cannot land on the next exit's funding address.
+ Assert.True(SparkExitFundingKey.TryDerive(Mnemonic, Network.RegTest, 1, out var second, out _));
+ using (second)
+ {
+ Assert.NotEqual(FundingAddress, second!.Address);
+ }
+ }
+
+ /// Disposing the key zeroes it, and using it afterwards is an error rather than a silent zero key.
+ [Fact]
+ public void Disposing_the_funding_key_zeroes_the_secret()
+ {
+ Assert.True(SparkExitFundingKey.TryDerive(Mnemonic, Network.RegTest, 0, out var key, out _));
+ var secret = key!.Secret;
+ Assert.Contains(secret, b => b != 0);
+
+ key.Dispose();
+ key.Dispose();
+
+ Assert.All(secret, b => Assert.Equal(0, b));
+ // A signer built over 32 zero bytes fails a long way from the mistake, so this throws instead.
+ Assert.Throws(() => key.Secret);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not a mnemonic at all")]
+ [InlineData("abandon abandon abandon")]
+ public void An_unusable_phrase_is_a_refusal_rather_than_an_exception(string? phrase)
+ {
+ Assert.False(SparkExitFundingKey.TryDerive(phrase, Network.RegTest, 0, out var key, out var error));
+ Assert.Null(key);
+ Assert.False(string.IsNullOrWhiteSpace(error));
+ }
+
+ #endregion
+
+ #region The explorer's own rules
+
+ /// The default explorer applies on mainnet only; elsewhere the override is required.
+ [Fact]
+ public void The_default_explorer_is_mainnet_only()
+ {
+ Assert.True(SparkExitFundingExplorer.TryResolveBaseUrl(
+ new UnilateralExitSettings(), mainnet: true, out var mainnet, out _));
+ Assert.Equal(SparkExitFundingExplorer.MainnetDefaultApiUrl, mainnet);
+
+ Assert.False(SparkExitFundingExplorer.TryResolveBaseUrl(
+ new UnilateralExitSettings(), mainnet: false, out _, out var error));
+ Assert.Contains("esplora API URL", error);
+
+ // A configured override wins on either network, and its trailing slash is not doubled into the path.
+ Assert.True(SparkExitFundingExplorer.TryResolveBaseUrl(
+ new UnilateralExitSettings { EsploraApiUrl = "https://explorer.example/api/" },
+ mainnet: false,
+ out var configured,
+ out _));
+ Assert.Equal("https://explorer.example/api", configured);
+ }
+
+ [Theory]
+ [InlineData("not a url")]
+ [InlineData("ftp://explorer.example/api")]
+ [InlineData("/relative/api")]
+ public void An_unusable_explorer_url_is_refused(string configured)
+ {
+ Assert.False(SparkExitFundingExplorer.TryResolveBaseUrl(
+ new UnilateralExitSettings { EsploraApiUrl = configured }, mainnet: true, out _, out var error));
+ Assert.NotNull(error);
+ }
+
+ ///
+ /// An output whose txid is not 32 bytes of hex is dropped rather than passed to the SDK.
+ ///
+ ///
+ /// Dropped and not refused, so one junk row from a third party cannot hide the real funding output — the same
+ /// discipline the sweep labeller applies to a provider-supplied txid.
+ ///
+ [Fact]
+ public async Task An_output_with_a_malformed_txid_is_dropped()
+ {
+ using var harness = Harness.Create();
+ harness.Configure(acknowledged: true);
+ harness.Seed();
+ harness.ExplorerBody(
+ """[{"txid":"../../etc/passwd","vout":0,"value":9000,"status":{"confirmed":true}},"""
+ + Utxo(4_200, vout: 3)
+ + "]");
+
+ var page = await harness.Service.ReadAsync(StoreId, Ct);
+
+ Assert.Equal(4_200, page.FundingReceivedSat);
+ }
+
+ #endregion
+
+ /// One entry of an esplora /address/{address}/utxo response.
+ private static string Utxo(long valueSat, uint vout = 0, bool confirmed = true) =>
+ string.Format(
+ CultureInfo.InvariantCulture,
+ """{{"txid":"{0}","vout":{1},"value":{2},"status":{{"confirmed":{3}}}}}""",
+ FundingTxid,
+ vout,
+ valueSat,
+ confirmed ? "true" : "false");
+
+ ///
+ /// The service under test with every collaborator faked, and the feature gate held for the test's duration.
+ ///
+ ///
+ /// The settings store is built without a runtime on purpose: modelling the SDK reconnect a settings
+ /// write causes would replace the fake wallet mid-test, and what these tests need to observe is the
+ /// acknowledgement landing in storage rather than the reconnect that follows it. The reconnect itself is
+ /// covered where it matters, in the Stable Balance tests.
+ ///
+ private sealed class Harness : IDisposable
+ {
+ private const string Variable = "FLINT_EXPERIMENTAL_UNILATERAL_EXIT";
+
+ private readonly string? _previous;
+ private readonly ExplorerHandler _handler = new();
+
+ private Harness(bool featureEnabled)
+ {
+ _previous = Environment.GetEnvironmentVariable(Variable);
+ Environment.SetEnvironmentVariable(Variable, featureEnabled ? "1" : null);
+
+ Protector = new SparkMnemonicProtector(new EphemeralDataProtectionProvider());
+ Runtime.Clients[StoreId] = Sdk;
+
+ Service = new SparkUnilateralExitService(
+ Settings,
+ Runtime,
+ Records,
+ Protector,
+ new SparkExitFundingExplorer(
+ new ExplorerClientFactory(_handler),
+ NullLogger.Instance),
+ Network.RegTest,
+ new StubTimeProvider(Now),
+ NullLogger.Instance);
+ }
+
+ public static Harness Create(bool featureEnabled = true) => new(featureEnabled);
+
+ public DateTimeOffset Now { get; } = new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero);
+
+ public FakeSparkSdkClient Sdk { get; } = new();
+
+ public FakeSparkStoreRuntime Runtime { get; } = new();
+
+ public FakeSparkStoreSettingsStore Settings { get; } = new();
+
+ public InMemoryUnilateralExitRecordStore Records { get; } = new();
+
+ public SparkMnemonicProtector Protector { get; }
+
+ /// How many lookups actually reached the explorer.
+ public int ExplorerRequests => _handler.Requests;
+
+ public SparkUnilateralExitService Service { get; }
+
+ /// Gives the store a Spark configuration, and optionally a stored acknowledgement.
+ public void Configure(
+ bool acknowledged = false,
+ bool walletRunning = true,
+ string? esploraApiUrl = "http://explorer.test/api")
+ {
+ Settings.Settings[StoreId] = new SparkSettings
+ {
+ ProtectedMnemonic = Protector.Protect(Mnemonic),
+ SeedSource = SeedSource.Imported,
+ UnilateralExit = new UnilateralExitSettings
+ {
+ DisclosureAcknowledged = acknowledged,
+ EsploraApiUrl = esploraApiUrl
+ }
+ };
+
+ if (!walletRunning)
+ Runtime.Clients.Remove(StoreId);
+ }
+
+ /// The leaves an automatic selection would find.
+ public void WithLeaves(params (string LeafId, long ValueSat)[] leaves)
+ {
+ Sdk.ExitLeaves.Clear();
+ foreach (var (leafId, valueSat) in leaves)
+ Sdk.ExitLeaves.Add(new SparkExitLeaf(leafId, valueSat));
+ }
+
+ /// Answers explorer lookups with these outputs.
+ public void Explorer(params string[] utxos) =>
+ _handler.Body = "[" + string.Join(",", utxos) + "]";
+
+ /// Answers explorer lookups with a body of the test's own.
+ public void ExplorerBody(string body) => _handler.Body = body;
+
+ /// An explorer that refuses to connect: an air-gapped or misconfigured host.
+ public void ExplorerOffline() => _handler.Offline = true;
+
+ /// An explorer that answers, badly.
+ public void ExplorerFails(HttpStatusCode status = HttpStatusCode.ServiceUnavailable) =>
+ _handler.Status = status;
+
+ ///
+ /// An exit already in storage, as a quote would have left it.
+ ///
+ ///
+ /// The insert is asserted rather than ignored. The store refuses a second active exit for one store — the
+ /// production unique index, reproduced in the fake — so a test that seeded two of them would otherwise
+ /// carry on against a row that was never stored.
+ ///
+ public UnilateralExitRecord Seed(
+ string? id = null,
+ string? storeId = null,
+ UnilateralExitStatus status = UnilateralExitStatus.AwaitingFunding,
+ string[]? leafIds = null,
+ long singleUtxoFundingSat = 4_200,
+ string? fundingAddress = null,
+ string? lastError = null,
+ int minutesOld = 0,
+ long fundingKeyIndex = 0)
+ {
+ var record = new UnilateralExitRecord
+ {
+ Id = id ?? "exit-" + Guid.NewGuid().ToString("N"),
+ StoreId = storeId ?? StoreId,
+ Status = status,
+ CreatedUtc = Now.AddMinutes(-minutesOld),
+ UpdatedUtc = Now.AddMinutes(-minutesOld),
+ DestinationAddress = Destination,
+ FeeRateSatPerVbyte = 10,
+ LeafIdsJson = JsonSerializer.Serialize(leafIds ?? ["leaf-a"]),
+ RecoverableValueSat = 500_000,
+ TotalFeeSat = 3_000,
+ SingleUtxoFundingSat = singleUtxoFundingSat,
+ FundingAddress = fundingAddress
+ ?? FundingAddressFor(fundingKeyIndex),
+ FundingKeyIndex = fundingKeyIndex,
+ LastError = lastError
+ };
+
+ var created = Records.CreateAsync(record, CancellationToken.None).GetAwaiter().GetResult();
+ Assert.True(created, "the seeded exit was refused by the store");
+ return record;
+ }
+
+ ///
+ /// The funding address derives at one index on regtest.
+ ///
+ ///
+ /// Derived rather than pinned for indexes other than zero, which is the one index worth pinning (see
+ /// ): a seeded row has to agree with what the build re-derives, or every test
+ /// at a non-zero index would refuse on the address-mismatch guard instead of testing what it meant to.
+ ///
+ private static string FundingAddressFor(long index)
+ {
+ if (index == 0)
+ return FundingAddress;
+
+ Assert.True(SparkExitFundingKey.TryDerive(
+ Mnemonic, Network.RegTest, (uint)index, out var key, out _));
+ using (key)
+ {
+ return key!.Address;
+ }
+ }
+
+ public void Dispose() => Environment.SetEnvironmentVariable(Variable, _previous);
+
+ ///
+ /// An esplora endpoint a test can change after the service has been built.
+ ///
+ ///
+ /// Its own handler rather than the suite's StubHttpMessageHandler, whose response is fixed at
+ /// construction: the funding on the address is arranged per test, and often after the record it belongs to
+ /// exists.
+ ///
+ private sealed class ExplorerHandler : HttpMessageHandler
+ {
+ public string Body { get; set; } = "[]";
+
+ public HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
+
+ public bool Offline { get; set; }
+
+ public int Requests { get; private set; }
+
+ protected override Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ Requests++;
+
+ if (Offline)
+ {
+ return Task.FromException(
+ new HttpRequestException("no route to host"));
+ }
+
+ return Task.FromResult(new HttpResponseMessage(Status)
+ {
+ Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json")
+ });
+ }
+ }
+
+ private sealed class ExplorerClientFactory : IHttpClientFactory
+ {
+ private readonly HttpMessageHandler _handler;
+
+ public ExplorerClientFactory(HttpMessageHandler handler) => _handler = handler;
+
+ public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
+ }
+ }
+}
+
+///
+/// Serialises everything that toggles the unilateral-exit feature gate.
+///
+///
+/// The gate is an environment variable, and an environment variable is shared by every test in the process. A
+/// class that flips it while another reads it produces a failure that reproduces about once a week, which is the
+/// worst kind — so the collection is not parallelised against the rest of the suite.
+///
+[CollectionDefinition(Name, DisableParallelization = true)]
+public sealed class UnilateralExitTestCollection
+{
+ public const string Name = "UnilateralExitFeatureGate";
+}
+
+///
+/// The record-store contract, asserted against the in-memory fake the service tests run on.
+///
+///
+/// Lives beside those tests because the fake arrived with them. The point is stated in
+/// UnilateralExitRecordStoreContractTests: the service tests are worthless if this store and the
+/// production one disagree, and the disagreement that would matter most — an update that quietly rewrites the
+/// destination or the leaf set an operator funded against — is one no service test could see.
+///
+public class InMemoryUnilateralExitRecordStoreTests : UnilateralExitRecordStoreContractTests
+{
+ protected override Task CreateStoreAsync() =>
+ Task.FromResult(new InMemoryUnilateralExitRecordStore());
+}
diff --git a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs
new file mode 100644
index 0000000..519be8f
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs
@@ -0,0 +1,147 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Sdk;
+
+namespace BTCPayServer.Plugins.Flint.Services;
+
+///
+/// The unilateral-exit flow: quote which leaves are worth forcing on-chain, collect operator-supplied
+/// funding, and build the signed transaction set the operator broadcasts by hand.
+///
+///
+///
+/// This service holds every guard; the controller renders and redirects and decides nothing. All five
+/// methods behave as if the feature does not exist when
+/// is false, because the controller's gate is a courtesy, not the enforcement.
+///
+///
+/// Nothing here broadcasts. Phase 0 ends at a signed, ordered transaction set persisted on the
+/// ; the operator broadcasts each package themselves (fan-out first
+/// and alone, then tree-node packages in depends_on order waiting for confirmation between,
+/// refunds after their CSV timelocks, sweep last and alone). The SDK in use (0.22.0) still needs the
+/// operators reachable to prepare an exit; exit-from-local-state arrives with a later SDK bump.
+///
+///
+/// One exit at a time per store: a store with an active record (awaiting funding or built) refuses a
+/// new quote, because two exits would compete for the same leaves and the same funding UTXOs.
+///
+///
+public interface ISparkUnilateralExitService
+{
+ ///
+ /// Everything the exit page shows: settings state, the active record, history, and — while a
+ /// record is awaiting funding — what the funding address holds according to the explorer.
+ ///
+ Task ReadAsync(string storeId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Records that the operator has read and accepted the disclosure. Server-side state, not a UI
+ /// checkbox: refuses until this has been stored, the same pattern Stable
+ /// Balance uses.
+ ///
+ Task AcknowledgeDisclosureAsync(string storeId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Quotes an auto-selected exit and persists it as the store's active record, awaiting funding.
+ ///
+ ///
+ /// Guards: feature gate, wallet running, disclosure acknowledged, fee rate in [1, 500], destination
+ /// parses for the store's network, no other active record. An empty auto-selection (nothing worth
+ /// exiting at this rate) and a quote whose fee exceeds what it recovers are refusals, not errors.
+ /// The quoted leaf ids are persisted on the record so the build re-quotes those exact leaves.
+ ///
+ Task QuoteAsync(
+ string storeId,
+ long feeRateSatPerVbyte,
+ string destinationAddress,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Discovers the funding UTXOs on the record's funding address, re-quotes the record's own leaves,
+ /// and builds the signed transaction set onto the record.
+ ///
+ ///
+ /// Refuses when the discovered funding falls short of the quoted requirement, and re-checks
+ /// recoverable-exceeds-fee against the fresh quote before signing (the persisted quote is display
+ /// state, not the guard). Safe to call again after a failure: the SDK resumes from chain state and
+ /// a shortfall or spent-funding conflict lands on the record as .
+ ///
+ Task BuildAsync(string storeId, string recordId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks the record abandoned so the store can start over. Abandoning moves no money and cancels
+ /// nothing on-chain: transactions already broadcast stay valid, which the page says out loud.
+ ///
+ Task AbandonAsync(string storeId, string recordId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks a built record completed: the operator confirms they have broadcast the set and the sweep
+ /// has confirmed. The plugin cannot verify this itself in Phase 0 (nothing watches the chain), so
+ /// this is the operator's statement of fact — but without it, Abandon would be the only way a
+ /// finished exit ever leaves the active state, and abandoning is the wrong verb for success.
+ ///
+ Task MarkCompletedAsync(string storeId, string recordId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Stores the explorer override used for funding discovery. Null or blank clears it. This is the
+ /// feature's one piece of real configuration, so it is settable from the page that reports it
+ /// missing; validation (absolute http/https URL) is here, not in the controller.
+ ///
+ Task SetExplorerUrlAsync(string storeId, string? esploraApiUrl, CancellationToken cancellationToken = default);
+}
+
+///
+/// What happened when a write was attempted. is merchant-facing copy, set
+/// exactly when is false; is the record the
+/// attempt created or updated, when one exists either way.
+///
+public sealed record UnilateralExitOpResult(bool Success, string? Error, UnilateralExitRecord? Record);
+
+///
+/// Everything the exit page renders in one read. The service is the only reader and writer of the
+/// record's JSON columns: the page receives typed data here and no other layer deserializes the blob,
+/// so the write format has exactly one owner.
+///
+/// False hides every form: nothing can be quoted without a live wallet.
+/// Gates the quote form behind the disclosure form.
+/// The wallet balance, for context next to the quote form.
+/// The store's one in-flight exit (awaiting funding or built), or null.
+/// Newest-first terminal records (completed/abandoned), bounded, with the
+/// heavy JSON columns left unloaded — the history table renders five scalar columns and must not drag
+/// every signed transaction set out of the database to do it.
+///
+/// Total confirmed satoshis the explorer reports on the active record's funding address, or null when
+/// there is no active record awaiting funding, no explorer is configured for this network, or the
+/// explorer was unreachable — the page distinguishes "unknown" from zero.
+///
+///
+/// The largest single confirmed output on the funding address. This, not ,
+/// is the number the build's single-output rule is judged by, and the page compares this one against
+/// the requirement so split funding never reads as complete.
+///
+/// Leaves pinned by the active record's quote, or null without one.
+///
+/// The BIP32 path of the active record's funding key, for hand recovery of funding sats from the seed.
+///
+///
+/// The active record's built transaction set, deserialized and sanity-checked by the service, or null
+/// when there is no built set or the column is unreadable (see ).
+///
+///
+/// True when a built record's transaction column could not be read back as a well-formed set — malformed
+/// syntax or structurally null members. The page renders that as an explanation, never as an exception.
+///
+public sealed record UnilateralExitPageData(
+ bool WalletRunning,
+ bool DisclosureAcknowledged,
+ long BalanceSats,
+ UnilateralExitRecord? ActiveRecord,
+ IReadOnlyList History,
+ long? FundingReceivedSat,
+ long? FundingLargestOutputSat,
+ int? LeafCount,
+ string? FundingKeyPath,
+ IReadOnlyList? Transactions,
+ bool TransactionsUnreadable);
diff --git a/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs
new file mode 100644
index 0000000..3401955
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs
@@ -0,0 +1,439 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Flint.Sdk;
+using Microsoft.Extensions.Logging;
+
+namespace BTCPayServer.Plugins.Flint.Services;
+
+///
+/// What an explorer said about a funding address: the confirmed outputs on it, or why nothing could be said.
+///
+///
+/// "None found" and "could not look" are different answers and must never collapse into one. An operator
+/// who has already sent the funding sats reads "0 sat on the funding address" as "my transaction has not
+/// confirmed yet" and waits — possibly for hours, on a confirmation that already happened, because the explorer
+/// URL was wrong. So a failure carries and a null , and
+/// refuses instead of reporting a shortfall it did not
+/// measure. keeps the same distinction for the read path.
+///
+///
+/// The confirmed outputs, possibly empty. Null exactly when the lookup failed.
+///
+/// Merchant-facing reason the lookup failed, set exactly when is null.
+public sealed record SparkExitFundingLookup(IReadOnlyList? Utxos, string? Error)
+{
+ public static SparkExitFundingLookup Found(IReadOnlyList utxos) => new(utxos, null);
+
+ public static SparkExitFundingLookup Failed(string error) => new(null, error);
+}
+
+///
+/// What a funding address holds, for a caller that only needs the numbers.
+///
+///
+///
+/// The read path's answer, and it exists so that rendering the exit page needs no key material: measuring an
+/// address takes no public key, whereas every carries one because the SDK
+/// needs it to build a witness. Only a build derives the funding key.
+///
+///
+/// Both figures, because the sum is the misleading one. An exit is funded by a single output, so an
+/// address holding twice the requirement across two outputs funds nothing — and a page reporting only the total
+/// would tell an operator they are done while every build refuses.
+/// is the number the requirement is judged against.
+///
+///
+/// Confirmed satoshi on the address, or null when the explorer could not be read.
+///
+/// The largest single confirmed output, zero when there is none, and null on the same terms as
+/// .
+///
+/// Merchant-facing reason the lookup failed, set exactly when the two figures are null.
+public sealed record SparkExitFundingBalance(long? TotalSat, long? LargestOutputSat, string? Error)
+{
+ public static SparkExitFundingBalance Unknown(string error) => new(null, null, error);
+}
+
+///
+/// Finds the confirmed on-chain outputs sitting on a unilateral exit's funding address.
+///
+///
+///
+/// Nothing else can answer this question. The funding output is an ordinary UTXO on a key derived outside
+/// Spark's tree (), so the SDK has never heard of it; and the address is in none
+/// of the store's derivation schemes, so NBXplorer has not either. That leaves a block explorer, which is why
+/// there is an override for operators who would rather not tell mempool.space which address funds their exit.
+///
+///
+/// Confirmed only, and that is an economic decision rather than caution. Every transaction in the exit is
+/// a CPFP child of this output. Spending an unconfirmed funding UTXO would make the whole exit a package
+/// descending from an unconfirmed parent, and mempool policy limits how deep and how large such a package may be
+/// — an exit tree is dozens of transactions across many levels, so the packages would be rejected as
+/// non-relayable somewhere in the middle, after the operator had already broadcast the fan-out and paid for it.
+/// Waiting one confirmation costs ten minutes; discovering the limit halfway through costs the fan-out fee and a
+/// re-quote.
+///
+///
+/// This explorer is trusted with nothing. A wrong or hostile one can make a build refuse (it reports no
+/// UTXO) or fail at signing time (it reports one that does not exist); it cannot move a satoshi anywhere, because
+/// the destination lives in the transactions the SDK signs and the funding key never leaves the plugin. Outputs
+/// whose txid is not 32 bytes of hex are dropped rather than passed on, on the same principle as the sweep
+/// labeller's: a malformed identifier from a third party should not become an argument to the SDK.
+///
+///
+public sealed class SparkExitFundingExplorer
+{
+ ///
+ /// The named this uses, registered in SparkPlugin with its own timeout.
+ ///
+ ///
+ /// Named rather than default so the short timeout below applies to this endpoint alone, and so the factory
+ /// owns socket lifetime — the same arrangement uses for its one endpoint.
+ ///
+ public const string HttpClientName = "spark-exit-funding-explorer";
+
+ ///
+ /// The default explorer, used on mainnet when the store has configured no override.
+ ///
+ ///
+ /// A third party, and named in the settings copy as one. It is the same API surface as any esplora instance,
+ /// so an operator who objects points at their own.
+ ///
+ public const string MainnetDefaultApiUrl = "https://mempool.space/api";
+
+ ///
+ /// The whole lookup, including connect, response and parse.
+ ///
+ ///
+ /// Short because a request thread is waiting on it: this runs while the exit page renders and while a Build
+ /// press is being answered. A slow explorer must degrade to "unknown" quickly rather than hold the page.
+ ///
+ public static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10);
+
+ ///
+ /// The most of a response that will be read before it is abandoned.
+ ///
+ ///
+ /// A UTXO list for one address is a few kilobytes. The ceiling exists for the response that never ends, which
+ /// also bounds — belt and braces, because the timeout bounds the wait and this
+ /// bounds the memory.
+ ///
+ public const long MaxResponseBytes = 4L * 1024 * 1024;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ // esplora spells everything lower case; being insensitive also survives an instance that does not.
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly ILogger _logger;
+
+ public SparkExitFundingExplorer(
+ IHttpClientFactory httpClientFactory,
+ ILogger logger)
+ {
+ _httpClientFactory = httpClientFactory;
+ _logger = logger;
+ }
+
+ ///
+ /// The explorer base URL to use for a store, or the reason there is none.
+ ///
+ ///
+ /// Off mainnet a missing override is a refusal, not a fallback. mempool.space has no regtest, so
+ /// pointing at it there would answer every lookup with "no outputs found" — indistinguishable from an
+ /// unconfirmed funding transaction, and an operator would wait on a confirmation that already happened. The
+ /// honest answer names the setting.
+ ///
+ public static bool TryResolveBaseUrl(
+ UnilateralExitSettings? settings,
+ bool mainnet,
+ out string? baseUrl,
+ out string? error)
+ {
+ var configured = settings?.EsploraApiUrl;
+
+ if (!string.IsNullOrWhiteSpace(configured))
+ {
+ if (!TryNormaliseApiUrl(configured, out var normalised, out var fragment))
+ {
+ baseUrl = null;
+ error = "The block-explorer URL configured for exit funding cannot be used: " + fragment
+ + ". Correct it in this store's exit settings.";
+ return false;
+ }
+
+ baseUrl = normalised;
+ error = null;
+ return true;
+ }
+
+ if (!mainnet)
+ {
+ baseUrl = null;
+ error = "No block explorer is configured for exit funding, and there is no default off mainnet: "
+ + "mempool.space has no regtest. Set the esplora API URL on this page to an explorer that "
+ + "can see this chain.";
+ return false;
+ }
+
+ baseUrl = MainnetDefaultApiUrl;
+ error = null;
+ return true;
+ }
+
+ ///
+ /// Canonicalises an operator-supplied explorer base URL, or says why it is unusable.
+ ///
+ ///
+ /// One owner for the rule, called both when the setting is stored (so a typo is refused while the operator is
+ /// looking at the form) and when it is used (so a value that arrived from a backup, an API call or a hand
+ /// edit is refused rather than concatenated into a request URL). The trailing slash is trimmed here so the
+ /// path built below never doubles it.
+ ///
+ ///
+ /// A merchant-facing sentence fragment naming what is wrong, set exactly when this returns false.
+ ///
+ public static bool TryNormaliseApiUrl(string? candidate, out string? normalised, out string? error)
+ {
+ normalised = null;
+
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ error = "no address was supplied";
+ return false;
+ }
+
+ var trimmed = candidate.Trim().TrimEnd('/');
+ if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) ||
+ (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+ {
+ error = "it is not an absolute http:// or https:// address";
+ return false;
+ }
+
+ normalised = trimmed;
+ error = null;
+ return true;
+ }
+
+ ///
+ /// Lists the confirmed outputs on , tagged with the public key that spends them.
+ ///
+ ///
+ /// The compressed public key for the address, copied onto every output. The explorer does not report it — a
+ /// P2WPKH script carries only the hash — and the SDK needs it to build the witness it will ask the signer to
+ /// sign, so it comes from the derivation rather than from the wire.
+ ///
+ ///
+ ///
+ /// The build path. Requiring the public key here rather than making it optional is deliberate: an output
+ /// tagged with the wrong key, or with none, fails deep inside the SDK's witness construction, so the only
+ /// caller that can produce one of these is the one that has derived the key. Everything that merely wants to
+ /// know what the address holds uses and derives nothing.
+ ///
+ ///
+ /// The order of the returned list carries no meaning. Which output to spend is the service's policy — it
+ /// takes the smallest one that covers the requirement, so an over-funded address keeps its larger output
+ /// intact — and sorting here would look like that decision had already been made.
+ ///
+ ///
+ /// Never throws for a network or parse failure; those come back as
+ /// . Cancellation does propagate, because a cancelled request is
+ /// the caller going away rather than an explorer being unreachable.
+ ///
+ ///
+ public async Task ListConfirmedAsync(
+ string baseUrl,
+ string address,
+ string pubkeyHex,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(pubkeyHex);
+
+ var (outputs, error) = await FetchConfirmedAsync(baseUrl, address, cancellationToken)
+ .ConfigureAwait(false);
+
+ return outputs is null
+ ? SparkExitFundingLookup.Failed(error!)
+ : SparkExitFundingLookup.Found(outputs
+ .Select(output => new SparkExitFundingUtxo(
+ output.Txid, output.Vout, output.ValueSat, pubkeyHex))
+ .ToList());
+ }
+
+ ///
+ /// What holds in confirmed satoshi, in total and in its largest single output.
+ ///
+ ///
+ /// The read path, and the reason it exists is that rendering the exit page must not derive the store's
+ /// funding key: measuring an address needs no key at all, and a page that unprotected the merchant's seed on
+ /// every load would be paying a real risk for nothing. Failures come back as
+ /// — never as zero, which an operator would read as "my
+ /// funding has not confirmed yet".
+ ///
+ public async Task MeasureConfirmedAsync(
+ string baseUrl,
+ string address,
+ CancellationToken cancellationToken = default)
+ {
+ var (outputs, error) = await FetchConfirmedAsync(baseUrl, address, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (outputs is null)
+ return SparkExitFundingBalance.Unknown(error!);
+
+ return new SparkExitFundingBalance(
+ outputs.Sum(output => output.ValueSat),
+ outputs.Count == 0 ? 0 : outputs.Max(output => output.ValueSat),
+ null);
+ }
+
+ ///
+ /// The one HTTP round trip both public methods share: the address's confirmed outputs, untagged.
+ ///
+ ///
+ /// The outputs, possibly empty, and a null error; or a null list and a merchant-facing reason. Exactly one of
+ /// the two is set, which is what keeps "none found" and "could not look" from collapsing into one answer.
+ ///
+ private async Task<(IReadOnlyList? Outputs, string? Error)> FetchConfirmedAsync(
+ string baseUrl,
+ string address,
+ CancellationToken cancellationToken)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(baseUrl);
+ ArgumentException.ThrowIfNullOrWhiteSpace(address);
+
+ var url = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}/address/{1}/utxo",
+ baseUrl.TrimEnd('/'),
+ Uri.EscapeDataString(address));
+
+ try
+ {
+ using var deadline = new CancellationTokenSource(RequestTimeout);
+ using var bounded = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, deadline.Token);
+
+ var client = _httpClientFactory.CreateClient(HttpClientName);
+
+ using var response = await client
+ .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, bounded.Token)
+ .ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ await using var body = await response.Content
+ .ReadAsStreamAsync(bounded.Token)
+ .ConfigureAwait(false);
+
+ var payload = await ReadBoundedAsync(body, bounded.Token).ConfigureAwait(false);
+ var reported = JsonSerializer.Deserialize>(payload, JsonOptions);
+
+ var outputs = new List();
+ foreach (var candidate in reported ?? [])
+ {
+ if (candidate.Status?.Confirmed is not true)
+ continue;
+
+ // See the class remarks: a third party's identifier is validated before it can become an argument
+ // to the SDK. Dropped rather than refused, so one junk row cannot hide the real funding output.
+ var txid = SparkLightningClient.NormaliseHash(candidate.Txid);
+ if (txid is null || candidate.Value <= 0)
+ {
+ _logger.LogWarning(
+ "Exit funding lookup for {Address} skipped an unusable output reported by {Url}",
+ address, baseUrl);
+ continue;
+ }
+
+ outputs.Add(new ConfirmedOutput(txid, candidate.Vout, candidate.Value));
+ }
+
+ return (outputs, null);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // Warning rather than error: nothing is broken by this, and the two surfaces above both have a
+ // sensible "unknown" to render.
+ _logger.LogWarning(ex,
+ "Could not read the exit funding address {Address} from {Url}", address, baseUrl);
+
+ return (null,
+ "The block explorer could not be read, so it is not known what is on the exit funding address "
+ + $"yet: {Describe(ex)}");
+ }
+ }
+
+ /// One confirmed output as the explorer described it, before any key is attached to it.
+ private readonly record struct ConfirmedOutput(string Txid, uint Vout, long ValueSat);
+
+ ///
+ /// Reads a response body, refusing one that goes past .
+ ///
+ ///
+ /// Applied while reading rather than off Content-Length, because a chunked response does not have one
+ /// — and a response with no declared length is exactly the case worth defending against.
+ ///
+ private static async Task ReadBoundedAsync(Stream body, CancellationToken cancellationToken)
+ {
+ using var collected = new MemoryStream();
+ var chunk = new byte[8 * 1024];
+
+ while (true)
+ {
+ var read = await body.ReadAsync(chunk, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ break;
+
+ if (collected.Length + read > MaxResponseBytes)
+ {
+ throw new InvalidOperationException(
+ "the explorer's answer was larger than this plugin will read");
+ }
+
+ collected.Write(chunk, 0, read);
+ }
+
+ return collected.ToArray();
+ }
+
+ private static string Describe(Exception exception) => exception switch
+ {
+ OperationCanceledException => "the explorer did not answer in time",
+ HttpRequestException http => http.StatusCode is { } status
+ ? string.Format(CultureInfo.InvariantCulture, "the explorer answered {0:D}", (int)status)
+ : "the explorer could not be reached",
+ JsonException => "the explorer's answer was not in the expected format",
+ _ => exception.Message
+ };
+
+ /// One entry of esplora's GET /address/{address}/utxo.
+ ///
+ /// Only the four fields the plugin uses are bound. esplora also reports the block height and time of the
+ /// confirming block, and neither decides anything here: one confirmation is the bar, and it is
+ /// that states it.
+ ///
+ private sealed record EsploraUtxo(
+ [property: JsonPropertyName("txid")] string? Txid,
+ [property: JsonPropertyName("vout")] uint Vout,
+ [property: JsonPropertyName("value")] long Value,
+ [property: JsonPropertyName("status")] EsploraUtxoStatus? Status);
+
+ private sealed record EsploraUtxoStatus(
+ [property: JsonPropertyName("confirmed")] bool Confirmed);
+}
diff --git a/BTCPayServer.Plugins.Flint/Services/SparkExitFundingKey.cs b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingKey.cs
new file mode 100644
index 0000000..44cd1b1
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingKey.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Globalization;
+using NBitcoin;
+
+namespace BTCPayServer.Plugins.Flint.Services;
+
+///
+/// The on-chain key that pays a unilateral exit's fees: its address, its public half, and — for as long as one
+/// build needs it — its private half.
+///
+///
+///
+/// Why the plugin holds an on-chain key at all. The statechain's tree transactions are pre-signed and
+/// cannot pay their own fees, so every one of them is bumped by CPFP from an ordinary confirmed UTXO the
+/// operator supplies. Somebody has to sign that child, and the only seed the plugin has is the store's Spark
+/// mnemonic — so the funding key is derived from it, at m/84'/{coin}'/4607060'/0/{index}. See
+/// for why the account index is deliberately absurd: on a
+/// store provisioned from the same seed is BTCPay's own hot wallet, and
+/// deriving at BIP84 account 0 would put these addresses inside the merchant's tracked wallet where their own
+/// coin selection could spend the funding UTXO out from under a half-broadcast exit.
+///
+///
+/// One address per exit, which is what the index is for. A fixed address would collect the change of
+/// every exit a store ever quotes, so a new exit would find another exit's leftovers sitting on the address it
+/// just told the operator to fund — and a leftover large enough to satisfy the new requirement makes a build
+/// succeed against money nobody just sent. The index comes from
+/// and is allocated once, at quote time.
+///
+///
+/// Disposable, and the reason is . The 32 bytes are the spending authority for the
+/// funding output; they are needed only for the duration of one
+/// call and are zeroed on dispose. Quoting needs no secret
+/// at all — only — and reading the page needs neither, only
+/// , so nothing but a build ever derives. That is why this type caches
+/// nothing per store: BIP39 seed derivation is PBKDF2 with 2048 iterations and measures around a millisecond,
+/// which is affordable on the one path that needs it and not a reason to hold key material in a long-lived
+/// field.
+///
+///
+/// The mnemonic itself is never held here. It arrives already decrypted from
+/// , is consumed inside , and nothing on
+/// this type can echo it back. Neither the phrase nor is ever logged, and neither appears
+/// in — the record carries the address and nothing else.
+///
+///
+public sealed class SparkExitFundingKey : IDisposable
+{
+ private readonly byte[] _secret;
+ private bool _disposed;
+
+ private SparkExitFundingKey(string address, string pubkeyHex, byte[] secret)
+ {
+ Address = address;
+ PubkeyHex = pubkeyHex;
+ _secret = secret;
+ }
+
+ ///
+ /// The native-SegWit (P2WPKH) address the operator sends funding to, for the network it was derived on.
+ ///
+ ///
+ /// P2WPKH and not P2TR because it is the one CpfpFundingKind the plugin asks the SDK for. The funding
+ /// input's script type has to match what the quote was taken with, or the witness the SDK builds does not
+ /// verify — so this is not a preference, and it is not a choice a merchant is offered.
+ ///
+ public string Address { get; }
+
+ /// The compressed public key, hex, as wants it.
+ public string PubkeyHex { get; }
+
+ ///
+ /// The private key, 32 bytes, for the one-shot CPFP signer.
+ ///
+ ///
+ /// The key has been disposed and the bytes zeroed. Thrown rather than handing back a zeroed array, because a
+ /// signer built over 32 zero bytes fails somewhere far away from the mistake.
+ ///
+ public byte[] Secret
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return _secret;
+ }
+ }
+
+ ///
+ /// Derives the funding key for a store, or explains why it could not be derived.
+ ///
+ ///
+ /// The store's decrypted BIP39 phrase. Null or unusable is the expected failure — a server whose
+ /// data-protection keyring was replaced can no longer unprotect it — and it is reported rather than thrown,
+ /// because the operator's fix is to re-enter their seed and not to read a stack trace.
+ ///
+ ///
+ /// The network the address is rendered for, which also picks the BIP44 coin type: 0 on mainnet, 1 everywhere
+ /// else. Both halves matter — a mainnet-shaped address on regtest is unusable, and a coin type that differed
+ /// between the quote and the build would derive a different key for the same exit.
+ ///
+ ///
+ /// The exit's own address index, from . Must be the
+ /// index the record was created with: derive at another one and the plugin holds no key for the output the
+ /// operator funded.
+ ///
+ ///
+ /// No BIP39 passphrase, matching how the mnemonic is handed to the SDK: an empty passphrase is the only value
+ /// this plugin ever uses, and inventing one here would make the funding address unrecoverable by hand from
+ /// the seed the merchant backed up. That recoverability is the point — an operator who abandons an exit with
+ /// sats still on the funding address must be able to sweep them with any BIP84 wallet, given the path.
+ ///
+ public static bool TryDerive(
+ string? mnemonic,
+ Network network,
+ uint index,
+ out SparkExitFundingKey? key,
+ out string? error)
+ {
+ ArgumentNullException.ThrowIfNull(network);
+
+ key = null;
+
+ if (string.IsNullOrWhiteSpace(mnemonic))
+ {
+ error = "This store's Spark seed could not be read, so the exit funding address cannot be derived. "
+ + "Re-enter the store's recovery phrase on the Flint setup page.";
+ return false;
+ }
+
+ ExtKey derived;
+ try
+ {
+ var phrase = new Mnemonic(mnemonic.Trim());
+ // Hardened at the account level, so the derived child cannot be reached from any xpub the seed's
+ // other consumers publish.
+ derived = phrase.DeriveExtKey().Derive(KeyPathFor(network, index));
+ }
+ catch (Exception)
+ {
+ // Swallowed whole, deliberately: NBitcoin's wording for a bad phrase names word lists and checksums,
+ // and the only actionable half of it is that the stored seed is not usable.
+ error = "This store's Spark seed is not a usable recovery phrase, so the exit funding address cannot "
+ + "be derived.";
+ return false;
+ }
+
+ var privateKey = derived.PrivateKey;
+ key = new SparkExitFundingKey(
+ privateKey.PubKey.GetAddress(ScriptPubKeyType.Segwit, network).ToString(),
+ privateKey.PubKey.ToHex(),
+ privateKey.ToBytes());
+
+ error = null;
+ return true;
+ }
+
+ ///
+ /// m/84'/{coin}'/4607060'/0/{index} for a network and an exit's address index.
+ ///
+ ///
+ /// Exposed, and shown on the exit page, because it is what makes funding left on the address recoverable
+ /// outside this plugin: an operator who abandons an exit with sats still on its funding address sweeps them
+ /// with any BIP84 wallet, given the seed and this path. Cheap enough to call on a read path — it derives
+ /// nothing.
+ ///
+ ///
+ /// is past . BIP32 reserves the top bit of a child number
+ /// for hardening, so an index above that is not an unhardened address index at all — and silently wrapping it
+ /// into one would derive a key for a different address than the path printed on the page.
+ ///
+ public static KeyPath KeyPathFor(Network network, uint index)
+ {
+ ArgumentNullException.ThrowIfNull(network);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(index, (uint)int.MaxValue, nameof(index));
+
+ // 0 on mainnet, 1 on everything else, as BIP44 registers them. Regtest is the only other network the SDK
+ // supports, and it shares testnet's coin type.
+ var coin = network == Network.Main ? 0 : 1;
+
+ return KeyPath.Parse(string.Format(
+ CultureInfo.InvariantCulture,
+ "84'/{0}'/{1}'/0/{2}",
+ coin,
+ Constants.UnilateralExitFundingAccount,
+ index));
+ }
+
+ /// Zeroes the private key. Safe to call twice.
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ Array.Clear(_secret);
+ }
+}
diff --git a/BTCPayServer.Plugins.Flint/Services/SparkStoreProvisioner.cs b/BTCPayServer.Plugins.Flint/Services/SparkStoreProvisioner.cs
index 40e6470..245ad4d 100644
--- a/BTCPayServer.Plugins.Flint/Services/SparkStoreProvisioner.cs
+++ b/BTCPayServer.Plugins.Flint/Services/SparkStoreProvisioner.cs
@@ -209,7 +209,20 @@ public async Task ProvisionAsync(
// asked.
StableBalance = existing?.StableBalance is { } previousStable
? previousStable.Clone()
- : new StableBalanceSettings()
+ : new StableBalanceSettings(),
+
+ // Carried across like the rest, and both halves earn it. The explorer override is a piece of
+ // infrastructure configuration that has nothing to do with which seed the store runs on, and losing
+ // it on a regtest server means the next exit refuses with "no block explorer is configured". The
+ // acknowledgement is the operator's statement that they have read what a unilateral exit costs them,
+ // which a seed change does not un-read.
+ //
+ // Note what this does not carry: any exit already recorded. Those rows name a funding address
+ // derived from the *old* seed, and the build re-derives and refuses when the two disagree — which is
+ // the honest outcome, because the plugin can no longer sign for what was sent there.
+ UnilateralExit = existing?.UnilateralExit is { } previousExit
+ ? previousExit.Clone()
+ : new UnilateralExitSettings()
};
SparkSettingsApplied applied;
diff --git a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs
new file mode 100644
index 0000000..e39c79e
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs
@@ -0,0 +1,1311 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Sdk;
+using Microsoft.Extensions.Logging;
+using NBitcoin;
+
+namespace BTCPayServer.Plugins.Flint.Services;
+
+///
+/// The one path by which a store quotes, funds and builds a unilateral exit.
+///
+///
+///
+/// Every guard is here. The controller renders, redirects and decides nothing; the feature gate is
+/// re-checked in every method because a controller's 404 is a courtesy and not the enforcement, and the
+/// disclosure is re-read from storage before each write because a checkbox enforced in a view is enforced
+/// nowhere — the same arrangement uses for a comparably irreversible
+/// action.
+///
+///
+/// Nothing here broadcasts, and that is what makes the failure modes benign. Every refusal and every
+/// exception below has moved no coins: the SDK builds and signs and stops. What can be lost is the signed
+/// transaction set itself, which exists only in — so a
+/// failure to persist a successful build is logged as an error with the txids, is reported as a failure even
+/// though the SDK call succeeded, and is never skipped because the operator's browser went away.
+///
+///
+/// One exit operation at a time per store, held in exactly as
+/// holds a sweep pass. Two of these must never overlap for a reason stronger than
+/// tidiness: they would race the same funding UTXO, which the SDK reports as
+/// after one of them has already committed. The gate also
+/// covers the two settings writes, because storing settings tears down and reconnects the store's SDK handle —
+/// pulling it out from under a build in flight. It is an in-process gate, so the durable half of the same rule
+/// lives in the database: see and the compare-and-set on
+/// .
+///
+///
+/// This service is the only reader and writer of the record's JSON columns. Leaf ids, funding UTXOs and
+/// transactions are written with default settings — exact property names, numeric
+/// enum values — and read back with the same options, so the write format has exactly one owner. The enum orders
+/// in and are documented as fixed for this reason.
+/// Callers get typed data out of and never see the blobs.
+///
+///
+public sealed class SparkUnilateralExitService : ISparkUnilateralExitService
+{
+ /// How many past exits the page lists. Small: this is a last-resort tool, not a ledger.
+ internal const int HistoryLimit = 20;
+
+ internal const long MinFeeRateSatPerVbyte = 1;
+
+ ///
+ /// The highest fee rate a quote may be taken at.
+ ///
+ ///
+ /// A backstop against a typo, not an opinion about the fee market. The rate multiplies across every
+ /// transaction in the tree — dozens of them — so a mistyped rate is not one overpriced transaction, it is an
+ /// overpriced exit and a funding requirement to match.
+ ///
+ internal const long MaxFeeRateSatPerVbyte = 500;
+
+ internal const string FeatureDisabled =
+ "Unilateral exit is not enabled on this server.";
+
+ internal const string NotConfigured =
+ "Flint is not set up for this store.";
+
+ internal const string WalletNotRunning =
+ "This store's Spark wallet is not running, so nothing can be quoted or built.";
+
+ internal const string DisclosureRequired =
+ "Confirm that you have read what a unilateral exit involves. It is a last resort: the transactions are "
+ + "broadcast by hand, the funds are locked behind timelocks measured in days, and the on-chain fees are "
+ + "paid up front from a separate funding address.";
+
+ internal const string OperationInFlight =
+ "Another unilateral-exit operation for this store is already running. Try again in a moment.";
+
+ internal const string NothingWorthExiting =
+ "There is nothing worth exiting at this fee rate. Spark selected no leaves, which means every one of them "
+ + "would cost more to force on-chain than it holds. A lower fee rate may select some.";
+
+ internal const string ExitNotFound =
+ "This store has no exit with that reference.";
+
+ internal const string ExitAlreadyInProgress =
+ "This store already has an exit in progress. Finish or abandon it before quoting another: two exits would "
+ + "compete for the same leaves, and only one of the two sets of transactions could ever be broadcast.";
+
+ ///
+ /// A compare-and-set lost its race: the row moved between being read and being written.
+ ///
+ ///
+ /// Reachable from two browser tabs, or from a second server behind the same database. Worth its own message
+ /// rather than a generic failure, because nothing is broken and reloading shows the operator what happened.
+ ///
+ internal const string ExitChangedUnderneath =
+ "This exit changed while that was being done, so nothing was applied. Reload the page to see its current "
+ + "state.";
+
+ ///
+ /// The build's pre-check and its veto share this: the leaf set the operator funded for is gone.
+ ///
+ internal const string LeavesGone =
+ "The leaves this exit was quoted for are no longer in this wallet, so there is nothing left to force "
+ + "on-chain. Abandon this exit and quote a new one.";
+
+ internal const string BuiltButNotSaved =
+ "The exit was built, but its signed transactions could not be saved, so they are lost. Nothing was "
+ + "broadcast. Try again.";
+
+ ///
+ /// A funding key index that is not a BIP32 address index. Only reachable from a hand-edited row.
+ ///
+ internal const string FundingIndexUnusable =
+ "This exit's funding key index is outside the range a key can be derived at, so its funding address "
+ + "cannot be reproduced. Abandon it and quote a new one.";
+
+ private static readonly JsonSerializerOptions JsonOptions = new();
+
+ ///
+ /// What the page data looks like when there is no feature, or no Flint on this store.
+ ///
+ ///
+ /// Spelled out once rather than at each return, because a positional record of eleven members is exactly the
+ /// shape where two "empty" literals drift apart from one another.
+ ///
+ private static UnilateralExitPageData AbsentFeature =>
+ new(false, false, 0, null, [], null, null, null, null, null, false);
+
+ private readonly ISparkStoreSettingsStore _settingsStore;
+ private readonly ISparkStoreRuntime _runtime;
+ private readonly IUnilateralExitRecordStore _records;
+ private readonly SparkMnemonicProtector _mnemonicProtector;
+ private readonly SparkExitFundingExplorer _explorer;
+ private readonly Network _network;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ ///
+ /// Stores with an exit operation in progress. Membership is the lock, and there is deliberately no queueing:
+ /// see the class remarks.
+ ///
+ private readonly ConcurrentDictionary _running = new();
+
+ ///
+ /// The chain this server runs on, resolved once at registration because it is fixed for the life of the
+ /// process. Null coalesces to mainnet rules, matching : on a chain the
+ /// SDK does not support no wallet starts at all, so nothing here is reachable, and failing DI would hide the
+ /// clearer error.
+ ///
+ public SparkUnilateralExitService(
+ ISparkStoreSettingsStore settingsStore,
+ ISparkStoreRuntime runtime,
+ IUnilateralExitRecordStore records,
+ SparkMnemonicProtector mnemonicProtector,
+ SparkExitFundingExplorer explorer,
+ Network? network,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _settingsStore = settingsStore;
+ _runtime = runtime;
+ _records = records;
+ _mnemonicProtector = mnemonicProtector;
+ _explorer = explorer;
+ _network = network ?? Network.Main;
+ _timeProvider = timeProvider;
+ _logger = logger;
+ }
+
+ private bool Mainnet => _network == Network.Main;
+
+ ///
+ public async Task ReadAsync(
+ string storeId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ // Feature-off reads as "there is no such feature": no wallet, no history, nothing acknowledged. The page
+ // is unreachable anyway, and a read that reported a store's real acknowledgement through a disabled
+ // feature would be a surface the gate does not cover.
+ if (!Constants.UnilateralExitEnabled)
+ return AbsentFeature;
+
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ if (settings is null)
+ return AbsentFeature;
+
+ var exitSettings = settings.UnilateralExit ?? new UnilateralExitSettings();
+
+ var sdk = await _runtime.GetSdkClientAsync(storeId).ConfigureAwait(false);
+ var balance = 0L;
+ if (sdk is not null)
+ {
+ try
+ {
+ // Cached read: this is a request thread, and the balance is context next to the quote form rather
+ // than an input to any decision — the quote itself walks the wallet's own tree.
+ var info = await sdk.GetInfoAsync(ensureSynced: false, cancellationToken).ConfigureAwait(false);
+ balance = info.BalanceSats;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Store {StoreId}: could not read its Spark balance for the exit page ({Reason})",
+ storeId, SparkErrors.Describe(ex));
+ }
+ }
+
+ var active = await _records.GetActiveForStoreAsync(storeId, cancellationToken).ConfigureAwait(false);
+ var history = await _records
+ .ListTerminalForStoreAsync(storeId, HistoryLimit, cancellationToken)
+ .ConfigureAwait(false);
+
+ var funding = SparkExitFundingBalance.Unknown("no exit is awaiting funding");
+ if (active is { Status: UnilateralExitStatus.AwaitingFunding })
+ {
+ // Only while funding is what the operator is waiting on. Once the exit is built the UTXO has been
+ // committed to signed transactions, and reporting a balance for it would invite a top-up that helps
+ // nothing.
+ funding = await ReadFundingAsync(active, exitSettings, cancellationToken).ConfigureAwait(false);
+ }
+
+ int? leafCount = null;
+ string? keyPath = null;
+ if (active is not null)
+ {
+ leafCount = DeserializeLeafIds(active).Count;
+ keyPath = DescribeKeyPath(active);
+ }
+
+ // Read back and checked here rather than anywhere above: the page renders these, and a malformed column
+ // has to become an explanation on the page instead of an exception in a view.
+ var readable = TryReadTransactions(active, out var transactions);
+
+ return new UnilateralExitPageData(
+ sdk is not null,
+ exitSettings.DisclosureAcknowledged,
+ balance,
+ active,
+ history,
+ funding.TotalSat,
+ funding.LargestOutputSat,
+ leafCount,
+ keyPath,
+ transactions,
+ !readable);
+ }
+
+ ///
+ public async Task AcknowledgeDisclosureAsync(
+ string storeId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ if (settings is null)
+ return Refuse(NotConfigured);
+
+ if ((settings.UnilateralExit ?? new UnilateralExitSettings()).DisclosureAcknowledged)
+ return new UnilateralExitOpResult(true, null, null);
+
+ return await SaveExitSettingsAsync(
+ storeId,
+ settings,
+ exit => exit.DisclosureAcknowledged = true,
+ "the unilateral-exit disclosure acknowledgement",
+ "The acknowledgement")
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ ///
+ public async Task SetExplorerUrlAsync(
+ string storeId,
+ string? esploraApiUrl,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ // Blank clears it, which is the only way back to the mainnet default once an override has been set.
+ string? normalised = null;
+ if (!string.IsNullOrWhiteSpace(esploraApiUrl))
+ {
+ if (!SparkExitFundingExplorer.TryNormaliseApiUrl(esploraApiUrl, out normalised, out var fragment))
+ {
+ return Refuse(
+ "That block-explorer address cannot be used: " + fragment
+ + ". Give the base URL of an esplora-compatible API, for example "
+ + SparkExitFundingExplorer.MainnetDefaultApiUrl + ", or leave it empty to use the default.");
+ }
+ }
+
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ if (settings is null)
+ return Refuse(NotConfigured);
+
+ var current = (settings.UnilateralExit ?? new UnilateralExitSettings()).EsploraApiUrl;
+ if (string.Equals(current, normalised, StringComparison.Ordinal))
+ {
+ // No write for a press that changes nothing: storing settings tears down and reconnects the
+ // store's wallet, which is not a thing to do to confirm the status quo.
+ return new UnilateralExitOpResult(true, null, null);
+ }
+
+ return await SaveExitSettingsAsync(
+ storeId,
+ settings,
+ exit => exit.EsploraApiUrl = normalised,
+ "the unilateral-exit block-explorer URL",
+ "The block-explorer address")
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ ///
+ public async Task QuoteAsync(
+ string storeId,
+ long feeRateSatPerVbyte,
+ string destinationAddress,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ if (settings is null)
+ return Refuse(NotConfigured);
+
+ var exitSettings = settings.UnilateralExit ?? new UnilateralExitSettings();
+
+ // The disclosure first, before the input checks: an operator who has not read what this costs them should
+ // be told that rather than that their fee rate is out of range.
+ if (!exitSettings.DisclosureAcknowledged)
+ return Refuse(DisclosureRequired);
+
+ if (feeRateSatPerVbyte is < MinFeeRateSatPerVbyte or > MaxFeeRateSatPerVbyte)
+ {
+ return Refuse(string.Format(
+ CultureInfo.InvariantCulture,
+ "The fee rate has to be between {0:N0} and {1:N0} sat/vB. Every transaction in the exit is built "
+ + "at this one rate, so it also decides which leaves are worth exiting at all.",
+ MinFeeRateSatPerVbyte,
+ MaxFeeRateSatPerVbyte));
+ }
+
+ if (!TryParseDestination(destinationAddress, out var destination, out var destinationError))
+ return Refuse(destinationError);
+
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var active = await _records.GetActiveForStoreAsync(storeId, cancellationToken).ConfigureAwait(false);
+ if (active is not null)
+ return new UnilateralExitOpResult(false, ExitAlreadyInProgress, active);
+
+ var sdk = await _runtime.GetSdkClientAsync(storeId).ConfigureAwait(false);
+ if (sdk is null)
+ return Refuse(WalletNotRunning);
+
+ // Allocated before the derivation, because the index is what the derivation is for. One address per
+ // exit: see UnilateralExitRecord.FundingKeyIndex for why reusing one is a trap rather than a saving.
+ var nextIndex = await _records.NextFundingKeyIndexAsync(storeId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!TryFundingKeyIndex(nextIndex, out var keyIndex))
+ {
+ _logger.LogError(
+ "Store {StoreId}: its next exit funding key index ({Index}) is outside the BIP32 range",
+ storeId, nextIndex);
+ return Refuse(FundingIndexUnusable);
+ }
+
+ // Derived before the quote, and disposed immediately. The address is all a quote needs — the private
+ // half is a build's business — and deriving first means a store whose seed cannot be decrypted is
+ // refused without an SDK round trip.
+ string fundingAddress;
+ using (var derived = DeriveFundingKey(settings, keyIndex, out var keyError))
+ {
+ if (derived is null)
+ return Refuse(keyError!);
+ fundingAddress = derived.Address;
+ }
+
+ SparkExitQuote quote;
+ try
+ {
+ quote = await sdk
+ .PrepareUnilateralExitAsync(
+ (ulong)feeRateSatPerVbyte, destination, leafIds: null, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Store {StoreId}: could not quote a unilateral exit ({Reason})",
+ storeId, SparkErrors.Describe(ex));
+
+ return Refuse(
+ "Spark could not quote a unilateral exit: " + SparkErrors.Describe(ex)
+ + ". On this SDK version quoting still needs the Spark operators to be reachable.");
+ }
+
+ // Not an error. Auto selection returns nothing whenever no leaf clears the fee rate, and the honest
+ // report is that there is nothing worth doing rather than that something failed.
+ if (quote.IsEmpty)
+ return Refuse(NothingWorthExiting);
+
+ if (quote.RecoverableValueSat <= quote.TotalFeeSat)
+ {
+ return Refuse(string.Format(
+ CultureInfo.InvariantCulture,
+ "This exit would cost more than it recovers: {0:N0} sat of fees against {1:N0} sat of value. "
+ + "Nothing has been recorded. A lower fee rate may change the arithmetic.",
+ quote.TotalFeeSat,
+ quote.RecoverableValueSat));
+ }
+
+ var now = _timeProvider.GetUtcNow();
+ var record = new UnilateralExitRecord
+ {
+ Id = Guid.NewGuid().ToString(),
+ StoreId = storeId,
+ Status = UnilateralExitStatus.AwaitingFunding,
+ CreatedUtc = now,
+ UpdatedUtc = now,
+ DestinationAddress = destination,
+ FeeRateSatPerVbyte = feeRateSatPerVbyte,
+ // Pinned here and never rewritten: the build re-quotes these exact leaves, so the operator cannot
+ // end up funding one exit and building another.
+ LeafIdsJson = JsonSerializer.Serialize(
+ quote.Leaves.Select(leaf => leaf.LeafId).ToArray(), JsonOptions),
+ RecoverableValueSat = quote.RecoverableValueSat,
+ TotalFeeSat = quote.TotalFeeSat,
+ SingleUtxoFundingSat = quote.SingleUtxoFundingSat,
+ FundingAddress = fundingAddress,
+ FundingKeyIndex = keyIndex
+ };
+
+ bool created;
+ try
+ {
+ created = await _records.CreateAsync(record, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // The correct direction to fail in: the operator is never shown a funding address for an exit
+ // that was not recorded, because sats on an unrecorded funding address are only recoverable by
+ // re-deriving the key by hand.
+ _logger.LogError(ex, "Store {StoreId}: could not record a quoted unilateral exit", storeId);
+ return Refuse("The quote could not be recorded, so no funding address has been issued.");
+ }
+
+ if (!created)
+ {
+ // The database's own single-flight guard fired: something inserted an active exit between the
+ // check above and this insert. Reported as the same refusal, with the row that won.
+ var winner = await _records.GetActiveForStoreAsync(storeId, cancellationToken)
+ .ConfigureAwait(false);
+ return new UnilateralExitOpResult(false, ExitAlreadyInProgress, winner);
+ }
+
+ _logger.LogInformation(
+ "Store {StoreId}: quoted a unilateral exit of {Leaves} leaves worth {Recoverable} sat at "
+ + "{FeeRate} sat/vB; it needs {Funding} sat on {FundingAddress}",
+ storeId, quote.Leaves.Count, quote.RecoverableValueSat, feeRateSatPerVbyte,
+ quote.SingleUtxoFundingSat, fundingAddress);
+
+ return new UnilateralExitOpResult(true, null, record);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ ///
+ ///
+ /// The order of the steps below is the fix for a deadlock, not a preference. A quote's funding
+ /// requirement moves with the fee market and with the wallet's tree, and the build's own veto judges the
+ /// funding output against a quote taken inside the SDK call. If the output were selected against the figure
+ /// the record was created with, an operator who topped up to exactly the amount the veto demanded would find
+ /// that top-up ignored — selection would keep picking the smaller output that satisfied the stale figure, and
+ /// the veto would keep refusing it, for ever. So this re-quotes first, persists the fresh requirement so that
+ /// the number on the page is the number that will be judged, and only then selects.
+ ///
+ public async Task BuildAsync(
+ string storeId,
+ string recordId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ if (string.IsNullOrWhiteSpace(recordId))
+ return Refuse(ExitNotFound);
+
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ if (settings is null)
+ return Refuse(NotConfigured);
+
+ var exitSettings = settings.UnilateralExit ?? new UnilateralExitSettings();
+
+ // Re-checked here and not only at quote time. This is the call that produces signed transactions
+ // spending the store's balance, and a gate only one entry point enforces is a gate with a bypass.
+ if (!exitSettings.DisclosureAcknowledged)
+ return Refuse(DisclosureRequired);
+
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var record = await _records.GetAsync(storeId, recordId, cancellationToken).ConfigureAwait(false);
+ if (record is null)
+ return Refuse(ExitNotFound);
+
+ if (!record.IsActive)
+ {
+ return new UnilateralExitOpResult(
+ false,
+ "This exit is finished. Quote a new one rather than building this one again.",
+ record);
+ }
+
+ // The status every compare-and-set below is guarded on: whatever this row was when it was read is
+ // what all of the following decisions are about.
+ var from = record.Status;
+
+ if (record.FeeRateSatPerVbyte is < MinFeeRateSatPerVbyte or > MaxFeeRateSatPerVbyte)
+ {
+ // Only reachable from a hand-edited row: the quote guard bounds this before it is ever stored. It
+ // is checked again because the value is cast to an unsigned rate on the way to the SDK, where a
+ // negative would arrive as an astronomical one.
+ return await FailAsync(
+ record,
+ from,
+ "This exit's fee rate is out of range, so it cannot be built. Abandon it and quote a new "
+ + "one.")
+ .ConfigureAwait(false);
+ }
+
+ var leafIds = DeserializeLeafIds(record);
+ if (leafIds.Count == 0)
+ {
+ return await FailAsync(
+ record,
+ from,
+ "This exit's leaf selection could not be read, so it cannot be rebuilt. Abandon it and "
+ + "quote a new one.")
+ .ConfigureAwait(false);
+ }
+
+ if (!TryFundingKeyIndex(record.FundingKeyIndex, out var keyIndex))
+ return await FailAsync(record, from, FundingIndexUnusable).ConfigureAwait(false);
+
+ var sdk = await _runtime.GetSdkClientAsync(storeId).ConfigureAwait(false);
+ if (sdk is null)
+ return new UnilateralExitOpResult(false, WalletNotRunning, record);
+
+ using var funding = DeriveFundingKey(settings, keyIndex, out var keyError);
+ if (funding is null)
+ return await FailAsync(record, from, keyError!).ConfigureAwait(false);
+
+ // The funding address is stored rather than re-derived for display, so the two can disagree — a
+ // replaced seed, a different network. If they do, the plugin no longer holds the key to the output the
+ // operator funded, and building against a key that cannot sign it would fail deep inside the SDK.
+ if (!string.Equals(funding.Address, record.FundingAddress, StringComparison.Ordinal))
+ {
+ return await FailAsync(
+ record,
+ from,
+ "This store's seed no longer derives the funding address this exit was quoted against, so "
+ + "the plugin cannot spend what was sent there. Abandon this exit and quote a new one; the "
+ + "old funding is recoverable from the original seed at "
+ + $"{DescribeKeyPath(record)}.")
+ .ConfigureAwait(false);
+ }
+
+ if (!SparkExitFundingExplorer.TryResolveBaseUrl(exitSettings, Mainnet, out var baseUrl, out var urlError))
+ return await FailAsync(record, from, urlError!).ConfigureAwait(false);
+
+ // Step one: re-quote the record's own leaves. This happens before funding is even looked at, because
+ // its answer is what the funding has to satisfy — see the remarks on this method.
+ SparkExitQuote fresh;
+ try
+ {
+ fresh = await sdk
+ .PrepareUnilateralExitAsync(
+ (ulong)record.FeeRateSatPerVbyte,
+ record.DestinationAddress,
+ leafIds,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Store {StoreId}: could not re-quote unilateral exit {ExitId} ({Reason})",
+ storeId, record.Id, SparkErrors.Describe(ex));
+
+ return await FailAsync(
+ record,
+ from,
+ "Spark could not re-price this exit: " + SparkErrors.Describe(ex)
+ + ". Nothing was signed, so trying again is safe. On this SDK version pricing an exit "
+ + "still needs the Spark operators to be reachable.")
+ .ConfigureAwait(false);
+ }
+
+ if (fresh.IsEmpty)
+ {
+ return await FailAsync(record, from, LeavesGone).ConfigureAwait(false);
+ }
+
+ if (fresh.RecoverableValueSat <= fresh.TotalFeeSat)
+ {
+ return await FailAsync(record, from, DescribeUneconomic(fresh)).ConfigureAwait(false);
+ }
+
+ // Step two: persist the fresh figures before selecting against them, so the requirement the operator
+ // reads on the page and the requirement the selection uses are the same number. Written even though
+ // the build may still fail — especially then, because a failed attempt's whole value to the operator
+ // is telling them what to fund.
+ ApplyQuote(record, fresh);
+ record.LastError = null;
+ record.UpdatedUtc = _timeProvider.GetUtcNow();
+
+ if (!await _records.UpdateAsync(record, from, cancellationToken).ConfigureAwait(false))
+ return new UnilateralExitOpResult(false, ExitChangedUnderneath, record);
+
+ var required = fresh.SingleUtxoFundingSat;
+
+ var lookup = await _explorer
+ .ListConfirmedAsync(baseUrl!, record.FundingAddress, funding.PubkeyHex, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (lookup.Utxos is not { } confirmed)
+ return await FailAsync(record, from, lookup.Error!).ConfigureAwait(false);
+
+ var largest = confirmed.Count == 0 ? 0 : confirmed.Max(utxo => utxo.ValueSat);
+
+ // One output, not a sum. CPFP funding spends a single P2WPKH outpoint, so two outputs each half the
+ // required size do not fund the exit however encouraging their total looks — which is exactly why the
+ // funding instructions say "as one output" and why the check is not against the balance.
+ //
+ // The smallest output that suffices, so an operator who over-funded (or funded twice) keeps the larger
+ // one intact for a later attempt rather than having it committed to this one.
+ var chosen = confirmed
+ .Where(utxo => utxo.ValueSat >= required)
+ .OrderBy(utxo => utxo.ValueSat)
+ .FirstOrDefault();
+
+ if (chosen is null)
+ {
+ return await FailAsync(
+ record,
+ from,
+ DescribeShortfall(required, record.FundingAddress, confirmed))
+ .ConfigureAwait(false);
+ }
+
+ SparkExitResult result;
+ SparkExitQuote? committed = null;
+ try
+ {
+ result = await sdk
+ .UnilateralExitAsync(
+ (ulong)record.FeeRateSatPerVbyte,
+ record.DestinationAddress,
+ leafIds,
+ [chosen],
+ funding.Secret,
+ second =>
+ {
+ // The veto, against the quote the SDK took inside this call. A unilateral-exit quote
+ // does not expire — it goes stale silently as the wallet's tree moves — so this is
+ // the last point at which the arithmetic is authoritative. It can differ again from
+ // the quote taken moments ago, which is why its own requirement is re-checked and
+ // then persisted by the catch below.
+ committed = second;
+
+ if (second.IsEmpty)
+ return LeavesGone;
+
+ if (second.RecoverableValueSat <= second.TotalFeeSat)
+ return DescribeUneconomic(second);
+
+ if (second.SingleUtxoFundingSat > chosen.ValueSat)
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "This exit now needs {0:N0} sat as a single confirmed output, and the largest "
+ + "one on the funding address holds {1:N0} sat. Send at least the full "
+ + "required amount as a single new output and try again once it confirms.",
+ second.SingleUtxoFundingSat,
+ largest);
+ }
+
+ return null;
+ },
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (SparkExitRefusedException refused)
+ {
+ // The veto above. Already written for a merchant, so it is passed through verbatim — and the
+ // quote it judged by is persisted, so the next attempt selects against the same requirement the
+ // operator was just asked to fund.
+ ApplyQuote(record, committed);
+ return await FailAsync(record, from, refused.Reason).ConfigureAwait(false);
+ }
+ catch (SparkExitFundingShortfallException shortfall)
+ {
+ ApplyQuote(record, committed);
+ return await FailAsync(record, from, shortfall.Message).ConfigureAwait(false);
+ }
+ catch (SparkExitFundingUtxoConflictException conflict)
+ {
+ ApplyQuote(record, committed);
+ return await FailAsync(record, from, conflict.Message).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Store {StoreId}: could not build unilateral exit {ExitId} ({Reason})",
+ storeId, record.Id, SparkErrors.Describe(ex));
+
+ ApplyQuote(record, committed);
+ return await FailAsync(
+ record,
+ from,
+ "Spark could not build this exit: " + SparkErrors.Describe(ex)
+ + ". Nothing was signed or broadcast, so trying again is safe.")
+ .ConfigureAwait(false);
+ }
+
+ // Past this point the request's cancellation token is deliberately never used again. The SDK has
+ // returned signed transactions that exist in this process and nowhere else, and it will not hand them
+ // back without a fresh build and a fresh funding output — so a browser that went away must not be
+ // able to skip the write that saves them.
+ record.Status = UnilateralExitStatus.Built;
+ record.UpdatedUtc = _timeProvider.GetUtcNow();
+ record.RecoverableValueSat = result.RecoverableValueSat;
+ record.TotalFeeSat = result.TotalFeeSat;
+ record.SingleUtxoFundingSat = committed?.SingleUtxoFundingSat ?? record.SingleUtxoFundingSat;
+ record.FundingUtxosJson = JsonSerializer.Serialize(new[] { chosen }, JsonOptions);
+ record.TransactionsJson = JsonSerializer.Serialize(result.Transactions.ToArray(), JsonOptions);
+ // Cleared, not left in place: a build that got further must not show the failed attempt's complaint
+ // next to its own transactions.
+ record.LastError = null;
+
+ bool persisted;
+ try
+ {
+ persisted = await _records
+ .UpdateAsync(record, from, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Wrapped rather than allowed to propagate, so the txids reach the log on the one failure where
+ // the log is the last copy of them.
+ LogUnsavedBuild(storeId, record, result, ex);
+ return new UnilateralExitOpResult(false, BuiltButNotSaved, record);
+ }
+
+ if (!persisted)
+ {
+ LogUnsavedBuild(storeId, record, result, null);
+ return new UnilateralExitOpResult(false, BuiltButNotSaved, record);
+ }
+
+ _logger.LogInformation(
+ "Store {StoreId}: built unilateral exit {ExitId}: {Count} transactions recovering {Recoverable} "
+ + "sat for {Fee} sat in fees. Nothing has been broadcast",
+ storeId, record.Id, result.Transactions.Count, result.RecoverableValueSat, result.TotalFeeSat);
+
+ return new UnilateralExitOpResult(true, null, record);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ ///
+ public async Task MarkCompletedAsync(
+ string storeId,
+ string recordId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ if (string.IsNullOrWhiteSpace(recordId))
+ return Refuse(ExitNotFound);
+
+ // Held for the same reason abandoning is: this frees the store for a new quote, and doing that under a
+ // build in flight would let the next quote start while the first exit is still committing to its output.
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var record = await _records.GetAsync(storeId, recordId, cancellationToken).ConfigureAwait(false);
+ if (record is null)
+ return Refuse(ExitNotFound);
+
+ if (record.Status is UnilateralExitStatus.Completed)
+ return new UnilateralExitOpResult(true, null, record);
+
+ if (record.Status is not UnilateralExitStatus.Built)
+ {
+ return new UnilateralExitOpResult(
+ false,
+ record.Status is UnilateralExitStatus.Abandoned
+ ? "This exit was abandoned, so there is nothing to mark as finished."
+ : "This exit has not been built yet, so there is nothing to mark as finished.",
+ record);
+ }
+
+ record.Status = UnilateralExitStatus.Completed;
+ record.UpdatedUtc = _timeProvider.GetUtcNow();
+
+ if (!await _records
+ .UpdateAsync(record, UnilateralExitStatus.Built, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ return new UnilateralExitOpResult(false, ExitChangedUnderneath, record);
+ }
+
+ _logger.LogInformation(
+ "Store {StoreId}: unilateral exit {ExitId} marked completed by the operator. The plugin watches "
+ + "no chain, so this is their statement rather than an observation",
+ storeId, record.Id);
+
+ return new UnilateralExitOpResult(true, null, record);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ ///
+ public async Task AbandonAsync(
+ string storeId,
+ string recordId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(storeId);
+
+ if (!Constants.UnilateralExitEnabled)
+ return Refuse(FeatureDisabled);
+
+ if (string.IsNullOrWhiteSpace(recordId))
+ return Refuse(ExitNotFound);
+
+ // Held even though abandoning moves nothing: a row marked abandoned under a build in flight would let the
+ // next quote start while the build is still committing to its funding output.
+ if (!_running.TryAdd(storeId, 0))
+ return Refuse(OperationInFlight);
+
+ try
+ {
+ var record = await _records.GetAsync(storeId, recordId, cancellationToken).ConfigureAwait(false);
+ if (record is null)
+ return Refuse(ExitNotFound);
+
+ if (record.Status is UnilateralExitStatus.Abandoned)
+ return new UnilateralExitOpResult(true, null, record);
+
+ if (record.Status is UnilateralExitStatus.Completed)
+ {
+ return new UnilateralExitOpResult(
+ false,
+ "This exit is already recorded as finished, so there is nothing to abandon.",
+ record);
+ }
+
+ var from = record.Status;
+ record.Status = UnilateralExitStatus.Abandoned;
+ record.UpdatedUtc = _timeProvider.GetUtcNow();
+
+ if (!await _records.UpdateAsync(record, from, cancellationToken).ConfigureAwait(false))
+ return new UnilateralExitOpResult(false, ExitChangedUnderneath, record);
+
+ _logger.LogInformation(
+ "Store {StoreId}: abandoned unilateral exit {ExitId}. Any transactions already broadcast remain "
+ + "valid",
+ storeId, record.Id);
+
+ return new UnilateralExitOpResult(true, null, record);
+ }
+ finally
+ {
+ _running.TryRemove(storeId, out _);
+ }
+ }
+
+ private static string DescribeUneconomic(SparkExitQuote quote) => string.Format(
+ CultureInfo.InvariantCulture,
+ "This exit now costs more than it recovers: {0:N0} sat of fees against {1:N0} sat of value. Nothing was "
+ + "built.",
+ quote.TotalFeeSat,
+ quote.RecoverableValueSat);
+
+ ///
+ /// Confirmed satoshi on a record's funding address, in total and in its largest output.
+ ///
+ ///
+ /// No key is derived here. Measuring an address takes no key at all, so the read path unprotects
+ /// nothing — only a build does. Both figures come back null when the explorer could not be read or none is
+ /// configured for this network, and the page renders that as unknown rather than as zero: collapsing the two
+ /// is the failure this whole distinction exists to prevent.
+ ///
+ private async Task ReadFundingAsync(
+ UnilateralExitRecord record,
+ UnilateralExitSettings settings,
+ CancellationToken cancellationToken)
+ {
+ if (!SparkExitFundingExplorer.TryResolveBaseUrl(settings, Mainnet, out var baseUrl, out var error))
+ return SparkExitFundingBalance.Unknown(error!);
+
+ return await _explorer
+ .MeasureConfirmedAsync(baseUrl!, record.FundingAddress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// The store's exit funding key at one address index, or null with a merchant-facing reason.
+ ///
+ ///
+ /// The mnemonic is unprotected here and handed straight to the derivation; nothing keeps a reference to it,
+ /// and the caller is expected to dispose the returned key as soon as it has what it needs — see
+ /// . Only and call this.
+ ///
+ private SparkExitFundingKey? DeriveFundingKey(SparkSettings settings, uint index, out string? error)
+ {
+ var mnemonic = _mnemonicProtector.TryUnprotect(settings.ProtectedMnemonic);
+ return SparkExitFundingKey.TryDerive(mnemonic, _network, index, out var key, out error) ? key : null;
+ }
+
+ ///
+ /// The BIP32 path of a record's funding key, as an operator would type it into a recovery wallet.
+ ///
+ ///
+ /// This is what makes sats stranded on an abandoned exit's funding address recoverable without this plugin,
+ /// so it is shown on the page and repeated in the refusal for a seed that no longer derives the address.
+ /// Null only for a row whose index is not a usable one, which no quote can produce.
+ ///
+ private string? DescribeKeyPath(UnilateralExitRecord record) =>
+ TryFundingKeyIndex(record.FundingKeyIndex, out var index)
+ ? "m/" + SparkExitFundingKey.KeyPathFor(_network, index)
+ : null;
+
+ ///
+ /// Narrows a stored funding key index to a BIP32 address index.
+ ///
+ ///
+ /// The column is a long because Postgres has no unsigned types, and BIP32 reserves the top bit of a
+ /// child number for hardening — so the usable range is 0 to . A row outside it is
+ /// refused rather than wrapped, because a wrapped index derives a real key for the wrong address.
+ ///
+ private static bool TryFundingKeyIndex(long stored, out uint index)
+ {
+ if (stored is < 0 or > int.MaxValue)
+ {
+ index = 0;
+ return false;
+ }
+
+ index = (uint)stored;
+ return true;
+ }
+
+ /// Copies a quote's three figures onto a record. A null quote leaves them as they were.
+ private static void ApplyQuote(UnilateralExitRecord record, SparkExitQuote? quote)
+ {
+ if (quote is null)
+ return;
+
+ record.RecoverableValueSat = quote.RecoverableValueSat;
+ record.TotalFeeSat = quote.TotalFeeSat;
+ record.SingleUtxoFundingSat = quote.SingleUtxoFundingSat;
+ }
+
+ ///
+ /// Applies a change to a store's exit settings and reports whether the store came back up.
+ ///
+ ///
+ /// Applied to a copy of the whole blob rather than to the instance that was read, for the reason
+ /// SparkStableBalanceService.SaveAsync documents: a write that throws on the way to the database must
+ /// not leave the caller holding settings that were never persisted. The protected mnemonic in the same blob is
+ /// carried across untouched. Storing settings also reconciles the store's running SDK instance with them,
+ /// which tears the wallet down and reconnects it — which is why every caller holds the single-flight gate.
+ ///
+ /// Lower-case description for the operator log, e.g. "the disclosure acknowledgement".
+ /// Capitalised subject for the merchant-facing sentences, e.g. "The acknowledgement".
+ private async Task SaveExitSettingsAsync(
+ string storeId,
+ SparkSettings settings,
+ Action change,
+ string what,
+ string subject)
+ {
+ var updated = settings.Clone();
+ updated.UnilateralExit = (settings.UnilateralExit ?? new UnilateralExitSettings()).Clone();
+ change(updated.UnilateralExit);
+
+ SparkSettingsApplied applied;
+ try
+ {
+ applied = await _settingsStore.SetAsync(storeId, updated).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Store {StoreId}: could not store {What} ({Reason})",
+ storeId, what, SparkErrors.Describe(ex));
+
+ return Refuse($"{subject} could not be saved: {SparkErrors.Describe(ex)}");
+ }
+
+ if (!applied.WalletRunning)
+ {
+ // Reported as a failure even though the change is stored, because the operator cannot do the next
+ // thing: quoting and building both need a running wallet, and saying "saved" would send them to a
+ // form that refuses.
+ return Refuse(
+ $"{subject} was saved, but this store's Spark wallet did not come back up: "
+ + (applied.Reason ?? "check the server logs."));
+ }
+
+ return new UnilateralExitOpResult(true, null, null);
+ }
+
+ ///
+ /// Records why an attempt on a live exit failed and reports it, leaving the row's status alone.
+ ///
+ ///
+ ///
+ /// The status does not move, deliberately: an exit that failed to build is still awaiting funding (or still
+ /// holds the previous build's transactions), and the explanation belongs beside it rather than in a log
+ /// nobody reads. A row that cannot be updated still reports the original refusal — the operator's problem is
+ /// the refusal, not the bookkeeping.
+ ///
+ ///
+ /// Never cancellable. Recording why something failed is the cheapest write in this service and the one an
+ /// operator most needs to see, so it does not take the request's token: a browser that went away is not a
+ /// reason to leave a row with no explanation on it.
+ ///
+ ///
+ /// The status the caller read, guarding the update — see the store's contract.
+ private async Task FailAsync(
+ UnilateralExitRecord record,
+ UnilateralExitStatus expectedStatus,
+ string error)
+ {
+ record.LastError = error;
+ record.UpdatedUtc = _timeProvider.GetUtcNow();
+
+ try
+ {
+ await _records
+ .UpdateAsync(record, expectedStatus, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Store {StoreId}: could not record why unilateral exit {ExitId} failed",
+ record.StoreId, record.Id);
+ }
+
+ return new UnilateralExitOpResult(false, error, record);
+ }
+
+ ///
+ /// The one log line in this service that is the last copy of something valuable.
+ ///
+ ///
+ /// An error rather than a warning, and it names every txid: the SDK will not hand these transactions back
+ /// without a fresh build against a fresh funding output, so an operator recovering from this reads the hex out
+ /// of nothing. The hex itself is deliberately not logged — it is large, and the txids are enough to establish
+ /// what was signed and whether any of it reached the chain.
+ ///
+ private void LogUnsavedBuild(
+ string storeId,
+ UnilateralExitRecord record,
+ SparkExitResult result,
+ Exception? exception)
+ {
+ var txids = string.Join(", ", result.Transactions.Select(transaction => transaction.Txid));
+
+ _logger.LogError(
+ exception,
+ "Store {StoreId}: built unilateral exit {ExitId} but could not persist its {Count} signed "
+ + "transactions. Nothing was broadcast. The transactions were: {Txids}",
+ storeId, record.Id, result.Transactions.Count, txids);
+ }
+
+ private static UnilateralExitOpResult Refuse(string error) => new(false, error, null);
+
+ ///
+ /// Why the funding on the address does not fund this exit, in terms an operator can act on.
+ ///
+ ///
+ /// Every branch says "a single new output", and that is the whole point of the message. The natural
+ /// reading of "the address holds 3,000 sat and needs 4,200" is "send 1,200 more", which produces a second
+ /// output and funds nothing — CPFP spends one outpoint. So the instruction is always to send the full amount
+ /// again, as one output, and the arithmetic is there to explain why rather than to be added up.
+ ///
+ private static string DescribeShortfall(
+ long required,
+ string fundingAddress,
+ IReadOnlyList confirmed)
+ {
+ var total = confirmed.Sum(utxo => utxo.ValueSat);
+ var largest = confirmed.Count == 0 ? 0 : confirmed.Max(utxo => utxo.ValueSat);
+
+ return confirmed.Count switch
+ {
+ 0 => string.Format(
+ CultureInfo.InvariantCulture,
+ "The funding address holds no confirmed output yet. Send at least {0:N0} sat to {1} as a single "
+ + "transaction and try again once it has one confirmation.",
+ required,
+ fundingAddress),
+ 1 => string.Format(
+ CultureInfo.InvariantCulture,
+ "The funding address holds one confirmed output of {0:N0} sat and this exit needs {1:N0} sat. The "
+ + "fees are paid from one output, so topping up does not help: send at least the full required "
+ + "amount as a single new output and try again once it confirms.",
+ largest,
+ required),
+ _ => string.Format(
+ CultureInfo.InvariantCulture,
+ "The funding address holds {0:N0} sat across {1} confirmed outputs and this exit needs {2:N0} sat, "
+ + "but the fees are paid from one single output and the largest holds {3:N0} sat. Send at least "
+ + "the full required amount as a single new output and try again once it confirms.",
+ total,
+ confirmed.Count,
+ required,
+ largest)
+ };
+ }
+
+ ///
+ /// The leaf ids this exit was pinned to, or an empty list when the column cannot be read.
+ ///
+ ///
+ /// A malformed column is a refusal rather than a fallback to automatic selection, which is why an empty list
+ /// is returned instead of null: Auto at build time would price and sign a different set of leaves than
+ /// the one the operator funded for, which is the whole hazard the column exists to prevent.
+ ///
+ private IReadOnlyList DeserializeLeafIds(UnilateralExitRecord record)
+ {
+ if (string.IsNullOrEmpty(record.LeafIdsJson))
+ return [];
+
+ try
+ {
+ var ids = JsonSerializer.Deserialize(record.LeafIdsJson, JsonOptions);
+ return ids is null
+ ? []
+ : ids.Where(id => !string.IsNullOrWhiteSpace(id)).ToArray();
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex,
+ "Store {StoreId}: unilateral exit {ExitId} has an unreadable leaf selection",
+ record.StoreId, record.Id);
+ return [];
+ }
+ }
+
+ ///
+ /// Reads a built record's transaction set back, refusing anything that is not a well-formed set.
+ ///
+ ///
+ ///
+ /// Sanity-checked and not merely deserialised, because will happily produce a
+ /// with a null Txid and a null DependsOn from
+ /// [{}] — records get no null checks on their positional parameters. The page renders these as
+ /// broadcast instructions, so a structurally broken entry must become an explanation here rather than a
+ /// in a view. An out-of-range Kind or Status is the same
+ /// story from the other direction: the enums are persisted numerically, so an unknown number would render as
+ /// a bare integer next to copy-pasteable transaction hex.
+ ///
+ ///
+ /// The order is left exactly as stored. It is the SDK's own topological broadcast order — see
+ /// — and re-deriving it here from DependsOn would be inventing an
+ /// ordering the SDK already gave.
+ ///
+ ///
+ ///
+ /// False when a built record's column could not be read as a well-formed set. True — with a null
+ /// — when there is simply nothing built yet.
+ ///
+ private bool TryReadTransactions(
+ UnilateralExitRecord? record,
+ out IReadOnlyList? transactions)
+ {
+ transactions = null;
+
+ if (record?.TransactionsJson is not { } json || string.IsNullOrWhiteSpace(json))
+ return true;
+
+ SparkExitTransaction[]? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(json, JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex,
+ "Store {StoreId}: unilateral exit {ExitId} has an unreadable transaction set",
+ record.StoreId, record.Id);
+ return false;
+ }
+
+ if (parsed is null || parsed.Length == 0 || parsed.Any(IsMalformed))
+ {
+ _logger.LogError(
+ "Store {StoreId}: unilateral exit {ExitId} has a transaction set that parsed but is not usable",
+ record.StoreId, record.Id);
+ return false;
+ }
+
+ transactions = parsed;
+ return true;
+
+ static bool IsMalformed(SparkExitTransaction? transaction) =>
+ transaction is null
+ || string.IsNullOrWhiteSpace(transaction.Txid)
+ || string.IsNullOrWhiteSpace(transaction.TxHex)
+ || transaction.DependsOn is null
+ || !Enum.IsDefined(transaction.Kind)
+ || !Enum.IsDefined(transaction.Status);
+ }
+
+ ///
+ /// Validates the destination for this server's network, wrapping the sweep resolver's own parser.
+ ///
+ ///
+ /// The same parser, deliberately, and not a second call:
+ /// it also rejects a bitcoin: payment link, which parses as nothing and would otherwise reach the SDK
+ /// as a destination. Its messages are sentence fragments by design, so they are wrapped here — the fragment
+ /// names the fault and the wrapper says why it matters at all.
+ ///
+ private bool TryParseDestination(string? candidate, out string destination, out string error)
+ {
+ destination = string.Empty;
+
+ if (!SweepDestinationResolver.TryParse(candidate, _network, out var fragment))
+ {
+ error = $"That destination cannot be used: {fragment}. The recovered coins are swept there by a "
+ + $"transaction signed during the build, so it has to be a plain address that is valid on "
+ + $"{_network.ChainName}.";
+ return false;
+ }
+
+ destination = candidate!.Trim();
+ error = string.Empty;
+ return true;
+ }
+}
diff --git a/BTCPayServer.Plugins.Flint/SparkPlugin.cs b/BTCPayServer.Plugins.Flint/SparkPlugin.cs
index b451842..795154b 100644
--- a/BTCPayServer.Plugins.Flint/SparkPlugin.cs
+++ b/BTCPayServer.Plugins.Flint/SparkPlugin.cs
@@ -206,6 +206,38 @@ public override void Execute(IServiceCollection services)
// settings form and the Greenfield sweep endpoints so a configuration one accepts is one the other accepts.
services.AddSingleton();
+ // Experimental unilateral exit, behind Constants.UnilateralExitEnabled. Registered unconditionally: the
+ // gate is enforced inside the service and the controller, not by whether the type exists, so a host that
+ // sets the variable after startup does not get a half-wired graph.
+ //
+ // Its own named HTTP client, because discovering the CPFP funding UTXO is the one question neither the SDK
+ // nor NBXplorer can answer — the funding address is outside both key trees — so it goes to an esplora
+ // instance. Short timeout: a request thread is waiting on it while the exit page renders.
+ services.AddHttpClient(SparkExitFundingExplorer.HttpClientName, client =>
+ {
+ client.Timeout = SparkExitFundingExplorer.RequestTimeout;
+ client.DefaultRequestHeaders.UserAgent.ParseAdd(
+ $"BTCPayServer.Plugins.Flint/{typeof(SparkPlugin).Assembly.GetName().Version}");
+ });
+ services.AddSingleton();
+ services.AddSingleton(provider =>
+ {
+ // The chain is resolved once, as for the sweep destination resolver: it decides the funding key's
+ // derivation path, the address format, and which network a destination is parsed against.
+ var networkProvider = provider.GetRequiredService();
+ return new SparkUnilateralExitService(
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ provider.GetRequiredService(),
+ SparkNetworks.ToNBitcoinNetwork(networkProvider.NetworkType),
+ provider.GetRequiredService(),
+ provider.GetRequiredService