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/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/Fakes/SparkSurfaceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs
index b799353..4cb5c0c 100644
--- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs
@@ -163,6 +163,9 @@ private SparkSurfaceHarness(
/// because that is the state the sweep page has to keep rendering and saving in, and because a test that did
/// not ask for a live catalogue should not quietly get one.
///
+ ///
+ /// The unilateral-exit service the pages call, or null for one that refuses everything.
+ ///
public static SparkSurfaceHarness Create(
bool allowHotWalletForAll = true,
HotWalletSeedResult? hotWalletSeed = null,
@@ -170,7 +173,8 @@ public static SparkSurfaceHarness Create(
bool configureAttackerStore = false,
bool mainnet = false,
bool serverAdmin = false,
- string? crossChainRoutes = null)
+ string? crossChainRoutes = null,
+ ISparkUnilateralExitService? unilateralExit = null)
{
var writeLog = new WriteLog();
@@ -263,9 +267,14 @@ public static SparkSurfaceHarness Create(
TimeProvider.System,
NullLogger.Instance);
+ // Refuses everything unless a test supplies its own. A page test that did not ask for an exit service
+ // should not be able to quote one by accident, and an unstubbed call failing loudly beats it returning
+ // a plausible-looking empty page.
+ var exit = unilateralExit ?? new UnavailableUnilateralExitService();
+
var mvc = new SparkController(
settings, provisioner, wiring, seedResolver, statusReader, sweepEngine, sweepSettings,
- depositService, stableBalanceService, crossChainCatalog,
+ depositService, stableBalanceService, exit, crossChainCatalog,
new FakeAuthorizationService(), NullLogger.Instance);
var api = new GreenfieldSparkController(
@@ -324,4 +333,60 @@ private static void BindContext(
if (withTempData && controller is Controller mvc)
mvc.TempData = new TempDataDictionary(httpContext, new NullTempDataProvider());
}
+
+ ///
+ /// The default unilateral-exit service: a store with nothing in flight, and a refusal for every write.
+ ///
+ ///
+ /// The exit flow is behind an environment switch and off for the whole suite bar the tests that turn it on,
+ /// so this exists to satisfy the constructor rather than to be exercised. It answers the read with an empty,
+ /// unacknowledged store — the state every other page test is implicitly asserting nothing about — and
+ /// refuses every write with a sentence that names itself, so a test that unexpectedly reaches one sees where
+ /// it came from.
+ ///
+ private sealed class UnavailableUnilateralExitService : ISparkUnilateralExitService
+ {
+ private static UnilateralExitOpResult Refused =>
+ new(false, "No unilateral-exit service was supplied to this test harness.", null);
+
+ public Task ReadAsync(string storeId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(
+ new UnilateralExitPageData(
+ WalletRunning: false,
+ DisclosureAcknowledged: false,
+ BalanceSats: 0,
+ ActiveRecord: null,
+ History: [],
+ FundingReceivedSat: null,
+ FundingLargestOutputSat: null,
+ LeafCount: null,
+ FundingKeyPath: null,
+ Transactions: null,
+ TransactionsUnreadable: false));
+
+ public Task AcknowledgeDisclosureAsync(
+ string storeId, CancellationToken cancellationToken = default) => Task.FromResult(Refused);
+
+ public Task QuoteAsync(
+ string storeId,
+ long feeRateSatPerVbyte,
+ string destinationAddress,
+ CancellationToken cancellationToken = default) => Task.FromResult(Refused);
+
+ public Task BuildAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Refused);
+
+ public Task AbandonAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Refused);
+
+ public Task MarkCompletedAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Refused);
+
+ public Task SetExplorerUrlAsync(
+ string storeId, string? esploraApiUrl, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Refused);
+ }
}
diff --git a/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs b/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs
index 879b559..bf56253 100644
--- a/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs
+++ b/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs
@@ -142,7 +142,7 @@ public async Task Importing_an_unusable_phrase_is_refused_without_quoting_it(str
[InlineData(null)]
[InlineData("")]
[InlineData("wibble")]
- // Named on purpose: there is no unilateral-exit path anywhere in this plugin, so asking for one is simply an
+ // Named on purpose: the API offers no unilateral-exit path, so asking for one is simply an
// unrecognised seed source rather than something the API quietly interprets.
[InlineData("unilateral-exit")]
public async Task An_unrecognised_seed_source_is_refused_with_the_plugins_own_message(string? value)
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/SparkExitPageTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs
new file mode 100644
index 0000000..3a16d97
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs
@@ -0,0 +1,811 @@
+using System.Runtime.CompilerServices;
+using System.Text.RegularExpressions;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Models;
+using BTCPayServer.Plugins.Flint.Sdk;
+using BTCPayServer.Plugins.Flint.Services;
+using BTCPayServer.Plugins.Flint.Tests.Fakes;
+using Microsoft.AspNetCore.Mvc;
+using Xunit;
+
+namespace BTCPayServer.Plugins.Flint.Tests;
+
+///
+/// The unilateral-exit page: the feature gate, the disclosure-first ordering, and what the controller and the
+/// template make of the service's typed page data.
+///
+///
+///
+/// The first thing being pinned here is not about exits at all: a feature behind an environment switch is
+/// invisible when the switch is off. Every action, the GET included, answers NotFound from
+/// inside the action, because a redirect or a validation error on one of them is already an admission that the
+/// route exists. (The filters in front of the action still answer first — an unauthenticated caller gets the
+/// pipeline's 401 whether the feature is on or off — which is why the controller's remarks say the gate hides
+/// the flow from callers already entitled to be on this controller, not the route prefix from the world.)
+///
+///
+/// The second is that the controller reads nothing. It used to deserialise the record's JSON columns itself,
+/// which meant two sets of serialiser options for one format and a failure mode — an empty transaction table
+/// for an exit worth a store's whole balance — that threw nothing. The service now owns both ends and this
+/// class asserts the controller only copies fields across, including the "could not be read" flag it must
+/// carry rather than smooth over.
+///
+///
+/// The third is the template, asserted as text because no test in this suite renders a view (see
+/// for why, and what it costs). What is checked there is
+/// load-bearing and would not fail anything else: signed hex sits behind
+/// CanModifyStoreSettings, the funding shortfall is judged by the largest single output rather than the
+/// total, and no state of the page is a dead end whose only control is the one its own copy forbids.
+///
+///
+/// One class, in the serialised collection. The gate is an environment variable, which is process-wide
+/// state that xUnit's per-class parallelism would let two tests fight over. Every test here restores it in a
+/// finally, and the class joins — the same collection
+/// uses — so the two classes that read the variable cannot run
+/// at the same time as each other or as anything else.
+///
+///
+[Collection(UnilateralExitTestCollection.Name)]
+public class SparkExitPageTests
+{
+ private const string Store = SparkSurfaceHarness.AttackerStore;
+ private const string Gate = "FLINT_EXPERIMENTAL_UNILATERAL_EXIT";
+
+ /// A regtest address, so a destination in a test reads like one a merchant would type.
+ private const string Destination = "bcrt1qt8hufshrz62z5vj4q40uqx6c6ytlujy5s03gwm";
+
+ #region The gate
+
+ [Fact]
+ public async Task With_the_feature_off_every_exit_route_is_not_found()
+ {
+ using var gate = FeatureGate(enabled: false);
+
+ // Deliberately a service that would answer happily. What must produce the 404 is the gate, not an
+ // absent dependency — otherwise the test would pass on a build where the gate had been deleted.
+ var exit = new StubExitService();
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ Assert.IsType(await h.Mvc.Exit(Store, CancellationToken.None));
+ Assert.IsType(await h.Mvc.AcknowledgeExit(Store, CancellationToken.None));
+ Assert.IsType(
+ await h.Mvc.QuoteExit(
+ Store,
+ new SparkExitViewModel { FeeRateSatPerVbyte = 10, DestinationAddress = Destination },
+ CancellationToken.None));
+ Assert.IsType(await h.Mvc.BuildExit(Store, "some-record", CancellationToken.None));
+ Assert.IsType(await h.Mvc.AbandonExit(Store, "some-record", CancellationToken.None));
+ Assert.IsType(await h.Mvc.CompleteExit(Store, "some-record", CancellationToken.None));
+ Assert.IsType(
+ await h.Mvc.SetExitExplorer(Store, "https://esplora.example/api", CancellationToken.None));
+
+ // And nothing reached the service, so a gate that 404'd after acting would still fail this.
+ Assert.Empty(exit.Calls);
+ }
+
+ [Fact]
+ public async Task With_the_feature_on_the_exit_routes_still_refuse_another_stores_id()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ // The store the request was authorised for is the attacker's; the id on the route is the victim's. The
+ // same hole the rest of this controller is guarded against (see SparkControllerStoreScopeTests), and a
+ // feature gate is no substitute for the guard — an exit built for another store's leaves would send its
+ // balance to an address this caller chose.
+ var exit = new StubExitService();
+ var h = SparkSurfaceHarness.Create(unilateralExit: exit);
+ var victim = SparkSurfaceHarness.VictimStore;
+
+ Assert.IsType(await h.Mvc.Exit(victim, CancellationToken.None));
+ Assert.IsType(await h.Mvc.AcknowledgeExit(victim, CancellationToken.None));
+ Assert.IsType(
+ await h.Mvc.QuoteExit(
+ victim,
+ new SparkExitViewModel { FeeRateSatPerVbyte = 10, DestinationAddress = Destination },
+ CancellationToken.None));
+ Assert.IsType(await h.Mvc.BuildExit(victim, "record-7", CancellationToken.None));
+ Assert.IsType(await h.Mvc.AbandonExit(victim, "record-7", CancellationToken.None));
+ Assert.IsType(await h.Mvc.CompleteExit(victim, "record-7", CancellationToken.None));
+ Assert.IsType(
+ await h.Mvc.SetExitExplorer(victim, "https://esplora.example/api", CancellationToken.None));
+
+ Assert.Empty(exit.Calls);
+ }
+
+ #endregion
+
+ #region What the page shows
+
+ [Fact]
+ public async Task The_page_leads_with_the_disclosure_until_it_has_been_acknowledged()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService
+ {
+ Page = Page(disclosureAcknowledged: false, balanceSats: 250_000)
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.Equal(Store, model.StoreId);
+ Assert.False(model.DisclosureAcknowledged);
+ Assert.True(model.WalletRunning);
+ Assert.Equal(250_000, model.BalanceSats);
+ Assert.Null(model.ActiveRecord);
+ Assert.Empty(model.Transactions);
+ }
+
+ [Fact]
+ public async Task An_acknowledged_store_with_nothing_in_flight_gets_the_quote_form()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Page = Page(balanceSats: 900_000) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.True(model.DisclosureAcknowledged);
+ Assert.Null(model.ActiveRecord);
+
+ // Nothing pre-filled from a previous exit, because there is no previous exit to pre-fill from.
+ Assert.Equal(0, model.FeeRateSatPerVbyte);
+ Assert.Null(model.DestinationAddress);
+ Assert.Null(model.LeafCount);
+ Assert.Null(model.FundingKeyPath);
+ }
+
+ [Fact]
+ public async Task A_record_awaiting_funding_carries_the_quote_the_funding_figures_and_the_key_path()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var record = AwaitingFunding();
+ var exit = new StubExitService
+ {
+ // Split funding: 6,000 sats have arrived in total but the biggest single output is 2,500, and the
+ // requirement is 4,300. The page has to be able to say "not enough" off the largest while still
+ // reporting the total honestly, which is why both numbers travel.
+ Page = Page(
+ activeRecord: record,
+ history: [record],
+ fundingReceivedSat: 6_000,
+ fundingLargestOutputSat: 2_500,
+ leafCount: 2,
+ fundingKeyPath: "m/84'/1'/4607060'/0/3")
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.Same(record, model.ActiveRecord);
+ Assert.Equal(6_000, model.FundingReceivedSat);
+ Assert.Equal(2_500, model.FundingLargestOutputSat);
+ Assert.Equal(2, model.LeafCount);
+ Assert.Equal("m/84'/1'/4607060'/0/3", model.FundingKeyPath);
+ Assert.Equal(record.FeeRateSatPerVbyte, model.FeeRateSatPerVbyte);
+ Assert.Equal(record.DestinationAddress, model.DestinationAddress);
+ Assert.Empty(model.Transactions);
+ Assert.False(model.TransactionsUnreadable);
+ Assert.Single(model.History);
+ }
+
+ [Fact]
+ public async Task An_unreachable_explorer_reaches_the_page_as_unknown_rather_than_as_zero()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var record = AwaitingFunding();
+ var exit = new StubExitService
+ {
+ // Null rather than zero, on both figures. A merchant who read "unknown" as "my funding has not
+ // arrived" would send it twice, and the second send would not combine with the first.
+ Page = Page(activeRecord: record, fundingReceivedSat: null, fundingLargestOutputSat: null)
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.Null(model.FundingReceivedSat);
+ Assert.Null(model.FundingLargestOutputSat);
+ }
+
+ [Fact]
+ public async Task The_explorer_input_shows_what_is_stored_and_the_page_knows_its_network()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Page = Page() };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+ h.Settings.Settings[Store]!.UnilateralExit.EsploraApiUrl = "http://localhost:3002/api";
+
+ var model = await RenderExit(h);
+
+ // Pre-filled on purpose: posting that form empty is how the override is cleared, so an input that
+ // rendered blank while one was set would delete it the first time somebody pressed Save.
+ Assert.Equal("http://localhost:3002/api", model.EsploraApiUrl);
+
+ // Regtest, which is the case where the explorer is not a preference but a prerequisite. The name is
+ // taken from NBitcoin rather than spelled out, because it is the copy the page prints and its casing is
+ // NBitcoin's to choose.
+ Assert.False(model.IsMainnet);
+ Assert.Equal(NBitcoin.Network.RegTest.ChainName.ToString(), model.NetworkName);
+ }
+
+ [Fact]
+ public async Task On_mainnet_the_page_says_so_and_starts_with_no_override()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Page = Page() };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, mainnet: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.True(model.IsMainnet);
+ Assert.Null(model.EsploraApiUrl);
+ }
+
+ #endregion
+
+ #region The built transaction set
+
+ [Fact]
+ public async Task A_built_record_reaches_the_page_as_transactions_to_broadcast()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ // No JSON anywhere in this test. The service deserialises the record's column and hands over typed
+ // transactions; the controller's only job is to carry them across without inventing an empty list.
+ var record = Built();
+ var exit = new StubExitService
+ {
+ Page = Page(activeRecord: record, history: [record], transactions: SignedExit())
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.False(model.TransactionsUnreadable);
+ Assert.Equal(2, model.Transactions.Count);
+
+ var fanout = model.Transactions[0];
+ Assert.Equal(SparkExitTxKind.Fanout, fanout.Kind);
+ Assert.Equal("aa11", fanout.Txid);
+ Assert.Null(fanout.CpfpTxHex);
+ Assert.False(fanout.RequiresPackageBroadcast);
+ Assert.Empty(fanout.DependsOn);
+
+ var node = model.Transactions[1];
+ Assert.Equal(SparkExitTxKind.TreeNode, node.Kind);
+ Assert.Equal("node-1", node.NodeId);
+ Assert.True(node.RequiresPackageBroadcast);
+ Assert.Equal("cpfphex", node.CpfpTxHex);
+ Assert.Equal(144u, node.CsvTimelockBlocks);
+ Assert.Equal(["aa11"], node.DependsOn);
+ Assert.Equal(SparkExitTxStatus.Unconfirmed, node.Status);
+ }
+
+ [Fact]
+ public async Task An_unreadable_transaction_column_is_carried_through_rather_than_smoothed_over()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var record = Built();
+ var exit = new StubExitService
+ {
+ // What the service reports when the column will not parse or comes back structurally broken: no
+ // transactions, and a flag saying that is not the same as none.
+ Page = Page(activeRecord: record, transactions: null, transactionsUnreadable: true)
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ // The page renders "the log has the detail, build again" off this flag. An empty transaction list with
+ // the flag clear would tell the merchant the opposite of the truth.
+ Assert.True(model.TransactionsUnreadable);
+ Assert.Empty(model.Transactions);
+ }
+
+ [Fact]
+ public async Task A_built_record_with_no_transactions_is_distinguishable_from_an_unreadable_one()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var record = Built();
+ var exit = new StubExitService
+ {
+ Page = Page(activeRecord: record, transactions: [], transactionsUnreadable: false)
+ };
+
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var model = await RenderExit(h);
+
+ Assert.Empty(model.Transactions);
+ Assert.False(model.TransactionsUnreadable);
+ }
+
+ #endregion
+
+ #region What the template does with it
+
+ [Fact]
+ public void A_packaged_transaction_is_shown_as_a_submitpackage_command()
+ {
+ // The wording is load-bearing: a tree transaction pays no fee of its own, so an operator who pastes
+ // sendrawtransaction gets a rejection and no explanation, and the page is the only place that
+ // distinction is made.
+ var view = ExitTemplate();
+
+ Assert.Contains("bitcoin-cli submitpackage", view);
+ Assert.Contains("CpfpTxHex is { } cpfpTxHex", view);
+ Assert.Contains("sendrawtransaction", view);
+ Assert.Contains("SparkExitTransactions", view);
+ Assert.Contains("SparkExitFundingAddress", view);
+ }
+
+ [Fact]
+ public void Signed_hex_is_behind_the_permission_that_built_it()
+ {
+ // The hex is enough on its own to move this store's balance to the destination already baked into it,
+ // so it belongs to whoever may modify the store, not to whoever may read the page. Asserted
+ // structurally — the wrapper has to open immediately before the table — because a `permission`
+ // attribute somewhere else in the file would satisfy a plain Contains while leaving the hex public.
+ var view = ExitTemplate();
+
+ Assert.Matches(
+ new Regex(
+ "\\s*"
+ + "
", StringComparison.Ordinal);
+ Assert.InRange(wrapper, 0, view.Length);
+ foreach (var carrier in new[] { "SparkExitPackage@step", "SparkExitTxHex@step", "SparkExitCpfpHex@step" })
+ Assert.True(view.IndexOf(carrier, StringComparison.Ordinal) > wrapper, carrier);
+ }
+
+ [Fact]
+ public void The_funding_panel_judges_the_shortfall_by_the_largest_output_not_the_total()
+ {
+ // Five outputs adding up to the requirement fund nothing: the fee-bumping transaction spends one
+ // outpoint. A page that compared the sum would tell a merchant they were funded while the build
+ // refused, and the merchant would conclude the plugin was broken.
+ var view = ExitTemplate();
+
+ Assert.Contains("id=\"SparkExitFundingLargest\"", view);
+ Assert.Contains("id=\"SparkExitFundingReceived\"", view);
+ Assert.Contains("Model.FundingLargestOutputSat is { } largest", view);
+ Assert.Contains("largest < record.SingleUtxoFundingSat", view);
+
+ // The total is reported but must not be what a shortfall is judged by.
+ Assert.DoesNotContain("received < record.SingleUtxoFundingSat", view);
+
+ // And the copy has to say the single-output rule out loud, in both directions.
+ Assert.Contains("one single output", view);
+ Assert.Contains("as one new", view);
+ }
+
+ [Fact]
+ public void No_state_of_the_page_is_a_dead_end()
+ {
+ // The unreadable branch tells the operator not to abandon the exit. If the only control it rendered
+ // were the abandon button, the page would be telling them to do nothing and offering them one thing —
+ // and they would press it.
+ var view = ExitTemplate();
+
+ Assert.Contains("id=\"SparkExitRebuildUnreadable\"", view);
+ Assert.Contains("id=\"SparkExitRebuildEmpty\"", view);
+ Assert.Contains("id=\"SparkExitRebuild\"", view);
+ Assert.Contains("id=\"SparkExitAbandon\"", view);
+
+ // The ending a successful exit deserves, and the funding key path that makes an abandoned one
+ // recoverable by hand.
+ Assert.Contains("id=\"SparkExitComplete\"", view);
+ Assert.Contains("id=\"SparkExitFundingKeyPath\"", view);
+ Assert.Contains("id=\"SparkExitExplorerForm\"", view);
+ }
+
+ [Fact]
+ public void The_fee_input_takes_its_bounds_from_the_service()
+ {
+ // Two numbers typed into a template are two numbers to keep in step, and the one that mattered would
+ // be the one nobody edited. The browser's hint and the server's refusal come from the same constants.
+ var view = ExitTemplate();
+
+ Assert.Contains("min=\"@SparkUnilateralExitService.MinFeeRateSatPerVbyte\"", view);
+ Assert.Contains("max=\"@SparkUnilateralExitService.MaxFeeRateSatPerVbyte\"", view);
+ Assert.DoesNotContain("min=\"1\" max=\"500\"", view);
+ }
+
+ #endregion
+
+ #region Relaying the service's answer
+
+ [Fact]
+ public async Task An_acknowledgement_reports_success_and_returns_to_the_page()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(true, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.AcknowledgeExit(Store, CancellationToken.None);
+
+ var redirect = Assert.IsType(result);
+ Assert.Equal(nameof(h.Mvc.Exit), redirect.ActionName);
+ Assert.Equal(["Acknowledge"], exit.Calls);
+ Assert.NotNull(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ Assert.Null(h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ }
+
+ [Fact]
+ public async Task A_refused_quote_is_relayed_verbatim_and_returns_to_the_page()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ const string refusal = "Nothing is worth exiting at 400 sat/vB.";
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(false, refusal, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.QuoteExit(
+ Store,
+ new SparkExitViewModel { FeeRateSatPerVbyte = 400, DestinationAddress = Destination },
+ CancellationToken.None);
+
+ Assert.IsType(result);
+
+ // The service's sentence, unedited. The controller has no opinion to add and no guard of its own to
+ // report, so anything else here would be the page inventing a reason.
+ Assert.Equal(refusal, h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ Assert.Null(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+
+ // Passed through untouched, including the fee rate the service will refuse: the bounds are its business.
+ Assert.Equal(["Quote:400:" + Destination], exit.Calls);
+ }
+
+ [Fact]
+ public async Task A_failed_build_is_relayed_and_the_record_id_reaches_the_service()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ const string refusal = "The funding address holds 900 sats; this exit needs 4,300 in one UTXO.";
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(false, refusal, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.BuildExit(Store, "record-7", CancellationToken.None);
+
+ Assert.IsType(result);
+ Assert.Equal(refusal, h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ Assert.Equal(["Build:record-7"], exit.Calls);
+ }
+
+ [Fact]
+ public async Task Abandoning_says_out_loud_that_it_cancels_nothing()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(true, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.AbandonExit(Store, "record-7", CancellationToken.None);
+
+ Assert.IsType(result);
+ Assert.Equal(["Abandon:record-7"], exit.Calls);
+
+ // The one piece of copy worth pinning: "abandon" is the word a merchant reaches for when they want to
+ // undo a broadcast, and this does not do that.
+ var message = Assert.IsType(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ Assert.Contains("already broadcast is unaffected", message);
+ }
+
+ [Fact]
+ public async Task Marking_an_exit_completed_says_it_moved_nothing()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(true, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.CompleteExit(Store, "record-7", CancellationToken.None);
+
+ var redirect = Assert.IsType(result);
+ Assert.Equal(nameof(h.Mvc.Exit), redirect.ActionName);
+ Assert.Equal(["Complete:record-7"], exit.Calls);
+
+ // Nothing here watches the chain, so the banner must not imply the plugin verified anything, and it has
+ // to say that recording a completion is not itself an action on the money.
+ var message = Assert.IsType(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ Assert.Contains("Nothing was broadcast or moved", message);
+ Assert.Contains("your confirmation", message);
+ }
+
+ [Fact]
+ public async Task A_refused_completion_is_relayed_like_any_other_refusal()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ const string refusal = "This exit has not been built yet, so there is nothing to mark completed.";
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(false, refusal, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ await h.Mvc.CompleteExit(Store, "record-7", CancellationToken.None);
+
+ Assert.Equal(refusal, h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ Assert.Null(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ }
+
+ [Fact]
+ public async Task An_explorer_url_reaches_the_service_exactly_as_typed()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(true, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ var result = await h.Mvc.SetExitExplorer(Store, " http://localhost:3002/api ", CancellationToken.None);
+
+ Assert.IsType(result);
+
+ // Untrimmed and unexamined: whether that string is an acceptable URL is the service's judgement, and a
+ // controller that pre-validated it would be a second opinion to keep in step with the first.
+ Assert.Equal(["Explorer: http://localhost:3002/api "], exit.Calls);
+ Assert.NotNull(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ }
+
+ [Fact]
+ public async Task Clearing_the_explorer_says_what_clearing_it_costs()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(true, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ await h.Mvc.SetExitExplorer(Store, " ", CancellationToken.None);
+
+ Assert.Equal(["Explorer: "], exit.Calls);
+
+ // Off mainnet clearing the override leaves funding discovery with nothing to ask, and the banner is the
+ // only place a merchant finds that out.
+ var message = Assert.IsType(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ Assert.Contains("cleared", message);
+ }
+
+ [Fact]
+ public async Task A_refused_explorer_url_is_relayed_verbatim()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ const string refusal = "That is not an absolute http or https URL.";
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(false, refusal, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ await h.Mvc.SetExitExplorer(Store, "not-a-url", CancellationToken.None);
+
+ Assert.Equal(refusal, h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ Assert.Null(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ }
+
+ [Fact]
+ public async Task A_failure_with_no_reason_still_produces_a_banner()
+ {
+ using var gate = FeatureGate(enabled: true);
+
+ // A service that fails without saying why is a bug, but a silent redirect looks exactly like success —
+ // so the controller substitutes a sentence rather than leaving the merchant to guess.
+ var exit = new StubExitService { Result = new UnilateralExitOpResult(false, null, null) };
+ var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit);
+
+ await h.Mvc.AbandonExit(Store, "record-7", CancellationToken.None);
+
+ Assert.NotNull(h.Mvc.TempData[WellKnownTempData.ErrorMessage]);
+ Assert.Null(h.Mvc.TempData[WellKnownTempData.SuccessMessage]);
+ }
+
+ #endregion
+
+ #region Fixtures
+
+ /// The page's view model from one GET, with the boilerplate of unwrapping it out of the way.
+ private static async Task RenderExit(SparkSurfaceHarness h)
+ {
+ var view = Assert.IsType(await h.Mvc.Exit(Store, CancellationToken.None));
+ return Assert.IsType(view.Model);
+ }
+
+ ///
+ /// One service read, with every field named.
+ ///
+ ///
+ /// The page data has eleven members and most tests care about two of them. Named optional parameters keep
+ /// each test's fixture to the fields it is actually about, and — unlike a positional constructor call —
+ /// a field added to the record does not silently shift what an existing test was asserting.
+ ///
+ private static UnilateralExitPageData Page(
+ bool walletRunning = true,
+ bool disclosureAcknowledged = true,
+ long balanceSats = 0,
+ UnilateralExitRecord? activeRecord = null,
+ IReadOnlyList? history = null,
+ long? fundingReceivedSat = null,
+ long? fundingLargestOutputSat = null,
+ int? leafCount = null,
+ string? fundingKeyPath = null,
+ IReadOnlyList? transactions = null,
+ bool transactionsUnreadable = false) =>
+ new(
+ walletRunning,
+ disclosureAcknowledged,
+ balanceSats,
+ activeRecord,
+ history ?? [],
+ fundingReceivedSat,
+ fundingLargestOutputSat,
+ leafCount,
+ fundingKeyPath,
+ transactions,
+ transactionsUnreadable);
+
+ private static UnilateralExitRecord AwaitingFunding() => new()
+ {
+ Id = "record-7",
+ StoreId = Store,
+ Status = UnilateralExitStatus.AwaitingFunding,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ DestinationAddress = Destination,
+ FeeRateSatPerVbyte = 12,
+ RecoverableValueSat = 400_000,
+ TotalFeeSat = 9_000,
+ SingleUtxoFundingSat = 4_300,
+ FundingAddress = "bcrt1qfundingaddressfundingaddressfundingxyz"
+ };
+
+ private static UnilateralExitRecord Built()
+ {
+ var record = AwaitingFunding();
+ record.Status = UnilateralExitStatus.Built;
+ return record;
+ }
+
+ ///
+ /// A minimal but shaped-like-the-real-thing exit: a fan-out that broadcasts alone, and one tree node that
+ /// only works as a package with its CPFP child.
+ ///
+ private static SparkExitTransaction[] SignedExit() =>
+ [
+ new(SparkExitTxKind.Fanout, null, "aa11", "fanouthex", null, null, [], SparkExitTxStatus.Unconfirmed),
+ new(SparkExitTxKind.TreeNode, "node-1", "bb22", "nodehex", "cpfphex", 144u, ["aa11"],
+ SparkExitTxStatus.Unconfirmed)
+ ];
+
+ ///
+ /// Sets the feature switch for one test and puts back whatever was there.
+ ///
+ ///
+ /// The variable is process-wide, and is a property precisely so
+ /// that this works — a cached static readonly would freeze whichever value the first test to load the
+ /// class happened to see. Restoring the previous value rather than clearing it keeps a developer who exported
+ /// the variable in their own shell from watching later tests behave differently.
+ ///
+ private static IDisposable FeatureGate(bool enabled) => new EnvironmentSwitch(Gate, enabled ? "1" : null);
+
+ private sealed class EnvironmentSwitch : IDisposable
+ {
+ private readonly string _name;
+ private readonly string? _previous;
+
+ public EnvironmentSwitch(string name, string? value)
+ {
+ _name = name;
+ _previous = Environment.GetEnvironmentVariable(name);
+ Environment.SetEnvironmentVariable(name, value);
+ }
+
+ public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous);
+ }
+
+ ///
+ /// The exit service the page talks to: whatever says, whatever says,
+ /// and a note of every call so "the controller decided nothing" is falsifiable.
+ ///
+ ///
+ /// Hand-rolled rather than mocked, matching the suite's other fakes, and every write returns the same result
+ /// on purpose: these tests are about relaying and gating, so a per-method result table would be six places
+ /// to keep in step for no assertion's benefit.
+ ///
+ private sealed class StubExitService : ISparkUnilateralExitService
+ {
+ public UnilateralExitPageData Page { get; set; } =
+ new(WalletRunning: true, DisclosureAcknowledged: false, BalanceSats: 0,
+ ActiveRecord: null, History: [], FundingReceivedSat: null, FundingLargestOutputSat: null,
+ LeafCount: null, FundingKeyPath: null, Transactions: null, TransactionsUnreadable: false);
+
+ public UnilateralExitOpResult Result { get; set; } = new(true, null, null);
+
+ /// Every call, in order, with the arguments that came off the form.
+ public List Calls { get; } = [];
+
+ public Task ReadAsync(string storeId, CancellationToken cancellationToken = default)
+ {
+ Calls.Add("Read");
+ return Task.FromResult(Page);
+ }
+
+ public Task AcknowledgeDisclosureAsync(
+ string storeId, CancellationToken cancellationToken = default)
+ {
+ Calls.Add("Acknowledge");
+ return Task.FromResult(Result);
+ }
+
+ public Task QuoteAsync(
+ string storeId,
+ long feeRateSatPerVbyte,
+ string destinationAddress,
+ CancellationToken cancellationToken = default)
+ {
+ Calls.Add($"Quote:{feeRateSatPerVbyte}:{destinationAddress}");
+ return Task.FromResult(Result);
+ }
+
+ public Task BuildAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default)
+ {
+ Calls.Add($"Build:{recordId}");
+ return Task.FromResult(Result);
+ }
+
+ public Task AbandonAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default)
+ {
+ Calls.Add($"Abandon:{recordId}");
+ return Task.FromResult(Result);
+ }
+
+ public Task MarkCompletedAsync(
+ string storeId, string recordId, CancellationToken cancellationToken = default)
+ {
+ Calls.Add($"Complete:{recordId}");
+ return Task.FromResult(Result);
+ }
+
+ public Task SetExplorerUrlAsync(
+ string storeId, string? esploraApiUrl, CancellationToken cancellationToken = default)
+ {
+ Calls.Add($"Explorer:{esploraApiUrl ?? "(null)"}");
+ return Task.FromResult(Result);
+ }
+ }
+
+ /// The exit template's own text, for the assertions no unrendered view model can carry.
+ private static string ExitTemplate() => File.ReadAllText(
+ Path.Combine(RepositoryRoot, "BTCPayServer.Plugins.Flint", "Views", "Spark", "Exit.cshtml"));
+
+ ///
+ /// Repository root, from this file's compile-time path — the same trick
+ /// uses, and for the same reason: the output directory's depth
+ /// below the project is an MSBuild detail.
+ ///
+ private static string RepositoryRoot => Path.GetFullPath(Path.Combine(ThisFile(), "..", ".."));
+
+ private static string ThisFile([CallerFilePath] string path = "") => path;
+
+ #endregion
+}
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/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.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/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/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.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.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/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/Controllers/GreenfieldSparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/GreenfieldSparkController.cs
index 80bd1ee..54338eb 100644
--- a/BTCPayServer.Plugins.Flint/Controllers/GreenfieldSparkController.cs
+++ b/BTCPayServer.Plugins.Flint/Controllers/GreenfieldSparkController.cs
@@ -59,6 +59,7 @@ namespace BTCPayServer.Plugins.Flint.Controllers;
/// Exit paths. Every sweep this API can cause is a cooperative exit, by owner decision. There is no
/// unilateral-exit endpoint, no parameter that selects one, and no way to
/// reach one; "drain" in the sweep settings means the SDK's FeesIncluded fee policy and nothing else.
+/// The plugin's experimental unilateral-exit flow is deliberately UI-only and stays unreachable from here.
///
///
/// Not on the graph BTCPay builds at startup. A controller is constructed per request, so nothing here
@@ -364,7 +365,7 @@ public async Task UpdateSweepConfiguration(
/// minute and the engine re-quotes on a real sweep, so it is an estimate.
///
///
- /// Always a cooperative exit. There is no parameter here or anywhere else in this plugin that selects a
+ /// Always a cooperative exit. There is no parameter here or anywhere else in this API that selects a
/// unilateral exit.
///
///
diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs
index 2f16422..514ba19 100644
--- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs
+++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
@@ -10,10 +11,12 @@
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Plugins.Flint.Data;
using BTCPayServer.Plugins.Flint.Models;
+using BTCPayServer.Plugins.Flint.Sdk;
using BTCPayServer.Plugins.Flint.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
+using NBitcoin;
namespace BTCPayServer.Plugins.Flint.Controllers;
@@ -78,6 +81,7 @@ public class SparkController : Controller
private readonly SparkSweepSettingsService _sweepSettings;
private readonly SparkDepositService _deposits;
private readonly SparkStableBalanceService _stableBalance;
+ private readonly ISparkUnilateralExitService _unilateralExit;
private readonly CrossChainCatalog _crossChainCatalog;
private readonly IAuthorizationService _authorizationService;
private readonly ILogger _logger;
@@ -92,6 +96,7 @@ public SparkController(
SparkSweepSettingsService sweepSettings,
SparkDepositService deposits,
SparkStableBalanceService stableBalance,
+ ISparkUnilateralExitService unilateralExit,
CrossChainCatalog crossChainCatalog,
IAuthorizationService authorizationService,
ILogger logger)
@@ -105,6 +110,7 @@ public SparkController(
_sweepSettings = sweepSettings;
_deposits = deposits;
_stableBalance = stableBalance;
+ _unilateralExit = unilateralExit;
_crossChainCatalog = crossChainCatalog;
_authorizationService = authorizationService;
_logger = logger;
@@ -752,6 +758,330 @@ public async Task ClaimDeposit(
#endregion
+ #region Unilateral exit
+
+ ///
+ /// Forcing this store's Spark balance on-chain without the operators' cooperation: the disclosure, the
+ /// quote, the funding instructions, and the signed transactions the merchant broadcasts by hand.
+ ///
+ ///
+ ///
+ /// Every action in this region begins by pretending the feature does not exist.
+ /// is off by default, so each action answers
+ /// NotFound rather than a 403 or a validation error that would confirm the route is wired up —
+ /// the GET included, because a probe of the page answers as much as a probe of the write.
+ ///
+ ///
+ /// What that hides, and what it does not. The gate runs inside the action, so the filters in front
+ /// of it still answer first: an anonymous or under-privileged caller gets the pipeline's 401/403 and a
+ /// POST without a valid antiforgery token gets its 400, on a disabled feature exactly as on an enabled
+ /// one. Those answers are indistinguishable from any other route under this controller's
+ /// CanViewStoreSettings gate, which is the point — the thing kept from leaking is that
+ /// this store's exit flow exists to a caller who is otherwise entitled to be here, not the
+ /// existence of a route prefix. The service repeats the gate as the enforcement; this one keeps the page
+ /// and its writes from doing anything.
+ ///
+ ///
+ /// Beyond that gate these actions decide nothing at all. They read, they relay the service's own refusal
+ /// into the status banner, and they redirect back to the page — the same shape as
+ /// . The fee-rate bounds, the disclosure gate, the single-exit-per-store rule, the
+ /// funding-sufficiency check and the explorer URL's validation all live in
+ /// , so nothing a form can carry changes what is allowed.
+ ///
+ ///
+ [HttpGet("exit")]
+ public async Task Exit([FromRoute] string storeId, CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var page = await _unilateralExit.ReadAsync(storeId, cancellationToken).ConfigureAwait(false);
+ var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false);
+ return View(BuildExitViewModel(storeId, page, settings));
+ }
+
+ ///
+ /// Records the operator's acceptance of the disclosure, which is what unlocks quoting.
+ ///
+ ///
+ /// A POST to its own route rather than a checkbox on the quote form, so the acceptance is a stored fact
+ /// with its own moment — the Stable Balance pattern. A merchant who has read the warnings once is not asked
+ /// again on every quote, and a quote that arrives without this having happened is refused server-side
+ /// whatever any form said.
+ ///
+ [HttpPost("exit/acknowledge")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task AcknowledgeExit([FromRoute] string storeId, CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .AcknowledgeDisclosureAsync(storeId, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(result, "Acknowledged. You can now quote a unilateral exit for this store.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Quotes an exit at the requested fee rate and destination, creating the record the operator then funds.
+ ///
+ ///
+ /// The two posted values are handed to the service unexamined. It is the service that decides whether the
+ /// rate is sane, whether the address belongs to this server's network, whether anything is worth exiting at
+ /// that rate, and whether this store already has an exit in flight — and it says so in words this action
+ /// only forwards.
+ ///
+ [HttpPost("exit/quote")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task QuoteExit(
+ [FromRoute] string storeId,
+ SparkExitViewModel vm,
+ CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .QuoteAsync(storeId, vm.FeeRateSatPerVbyte, vm.DestinationAddress ?? string.Empty, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(
+ result,
+ "Exit quoted. Nothing has been signed and nothing has moved — send the funding shown below, then "
+ + "build.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Builds and signs the exit against the funding that has arrived. Broadcasts nothing.
+ ///
+ ///
+ /// Safe to post again after a failure, and the page says so: the service re-discovers the funding UTXOs and
+ /// re-quotes the record's own leaves each time, so a build that failed for want of funding succeeds once
+ /// more has been sent, and steps already confirmed on-chain are skipped rather than rebuilt.
+ ///
+ [HttpPost("exit/build")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task BuildExit(
+ [FromRoute] string storeId,
+ string recordId,
+ CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .BuildAsync(storeId, recordId, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(
+ result,
+ "The exit is built and signed. Nothing has been broadcast — the transactions below are yours to "
+ + "submit, in the order shown.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Abandons the record so the store can quote again.
+ ///
+ ///
+ /// Moves no money and cancels nothing on-chain: anything already broadcast stays valid and will still
+ /// confirm. The page carries that sentence next to the button, because "abandon" is the word a merchant
+ /// reaches for when they want to undo a broadcast, and this is not that.
+ ///
+ [HttpPost("exit/abandon")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task AbandonExit(
+ [FromRoute] string storeId,
+ string recordId,
+ CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .AbandonAsync(storeId, recordId, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(
+ result,
+ "This exit was abandoned. Anything already broadcast is unaffected and will still confirm.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Records the operator's own statement that they broadcast the set and the sweep confirmed.
+ ///
+ ///
+ /// Nothing here watches the chain in Phase 0, so this button is a note, not a verification — and it moves
+ /// no money either way. It exists because without it the only way a finished exit leaves the active state
+ /// is "abandon", and telling a merchant to abandon the exit that just succeeded is how a page teaches
+ /// somebody to distrust it.
+ ///
+ [HttpPost("exit/complete")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task CompleteExit(
+ [FromRoute] string storeId,
+ string recordId,
+ CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .MarkCompletedAsync(storeId, recordId, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(
+ result,
+ "Recorded as completed. Nothing was broadcast or moved by this — it is your confirmation that the "
+ + "sweep confirmed, and it frees this store to quote another exit.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Points funding discovery at a different esplora instance, or clears the override.
+ ///
+ ///
+ /// The one piece of real configuration this feature has, and it is settable from the page that reports it
+ /// missing: off mainnet there is no sensible default, so an operator who lands on "the explorer could not
+ /// be reached" would otherwise have to go looking for a settings screen that does not exist. A blank value
+ /// clears the override; whether the string is an acceptable URL is the service's judgement, not this
+ /// action's.
+ ///
+ [HttpPost("exit/explorer")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
+ public async Task SetExitExplorer(
+ [FromRoute] string storeId,
+ string? esploraApiUrl,
+ CancellationToken cancellationToken)
+ {
+ if (!Constants.UnilateralExitEnabled)
+ return NotFound();
+
+ if (!ResolveStore(storeId, out var store))
+ return NotFound();
+
+ storeId = store.Id;
+
+ var result = await _unilateralExit
+ .SetExplorerUrlAsync(storeId, esploraApiUrl, cancellationToken)
+ .ConfigureAwait(false);
+
+ RelayExitResult(
+ result,
+ string.IsNullOrWhiteSpace(esploraApiUrl)
+ ? "Explorer override cleared. Funding discovery falls back to the default for this network, "
+ + "which off mainnet means no discovery at all."
+ : "Explorer saved. Funding discovery will use it from the next read of this page.");
+ return RedirectToAction(nameof(Exit), new { storeId });
+ }
+
+ ///
+ /// Puts the service's own outcome in the status banner: its refusal verbatim, or this action's success copy.
+ ///
+ ///
+ /// The result type carries an error but no success message, deliberately — a refusal is the service's
+ /// sentence to write, while "what just worked" is a fact about which button was pressed and belongs to the
+ /// caller. The fallback exists only so a service that fails without saying why still produces a banner
+ /// rather than a silent redirect that looks like success.
+ ///
+ private void RelayExitResult(UnilateralExitOpResult result, string success)
+ {
+ if (result.Success)
+ {
+ TempData[WellKnownTempData.SuccessMessage] = success;
+ return;
+ }
+
+ TempData[WellKnownTempData.ErrorMessage] =
+ result.Error ?? "The unilateral exit could not be updated. Check the server logs for the reason.";
+ }
+
+ ///
+ /// Projects one service read onto the page. Copies fields; reads nothing.
+ ///
+ ///
+ ///
+ /// No deserialisation happens here any more, deliberately. The record's JSON columns are written by
+ /// and now read back by it too, which is why
+ /// arrives typed. A second reader in this class meant two sets of
+ /// serialiser options for one format, and the failure mode of them drifting apart was not an exception —
+ /// it was an empty transaction table for an exit worth a store's whole balance.
+ ///
+ ///
+ /// The two form fields are pre-filled from the active record so the page shows what was quoted rather than
+ /// an empty form beside a live exit. The explorer URL comes off the store's settings instead of the page
+ /// data: it is the input's current value, and posting the explorer form with a blank box is how the
+ /// override is cleared — so a box that rendered empty while an override was set would clear it by
+ /// accident.
+ ///
+ ///
+ private SparkExitViewModel BuildExitViewModel(
+ string storeId, UnilateralExitPageData page, SparkSettings? settings)
+ {
+ var model = new SparkExitViewModel
+ {
+ StoreId = storeId,
+ WalletRunning = page.WalletRunning,
+ DisclosureAcknowledged = page.DisclosureAcknowledged,
+ BalanceSats = page.BalanceSats,
+ ActiveRecord = page.ActiveRecord,
+ History = page.History,
+ FundingReceivedSat = page.FundingReceivedSat,
+ FundingLargestOutputSat = page.FundingLargestOutputSat,
+ LeafCount = page.LeafCount,
+ FundingKeyPath = page.FundingKeyPath,
+ Transactions = page.Transactions ?? [],
+ TransactionsUnreadable = page.TransactionsUnreadable,
+ EsploraApiUrl = settings?.UnilateralExit.EsploraApiUrl,
+ NetworkName = _sweepSettings.Network.ChainName.ToString(),
+ IsMainnet = _sweepSettings.Network.ChainName == ChainName.Mainnet
+ };
+
+ if (page.ActiveRecord is not { } record)
+ return model;
+
+ model.FeeRateSatPerVbyte = record.FeeRateSatPerVbyte;
+ model.DestinationAddress = record.DestinationAddress;
+
+ return model;
+ }
+
+ #endregion
+
#region Stable Balance
///
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/SweepRecord.cs b/BTCPayServer.Plugins.Flint/Data/SweepRecord.cs
index eb8fb6a..e5102d4 100644
--- a/BTCPayServer.Plugins.Flint/Data/SweepRecord.cs
+++ b/BTCPayServer.Plugins.Flint/Data/SweepRecord.cs
@@ -18,7 +18,8 @@ namespace BTCPayServer.Plugins.Flint.Data;
/// behaviour is not documented by the SDK; it was verified against coop exits on a funded regtest run.
///
///
-/// Every sweep this records is a cooperative exit. There is no unilateral-exit path in this plugin.
+/// Every sweep this records is a cooperative exit. A unilateral exit is never recorded here — the
+/// experimental unilateral-exit flow keeps its own records (UnilateralExitRecord).
///
///
/// The row is also the merchant's explanation of a sweep that did not happen: a refusal writes a
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
}
}
diff --git a/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs
new file mode 100644
index 0000000..95d48cd
--- /dev/null
+++ b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs
@@ -0,0 +1,166 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Plugins.Flint.Data;
+using BTCPayServer.Plugins.Flint.Sdk;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
+
+namespace BTCPayServer.Plugins.Flint.Models;
+
+///
+/// The unilateral-exit page: the disclosure, the quote form, the funding instructions, and — once built — the
+/// signed transactions the operator has to broadcast by hand.
+///
+///
+///
+/// One view model for what reads like five pages, because they are five states of the same object and a
+/// merchant should never have to work out which page they are on. Which section renders is decided by
+/// and 's status, never by a query string.
+///
+///
+/// Nothing here is a guard. Every field below is either display state read from
+/// or a form value posted straight back to the
+/// service, which re-validates all of it. The fee-rate bounds and the "acknowledged" flag exist on this type
+/// so the page can be honest about what will be accepted, not so the page can accept anything.
+///
+///
+/// The quote form's two fields — and the explorer URL, which posts to its own action — are the only members
+/// that ever come back off a form. is
+/// for the reason spelled out on —
+/// model binding prefers form values over route values, so a bindable store id is a cross-store hole — and
+/// every piece of display state is so a record read out of the database
+/// cannot fail this form's validation.
+///
+///
+public class SparkExitViewModel
+{
+ [BindNever]
+ public string StoreId { get; set; } = string.Empty;
+
+ /// False hides every form: nothing can be quoted or built without a live wallet.
+ public bool WalletRunning { get; set; }
+
+ ///
+ /// Whether the operator has accepted the disclosure. Stored server-side, so this is a fact about the store
+ /// rather than about this render — the quote form is hidden when it is false and the service refuses anyway.
+ ///
+ public bool DisclosureAcknowledged { get; set; }
+
+ /// The Spark balance, for context beside the quote form.
+ public long BalanceSats { get; set; }
+
+ /// The store's one in-flight exit, or null when there is none.
+ [ValidateNever]
+ public UnilateralExitRecord? ActiveRecord { get; set; }
+
+ /// Newest-first records, terminal ones included, for the history table.
+ [ValidateNever]
+ public IReadOnlyList History { get; set; } = [];
+
+ ///
+ /// What the explorer says sits on the active record's funding address, or null for "unknown".
+ ///
+ ///
+ /// Null and zero are different answers and the page renders them differently. Zero means the explorer
+ /// answered and the operator has not sent anything yet; null means nobody knows — no explorer is configured
+ /// for this network, or the one that is could not be reached — and an operator must not read that as "my
+ /// funding has not arrived".
+ ///
+ public long? FundingReceivedSat { get; set; }
+
+ ///
+ /// The largest single confirmed output on the funding address, or null for "unknown".
+ ///
+ ///
+ /// This, and not , is the figure the build is judged by: the fee-bumping
+ /// transaction spends one outpoint, so five outputs adding up to the requirement fund nothing. The page
+ /// compares this one against the requirement for exactly that reason — a merchant reading a sufficient
+ /// total beside a "not funded yet" build would conclude the plugin was broken and top up again.
+ ///
+ public long? FundingLargestOutputSat { get; set; }
+
+ ///
+ /// The BIP32 path of the funding key, so the funding sats are recoverable from the seed by hand.
+ ///
+ ///
+ /// Shown because the funding address is on a hardened path of the plugin's own that no other wallet will
+ /// derive on its own. An operator who abandons an exit, or whose server dies after they funded one, needs
+ /// this string and their recovery phrase to get that money back — and nowhere else in the product prints
+ /// it.
+ ///
+ public string? FundingKeyPath { get; set; }
+
+ ///
+ /// The signed transactions of a built exit, as the service read them back. Empty until the build has run.
+ ///
+ ///
+ /// Deserialised by , which also owns the write side, so
+ /// there is exactly one set of serialiser options for the format. This page never opens the column itself:
+ /// a malformed one arrives here as and gets rendered as an
+ /// explanation, because an exception thrown inside a Razor template would take the whole page — the
+ /// funding address and the history included — with it.
+ ///
+ [ValidateNever]
+ public IReadOnlyList Transactions { get; set; } = [];
+
+ ///
+ /// True when the record claims to be built but its transaction column could not be read.
+ ///
+ ///
+ /// Surfaced rather than swallowed. That column is the exit — nothing else holds the signed hex —
+ /// so an operator seeing an empty table needs to know whether the build produced nothing or whether the
+ /// page failed to read it, because those call for opposite next steps.
+ ///
+ public bool TransactionsUnreadable { get; set; }
+
+ ///
+ /// How many leaves the active record's quote pinned, or null with no active record.
+ ///
+ ///
+ /// Shown because it is the only figure that tells an operator how much broadcasting is ahead of them: one
+ /// package per branch, each waiting on the previous level's confirmation.
+ ///
+ public int? LeafCount { get; set; }
+
+ ///
+ /// The store's esplora override as currently stored, which is also the explorer form's current value.
+ ///
+ ///
+ /// Rendered into the input rather than left blank on purpose: that form clears the override when it is
+ /// posted empty, so a box that showed nothing while an override was set would delete it the first time
+ /// somebody pressed Save to change something else.
+ ///
+ public string? EsploraApiUrl { get; set; }
+
+ /// The chain this server runs on, named in the copy that depends on it.
+ public string NetworkName { get; set; } = string.Empty;
+
+ ///
+ /// Whether this server is on mainnet, which decides how loudly the explorer form is presented.
+ ///
+ ///
+ /// Off mainnet there is no default explorer at all — mempool.space has no regtest — so funding discovery
+ /// simply refuses until an override is set. On mainnet the override is a privacy preference. Same form,
+ /// two quite different meanings, and the page says which one applies.
+ ///
+ public bool IsMainnet { get; set; }
+
+ /// Fee rate the exit tree is quoted at, in sat/vB.
+ ///
+ /// No [Range] attribute. The bounds live in the service, which refuses out-of-range rates on both
+ /// surfaces; duplicating them here as validation would mean two numbers to keep in step, and the one that
+ /// mattered would be the one nobody edited. The input's min/max are a courtesy to the
+ /// merchant in exactly the way the sweep form's are.
+ ///
+ [Display(Name = "Fee rate")]
+ public long FeeRateSatPerVbyte { get; set; }
+
+ /// Where the recovered coins are swept once the tree has been unrolled.
+ ///
+ /// Baked into the signed sweep transaction, so it cannot be changed after the build — which is why the
+ /// service parses it against the store's network before it persists a record, rather than at build time
+ /// when the operator has already paid for a funding UTXO.
+ ///
+ [Display(Name = "Destination address")]
+ public string? DestinationAddress { get; set; }
+}
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;
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