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

Unilateral exit

+

+ Experimental, and a last resort. Forces this store's balance on-chain without + Spark's cooperation, funded by your own Bitcoin and broadcast by you, over days of timelocks. A + normal sweep is the way to get funds out; this is for when that has stopped working. +

+ + Unilateral exit + + } +

Sweep tuning

diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/Exit.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/Exit.cshtml new file mode 100644 index 0000000..a789416 --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Views/Spark/Exit.cshtml @@ -0,0 +1,768 @@ +@using BTCPayServer.Plugins.Flint.Data +@model SparkExitViewModel +@{ + // No nav entry of its own. A unilateral exit is the last thing a store should ever need, so it is reached + // from the Advanced page and keeps that entry lit rather than advertising itself in the sidebar. + ViewData.SetLayoutModel(new LayoutModel(SparkNavPages.Advanced, "Unilateral exit").SetCategory(SparkNavCategory.Id)); + + // A trail back through the section, so the heading is not the only entry in it. Without one, TitleHeader + // synthesises a single crumb equal to the title and prints the heading twice. + BTCPayServer.ViewDataDictionaryExtensions.SetBreadcrumbs(ViewData, + new BTCPayServer.Components.Breadcrumb.BreadcrumbItem("Flint", Action: "Status", + Controller: "Spark", RouteValues: new { storeId = Model.StoreId }), + new BTCPayServer.Components.Breadcrumb.BreadcrumbItem("Advanced", Action: "Advanced", + Controller: "Spark", RouteValues: new { storeId = Model.StoreId })); + + var record = Model.ActiveRecord; + + // Labels as local functions rather than inline switch expressions, so the markup below stays readable and + // the wording for each state lives in one place. + static string DescribeStatus(UnilateralExitStatus status) => status switch + { + UnilateralExitStatus.AwaitingFunding => "Quoted — waiting for your funding", + UnilateralExitStatus.Built => "Built and signed — waiting for you to broadcast", + UnilateralExitStatus.Completed => "Completed", + _ => "Abandoned" + }; + + static string DescribeKind(SparkExitTxKind kind) => kind switch + { + SparkExitTxKind.Fanout => "Fan-out", + SparkExitTxKind.TreeNode => "Tree node", + SparkExitTxKind.Refund => "Refund", + _ => "Sweep" + }; + + static string DescribeTxStatus(SparkExitTxStatus status) => status switch + { + SparkExitTxStatus.Confirmed => "Confirmed", + SparkExitTxStatus.Unconfirmed => "Not confirmed", + _ => "Unverified" + }; + + // The explorer form appears in two of this page's states — before anything is quoted, and beside the + // funding panel — and never in both at once, so one local function serves both without colliding ids. + // Off mainnet it is not a preference: mempool.space has no regtest or testnet, so funding discovery + // refuses outright until this is set, and the form says so rather than sitting there looking optional. + async Task ExplorerForm() + { + + +
+ + +
+
+ @if (Model.IsMainnet) + { + + An esplora API used only to look up what has arrived on the funding address. Left empty it + is @SparkExitFundingExplorer.MainnetDefaultApiUrl, which means telling + mempool.space which address funds your exit; point it at your own instance if you would + rather not. It never affects what gets built or signed. + + } + else + { + + Required on @Model.NetworkName. There is no public esplora for this + network, so without one Flint cannot tell you whether your funding has arrived — the build + itself will refuse rather than guess. Point this at your own esplora, for example + http://localhost:3002/api. Saving it empty clears it again. + + } +
+ + } +} + + + + + +
+
+ + @if (!Model.WalletRunning) + { + + } + + @if (!Model.DisclosureAcknowledged) + { + @* + The disclosure comes before anything else on the page, and there is deliberately no balance, no + fee field and no address input above it. A merchant who lands here is usually alarmed about + something; the first thing they should learn is that this is almost certainly not the tool they + want. + *@ +

Before you use this

+ + +
+
+ @* + The checkbox is friction, not the gate. What gates quoting is the stored acknowledgement + this post creates — the service refuses to quote without it, on this surface and on any + other — so the box is `required` for the merchant's benefit and the server does not read + it. Nothing here is a permission a form could grant itself. + *@ + + +
+ + + } + else if (record is null) + { + @* + Acknowledged, nothing in flight: the only thing to do here is price one. The balance is context + rather than an input — the quote decides for itself which leaves are worth exiting at the rate + asked for, and it can quite legitimately come back with none of them. + *@ +

Quote an exit

+

+ Nothing is signed, funded or sent by quoting. It prices which of this store's leaves are worth + forcing on-chain at the fee rate you name, and tells you what funding the exit would need. +

+ +
+ + + + + + +
Spark balance@Model.BalanceSats.ToString("N0") sats
+ +
+
+
+ +
+ @* + min/max are a courtesy: the service holds the real bounds and refuses anything + outside them, on this surface and on the API. They are read off the service's own + constants rather than typed in, so the browser and the server cannot disagree + about what will be accepted. + *@ + + sat/vB +
+ +
+ Every transaction in the tree is priced at this rate, and the tree is many + transactions. A rate that will not confirm strands the exit half-broadcast; a rate + far above the mempool wastes your own funding. +
+
+
+ +
+ + + +
+ Where the recovered coins end up. It is signed into the final transaction and + cannot be changed afterwards — an exit built to the wrong address has to + be abandoned and re-funded from scratch. +
+
+ + +
+ + @if (!Model.IsMainnet || Model.EsploraApiUrl is { Length: > 0 }) + { + @* + Offered before there is anything to fund, because off mainnet this is the difference between + a funding panel that reports what has arrived and one that can only shrug. On mainnet it + appears only when somebody has already set it, so an operator can see and change what they + chose without the page pushing a knob nobody needs. + *@ +

Funding discovery

+ await ExplorerForm(); + } + } + else + { + @if (record.LastError is { } lastError) + { + @* + Left on the record rather than cleared on read, so an exit that is still awaiting funding + carries the reason it has not been built. Warning rather than danger: the usual cause is a + funding shortfall, which is a step outstanding, not a failure. + *@ + + } + +

This exit

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Status@DescribeStatus(record.Status)
Recoverable + @record.RecoverableValueSat.ToString("N0") sats +
+ Gross, before the fees below come out of it. +
+
Total fee@record.TotalFeeSat.ToString("N0") sats at @record.FeeRateSatPerVbyte.ToString("N0") sat/vB
Funding required + @record.SingleUtxoFundingSat.ToString("N0") sats in a single UTXO +
+ One output of at least this size. Two outputs adding up to it do not + work — the fee-bumping transaction spends one outpoint, so a split + deposit funds nothing. +
+
Leaves + @if (Model.LeafCount is { } leaves) + { + @leaves.ToString("N0") +
+ Pinned when this was quoted. The build prices these exact leaves again rather + than re-selecting, so what you funded stays what gets built. +
+ } + else + { + @* The service reads this column; a blank here means it could not. *@ + Not readable from this record + } +
Destination@record.DestinationAddress
Funding address + + @if (Model.FundingKeyPath is { Length: > 0 } keyPath) + { + @* + The one string that makes this address recoverable without Flint. If the exit + is abandoned, or this server is gone, funding left sitting here is reachable + from the store's recovery phrase at this path and nowhere else — no wallet + finds it by scanning, because the account is deliberately not a standard one. + Shown for every state of an active record, not only while funding is awaited, + because the moment it matters most is after something has gone wrong. + *@ +
+ Derivation path @keyPath — keep this + with your recovery phrase to be able to reclaim unspent funding by hand. +
+ } +
Quoted@record.CreatedUtc.ToString("u")
+ + @if (record.Status is UnilateralExitStatus.AwaitingFunding) + { +

Fund it

+

+ Send @record.SingleUtxoFundingSat.ToString("N0") sats to the address below as + one single output, from any wallet you control. This is your own Bitcoin + paying the exit's on-chain fees; it is not part of the balance being recovered. +

+
+ + +
+
+ Derived from this store's recovery phrase on a path of the plugin's own, so it is not an + address any other wallet will show you. Send only the funding here — it is not a deposit + address and nothing sent to it reaches the Spark balance. +
+ + + + + + + + + + + + +
+ Largest single output +
+ What the build is actually judged by. +
+
+ @if (Model.FundingLargestOutputSat is { } largest) + { + @largest.ToString("N0") sats + @if (largest < record.SingleUtxoFundingSat) + { + @* + The shortfall is judged here and not on the total, because the total + can be comfortably over the requirement while every individual output + is under it — and in that state the build refuses. A hint that read + "you have enough" beside a refusal is worse than no hint. + *@ +
+ Short of the @record.SingleUtxoFundingSat.ToString("N0") sats this + exit needs in one output. Adding a second payment + does not combine with the first: send the full + @record.SingleUtxoFundingSat.ToString("N0") sats again as one new + output, and the earlier one stays yours to spend back out. +
+ } + else + { +
+ Enough to build with. The build re-checks it against a fresh quote + before signing anything. +
+ } + } + else + { + Unknown + } +
Received in total + @if (Model.FundingReceivedSat is { } received) + { + @received.ToString("N0") sats +
+ Everything confirmed on the address, across however many payments. This + figure does not decide anything — one output has to cover the whole + requirement on its own, because the fee-bumping transaction spends a + single outpoint. +
+ } + else + { + @* + Explicitly not "0 sats". Nobody knows, and a merchant who read this as + "my funding has not arrived" would send it twice. + *@ + + Unknown — the block explorer could not be reached + +
+ This is not the same as zero, and it does not stop you: check the address + in any explorer yourself, or set one below and reload. The build looks the + funding up again itself and refuses rather than half-building. +
+ } +
+ + @if (Model.FundingReceivedSat is null || !Model.IsMainnet || Model.EsploraApiUrl is { Length: > 0 }) + { + await ExplorerForm(); + } + +
+ + +
+ Finds the funding, prices this exit's leaves again, and signs the transactions. + It broadcasts nothing. If the funding is short, nothing is signed and you + can send more and press this again. +
+
+ } + else if (record.Status is UnilateralExitStatus.Built) + { +

Broadcast it yourself

+ + + + + + @if (Model.TransactionsUnreadable) + { + + + @* + Building again is offered here precisely because the copy above says not to abandon. A + state whose only button is the one action its own warning forbids is a dead end, and an + operator in a dead end presses the forbidden button. + *@ +
+ + +
+ Re-discovers the funding, prices this exit's own leaves again, signs, and replaces the + stored set. It broadcasts nothing and anything already broadcast and + confirmed is skipped rather than rebuilt. +
+
+ } + else if (Model.Transactions.Count == 0) + { + + +
+ + +
+ Prices this exit's leaves again against the funding on its address and signs the + transactions. It broadcasts nothing. +
+
+ } + else + { + @* + Everything from here down is signed, broadcastable Bitcoin transactions. A store role that + can only view settings has no business holding them: the hex is enough to move this + store's balance to the destination already baked into it, so it is scoped to the same + permission the build itself is, not to whoever can read the page. + *@ + + +
+ + + + + + + + + + + + + @for (var i = 0; i < Model.Transactions.Count; i++) + { + var tx = Model.Transactions[i]; + var step = i + 1; + + + + + + + + + + + + } + +
#KindStatusTxidCSVDepends on
@step + @DescribeKind(tx.Kind) + @if (tx.NodeId is { } nodeId) + { +
+ +
+ } +
+ @DescribeTxStatus(tx.Status) + @if (tx.Status is SparkExitTxStatus.Unverified) + { +
+ No chain service answered. Check it yourself before treating it as + either. +
+ } +
+ @if (tx.CsvTimelockBlocks is { } csv) + { + @csv.ToString("N0") +
blocks
+ } + else + { + — + } +
+ @if (tx.DependsOn.Count == 0) + { + Nothing + } + else + { + foreach (var parent in tx.DependsOn) + { + + } + } +
+
+ Step @step — what to run + @if (tx.CpfpTxHex is { } cpfpTxHex) + { +

+ A package. This transaction pays no fee of + its own; the second hex is the child that pays for it, and the + two are only valid submitted together. +

+
+ bitcoin-cli submitpackage '["@tx.TxHex","@cpfpTxHex"]' + +
+ } + else + { +

+ Broadcasts alone. This one pays its own fee + and must not be submitted alongside anything else. +

+
+ bitcoin-cli sendrawtransaction @tx.TxHex + +
+ } +
+ @tx.TxHex + +
+ @if (tx.CpfpTxHex is { } childHex) + { +
+ @childHex + +
+ } +
+
+ +
+ + +
+ If a fee turned out too low, send more funding and build again: steps that have + already confirmed are skipped and the rest are rebuilt. It broadcasts nothing either + way. +
+
+
+ } + + @* + The exit's own ending. Nothing in Phase 0 watches the chain, so the plugin cannot notice that + the sweep confirmed — without this button the only way a finished exit leaves the active state + is "abandon", and telling a merchant to abandon the thing that just worked is how a page + teaches them to distrust it. + *@ +
+

When it is done

+
+ + +
+ Records your confirmation that you broadcast the set and the sweep confirmed + on-chain. It moves nothing and broadcasts nothing — Flint has no way + to check the chain itself here, so this is a note you are writing, not a verification. + It frees this store to quote another exit and keeps the record out of the way. +
+
+
+ } + +
+

Abandon

+
+
+ + +
+ Frees this store to quote a new exit. It cancels nothing. Any + transaction you have already broadcast stays valid and will still confirm, and the + funding you sent is not returned — it stays on the funding address, spendable from + this store's recovery phrase at the derivation path shown above. + @if (Model.TransactionsUnreadable) + { + + This exit's transactions could not be read. Abandoning it + does not undo anything that was signed, and the signed set stays stored: + build again first if you have not already, so you can see what exists before + you set this record aside. + + } +
+
+
+
+ } + + @if (Model.History.Count > 0) + { +

Earlier exits

+ + + + + + + + + + + + @foreach (var past in Model.History) + { + + + + + + + + } + +
StatusQuotedDestinationRecoverableFee
@past.Status@past.CreatedUtc.ToString("u")@past.RecoverableValueSat.ToString("N0") sats@past.TotalFeeSat.ToString("N0") sats
+ } +
+ + +@section PageFootContent { + +} From 995f42e3b79927256b62a8a51aad62e0adf7d347 Mon Sep 17 00:00:00 2001 From: sethforprivacy <40500387+sethforprivacy@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:34:38 -0400 Subject: [PATCH 06/22] Update docs and copy for the experimental unilateral exit The trust model, limitations, sweeping docs and README no longer claim the plugin has no unilateral-exit path anywhere; they now scope the truth: every automated flow remains a cooperative exit, and the one unilateral path is the experimental, environment-gated, manually broadcast flow on the Advanced page. The limitations entry states the four hard limits plainly - the plugin never broadcasts, the pinned SDK still needs the operators reachable, the CPFP funding is hand-supplied, and settlement waits out multi-day CSV timelocks - plus the explorer disclosure caveat. Sweep-engine, sweep-record and Greenfield comments are rescoped the same way; the API remains deliberately exit-free. --- .../GreenfieldSparkProvisioningTests.cs | 2 +- .../Controllers/GreenfieldSparkController.cs | 3 +- .../Data/SweepRecord.cs | 3 +- .../Services/SparkSweepEngine.cs | 5 ++-- .../Views/Spark/SweepConfirm.cshtml | 4 +-- README.md | 5 ++-- docs/limitations.md | 13 +++++++++ docs/sweeping.md | 3 +- docs/trust-model.md | 29 ++++++++++++++++--- 9 files changed, 53 insertions(+), 14 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs b/BTCPayServer.Plugins.Flint.Tests/GreenfieldSparkProvisioningTests.cs index b77f609..0f38c4c 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/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/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/Services/SparkSweepEngine.cs b/BTCPayServer.Plugins.Flint/Services/SparkSweepEngine.cs index b4fd2df..b373647 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkSweepEngine.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkSweepEngine.cs @@ -91,8 +91,9 @@ public sealed record SweepPreview( /// /// /// Every sweep here is a cooperative exit — PrepareSendPayment/SendPayment to a Bitcoin -/// address through the service provider. There is no unilateral-exit path in this class, in this plugin, or in -/// its UI, by owner decision. "Drain" means the SDK's +/// address through the service provider. There is no unilateral-exit path in this class or anywhere sweeping +/// can reach; the plugin's only unilateral path is the experimental, gated flow on the Advanced page, entirely +/// separate from this engine. "Drain" means the SDK's /// FeePolicy.FeesIncluded and nothing else. /// /// diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/SweepConfirm.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/SweepConfirm.cshtml index 9ed57b4..5371a24 100644 --- a/BTCPayServer.Plugins.Flint/Views/Spark/SweepConfirm.cshtml +++ b/BTCPayServer.Plugins.Flint/Views/Spark/SweepConfirm.cshtml @@ -43,8 +43,8 @@

This is a cross-chain send: the wallet transfers to @cc.Route.Provider's Spark address and the provider delivers on - @cc.Route.Chain. It is a normal Spark transfer at the point money leaves this wallet — there is - no unilateral exit involved. + @cc.Route.Chain. It is a normal Spark transfer at the point money leaves this wallet — not a + unilateral exit.

diff --git a/README.md b/README.md index 95e3619..ecb9d93 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ chain; and it can be held in USDB between sweeps. **What you are trusting, in one paragraph.** Spark is a 2-of-3 statechain operated by Lightspark, Breez and Flashnet. A balance sitting on it is not in your sole custody the way an on-chain UTXO or a channel -you own is: every Lightning receive rides Lightspark's service provider, and this plugin performs -**cooperative exits only** — it offers no unilateral-exit path anywhere in its UI or its code. Sweeping is +you own is: every Lightning receive rides Lightspark's service provider, and every automated flow in this plugin performs +**cooperative exits only** — the sole unilateral-exit path is an experimental, environment-gated flow on the +Advanced page whose transactions the operator broadcasts by hand. Sweeping is the only thing that reduces that exposure, which is why the sweep threshold is the most important setting on the plugin. Stable Balance and cross-chain sweeps each add a further counterparty of a different kind: a regulated stablecoin issuer whose token metadata says it can **freeze** the balance, and a bridge diff --git a/docs/limitations.md b/docs/limitations.md index 27a5ad9..db3599b 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -135,6 +135,19 @@ the wallet on-chain](deposits.md). Closing this properly would mean the plugin doing all the claiming itself, which trades an overpayment risk for a worse one — a claim loop that stops means no deposit ever arrives — and is not something to ship without mainnet evidence behind it. +- **The unilateral exit is experimental, manual, and narrower than the name suggests.** It exists behind an + environment gate (`FLINT_EXPERIMENTAL_UNILATERAL_EXIT`) on the Advanced page and carries four limits that + do not show from the name alone. The plugin **never broadcasts**: it quotes, funds and signs, and the + operator pushes every transaction out by hand, package by package, through a node that supports package + relay — a plain `sendrawtransaction` rejects the zero-fee tree transactions. Building an exit **still + requires the Spark operators to be reachable** on the pinned SDK (0.22.0); exiting from purely local state + arrives with a later SDK release, so today this path defends against operators who stop cooperating, not + operators who are gone. The fees are paid from a **separate on-chain output the operator funds by hand**, + as a single output covering the quoted amount, on an address derived from the store's seed at a documented + path. And settlement is **not fast**: refunds carry multi-day CSV timelocks, and nothing in the plugin + watches the chain on the operator's behalf. Funding discovery also asks a block explorer + (mempool.space by default on mainnet, configurable) about the funding address, which discloses that + address to a third party unless an own instance is configured. - **Neither post-MVP feature can be tested off mainnet.** Cross-chain sending is hard-gated — the SDK throws at connect on any other network — and Stable Balance is *accepted* on regtest and then never converts, because USDB does not exist there. So the unit tests run against a fake built to model the real SDK's diff --git a/docs/sweeping.md b/docs/sweeping.md index 7e655af..011ae10 100644 --- a/docs/sweeping.md +++ b/docs/sweeping.md @@ -15,7 +15,8 @@ the swept amount") live on the **Advanced** page, because their defaults — no the swept amount — are right for almost every store. **Every sweep is a cooperative exit** — the plugin asks Spark's service providers to build and broadcast a -Bitcoin transaction. There is no unilateral-exit path in this plugin, in its UI, or anywhere in its code. +Bitcoin transaction. Sweeping never performs a unilateral exit — the only unilateral path in the plugin is +the experimental, environment-gated flow on the Advanced page, which nothing here can reach or trigger. "Take the exit fee out of the swept amount" is the SDK's `FeesIncluded` fee policy on a cooperative exit and nothing more. diff --git a/docs/trust-model.md b/docs/trust-model.md index 6345310..24fff7c 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -8,10 +8,31 @@ every Lightning receive rides Lightspark's service provider, and unilateral exit resort that requires reachable operators and an external UTXO. Keeping the auto-sweep threshold low is the best available mitigation, since it bounds how much is ever exposed on the L2. -The plugin performs **cooperative exits only** (owner decision). It offers no unilateral-exit path, so if the -Spark operators were to become unavailable or refuse to process an exit, recovering funds would mean using -the store's recovery phrase with another Spark wallet implementation. Set the sweep threshold according to -how much you are willing to have depend on those operators; sweeping is the only thing that reduces it. +Every sweep this plugin makes is a **cooperative exit**, and that is the only automated path off Spark: the +operators build and broadcast one Bitcoin transaction for a flat fee, and it lands in seconds. Set the sweep +threshold according to how much you are willing to have depend on those operators; sweeping is the only thing +that reduces it. + +There is now an **experimental unilateral exit**, and it is deliberately hard to reach: it appears on the +store's Advanced page only when the server operator sets `FLINT_EXPERIMENTAL_UNILATERAL_EXIT=1` in BTCPay's +environment, and without that variable the page shows nothing and the routes do not exist. Read what it is +before counting on it: + +- **The plugin never broadcasts.** It asks the SDK to build and sign the statechain's timelocked transaction + tree and then shows you the raw transactions; pushing them, in dependency order, with `submitpackage` where + a transaction and its fee-bumping child go together, is your job. +- **It still needs the operators reachable.** On the pinned SDK, preparing an exit talks to them — so the + scenario you most want this for, operators gone for good, is the one it cannot serve yet. That changes when + the SDK ships exit-from-local-state. +- **You have to fund it on-chain first.** The tree transactions cannot pay their own fees, so the exit is + bumped by CPFP from a native-SegWit UTXO you send to an address the plugin derives from the store's seed at + its own hardened account. Too little there and nothing gets built. +- **It settles in days, not seconds.** The outputs are behind CSV timelocks measured in blocks; the money is + spendable when the last one expires, not when the transactions are signed. + +So it is a last resort that costs days and attention, not a second sweep destination. If the operators +became unavailable and this path did not get you out, recovering funds still means using the store's recovery +phrase with another Spark wallet implementation. **Stable Balance adds a second counterparty, and a different kind.** Holding the store's balance in USDB means holding a token issued by a regulated stablecoin issuer whose metadata says it is **freezable**: the From bce853e942af6801a0f71d32eedee8c8f75a3938 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:48:12 -0400 Subject: [PATCH 07/22] Bump Breez.Sdk.Spark to 0.25.0 and rework the exit seam for the new API --- .../Sdk/ISparkSdkClient.cs | 123 ++++++++-- BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs | 64 ++---- .../Sdk/SparkExitModel.cs | 164 +++++++++++++- .../Sdk/SparkSdkClient.cs | 210 ++++++++++++++++-- 4 files changed, 463 insertions(+), 98 deletions(-) diff --git a/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs b/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs index 12bc401..e995f53 100644 --- a/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs +++ b/BTCPayServer.Plugins.Flint/Sdk/ISparkSdkClient.cs @@ -438,14 +438,16 @@ Task ReceiveCrossChainAsync( /// 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, free of side effects, and made from what the wallet already holds locally. Unlike the send + /// paths it reserves nothing, expires nothing and mints no quote id. Since SDK 0.25 it also reads each + /// leaf's pre-signed transaction chain out of local storage rather than from the operators, which is what + /// makes an exit quotable at all in the situation the feature exists for. What it cannot do is obtain that + /// data: a leaf can be exited this way only once its chain has been collected, which happens on + /// and in the background as funds arrive. /// /// - /// 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. + /// The quote is a description of the wallet's tree at one moment and the tree moves as payments settle, so + /// a quote is not carried across a request boundary. takes its own. /// /// Task PrepareUnilateralExitAsync( @@ -460,8 +462,7 @@ Task PrepareUnilateralExitAsync( /// /// /// 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. + /// the operator funded for, so the leaves and the funding requirement cannot drift apart underneath them. /// /// /// Confirmed P2WPKH outputs that will pay every fee in the exit. Must be non-empty. The SDK accepts @@ -482,26 +483,32 @@ Task PrepareUnilateralExitAsync( /// /// /// - /// 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. + /// Quote and build are one call, and since 0.25 that is a property of the SDK rather than a choice + /// here. The prepared response is what the build consumes — it carries the tree walk the build works + /// from — so a caller cannot hold one across a request boundary even if it wanted to. The reason it would + /// not want to is unchanged: 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. /// /// /// 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. + /// inert; an operator pushes them out by hand. Which of them may go out now is read off each + /// transaction's , not derived here: since 0.25 the SDK reports + /// readiness per transaction (ready, waiting on a dependency, waiting on a timelock, already confirmed, + /// or unverifiable) and that is authoritative. That is also what makes the failure modes here benign: + /// every exception this can throw has moved no coins. + /// + /// + /// A later call is how a stuck exit is recovered. Because the SDK reads confirmed chain state, steps that + /// have already confirmed are not rebuilt, and everything still outstanding is rebuilt at the fee rate + /// passed here — which replaces the earlier version on the network. A partial or empty transaction set is + /// a valid outcome of that, not a failure. /// /// /// 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, @@ -511,6 +518,84 @@ Task UnilateralExitAsync( Func approveQuote, CancellationToken cancellationToken = default); + /// + /// Asks the chain how far a built exit has got, and what to do about it. + /// + /// + /// + /// This reads the chain and nothing else — no wallet, no leaves, no signer, no funding — which is + /// what makes an exit followable on a device that has lost everything but the stored response. An exit runs + /// for days, so this is called after each broadcast and whenever a page wants to show progress. + /// + /// + /// The statuses it reports replace the ones in . The verdict is what the + /// caller switches on: means carry on broadcasting whatever is ready, + /// means the money is at the destination address, + /// means this transaction set can no longer finish as it stands and the + /// exit has to be quoted and built again from the same leaves. The funds are not lost in that case — they + /// are still in the tree, or in an output the plugin controls — but nothing can be salvaged from the stored + /// set, so a caller must not present "redo" as a pause. + /// + /// + /// A caller that has persisted the exit can rebuild the response this needs from its own record; see + /// . Broadcasting an already-confirmed transaction is harmless, so a caller + /// never has to remember what it sent. + /// + /// + Task CheckUnilateralExitAsync( + SparkExitResult exit, + CancellationToken cancellationToken = default); + + /// + /// Exports the SDK's unilateral-exit backup blob for this wallet. + /// + /// + /// + /// The transactions an exit is built from live in the SDK's local storage. While the operators are + /// reachable they can be fetched again, so a wallet restored from its seed rebuilds them; when that storage + /// is gone and the operators are unreachable they cannot be recovered from anywhere, and the leaves they + /// cover cannot be exited. This is the way to keep that data somewhere the wallet's own storage cannot take + /// with it, and it works for every leaf the wallet holds, not just the ones an exit has been quoted for. + /// + /// + /// The value is sensitive and the caller owns protecting it. Carrying every leaf and its + /// transactions, it discloses the wallet's balance, how that balance is split, and the history of what the + /// wallet received and spent. It can reach several megabytes. Whatever stores it has to encrypt it, and it + /// must never be logged or shown. + /// + /// + Task ExportUnilateralExitStateAsync(CancellationToken cancellationToken = default); + + /// + /// Puts an exported blob back into this wallet's storage. + /// + /// + /// A value from on the same network, and the same + /// wallet: a leaf is taken only when the exit state records this wallet as its owner, and the rest are + /// reported back in . + /// + /// + /// + /// It does not contact the operators, so it works while they are unreachable — which is the whole point of + /// having the backup. A restart of the plugin does this on its own when a store has one configured. + /// + /// + /// Imports never make a leaf less exitable: an exported value carries no mark of when it was + /// taken, so the wallet keeps whatever usable exit data it already has and the imported copy is used only + /// for a leaf it has nothing for, and only when that copy is complete on its own. + /// is the one count that means data could not + /// be put back, because the copy disagrees with a node the wallet already holds on a value that cannot + /// change. + /// + /// + /// An out-of-date value can restore leaves that have since been spent, so the reported balance may read + /// high until the next sync reconciles it with the operators. + /// + /// + Task ImportUnilateralExitStateAsync( + string exitState, + CancellationToken cancellationToken = default); + #endregion /// diff --git a/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs b/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs index f1145ff..1af19a0 100644 --- a/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs +++ b/BTCPayServer.Plugins.Flint/Sdk/SparkErrors.cs @@ -46,7 +46,13 @@ 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), -#1 @both + // The two typed cross-chain refusals (0.26) carry the provider's own reason in a named field rather + // than v1, and the reason is the whole message: which bound an amount missed, or that a route is down. + SdkException.CrossChainAmountOutOfRange outOfRange => Strip(outOfRange.reason), + SdkException.CrossChainRouteUnavailable unavailable => Strip(unavailable.reason), + SdkException.DepositClaimInProgress => + "A claim for this deposit is already in progress. Nothing more is needed; check the balance shortly.", + SdkException.InsufficientCpfpFunds shortfall => DescribeCpfpShortfall(ToSats(shortfall.requiredSat)), // 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), @@ -143,18 +149,17 @@ public static bool IsNotFound(Exception exception) } /// - /// Turns the two unilateral-exit-specific SDK errors into typed plugin exceptions, or returns null when the + /// Turns the unilateral-exit-specific SDK error into a typed plugin exception, 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. + /// There is now exactly one of these, and there used to be two. SDK 0.25 removed + /// SdkException.FundingUtxoConflict along with the build shape that could produce it: a conflict is + /// no longer reported as an error at all, because an earlier attempt's spent funding is followed to + /// whatever it became rather than rejected, and fresh funding can be passed alongside the old. What + /// remains is the shortfall, which a caller still has to act on and which needs the number the SDK + /// provides — the figure to put in front of an operator who has to top a funding address up. /// /// /// Returns null rather than the original exception so a call site can use it as an exception filter and let @@ -168,8 +173,6 @@ public static bool IsNotFound(Exception exception) { SdkException.InsufficientCpfpFunds shortfall => new SparkExitFundingShortfallException(ToSats(shortfall.requiredSat), shortfall), - SdkException.FundingUtxoConflict conflict => - new SparkExitFundingUtxoConflictException(conflict.txid, conflict.vout, conflict), _ => null }; } @@ -180,13 +183,6 @@ internal static string DescribeCpfpShortfall(long requiredSat) => string.Format( + "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: @@ -228,35 +224,3 @@ public SparkExitFundingShortfallException(long requiredSat, Exception? innerExce /// 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 index d731d15..07c355f 100644 --- a/BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs +++ b/BTCPayServer.Plugins.Flint/Sdk/SparkExitModel.cs @@ -48,27 +48,79 @@ public enum SparkExitTxKind /// 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. +/// +/// This is the SDK's ExitTransactionStatus union, collapsed into one type that carries its case. +/// SDK 0.25 replaced the flat Confirmed/Unconfirmed/Unverified enum with a union, because +/// the useful question stopped being "is it mined" and became "may I broadcast it yet, and if not, what am I +/// waiting for". A flat enum cannot answer that, and a plugin-side mirror of the union would put +/// Breez.Sdk.Spark types into the persisted record, which is exactly what this seam exists to prevent. +/// A single type with a discriminant carries the same information and survives +/// serialisation. +/// +/// +/// The member order of is deliberately not the SDK's. Nothing in the +/// SDK's union has an ordinal to mirror, and this order puts +/// first so that a default-initialised value, a missing JSON field, or a column added to an existing row all +/// read as "not ready to broadcast" rather than as "ready". Ordering it the other way is instructions to +/// broadcast a transaction whose timelock has not matured. +/// /// -public enum SparkExitTxStatus +public enum SparkExitTxReadiness { - /// Broadcast (or buildable) but not yet mined. - Unconfirmed, + /// + /// Its inputs are not yet where they need to be — either something in + /// has not confirmed, or its CSV timelock has not matured. Do not broadcast. + /// + Waiting, - /// Mined. + /// Broadcast it now. Sending one that is already sent is harmless. + Ready, + + /// Already mined; skip it and broadcast the next step. 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. + /// The SDK could not read the chain for this transaction, so it cannot say whether broadcasting is safe. + /// Not a failure and not a confirmation — the SDK's own guidance is to build the exit again once the chain + /// service is healthy. An operator must check the transaction themselves before treating it as either. /// Unverified } +/// +/// Where one transaction of an exit stands: its readiness, and whatever height that readiness implies. +/// +/// +/// +/// The two heights are meaningful in different cases and null otherwise, kept as two fields rather than one so +/// that neither can be read as the other. is set only for +/// and is the height the transaction landed at, which is what a +/// child's CSV timelock counts from. is set only for +/// when the input is confirmed but the timelock has not matured, and +/// is the first block the transaction can be mined in. The SDK reports either as nullable, so both are +/// nullable here. +/// +/// +/// The readiness is what the page and the record switch on; the heights are shown to an operator so "wait" is +/// a number rather than an instruction to keep refreshing. +/// +/// +public sealed record SparkExitTxStatus( + SparkExitTxReadiness Readiness, + uint? BlockHeight = null, + uint? SpendableAtHeight = null) +{ + /// Shorthand for the state every freshly built, unbroadcast transaction is in. + public static SparkExitTxStatus Ready { get; } = new(SparkExitTxReadiness.Ready); + + /// True when this transaction may be broadcast right now. + /// + /// The single predicate the page and the operator's checklist both use, so "ready" cannot come to mean two + /// different things in two places. + /// + public bool CanBroadcast => Readiness is SparkExitTxReadiness.Ready; +} + /// /// One statechain leaf a quoted exit would recover. /// @@ -254,6 +306,96 @@ public sealed record SparkExitResult( IReadOnlyList Transactions, IReadOnlyList Leaves); +/// +/// What to do with a built exit, as the chain currently reports it. +/// +/// +/// The SDK's UnilateralExitVerdict. Three cases rather than a boolean because the three call for +/// genuinely different actions, and — the important part — "this cannot finish" is not an error: the money is +/// still recoverable, it just needs a new exit built from the same leaves. +/// +public enum SparkExitVerdict +{ + /// + /// On track. Broadcast every transaction whose is ready, and call + /// again later. This is the ordinary state of an exit that is part-way through its timelocks. + /// + Valid, + + /// + /// Every transaction has confirmed, the sweep included. The money is at the destination address and there + /// is nothing left to do. + /// + Done, + + /// + /// This transaction set can no longer finish: something on-chain stopped matching it — a different refund + /// for a leaf confirmed, a step was fee-bumped in a way these transactions cannot follow, or funding they + /// counted on went elsewhere. The fix is always the same: quote and build the exit again, naming the + /// same leaves. The funds are not lost. + /// + Redo +} + +/// +/// The result of asking the chain how far a built exit has got. +/// +/// +/// +/// replaces the set that was passed in: these are the same transactions with their +/// statuses brought up to date, and they are what a caller should store back over what it had. Everything else +/// is the SDK's own echo of the exit it was handed, and the two totals are carried again rather than assumed +/// unchanged. +/// +/// +/// The verdict is what a caller switches on, and it is the only field that decides an action. It is +/// deliberately not derived from the statuses by this plugin: the SDK reads the chain tip to tell "waiting" +/// from "cannot finish", and re-deriving it here would be a second opinion with less information. +/// +/// +/// What to do next — see . +/// +/// Every transaction of the exit, in the SDK's own broadcast order, with chain-reported statuses. +/// +public sealed record SparkExitProgress( + SparkExitVerdict Verdict, + long RecoverableValueSat, + long TotalFeeSat, + IReadOnlyList Transactions); + +/// +/// What an import of an exit-state backup actually took, and what it left behind. +/// +/// +/// +/// The counts are not decoration: importing is the recovery path of last resort, and a caller that reports +/// only "imported" would be telling an operator their backup is in place when the part of it that matters was +/// skipped. Each number names a different reason nothing was restored, and they call for different responses. +/// +/// +/// The two benign skips — and — are normal for a +/// backup taken from a wallet that has since moved on, because an imported copy is used only for a leaf the +/// wallet has nothing usable for. is the one that means data could not +/// be put back: the copy disagrees with a node the wallet already holds on a value that cannot change over a +/// node's lifetime, so one of the two copies is simply wrong and nothing in that entry is trusted. +/// +/// +public sealed record SparkExitStateImport( + uint ImportedLeaves, + uint SkippedForeignLeaves, + uint SkippedConflictingLeaves, + uint SkippedChains) +{ + /// + /// True when no leaf's exit data was restored at all. + /// + /// + /// Worth naming because it is the answer that looks like success from the outside — the call returned, the + /// blob parsed, nothing threw — while the wallet is exactly as un-exitable as it was before. + /// + public bool RestoredNothing => ImportedLeaves == 0; +} + /// /// Raised when the caller's quote approval callback vetoed an exit, so nothing was built. /// diff --git a/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs b/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs index f7d48ab..069e8bd 100644 --- a/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs +++ b/BTCPayServer.Plugins.Flint/Sdk/SparkSdkClient.cs @@ -1036,9 +1036,10 @@ public async Task UnilateralExitAsync( 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. + // Quoted inside this call rather than accepted from the caller, for the same reason as before the SDK + // change: a quote describes the wallet's tree, which moves under it, and 0.25 made the quote the thing + // the build consumes rather than an argument it re-derives from. A caller that held one across a + // request boundary would hand back a document describing leaves the wallet no longer has. var prepared = await PrepareExitAsync(feeRateSatPerVbyte, destinationAddress, leafIds) .ConfigureAwait(false); var quote = MapExitQuote(prepared); @@ -1067,9 +1068,9 @@ public async Task UnilateralExitAsync( } 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. + // A funding shortfall means the outputs were wrong, not that the exit is impossible, and it + // built and broadcast nothing. Raised as a typed exception so the service above can put the + // SDK's own number in front of an operator. throw typed; } @@ -1094,6 +1095,64 @@ public async Task UnilateralExitAsync( } } + public async Task CheckUnilateralExitAsync( + SparkExitResult exit, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(exit); + + var request = new CheckUnilateralExitRequest(ToSdkExit(exit)); + var checkedExit = await _sdk.CheckUnilateralExit(request).ConfigureAwait(false); + + var verdict = checkedExit.verdict switch + { + UnilateralExitVerdict.Done => SparkExitVerdict.Done, + UnilateralExitVerdict.Redo => SparkExitVerdict.Redo, + UnilateralExitVerdict.Valid => SparkExitVerdict.Valid, + _ => throw new ArgumentOutOfRangeException( + nameof(exit), checkedExit.verdict?.GetType().Name, + "Spark returned a unilateral-exit verdict this plugin does not know.") + }; + + return new SparkExitProgress( + verdict, + ToLong(checkedExit.exit.recoverableValueSat), + ToLong(checkedExit.exit.totalFeeSat), + MapExitTransactions(checkedExit.exit.transactions)); + } + + /// + /// Exports the SDK's unilateral-exit backup blob for this wallet. + /// + /// + /// See : this is the one thing that makes the + /// exit data survivable without the operators, and it is sensitive because it describes the wallet's whole + /// balance and its history. + /// + public async Task ExportUnilateralExitStateAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var exported = await _sdk.ExportUnilateralExitState().ConfigureAwait(false); + return exported.exitState; + } + + public async Task ImportUnilateralExitStateAsync( + string exitState, + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ArgumentException.ThrowIfNullOrWhiteSpace(exitState); + + var imported = await _sdk + .ImportUnilateralExitState(new ImportUnilateralExitStateRequest(exitState)) + .ConfigureAwait(false); + + return new SparkExitStateImport( + imported.importedLeaves, imported.skippedForeignLeaves, + imported.skippedConflictingLeaves, imported.skippedChains); + } + /// /// One PrepareUnilateralExit, with the response's own echo of the request checked. /// @@ -1115,8 +1174,9 @@ private async Task PrepareExitAsync( 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(), + // funding kind that disagrees with the input supplied later produces a signature that does not + // verify. + ToSdkFundingKind(), destinationAddress, ToSdkLeafSelection(leafIds))) .ConfigureAwait(false); @@ -1125,6 +1185,16 @@ private async Task PrepareExitAsync( return prepared; } + /// + /// The funding kind every exit in this plugin is quoted with. + /// + /// + /// Named once, because the quote and the build have to agree on it. builds + /// the P2WPKH half of the same decision, and the two are deliberately adjacent so a future funding kind + /// cannot be added to one without the other. + /// + internal static CpfpFundingKind ToSdkFundingKind() => new CpfpFundingKind.P2wpkh(); + /// /// Refuses a quote that does not describe the exit that was asked for. /// @@ -1229,6 +1299,84 @@ private static IReadOnlyList MapExitTransactions( UnilateralExitTransaction[]? transactions) => transactions is null ? [] : transactions.Select(MapExitTransaction).ToList(); + /// + /// Rebuilds an SDK exit response from the stored result, to hand back to CheckUnilateralExit. + /// + /// + /// + /// CheckUnilateralExit reads the chain and nothing else — no wallet, no leaves, no signer, no + /// funding — which is what makes an exit followable on a device that has lost everything but the stored + /// response. So the reconstruction is exact rather than approximate: every transaction is handed back with + /// the status the stored copy carries, because the SDK replaces those statuses from the chain and would + /// otherwise be judging a set whose dependsOn edges had been quietly dropped. + /// + /// + /// The fields the check does not read (cpfpFeeSat, fanoutFeeSat, sweepFeeSat) are + /// reconstructed as zero rather than as their real values, because the plugin's persisted + /// does not keep them — it keeps the two totals an operator is shown. That is + /// a deliberate omission and not an oversight: adding fields to the record for numbers nothing reads would + /// be cargo, and the verdict and the per-transaction statuses are what the follow-up flow acts on. + /// + /// + internal static UnilateralExitResponse ToSdkExit(SparkExitResult exit) + { + ArgumentNullException.ThrowIfNull(exit); + + return new UnilateralExitResponse( + ToUlong(exit.RecoverableValueSat), + ToUlong(exit.TotalFeeSat), + cpfpFeeSat: 0, + fanoutFeeSat: 0, + sweepFeeSat: 0, + exit.Leaves.Select(leaf => new UnilateralExitLeaf(leaf.LeafId, ToUlong(leaf.ValueSat))).ToArray(), + exit.Transactions.Select(ToSdkExitTransaction).ToArray(), + // Not needed by the check, and not reconstructible from what is stored: the funding outputs are + // followed by the SDK at build time, and an exit that is being followed does not rebuild. + fundingInputs: []); + } + + internal static UnilateralExitTransaction ToSdkExitTransaction(SparkExitTransaction transaction) + { + ArgumentNullException.ThrowIfNull(transaction); + + return new UnilateralExitTransaction( + ToSdkExitTxKind(transaction.Kind), + transaction.NodeId, + transaction.Txid, + transaction.TxHex, + transaction.CpfpTxHex, + transaction.CsvTimelockBlocks, + transaction.DependsOn.ToArray(), + ToSdkExitTxStatus(transaction.Status)); + } + + internal static UnilateralExitTxKind ToSdkExitTxKind(SparkExitTxKind kind) => kind switch + { + SparkExitTxKind.Fanout => UnilateralExitTxKind.FanOut, + SparkExitTxKind.TreeNode => UnilateralExitTxKind.Node, + SparkExitTxKind.Refund => UnilateralExitTxKind.Refund, + SparkExitTxKind.Sweep => UnilateralExitTxKind.Sweep, + _ => throw new ArgumentOutOfRangeException( + nameof(kind), kind, "Unknown plugin unilateral-exit transaction kind.") + }; + + internal static ExitTransactionStatus ToSdkExitTxStatus(SparkExitTxStatus status) + { + ArgumentNullException.ThrowIfNull(status); + + return status.Readiness switch + { + SparkExitTxReadiness.Confirmed => new ExitTransactionStatus.Confirmed(status.BlockHeight), + SparkExitTxReadiness.Ready => new ExitTransactionStatus.Ready(), + SparkExitTxReadiness.Unverified => new ExitTransactionStatus.Unverified(), + // The plugin collapses the SDK's two waiting cases into one, because what a merchant needs to know + // is "not yet" and, when it is a timelock, from which height. Handing it back as + // WaitingForDependencies is the safe reconstruction: both are "do not broadcast", and the SDK + // replaces the status from the chain on the way out anyway. + _ => new ExitTransactionStatus.WaitingForDependencies() + }; + } + internal static SparkExitTransaction MapExitTransaction(UnilateralExitTransaction transaction) { ArgumentNullException.ThrowIfNull(transaction); @@ -1261,19 +1409,45 @@ internal static SparkExitTransaction MapExitTransaction(UnilateralExitTransactio "Spark returned a unilateral-exit transaction of a kind this plugin does not know how to broadcast.") }; + /// + /// Collapses the SDK's status union onto the plugin's readiness plus whatever height that case carries. + /// /// - /// 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. + /// + /// Mapped by case rather than by ordinal, and an unknown case is a hard failure. Ready is the one + /// answer that authorises a broadcast, so falling back to it for a variant this plugin has never seen would + /// be exactly the wrong default — a new SDK state would arrive as permission to push a transaction out. + /// + /// + /// The two waiting cases collapse into deliberately: a merchant + /// needs to know "not yet" and, when it is a timelock, the height it unlocks at. Which of the SDK's two + /// reasons it is changes nothing an operator does, and + /// carries the part that does. + /// /// - internal static SparkExitTxStatus MapExitTxStatus(ConfirmationStatus status) => status switch + internal static SparkExitTxStatus MapExitTxStatus(ExitTransactionStatus status) { - ConfirmationStatus.Confirmed => SparkExitTxStatus.Confirmed, - ConfirmationStatus.Unconfirmed => SparkExitTxStatus.Unconfirmed, - ConfirmationStatus.Unverified => SparkExitTxStatus.Unverified, - _ => throw new ArgumentOutOfRangeException( - nameof(status), status, "Unknown Spark confirmation status.") - }; + ArgumentNullException.ThrowIfNull(status); + + return status switch + { + ExitTransactionStatus.Confirmed confirmed => + new SparkExitTxStatus(SparkExitTxReadiness.Confirmed, BlockHeight: confirmed.blockHeight), + ExitTransactionStatus.Ready => + new SparkExitTxStatus(SparkExitTxReadiness.Ready), + ExitTransactionStatus.WaitingForTimelock timelock => + new SparkExitTxStatus( + SparkExitTxReadiness.Waiting, SpendableAtHeight: timelock.spendableAtHeight), + ExitTransactionStatus.WaitingForDependencies => + new SparkExitTxStatus(SparkExitTxReadiness.Waiting), + ExitTransactionStatus.Unverified => + new SparkExitTxStatus(SparkExitTxReadiness.Unverified), + _ => throw new ArgumentOutOfRangeException( + nameof(status), status.GetType().Name, + "Spark returned a unilateral-exit transaction status this plugin does not know how to " + + "broadcast; refusing to guess whether it is safe to send.") + }; + } #endregion From 3dcf19ee72bd29d2551fd51a3078896daf849b21 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:59:10 -0400 Subject: [PATCH 08/22] Rework the unilateral exit for the Breez SDK 0.25 flow: check-in, status model, backup The 0.25 exit API inverts the flow PR 19 was written against: prepare now takes the funding kind and returns the document the build consumes, transactions carry a status union rather than a flat confirmation enum, and a wallet can be checked against the chain and backed up. This reworks the seam, the record and service, the page and the suite onto it. Also fixes two 0.23->0.25 breakages that were not PR 19's: GetSparkStatus now takes a request, and CrossChainRoutePair.supportedSources became acceptedAssets. --- .../Fakes/FakeSparkSdkClient.cs | 188 +++++++++- .../Fakes/SparkSurfaceHarness.cs | 11 +- .../SparkExitPageTests.cs | 159 ++++++-- .../SparkSettingsSerializationTests.cs | 17 +- .../SparkSettlementReconcilerTests.cs | 13 + .../SparkUnilateralExitSeamTests.cs | 303 +++++++++++++--- .../SparkUnilateralExitServiceTests.cs | 338 +++++++++++++++++- .../Controllers/SparkController.cs | 185 +++++++++- .../Data/UnilateralExitRecord.cs | 13 +- .../Models/SparkAdvancedViewModel.cs | 35 ++ .../Models/SparkExitViewModel.cs | 55 +++ .../Services/ISparkUnilateralExitService.cs | 74 +++- .../Services/SparkUnilateralExitService.cs | 256 ++++++++++++- BTCPayServer.Plugins.Flint/SparkSettings.cs | 28 +- .../Views/Spark/Advanced.cshtml | 93 +++++ .../Views/Spark/Exit.cshtml | 188 +++++++++- CHANGELOG.md | 34 ++ README.md | 3 +- docs/limitations.md | 25 +- docs/trust-model.md | 17 +- 20 files changed, 1893 insertions(+), 142 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs index aa4a058..f81a312 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkSdkClient.cs @@ -1207,6 +1207,100 @@ public sealed record CrossChainReceiveCall(SparkCrossChainReceiveRoute Route, Bi /// Every build this fake has been asked for, in order. public List ExitBuildCalls { get; } = []; + /// Every check this fake has been asked for, in order. + public List ExitCheckCalls { get; } = []; + + /// Every export this fake has been asked for, in order. + public List ExitExportCalls { get; } = []; + + /// Every import this fake has been asked for, in order, with the blob it was handed. + public List ExitImportCalls { get; } = []; + + /// + /// The status the build gives every transaction it hands back. + /// + /// + /// + /// Defaults to , not to a single "unconfirmed". SDK 0.25 + /// replaced the flat status enum with a union because the useful question stopped being "is it mined" and + /// became "may I broadcast it yet", so one status for the whole set is precisely the shape that cannot + /// express the states the page and the service now have to handle. A test that wants a mixed set — some + /// ready, some waiting on a timelock at a known height, some confirmed — sets this per case. + /// + /// + /// The transaction whose readiness differs most usefully is the tree node: it is the one with a CSV timelock, + /// so set to with + /// is what a real set looks like one block after the fan-out confirms. + /// + /// + public SparkExitTxReadiness ExitReadiness { get; set; } = SparkExitTxReadiness.Ready; + + /// + /// When set, the build returns the quote's leaves but no transactions at all. + /// + /// + /// This is a distinct state from an empty quote, and the difference is the whole point. An empty + /// quote means the leaves the operator pinned are no longer in the wallet, which is a refusal. An empty + /// transaction set means the leaves are still there and the SDK has already seen every step this exit + /// planned confirm on chain — a resumed build with nothing left to do, which is a success. Reproducing it + /// means keeping populated while suppressing the set, so a caller cannot confuse + /// the two. + /// + public bool ExitBuildsNoTransactions { get; set; } + + /// Block height stamped on a transaction. + public uint? ExitConfirmedAtHeight { get; set; } + + /// Height stamped on a transaction. + public uint? ExitSpendableAtHeight { get; set; } + + /// + /// The verdict reports, overriding . + /// + /// + /// and deliberately not : a + /// check whose default answer is "this exit is finished" would let a caller treat every refresh as success + /// without ever reading the verdict, and no test would catch it. "On track" is the unremarkable answer; a + /// test that wants Done asks for it. + /// + public SparkExitVerdict CheckVerdict { get; set; } = SparkExitVerdict.Valid; + + /// + /// Consumed once by the next , then cleared, so a test can script + /// Valid → Done across two refreshes of the same exit. + /// + public SparkExitVerdict? NextCheckVerdict { get; set; } + + /// Thrown by a check when set, instead of answering. + public Exception? FailCheckWith { get; set; } + + /// Thrown by an export when set, instead of answering. + public Exception? FailExportWith { get; set; } + + /// Thrown by an import when set, instead of answering. + public Exception? FailImportWith { get; set; } + + /// + /// The blob hands back. + /// + /// + /// A short opaque string by default, because that is all any caller may do with it: the format is the SDK's + /// own and nothing in the plugin may interpret it. A test that asserted on its shape would be pinning an + /// encoding this side is not allowed to know. + /// + public string ExitStateToExport { get; set; } = "exit-state-blob"; + + /// + /// What reports having restored. + /// + /// + /// All zeros by default, so is true for a test that did + /// not configure it. That is the answer a caller must not render as "your backup is in place": + /// ImportUnilateralExitStateResponse with nothing imported is a perfectly successful call that + /// restored nothing, and a fake defaulting to a cheerful count would hide a caller that never read it. + /// + public SparkExitStateImport ExitStateImportResult { get; set; } = new(0, 0, 0, 0); + /// Thrown by a prepare when set, before any quote is produced. public Exception? FailExitQuoteWith { get; set; } @@ -1287,11 +1381,23 @@ public Task UnilateralExitAsync( throw new SparkExitFundingShortfallException(ExitSingleUtxoFundingSat); // Signed and inert. Nothing in this fake, and nothing in the real SDK, broadcasts any of it. + // + // The shape is the real one: a fan-out that goes out first and alone, one tree node per leaf carrying a + // CPFP child and the CSV timelock, and a sweep that depends on every node. Each transaction's status is + // derived from ExitReadiness rather than fixed, because a set where everything reports the same readiness + // cannot express what the page has to render: at most the fan-out is broadcastable and everything below + // it is waiting on a confirmation or a timelock. + // A resumed build whose every planned step is already confirmed on chain hands back no transactions at all. + // The leaves come back with the quote, so this is distinguishable from "the leaves are gone" — which is + // the case the caller refuses on. + if (ExitBuildsNoTransactions) + return Task.FromResult(new SparkExitResult( + quote.RecoverableValueSat, quote.TotalFeeSat, [], quote.Leaves)); + 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) + new(SparkExitTxKind.Fanout, null, "txid:fanout", "0200fanout", null, null, [], ExitStatus()) }; transactions.AddRange(quote.Leaves.Select(leaf => new SparkExitTransaction( @@ -1304,16 +1410,80 @@ public Task UnilateralExitAsync( $"0200cpfp{leaf.LeafId}", 1_008, ["txid:fanout"], - SparkExitTxStatus.Unconfirmed))); + ExitStatus()))); transactions.Add(new SparkExitTransaction( - SparkExitTxKind.Sweep, null, "txid:sweep", "0200sweep", null, null, sweepDependsOn, - SparkExitTxStatus.Unconfirmed)); + SparkExitTxKind.Sweep, null, "txid:sweep", "0200sweep", null, null, sweepDependsOn, ExitStatus())); return Task.FromResult(new SparkExitResult( quote.RecoverableValueSat, quote.TotalFeeSat, transactions, quote.Leaves)); } + /// One transaction status, derived from the readiness this fake is configured with. + /// + /// The two heights travel with the readiness exactly as the SDK reports them, and each is attached only to + /// the case it belongs to — a Confirmed transaction with a spendableAtHeight or a waiting one + /// with a block height is a shape the union cannot produce. + /// + private SparkExitTxStatus ExitStatus() => ExitReadiness switch + { + SparkExitTxReadiness.Confirmed => new SparkExitTxStatus( + SparkExitTxReadiness.Confirmed, BlockHeight: ExitConfirmedAtHeight), + SparkExitTxReadiness.Waiting => new SparkExitTxStatus( + SparkExitTxReadiness.Waiting, SpendableAtHeight: ExitSpendableAtHeight), + _ => new SparkExitTxStatus(ExitReadiness) + }; + + public Task CheckUnilateralExitAsync( + SparkExitResult exit, + CancellationToken cancellationToken = default) + { + ThrowIfConfigured(); + ArgumentNullException.ThrowIfNull(exit); + ExitCheckCalls.Add(new ExitCheckCall(exit)); + + if (FailCheckWith is not null) + throw FailCheckWith; + + // Consumed once, so a test can script Valid → Done across two refreshes rather than flipping the + // memorable property and losing the first answer. + var verdict = NextCheckVerdict ?? CheckVerdict; + NextCheckVerdict = null; + + // The transactions are handed back with this fake's configured readiness rather than with the statuses + // they came in with, which is what the SDK does: it reads the chain and replaces them. A check that + // echoed its input would let a caller appear to refresh the stored set without the read ever happening. + return Task.FromResult(new SparkExitProgress( + verdict, + exit.RecoverableValueSat, + exit.TotalFeeSat, + exit.Transactions + .Select(transaction => transaction with { Status = ExitStatus() }) + .ToList())); + } + + public Task ExportUnilateralExitStateAsync(CancellationToken cancellationToken = default) + { + ThrowIfConfigured(); + ExitExportCalls.Add("export"); + + return FailExportWith is not null + ? Task.FromException(FailExportWith) + : Task.FromResult(ExitStateToExport); + } + + public Task ImportUnilateralExitStateAsync( + string exitState, + CancellationToken cancellationToken = default) + { + ThrowIfConfigured(); + ExitImportCalls.Add(exitState); + + return FailImportWith is not null + ? Task.FromException(FailImportWith) + : Task.FromResult(ExitStateImportResult); + } + /// /// 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 @@ -1354,6 +1524,14 @@ public sealed record ExitBuildCall( int FundingSecretKeyLength, string? Rejection); + /// One call, with the whole exit it was handed. + /// + /// The reconstruction, not just a count. What the SDK is handed is the plugin's own record rebuilt into the + /// SDK's response, and "the check was called" says nothing about whether the thing it was asked to judge + /// still had its transactions in it. + /// + public sealed record ExitCheckCall(SparkExitResult Exit); + #endregion public Task DisconnectAsync() diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs index 3ee7703..a454d84 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs @@ -370,7 +370,8 @@ public Task ReadAsync(string storeId, CancellationToken LeafCount: null, FundingKeyPath: null, Transactions: null, - TransactionsUnreadable: false)); + TransactionsUnreadable: false, + PendingBroadcast: null)); public Task AcknowledgeDisclosureAsync( string storeId, CancellationToken cancellationToken = default) => Task.FromResult(Refused); @@ -393,6 +394,14 @@ public Task MarkCompletedAsync( string storeId, string recordId, CancellationToken cancellationToken = default) => Task.FromResult(Refused); + public Task CheckAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) => + Task.FromResult(Refused); + + public Task SetExitStateBackupAsync( + string storeId, string? exitState, 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/SparkExitPageTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs index 3a16d97..60e4d57 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Text.RegularExpressions; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Plugins.Flint.Data; using BTCPayServer.Plugins.Flint.Models; @@ -290,7 +289,9 @@ public async Task A_built_record_reaches_the_page_as_transactions_to_broadcast() Assert.Equal("cpfphex", node.CpfpTxHex); Assert.Equal(144u, node.CsvTimelockBlocks); Assert.Equal(["aa11"], node.DependsOn); - Assert.Equal(SparkExitTxStatus.Unconfirmed, node.Status); + // The readiness travels with the transaction and is what the page switches on to say whether it may be + // broadcast yet — the difference between "send this package" and "wait for the timelock". + Assert.Equal(SparkExitTxReadiness.Waiting, node.Status.Readiness); } [Fact] @@ -335,6 +336,50 @@ public async Task A_built_record_with_no_transactions_is_distinguishable_from_an Assert.False(model.TransactionsUnreadable); } + /// + /// The transactions that may be sent right now reach the page as their own list, in the SDK's order. + /// + /// + /// A built exit is a dozen rows of which one or two are ever actionable, and the page leads with them. + /// The service computes the subset from each transaction's readiness; the controller's only job is to carry + /// it across. Collapsing it away — or worse, substituting the whole set — would put a "send these now" block + /// on screen listing a transaction whose timelock has not matured, and the operator would broadcast it and be + /// rejected by the network with no explanation the page had not already given them. + /// + [Fact] + public async Task The_actionable_slice_of_a_built_exit_reaches_the_page_in_order() + { + using var gate = FeatureGate(enabled: true); + + var record = Built(); + var transactions = SignedExit(); + var exit = new StubExitService + { + // Only the fan-out is ready; the node is waiting on its timelock, which is the state a set is in one + // block after the fan-out confirms. + Page = Page( + activeRecord: record, + transactions: transactions, + pendingBroadcast: [transactions[0]]) + }; + + var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit); + + var model = await RenderExit(h); + + Assert.NotNull(model.PendingBroadcast); + var ready = Assert.Single(model.PendingBroadcast!); + Assert.Equal("aa11", ready.Txid); + Assert.True(ready.Status.CanBroadcast); + // The full set is still there, so the table below can show what is waiting and what it waits for. + Assert.Equal(2, model.Transactions.Count); + + // And the page renders the slice as a send-now block rather than a second copy of the table. + var view = ExitTemplate(); + Assert.Contains("Model.PendingBroadcast is { Count: > 0 } pending", view); + Assert.Contains("id=\"SparkExitPendingBroadcast\"", view); + } + #endregion #region What the template does with it @@ -359,26 +404,40 @@ 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` + // structurally — the table has to sit inside a permission-wrapped region — 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*" - + "
` + // after it. The actionable-slice block that now sits between them is inside the same guard, which is + // what this pins: it renders the same hex, so it cannot be outside it. + var tableAt = view.IndexOf("
", tableAt, StringComparison.Ordinal); + Assert.InRange(wrapper, 0, tableAt); + + // The wrapper's own matching close, by counting nested `
`s — a bare IndexOf("
") would find the + // first nested div and stop short of the rows it is meant to bound. + var closeAt = MatchingClose(view, wrapper); + Assert.InRange(closeAt, tableAt, view.Length); // And view-only access gets told why the table is missing rather than being shown an empty page. Assert.Contains("id=\"SparkExitTransactionsRestricted\"", view); Assert.Contains("not-permission=\"@Policies.CanModifyStoreSettings\"", view); - // Every id that carries hex or a command lives after the wrapper opens and before it closes. - var wrapper = view.IndexOf( - "
", 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); + // Every id that carries hex or a command lives after the wrapper opens, and before the wrapper's own + // close — including the "send these now" slice, which renders a broadcast command of its own. + foreach (var carrier in new[] + { + "SparkExitPackage@step", "SparkExitTxHex@step", "SparkExitCpfpHex@step", + "SparkExitPendingBroadcast" + }) + { + var at = view.IndexOf(carrier, StringComparison.Ordinal); + Assert.InRange(at, wrapper, closeAt); + } } [Fact] @@ -648,7 +707,8 @@ private static UnilateralExitPageData Page( int? leafCount = null, string? fundingKeyPath = null, IReadOnlyList? transactions = null, - bool transactionsUnreadable = false) => + bool transactionsUnreadable = false, + IReadOnlyList? pendingBroadcast = null) => new( walletRunning, disclosureAcknowledged, @@ -660,7 +720,8 @@ private static UnilateralExitPageData Page( leafCount, fundingKeyPath, transactions, - transactionsUnreadable); + transactionsUnreadable, + pendingBroadcast); private static UnilateralExitRecord AwaitingFunding() => new() { @@ -685,14 +746,20 @@ private static UnilateralExitRecord Built() } /// - /// 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. + /// A minimal but shaped-like-the-real-thing exit: a fan-out that broadcasts alone and is ready to go, and + /// one tree node that only works as a package with its CPFP child and is still waiting on its timelock. /// + /// + /// The two statuses differ on purpose, because a set where everything shares one readiness cannot express + /// the thing the page exists to render: which of a dozen rows an operator may send right now, and which they + /// have to wait for. + /// private static SparkExitTransaction[] SignedExit() => [ - new(SparkExitTxKind.Fanout, null, "aa11", "fanouthex", null, null, [], SparkExitTxStatus.Unconfirmed), + new(SparkExitTxKind.Fanout, null, "aa11", "fanouthex", null, null, [], + new SparkExitTxStatus(SparkExitTxReadiness.Ready)), new(SparkExitTxKind.TreeNode, "node-1", "bb22", "nodehex", "cpfphex", 144u, ["aa11"], - SparkExitTxStatus.Unconfirmed) + new SparkExitTxStatus(SparkExitTxReadiness.Waiting, SpendableAtHeight: 812_345)) ]; /// @@ -735,7 +802,8 @@ 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); + LeafCount: null, FundingKeyPath: null, Transactions: null, TransactionsUnreadable: false, + PendingBroadcast: null); public UnilateralExitOpResult Result { get; set; } = new(true, null, null); @@ -786,6 +854,20 @@ public Task MarkCompletedAsync( return Task.FromResult(Result); } + public Task CheckAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) + { + Calls.Add($"Check:{recordId}"); + return Task.FromResult(Result); + } + + public Task SetExitStateBackupAsync( + string storeId, string? exitState, CancellationToken cancellationToken = default) + { + Calls.Add($"ExitState:{exitState ?? "(null)"}"); + return Task.FromResult(Result); + } + public Task SetExplorerUrlAsync( string storeId, string? esploraApiUrl, CancellationToken cancellationToken = default) { @@ -794,6 +876,41 @@ public Task SetExplorerUrlAsync( } } + /// + /// The offset of the </div> that closes the <div> opening at + /// . + /// + /// + /// Depth-counted rather than a bare search for the next close tag, because the region being bounded contains + /// nested divs of its own — and a structural assertion that stopped at the first one would pass while the + /// markup it is meant to bound sat outside the guard. + /// + private static int MatchingClose(string view, int divStart) + { + var depth = 0; + for (var i = divStart; i < view.Length;) + { + var open = view.IndexOf("", i, StringComparison.Ordinal); + if (close < 0) + return -1; + + if (open >= 0 && open < close) + { + depth++; + i = open + 4; + continue; + } + + if (--depth == 0) + return close; + + i = close + 6; + } + + return -1; + } + /// 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")); diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs index d34bb84..dc3d83e 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkSettingsSerializationTests.cs @@ -53,7 +53,8 @@ public void A_blob_written_by_the_current_shape_round_trips_unchanged() UnilateralExit = new UnilateralExitSettings { DisclosureAcknowledged = true, - EsploraApiUrl = "http://localhost:3002/api" + EsploraApiUrl = "http://localhost:3002/api", + ExitStateBackup = "opaque-sdk-backup-blob" } }; @@ -75,6 +76,9 @@ public void A_blob_written_by_the_current_shape_round_trips_unchanged() Assert.Equal("bcrt1qtxwcjjvf4ny9wsw9emgnpazey2vde3xhnyqpw0", read.Sweep.StaticAddress); Assert.True(read.UnilateralExit.DisclosureAcknowledged); Assert.Equal("http://localhost:3002/api", read.UnilateralExit.EsploraApiUrl); + // The one field here whose loss is unrecoverable from anywhere else: it is the wallet's exit data, and + // without it a leaf cannot be forced on-chain once the Spark operators stop answering. + Assert.Equal("opaque-sdk-backup-blob", read.UnilateralExit.ExitStateBackup); } [Fact] @@ -208,7 +212,8 @@ public void Cloning_carries_the_unilateral_exit_section_and_leaves_the_original_ UnilateralExit = new UnilateralExitSettings { DisclosureAcknowledged = true, - EsploraApiUrl = "http://esplora.internal/api" + EsploraApiUrl = "http://esplora.internal/api", + ExitStateBackup = "opaque-sdk-backup-blob" } }; @@ -216,13 +221,18 @@ public void Cloning_carries_the_unilateral_exit_section_and_leaves_the_original_ Assert.True(clone.UnilateralExit.DisclosureAcknowledged); Assert.Equal("http://esplora.internal/api", clone.UnilateralExit.EsploraApiUrl); + Assert.Equal("opaque-sdk-backup-blob", clone.UnilateralExit.ExitStateBackup); Assert.NotSame(source.UnilateralExit, clone.UnilateralExit); clone.UnilateralExit.DisclosureAcknowledged = false; clone.UnilateralExit.EsploraApiUrl = "http://elsewhere/api"; + clone.UnilateralExit.ExitStateBackup = null; Assert.True(source.UnilateralExit.DisclosureAcknowledged); Assert.Equal("http://esplora.internal/api", source.UnilateralExit.EsploraApiUrl); + // An alias here would mean clearing the backup on a clone that was rolled back cleared the real one too — + // and the backup is the only copy of the data an operator can exit a leaf with when the operators are gone. + Assert.Equal("opaque-sdk-backup-blob", source.UnilateralExit.ExitStateBackup); } [Fact] @@ -235,7 +245,8 @@ public void The_unilateral_exit_section_has_exactly_the_properties_this_file_cov new[] { nameof(UnilateralExitSettings.DisclosureAcknowledged), - nameof(UnilateralExitSettings.EsploraApiUrl) + nameof(UnilateralExitSettings.EsploraApiUrl), + nameof(UnilateralExitSettings.ExitStateBackup) }, typeof(UnilateralExitSettings) .GetProperties(BindingFlags.Public | BindingFlags.Instance) diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs index 949e45b..ff8673e 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkSettlementReconcilerTests.cs @@ -1080,6 +1080,19 @@ public Task UnilateralExitAsync( feeRateSatPerVbyte, destinationAddress, leafIds, fundingUtxos, fundingSecretKey, approveQuote, cancellationToken); + public Task CheckUnilateralExitAsync( + SparkExitResult exit, + CancellationToken cancellationToken = default) => + _inner.CheckUnilateralExitAsync(exit, cancellationToken); + + public Task ExportUnilateralExitStateAsync(CancellationToken cancellationToken = default) => + _inner.ExportUnilateralExitStateAsync(cancellationToken); + + public Task ImportUnilateralExitStateAsync( + string exitState, + CancellationToken cancellationToken = default) => + _inner.ImportUnilateralExitStateAsync(exitState, cancellationToken); + public Task DisconnectAsync() => _inner.DisconnectAsync(); public void Dispose() => _inner.Dispose(); diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs index 2fe9761..21c0bfb 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitSeamTests.cs @@ -12,9 +12,11 @@ namespace BTCPayServer.Plugins.Flint.Tests; /// /// /// 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. +/// wrong is silent: a status union whose Ready case is the only one that authorises a broadcast, an +/// optional selection whose empty case means the opposite of what it looks like, a quote that echoes the request +/// back, the one typed error that carries a number worth acting on, and — the bridge nothing else covers — the +/// reconstruction of a stored exit back into the SDK's response, because CheckUnilateralExit judges the +/// reconstruction rather than anything the plugin holds. /// public class SparkUnilateralExitSeamTests { @@ -55,28 +57,110 @@ public void Transaction_kinds_are_mapped_by_name() Assert.Equal(SparkExitTxKind.Sweep, SparkSdkClient.MapExitTxKind(UnilateralExitTxKind.Sweep)); } + /// + /// Every case the SDK's status union can report maps to the readiness the page and the record switch on. + /// + /// + /// + /// Mapped by case rather than by ordinal, and the assertion that matters most is the last group: a + /// transaction the SDK reports as waiting must not come back broadcastable. That invariant used to + /// be expressible only as "the two enums are ordered differently, so do not cast" — SDK 0.25 replaced the + /// flat enum with a union, so it is now stated directly and the whole class of cast bug is gone by + /// construction. + /// + /// + /// The harm the waiting case prevents: a tree node whose CSV timelock has not matured is + /// invalid, not merely early. An operator handed it as "send this now" gets a rejected broadcast at + /// best, and at worst pushes a transaction that a sibling has already spent the same output of the + /// statechain for. + /// + /// + [Fact] + public void Every_chain_reported_status_becomes_the_readiness_that_decides_a_broadcast() + { + // A confirmed transaction carries the height its children's timelocks count from. + var confirmed = SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.Confirmed(812_345)); + Assert.Equal(SparkExitTxReadiness.Confirmed, confirmed.Readiness); + Assert.Equal(812_345u, confirmed.BlockHeight); + Assert.False(confirmed.CanBroadcast); + + // Confirmed with a null height is its own case: the SDK reports it and it must not become an exception. + var confirmedWithoutHeight = SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.Confirmed(null)); + Assert.Equal(SparkExitTxReadiness.Confirmed, confirmedWithoutHeight.Readiness); + Assert.Null(confirmedWithoutHeight.BlockHeight); + + var ready = SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.Ready()); + Assert.Equal(SparkExitTxReadiness.Ready, ready.Readiness); + Assert.True(ready.CanBroadcast); + + // The one the ordering test used to protect, now stated as the behaviour it was protecting: waiting is + // never permission to broadcast. + var waitingOnDependencies = + SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.WaitingForDependencies()); + Assert.Equal(SparkExitTxReadiness.Waiting, waitingOnDependencies.Readiness); + Assert.False(waitingOnDependencies.CanBroadcast); + + // A timelock keeps the height that makes "not yet" a number an operator can act on, rather than an + // instruction to keep refreshing the page. + var waitingOnTimelock = + SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.WaitingForTimelock(901_200)); + Assert.Equal(SparkExitTxReadiness.Waiting, waitingOnTimelock.Readiness); + Assert.Equal(901_200u, waitingOnTimelock.SpendableAtHeight); + Assert.False(waitingOnTimelock.CanBroadcast); + + // And the two heights stay in their own fields: a waiting transaction reports no block height, so + // nothing downstream can read a spendable-at height as "this already confirmed". + Assert.Null(waitingOnTimelock.BlockHeight); + + var unverified = SparkSdkClient.MapExitTxStatus(new ExitTransactionStatus.Unverified()); + Assert.Equal(SparkExitTxReadiness.Unverified, unverified.Readiness); + Assert.False(unverified.CanBroadcast); + } + + /// + /// Ready is the only readiness that authorises a broadcast, and it survives the trip back to the SDK. + /// + /// + /// The round trip is what makes the mapping a bijection rather than a lossy collapse, and the SDK is handed + /// this back on every check. If Ready came back as anything else, an operator would be told to + /// broadcast a transaction the check had just been shown as ready. + /// [Fact] - public void Confirmation_statuses_are_mapped_by_name() + public void A_readiness_survives_the_trip_back_to_the_SDK() { - Assert.Equal(SparkExitTxStatus.Confirmed, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Confirmed)); - Assert.Equal( - SparkExitTxStatus.Unconfirmed, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Unconfirmed)); - Assert.Equal(SparkExitTxStatus.Unverified, SparkSdkClient.MapExitTxStatus(ConfirmationStatus.Unverified)); + Assert.IsType( + SparkSdkClient.ToSdkExitTxStatus(new SparkExitTxStatus(SparkExitTxReadiness.Ready))); + + Assert.IsType( + SparkSdkClient.ToSdkExitTxStatus(new SparkExitTxStatus(SparkExitTxReadiness.Unverified))); + + var confirmed = Assert.IsType( + SparkSdkClient.ToSdkExitTxStatus( + new SparkExitTxStatus(SparkExitTxReadiness.Confirmed, BlockHeight: 700_000))); + Assert.Equal(700_000u, confirmed.blockHeight); + + // Both of the plugin's waiting cases go back as a wait, because the SDK replaces the status from the + // chain anyway and the one thing that must never happen is a wait returning as permission to send. + Assert.False( + SparkSdkClient.ToSdkExitTxStatus(new SparkExitTxStatus(SparkExitTxReadiness.Waiting)) + is ExitTransactionStatus.Ready); } /// - /// Guards the reason the status mapping is written out rather than cast. + /// The default-initialised readiness is "waiting", so a value that was never set cannot authorise a broadcast. /// /// - /// 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. + /// A missing JSON field, a column added to an existing row, or a default in a switch all produce a + /// zero-valued , and on this surface a zero that meant "ready" would be + /// instructions to push a timelocked transaction out. The plugin's own ordering puts Waiting first + /// deliberately; the SDK's union has no ordinal at all to mirror, which is why this is asserted here rather + /// than as an enum comparison. /// [Fact] - public void A_numeric_cast_between_the_status_enums_would_be_wrong() + public void The_default_readiness_is_waiting_rather_than_ready() { - Assert.NotEqual((int)ConfirmationStatus.Confirmed, (int)SparkExitTxStatus.Confirmed); - Assert.Equal(0, (int)SparkExitTxStatus.Unconfirmed); + Assert.Equal(SparkExitTxReadiness.Waiting, default(SparkExitTxReadiness)); + Assert.False(new SparkExitTxStatus(default).CanBroadcast); } [Fact] @@ -86,11 +170,14 @@ public void A_quote_carries_every_figure_the_binding_reports() leaves: [new UnilateralExitLeaf("leaf-a", 40_000), new UnilateralExitLeaf("leaf-b", 10_000)], recoverableValueSat: 50_000, totalFeeSat: 3_000, + cpfpFeeSat: 0, fanoutFeeSat: 500, + sweepFeeSat: 0, singleUtxoFundingSat: 4_200, perBranchFunding: [new PerBranchFunding("leaf-a", 3_000), new PerBranchFunding("leaf-b", 1_200)], feeRateSatPerVbyte: 7, - destination: Destination)); + destination: Destination, + exitChainState: new ExitChainState([], [], [], [], []))); Assert.Equal(50_000, quote.RecoverableValueSat); Assert.Equal(3_000, quote.TotalFeeSat); @@ -116,11 +203,14 @@ public void An_empty_selection_is_a_quote_rather_than_a_fault() leaves: [], recoverableValueSat: 0, totalFeeSat: 0, + cpfpFeeSat: 0, fanoutFeeSat: 0, + sweepFeeSat: 0, singleUtxoFundingSat: 0, perBranchFunding: [], feeRateSatPerVbyte: 1, - destination: Destination)); + destination: Destination, + exitChainState: new ExitChainState([], [], [], [], []))); Assert.True(quote.IsEmpty); Assert.Empty(quote.Leaves); @@ -138,11 +228,14 @@ public void Amounts_beyond_long_range_are_clamped_rather_than_wrapped() leaves: [new UnilateralExitLeaf("leaf-a", ulong.MaxValue)], recoverableValueSat: ulong.MaxValue, totalFeeSat: ulong.MaxValue, + cpfpFeeSat: 0, fanoutFeeSat: ulong.MaxValue, + sweepFeeSat: 0, singleUtxoFundingSat: ulong.MaxValue, perBranchFunding: [], feeRateSatPerVbyte: 1, - destination: Destination)); + destination: Destination, + exitChainState: new ExitChainState([], [], [], [], []))); Assert.Equal(long.MaxValue, quote.RecoverableValueSat); Assert.Equal(long.MaxValue, quote.TotalFeeSat); @@ -160,7 +253,7 @@ public void A_tree_node_keeps_its_child_its_timelock_and_its_dependencies() cpfpTxHex: "0200cpfp", csvTimelockBlocks: 1_008, dependsOn: ["fanout"], - status: ConfirmationStatus.Unconfirmed)); + status: new ExitTransactionStatus.Ready())); Assert.Equal(SparkExitTxKind.TreeNode, mapped.Kind); Assert.Equal("node-1", mapped.NodeId); @@ -168,6 +261,7 @@ public void A_tree_node_keeps_its_child_its_timelock_and_its_dependencies() Assert.Equal(1_008u, mapped.CsvTimelockBlocks!.Value); Assert.Equal(["fanout"], mapped.DependsOn); Assert.True(mapped.RequiresPackageBroadcast); + Assert.Equal(SparkExitTxReadiness.Ready, mapped.Status.Readiness); } /// @@ -186,14 +280,15 @@ public void A_standalone_transaction_needs_no_package() cpfpTxHex: null, csvTimelockBlocks: null, dependsOn: null!, - status: ConfirmationStatus.Unverified)); + status: new ExitTransactionStatus.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); + Assert.Equal(SparkExitTxReadiness.Unverified, mapped.Status.Readiness); + Assert.False(mapped.Status.CanBroadcast); } [Fact] @@ -259,16 +354,6 @@ public void A_CPFP_shortfall_becomes_a_typed_error_carrying_the_amount_that_woul 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. @@ -279,23 +364,153 @@ public void Any_other_failure_is_left_alone() Assert.Null(SparkErrors.TranslateUnilateralExit(new SdkException.NetworkException("@v1=offline"))); } + /// + /// There is exactly one translation left. SDK 0.25 removed SdkException.FundingUtxoConflict along with + /// the build shape that produced it — a spent funding output is now followed to whatever it became rather + /// than reported — so the filter must not claim a conflict it no longer recognises. + /// + [Fact] + public void Only_the_CPFP_shortfall_is_translated() + { + Assert.IsType( + SparkErrors.TranslateUnilateralExit(new SdkException.InsufficientCpfpFunds(1))); + + Assert.Null(SparkErrors.TranslateUnilateralExit(new SdkException.InsufficientFunds(tokenIdentifier: null))); + } + [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); - } + var described = SparkErrors.Describe(new SdkException.InsufficientCpfpFunds(1_234)); + Assert.False(string.IsNullOrWhiteSpace(described)); + Assert.DoesNotContain("@v1=", described); + } + + /// + /// A stored exit rebuilt into the SDK's response keeps every field CheckUnilateralExit judges it by. + /// + /// + /// + /// This is the bridge between the persisted record and the SDK, and nothing else crosses it. The + /// plugin never holds the SDK's response — it holds a serialised on a database + /// row, possibly days old, and CheckUnilateralExit is handed the result of rebuilding that back into + /// the SDK's own type. So a field this loses is a field the SDK never sees, and the SDK reads + /// dependsOn to decide whether a transaction is waiting on a confirmation, csvTimelockBlocks to + /// decide whether its lock has matured, and cpfpTxHex to know the fee-paying child exists at all. + /// + /// + /// The two failures that would be silent and expensive. A dropped dependsOn edge makes a + /// node look independent, so the check reports it ready and the operator broadcasts a transaction whose + /// parent has not confirmed — rejected, or worse, mined against a statechain state that has moved. A lost + /// cpfpTxHex turns a package into a transaction paying no fee, and the operator is left with a + /// stuck zero-fee parent and no explanation. Neither throws, and neither is visible on any other test. + /// + /// + /// The whole set is asserted, not just the interesting transaction, because the check is handed all of them: + /// a Zip-shaped rebuild that dropped the last entry would leave the sweep out of the exit the SDK was + /// asked to judge. + /// + /// + [Fact] + public void A_stored_exit_rebuilt_for_the_SDK_keeps_every_dependency_edge_and_hex() + { + var fanout = new SparkExitTransaction( + SparkExitTxKind.Fanout, null, "txid:fanout", "0200fanout", null, null, [], + new SparkExitTxStatus(SparkExitTxReadiness.Confirmed, BlockHeight: 800_100)); + + var node = new SparkExitTransaction( + SparkExitTxKind.TreeNode, "node:leaf-a", "txid:node:leaf-a", "0200nodeleaf-a", + "0200cpfpleaf-a", 1_008, ["txid:fanout"], + new SparkExitTxStatus(SparkExitTxReadiness.Waiting, SpendableAtHeight: 801_108)); + + var refund = new SparkExitTransaction( + SparkExitTxKind.Refund, "node:leaf-b", "txid:refund:leaf-b", "0200refundleaf-b", + "0200cpfprefund", 144, ["txid:fanout", "txid:node:leaf-b"], + new SparkExitTxStatus(SparkExitTxReadiness.Ready)); + + var sweep = new SparkExitTransaction( + SparkExitTxKind.Sweep, null, "txid:sweep", "0200sweep", null, null, + ["txid:node:leaf-a", "txid:refund:leaf-b"], + new SparkExitTxStatus(SparkExitTxReadiness.Unverified)); + + var exit = new SparkExitResult( + 350_000, + 4_100, + [fanout, node, refund, sweep], + [new SparkExitLeaf("leaf-a", 300_000), new SparkExitLeaf("leaf-b", 50_000)]); + + var rebuilt = SparkSdkClient.ToSdkExit(exit); + + // The totals and the leaves the check echoes back for a caller to store over what it had. + Assert.Equal(350_000UL, rebuilt.recoverableValueSat); + Assert.Equal(4_100UL, rebuilt.totalFeeSat); + Assert.Equal(["leaf-a", "leaf-b"], rebuilt.leaves.Select(leaf => leaf.leafId)); + Assert.Equal(300_000UL, rebuilt.leaves[0].value); + + // Order preserved: the SDK's topological order is the operator's broadcast schedule and nothing + // downstream re-derives it. + Assert.Equal(4, rebuilt.transactions.Length); + + Assert.Equal(UnilateralExitTxKind.FanOut, rebuilt.transactions[0].kind); + Assert.Equal("txid:fanout", rebuilt.transactions[0].txid); + Assert.Equal("0200fanout", rebuilt.transactions[0].txHex); + Assert.Empty(rebuilt.transactions[0].dependsOn); + + // The node: the CPFP child, the timelock and the dependency edge all have to survive, because these are + // the three fields that decide how and when it may be broadcast. + var rebuiltNode = rebuilt.transactions[1]; + Assert.Equal(UnilateralExitTxKind.Node, rebuiltNode.kind); + Assert.Equal("node:leaf-a", rebuiltNode.nodeId); + Assert.Equal("0200nodeleaf-a", rebuiltNode.txHex); + Assert.Equal("0200cpfpleaf-a", rebuiltNode.cpfpTxHex); + Assert.Equal(1_008u, rebuiltNode.csvTimelockBlocks); + Assert.Equal(["txid:fanout"], rebuiltNode.dependsOn); + + // A refund and a node with more than one parent, so the rebuild is not merely preserving a single edge. + Assert.Equal(UnilateralExitTxKind.Refund, rebuilt.transactions[2].kind); + Assert.Equal(["txid:fanout", "txid:node:leaf-b"], rebuilt.transactions[2].dependsOn); + + Assert.Equal(UnilateralExitTxKind.Sweep, rebuilt.transactions[3].kind); + Assert.Null(rebuilt.transactions[3].cpfpTxHex); + Assert.Equal(["txid:node:leaf-a", "txid:refund:leaf-b"], rebuilt.transactions[3].dependsOn); + + // The statuses go back as the cases the stored readiness came from, heights included — the SDK replaces + // them from the chain, but a wait must not come back as a ready. + var ready = Assert.IsType(rebuilt.transactions[0].status); + Assert.Equal(800_100u, ready.blockHeight); + var waiting = Assert.IsType( + rebuilt.transactions[1].status); + Assert.NotNull(waiting); + Assert.IsType(rebuilt.transactions[2].status); + Assert.IsType(rebuilt.transactions[3].status); + } + + /// + /// The check reads the chain and nothing else, so the funding outputs are absent by design — the SDK + /// follows them at build time and an exit being followed does not rebuild. Asserted because a rebuild that + /// invented a funding input would hand the SDK an output the plugin has no key for. + /// + [Fact] + public void A_rebuilt_exit_carries_no_funding_inputs() + { + var rebuilt = SparkSdkClient.ToSdkExit(new SparkExitResult(0, 0, [], [])); + + Assert.Empty(rebuilt.transactions); + Assert.Empty(rebuilt.leaves); + Assert.Empty(rebuilt.fundingInputs); } private static PrepareUnilateralExitResponse Response(string destination, ulong feeRate) => - new([], 0, 0, 0, 0, [], feeRate, destination); + new( + leaves: [], + recoverableValueSat: 0, + totalFeeSat: 0, + cpfpFeeSat: 0, + fanoutFeeSat: 0, + sweepFeeSat: 0, + singleUtxoFundingSat: 0, + perBranchFunding: [], + feeRateSatPerVbyte: feeRate, + destination: destination, + exitChainState: new ExitChainState([], [], [], [], [])); } diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs index 080e8e7..d176c80 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs @@ -672,7 +672,10 @@ public async Task A_funded_exit_builds_and_persists_its_transactions_and_totals( 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); + // Readiness round-trips through the column as its own enum, which is what the page switches on to decide + // what to tell an operator to broadcast. The default fake set is Ready, so every transaction is + // broadcastable — the state a fresh build is in immediately after the fan-out confirms. + Assert.Equal(SparkExitTxReadiness.Ready, transactions[0].Status.Readiness); var node = transactions.First(tx => tx.Kind is SparkExitTxKind.TreeNode); Assert.True(node.RequiresPackageBroadcast); @@ -920,14 +923,14 @@ public async Task A_build_cancelled_after_signing_still_persists_its_transaction } /// - /// The SDK's own funding failures arrive as readable copy on the record rather than as an exception. + /// The SDK's own funding shortfall arrives 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. + /// It means the operator has something to do — top up the funding address — and it leaves the exit exactly + /// where it was, because nothing was built. The number the SDK named is what makes that copy actionable. /// [Fact] - public async Task The_SDK_s_funding_failures_land_on_the_record_as_words() + public async Task The_SDK_s_funding_failure_lands_on_the_record_as_words() { using var harness = Harness.Create(); harness.Configure(acknowledged: true); @@ -944,14 +947,7 @@ public async Task The_SDK_s_funding_failures_land_on_the_record_as_words() 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); + Assert.Null(harness.Records.Records[shortfallRecord.Id].TransactionsJson); } /// A build against an unknown exit, or one that is finished, is refused. @@ -998,6 +994,302 @@ public async Task An_exit_whose_funding_key_no_longer_derives_is_refused() Assert.Empty(harness.Sdk.ExitBuildCalls); } + /// + /// A build that comes back with no transactions at all is a successful build, not a refusal. + /// + /// + /// + /// This is the semantic change of the SDK bump, and the one a merchant would notice. In 0.25 the SDK + /// reads confirmed chain state before building, so a resumed build whose steps have already gone out and + /// confirmed has nothing left to hand back — while the leaves it was pinned to are still perfectly well + /// present in the wallet. That is a successful build with an empty set, and it replaces the stored set. + /// + /// + /// The harm in getting it wrong is a trap with no way out: the exit has been force-closed on chain, the SDK + /// says there is nothing left to do, and a plugin that reads that as "the build failed" leaves the record at + /// AwaitingFunding asking the operator to fund an exit that is already done. It would also keep the + /// previous attempt's complaint on the row, so the page would show a stale error next to a finished exit. + /// + /// + /// The empty set is deliberately not the same condition as an empty quote — the leaves are still + /// pinned and still in the wallet here, which is what tells the two apart. See + /// A_build_whose_leaves_have_vanished_is_refused_before_anything_is_signed for the other side. + /// + /// + [Fact] + public async Task A_build_that_returns_no_transactions_is_a_successful_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, lastError: "not enough on the funding address"); + harness.Explorer(Utxo(10_000)); + + // The leaves are exactly where they were; what has changed is that the chain already has every step + // this exit planned, so the SDK has nothing left to sign. + harness.Sdk.ExitBuildsNoTransactions = true; + + 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); + // An empty array rather than null: "built, with nothing left to broadcast" and "never built" are + // different states and the page renders them differently. + Assert.Equal("[]", stored.TransactionsJson); + // The previous attempt's complaint does not sit next to a successful build. + Assert.Null(stored.LastError); + } + + /// + /// A build that returns fewer transactions than the stored set replaces it rather than merging into it. + /// + /// + /// A merge here would be actively dangerous rather than merely untidy. The transaction a later build + /// leaves out is one the chain says is already confirmed or already superseded; keeping it in the stored set + /// means the page keeps rendering it as a step to broadcast, and an operator who broadcasts a stale + /// transaction from a superseded exit is publishing a transaction that competes with, or spends an output + /// already claimed by, the exit that actually went through. The statuses would also be a mixture of two + /// different reads of the chain, which is a set no single moment ever produced. + /// + [Fact] + public async Task A_partial_rebuild_replaces_the_stored_set_rather_than_merging_with_it() + { + 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); + harness.Explorer(Utxo(10_000)); + + Assert.True((await harness.Service.BuildAsync(StoreId, record.Id, Ct)).Success); + var first = JsonSerializer.Deserialize( + harness.Records.Records[record.Id].TransactionsJson!)!; + Assert.Equal(4, first.Length); + Assert.Contains(first, tx => tx.Txid == "txid:node:leaf-b"); + + // leaf-b's branch has confirmed on chain, so the next build has nothing to do for it. + harness.Sdk.ExitLeaves.RemoveAll(leaf => leaf.LeafId == "leaf-b"); + + Assert.True((await harness.Service.BuildAsync(StoreId, record.Id, Ct)).Success); + + var second = JsonSerializer.Deserialize( + harness.Records.Records[record.Id].TransactionsJson!)!; + + Assert.Equal(3, second.Length); + Assert.DoesNotContain(second, tx => tx.Txid == "txid:node:leaf-b"); + // The transaction the rebuild dropped is gone from the row, not left behind as a step to broadcast. + Assert.NotEqual(4, second.Length); + Assert.Contains(second, tx => tx.Txid == "txid:node:leaf-a"); + } + + #endregion + + #region Checking a built exit against the chain + + /// + /// Checking refreshes the stored statuses from what the chain reports. + /// + /// + /// The stored statuses are the only thing that turns "here are twelve transactions" into a schedule. + /// A built set is a snapshot of a chain that has since moved: the fan-out has confirmed, a node's timelock + /// has matured, the sweep is still waiting. An operator who reloads the page has to be told which of those + /// is true now, and the only source is the SDK's own read — so a check that answers without writing the + /// refreshed set back leaves the page rendering yesterday's readiness for ever, which is how an operator ends + /// up rebroadcasting a transaction that is already mined or waiting on one that is not. + /// + [Fact] + public async Task Checking_a_built_exit_persists_the_refreshed_transactions() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + var record = await BuiltExit(harness); + + // The chain has moved since the build: everything is now waiting on the fan-out's confirmation. + harness.Sdk.ExitReadiness = SparkExitTxReadiness.Waiting; + harness.Sdk.ExitSpendableAtHeight = 810_000; + + var result = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + + Assert.True(result.Success, result.Error); + Assert.Single(harness.Sdk.ExitCheckCalls); + + var refreshed = JsonSerializer.Deserialize( + harness.Records.Records[record.Id].TransactionsJson!)!; + Assert.All( + refreshed, + tx => Assert.Equal(SparkExitTxReadiness.Waiting, tx.Status.Readiness)); + // The height that makes "not yet" a number travels with the status, so the page can say when. + Assert.Equal(810_000u, refreshed[0].Status.SpendableAtHeight); + } + + /// + /// The verdict comes back on the result and is deliberately not written to the row. + /// + /// + /// The verdict is the SDK's reading of the chain at the instant of the call. Stored, it would be rendered + /// later as a live claim about a chain nothing has re-read — an operator would see "on track" on a page + /// served from a row written days ago. The refreshed transactions are stored because they are what + /// the operator broadcasts against; the verdict is not, because it is only ever an answer to a question + /// somebody just asked. + /// + [Fact] + public async Task The_check_verdict_is_reported_but_not_persisted() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + var record = await BuiltExit(harness); + + // The SDK's own scripted progression: this refresh says on track, the next says finished. + harness.Sdk.CheckVerdict = SparkExitVerdict.Done; + + var done = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + + Assert.True(done.Success, done.Error); + Assert.Equal(SparkExitVerdict.Done, done.Verdict); + + // Nothing on the row carries it: the record's own columns are about the exit, not about the last time + // anybody asked the chain. + var stored = harness.Records.Records[record.Id]; + Assert.Equal(UnilateralExitStatus.Built, stored.Status); + Assert.Null(stored.LastError); + + // And the next call asks again rather than replaying the stored answer. + harness.Sdk.CheckVerdict = SparkExitVerdict.Redo; + var redo = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + Assert.Equal(SparkExitVerdict.Redo, redo.Verdict); + Assert.Equal(2, harness.Sdk.ExitCheckCalls.Count); + } + + /// + /// Checking an exit that was never built is refused: there is nothing on chain to ask about. + /// + /// + /// The refusal is what keeps the verdict honest. A check on an unbuilt row has no transaction set to + /// hand the SDK, and the SDK judges the exit it is given — so answering it at all would mean either inventing + /// a set or reporting a verdict about an exit that does not exist. Either way the page would show a chain + /// verdict next to an exit that has not been built, and an operator would read "on track" as "the funding + /// arrived and it is working". + /// + [Fact] + public async Task Checking_an_exit_that_is_not_built_is_refused() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + + var awaiting = harness.Seed(id: "exit-waiting", leafIds: ["leaf-a"]); + var notBuilt = await harness.Service.CheckAsync(StoreId, awaiting.Id, Ct); + + Assert.False(notBuilt.Success); + Assert.Contains("not been built", notBuilt.Error); + Assert.Null(notBuilt.Verdict); + + var abandoned = harness.Seed(id: "exit-abandoned", status: UnilateralExitStatus.Abandoned); + var gone = await harness.Service.CheckAsync(StoreId, abandoned.Id, Ct); + + Assert.False(gone.Success); + Assert.Contains("abandoned", gone.Error); + + Assert.Equal( + SparkUnilateralExitService.ExitNotFound, + (await harness.Service.CheckAsync(StoreId, "exit-nowhere", Ct)).Error); + + // Nothing reached the SDK on the way to any of those refusals. + Assert.Empty(harness.Sdk.ExitCheckCalls); + } + + /// + /// Checking builds nothing, signs nothing and spends nothing — it is a read of the chain. + /// + /// + /// This is the property that makes the button safe to press as often as an operator likes. A check + /// that took a quote, re-priced the leaves or asked the SDK to build would commit a fresh funding output and + /// produce a second signed set over an exit that is already part-way through being broadcast — and the + /// operator would have no way to tell from the page that pressing "check" had signed anything at all. It must + /// also work from a record alone: the whole reason the check exists is following an exit whose wallet is + /// gone. + /// + [Fact] + public async Task Checking_a_built_exit_builds_and_spends_nothing() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + var record = await BuiltExit(harness); + + var quotesAfterBuild = harness.Sdk.ExitQuoteCalls.Count; + var buildsAfterBuild = harness.Sdk.ExitBuildCalls.Count; + + var result = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + + Assert.True(result.Success, result.Error); + Assert.Single(harness.Sdk.ExitCheckCalls); + // No re-price, no re-quote, no second build: the count is unchanged from what the build itself did. + Assert.Equal(quotesAfterBuild, harness.Sdk.ExitQuoteCalls.Count); + Assert.Equal(buildsAfterBuild, harness.Sdk.ExitBuildCalls.Count); + // And it did not need the store's seed at all, which is what makes a lost wallet recoverable. + harness.Settings.Settings[StoreId]!.ProtectedMnemonic = "not something this keyring can unprotect"; + Assert.True((await harness.Service.CheckAsync(StoreId, record.Id, Ct)).Success); + } + + /// + /// A check whose answer cannot be written lands as a refusal rather than as a silent success. + /// + /// + /// The refreshed set is the point of the call. Reporting success while the compare-and-set missed would tell + /// an operator the page is showing what the chain says when the row still holds the build-time statuses — + /// and the comparison is not paranoia: an abandon or a completion from another tab between the read and the + /// write is exactly what it is there for. + /// + [Fact] + public async Task A_check_that_cannot_write_its_refresh_back_reports_a_refusal() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + var record = await BuiltExit(harness); + + harness.Records.RefuseUpdates = true; + + var result = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + + Assert.False(result.Success); + Assert.Equal(SparkUnilateralExitService.ExitChangedUnderneath, result.Error); + } + + /// + /// A check whose chain read blows up leaves the stored set exactly as it was. + /// + /// + /// "Spark could not read the chain" and "the chain says every transaction is waiting" are opposite answers, + /// and an operator who acted on the second when the first was true would rebroadcast a set they have already + /// broadcast. So a failed check writes nothing, and says so. + /// + [Fact] + public async Task A_check_that_fails_leaves_the_stored_set_untouched() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.WithLeaves(("leaf-a", 500_000)); + var record = await BuiltExit(harness); + var before = harness.Records.Records[record.Id].TransactionsJson; + + harness.Sdk.FailCheckWith = new InvalidOperationException("the chain service is down"); + + var result = await harness.Service.CheckAsync(StoreId, record.Id, Ct); + + Assert.False(result.Success); + Assert.Contains("could not read the chain", result.Error); + Assert.Equal(before, harness.Records.Records[record.Id].TransactionsJson); + Assert.Equal( + UnilateralExitStatus.Built, + harness.Records.Records[record.Id].Status); + } + #endregion #region Abandoning @@ -1561,6 +1853,26 @@ public async Task An_output_with_a_malformed_txid_is_dropped() #endregion + /// + /// Seeds a funded exit and builds it, returning the row as the build left it. + /// + /// + /// Shared by the check tests, which all start from "an exit that is Built and whose transaction set is on the + /// row" — reaching that state through rather than by + /// writing a JSON column by hand, so a check test cannot pass against a row shape the build would never + /// produce. + /// + private static async Task BuiltExit(Harness harness) + { + harness.WithLeaves(("leaf-a", 500_000)); + var record = harness.Seed(leafIds: ["leaf-a"], singleUtxoFundingSat: 4_200); + harness.Explorer(Utxo(10_000)); + + var built = await harness.Service.BuildAsync(StoreId, record.Id, Ct); + Assert.True(built.Success, built.Error); + return record; + } + /// One entry of an esplora /address/{address}/utxo response. private static string Utxo(long valueSat, uint vout = 0, bool confirmed = true) => string.Format( diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs index 368cbaf..c3afd68 100644 --- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs +++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs @@ -88,6 +88,7 @@ public class SparkController : Controller private readonly SparkDepositService _deposits; private readonly SparkStableBalanceService _stableBalance; private readonly ISparkUnilateralExitService _unilateralExit; + private readonly ISparkStoreRuntime _storeRuntime; private readonly CrossChainCatalog _crossChainCatalog; private readonly StablecoinPaymentService _stablecoins; private readonly IAuthorizationService _authorizationService; @@ -104,6 +105,7 @@ public SparkController( SparkDepositService deposits, SparkStableBalanceService stableBalance, ISparkUnilateralExitService unilateralExit, + ISparkStoreRuntime storeRuntime, CrossChainCatalog crossChainCatalog, StablecoinPaymentService stablecoins, IAuthorizationService authorizationService, @@ -119,6 +121,7 @@ public SparkController( _deposits = deposits; _stableBalance = stableBalance; _unilateralExit = unilateralExit; + _storeRuntime = storeRuntime; _crossChainCatalog = crossChainCatalog; _stablecoins = stablecoins; _authorizationService = authorizationService; @@ -847,7 +850,12 @@ private async Task BuildAdvancedViewModel( // Presence only — the key itself never leaves the settings blob for this page. Nobody else should // be using a store's key even though Breez does not treat it as a secret, so the page has no // business printing it into the DOM. - HasApiKeyOverride = !string.IsNullOrEmpty(settings?.ApiKeyOverride) + HasApiKeyOverride = !string.IsNullOrEmpty(settings?.ApiKeyOverride), + // Same discipline for the exit-state backup, and for the same reason at a higher severity: the + // blob describes the whole wallet's tree. Gated on the experiment too, so the block that would + // display it is never told there is one on a server where that block does not render. + HasExitStateBackup = Constants.UnilateralExitEnabled + && !string.IsNullOrEmpty(settings?.UnilateralExit.ExitStateBackup) }; } @@ -1059,6 +1067,172 @@ public async Task BuildExit( return RedirectToAction(nameof(Exit), new { storeId }); } + /// + /// Asks the chain how far the built exit has got, and re-renders the page with the answer and the + /// refreshed transactions. + /// + /// + /// + /// Renders instead of redirecting, and this is the only action in the region that does. The answer + /// belongs to this page's own table: a verdict that the stored set can no longer finish is only legible + /// beside the transactions it is about, and the refreshed statuses have to be on screen in the same + /// response the operator learns them from. A redirect would drop it into a status banner and show a table + /// that is still one read behind. The service has already persisted the refreshed set, so the re-read + /// below is showing what is stored, not a second opinion. + /// + /// + /// The service's refusal, if any, still goes through — a check that could not + /// run is a banner, not a verdict, and the page must not invent one. + /// + /// + [HttpPost("exit/check")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task CheckExit( + [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 + .CheckAsync(storeId, recordId, cancellationToken) + .ConfigureAwait(false); + + var page = await _unilateralExit.ReadAsync(storeId, cancellationToken).ConfigureAwait(false); + var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false); + var model = BuildExitViewModel(storeId, page, settings); + + // Read after the write, so the banner and the table describe the same moment. Verdict is not + // persisted by the service — it is a snapshot of the chain at the moment of this call — so it is + // carried on the model for this render and gone on the next read of the page. A check that refused + // carries no verdict, and the banner below says why rather than the page inventing one. + model.CheckResult = result.Success ? result.Verdict : null; + + if (!result.Success) + RelayExitResult(result, string.Empty); + + return View(nameof(Exit), model); + } + + /// + /// Exports this store's exit data and shows it once, for the operator to copy somewhere safe. + /// + /// + /// + /// The blob goes into the view model and never into TempData. It is a live SDK call that can + /// fail, so it cannot be a GET — but a redirect carrying it in a status message would print it into a + /// banner that survives navigation and redisplay, and the operator would have no way to tell which of the + /// store's pages was holding it. Rendered from the model, it exists for exactly one response. + /// + /// + /// Lives under the Advanced page because it is wallet infrastructure rather than one exit's business: it + /// is something a merchant should collect before they need it, and an exit built from data + /// collected while Spark was still reachable is the only kind that works with the operators gone. + /// + /// + /// This is the one action in the controller that resolves the store's live wallet itself, because + /// the export has no service method — exposes the check and the + /// backup store, not the export, and the exit service is the wrong owner for an operation that is + /// about the wallet rather than one exit. It goes through , the same seam + /// every service uses, so no SDK type is opened here: a null client is the ordinary "your wallet is not + /// running" answer and is reported as one, and any failure from the call is relayed through + /// exactly as the exit service relays its own. + /// + /// + [HttpPost("advanced/exit-state/export")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task ExportExitState([FromRoute] string storeId, CancellationToken cancellationToken) + { + if (!Constants.UnilateralExitEnabled) + return NotFound(); + + if (!ResolveStore(storeId, out var store)) + return NotFound(); + + storeId = store.Id; + + var status = await _statusReader.ReadAsync(storeId, cancellationToken).ConfigureAwait(false); + if (!status.Configured) + return await RedirectToSetupOrDeny(storeId).ConfigureAwait(false); + + var model = await BuildAdvancedViewModel(storeId, status, input: null, cancellationToken) + .ConfigureAwait(false); + + // Rendering rather than redirecting on every outcome, so the operator stays on the section they + // pressed the button in and the blob (when there is one) is never re-served by a replay of a + // redirect target. + var sdk = await _storeRuntime.GetSdkClientAsync(storeId).ConfigureAwait(false); + if (sdk is null) + { + TempData[WellKnownTempData.ErrorMessage] = + "This store's Spark wallet is not running, so its exit data cannot be read. Start the wallet " + + "and export again."; + return View("Advanced", model); + } + + try + { + model.ExportedExitState = await sdk + .ExportUnilateralExitStateAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // The message is the SDK's own, put through the same scrubber every other SDK failure goes + // through: this path prints its result into a page, so an unscrubbed payload would be a leak with + // a textarea around it. + _logger.LogWarning(ex, "Store {StoreId}: could not export unilateral-exit state", storeId); + TempData[WellKnownTempData.ErrorMessage] = + "Spark could not export this wallet's exit data: " + SparkErrors.Describe(ex) + + ". Nothing was changed; try again, and check the server log if it keeps failing."; + } + + return View("Advanced", model); + } + + /// + /// Stores a pasted exit-state blob, replacing whatever was there. + /// + /// + /// The blob is written for the next restart to import, not imported now: an import overwrites the wallet's + /// view of its own chains, and doing it under a running wallet would race the SDK. Nothing here inspects + /// the string — whether it is a well-formed blob at all is the SDK's judgement at import time, and a + /// plugin-side shape check would only be a second, weaker parser in front of the real one. + /// + [HttpPost("advanced/exit-state")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task SetExitStateBackup( + [FromRoute] string storeId, + SparkAdvancedViewModel vm, + CancellationToken cancellationToken) + { + if (!Constants.UnilateralExitEnabled) + return NotFound(); + + if (!ResolveStore(storeId, out var store)) + return NotFound(); + + storeId = store.Id; + + var result = await _unilateralExit + .SetExitStateBackupAsync(storeId, vm.ExitStateBackup, cancellationToken) + .ConfigureAwait(false); + + RelayExitResult( + result, + string.IsNullOrWhiteSpace(vm.ExitStateBackup) + ? "No exit-state backup is stored for this store now, so a restart will not import one." + : "Exit-state backup stored. It is imported automatically when this store's wallet next " + + "starts, so it must be one the wallet can use."); + return RedirectToAction(nameof(Advanced), new { storeId }); + } + /// /// Abandons the record so the store can quote again. /// @@ -1222,7 +1396,16 @@ private SparkExitViewModel BuildExitViewModel( FundingKeyPath = page.FundingKeyPath, Transactions = page.Transactions ?? [], TransactionsUnreadable = page.TransactionsUnreadable, + // Carried as the service reported it, without an empty-list normalisation: the page gates the + // whole "send these now" section on it being non-null and non-empty, so turning a service that + // said "nothing is ready" into an empty list here would change nothing — but turning a service + // that said "I could not tell" into one would put an action block on screen for an unknown set. + PendingBroadcast = page.PendingBroadcast, EsploraApiUrl = settings?.UnilateralExit.EsploraApiUrl, + // Presence only, and only behind the feature gate — the section that shows this is gated too, and + // a store on a server with the experiment off has no exit data for the flag to be about. + HasExitStateBackup = Constants.UnilateralExitEnabled + && !string.IsNullOrEmpty(settings?.UnilateralExit.ExitStateBackup), NetworkName = _sweepSettings.Network.ChainName.ToString(), IsMainnet = _sweepSettings.Network.ChainName == ChainName.Mainnet }; diff --git a/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs b/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs index 87d8e5b..5c5c586 100644 --- a/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs +++ b/BTCPayServer.Plugins.Flint/Data/UnilateralExitRecord.cs @@ -161,11 +161,14 @@ public class UnilateralExitRecord /// 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. + /// Recorded so an operator can see which outputs this exit already committed to, and because a later build + /// passes them back to the SDK, which follows each outpoint to whatever it became rather than rejecting a + /// spent one. That is the SDK's 0.25 behaviour and the reason this column is no longer merely explanatory: + /// the outpoints are an input to the next attempt. (It used to be recorded because the SDK reported + /// FundingUtxoConflict by outpoint; 0.25 no longer reports a conflict as an error at all.) 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; } diff --git a/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs b/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs index 212b187..982a208 100644 --- a/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs +++ b/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs @@ -56,4 +56,39 @@ public class SparkAdvancedViewModel /// is what the form looks like when nothing was touched. /// public bool UseBuiltInKey { get; set; } + + /// + /// Whether an exit-state backup is currently stored for this store. + /// + /// + /// Presence only. The stored blob is never rendered back into this page. It carries every leaf and + /// its transactions for the wallet, so it discloses the balance, how that balance is split and the + /// wallet's history — it is the one blob in the plugin that is worth more to a reader than the account it + /// describes. An operator who wants a copy asks for a fresh export. + /// + [BindNever] + public bool HasExitStateBackup { get; set; } + + /// + /// A blob to store, inbound only. The stored blob is never written into this model. + /// + /// + /// Outbound it is bound but never populated; empty posted to the set action clears the stored backup, + /// matching the explorer override's pattern. This is typed as string rather than annotated with + /// [DataType] because it is opaque to this page by design: nothing validates its shape here, since + /// whether it is a usable blob is settled by the SDK when it is imported. + /// + [Display(Name = "Exit-state backup")] + public string? ExitStateBackup { get; set; } + + /// + /// A freshly exported blob, for one render, so the operator can copy it. + /// + /// + /// Its own field beside so a render cannot confuse "a backup exists" + /// with "here is that backup": only the export action sets this, and what it sets is what the SDK just + /// returned. + /// + [BindNever] + public string? ExportedExitState { get; set; } } diff --git a/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs index 95d48cd..46e5565 100644 --- a/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs +++ b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs @@ -132,6 +132,61 @@ public class SparkExitViewModel /// public string? EsploraApiUrl { get; set; } + /// + /// The stored transactions the service still reports as ready to broadcast, or null for none. + /// + /// + /// Null and empty are the same answer here — "nothing to send right now" — and the page treats them + /// identically, so nothing downstream can read an empty list as a failure. This is a convenience copy of + /// rows that are already in , kept so an operator opening this page a + /// day later does not have to read the whole set to find the one actionable row. It is deliberately not a + /// second source of truth: the hex, the txid and the package decision all come from the entries here, and + /// the section that lists them is only ever a filter over the table below. + /// + [ValidateNever] + public IReadOnlyList? PendingBroadcast { get; set; } + + /// + /// The verdict from the operator's last check-in with the chain, or null when none has just run. + /// + /// + /// + /// Deliberately not TempData. A verdict of has to be read + /// beside the table it is about — the transactions below are the ones that can no longer finish — and a + /// status banner survives a redirect but shows a detached summary of a state the operator would then have + /// to match up by hand. Held on the model, it renders in the section it belongs to and disappears on the + /// next read of the page. + /// + /// + /// A verdict, not the whole : the refreshed transaction set is already + /// persisted by the service and arrives here as and + /// , so carrying a second copy would be two sources of truth for the same + /// rows in one render. + /// + /// + [ValidateNever] + public SparkExitVerdict? CheckResult { get; set; } + + /// Whether an exit-state backup blob is currently stored for this store. Presence only. + /// + /// The blob itself is never rendered back into the page, for the same reason the Breez API key is not: + /// it carries every leaf and its transactions, so it discloses the balance, how it is split and the + /// history. The Advanced page therefore shows only whether one exists, and an operator who wants a copy + /// asks for a fresh export. + /// + public bool HasExitStateBackup { get; set; } + + /// + /// A freshly exported exit-state blob, held for one render so the operator can copy it. + /// + /// + /// Never a stored blob. This is set only by the export action, from a live SDK call, and is + /// discarded with the response — there is no path that reads the stored backup and puts it in a page. + /// Set as its own field rather than appended to so a render cannot + /// confuse "a backup exists" with "here is the backup". + /// + public string? ExportedExitState { get; set; } + /// The chain this server runs on, named in the copy that depends on it. public string NetworkName { get; set; } = string.Empty; diff --git a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs index 519be8f..9c6654f 100644 --- a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs @@ -84,6 +84,59 @@ Task QuoteAsync( /// Task MarkCompletedAsync(string storeId, string recordId, CancellationToken cancellationToken = default); + /// + /// Asks the chain how far a built exit has got, refreshes the record's stored transaction statuses from the + /// answer, and reports the SDK's verdict on the caller's result. + /// + /// + /// + /// Safe to call as often as an operator likes: it broadcasts nothing and signs nothing. It reads the + /// chain and nothing else — no wallet, no leaves, no funding, no signer — which is what makes a built exit + /// followable from a stored record alone, days after the build, on a plugin that has been restarted since. + /// + /// + /// The refreshed transactions are persisted in place of the stored set, replacing the statuses with what the + /// chain now reports. The is deliberately not persisted: it is derived + /// state, recomputed by the SDK from the chain on every call, and a stored copy would be a claim about the + /// chain that goes stale the moment it is written — an operator would see "on track" on a page rendered from + /// a row nothing has refreshed. It travels back on instead. + /// + /// + /// Refused for anything that is not : there is no transaction set to + /// check before a build, and nothing to say about one after it has been completed or abandoned. + /// + /// + Task CheckAsync( + string storeId, + string recordId, + CancellationToken cancellationToken = default); + + /// + /// Stores an exported unilateral-exit backup blob on the store's exit settings, or clears it when the + /// argument is null or blank. + /// + /// + /// + /// The blob is sensitive and must never be logged or rendered. It carries every leaf of the wallet and + /// the transactions under them, so it discloses the balance, how that balance is split and what the wallet + /// has received and spent. Anything that shows it — an error message, a log line, a page — leaks the + /// store's financial history to whoever can read that surface. + /// + /// + /// The format is not validated here, deliberately. It is the SDK's own opaque encoding and the SDK is + /// the only thing that can judge whether a value is usable; a validator invented on this side would reject + /// valid backups from a future SDK, which is the one input that has to keep working — the operator pasting it + /// is doing so because the wallet's own storage is already lost. Only an absurd size is refused, because the + /// SDK documents a real wallet's backup as reaching several megabytes, so a value past the cap is a paste + /// error rather than a backup. + /// + /// + /// The exported blob, or null/blank to clear what is stored. + Task SetExitStateBackupAsync( + string storeId, + string? exitState, + 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 @@ -97,7 +150,17 @@ Task QuoteAsync( /// 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); +/// +/// is set by and by nothing +/// else; every other path leaves it null, which is why it defaults. It is a snapshot of the chain at +/// the moment of the call and is deliberately not persisted — the SDK recomputes it from the chain +/// on every check, so a stored copy would be a stale claim about the chain rendered as if it were live. +/// +public sealed record UnilateralExitOpResult( + bool Success, + string? Error, + UnilateralExitRecord? Record, + SparkExitVerdict? Verdict = null); /// /// Everything the exit page renders in one read. The service is the only reader and writer of the @@ -133,6 +196,12 @@ public sealed record UnilateralExitOpResult(bool Success, string? Error, Unilate /// 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. /// +/// +/// The subset of whose status says it may be broadcast right now, in the +/// SDK's own order — the transactions the page tells the operator to send, and the reason it exists is that +/// a built exit runs to a dozen rows of which at most one or two are actionable at any moment. Null when +/// there is no built set, or when the set read back was empty. +/// public sealed record UnilateralExitPageData( bool WalletRunning, bool DisclosureAcknowledged, @@ -144,4 +213,5 @@ public sealed record UnilateralExitPageData( int? LeafCount, string? FundingKeyPath, IReadOnlyList? Transactions, - bool TransactionsUnreadable); + bool TransactionsUnreadable, + IReadOnlyList? PendingBroadcast); diff --git a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs index e39c79e..e0011bc 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs @@ -34,8 +34,8 @@ namespace BTCPayServer.Plugins.Flint.Services; /// /// One exit operation at a time per store, held in exactly as /// holds a sweep pass. Two of these must never overlap for a reason stronger than -/// tidiness: they would race the same funding UTXO, which the SDK reports as -/// after one of them has already committed. The gate also +/// tidiness: they would race the same funding UTXO, and a second build could spend an output the first has +/// already committed to signed transactions. The gate also /// covers the two settings writes, because storing settings tears down and reconnects the store's SDK handle — /// pulling it out from under a build in flight. It is an in-process gate, so the durable half of the same rule /// lives in the database: see and the compare-and-set on @@ -116,6 +116,18 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService "The exit was built, but its signed transactions could not be saved, so they are lost. Nothing was " + "broadcast. Try again."; + /// + /// Largest exit-state backup the plugin will store, in characters. + /// + /// + /// A backstop against a wrong paste, not a format check. The SDK documents a real wallet's export as reaching + /// several megabytes, so sixteen is generous by design: the cap has to be far above any true value, because + /// refusing a genuine backup costs an operator the one copy of data they cannot get back — and accepting a + /// paste that is not a backup costs only the storage. It exists at all because a pasted page or a hex dump can + /// be tens of megabytes, and a settings blob that size breaks every later settings read. + /// + internal const int MaxExitStateBackupChars = 16 * 1024 * 1024; + /// /// A funding key index that is not a BIP32 address index. Only reachable from a hand-edited row. /// @@ -129,11 +141,11 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService /// What the page data looks like when there is no feature, or no Flint on this store. /// /// - /// Spelled out once rather than at each return, because a positional record of eleven members is exactly the + /// Spelled out once rather than at each return, because a positional record of twelve members is exactly the /// shape where two "empty" literals drift apart from one another. /// private static UnilateralExitPageData AbsentFeature => - new(false, false, 0, null, [], null, null, null, null, null, false); + new(false, false, 0, null, [], null, null, null, null, null, false, null); private readonly ISparkStoreSettingsStore _settingsStore; private readonly ISparkStoreRuntime _runtime; @@ -242,6 +254,18 @@ public async Task ReadAsync( // has to become an explanation on the page instead of an exception in a view. var readable = TryReadTransactions(active, out var transactions); + // Derived here rather than left to the view. A built exit runs to a dozen rows of which at most one or + // two are actionable at any moment, so "send these now" is the one thing the page has to say about the + // set — and having it come from the same read that produced the list means the two cannot disagree. + // + // Null, not empty, when there is nothing to send: the page distinguishes "no built set yet" from "a set + // that is entirely waiting on timelocks", and collapsing those two would put a materialised empty + // instruction block on a record that has never been built. Null when the set was unreadable as well — + // there is nothing trustworthy to filter. + var pending = readable && transactions is { Count: > 0 } + ? transactions.Where(transaction => transaction.Status.CanBroadcast).ToArray() + : null; + return new UnilateralExitPageData( sdk is not null, exitSettings.DisclosureAcknowledged, @@ -253,7 +277,8 @@ public async Task ReadAsync( leafCount, keyPath, transactions, - !readable); + !readable, + pending); } /// @@ -347,6 +372,193 @@ public async Task SetExplorerUrlAsync( } } + /// + public async Task CheckAsync( + string storeId, + string recordId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + if (!Constants.UnilateralExitEnabled) + return Refuse(FeatureDisabled); + + if (string.IsNullOrWhiteSpace(recordId)) + return Refuse(ExitNotFound); + + // Held for the same reason the build holds it. Checking does not itself move money, but it writes the + // refreshed transaction set back over the row, and a compare-and-set landing between a build's two writes + // would let a check persist statuses for a set the build is in the middle of replacing. + if (!_running.TryAdd(storeId, 0)) + return Refuse(OperationInFlight); + + try + { + var record = await _records.GetAsync(storeId, recordId, cancellationToken).ConfigureAwait(false); + if (record is null) + return Refuse(ExitNotFound); + + if (record.Status is not UnilateralExitStatus.Built) + { + return new UnilateralExitOpResult( + false, + record.Status is UnilateralExitStatus.Abandoned + ? "This exit was abandoned, so there is nothing to check on chain." + : "This exit has not been built yet, so there is nothing to check on chain.", + record); + } + + // The status every compare-and-set below is guarded on, read once: the row must still be Built when + // the refreshed set lands, because anything else means the operator finished or abandoned it while the + // chain was being asked. + var from = record.Status; + + if (!TryReadTransactions(record, out var stored) || stored is not { Count: > 0 }) + { + // A built row whose set cannot be read has nothing to ask the chain about, and the SDK is handed + // the set — so this is a refusal rather than a call that would report on an exit that is not the + // one stored. The page reports the same condition from the same check. + return await FailAsync( + record, + from, + "This exit's stored transactions could not be read back, so there is nothing to check " + + "against the chain. Abandon it and quote a new one.") + .ConfigureAwait(false); + } + + // The SDK checks a whole exit response, and the record only stores the transactions — see + // UnilateralExitRecord. The two totals and the leaf set it echoes back are not inputs to the check, + // so the stored figures are passed through as they are rather than re-quoted: quoting is what this + // read path must not do, because a check has to work when the wallet is gone. + var stored2 = new SparkExitResult( + record.RecoverableValueSat, + record.TotalFeeSat, + stored, + DeserializeLeafIds(record).Select(id => new SparkExitLeaf(id, 0)).ToArray()); + + // The client can be gone — the wallet failed to start, or was stopped while the exit sat built — and + // reporting that as a chain failure would send the operator to look at the wrong thing. A built exit + // is checkable from a store whose wallet is down, which is the case this feature exists for, so a null + // client is a refusal here rather than a silent no-op. + var sdk = await _runtime.GetSdkClientAsync(storeId).ConfigureAwait(false); + if (sdk is null) + return new UnilateralExitOpResult(false, WalletNotRunning, record); + + SparkExitProgress progress; + try + { + progress = await sdk.CheckUnilateralExitAsync(stored2, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Store {StoreId}: could not check unilateral exit {ExitId} against the chain ({Reason})", + storeId, record.Id, SparkErrors.Describe(ex)); + + return await FailAsync( + record, + from, + "Spark could not read the chain for this exit: " + SparkErrors.Describe(ex) + + ". The stored transactions are unchanged and nothing was broadcast.") + .ConfigureAwait(false); + } + + // The statuses it reports replace the ones stored, which is the whole point of the call: an operator + // reading the page afterwards sees what the chain says, not what it said when the set was built. + record.TransactionsJson = JsonSerializer.Serialize(progress.Transactions.ToArray(), JsonOptions); + record.UpdatedUtc = _timeProvider.GetUtcNow(); + + // The verdict is not written to the row — it is derived state the SDK recomputes on every call, and a + // stored copy would be rendered later as a live claim about a chain nothing has re-read. It travels + // back on the result instead. The error is cleared on success for the same reason a build clears it. + record.LastError = null; + + if (!await _records.UpdateAsync(record, from, CancellationToken.None).ConfigureAwait(false)) + return new UnilateralExitOpResult(false, ExitChangedUnderneath, record, progress.Verdict); + + _logger.LogInformation( + "Store {StoreId}: checked unilateral exit {ExitId} against the chain: {Verdict}, {Ready} of " + + "{Count} transactions ready to broadcast. Nothing was broadcast by this check", + storeId, record.Id, progress.Verdict, + progress.Transactions.Count(transaction => transaction.Status.CanBroadcast), + progress.Transactions.Count); + + return new UnilateralExitOpResult(true, null, record, progress.Verdict); + } + finally + { + _running.TryRemove(storeId, out _); + } + } + + /// + public async Task SetExitStateBackupAsync( + string storeId, + string? exitState, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + if (!Constants.UnilateralExitEnabled) + return Refuse(FeatureDisabled); + + // Blank clears it, so the only way back to "no backup configured" is the same form that set one. + string? normalised = null; + if (!string.IsNullOrWhiteSpace(exitState)) + { + if (exitState.Length > MaxExitStateBackupChars) + { + // The only check made on the value, and it exists because the alternative is silently storing a + // paste that will never import. The SDK documents a real wallet's backup as reaching several + // megabytes, so anything past this cap is a wrong paste — a page's HTML, a hex dump of something + // else — rather than a backup. The content itself is not checked: see the interface remarks. + return Refuse(string.Format( + CultureInfo.InvariantCulture, + "That is {0:N0} characters, which is far larger than an exit-state backup can be, so it is " + + "not one. A real backup is a few megabytes at most. Nothing has been stored.", + exitState.Length)); + } + + normalised = exitState; + } + + if (!_running.TryAdd(storeId, 0)) + return Refuse(OperationInFlight); + + try + { + var settings = await _settingsStore.GetAsync(storeId).ConfigureAwait(false); + if (settings is null) + return Refuse(NotConfigured); + + var current = (settings.UnilateralExit ?? new UnilateralExitSettings()).ExitStateBackup; + if (string.Equals(current, normalised, StringComparison.Ordinal)) + { + // No write for a press that changes nothing: storing settings tears down and reconnects the + // store's wallet, which is not a thing to do to confirm the status quo. Compared by value rather + // than by reference, so a re-paste of the same blob is also a no-op. + return new UnilateralExitOpResult(true, null, null); + } + + // The subject and the log's description deliberately say nothing about the value: it discloses the + // store's balance and history, and this method is the one place in the plugin that handles it. + return await SaveExitSettingsAsync( + storeId, + settings, + exit => exit.ExitStateBackup = normalised, + normalised is null + ? "the unilateral-exit state backup being cleared" + : "a unilateral-exit state backup", + "The exit-state backup") + .ConfigureAwait(false); + } + finally + { + _running.TryRemove(storeId, out _); + } + } + /// public async Task QuoteAsync( string storeId, @@ -436,7 +648,7 @@ public async Task QuoteAsync( return Refuse( "Spark could not quote a unilateral exit: " + SparkErrors.Describe(ex) - + ". On this SDK version quoting still needs the Spark operators to be reachable."); + + ". Nothing has been recorded, so trying again is safe."); } // Not an error. Auto selection returns nothing whenever no leaf clears the fee rate, and the honest @@ -643,8 +855,7 @@ public async Task BuildAsync( record, from, "Spark could not re-price this exit: " + SparkErrors.Describe(ex) - + ". Nothing was signed, so trying again is safe. On this SDK version pricing an exit " - + "still needs the Spark operators to be reachable.") + + ". Nothing was signed, so trying again is safe.") .ConfigureAwait(false); } @@ -755,11 +966,10 @@ public async Task BuildAsync( ApplyQuote(record, committed); return await FailAsync(record, from, shortfall.Message).ConfigureAwait(false); } - catch (SparkExitFundingUtxoConflictException conflict) - { - ApplyQuote(record, committed); - return await FailAsync(record, from, conflict.Message).ConfigureAwait(false); - } + // No catch for a funding conflict, and its absence is the 0.25 change: the SDK removed + // FundingUtxoConflict entirely, because a spent funding output is no longer an error — it follows + // each outpoint to whatever it became and accepts fresh funding alongside it. An empty catch here + // would swallow a real failure into a message about a condition the SDK can no longer report. catch (Exception ex) { _logger.LogWarning(ex, @@ -785,6 +995,12 @@ public async Task BuildAsync( record.TotalFeeSat = result.TotalFeeSat; record.SingleUtxoFundingSat = committed?.SingleUtxoFundingSat ?? record.SingleUtxoFundingSat; record.FundingUtxosJson = JsonSerializer.Serialize(new[] { chosen }, JsonOptions); + // Replaced with exactly what the SDK returned, however short that is. Since 0.25 the build continues + // from confirmed chain state, so a partial set is the normal result of a resumed attempt and an empty + // one means everything it planned has already been seen on-chain. Merging the previous set back in + // would be actively wrong: a transaction missing from the new set is one the SDK has already observed + // confirm, or one its own re-plan dropped, and keeping a stale copy of it next to the new set invites + // an operator to broadcast a transaction the SDK has already superseded. record.TransactionsJson = JsonSerializer.Serialize(result.Transactions.ToArray(), JsonOptions); // Cleared, not left in place: a build that got further must not show the failed attempt's complaint // next to its own transactions. @@ -811,10 +1027,16 @@ public async Task BuildAsync( return new UnilateralExitOpResult(false, BuiltButNotSaved, record); } + // Wording that reads correctly when the set is empty, which is now a normal outcome rather than a fault: + // "built ... : 0 signed transactions (everything it planned is already on chain)". A count that only + // made sense above zero would have an operator reading a successful resumed build as a broken one. _logger.LogInformation( - "Store {StoreId}: built unilateral exit {ExitId}: {Count} transactions recovering {Recoverable} " - + "sat for {Fee} sat in fees. Nothing has been broadcast", - storeId, record.Id, result.Transactions.Count, result.RecoverableValueSat, result.TotalFeeSat); + "Store {StoreId}: built unilateral exit {ExitId}: {Count} signed transactions recovering " + + "{Recoverable} sat for {Fee} sat in fees{Note}. Nothing has been broadcast", + storeId, record.Id, result.Transactions.Count, result.RecoverableValueSat, result.TotalFeeSat, + result.Transactions.Count == 0 + ? " (the set is empty, so everything this build planned is already confirmed on chain)" + : string.Empty); return new UnilateralExitOpResult(true, null, record); } @@ -1280,7 +1502,7 @@ transaction is null || string.IsNullOrWhiteSpace(transaction.TxHex) || transaction.DependsOn is null || !Enum.IsDefined(transaction.Kind) - || !Enum.IsDefined(transaction.Status); + || !Enum.IsDefined(transaction.Status.Readiness); } /// diff --git a/BTCPayServer.Plugins.Flint/SparkSettings.cs b/BTCPayServer.Plugins.Flint/SparkSettings.cs index dc67c42..0caa0db 100644 --- a/BTCPayServer.Plugins.Flint/SparkSettings.cs +++ b/BTCPayServer.Plugins.Flint/SparkSettings.cs @@ -530,11 +530,37 @@ public class UnilateralExitSettings /// public string? EsploraApiUrl { get; set; } + /// + /// A backup of the SDK's unilateral-exit state, as produced by its export and accepted by its import. Null + /// when none has been stored. + /// + /// + /// + /// What this is for: the transactions an exit is built from live only in the SDK's local storage. + /// While the operators are reachable they can be fetched again; when that storage is gone and the operators + /// are not, they cannot be recovered from anywhere and the leaves they cover can no longer be exited. This + /// blob is the copy that survives the device. + /// + /// + /// It is sensitive and the plugin treats it as such. It carries every leaf of the wallet and its + /// transactions, which discloses the balance, how it is split and what the wallet has received and spent. It + /// must never be written to a log, echoed in an error, or sent back to a page. + /// + /// + /// Its content is not validated on the way in. The encoding is the SDK's own and the SDK is the only + /// thing that can judge it; a check invented here would reject a valid backup from a future SDK, and a + /// rejected backup is a lost exit. Only its length is bounded, because a value past the SDK's own few + /// megabytes is a paste error rather than a backup. + /// + /// + public string? ExitStateBackup { get; set; } + /// An independent copy. Every property added to this class must be added here too. public UnilateralExitSettings Clone() => new() { DisclosureAcknowledged = DisclosureAcknowledged, - EsploraApiUrl = EsploraApiUrl + EsploraApiUrl = EsploraApiUrl, + ExitStateBackup = ExitStateBackup }; } diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml index 814673b..f0bac07 100644 --- a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml +++ b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml @@ -105,6 +105,99 @@ id="SparkUnilateralExitLink"> Unilateral exit + + @* + Wallet infrastructure rather than one exit's business, which is why it lives here and not on the + exit page: it covers every leaf the wallet holds, and a merchant should collect it BEFORE they + need it. An exit is built from data the SDK keeps in this wallet's local storage; while the + operators are reachable that data can be re-fetched, and once they are gone and the storage is + lost it cannot be recovered from anywhere — so a backup taken now is the difference between an + exit that works with the operators vanished and one that does not. + *@ +

Exit-state backup

+

+ This wallet keeps the data an exit is built from in its own storage on this server. While + Spark's operators are reachable that data can be fetched again, so a wallet restored from its + recovery phrase rebuilds it. When they are gone and this server's storage is lost, it + cannot be recovered from anywhere — and the leaves it covered can no longer be exited. + Export it here, keep the copy somewhere safe, and an exit built from it goes ahead with the + operators gone. +

+ +

+ @if (Model.HasExitStateBackup) + { + Stored + An exit-state backup is stored for this store and is imported automatically when the + wallet next starts. It is not shown here — export a fresh copy if you need one. + } + else + { + None stored + No exit-state backup is stored, so a restart imports nothing. Export one below and + keep the copy. + } +

+ +
+ +
+ Reads the current data out of this store's running wallet. It changes nothing + and can fail if the wallet is not running — try again once it is. +
+ + + @if (Model.ExportedExitState is { Length: > 0 } exported) + { + @* + Rendered from the model, for this one response only — never read back out of storage. The + roll-up is what makes a multi-megabyte blob usable in a box this size; the copy button + still carries the whole value. + *@ +
+ + +
+ +
+
+ Paste this into the replacement field below only if you are restoring it here. Keep the + copy somewhere encrypted. +
+
+ } + +
+
+ + +
+ A backup you exported earlier. It is stored and imported automatically when this + store's wallet next restarts; it is not applied now. An out-of-date backup can + restore leaves that have since been spent, so the balance may read high until the next + sync. Submitting this empty stores nothing and clears the backup. +
+
+ + }
"Sweep" }; - static string DescribeTxStatus(SparkExitTxStatus status) => status switch + // The SDK's 0.25 status is a union, not a flat enum, because the useful question stopped being "is it + // mined" and became "may I broadcast it yet". So this says the action, not the state: an operator reading + // "Not confirmed" beside a transaction the chain service could not see would reasonably go looking for a + // node. Each readiness gets its own wording and none of them fall through to a generic one — "waiting" and + // "unverified" both mean "do not send this yet" but the next step is different in each case. + static string DescribeTxStatus(SparkExitTxStatus status) => status.Readiness switch { - SparkExitTxStatus.Confirmed => "Confirmed", - SparkExitTxStatus.Unconfirmed => "Not confirmed", - _ => "Unverified" + SparkExitTxReadiness.Ready => "Ready — broadcast it now", + // Naming the height is what turns "wait" into a number. Without it the operator has nothing to do but + // reload the page, and a page that only ever says "wait" is one people stop opening. + SparkExitTxReadiness.Waiting when status.SpendableAtHeight is { } spendableAt + => $"Waiting — valid from block {spendableAt.ToString("N0")}", + SparkExitTxReadiness.Waiting + => "Waiting — a transaction it depends on has not confirmed", + SparkExitTxReadiness.Confirmed when status.BlockHeight is { } blockHeight + => $"Confirmed in block {blockHeight.ToString("N0")} — nothing left to do", + SparkExitTxReadiness.Confirmed => "Confirmed — nothing left to do", + _ => "Unverified — no chain service answered" }; + // The one place the submitpackage-versus-sendrawtransaction decision is made. A tree transaction pays no + // fee of its own, so submitting it alone is rejected — which is why this is a decision and not a format + // string. It is a local function called from both the "send these now" list and the full table, so the two + // cannot disagree about which command a given row needs: a merchant who ran one command from the summary + // and a different one from the table would be right to stop trusting both. + static string BroadcastCommand(SparkExitTransaction tx) => tx.CpfpTxHex is { } cpfpTxHex + ? $"bitcoin-cli submitpackage '[\"{tx.TxHex}\",\"{cpfpTxHex}\"]'" + : $"bitcoin-cli sendrawtransaction {tx.TxHex}"; + // The explorer form appears in two of this page's states — before anything is quoted, and beside the // funding panel — and never in both at once, so one local function serves both without colliding ids. // Off mainnet it is not a preference: mempool.space has no regtest or testnet, so funding discovery @@ -126,9 +148,11 @@ control, in a specific order, over those days.

- It still needs Spark's operators to be reachable. The version of the SDK - shipped here has to talk to them to price and build an exit, so this is not yet a remedy for - operators who have vanished — only for operators who refuse. + It works without Spark's operators only if you prepared for it. An exit is + quoted and built from data the SDK holds on this server, so it does not need the operators + to be reachable — but only for leaves whose data was collected while they still were. That + is what the exit-state backup on the Advanced page is for: collect it before you need it, + and an exit built from it goes ahead with the operators gone for good.

It is experimental. It is behind a server-side switch for that reason, and @@ -448,18 +472,83 @@ {

Broadcast it yourself

+ @if (Model.CheckResult is { } verdict) + { + @* + The three verdicts are not a scale of severity — they are three different next actions, + which is why each gets its own copy rather than a shared banner with a varying colour. + In particular Redo is an error that must not read like a loss: the chain moved out from + under the stored transactions, and the money is still there to be recovered by building + again. The word "abandon" is deliberately absent. + *@ + @if (verdict is SparkExitVerdict.Done) + { + + } + else if (verdict is SparkExitVerdict.Redo) + { + + } + else + { + + } + } +
+ @* + The actionable slice of the table below, first. A built exit runs to a dozen rows + and at most one or two are ever sendable at a time, so an operator who opens this + page a day later should not have to read the whole set to find the row that matters. + This is a filter, not a second exit: the same transactions, the same + BroadcastCommand, read off the same model. + *@ + @if (Model.PendingBroadcast is { Count: > 0 } pending) + { +

Send these now

+ + } +
@@ -577,11 +703,13 @@ @@ -623,7 +751,15 @@ two are only valid submitted together.

- bitcoin-cli submitpackage '["@tx.TxHex","@cpfpTxHex"]' + @* + The command comes from BroadcastCommand rather than being + spelled out here, because the "send these now" list above + renders the same decision for the same row. Two copies + would be two chances to disagree about when a package is + required, and the merchant runs whichever one they read + first. + *@ + @BroadcastCommand(tx)
} + @* + The check-in sits outside the branches above on purpose. It is the only control that reports + progress rather than asking for an action, and it is the one thing an operator wants in + *every* built state — including the empty and unreadable ones, where "what does the chain + say about this record" is the question they actually have. It posts rather than being a + link because it re-reads the chain and rewrites the stored statuses, and it works with this + store's Spark wallet stopped, so it is not gated on the wallet. + *@ +
+ + + +
+ Asks the chain how far this exit has got and updates every status above. + It broadcasts nothing and signs nothing, so it is safe to press as + often as you like. Checking daily is the point: a step left unsent for a day or more + can start paying its own fee out of the money it is recovering. +
+ +
+ @* The exit's own ending. Nothing in Phase 0 watches the chain, so the plugin cannot notice that the sweep confirmed — without this button the only way a finished exit leaves the active state diff --git a/CHANGELOG.md b/CHANGELOG.md index 77877c0..b4fc30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,40 @@ All notable changes to this plugin are recorded here. The format follows more than an ordinary claim would be allowed to. A deposit credited early stays in the SDK's unclaimed list until the provider spends its output; the plugin no longer shows it as unclaimed, so it cannot read as stuck and invite a second claim. +- **A unilateral exit no longer needs Spark's operators to be reachable.** The experimental + unilateral exit is rebuilt on the SDK's exit API, which inverted the flow — an exit is now quoted + into a prepared request that is passed back into the build, `CheckUnilateralExit` reports progress + against the chain, and the SDK can export and import the wallet's exit data — and the plugin's + exit surface follows it. On the old API, pricing and building an exit talked to the operators, so + the flow only helped against operators who *refused*; against operators that were gone it could do + nothing. An exit is now quoted and built from data the SDK holds locally. The caveat is the point + of the next entry: this works for leaves whose data was collected while the operators *were* + reachable, and only for those. + +### Added + +- **An exit-state backup, on the Advanced page.** The SDK can now export the wallet's unilateral-exit data + and import it back, and an exit built from an exported copy is the only kind that survives the loss of the + wallet's own storage while the operators are gone. The page exports a fresh blob for copying and stores a + pasted one, which a restart imports automatically. The blob is sensitive — it carries every leaf and its + transactions, so it discloses the balance, how it is split and the history — so it is never rendered back + out of storage, and the page says so. +- **A "Check progress" control on the exit page.** Asks the chain how far a built exit has got and refreshes + every transaction's status. It reports whether the set is on track, finished (with a pointer to marking it + completed), or can no longer finish — in which case it says plainly that the money is not lost and that the + fix is to build again from the same leaves. It broadcasts and signs nothing, and it works with the store's + wallet stopped. +- **A "send these now" list above the transaction table**, naming exactly which transactions can be + broadcast at this moment and the command to run for each, so an operator opening the page a day later does + not have to read the whole set to find the actionable row. + +- **The exit page now warns that leaving a step unbroadcast costs money.** About 50 blocks (~8 hours) after + a step becomes valid, Spark's watchtowers can broadcast their own version of that step; its fee is taken + out of the leaf rather than paid by the funding UTXO, so a step left unsent for a day or more pays part of + its own cost out of the money being recovered. Each transaction row still reports its CSV timelock, but the + page now also reports readiness directly — "broadcast it now", "valid from block N", or "confirmed" — and + tells the operator that following the Status column is what matters, since broadcasting an already-sent + transaction is harmless. ## [1.1.0] — 2026-09-07 diff --git a/README.md b/README.md index ecb9d93..81911ed 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ chain; and it can be held in USDB between sweeps. and Flashnet. A balance sitting on it is not in your sole custody the way an on-chain UTXO or a channel you own is: every Lightning receive rides Lightspark's service provider, and every automated flow in this plugin performs **cooperative exits only** — the sole unilateral-exit path is an experimental, environment-gated flow on the -Advanced page whose transactions the operator broadcasts by hand. Sweeping is +Advanced page whose transactions the operator broadcasts by hand, and which needs an exit-state backup taken +while the operators were still reachable to work without them. Sweeping is the only thing that reduces that exposure, which is why the sweep threshold is the most important setting on the plugin. Stable Balance and cross-chain sweeps each add a further counterparty of a different kind: a regulated stablecoin issuer whose token metadata says it can **freeze** the balance, and a bridge diff --git a/docs/limitations.md b/docs/limitations.md index db3599b..b503681 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -139,15 +139,22 @@ environment gate (`FLINT_EXPERIMENTAL_UNILATERAL_EXIT`) on the Advanced page and carries four limits that do not show from the name alone. The plugin **never broadcasts**: it quotes, funds and signs, and the operator pushes every transaction out by hand, package by package, through a node that supports package - relay — a plain `sendrawtransaction` rejects the zero-fee tree transactions. Building an exit **still - requires the Spark operators to be reachable** on the pinned SDK (0.22.0); exiting from purely local state - arrives with a later SDK release, so today this path defends against operators who stop cooperating, not - operators who are gone. The fees are paid from a **separate on-chain output the operator funds by hand**, - as a single output covering the quoted amount, on an address derived from the store's seed at a documented - path. And settlement is **not fast**: refunds carry multi-day CSV timelocks, and nothing in the plugin - watches the chain on the operator's behalf. Funding discovery also asks a block explorer - (mempool.space by default on mainnet, configurable) about the funding address, which discloses that - address to a third party unless an own instance is configured. + relay — a plain `sendrawtransaction` rejects the zero-fee tree transactions. An exit is quoted and built + from data the SDK holds **locally**, so on the pinned SDK (0.25.0) it does not need the Spark operators to + be reachable — but only for leaves whose data was collected while they still were. That is why the + exit-state backup on the Advanced page matters: it is the copy that survives the wallet's own storage, and + a leaf is only exitable this second way once its chain has been synced at least once so its data exists to + be collected. So the path now covers operators who are gone, given a backup taken in time — not a wallet + that was never online with them. The fees are paid from a **separate on-chain output the operator funds by + hand**, as a single output covering the quoted amount, on an address derived from the store's seed at a + documented path. And settlement is **not fast**: refunds carry multi-day CSV timelocks, and nothing in the + plugin watches the chain on the operator's behalf. That last point has a cost worth naming: about 50 blocks + after a step becomes valid, Spark's watchtowers can broadcast their own version of that step, whose fee is + taken out of the leaf rather than paid by the funding UTXO — so a step left unbroadcast for a day or more + can end up paying for parts of itself out of the money being recovered. Checking the exit page daily is the + mitigation. Funding discovery also asks a block explorer (mempool.space by default on mainnet, + configurable) about the funding address, which discloses that address to a third party unless an own + instance is configured. - **Neither post-MVP feature can be tested off mainnet.** Cross-chain sending is hard-gated — the SDK throws at connect on any other network — and Stable Balance is *accepted* on regtest and then never converts, because USDB does not exist there. So the unit tests run against a fake built to model the real SDK's diff --git a/docs/trust-model.md b/docs/trust-model.md index 24fff7c..4cf30f4 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -5,7 +5,8 @@ Spark is a 2-of-3 statechain system operated by Lightspark, Breez and Flashnet. Funds held on Spark are not held in your own custody in the way an on-chain UTXO or a Lightning channel you own is: every Lightning receive rides Lightspark's service provider, and unilateral exit is a multi-day last -resort that requires reachable operators and an external UTXO. Keeping the auto-sweep threshold low +resort that needs an external UTXO and an exit-state backup taken while the operators were still +reachable. Keeping the auto-sweep threshold low is the best available mitigation, since it bounds how much is ever exposed on the L2. Every sweep this plugin makes is a **cooperative exit**, and that is the only automated path off Spark: the @@ -21,14 +22,20 @@ before counting on it: - **The plugin never broadcasts.** It asks the SDK to build and sign the statechain's timelocked transaction tree and then shows you the raw transactions; pushing them, in dependency order, with `submitpackage` where a transaction and its fee-bumping child go together, is your job. -- **It still needs the operators reachable.** On the pinned SDK, preparing an exit talks to them — so the - scenario you most want this for, operators gone for good, is the one it cannot serve yet. That changes when - the SDK ships exit-from-local-state. +- **It works with the operators gone only if you prepared.** An exit is quoted and built from data the SDK + holds locally, so it no longer needs the operators to be reachable — but only for leaves whose data was + collected while they still were. The exit-state backup on the Advanced page is that copy: a wallet whose + own storage is lost and has no backup cannot rebuild its exit data from anywhere once the operators are + gone, and a leaf is only exitable this way once its chain has been synced at least once. - **You have to fund it on-chain first.** The tree transactions cannot pay their own fees, so the exit is bumped by CPFP from a native-SegWit UTXO you send to an address the plugin derives from the store's seed at its own hardened account. Too little there and nothing gets built. - **It settles in days, not seconds.** The outputs are behind CSV timelocks measured in blocks; the money is - spendable when the last one expires, not when the transactions are signed. + spendable when the last one expires, not when the transactions are signed. That window has a cost: about 50 + blocks after a step becomes valid, Spark's watchtowers can broadcast their own version of that step, whose + fee comes out of the leaf rather than the funding UTXO — so a step left unbroadcast for a day or more pays + part of its own cost out of the money being recovered. The exit page reports each step's readiness, and the + operator is expected to check it daily. So it is a last resort that costs days and attention, not a second sweep destination. If the operators became unavailable and this path did not get you out, recovering funds still means using the store's recovery From b2d2cff043cc3e2eb25b614523362ed015d79e40 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:14:18 -0400 Subject: [PATCH 09/22] Ground the CheckAsync leaf placeholder in the SDK's documented contract check_unilateral_exit reads the chain and nothing else, so the leaf values it is handed are not an input to the verdict. Say so where the placeholder is built, and say why it is zero rather than a plausible number: a zero cannot be mistaken for a real leaf value by a later reader. --- .../packages.lock.json | 341 +++++++++--------- BTCPayServer.Plugins.Flint/packages.lock.json | 341 +++++++++--------- 2 files changed, 342 insertions(+), 340 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json index 371f497..1397c28 100644 --- a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json +++ b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json @@ -13,13 +13,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.2", - "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -91,63 +91,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.8", - "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", + "resolved": "1.7.6", + "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.7", - "BTCPayServer.Lightning.Eclair": "1.7.2", - "BTCPayServer.Lightning.LND": "1.7.2", - "BTCPayServer.Lightning.LNDhub": "1.7.2", - "BTCPayServer.Lightning.Phoenixd": "1.7.2" + "BTCPayServer.Lightning.CLightning": "1.7.5", + "BTCPayServer.Lightning.Eclair": "1.7.1", + "BTCPayServer.Lightning.LND": "1.7.1", + "BTCPayServer.Lightning.LNDhub": "1.7.1", + "BTCPayServer.Lightning.Phoenixd": "1.7.1" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.7", - "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", + "resolved": "1.7.5", + "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", + "resolved": "1.7.1", + "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", "dependencies": { - "NBitcoin": "10.0.9", + "NBitcoin": "10.0.1", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", + "resolved": "1.7.1", + "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", + "resolved": "1.7.1", + "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", + "resolved": "1.7.1", + "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", + "resolved": "1.7.1", + "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.NTag424": { @@ -271,11 +271,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.2.1039", - "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", + "resolved": "9.1.982", + "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", "dependencies": { - "AngleSharp": "1.7.2", - "AngleSharp.Css": "1.0.2" + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1" } }, "libsodium": { @@ -325,67 +325,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", + "resolved": "10.0.10", + "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.11" + "Microsoft.Extensions.Features": "10.0.10" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" + "resolved": "10.0.10", + "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", + "resolved": "10.0.10", + "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", + "resolved": "10.0.10", + "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.11", - "Microsoft.Extensions.Identity.Stores": "10.0.11" + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Identity.Stores": "10.0.10" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", + "resolved": "10.0.10", + "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", + "resolved": "10.0.10", + "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.11", + "Microsoft.AspNetCore.JsonPatch": "10.0.10", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", + "resolved": "10.0.10", + "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", + "resolved": "10.0.10", + "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.11", + "Microsoft.AspNetCore.SignalR.Common": "10.0.10", "Newtonsoft.Json": "13.0.3" } }, @@ -423,80 +423,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -533,16 +533,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -551,27 +551,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" + "resolved": "10.0.10", + "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -623,68 +623,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", + "resolved": "10.0.10", + "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", - "Microsoft.Extensions.Diagnostics": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", + "resolved": "10.0.10", + "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.Identity.Core": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Identity.Core": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Configuration.Binder": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -778,8 +778,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", + "resolved": "10.0.8", + "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -896,16 +896,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.8.0", - "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1202,29 +1202,30 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", - "BTCPayServer.Common": "[2.4.4, )", - "BTCPayServer.Data": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Common": "[2.4.2, )", + "BTCPayServer.Data": "[2.4.2, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.8, )", + "BTCPayServer.Lightning.All": "[1.7.6, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.4, )", + "BTCPayServer.Rating": "[2.4.2, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "NBitcoin": "[10.0.10, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.8.0, )", - "Serilog": "[4.4.0, )", + "QRCoder": "[1.7.0, )", + "SSH.NET": "[2025.1.0, )", + "Serilog": "[4.3.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1238,18 +1239,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.4.4, )", - "HtmlSanitizer": "[9.2.1039, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "BTCPayServer.Client": "[2.0.2, )", + "HtmlSanitizer": "[9.1.982, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.2, )", - "NBitcoin": "[10.0.10, )", + "BTCPayServer.Lightning.Common": "[1.7.1, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1263,11 +1264,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1285,7 +1286,7 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } } diff --git a/BTCPayServer.Plugins.Flint/packages.lock.json b/BTCPayServer.Plugins.Flint/packages.lock.json index b8eecf0..420ad53 100644 --- a/BTCPayServer.Plugins.Flint/packages.lock.json +++ b/BTCPayServer.Plugins.Flint/packages.lock.json @@ -20,13 +20,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.2", - "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -93,63 +93,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.8", - "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", + "resolved": "1.7.6", + "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.7", - "BTCPayServer.Lightning.Eclair": "1.7.2", - "BTCPayServer.Lightning.LND": "1.7.2", - "BTCPayServer.Lightning.LNDhub": "1.7.2", - "BTCPayServer.Lightning.Phoenixd": "1.7.2" + "BTCPayServer.Lightning.CLightning": "1.7.5", + "BTCPayServer.Lightning.Eclair": "1.7.1", + "BTCPayServer.Lightning.LND": "1.7.1", + "BTCPayServer.Lightning.LNDhub": "1.7.1", + "BTCPayServer.Lightning.Phoenixd": "1.7.1" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.7", - "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", + "resolved": "1.7.5", + "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", + "resolved": "1.7.1", + "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", "dependencies": { - "NBitcoin": "10.0.9", + "NBitcoin": "10.0.1", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", + "resolved": "1.7.1", + "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", + "resolved": "1.7.1", + "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", + "resolved": "1.7.1", + "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", + "resolved": "1.7.1", + "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.NTag424": { @@ -273,11 +273,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.2.1039", - "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", + "resolved": "9.1.982", + "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", "dependencies": { - "AngleSharp": "1.7.2", - "AngleSharp.Css": "1.0.2" + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1" } }, "libsodium": { @@ -322,67 +322,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", + "resolved": "10.0.10", + "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.11" + "Microsoft.Extensions.Features": "10.0.10" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" + "resolved": "10.0.10", + "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", + "resolved": "10.0.10", + "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", + "resolved": "10.0.10", + "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.11", - "Microsoft.Extensions.Identity.Stores": "10.0.11" + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Identity.Stores": "10.0.10" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", + "resolved": "10.0.10", + "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", + "resolved": "10.0.10", + "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.11", + "Microsoft.AspNetCore.JsonPatch": "10.0.10", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", + "resolved": "10.0.10", + "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", + "resolved": "10.0.10", + "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.11", + "Microsoft.AspNetCore.SignalR.Common": "10.0.10", "Newtonsoft.Json": "13.0.3" } }, @@ -420,80 +420,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -530,16 +530,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -548,27 +548,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" + "resolved": "10.0.10", + "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -620,68 +620,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", + "resolved": "10.0.10", + "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", - "Microsoft.Extensions.Diagnostics": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", + "resolved": "10.0.10", + "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.Identity.Core": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Identity.Core": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Configuration.Binder": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -740,8 +740,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", + "resolved": "10.0.8", + "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -858,16 +858,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.8.0", - "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1082,29 +1082,30 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", - "BTCPayServer.Common": "[2.4.4, )", - "BTCPayServer.Data": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Common": "[2.4.2, )", + "BTCPayServer.Data": "[2.4.2, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.8, )", + "BTCPayServer.Lightning.All": "[1.7.6, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.4, )", + "BTCPayServer.Rating": "[2.4.2, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "NBitcoin": "[10.0.10, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.8.0, )", - "Serilog": "[4.4.0, )", + "QRCoder": "[1.7.0, )", + "SSH.NET": "[2025.1.0, )", + "Serilog": "[4.3.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1118,18 +1119,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.4.4, )", - "HtmlSanitizer": "[9.2.1039, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "BTCPayServer.Client": "[2.0.2, )", + "HtmlSanitizer": "[9.1.982, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.2, )", - "NBitcoin": "[10.0.10, )", + "BTCPayServer.Lightning.Common": "[1.7.1, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1143,11 +1144,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1157,7 +1158,7 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } } From cc27413ad5510f7c5d592a0e359b3417dc597e6a Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:27:13 -0400 Subject: [PATCH 10/22] Import the exit-state backup when the wallet starts The Advanced page, the exit controller's banner and the CHANGELOG all told an operator that a stored backup is imported automatically on the next wallet start. Nothing did it: ExitStateBackup was written by SetExitStateBackupAsync and read only to render a 'Stored' badge, so the blob was data the plugin recorded and never used. The backup is the recovery path for a wallet whose own storage is lost while the Spark operators are gone, so an operator was told their exit data was secured and would find out otherwise only when they needed it. The import now runs on the warm-up path, fire-and-forget like the first sync and behind the same exception boundary, so it cannot hold up BTCPay's startup. It is ordered before the sync rather than after, because the sync needs the operators and the import is for when they are gone. Nothing about it can fail a connect: a wallet whose backup will not import still has a working Lightning wallet, and the failure is logged rather than raised. The blob itself is never logged on either path. --- .../SparkServiceStartupTests.cs | 135 ++++++++++++++++++ .../Services/SparkService.cs | 94 ++++++++++++ 2 files changed, 229 insertions(+) diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs index e97bf83..19472b1 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs @@ -26,8 +26,11 @@ namespace BTCPayServer.Plugins.Flint.Tests; /// cancellation, because no SDK call can be cancelled — see . /// /// +[Collection(UnilateralExitTestCollection.Name)] public class SparkServiceStartupTests { + private const string Gate = "FLINT_EXPERIMENTAL_UNILATERAL_EXIT"; + /// /// How long startup may take before it is treated as hung. /// @@ -319,6 +322,138 @@ private static TimeSpan StartWithinTimeout(SparkServiceHarness h) return stopwatch.Elapsed; } + // ------------------------------------------------------------------------------------------------ + // The exit-state backup is imported on connect, or the page lies about it. + // ------------------------------------------------------------------------------------------------ + + private const string BackupStore = "store-with-an-exit-state-backup"; + + /// + /// A stored exit-state backup is put into the wallet when it starts. + /// + /// + /// This is the only thing that makes the backup worth taking. The Advanced page tells an operator + /// that a stored backup is imported automatically, and the backup exists for the case where the wallet's + /// own storage is gone while the Spark operators are unreachable — the one situation in which a leaf's exit + /// data cannot be re-fetched from anywhere. If nothing imports it, the operator is shown a "Stored" badge + /// for data the plugin never reads, and they find out only when they need it, which is the worst possible + /// moment. So this asserts the import reaches the SDK with the stored value, not merely that a code path + /// exists. + /// + [Fact] + public async Task A_stored_exit_state_backup_is_imported_when_the_wallet_starts() + { + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(); + h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); + WithExitStateBackup(h, BackupStore, "the-stored-backup-blob"); + + StartWithinTimeout(h); + + // The import happens on the warm-up path, which is deliberately not awaited by the connect, so the + // assertion has to wait for it rather than assume it has already run. + await WaitUntil( + () => h.Sdk.Clients.TryGetValue(BackupStore, out var backupClient) && backupClient.ExitImportCalls.Count > 0, + "the exit-state backup to be imported"); + + Assert.Equal(["the-stored-backup-blob"], h.Sdk.Clients[BackupStore].ExitImportCalls); + } + + /// + /// A store with no backup has nothing imported, so the feature costs nothing until it is used. + /// + /// + /// The other half of the guard above, and the one that catches an import wired to the wrong place: an + /// implementation that imported on every connect regardless of whether a backup existed would pass the + /// first test while quietly sending an empty or absent value to the SDK for every store on the server. + /// The gate is on for this test deliberately. With it off, nothing imports and the assertion would + /// pass against an implementation that imported for every store, which is precisely the bug it exists to + /// catch. + /// + [Fact] + public async Task A_store_with_no_backup_imports_nothing_on_start() + { + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(); + h.SeedStore(HealthyStore, SparkServiceHarness.MnemonicFor(1)); + + StartWithinTimeout(h); + + // Give a would-be import the same window the positive test gives the real one, so this cannot pass + // merely by observing the store before an incorrect import had a chance to run. + await Task.Delay(250); + + Assert.Empty(h.Sdk.Clients[HealthyStore].ExitImportCalls); + } + + /// + /// The backup value itself never reaches the log, on either path. + /// + /// + /// The blob carries every leaf of the wallet and the transactions that spend them, so it discloses the + /// balance, how it is split, and the payment history. It is the one secret on this surface, and the import + /// is the only place the plugin handles it — which makes it the place a well-meaning debug line would leak + /// it. Asserted on the failure path too, because that is where an implementation is most tempted to print + /// what it could not read. + /// + [Fact] + public async Task The_exit_state_backup_value_never_reaches_the_log() + { + const string secret = "blob-that-must-not-be-logged-9f3a"; + + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(); + h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); + WithExitStateBackup(h, BackupStore, secret); + + StartWithinTimeout(h); + await WaitUntil( + () => h.Sdk.Clients.TryGetValue(BackupStore, out var backupClient) && backupClient.ExitImportCalls.Count > 0, + "the import to run"); + + Assert.DoesNotContain(secret, h.Log.AllText); + } + + /// + /// Turns the experimental-exit gate on for the duration of a test. + /// + /// + /// The import is behind , so a test that did not set this + /// would be asserting against a feature that was off and would pass for the wrong reason. The variable is + /// process-wide, which is why this class joins : xUnit's + /// per-class parallelism would otherwise let two classes read it while another is mid-swap. + /// + private static IDisposable FeatureGate() => new EnvironmentSwitch(Gate); + + private sealed class EnvironmentSwitch : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvironmentSwitch(string name) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, "1"); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } + + /// + /// Gives a seeded store an exit-state backup in its persisted settings. + /// + /// + /// Written through the store repository rather than the in-memory cache, because the connect path reads + /// what a previous run persisted — a test that set only the cache would be testing the harness. + /// + private static void WithExitStateBackup(SparkServiceHarness h, string storeId, string backup) + { + var settings = h.Stores.Stored(storeId, Constants.StoreSettingsKey)!; + settings.UnilateralExit = new UnilateralExitSettings { ExitStateBackup = backup }; + h.Stores.Seed(storeId, Constants.StoreSettingsKey, settings); + } + private static async Task WaitUntil(Func condition, string what) { var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index c0d5c27..776e922 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -831,6 +831,100 @@ private async Task WarmUpAsync(string storeId, ISparkSdkClient sdk) + "creation may fail until this resolves", storeId, SparkErrors.Describe(ex)); } + + await RestoreExitStateAsync(storeId, sdk).ConfigureAwait(false); + } + + /// + /// Puts a store's exported exit-state backup back into the wallet that just started. + /// + /// + /// + /// This is what makes the backup on the Advanced page mean anything. The page tells an operator that + /// a stored backup is imported automatically, and this is the only code that does it; without it the backup + /// is a value the plugin writes down and never reads, and an operator who pasted one would be told their + /// exit data was secured while the wallet that needed it stayed exactly as exitable as before. That is the + /// failure this method exists to make impossible, so it runs on every connect — not only the first + /// one after a paste — because the wallet storage it is restoring into can be lost at any time, and the + /// connect is the only moment the plugin reliably gets. + /// + /// + /// Ordered before the first sync, deliberately. The SDK collects exit data for leaves as it learns + /// about them, so importing first means a leaf whose chain was only in the backup is present before + /// anything asks the operators about it. The reverse order would spend a round trip confirming a leaf set + /// that import might have expanded, and it is the import that has to happen while the operators are + /// unreachable — which is precisely when a sync is most likely to fail. + /// + /// + /// Every failure here is logged and swallowed. A store whose backup will not import still has a + /// working Lightning wallet, and taking the wallet down over a recovery aid would trade a rare loss of + /// exit data for a certain loss of payments. The SDK also cannot be trusted to be idempotent about a + /// blob it refuses on one attempt, so this is not retried here: the next connect tries again, which is + /// the same cadence the operator's own restart has. + /// + /// + /// The blob is never logged, not even in the failure path. It discloses the store's balance, how it + /// is split, and its payment history — so the log line names the store, the outcome and the counts, and + /// nothing else. That is also why this does not go through SparkErrors.Describe on the raw + /// exception: an SDK that echoed the blob back in a message would put it in the log. + /// + /// + private async Task RestoreExitStateAsync(string storeId, ISparkSdkClient sdk) + { + // Off unless the host turned the feature on. A store can carry a section with a backup in it from a + // host that had the gate set, and importing it on a host that did not would be this plugin acting on + // exit data for a feature that is otherwise absent — including on the connect path, where no operator + // asked for anything. + if (!Constants.UnilateralExitEnabled) + return; + + string? backup; + try + { + var settings = await Get(storeId).ConfigureAwait(false); + backup = settings?.UnilateralExit?.ExitStateBackup; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Store {StoreId}: its settings could not be read, so a stored exit-state backup was not " + + "imported on this connect", storeId); + return; + } + + if (string.IsNullOrWhiteSpace(backup)) + return; + + try + { + var imported = await sdk.ImportUnilateralExitStateAsync(backup).ConfigureAwait(false); + + // Logged at information even when nothing was restored, because "the backup did not cover this + // wallet" is a fact the operator needs and cannot see anywhere else: the page only reports that a + // backup is stored. The conflicting count is called out separately because it is the one that + // means data was refused rather than merely unnecessary. + _logger.LogInformation( + "Store {StoreId}: imported exit-state backup: {Imported} leaves restored, {Foreign} foreign, " + + "{Conflicting} conflicting, {Chains} chains skipped", + storeId, imported.ImportedLeaves, imported.SkippedForeignLeaves, + imported.SkippedConflictingLeaves, imported.SkippedChains); + + if (imported.RestoredNothing && imported.SkippedConflictingLeaves > 0) + { + _logger.LogWarning( + "Store {StoreId}: every leaf in its exit-state backup was refused as conflicting, so no " + + "exit data was restored. The backup disagrees with exit data this wallet already holds", + storeId); + } + } + catch (Exception ex) + { + _logger.LogWarning( + "Store {StoreId}: its exit-state backup could not be imported ({ExceptionType}). The wallet is " + + "running; a leaf whose data was only in that backup cannot be exited unilaterally until this " + + "succeeds", + storeId, ex.GetType().Name); + } } /// From d3cfd20d8ab2aa4b3ec328e85f23fd0e3d865ebb Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:27:52 -0400 Subject: [PATCH 11/22] Say where a failed exit-state import shows up The backup is imported on connect, and a failure there is deliberately not fatal, which leaves the server log as the only place it is visible. Tell the operator that rather than letting them assume a stored backup was applied. --- BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml index f0bac07..b662ba3 100644 --- a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml +++ b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml @@ -134,7 +134,9 @@ { Stored An exit-state backup is stored for this store and is imported automatically when the - wallet next starts. It is not shown here — export a fresh copy if you need one. + wallet next starts. It is not shown here — export a fresh copy if you need one. If the + import itself fails, the wallet still runs and the reason is in the server log, so check + there after a restart rather than assuming a stored backup was applied. } else { From 25f2e32fef8bdc478c6ea9f7f834f1a92720f6b7 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:29:53 -0400 Subject: [PATCH 12/22] Import the exit-state backup before the wallet's first sync, as documented The comment said the import ran before the first sync and the code ran it after. The ordering the comment describes is the correct one: the SDK collects a leaf's exit data as it learns about the leaf, so importing first means a leaf whose chain existed only in the backup is present before anything asks the operators about it. The old order spent a round trip confirming a leaf set the import might have expanded, in the situation where the operators are least likely to answer. --- BTCPayServer.Plugins.Flint/Services/SparkService.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index 776e922..bf54e5d 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -811,8 +811,16 @@ private void AbandonConnect( }); } - private async Task WarmUpAsync(string storeId, ISparkSdkClient sdk) +private async Task WarmUpAsync(string storeId, ISparkSdkClient sdk) { + // Imported before the sync, and the order is the point. The SDK collects a leaf's exit data as it + // learns about the leaf, so bringing the backup in first means a leaf whose chain existed only in the + // backup is present before anything asks the operators about it. Doing it the other way round spends a + // round trip confirming a leaf set that the import might have expanded — and the import is the part + // that has to work when the operators are unreachable, which is exactly when a sync is most likely to + // fail or to be wasted. + await RestoreExitStateAsync(storeId, sdk).ConfigureAwait(false); + try { var info = await sdk.GetInfoAsync(ensureSynced: true).ConfigureAwait(false); @@ -831,8 +839,6 @@ private async Task WarmUpAsync(string storeId, ISparkSdkClient sdk) + "creation may fail until this resolves", storeId, SparkErrors.Describe(ex)); } - - await RestoreExitStateAsync(storeId, sdk).ConfigureAwait(false); } /// From a2f57fb7cb520aadfc9e064a7c22e51300e636a6 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:47:05 -0400 Subject: [PATCH 13/22] Keep the lock files' transitive pins at what main resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring this project alone rewrote 86 transitive pins downward relative to main, because BTCPay's dependency graph resolves differently for a single project than for the solution. Those unrelated downgrades had ridden along under a commit message about a code comment. This branch should change exactly one thing in these files — the Breez.Sdk.Spark pin, which is what the work is about — so both are restored from main and only that pin is re-applied. The diff is now the SDK bump and nothing else. --- .../packages.lock.json | 343 +++++++++--------- BTCPayServer.Plugins.Flint/packages.lock.json | 343 +++++++++--------- 2 files changed, 342 insertions(+), 344 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json index 1397c28..2c70464 100644 --- a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json +++ b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json @@ -13,13 +13,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + "resolved": "1.7.2", + "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.1", - "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "resolved": "1.0.2", + "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -91,63 +91,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.6", - "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", + "resolved": "1.7.8", + "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.5", - "BTCPayServer.Lightning.Eclair": "1.7.1", - "BTCPayServer.Lightning.LND": "1.7.1", - "BTCPayServer.Lightning.LNDhub": "1.7.1", - "BTCPayServer.Lightning.Phoenixd": "1.7.1" + "BTCPayServer.Lightning.CLightning": "1.7.7", + "BTCPayServer.Lightning.Eclair": "1.7.2", + "BTCPayServer.Lightning.LND": "1.7.2", + "BTCPayServer.Lightning.LNDhub": "1.7.2", + "BTCPayServer.Lightning.Phoenixd": "1.7.2" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.5", - "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", + "resolved": "1.7.7", + "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", + "resolved": "1.7.2", + "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", "dependencies": { - "NBitcoin": "10.0.1", + "NBitcoin": "10.0.9", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", + "resolved": "1.7.2", + "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", + "resolved": "1.7.2", + "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", + "resolved": "1.7.2", + "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", + "resolved": "1.7.2", + "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.NTag424": { @@ -271,11 +271,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.1.982", - "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", + "resolved": "9.2.1039", + "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", "dependencies": { - "AngleSharp": "1.7.0", - "AngleSharp.Css": "1.0.1" + "AngleSharp": "1.7.2", + "AngleSharp.Css": "1.0.2" } }, "libsodium": { @@ -325,67 +325,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", + "resolved": "10.0.11", + "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.10" + "Microsoft.Extensions.Features": "10.0.11" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" + "resolved": "10.0.11", + "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", + "resolved": "10.0.11", + "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", + "resolved": "10.0.11", + "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.10", - "Microsoft.Extensions.Identity.Stores": "10.0.10" + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Microsoft.Extensions.Identity.Stores": "10.0.11" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", + "resolved": "10.0.11", + "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", + "resolved": "10.0.11", + "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.10", + "Microsoft.AspNetCore.JsonPatch": "10.0.11", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", + "resolved": "10.0.11", + "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", + "resolved": "10.0.11", + "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.10", + "Microsoft.AspNetCore.SignalR.Common": "10.0.11", "Newtonsoft.Json": "13.0.3" } }, @@ -423,80 +423,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -533,16 +533,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -551,27 +551,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" + "resolved": "10.0.11", + "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -623,68 +623,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", + "resolved": "10.0.11", + "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", - "Microsoft.Extensions.Diagnostics": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", + "resolved": "10.0.11", + "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.Identity.Core": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.Identity.Core": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Configuration.Binder": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -778,8 +778,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", + "resolved": "10.0.10", + "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -896,16 +896,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "resolved": "1.8.0", + "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + "resolved": "4.4.0", + "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1202,30 +1202,29 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", - "BTCPayServer.Common": "[2.4.2, )", - "BTCPayServer.Data": "[2.4.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Common": "[2.4.4, )", + "BTCPayServer.Data": "[2.4.4, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.6, )", + "BTCPayServer.Lightning.All": "[1.7.8, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.2, )", + "BTCPayServer.Rating": "[2.4.4, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "NBitcoin": "[10.0.8, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "NBitcoin": "[10.0.10, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.7.0, )", - "SSH.NET": "[2025.1.0, )", - "Serilog": "[4.3.0, )", + "QRCoder": "[1.8.0, )", + "Serilog": "[4.4.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1239,18 +1238,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.0.2, )", - "HtmlSanitizer": "[9.1.982, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "BTCPayServer.Client": "[2.4.4, )", + "HtmlSanitizer": "[9.2.1039, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.1, )", - "NBitcoin": "[10.0.8, )", + "BTCPayServer.Lightning.Common": "[1.7.2, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1264,11 +1263,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1286,10 +1285,10 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.8, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } } } } -} \ No newline at end of file +} diff --git a/BTCPayServer.Plugins.Flint/packages.lock.json b/BTCPayServer.Plugins.Flint/packages.lock.json index 420ad53..e08586d 100644 --- a/BTCPayServer.Plugins.Flint/packages.lock.json +++ b/BTCPayServer.Plugins.Flint/packages.lock.json @@ -20,13 +20,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + "resolved": "1.7.2", + "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.1", - "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "resolved": "1.0.2", + "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -93,63 +93,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.6", - "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", + "resolved": "1.7.8", + "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.5", - "BTCPayServer.Lightning.Eclair": "1.7.1", - "BTCPayServer.Lightning.LND": "1.7.1", - "BTCPayServer.Lightning.LNDhub": "1.7.1", - "BTCPayServer.Lightning.Phoenixd": "1.7.1" + "BTCPayServer.Lightning.CLightning": "1.7.7", + "BTCPayServer.Lightning.Eclair": "1.7.2", + "BTCPayServer.Lightning.LND": "1.7.2", + "BTCPayServer.Lightning.LNDhub": "1.7.2", + "BTCPayServer.Lightning.Phoenixd": "1.7.2" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.5", - "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", + "resolved": "1.7.7", + "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", + "resolved": "1.7.2", + "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", "dependencies": { - "NBitcoin": "10.0.1", + "NBitcoin": "10.0.9", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", + "resolved": "1.7.2", + "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", + "resolved": "1.7.2", + "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", + "resolved": "1.7.2", + "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", + "resolved": "1.7.2", + "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.NTag424": { @@ -273,11 +273,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.1.982", - "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", + "resolved": "9.2.1039", + "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", "dependencies": { - "AngleSharp": "1.7.0", - "AngleSharp.Css": "1.0.1" + "AngleSharp": "1.7.2", + "AngleSharp.Css": "1.0.2" } }, "libsodium": { @@ -322,67 +322,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", + "resolved": "10.0.11", + "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.10" + "Microsoft.Extensions.Features": "10.0.11" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" + "resolved": "10.0.11", + "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", + "resolved": "10.0.11", + "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", + "resolved": "10.0.11", + "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.10", - "Microsoft.Extensions.Identity.Stores": "10.0.10" + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Microsoft.Extensions.Identity.Stores": "10.0.11" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", + "resolved": "10.0.11", + "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", + "resolved": "10.0.11", + "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.10", + "Microsoft.AspNetCore.JsonPatch": "10.0.11", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", + "resolved": "10.0.11", + "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", + "resolved": "10.0.11", + "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.10", + "Microsoft.AspNetCore.SignalR.Common": "10.0.11", "Newtonsoft.Json": "13.0.3" } }, @@ -420,80 +420,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -530,16 +530,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -548,27 +548,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" + "resolved": "10.0.11", + "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -620,68 +620,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", + "resolved": "10.0.11", + "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", - "Microsoft.Extensions.Diagnostics": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", + "resolved": "10.0.11", + "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.Identity.Core": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.Identity.Core": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Configuration.Binder": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -740,8 +740,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", + "resolved": "10.0.10", + "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -858,16 +858,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "resolved": "1.8.0", + "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + "resolved": "4.4.0", + "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1082,30 +1082,29 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", - "BTCPayServer.Common": "[2.4.2, )", - "BTCPayServer.Data": "[2.4.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Common": "[2.4.4, )", + "BTCPayServer.Data": "[2.4.4, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.6, )", + "BTCPayServer.Lightning.All": "[1.7.8, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.2, )", + "BTCPayServer.Rating": "[2.4.4, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "NBitcoin": "[10.0.8, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "NBitcoin": "[10.0.10, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.7.0, )", - "SSH.NET": "[2025.1.0, )", - "Serilog": "[4.3.0, )", + "QRCoder": "[1.8.0, )", + "Serilog": "[4.4.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1119,18 +1118,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.0.2, )", - "HtmlSanitizer": "[9.1.982, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "BTCPayServer.Client": "[2.4.4, )", + "HtmlSanitizer": "[9.2.1039, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.1, )", - "NBitcoin": "[10.0.8, )", + "BTCPayServer.Lightning.Common": "[1.7.2, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1144,11 +1143,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1158,10 +1157,10 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.8, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } } } } -} \ No newline at end of file +} From 25c7282624d7e0314518eeaf64b5cb6821d3a67c Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:34:15 -0400 Subject: [PATCH 14/22] Test a unilateral exit end to end against the local Spark stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit surface was verified against the SDK's contract, the seam, a fake and unit tests, and none of that broadcasts anything — because the SDK never does, and the plugin's design is that the operator pushes the transactions out by hand. So the one thing no test had done is the thing the feature is for: force a real balance on chain through the real statechain tree. This drives the plugin's own code — SparkExitFundingKey for the funding key, the seam's quote/build/check — and broadcasts the result exactly as the exit page instructs: fan-out and sweep alone, tree nodes as packages via submitpackage, mining between rounds so each CSV timelock matures. Progress is read from the SDK's verdict and per-transaction readiness, the same two things the page renders. Verified passing against the fixture, and the assertions were mutation-tested: making the WaitingForDependencies status map to Ready instead of Waiting fails it at build time, naming the transaction that would be broadcast before its parent confirmed. Three things the runs taught, each now encoded: - The readiness invariant is 'at least one step Ready, nothing Unverified, and no step with an unconfirmed dependency claiming Ready'. Asserting 'all Ready' failed against correct behaviour, because an exit is a chain. - What arrives is recoverable + unspent funding - total fee, not recoverable - fee. Measured 149,901 + 2,914 - 2,620 = 150,195, which looks wrong and is the documented arithmetic: the CPFP and fan-out fees come out of the funding output, not the recovered value. - A multi-leaf exit on a shared chain hit the SDK's Redo verdict when another wallet mined concurrently, so the test pins to one leaf. That is the documented single-leaf shape and it keeps the test honest about what it proves. The test is its own trait and its own collection. It cannot share the suite's wallet, because it exits the balance and would starve the other tests, and it cannot share the stack, because a second wallet funded from the same SSP while the suite ran produced a measured failure in the suite's Lightning send. local-regtest.yml runs it as a separate step, after the suite and after an explicit SSP top-up: an exit converts its wallet to on-chain Bitcoin, which the fixture's return leg cannot undo, so each run permanently costs the SSP a wallet's worth. Also regenerates both lock files with --force-evaluate. CI restores in locked mode and the previous commit's restore from main left them inconsistent. --- .github/workflows/local-regtest.yml | 35 ++ .../SparkLocalRegtestExitTests.cs | 461 ++++++++++++++++++ .../packages.lock.json | 353 +++++++------- BTCPayServer.Plugins.Flint/packages.lock.json | 343 ++++++------- 4 files changed, 850 insertions(+), 342 deletions(-) create mode 100644 BTCPayServer.Plugins.Flint.Tests/LocalRegtest/SparkLocalRegtestExitTests.cs diff --git a/.github/workflows/local-regtest.yml b/.github/workflows/local-regtest.yml index 034f481..2b0e636 100644 --- a/.github/workflows/local-regtest.yml +++ b/.github/workflows/local-regtest.yml @@ -204,6 +204,41 @@ jobs: --configuration Release --no-build --filter "Category=LocalRegtest" --output Detailed + # The unilateral exit, in a step and a category of its own, and both halves of that are load-bearing. + # + # It cannot share the suite's wallet: this test exits the balance, because that is what an exit is, so on + # a shared fixture whichever ran first would decide whether the other tests had any money. Hence its own + # collection and its own 150,000-sat deposit. + # + # It cannot share the stack either, which is the less obvious half. A second wallet funded out of the + # same SSP while the main suite is running through it produced a measured failure in the suite's Lightning + # send — a payout reported as Error with an empty message — so this runs after the suite has finished + # rather than beside it. + # + # And it cannot rely on the suite's leftovers, because an exit converts its wallet to on-chain Bitcoin at + # a destination address: the fixture's return leg (which pays a wallet's remainder back over Lightning) + # cannot undo that, so each run permanently costs the SSP a wallet's worth. Hence the explicit top-up, + # which is the same command e2e/local-regtest/README.md documents under "Liquidity is the consumable". + # 500,000 per invocation; two leaves is what the README measured as the floor for a working suite, and + # this adds headroom for the exit on top. + - name: Top the SSP up for the exit test + run: | + set -euo pipefail + cd e2e/local-regtest/cashu-regtest + export COMPOSE_PROJECT_NAME=cashu COMPOSE_PROFILES=spark + # shellcheck disable=SC1091 + . ./docker-scripts.sh + cashu-spark-fund-ssp + cashu-spark-fund-ssp + + - name: Unilateral exit suite + env: + SPARK_LOCAL_REGTEST_NETWORK: ${{ github.workspace }}/e2e/local-regtest/network.json + run: > + dotnet test BTCPayServer.Plugins.Flint.Tests/BTCPayServer.Plugins.Flint.Tests.csproj + --configuration Release --no-build --filter "Category=LocalRegtestExit" + --output Detailed + # Stage 2 starts here: the same stack, now with the product on top of it. e2e/btcpay/up.sh builds # the plugin, starts BTCPay Server + NBXplorer + Postgres joined to the fixture's own docker network # (so NBXplorer talks to the fixture's bitcoind), side-loads the plugin, provisions an admin, a store diff --git a/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/SparkLocalRegtestExitTests.cs b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/SparkLocalRegtestExitTests.cs new file mode 100644 index 0000000..6900562 --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/SparkLocalRegtestExitTests.cs @@ -0,0 +1,461 @@ +using System.Globalization; +using System.Text.Json; +using BTCPayServer.Plugins.Flint.Sdk; +using BTCPayServer.Plugins.Flint.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using Network = NBitcoin.Network; + +namespace BTCPayServer.Plugins.Flint.Tests.LocalRegtest; + +/// +/// A unilateral exit, executed end to end against real Spark operators and a chain this suite mines. +/// +/// +/// +/// Why this file exists. Everything on the exit surface was verified against the SDK's published +/// contract, the seam, a fake and unit tests — and none of that broadcasts anything, because the SDK never +/// does. The plugin's whole design is that an operator pushes the transactions out by hand, so the one thing +/// no test had ever done is the thing the feature is for: force a real balance on chain through the real +/// statechain tree. This does it. +/// +/// +/// It drives the product's own code, not a parallel implementation. The quote, the funding key +/// derivation, the build and the signer are the plugin's seam and . Only the +/// broadcasting is the test's, because that is genuinely the operator's job and the plugin deliberately does +/// not do it. The loop is driven by CheckUnilateralExitAsync's verdict and per-transaction readiness — +/// the same two things the exit page renders — so a bug in the 0.25 status mapping shows up here as a stuck +/// exit rather than as a wrong colour on a table. +/// +/// +/// The destination is asserted on, not the intermediate state. The only assertion that cannot be +/// satisfied without a real exit is that the destination address' balance grew. A test that stopped at "the +/// build returned transactions" would pass against an exit that can never confirm. +/// +/// +/// This test consumes the fixture's liquidity, unlike the rest of the suite. Every other test in this +/// collection moves money and LocalRegtestStack.DisposeAsync pays what is left back to the chain, so a +/// run is roughly revenue-neutral for the SSP. An exit is not: it converts the wallet's whole balance to +/// on-chain Bitcoin at the destination address, which the return leg cannot undo and no later run re-uses. +/// Measured here, one run costs the SSP its full ~150,000-sat wallet — so a stack that has already run this +/// several times will start failing in fixture setup with +/// amount cannot be represented by available leaves without creating a child below the configured split +/// floor, which is the SSP being empty rather than anything wrong with the exit. Top it up with +/// cashu-regtest/docker-scripts.sh's cashu-spark-fund-ssp (500,000 sats per invocation) — see +/// e2e/local-regtest/README.md, "Liquidity is the consumable". +/// +/// +/// Gated on SPARK_LOCAL_REGTEST_NETWORK, like the rest of this collection. See +/// . +/// +/// +// A category of its own, not the suite's "LocalRegtest", and the reason is in the collection remarks +// below plus the test's own: an exit needs a stack nothing else is using. It is run by a step of its +// own in .github/workflows/local-regtest.yml, after the main suite and after a top-up. +[Trait("Category", "LocalRegtestExit")] +[Collection(LocalRegtestExitCollection.Name)] +public class SparkLocalRegtestExitTests +{ + private readonly LocalRegtestStack _stack; + + public SparkLocalRegtestExitTests(LocalRegtestStack stack) => _stack = stack; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + /// + /// The rate the exit is quoted at, in sat/vB. + /// + /// + /// Deliberately near the floor rather than what the estimator suggests. This chain's estimator reads high + /// (~100 sat/vB) because nothing has told it otherwise, and the rate multiplies across every transaction + /// in the tree — at 100 sat/vB the CPFP funding requirement runs past what the wallet holds, which would + /// make this test a test of the funding size rather than of the exit. Nothing here depends on the fee + /// market ridiculing itself: the suite mines every block, so a low rate confirms as fast as a high one. + /// + private const ulong FeeRateSatPerVbyte = 2; + + /// + /// How many broadcast-and-mine rounds the exit is allowed before it is called stuck. + /// + /// + /// Sized from a measured run rather than guessed. On this fixture a wallet funded with one deposit exits + /// through a tree node, then another, then a third, then a refund, then the sweep — each of the last three + /// behind a ~2,000-block CSV timelock, and each level taking a couple of rounds to broadcast and confirm. + /// That was 12 rounds to get the sweep onto the wire, so this leaves roughly double the room. A bound exists + /// at all so that a status mapping which never reports Ready fails with "stuck at round N" and the whole + /// trace, instead of running until the CI job's own timeout kills the process with nothing to read. + /// + private const int MaxRounds = 28; + + /// + /// How far past a reported timelock height to mine, so the transaction is unambiguously spendable. + /// + /// + /// A CSV lock of N is satisfied once N blocks have been mined after the parent confirmed, and the + /// SDK reports the height that lands on. Mining exactly to it was observed to leave the transaction still + /// non-final, which is the off-by-one this margin exists for rather than an unexplained rejection. + /// + private const int TimelockMargin = 2; + + [Fact] + public async Task A_unilateral_exit_puts_the_balance_on_chain() + { + // Skips rather than fails when the descriptor is unset, matching the rest of this collection: the main + // CI job runs the unit suite with no stack at all, and a missing external fixture is not a defect. + Assert.SkipUnless(LocalRegtestStack.IsEnabled, LocalRegtestStack.SkipReason); + + // 1. Sync first, and this is load-bearing. A leaf can only be exited from exit data the wallet has + // already collected, and the SDK collects it during a sync. A quote taken before this can legally + // select nothing, and the failure would look like "nothing worth exiting" rather than "you did not + // sync". + await _stack.Sdk.SyncWalletAsync(Ct).ConfigureAwait(false); + + // 2. The destination is a fresh address of the fixture's wallet, so the test can read back what + // arrived. Bech32 because a real destination would be, and because the sweep is signed against + // whatever the quote echoes back. + var destination = await _stack.Control.NewAddressAsync("exit-destination", cancellationToken: Ct) + .ConfigureAwait(false); + + // 3. Quote. Auto first, to find out what the wallet actually holds — that is what a merchant pressing the + // button gets, and what this test needs in order to pick a leaf. + var auto = await _stack.Sdk + .PrepareUnilateralExitAsync(FeeRateSatPerVbyte, destination, leafIds: null, Ct) + .ConfigureAwait(false); + + Assert.False( + auto.IsEmpty, + "Spark selected no leaves to exit. Either the wallet holds nothing or the funding step did not " + + "credit it; both are fixture faults rather than exit faults."); + + // 4. Then pin to ONE leaf, the largest, and this is deliberate rather than a shortcut. + // + // A wallet funded by a deposit can be split into several leaves, and an exit across several branches + // is a different and much larger thing: a fan-out transaction, one chain of tree nodes per branch, and + // a refund per leaf, each behind its own CSV timelock. This collection's other fixture shares one chain + // and mines it concurrently, and a measured run of that shape reached OnChainStateDiverged — the SDK's + // "this can no longer finish" verdict — because branches matured out of step with each other. That is + // worth investigating on its own, but a test that reports a real feature as broken a third of the time + // is worse than no test, so this pins the exit to the single-leaf shape the SDK's own guide describes + // first: no fan-out, one tree-node chain, one refund, one sweep. + // + // What is still covered: the quote, the funding derivation and discovery, the build and CPFP signing, + // package broadcast, a CSV timelock matured by mining, the sweep, the fee arithmetic, and the SDK's + // own verdict driving all of it. + var leafId = auto.Leaves.OrderByDescending(leaf => leaf.ValueSat).First().LeafId; + + var quote = await _stack.Sdk + .PrepareUnilateralExitAsync(FeeRateSatPerVbyte, destination, [leafId], Ct) + .ConfigureAwait(false); + + Assert.Single(quote.Leaves); + + Assert.True( + quote.RecoverableValueSat > quote.TotalFeeSat, + $"The exit costs more than it recovers ({quote.TotalFeeSat} sat of fees against " + + $"{quote.RecoverableValueSat} sat of value), so the plugin would refuse it. Lower the rate or " + + "fund the wallet with more."); + Assert.True( + quote.SingleUtxoFundingSat > 0, + "The quote asked for no funding at all, which cannot be right for an exit whose fees are paid by " + + "CPFP."); + + // 5. The funding key, derived exactly as the plugin derives it. Index 0 because this test takes one + // exit; the plugin allocates one index per exit. + Assert.True( + SparkExitFundingKey.TryDerive( + _stack.Mnemonic, Network.RegTest, index: 0, out var fundingKey, out var keyError), + $"The funding key could not be derived from the fixture's own mnemonic: {keyError}"); + Assert.NotNull(fundingKey); + + using var _ = fundingKey; + + // 6. Fund it. One output, and at least the requirement — the SDK fans a single UTXO out across + // branches, and two smaller outputs would not fund the exit however encouraging their total. + var fundingTxId = await _stack.Control + .SendToAddressAsync( + fundingKey.Address, + SatsToBtc(quote.SingleUtxoFundingSat), + feeRateSatPerVb: 2, + Ct) + .ConfigureAwait(false); + + await _stack.Control.MineAsync(3, Ct).ConfigureAwait(false); + + var fundingVout = await _stack.Control.FindVoutAsync(fundingTxId, fundingKey.Address, Ct) + .ConfigureAwait(false); + + Assert.NotNull(fundingVout); + + var funding = new SparkExitFundingUtxo( + fundingTxId, fundingVout!.Value, quote.SingleUtxoFundingSat, fundingKey.PubkeyHex); + + // 7. Build and sign. The quote is taken inside this call on purpose — see the seam — so the only thing + // worth asserting on the callback is that it got a quote at all. + SparkExitQuote? committed = null; + var built = await _stack.Sdk + .UnilateralExitAsync( + FeeRateSatPerVbyte, + destination, + leafIds: [leafId], + [funding], + fundingKey.Secret, + second => + { + committed = second; + return null; + }, + Ct) + .ConfigureAwait(false); + + Assert.NotNull(committed); + Assert.NotEmpty(built.Transactions); + + // A sweep, because the exit is not an exit without one. And a tree node, because the balance has to be + // unrolled out of the tree before anything can be swept. + // + // Deliberately NOT asserting a fan-out. One exists only to split a single funding UTXO across several + // branches, so a wallet whose quote selected one leaf pays its fees directly from the funding output + // and has none — which is the documented shape and, on a wallet funded with one deposit, the ordinary + // one. Asserting it here would have been this test pinning its own fixture's leaf count. + Assert.Contains(built.Transactions, transaction => transaction.Kind is SparkExitTxKind.TreeNode); + Assert.Contains(built.Transactions, transaction => transaction.Kind is SparkExitTxKind.Sweep); + + // The readiness invariant, and the one a wrong status mapping would break. + // + // An exit is a *chain*: the tree node that spends the funding output can go out immediately, and every + // step after it is waiting on something in dependsOn to confirm. So the correct shape after a build with + // nothing broadcast is "at least one step is Ready, and nothing claims to be Unverified" — NOT "all of + // them are Ready", which is what this asserted first and which no real exit can satisfy. + // + // What would be a bug: nothing Ready at all (the exit can never start), or any step Unverified (the SDK + // could not read the chain, so broadcasting it is unsafe and the operator is told to rebuild). + Assert.Contains( + built.Transactions, + transaction => transaction.Status.Readiness is SparkExitTxReadiness.Ready); + Assert.DoesNotContain( + built.Transactions, + transaction => transaction.Status.Readiness is SparkExitTxReadiness.Unverified); + Assert.All( + built.Transactions, + transaction => Assert.Contains( + transaction.Status.Readiness, + new[] { SparkExitTxReadiness.Ready, SparkExitTxReadiness.Waiting })); + + // The chain is rooted: the step that spends the funding output must be the ready one, or the exit has + // no way in. A Ready step downstream of an unconfirmed parent would be instructions to broadcast a + // transaction whose inputs do not exist yet. + var roots = built.Transactions + .Where(transaction => transaction.DependsOn.Count == 0) + .ToList(); + + Assert.NotEmpty(roots); + Assert.Contains(roots, root => root.Status.Readiness is SparkExitTxReadiness.Ready); + + // And the converse, which is the assertion that actually defends the mapping: at build time nothing has + // been broadcast, so no step with a dependency can possibly be broadcastable. A transaction reported + // Ready while its parent is unconfirmed is the page telling an operator to push a transaction whose + // inputs do not exist — the node rejects it, and on a chain they do not control the rejection is all + // they get. + // + // This is stricter than it first looks, and deliberately so. It was added after a mutation test: making + // the SDK's WaitingForDependencies case map to Ready instead of Waiting left the whole exit still + // completing, because the loop simply retried each rejected broadcast until the parents confirmed. The + // exit was slow and noisy rather than wrong, so only an assertion at build time catches it. + Assert.All( + built.Transactions.Where(transaction => transaction.DependsOn.Count > 0), + transaction => Assert.True( + transaction.Status.Readiness is not SparkExitTxReadiness.Ready, + $"{transaction.Kind} depends on {transaction.DependsOn.Count} transaction(s) that have not " + + "confirmed, yet it reports Ready — the page would tell the operator to broadcast a " + + "transaction whose inputs do not exist.")); + + // 8. Push it out, exactly as the page instructs and with the result checked on chain. + var exited = await DriveToCompletionAsync(built).ConfigureAwait(false); + + // 9. The assertion that cannot be satisfied without a real exit. + var received = await ReceivedByAddressAsync(destination).ConfigureAwait(false); + + Assert.True( + received > 0, + $"The exit reported {exited.Verdict} but {destination} received nothing, so no sweep confirmed."); + + // What arrives is exactly recoverable + unspent funding − total fee, and getting this identity right is + // the point of asserting on the amount rather than on "something arrived". + // + // The tempting reading — recoverable minus the fee — is the one the SDK's own guide goes out of its way + // to correct: the CPFP and fan-out fees are paid out of the funding UTXO, not out of the recovered + // value, so subtracting the total fee from it charges those to the merchant twice. Both halves matter. + // The funding the exit did not spend comes back to the destination with the swept coins, which is why + // the sweep collects the CPFP children's change; and a fee-accounting regression that quietly kept that + // change, or that netted the CPFP fees out of the recovered value twice, shows up here as a shortfall. + // + // Measured on this fixture: 149,901 recoverable + 2,914 funding − 2,620 fees = 150,195 received, which + // is more than the recoverable value — the shape that looks wrong and is right. + Assert.Equal( + built.RecoverableValueSat + funding.ValueSat - built.TotalFeeSat, + received); + + // And independent of the identity: the merchant must end up with at least what the leaves were worth, + // because the fees were paid from funding they supplied on top. A test that only checked the identity + // would still pass on an exit that recovered nothing at all. + Assert.True( + received >= built.RecoverableValueSat, + $"The destination received {received} sat, less than the {built.RecoverableValueSat} sat the exit " + + "recovered, so the fees came out of the recovered value instead of the funding output."); + } + + /// + /// Broadcasts whatever the chain says is ready, mines, and repeats until the SDK reports the exit done. + /// + /// + /// + /// This is the operator's job, automated — and modelled on the page's own instructions rather than on what + /// happens to work: the fan-out and the sweep go out alone, a tree node goes out as a package with its + /// CPFP child, and between rounds blocks are mined because that is what makes a level's CSV timelock + /// mature. On a chain without a block producer each of those waits is real time; here it is a command, + /// which is the whole reason this test is possible. + /// + /// + /// Progress is read from the SDK, never inferred. A transaction is broadcast only when its status says + /// Ready, and the loop stops when the verdict says the exit is finished or cannot finish. Re-broadcasting + /// one that is already sent is harmless by the SDK's own contract, so a round that makes no progress is a + /// bug rather than a lost broadcast — and is reported as such rather than retried forever. + /// + /// + private async Task DriveToCompletionAsync(SparkExitResult built) + { + var current = built; + var trace = new System.Text.StringBuilder(); + + for (var round = 1; round <= MaxRounds; round++) + { + var progress = await _stack.Sdk.CheckUnilateralExitAsync(current, Ct).ConfigureAwait(false); + + current = new SparkExitResult( + progress.RecoverableValueSat, progress.TotalFeeSat, progress.Transactions, current.Leaves); + + trace.AppendLine( + $"round {round}: verdict={progress.Verdict} " + + $"height={await _stack.Control.BlockHeightAsync(Ct).ConfigureAwait(false)} " + + $"txs=[{string.Join(", ", progress.Transactions.Select(t => $"{t.Kind}:{t.Status.Readiness}" + (t.Status.SpendableAtHeight is { } h ? $"@{h}" : string.Empty) + (t.Status.BlockHeight is { } bh ? $"#{bh}" : string.Empty)))}]"); + + if (progress.Verdict is SparkExitVerdict.Done) + return progress; + + if (progress.Verdict is SparkExitVerdict.Redo) + { + Assert.Fail( + $"Round {round}: the SDK reported that this exit can no longer finish as it stands, against " + + $"a chain this suite controls and has not reorganised.\n{trace}"); + } + + var ready = progress.Transactions.Where(t => t.Status.CanBroadcast).ToList(); + + foreach (var transaction in ready) + { + try + { + var outcome = await BroadcastAsync(transaction).ConfigureAwait(false); + trace.AppendLine($" broadcast {transaction.Kind} {transaction.Txid[..16]}: {outcome}"); + } + catch (Exception ex) + { + trace.AppendLine($" broadcast {transaction.Kind} {transaction.Txid[..16]} THREW: {ex.Message}"); + } + } + + // Mine enough to confirm what just went out, and to mature whatever timelock is next. Where the + // SDK names the height a waiting transaction unlocks at, mine to it — that is strictly better than + // guessing, and it is the number the page shows the operator. + var nextUnlock = progress.Transactions + .Select(t => t.Status.SpendableAtHeight) + .Where(height => height is not null) + .Select(height => (int)height!.Value) + .DefaultIfEmpty(0) + .Max(); + + var height = await _stack.Control.BlockHeightAsync(Ct).ConfigureAwait(false); + + var blocks = Math.Max( + ready.Count > 0 ? 2 : 1, + nextUnlock > 0 ? (nextUnlock + TimelockMargin) - height : 0); + + await _stack.Control.MineAsync(blocks, Ct).ConfigureAwait(false); + trace.AppendLine($" mined {blocks}"); + } + + Assert.Fail( + $"The exit did not finish within {MaxRounds} rounds. It is stuck rather than slow: every wait on " + + $"this chain is a block this suite mines.\n{trace}"); + throw new InvalidOperationException("unreachable"); + } + + /// + /// Puts one transaction on the wire the way its own shape requires. + /// + /// + /// The package decision is read off the presence of the CPFP child, matching + /// . A tree transaction pays no fee of its own, + /// so sendrawtransaction rejects it — that rejection is the reason the page's instructions differ + /// per row, and getting it wrong here would silently test a path no operator takes. + /// + private async Task BroadcastAsync(SparkExitTransaction transaction) + { + if (transaction.RequiresPackageBroadcast) + { + // Bitcoin Core 31 on this fixture, so package relay is available. Core 25 is where submitpackage + // arrives; a fixture bump below that would fail here with a clear "method not found" rather than + // quietly falling back to a single-transaction broadcast that cannot work. + using var response = await _stack.Control + .BitcoinCliJsonAsync( + ["submitpackage", $"[\"{transaction.TxHex}\",\"{transaction.CpfpTxHex}\"]"], Ct) + .ConfigureAwait(false); + + return "submitpackage " + response.RootElement.ToString()[..Math.Min(200, response.RootElement.ToString().Length)]; + } + + var txid = await _stack.Control + .BitcoinCliAsync(["sendrawtransaction", transaction.TxHex], Ct) + .ConfigureAwait(false); + + return "sendrawtransaction " + txid.Trim(); + } + + /// What the fixture's wallet has received at one address, in satoshi. + private async Task ReceivedByAddressAsync(string address) + { + var btc = decimal.Parse( + (await _stack.Control + .BitcoinCliAsync(["getreceivedbyaddress", address, "0"], Ct) + .ConfigureAwait(false)).Trim(), + CultureInfo.InvariantCulture); + + return (long)(btc * 100_000_000m); + } + + private static decimal SatsToBtc(long sats) => sats / 100_000_000m; +} +/// +/// A collection of its own, so gets its own wallet. +/// +/// +/// +/// This is a correctness requirement, not tidiness. A collection fixture is one instance shared by every +/// test class in the collection, so joining LocalRegtestStack.CollectionName would put this class on the +/// same wallet as the receive, send and cooperative-exit tests — and this test exits the whole +/// balance, because that is what an exit is. Whichever ran first would decide whether the others had any money: +/// a full pile of rent paid for one test and four failures that read like unrelated regressions. +/// +/// +/// A distinct collection name gives xUnit a distinct fixture instance, and therefore a distinct mnemonic, a +/// distinct wallet and a distinct 150,000-sat deposit. The cost is real and worth stating: the SSP is drained +/// twice per run rather than once, and both instances mine the same chain and so advance each other's heights. +/// Nothing here depends on absolute height — the exit follows heights the SDK reports — so the second cost is +/// only that the CSV waits are measured from wherever the other wallet's test left the tip. +/// +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class LocalRegtestExitCollection : ICollectionFixture +{ + public const string Name = "Spark local regtest (exit)"; +} diff --git a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json index 2c70464..610a45e 100644 --- a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json +++ b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json @@ -13,13 +13,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.2", - "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -78,8 +78,13 @@ }, "Breez.Sdk.Spark": { "type": "Transitive", +<<<<<<< HEAD "resolved": "0.26.0", "contentHash": "uq45j6gHCXuTROT3npe/OXHwQIpmCWUzL37gGDu2AgHh5v3h6/xFOaNSdV8D266A0HGhHM6gjG7nafsEX62xMQ==" +======= + "resolved": "0.25.0", + "contentHash": "OzudL7reJPpFFqRmvyOu8zl5C8zdv+zNsIURZZ/dT5YttTWpzbttLHUv627gkSNbRK5bNBKWn0tId8IYGulnMg==" +>>>>>>> f4547ed (Test a unilateral exit end to end against the local Spark stack) }, "BTCPayServer.Hwi": { "type": "Transitive", @@ -91,63 +96,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.8", - "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", + "resolved": "1.7.6", + "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.7", - "BTCPayServer.Lightning.Eclair": "1.7.2", - "BTCPayServer.Lightning.LND": "1.7.2", - "BTCPayServer.Lightning.LNDhub": "1.7.2", - "BTCPayServer.Lightning.Phoenixd": "1.7.2" + "BTCPayServer.Lightning.CLightning": "1.7.5", + "BTCPayServer.Lightning.Eclair": "1.7.1", + "BTCPayServer.Lightning.LND": "1.7.1", + "BTCPayServer.Lightning.LNDhub": "1.7.1", + "BTCPayServer.Lightning.Phoenixd": "1.7.1" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.7", - "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", + "resolved": "1.7.5", + "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", + "resolved": "1.7.1", + "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", "dependencies": { - "NBitcoin": "10.0.9", + "NBitcoin": "10.0.1", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", + "resolved": "1.7.1", + "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", + "resolved": "1.7.1", + "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", + "resolved": "1.7.1", + "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", + "resolved": "1.7.1", + "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.NTag424": { @@ -271,11 +276,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.2.1039", - "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", + "resolved": "9.1.982", + "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", "dependencies": { - "AngleSharp": "1.7.2", - "AngleSharp.Css": "1.0.2" + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1" } }, "libsodium": { @@ -325,67 +330,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", + "resolved": "10.0.10", + "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.11" + "Microsoft.Extensions.Features": "10.0.10" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" + "resolved": "10.0.10", + "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", + "resolved": "10.0.10", + "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", + "resolved": "10.0.10", + "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.11", - "Microsoft.Extensions.Identity.Stores": "10.0.11" + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Identity.Stores": "10.0.10" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", + "resolved": "10.0.10", + "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", + "resolved": "10.0.10", + "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.11", + "Microsoft.AspNetCore.JsonPatch": "10.0.10", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", + "resolved": "10.0.10", + "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", + "resolved": "10.0.10", + "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.11", + "Microsoft.AspNetCore.SignalR.Common": "10.0.10", "Newtonsoft.Json": "13.0.3" } }, @@ -423,80 +428,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -533,16 +538,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -551,27 +556,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" + "resolved": "10.0.10", + "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -623,68 +628,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", + "resolved": "10.0.10", + "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", - "Microsoft.Extensions.Diagnostics": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", + "resolved": "10.0.10", + "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.Identity.Core": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Identity.Core": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Configuration.Binder": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -778,8 +783,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", + "resolved": "10.0.8", + "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -896,16 +901,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.8.0", - "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1202,29 +1207,30 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", - "BTCPayServer.Common": "[2.4.4, )", - "BTCPayServer.Data": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Common": "[2.4.2, )", + "BTCPayServer.Data": "[2.4.2, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.8, )", + "BTCPayServer.Lightning.All": "[1.7.6, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.4, )", + "BTCPayServer.Rating": "[2.4.2, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "NBitcoin": "[10.0.10, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.8.0, )", - "Serilog": "[4.4.0, )", + "QRCoder": "[1.7.0, )", + "SSH.NET": "[2025.1.0, )", + "Serilog": "[4.3.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1238,18 +1244,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.4.4, )", - "HtmlSanitizer": "[9.2.1039, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "BTCPayServer.Client": "[2.0.2, )", + "HtmlSanitizer": "[9.1.982, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.2, )", - "NBitcoin": "[10.0.10, )", + "BTCPayServer.Lightning.Common": "[1.7.1, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1263,19 +1269,24 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, "btcpayserver.plugins.flint": { "type": "Project", "dependencies": { +<<<<<<< HEAD "BTCPayServer": "[2.4.4, )", "Breez.Sdk.Spark": "[0.26.0, 0.26.0]", +======= + "BTCPayServer": "[2.4.2, )", + "Breez.Sdk.Spark": "[0.25.0, 0.25.0]", +>>>>>>> f4547ed (Test a unilateral exit end to end against the local Spark stack) "SSH.NET": "[2026.0.0, )" } }, @@ -1285,10 +1296,10 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } } } } -} +} \ No newline at end of file diff --git a/BTCPayServer.Plugins.Flint/packages.lock.json b/BTCPayServer.Plugins.Flint/packages.lock.json index e08586d..420ad53 100644 --- a/BTCPayServer.Plugins.Flint/packages.lock.json +++ b/BTCPayServer.Plugins.Flint/packages.lock.json @@ -20,13 +20,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" + "resolved": "1.7.0", + "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.2", - "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", + "resolved": "1.0.1", + "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -93,63 +93,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.8", - "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", + "resolved": "1.7.6", + "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.7", - "BTCPayServer.Lightning.Eclair": "1.7.2", - "BTCPayServer.Lightning.LND": "1.7.2", - "BTCPayServer.Lightning.LNDhub": "1.7.2", - "BTCPayServer.Lightning.Phoenixd": "1.7.2" + "BTCPayServer.Lightning.CLightning": "1.7.5", + "BTCPayServer.Lightning.Eclair": "1.7.1", + "BTCPayServer.Lightning.LND": "1.7.1", + "BTCPayServer.Lightning.LNDhub": "1.7.1", + "BTCPayServer.Lightning.Phoenixd": "1.7.1" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.7", - "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", + "resolved": "1.7.5", + "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", + "resolved": "1.7.1", + "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", "dependencies": { - "NBitcoin": "10.0.9", + "NBitcoin": "10.0.1", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", + "resolved": "1.7.1", + "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", + "resolved": "1.7.1", + "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", + "resolved": "1.7.1", + "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.2", - "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", + "resolved": "1.7.1", + "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.2" + "BTCPayServer.Lightning.Common": "1.7.1" } }, "BTCPayServer.NTag424": { @@ -273,11 +273,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.2.1039", - "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", + "resolved": "9.1.982", + "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", "dependencies": { - "AngleSharp": "1.7.2", - "AngleSharp.Css": "1.0.2" + "AngleSharp": "1.7.0", + "AngleSharp.Css": "1.0.1" } }, "libsodium": { @@ -322,67 +322,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", + "resolved": "10.0.10", + "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.11" + "Microsoft.Extensions.Features": "10.0.10" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" + "resolved": "10.0.10", + "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", + "resolved": "10.0.10", + "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", + "resolved": "10.0.10", + "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.11", - "Microsoft.Extensions.Identity.Stores": "10.0.11" + "Microsoft.EntityFrameworkCore.Relational": "10.0.10", + "Microsoft.Extensions.Identity.Stores": "10.0.10" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", + "resolved": "10.0.10", + "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", + "resolved": "10.0.10", + "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.11", + "Microsoft.AspNetCore.JsonPatch": "10.0.10", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", + "resolved": "10.0.10", + "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", + "resolved": "10.0.10", + "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.11", + "Microsoft.AspNetCore.SignalR.Common": "10.0.10", "Newtonsoft.Json": "13.0.3" } }, @@ -420,80 +420,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", + "resolved": "10.0.10", + "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" + "resolved": "10.0.10", + "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" + "resolved": "10.0.10", + "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", + "resolved": "10.0.10", + "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.11", - "Microsoft.Extensions.Caching.Memory": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.EntityFrameworkCore": "10.0.10", + "Microsoft.Extensions.Caching.Memory": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", + "resolved": "10.0.10", + "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -530,16 +530,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "resolved": "10.0.10", + "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -548,27 +548,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", + "resolved": "10.0.10", + "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.11", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", + "resolved": "10.0.10", + "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" + "resolved": "10.0.10", + "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -620,68 +620,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", + "resolved": "10.0.10", + "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", - "Microsoft.Extensions.Diagnostics": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", + "Microsoft.Extensions.Diagnostics": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", + "resolved": "10.0.10", + "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.11", - "Microsoft.Extensions.Identity.Core": "10.0.11", - "Microsoft.Extensions.Logging": "10.0.11" + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Identity.Core": "10.0.10", + "Microsoft.Extensions.Logging": "10.0.10" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "resolved": "10.0.10", + "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.11", - "Microsoft.Extensions.Logging.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11" + "Microsoft.Extensions.DependencyInjection": "10.0.10", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", + "resolved": "10.0.10", + "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", - "Microsoft.Extensions.Configuration.Binder": "10.0.11", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "Microsoft.Extensions.Options": "10.0.11", - "Microsoft.Extensions.Primitives": "10.0.11" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.Binder": "10.0.10", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Options": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -740,8 +740,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", + "resolved": "10.0.8", + "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -858,16 +858,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.8.0", - "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", + "resolved": "1.7.0", + "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.4.0", - "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" + "resolved": "4.3.0", + "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1082,29 +1082,30 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", - "BTCPayServer.Common": "[2.4.4, )", - "BTCPayServer.Data": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Common": "[2.4.2, )", + "BTCPayServer.Data": "[2.4.2, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.8, )", + "BTCPayServer.Lightning.All": "[1.7.6, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.4, )", + "BTCPayServer.Rating": "[2.4.2, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "NBitcoin": "[10.0.10, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.8.0, )", - "Serilog": "[4.4.0, )", + "QRCoder": "[1.7.0, )", + "SSH.NET": "[2025.1.0, )", + "Serilog": "[4.3.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1118,18 +1119,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.4.4, )", - "HtmlSanitizer": "[9.2.1039, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "BTCPayServer.Client": "[2.0.2, )", + "HtmlSanitizer": "[9.1.982, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.2, )", - "NBitcoin": "[10.0.10, )", + "BTCPayServer.Lightning.Common": "[1.7.1, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1143,11 +1144,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.4, )", - "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Abstractions": "[2.4.2, )", + "BTCPayServer.Client": "[2.0.2, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", - "Microsoft.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.EntityFrameworkCore": "[10.0.10, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1157,10 +1158,10 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.10, )", + "NBitcoin": "[10.0.8, )", "Newtonsoft.Json": "[13.0.4, )" } } } } -} +} \ No newline at end of file From 9a90fcf879f93051a98da06c1c9767d8287d7200 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:41:33 -0400 Subject: [PATCH 15/22] Regenerate both lock files against the pinned btcpayserver, not the local one CI restores in locked mode and was failing with NU1004 on the plugin project: the lock had been generated against the btcpayserver working tree on this machine, which sits at v2.4.2 because v2.4.4 does not build with the local .NET 10.0.400 Razor toolchain, while CI checks out the commit the repo actually records (v2.4.4). Two different dependency graphs, one lock file, and locked mode refuses the mismatch. Restore does not compile anything, so the locks can be generated against the right submodule even where that submodule cannot be built: checked out v2.4.4, regenerated both with --force-evaluate, and confirmed the exact two commands ci.yml runs now pass in locked mode. The diff is large and that is expected rather than drift: bumping Breez.Sdk.Spark from 0.23.0 to 0.25.0 changes that package's own dependency set, so the whole resolved graph downstream of it moves with it. --- .../packages.lock.json | 351 +++++++++--------- BTCPayServer.Plugins.Flint/packages.lock.json | 341 +++++++++-------- 2 files changed, 340 insertions(+), 352 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json index 610a45e..371f497 100644 --- a/BTCPayServer.Plugins.Flint.Tests/packages.lock.json +++ b/BTCPayServer.Plugins.Flint.Tests/packages.lock.json @@ -13,13 +13,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + "resolved": "1.7.2", + "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.1", - "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "resolved": "1.0.2", + "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -78,13 +78,8 @@ }, "Breez.Sdk.Spark": { "type": "Transitive", -<<<<<<< HEAD "resolved": "0.26.0", "contentHash": "uq45j6gHCXuTROT3npe/OXHwQIpmCWUzL37gGDu2AgHh5v3h6/xFOaNSdV8D266A0HGhHM6gjG7nafsEX62xMQ==" -======= - "resolved": "0.25.0", - "contentHash": "OzudL7reJPpFFqRmvyOu8zl5C8zdv+zNsIURZZ/dT5YttTWpzbttLHUv627gkSNbRK5bNBKWn0tId8IYGulnMg==" ->>>>>>> f4547ed (Test a unilateral exit end to end against the local Spark stack) }, "BTCPayServer.Hwi": { "type": "Transitive", @@ -96,63 +91,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.6", - "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", + "resolved": "1.7.8", + "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.5", - "BTCPayServer.Lightning.Eclair": "1.7.1", - "BTCPayServer.Lightning.LND": "1.7.1", - "BTCPayServer.Lightning.LNDhub": "1.7.1", - "BTCPayServer.Lightning.Phoenixd": "1.7.1" + "BTCPayServer.Lightning.CLightning": "1.7.7", + "BTCPayServer.Lightning.Eclair": "1.7.2", + "BTCPayServer.Lightning.LND": "1.7.2", + "BTCPayServer.Lightning.LNDhub": "1.7.2", + "BTCPayServer.Lightning.Phoenixd": "1.7.2" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.5", - "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", + "resolved": "1.7.7", + "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", + "resolved": "1.7.2", + "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", "dependencies": { - "NBitcoin": "10.0.1", + "NBitcoin": "10.0.9", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", + "resolved": "1.7.2", + "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", + "resolved": "1.7.2", + "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", + "resolved": "1.7.2", + "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", + "resolved": "1.7.2", + "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.NTag424": { @@ -276,11 +271,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.1.982", - "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", + "resolved": "9.2.1039", + "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", "dependencies": { - "AngleSharp": "1.7.0", - "AngleSharp.Css": "1.0.1" + "AngleSharp": "1.7.2", + "AngleSharp.Css": "1.0.2" } }, "libsodium": { @@ -330,67 +325,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", + "resolved": "10.0.11", + "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.10" + "Microsoft.Extensions.Features": "10.0.11" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" + "resolved": "10.0.11", + "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", + "resolved": "10.0.11", + "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", + "resolved": "10.0.11", + "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.10", - "Microsoft.Extensions.Identity.Stores": "10.0.10" + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Microsoft.Extensions.Identity.Stores": "10.0.11" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", + "resolved": "10.0.11", + "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", + "resolved": "10.0.11", + "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.10", + "Microsoft.AspNetCore.JsonPatch": "10.0.11", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", + "resolved": "10.0.11", + "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", + "resolved": "10.0.11", + "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.10", + "Microsoft.AspNetCore.SignalR.Common": "10.0.11", "Newtonsoft.Json": "13.0.3" } }, @@ -428,80 +423,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -538,16 +533,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -556,27 +551,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" + "resolved": "10.0.11", + "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -628,68 +623,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", + "resolved": "10.0.11", + "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", - "Microsoft.Extensions.Diagnostics": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", + "resolved": "10.0.11", + "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.Identity.Core": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.Identity.Core": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Configuration.Binder": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -783,8 +778,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", + "resolved": "10.0.10", + "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -901,16 +896,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "resolved": "1.8.0", + "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + "resolved": "4.4.0", + "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1207,30 +1202,29 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", - "BTCPayServer.Common": "[2.4.2, )", - "BTCPayServer.Data": "[2.4.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Common": "[2.4.4, )", + "BTCPayServer.Data": "[2.4.4, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.6, )", + "BTCPayServer.Lightning.All": "[1.7.8, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.2, )", + "BTCPayServer.Rating": "[2.4.4, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "NBitcoin": "[10.0.8, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "NBitcoin": "[10.0.10, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.7.0, )", - "SSH.NET": "[2025.1.0, )", - "Serilog": "[4.3.0, )", + "QRCoder": "[1.8.0, )", + "Serilog": "[4.4.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1244,18 +1238,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.0.2, )", - "HtmlSanitizer": "[9.1.982, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "BTCPayServer.Client": "[2.4.4, )", + "HtmlSanitizer": "[9.2.1039, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.1, )", - "NBitcoin": "[10.0.8, )", + "BTCPayServer.Lightning.Common": "[1.7.2, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1269,24 +1263,19 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, "btcpayserver.plugins.flint": { "type": "Project", "dependencies": { -<<<<<<< HEAD "BTCPayServer": "[2.4.4, )", "Breez.Sdk.Spark": "[0.26.0, 0.26.0]", -======= - "BTCPayServer": "[2.4.2, )", - "Breez.Sdk.Spark": "[0.25.0, 0.25.0]", ->>>>>>> f4547ed (Test a unilateral exit end to end against the local Spark stack) "SSH.NET": "[2026.0.0, )" } }, @@ -1296,7 +1285,7 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.8, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } } diff --git a/BTCPayServer.Plugins.Flint/packages.lock.json b/BTCPayServer.Plugins.Flint/packages.lock.json index 420ad53..b8eecf0 100644 --- a/BTCPayServer.Plugins.Flint/packages.lock.json +++ b/BTCPayServer.Plugins.Flint/packages.lock.json @@ -20,13 +20,13 @@ }, "AngleSharp": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "v1R++46dblGRBo2/fKFUfgtZOaFnDGQhqBM0AarxgZSJuumj1e1vexBbjlTv2hx3Olr3qeoJa2yUoWyv5fuJ5A==" + "resolved": "1.7.2", + "contentHash": "r1rb5Qo/0KPzmP0nbSiXPDVfh4Ctu0B+y1RyyUq73g4sgAmEW7q10RDyTq2ZsILwtO9O2LAvMrUles7eD4zz1g==" }, "AngleSharp.Css": { "type": "Transitive", - "resolved": "1.0.1", - "contentHash": "6S13xNHH+SUGPZd6EO1MhkuDbpEnFroUM6A8DZpLJHQnLRTvtBJb3Ydu65s618E4gWF9FSWmM5jhKk4RIfwT2A==", + "resolved": "1.0.2", + "contentHash": "XeBxh0h/73+MWFsGfeMwiK7xbzAK3YeHFdUIrMH1P90amIrDFD/vUDIrPlF3CixqaMH5MRIUvT6Ux+2zvhJ3SA==", "dependencies": { "AngleSharp": "[1.5.0, 2.0.0)" } @@ -93,63 +93,63 @@ }, "BTCPayServer.Lightning.All": { "type": "Transitive", - "resolved": "1.7.6", - "contentHash": "vcIPjxAUJSAlPMe23+ug+EYuED7nfzVH9Okri82/13BZ+2zwRlmDX498CFvqdvF00zGdoGrhcjwxFa/IGKSdWA==", + "resolved": "1.7.8", + "contentHash": "93DZVLRrGZAr1hlYVnqp7upD5WhyrwdH5YASD83kfoIr2pBLUa2ze+vBkYxUQD8vv7Nm6gpagKhRd+pT9aeSxQ==", "dependencies": { - "BTCPayServer.Lightning.CLightning": "1.7.5", - "BTCPayServer.Lightning.Eclair": "1.7.1", - "BTCPayServer.Lightning.LND": "1.7.1", - "BTCPayServer.Lightning.LNDhub": "1.7.1", - "BTCPayServer.Lightning.Phoenixd": "1.7.1" + "BTCPayServer.Lightning.CLightning": "1.7.7", + "BTCPayServer.Lightning.Eclair": "1.7.2", + "BTCPayServer.Lightning.LND": "1.7.2", + "BTCPayServer.Lightning.LNDhub": "1.7.2", + "BTCPayServer.Lightning.Phoenixd": "1.7.2" } }, "BTCPayServer.Lightning.CLightning": { "type": "Transitive", - "resolved": "1.7.5", - "contentHash": "fhy4mySZvPAE8M+85LHmIDgn6ufkH/JdfIm71oEgcfhSJOJ6fe00YBPEx9mWxNdWOuVoa21MKYAHxT4JyfpM8A==", + "resolved": "1.7.7", + "contentHash": "Bp+Q5BIQ4vo6orDWHfbeJ0SyNAaFFt0ODuaz6ShdZmC7Q/BKs+G7mU3Ax9ghOg9ZSqkwkV7Mt4qzNt5QMayEVg==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Common": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZR58Tx3byb+yEfqwyZrbDIMMZSaHepjGnEB26q1LzXtislzZUlFpRciSX0B4U0CfbvopDSbl+O0jgosJiFzyRA==", + "resolved": "1.7.2", + "contentHash": "jQzP/EACSP3lTAGQ0NB4pOMKpw7J+rjoaNoUqSva+MpikxES6WNlNP3+DTp3drLcsAJ7cZyGFJs/Bqltr6qwtA==", "dependencies": { - "NBitcoin": "10.0.1", + "NBitcoin": "10.0.9", "Newtonsoft.Json": "13.0.3" } }, "BTCPayServer.Lightning.Eclair": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "ZXO1JaD5mljSBBh6peS12G/tXKlbeJmtAhegGNlFA9ui8miZxjaJbmYGBvMVX+2eQLuXygUtV/bgZFMPHSnYpA==", + "resolved": "1.7.2", + "contentHash": "LNrXRp2YZPY92Z1QBCsU3si9r8AHZyrpGZ4SPcWRSQKtvlBIsYvlQyyn0FuklwjPXUWukWL/3ri8JppYmtwEiQ==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LND": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "Ur9pYRsxVmAA7UG2Aww1RVmEk2XhjDrBG73c283hqpDik5HyoixDrR9h6h9sRn0gGBw4N7/lNPuFvbLPnO2JFg==", + "resolved": "1.7.2", + "contentHash": "RT9unq9A6nX6sdpBL5PakmelyeAdhVbOWBzP/MPo7zMhVgQ/T1ZPugBbP2gTV0dRtspPuMZ2L9rhQNik9RMNAA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.LNDhub": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "PwYMEthz+DpBqwNVVzPPz9q3MZdomd8a7zXh+DCbyWSBAzb9knh0eplhqjSj9FezTVwnpaWrwhY6BjrDKzW8+g==", + "resolved": "1.7.2", + "contentHash": "IYx5B35Rj56Rxdll5Vhdmn8xZspaXMwycbObZhubhRd+ZgICdjYaLYZp/iDXUlSJwTW96jaDcTXtl3wuJ37ZyA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.Lightning.Phoenixd": { "type": "Transitive", - "resolved": "1.7.1", - "contentHash": "xBNjTplPd+OwHTALQvPwOSir3xu2i2HgPvDkbjrzvq55L6U8EsRG8dEvEzabEYHEcNkDBDIIO5OSTwfrod33nA==", + "resolved": "1.7.2", + "contentHash": "7qHPupwFvEYXEDBOhTs8IXH357vnBAWpdc1uiR3B9PF/fSHtRqxS5syMj720LwyleBadKwlCuNXNB19gdsGbrA==", "dependencies": { - "BTCPayServer.Lightning.Common": "1.7.1" + "BTCPayServer.Lightning.Common": "1.7.2" } }, "BTCPayServer.NTag424": { @@ -273,11 +273,11 @@ }, "HtmlSanitizer": { "type": "Transitive", - "resolved": "9.1.982", - "contentHash": "+KBhQAoddWFWXgyWfmV5QAW9auveh29581t47jxtjJAEB5BxZILR2LUue5Lr4DCZFtpYeUUskD3nE1tct7DJPw==", + "resolved": "9.2.1039", + "contentHash": "PKxy1hYknAij8YlHCC2a9GSqzUd4bh3IvY+abJBvOH1FKcZpbyafEHtdFcXQBt12Y7hNPkNEOP6Qa7uKNSs/yA==", "dependencies": { - "AngleSharp": "1.7.0", - "AngleSharp.Css": "1.0.1" + "AngleSharp": "1.7.2", + "AngleSharp.Css": "1.0.2" } }, "libsodium": { @@ -322,67 +322,67 @@ }, "Microsoft.AspNetCore.Connections.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "oXFVxDMZeUSCVGRyZsZAIJIrKVNayMstMfBrNOkPWJvxePziwmTGfx3+HPlf5bnwYxt6oq/FKduCoTIXXMNf1A==", + "resolved": "10.0.11", + "contentHash": "DCrayFIb+t+P9EI6NAP8BmAIgi51lrdmdtMAQnZf2v2J51q5ptOMR2rzfhvSMAZZhYReAK4H+ZTprf8Q5rOnzw==", "dependencies": { - "Microsoft.Extensions.Features": "10.0.10" + "Microsoft.Extensions.Features": "10.0.11" } }, "Microsoft.AspNetCore.Cryptography.Internal": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "T/kOT3kAVZU1B0QlpRxASpdbAJ/o5DLFW7bWS6vyE44uMqPmojEcaFENt6ww1xaLH++ZNwsEJ1YUOwPK050lNQ==" + "resolved": "10.0.11", + "contentHash": "rDS7psQk0UGKAHFg8O3Ho9E+wLz1E2O9Ppt47wtc7A5H0kI6rwTcJ7V1rxj5jN7FfD/KkS4jzbdMiVOaHGUyiQ==" }, "Microsoft.AspNetCore.Cryptography.KeyDerivation": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "w6P461MvhJrttEcyGfN00tf8rdwQJFK+s0pebR44jiTzAa+PUZ804xzbGZQpR6klSFd212M4DIIROSaW0Acifg==", + "resolved": "10.0.11", + "contentHash": "4L8yQnfUR6SJ2uu52YOyNGNmvSmX77Wr/XLNn+dM5IazSFz/z71BEzdDCLBlGU/GCUxxPhogJJ+l6rHR3WWEaQ==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.Internal": "10.0.10" + "Microsoft.AspNetCore.Cryptography.Internal": "10.0.11" } }, "Microsoft.AspNetCore.Identity.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "/ZV8RMWbWob1ZGsF5f1wVJNNlFLPKCf2ba834PpQiNhirVQ/ksup5SujtuUsvByHEZgv+9dmjC/4Xdi75axmXQ==", + "resolved": "10.0.11", + "contentHash": "N3+Fs465t08FyrQ+uoQyYH6TehLjtuGr/v5IjlFZANBbsivq9M5xFCVD25Ktq+HpWCfWXIrRjoakgymzO2trlw==", "dependencies": { - "Microsoft.EntityFrameworkCore.Relational": "10.0.10", - "Microsoft.Extensions.Identity.Stores": "10.0.10" + "Microsoft.EntityFrameworkCore.Relational": "10.0.11", + "Microsoft.Extensions.Identity.Stores": "10.0.11" } }, "Microsoft.AspNetCore.JsonPatch": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "sNPvgAoV/IsK4fS2gYDAqvbK5kMQPCU8h7WjOjxS1f4/0+bGnnZbTJ0ceM8jvMcUanDoNpYRFf+7UDb+s3P1ag==", + "resolved": "10.0.11", + "contentHash": "IE0B7q5/bUAHoOvffJnOfaf5zIxeEpr5Jel4ZLzLyi8L4v1ihYwG88lLn7y2U3P9QXvY3lOG5TFLnF3zXZ+ykg==", "dependencies": { "Newtonsoft.Json": "13.0.3" } }, "Microsoft.AspNetCore.Mvc.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "BuigyKPrvORCHyU8Vna7eQMQg6hDNqRQyFGCEa0+QJ8pLL5xcgNpLzaD1eom54Oaxb4mHnPs8MaPKejNL9lTKA==", + "resolved": "10.0.11", + "contentHash": "+bLLpFxDgwyS4cqgQqpVxXdAd+0v11WHl50zi6K74WzKZSDYjAQHV+3Bn9BER8rHKg9ImBqUC+daakSeXkoQKw==", "dependencies": { - "Microsoft.AspNetCore.JsonPatch": "10.0.10", + "Microsoft.AspNetCore.JsonPatch": "10.0.11", "Newtonsoft.Json": "13.0.3", "Newtonsoft.Json.Bson": "1.0.2" } }, "Microsoft.AspNetCore.SignalR.Common": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "X0bTYSNXyLeOHbS4HbF1x4aXFUxgu35CyUgAuCJGpZcRz2wwftRbeJ6v17wlUm7Dzvbbo5Dvff5arwY2tDHYBg==", + "resolved": "10.0.11", + "contentHash": "fDg4cdP3q4VTxNdp+oy1Ju+7Zi12pFEtQQVeQu3oOXwaIh2KwprmebI+zgfQZQSimQx0DSoWUB87sKiyDaT9nA==", "dependencies": { - "Microsoft.AspNetCore.Connections.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Connections.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "YWCDeZYmRtF527xH5RYGa2aPyefHhjDpAMsqaD5+OxjfYqH26/fBF9vx2CrcTq27z3TZwdMtGNJTRrnExeuOaw==", + "resolved": "10.0.11", + "contentHash": "zCROl/LG2R9iO1UrOs7Hkdn2sAOlzCNwKU93uvCIvNarkJY4I+VU7phHNQITTv/Pm2grB/TFjt1kuDmua2n9zg==", "dependencies": { - "Microsoft.AspNetCore.SignalR.Common": "10.0.10", + "Microsoft.AspNetCore.SignalR.Common": "10.0.11", "Newtonsoft.Json": "13.0.3" } }, @@ -420,80 +420,80 @@ }, "Microsoft.EntityFrameworkCore": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==", + "resolved": "10.0.11", + "contentHash": "VOSGU8en6HZJs8t7UMFN+9vGcRgVOOn6fA44Ngcg2NyvJ3P1KE94iAb0XzaVaGhXGtt+qaM/VtEn0/hzluQJeg==", "dependencies": { - "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10", - "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore.Abstractions": "10.0.11", + "Microsoft.EntityFrameworkCore.Analyzers": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.EntityFrameworkCore.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA==" + "resolved": "10.0.11", + "contentHash": "6auJR+9+9VunznKfH7WGrHMrnrmA0F7JZ22EXzwXvVhjfnbu9Xq7NSIWaOf3KJsOanM2qf5ajJ2JR5TlcPZTLA==" }, "Microsoft.EntityFrameworkCore.Analyzers": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q==" + "resolved": "10.0.11", + "contentHash": "Bv7X4wSSnzCQED9WYXKJ8fwgyvKwf0xZM1GO8xkf6CF9zl+UBnvjxmcPnokJRy0JKjc1SlHSzzhx1HcL4jitTQ==" }, "Microsoft.EntityFrameworkCore.Relational": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==", + "resolved": "10.0.11", + "contentHash": "grznnTJgEYxaWpdKAsTzg6j+89jHgCXWYp+QGtlX5O92+w/VuhWM6JLPYb+uw8M9VhGUvOTsO76dYOy9vNPd5Q==", "dependencies": { - "Microsoft.EntityFrameworkCore": "10.0.10", - "Microsoft.Extensions.Caching.Memory": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.EntityFrameworkCore": "10.0.11", + "Microsoft.Extensions.Caching.Memory": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "resolved": "10.0.11", + "contentHash": "vUl798SmruTqqlt/xH2gDk3tJlhk6k3HdOXAHirlRfbNKDym4g/kRpUL9S4sl6F6FsOTOMW+ZsDapqlZMOOiEw==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==", + "resolved": "10.0.11", + "contentHash": "el1g0mBEbDBGY2bT9mcSfrTWO8QlPdq2nOCnvQugioOFwHV+bVBMeiakoI0dNOdj8d6Hi9K6HY2xzRUWJiDR3w==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Configuration.Binder": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Configuration.EnvironmentVariables": { @@ -530,16 +530,16 @@ }, "Microsoft.Extensions.DependencyInjection": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -548,27 +548,27 @@ }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Kr/e7lUf4+N8tacbqJ2Ctwe/HarKdAc9ZkgKVVqvtJDBKbez+T/KnUwu82KSlnBp/SrpBcxc7u7xkE2oUZT/5Q==", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.10", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.10", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.10" + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" } }, "Microsoft.Extensions.Diagnostics.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "9uWiKpeOVac355STyChWR/pliFX/5CeLqChW9kKsaxyDH4EUTZxMkT4Jwp/J/peLm0GBFmSX5c0WCse3yCnq1Q==", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Features": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "4Zdm7n1vxXAXpHOhGQVpGd5KCdcI1EWk66ilgdrDt2I+928ND+u/F+EVrzYi9pmRR+XeAE47kjuVEmkP0b0mBw==" + "resolved": "10.0.11", + "contentHash": "/ro7Ate9LihDcZP6ukTwUjFj3dBzz7tijNcGg4aYBa3RkjqC/cOPqxZtMrOS3RU3nhRLHX8WTKq9EKL/4V4r5A==" }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", @@ -620,68 +620,68 @@ }, "Microsoft.Extensions.Identity.Core": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "ZA+9MX1D7+jm/1RF3iOOBThCps55MT1jAwLne1dryMj9cuAeOVtGS3pBegqk1Mwza+0tuVAklob+Q/EA9bHnQw==", + "resolved": "10.0.11", + "contentHash": "74BKWqcioSjoG2NopnIPoxtc8uqJjsMEXsZ+a0dGkLnZmCtcEOnwS7FFqxcA7TbJVNk54IMLqjrvacPSOKH80Q==", "dependencies": { - "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.10", - "Microsoft.Extensions.Diagnostics": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.AspNetCore.Cryptography.KeyDerivation": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Identity.Stores": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "WMG/9wPJPnwU2w1R/WPLO/RL2UpGpBhw9z6odAAUkFdZypzzXl620FJWEoPpuBUq6Q4GYgMg0FQVRCO6gO4MQQ==", + "resolved": "10.0.11", + "contentHash": "9g72hwc5ARsracMp9aQuG0HcTg1Oj62dnq+LcRNpoqk5MIVfUE3xvlMprxhDhU/Dj1Vpc658W6vs3Rjs5dp0Jg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.10", - "Microsoft.Extensions.Identity.Core": "10.0.10", - "Microsoft.Extensions.Logging": "10.0.10" + "Microsoft.Extensions.Caching.Abstractions": "10.0.11", + "Microsoft.Extensions.Identity.Core": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11" } }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.10", - "Microsoft.Extensions.Logging.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10" + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" } }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "tnBmu/LwF25ZQK+HBNCu2xrwnkKoB/XEbJyooGGoYxHrhvxbSKi7eOFiJ4AXBy/QU4vtCvCJfoi8k9Ej72qzOQ==", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", - "Microsoft.Extensions.Configuration.Binder": "10.0.10", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "Microsoft.Extensions.Options": "10.0.10", - "Microsoft.Extensions.Primitives": "10.0.10" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" } }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" }, "Microsoft.Identity.Abstractions": { "type": "Transitive", @@ -740,8 +740,8 @@ }, "NBitcoin": { "type": "Transitive", - "resolved": "10.0.8", - "contentHash": "ZM4/FxOKxF/sTHZtWefyO3DP4qezRqzcrzzdR/MzTqbUbRhyqGoV3rcn4UWBGyVKucPV7EE7rTt8xlbfM7gsJg==", + "resolved": "10.0.10", + "contentHash": "YSCBYgTy53gxDs59zcVYWGret3HPmaMmzTyRiLdCjA/EwBm+dmu5mNvTcQb8PQVt/TJxoz2ULfBhTSrTwSYZhQ==", "dependencies": { "Microsoft.Extensions.Logging.Abstractions": "1.0.0", "Newtonsoft.Json": "13.0.1" @@ -858,16 +858,16 @@ }, "QRCoder": { "type": "Transitive", - "resolved": "1.7.0", - "contentHash": "6R3hQkayihGIDjp3F1nLRDBWG+nqahGyOY2+fH4Rll16Vad67oaUUfHkOiMWKiJFnGh+PIGDfUos+0R9m54O1g==", + "resolved": "1.8.0", + "contentHash": "RuvX3PEXU6pbY/I5ItAk800jm62r+YnoPLgyS2WTgwxkOnGkOfU9ORiipHUF0LkLyqM8rlroUCA319JjRYfRFQ==", "dependencies": { "System.Drawing.Common": "6.0.0" } }, "Serilog": { "type": "Transitive", - "resolved": "4.3.0", - "contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ==" + "resolved": "4.4.0", + "contentHash": "ZC6Le3rr4TVJJjS4KsQAesxeF1EhW9qcZmmG7eP5Y2G3+gTGkJEUXkq4+tZNPtyp05I0wWOxnIjwtxFweJsObw==" }, "Serilog.AspNetCore": { "type": "Transitive", @@ -1082,30 +1082,29 @@ "type": "Project", "dependencies": { "BIP78.Sender": "[0.2.5, )", - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", - "BTCPayServer.Common": "[2.4.2, )", - "BTCPayServer.Data": "[2.4.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", + "BTCPayServer.Common": "[2.4.4, )", + "BTCPayServer.Data": "[2.4.4, )", "BTCPayServer.Hwi": "[2.0.6, )", - "BTCPayServer.Lightning.All": "[1.7.6, )", + "BTCPayServer.Lightning.All": "[1.7.8, )", "BTCPayServer.NTag424": "[1.0.25, )", - "BTCPayServer.Rating": "[2.4.2, )", + "BTCPayServer.Rating": "[2.4.4, )", "CsvHelper": "[33.1.0, )", "Fido2": "[4.0.1, )", "Fido2.AspNet": "[4.0.1, )", "LNURL": "[0.0.36, )", "MailKit": "[4.17.0, )", - "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.10, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "NBitcoin": "[10.0.8, )", + "Microsoft.AspNetCore.Mvc.NewtonsoftJson": "[10.0.11, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "NBitcoin": "[10.0.10, )", "NBitpayClient": "[1.0.0.39, )", "Newtonsoft.Json": "[13.0.4, )", "NicolasDorier.CommandLine": "[2.0.0, )", "NicolasDorier.CommandLine.Configuration": "[2.0.0, )", "NicolasDorier.RateLimits": "[1.2.3, )", - "QRCoder": "[1.7.0, )", - "SSH.NET": "[2025.1.0, )", - "Serilog": "[4.3.0, )", + "QRCoder": "[1.8.0, )", + "Serilog": "[4.4.0, )", "Serilog.AspNetCore": "[10.0.0, )", "Serilog.Sinks.File": "[7.0.0, )", "TwentyTwenty.Storage": "[2.26.1, )", @@ -1119,18 +1118,18 @@ "btcpayserver.abstractions": { "type": "Project", "dependencies": { - "BTCPayServer.Client": "[2.0.2, )", - "HtmlSanitizer": "[9.1.982, )", - "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "BTCPayServer.Client": "[2.4.4, )", + "HtmlSanitizer": "[9.2.1039, )", + "Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "Npgsql.EntityFrameworkCore.PostgreSQL": "[10.0.3, )" } }, "btcpayserver.client": { "type": "Project", "dependencies": { - "BTCPayServer.Lightning.Common": "[1.7.1, )", - "NBitcoin": "[10.0.8, )", + "BTCPayServer.Lightning.Common": "[1.7.2, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } }, @@ -1144,11 +1143,11 @@ "btcpayserver.data": { "type": "Project", "dependencies": { - "BTCPayServer.Abstractions": "[2.4.2, )", - "BTCPayServer.Client": "[2.0.2, )", + "BTCPayServer.Abstractions": "[2.4.4, )", + "BTCPayServer.Client": "[2.4.4, )", "Dapper": "[2.1.79, )", - "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.10, )", - "Microsoft.EntityFrameworkCore": "[10.0.10, )", + "Microsoft.AspNetCore.Identity.EntityFrameworkCore": "[10.0.11, )", + "Microsoft.EntityFrameworkCore": "[10.0.11, )", "NBitcoin.Altcoins": "[6.0.4, )" } }, @@ -1158,7 +1157,7 @@ "DigitalRuby.ExchangeSharp": "[1.2.1, )", "Microsoft.AspNet.WebApi.Client": "[6.0.0, )", "Microsoft.CodeAnalysis.CSharp": "[5.6.0, )", - "NBitcoin": "[10.0.8, )", + "NBitcoin": "[10.0.10, )", "Newtonsoft.Json": "[13.0.4, )" } } From b49e6b95e059d0d4fd04ff2e74651ae0f8481db2 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:57:56 -0400 Subject: [PATCH 16/22] Document what the end-to-end exit test costs the fixture Category=LocalRegtestExit is the one category that is not revenue-neutral: an exit turns its wallet into on-chain Bitcoin, which the return leg cannot undo, so each run costs the SSP a wallet's worth and repeated runs empty it. Also records why it is its own CI step rather than part of Category=LocalRegtest - it cannot share the wallet, and a measured run showed it cannot share the stack either. --- docs/testing.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/testing.md b/docs/testing.md index 1599664..be2300f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -273,6 +273,26 @@ plugin's fee guard correctly refuses any remainder much smaller than that, which after the sweep test. Without it the leftover is stranded in a wallet whose storage `down.sh` destroys, and one measured pair of runs took the SSP from 500,000 to 382,000. +**`Category=LocalRegtestExit` is the exception, and it is not revenue-neutral.** That category holds the +end-to-end unilateral exit (`SparkLocalRegtestExitTests`), which runs the whole exit against real operators: +it quotes, derives and funds the plugin's CPFP key from `bitcoind`, builds and signs, then broadcasts the +transactions exactly as the exit page instructs — fan-out and sweep alone, tree nodes as packages via +`submitpackage`, mining between rounds so every CSV timelock matures — and asserts the destination address +receives `recoverable + unspent funding − total fee`. It passes in CI in about 1m 40s. + +It costs the SSP a whole wallet, because an exit converts that wallet's balance to on-chain Bitcoin at a +destination address and the return leg cannot undo that. Measured: repeated runs take a stack from 500,000 +to 0 in a handful of runs, after which fixture setup fails with `amount cannot be represented by available +leaves without creating a child below the configured split floor`. Hence teardown does not restore it and +`local-regtest.yml` tops the SSP up explicitly before running the category. Locally, top up with +`cashu-regtest/docker-scripts.sh`'s `cashu-spark-fund-ssp` (500,000 per call) before each run. + +It is also deliberately separated in CI rather than folded into `Category=LocalRegtest`, for two measured +reasons: it cannot share the suite's wallet (it exits the balance, and whichever ran first would decide +whether the other tests had any money), and it cannot share the *stack* (a second wallet funded from the +same SSP while the suite ran produced a failure in the suite's Lightning send, reported as `Error:` with an +empty message). So it runs as its own step, after the suite and after the top-up. + Observed figures from a green run, for calibration: 150,000 sats deposited, **140,100 credited** (a 9,900-sat claim fee at ~100 sat/vB, auto-claimed by the SDK's own worker), and a sweep of 125,100 delivering 105,400 for a **19,700-sat exit fee** — 18.7% of what the destination received, inside the store's 40% From 91f91297aad7e25bce92700d55ea9018235a8aeb Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:26:55 -0400 Subject: [PATCH 17/22] Exit page copy, and a mempool.space-backed fee-rate default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy, at the operator's direction: - the fee-rate hint now says what the rate is for and that it must clear the mempool minimum, because one rate prices the whole chain over hours or days; - the destination hint says the funds end up there and that changing it later is costly; - the 'Quote an exit' sub-copy is gone entirely; - the empty-selection refusal is now 'No leaves are large enough to exit properly at the selected fee-rate...', which tells the operator what to do rather than describing what Spark decided. The fee rate itself now defaults to mempool.space's half-hour recommendation, fetched through the explorer client the exit already has, clamped to the service's bounds, cached five minutes, and never throwing — an unreachable API or a regtest chain with no mempool.space leaves the field at a floor of 2 sat/vB instead. The recommendation is only fetched when the quote form will actually render, and a record's own rate still wins over it. Also enables the exit gate on the e2e BTCPay stack, which is where the product is exercised against real operators and was the one place the exit could not be seen. The Advanced page's backup paragraph now answers the question it invited: a backup is not an alternative to exiting, it is what keeps exiting possible, because collecting that data needs the operators up and the moment you need an exit is the moment you can no longer collect it. --- .../Fakes/SparkSurfaceHarness.cs | 1 + .../SparkExitPageTests.cs | 43 +++- .../SparkUnilateralExitServiceTests.cs | 227 +++++++++++++++++- .../Controllers/SparkController.cs | 11 + .../Services/ISparkUnilateralExitService.cs | 10 + .../Services/SparkExitFundingExplorer.cs | 204 ++++++++++++++++ .../Services/SparkUnilateralExitService.cs | 34 ++- .../Views/Spark/Advanced.cshtml | 11 +- .../Views/Spark/Exit.cshtml | 15 +- e2e/btcpay/docker-compose.yml | 6 + 10 files changed, 536 insertions(+), 26 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs index a454d84..0f3e07a 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs @@ -363,6 +363,7 @@ public Task ReadAsync(string storeId, CancellationToken WalletRunning: false, DisclosureAcknowledged: false, BalanceSats: 0, + RecommendedFeeRateSatPerVbyte: null, ActiveRecord: null, History: [], FundingReceivedSat: null, diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs index 60e4d57..b74d7c7 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs @@ -151,13 +151,40 @@ public async Task An_acknowledged_store_with_nothing_in_flight_gets_the_quote_fo 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); + // Nothing pre-filled from a previous exit, because there is no previous exit to pre-fill from. The fee + // rate is the exception and is deliberately not zero: there is no earlier exit to take it from, so it + // opens at the value the exit service decided on (see the rate tests below), and a zero in a form whose + // own bounds start at one is a field no submission would accept. + Assert.Equal(SparkUnilateralExitService.DefaultFeeRateSatPerVbyte, model.FeeRateSatPerVbyte); Assert.Null(model.DestinationAddress); Assert.Null(model.LeafCount); Assert.Null(model.FundingKeyPath); } + /// + /// The quote form opens at the recommended rate, and at the plugin's own floor when there is none. + /// + /// + /// Both halves matter and they are the same feature: the field is a number the operator may accept or replace, + /// and the failure this defends against is the empty one it used to be. A recommendation that could not be + /// fetched — off mainnet with no explorer override, or an explorer that was unreachable — must still leave the + /// form usable rather than blank, because an operator has no way to tell a page that offered them nothing from + /// one whose value failed to render. + /// + [Theory] + [InlineData(7L, 7L)] + [InlineData(null, SparkUnilateralExitService.DefaultFeeRateSatPerVbyte)] + public async Task The_quote_form_opens_at_the_recommended_rate_or_the_plugin_floor( + long? recommended, long expected) + { + using var gate = FeatureGate(enabled: true); + + var exit = new StubExitService { Page = Page(recommendedFeeRateSatPerVbyte: recommended) }; + var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit); + + Assert.Equal(expected, (await RenderExit(h)).FeeRateSatPerVbyte); + } + [Fact] public async Task A_record_awaiting_funding_carries_the_quote_the_funding_figures_and_the_key_path() { @@ -175,7 +202,12 @@ public async Task A_record_awaiting_funding_carries_the_quote_the_funding_figure fundingReceivedSat: 6_000, fundingLargestOutputSat: 2_500, leafCount: 2, - fundingKeyPath: "m/84'/1'/4607060'/0/3") + fundingKeyPath: "m/84'/1'/4607060'/0/3", + // A live recommendation is carried alongside the record on purpose, and it disagrees with the + // record's 12 sat/vB. The rate this exit was quoted at is the one the form must show: the record + // is the thing being funded, and a market rate that moved since would offer the operator a number + // the quoted leaves, their fees and their funding requirement were not computed at. + recommendedFeeRateSatPerVbyte: 7) }; var h = SparkSurfaceHarness.Create(configureAttackerStore: true, unilateralExit: exit); @@ -692,7 +724,7 @@ private static async Task RenderExit(SparkSurfaceHarness h) /// 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 + /// The page data has thirteen 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. /// @@ -700,6 +732,7 @@ private static UnilateralExitPageData Page( bool walletRunning = true, bool disclosureAcknowledged = true, long balanceSats = 0, + long? recommendedFeeRateSatPerVbyte = null, UnilateralExitRecord? activeRecord = null, IReadOnlyList? history = null, long? fundingReceivedSat = null, @@ -713,6 +746,7 @@ private static UnilateralExitPageData Page( walletRunning, disclosureAcknowledged, balanceSats, + recommendedFeeRateSatPerVbyte, activeRecord, history ?? [], fundingReceivedSat, @@ -801,6 +835,7 @@ private sealed class StubExitService : ISparkUnilateralExitService { public UnilateralExitPageData Page { get; set; } = new(WalletRunning: true, DisclosureAcknowledged: false, BalanceSats: 0, + RecommendedFeeRateSatPerVbyte: null, ActiveRecord: null, History: [], FundingReceivedSat: null, FundingLargestOutputSat: null, LeafCount: null, FundingKeyPath: null, Transactions: null, TransactionsUnreadable: false, PendingBroadcast: null); diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs index d176c80..c4dfa12 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs @@ -1710,6 +1710,36 @@ public async Task A_built_exit_reports_no_funding_balance() Assert.Equal(0, harness.ExplorerRequests); } + /// The quote form's opening rate is fetched while nothing is in flight, and not once something is. + /// + /// A store with an exit already in flight has the rate it was quoted at on the record, and the page shows that + /// one — so fetching a market rate on every view of that page would be an external round trip that decides + /// nothing, from a page any store viewer can reload. The recommendation travels as null rather than as a number + /// in that case: the service has no basis for one, and the fallback belongs to whoever renders the field. + /// + [Fact] + public async Task The_page_carries_a_recommendation_only_while_nothing_is_in_flight() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.ExplorerBody("""{"halfHourFee":7}"""); + + var page = await harness.Service.ReadAsync(StoreId, Ct); + + Assert.Null(page.ActiveRecord); + Assert.Equal(7, page.RecommendedFeeRateSatPerVbyte); + Assert.Equal(1, harness.ExplorerRequests); + + harness.Seed(status: UnilateralExitStatus.Built); + var inFlight = await harness.Service.ReadAsync(StoreId, Ct); + + Assert.NotNull(inFlight.ActiveRecord); + Assert.Null(inFlight.RecommendedFeeRateSatPerVbyte); + // Still one request in total, and a built exit asks for nothing at all — see + // A_built_exit_reports_no_funding_balance — so this counts the recommendation and nothing else. + Assert.Equal(1, harness.ExplorerRequests); + } + #endregion #region The funding key @@ -1851,6 +1881,157 @@ public async Task An_output_with_a_malformed_txid_is_dropped() Assert.Equal(4_200, page.FundingReceivedSat); } + /// + /// A well-formed answer is the half-hour estimate, and it is clamped to what the quote form will accept. + /// + /// + /// The half-hour estimate and not the fastest rate on offer, because this one number is paid by every + /// transaction in a chain of dozens: pricing all of them at the panic rate overpays on each, and pricing them at + /// a floor risks the failure this flow cannot recover from — a half-broadcast exit whose fan-out is already + /// spent. The ceiling matters from the other side: a fee spike must not produce a rate the quote form's own + /// bounds refuse, which would show an operator a number that cannot be submitted. + /// + [Fact] + public async Task A_well_formed_answer_is_the_half_hour_rate_clamped_to_the_form_bounds() + { + using var harness = Harness.Create(); + harness.ExplorerBody( + """{"fastestFee":40,"halfHourFee":7,"hourFee":5,"economyFee":3,"minimumFee":1}"""); + + Assert.Equal(7, await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync( + mainnet: true, new UnilateralExitSettings(), Ct)); + + // The path, asserted because it is this plugin's contract with a third party rather than an internal + // choice: {base}/v1/fees/recommended under the same default base URL the funding lookups use. + Assert.Equal( + SparkExitFundingExplorer.MainnetDefaultApiUrl + "/v1/fees/recommended", + Assert.Single(harness.ExplorerUrls)); + + // A fee spike, read after the cache window has passed — which is what an operator reloading the page + // minutes later gets. The rate is what the form can quote at, not what the explorer said. + harness.Time.Advance(SparkExitFundingExplorer.RecommendedFeeRateTtl); + harness.ExplorerBody( + """{"fastestFee":4000,"halfHourFee":900,"hourFee":800,"economyFee":700,"minimumFee":600}"""); + + Assert.Equal( + SparkUnilateralExitService.MaxFeeRateSatPerVbyte, + await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync( + mainnet: true, new UnilateralExitSettings(), Ct)); + } + + /// + /// Every way the lookup can fail is a null rate rather than an exception. + /// + /// + /// The exit page has to render on a server with no outbound network at all, so an unreachable explorer cannot + /// travel as an exception: it would take down a GET whose worst honest answer is "no recommendation, quote at + /// the fallback". The endless response is here because it is the failure that does not look like one — a 200 + /// with no end to it, where only the read ceiling can stop the wait. + /// + [Fact] + public async Task A_lookup_that_fails_is_null_rather_than_an_exception() + { + var settings = new UnilateralExitSettings { EsploraApiUrl = "http://explorer.test/api" }; + + using (var offline = Harness.Create()) + { + offline.ExplorerOffline(); + Assert.Null(await offline.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + } + + using (var refused = Harness.Create()) + { + // An explorer rate-limiting this plugin, which is the answer that most needs to not become a retry. + refused.ExplorerFails(HttpStatusCode.TooManyRequests); + Assert.Null(await refused.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + } + + using (var junk = Harness.Create()) + { + // A captive portal or a proxy's error page: a 200 whose body is not this API's JSON. + junk.ExplorerBody("maintenance"); + Assert.Null(await junk.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + } + + using (var endless = Harness.Create()) + { + endless.ExplorerEndless(); + Assert.Null(await endless.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + } + } + + /// + /// A body with no usable half-hour rate is null, whatever shape the rate is missing in. + /// + /// + /// Zero and negative are the dangerous ones: a zeroed placeholder, or a field the parse emptied, would render as + /// a rate an operator accepts — in a form whose own minimum is one satoshi — and no transaction in the exit + /// would relay at it. A non-number fails the parse instead, which has to reach the caller as the same "no + /// recommendation" rather than as an exception on a page render. + /// + [Theory] + [InlineData("{}")] + [InlineData("""{"fastestFee":12,"minimumFee":1}""")] + [InlineData("""{"halfHourFee":null}""")] + [InlineData("""{"halfHourFee":"7"}""")] + [InlineData("""{"halfHourFee":0}""")] + [InlineData("""{"halfHourFee":-3}""")] + public async Task A_body_with_no_usable_rate_is_null(string body) + { + using var harness = Harness.Create(); + harness.ExplorerBody(body); + + Assert.Null(await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync( + true, new UnilateralExitSettings(), Ct)); + } + + /// + /// Off mainnet with no override there is no recommendation, and nothing is even asked. + /// + /// + /// mempool.space has no regtest, which is why the URL resolution refuses here — and for the recommendation the + /// refusal has to cost nothing: the caller falls back to the plugin's own rate and the field still renders. The + /// call count is the behaviour under test: a fetch attempted here would be a request to a third party that + /// cannot answer the question, on every render of a regtest exit page. + /// + [Fact] + public async Task Off_mainnet_with_no_override_a_recommendation_is_not_even_attempted() + { + using var harness = Harness.Create(); + + Assert.Null(await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync( + mainnet: false, new UnilateralExitSettings(), Ct)); + + Assert.Equal(0, harness.ExplorerRequests); + } + + /// + /// The recommendation is cached for its window instead of being fetched on every render. + /// + /// + /// The exit page is a GET any store viewer can reload in a loop, and a market rate is not a per-viewer fact: + /// one request per page view would turn refreshing a page into load on a third party's endpoint and would make + /// the field's presence depend on their latency. Once the window is out the market is asked again, because an + /// operator about to fund an exit must not be shown an old rate as if it were current. + /// + [Fact] + public async Task The_recommendation_is_cached_rather_than_fetched_per_render() + { + using var harness = Harness.Create(); + var settings = new UnilateralExitSettings(); + harness.ExplorerBody("""{"halfHourFee":7}"""); + + Assert.Equal(7, await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + Assert.Equal(7, await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + Assert.Equal(1, harness.ExplorerRequests); + + harness.Time.Advance(SparkExitFundingExplorer.RecommendedFeeRateTtl); + harness.ExplorerBody("""{"halfHourFee":9}"""); + + Assert.Equal(9, await harness.ExplorerClient.RecommendFeeRateSatPerVbyteAsync(true, settings, Ct)); + Assert.Equal(2, harness.ExplorerRequests); + } + #endregion /// @@ -1907,16 +2088,20 @@ private Harness(bool featureEnabled) Protector = new SparkMnemonicProtector(new EphemeralDataProtectionProvider()); Runtime.Clients[StoreId] = Sdk; + Time = new StubTimeProvider(Now); + ExplorerClient = new SparkExitFundingExplorer( + new ExplorerClientFactory(_handler), + Time, + NullLogger.Instance); + Service = new SparkUnilateralExitService( Settings, Runtime, Records, Protector, - new SparkExitFundingExplorer( - new ExplorerClientFactory(_handler), - NullLogger.Instance), + ExplorerClient, Network.RegTest, - new StubTimeProvider(Now), + Time, NullLogger.Instance); } @@ -1924,6 +2109,18 @@ private Harness(bool featureEnabled) public DateTimeOffset Now { get; } = new(2026, 8, 20, 12, 0, 0, TimeSpan.Zero); + /// + /// The clock the service and the explorer both read, so a test can step over the recommendation's cache + /// window rather than wait for it. + /// + public StubTimeProvider Time { get; } + + /// + /// The explorer instance the service was handed, exposed so the fetch rules can be exercised without the + /// service in the way — and so a test can tell a fetch the service made from one it did not. + /// + public SparkExitFundingExplorer ExplorerClient { get; } + public FakeSparkSdkClient Sdk { get; } = new(); public FakeSparkStoreRuntime Runtime { get; } = new(); @@ -1937,6 +2134,12 @@ private Harness(bool featureEnabled) /// How many lookups actually reached the explorer. public int ExplorerRequests => _handler.Requests; + /// The URLs the explorer was asked for, in order, so a test can tell the two endpoints apart. + public IReadOnlyList ExplorerUrls => _handler.Urls; + + /// An explorer that answers with a body that never ends: the response no header warned about. + public void ExplorerEndless() => _handler.Endless = true; + public SparkUnilateralExitService Service { get; } /// Gives the store a Spark configuration, and optionally a stored acknowledgement. @@ -2064,20 +2267,36 @@ private sealed class ExplorerHandler : HttpMessageHandler public bool Offline { get; set; } + /// Answers with an endless chunked body, so only the read ceiling can stop it. + public bool Endless { get; set; } + public int Requests { get; private set; } + public List Urls { get; } = []; + protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { Requests++; + lock (Urls) + Urls.Add(request.RequestUri?.ToString() ?? string.Empty); + if (Offline) { return Task.FromException( new HttpRequestException("no route to host")); } + if (Endless) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new EndlessStream()) + }); + } + return Task.FromResult(new HttpResponseMessage(Status) { Content = new StringContent(Body, System.Text.Encoding.UTF8, "application/json") diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs index c3afd68..2e9ce86 100644 --- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs +++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs @@ -1411,8 +1411,19 @@ private SparkExitViewModel BuildExitViewModel( }; if (page.ActiveRecord is not { } record) + { + // Nothing in flight, so the quote form is what renders and its rate field is otherwise empty. The + // recommendation when the explorer gave one, and the plugin's own floor when it did not — off mainnet + // with no override, or an explorer that could not be read. Either way the operator gets a rate to + // quote at rather than a field they have to fill in blind, and either way they can type another one. + model.FeeRateSatPerVbyte = page.RecommendedFeeRateSatPerVbyte + ?? SparkUnilateralExitService.DefaultFeeRateSatPerVbyte; return model; + } + // A record wins, and it is not a preference: this field is pre-filled with the rate the exit in front of + // the operator was actually quoted and funded at. A market rate that has moved since would offer them a + // number the record's own quote — and any transaction built from it — does not honour. model.FeeRateSatPerVbyte = record.FeeRateSatPerVbyte; model.DestinationAddress = record.DestinationAddress; diff --git a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs index 9c6654f..422de96 100644 --- a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs @@ -170,6 +170,15 @@ public sealed record UnilateralExitOpResult( /// 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. +/// +/// A rate fetched from the block explorer for the quote form to open at, or null when there is no recommendation +/// to be had — off mainnet with no explorer override, or the explorer was unreachable. Populated only while no +/// exit is in flight, because that is the only time the form renders and its rate is the only thing the answer +/// would decide. Null is not zero and is not a default: the caller falls back to +/// , and a service that invented a rate the +/// explorer did not report would be putting a claim about the fee market in front of an operator who is about to +/// fund an exit at it. +/// /// 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 @@ -206,6 +215,7 @@ public sealed record UnilateralExitPageData( bool WalletRunning, bool DisclosureAcknowledged, long BalanceSats, + long? RecommendedFeeRateSatPerVbyte, UnilateralExitRecord? ActiveRecord, IReadOnlyList History, long? FundingReceivedSat, diff --git a/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs index 3401955..ace701c 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkExitFundingExplorer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -128,6 +129,18 @@ public sealed class SparkExitFundingExplorer /// public const long MaxResponseBytes = 4L * 1024 * 1024; + /// + /// How long a recommended fee rate is reused before the explorer is asked for another one. + /// + /// + /// Cached rather than fetched per render, because the exit page is a GET that any store viewer can reload in + /// a loop. A rate is not a per-viewer fact, and a third party's endpoint should not see one request per page + /// view — nor should a page wait on the network when it already holds an answer from moments ago. Five minutes + /// is about one block: short enough that the number still tracks a moving market, long enough that an operator + /// reloading the page is answered from memory. + /// + public static readonly TimeSpan RecommendedFeeRateTtl = TimeSpan.FromMinutes(5); + private static readonly JsonSerializerOptions JsonOptions = new() { // esplora spells everything lower case; being insensitive also survives an instance that does not. @@ -135,13 +148,33 @@ public sealed class SparkExitFundingExplorer }; private readonly IHttpClientFactory _httpClientFactory; + private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + /// + /// The recommended rate last read from each explorer URL. + /// + /// + /// Keyed by base URL because that is what an answer belongs to: two stores pointed at different esplora + /// instances are asking different questions, and the process-wide mempool.space default must not answer for an + /// operator's self-hosted explorer. The map is bounded by the distinct explorer URLs configured on the server, + /// which is a handful, and an entry past its window is dropped as the next one is written — so a URL that stops + /// being used does not sit here for the life of the process. + /// + private readonly ConcurrentDictionary _recommendedFeeRates = + new(StringComparer.Ordinal); + + /// + /// Read for the cache window alone. Injected rather than taken from so a test + /// can step over the window instead of waiting it out. + /// public SparkExitFundingExplorer( IHttpClientFactory httpClientFactory, + TimeProvider timeProvider, ILogger logger) { _httpClientFactory = httpClientFactory; + _timeProvider = timeProvider; _logger = logger; } @@ -298,6 +331,165 @@ public async Task MeasureConfirmedAsync( null); } + /// + /// The rate the explorer would put a transaction in the next few blocks at, clamped to what the quote form + /// accepts, or null when no recommendation can be had. + /// + /// + /// + /// The half-hour estimate, deliberately, not the fastest one on offer. An exit is not one transaction: it + /// is a chain of dozens the operator broadcasts by hand over hours to days, every one of them paying whatever + /// rate was chosen here. The fastest rate overpays on all of them; a floor rate risks the one failure this flow + /// cannot recover from, a half-broadcast exit whose fan-out is already spent and whose tree will not relay until + /// a fee the operator cannot easily raise arrives. The half-hour estimate is the middle of that tradeoff — it + /// confirms while the operator is still at the keyboard for the part of the exit where that matters, without + /// pricing every transaction in the tree like an emergency. + /// + /// + /// Null means "no recommendation", never "zero". Off mainnet with no override there is no explorer to ask, + /// and a fetch that fails is an ordinary state of the world rather than an error: the caller falls back to + /// and the exit stays usable. So every + /// failure — unreachable host, timeout, HTTP error, unparseable body, oversized body — returns null instead of + /// throwing, and nothing about the exit page depends on a third party being up. + /// + /// + public async Task RecommendFeeRateSatPerVbyteAsync( + bool mainnet, + UnilateralExitSettings? settings, + CancellationToken cancellationToken = default) + { + // Off mainnet with no override this refuses, which is expected — mempool.space has no regtest — and the + // caller's fallback is what makes it harmless here. See TryResolveBaseUrl. + if (!TryResolveBaseUrl(settings, mainnet, out var baseUrl, out _)) + return null; + + if (TryReadCachedFeeRate(baseUrl!, out var cached)) + return cached; + + var recommended = await FetchRecommendedFeeRateAsync(baseUrl!, cancellationToken).ConfigureAwait(false); + + // Stored whether or not there was an answer. A fetch that failed is precisely the one that must not be + // repeated on every render while the explorer is down. + RememberFeeRate(baseUrl!, recommended); + + return recommended; + } + + /// + /// The cached answer for one explorer URL, when one arrived inside the last + /// . + /// + /// + /// A clock that moves backwards keeps the answer rather than discarding it: the entry is only stale when the + /// measured age reaches the window, so a negative age reads as fresh, which is the harmless direction to be + /// wrong in. + /// + private bool TryReadCachedFeeRate(string baseUrl, out long? satPerVbyte) + { + satPerVbyte = null; + + if (!_recommendedFeeRates.TryGetValue(baseUrl, out var cached)) + return false; + + if (_timeProvider.GetUtcNow() - cached.FetchedAt >= RecommendedFeeRateTtl) + return false; + + satPerVbyte = cached.SatPerVbyte; + return true; + } + + /// + /// Remembers one answer for one URL, dropping the entries that have already outlived the window. + /// + /// + /// The sweep on write is what keeps this map from becoming a permanent record of every explorer URL the process + /// has ever been pointed at. It costs a walk over a handful of entries, on an operation that happens at most + /// once per window per URL. + /// + private void RememberFeeRate(string baseUrl, long? satPerVbyte) + { + var now = _timeProvider.GetUtcNow(); + + foreach (var (url, entry) in _recommendedFeeRates) + { + if (now - entry.FetchedAt >= RecommendedFeeRateTtl) + _recommendedFeeRates.TryRemove(url, out _); + } + + _recommendedFeeRates[baseUrl] = new CachedFeeRate(satPerVbyte, now); + } + + /// + /// One HTTP round trip to an esplora instance's recommended-fee endpoint. + /// + /// + /// The half-hour rate clamped into the bounds the quote form enforces, or null when no usable rate could be + /// read — including every failure the caller must not see as an exception. + /// + private async Task FetchRecommendedFeeRateAsync(string baseUrl, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(baseUrl); + + var url = string.Format( + CultureInfo.InvariantCulture, + "{0}/v1/fees/recommended", + baseUrl.TrimEnd('/')); + + 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 rate = reported?.HalfHourFee; + + // Zero or negative is not a rate, it is a body that says nothing about the market: a zeroed + // placeholder, or a number that arrived in a shape the parse left empty. Refused rather than clamped up, + // because clamping would turn "the explorer told us nothing" into "the explorer said one satoshi". + if (rate is null or <= 0) + { + _logger.LogWarning( + "The block explorer at {Url} reported no usable half-hour fee rate", baseUrl); + return null; + } + + // Only the ceiling is ever reached: anything under the floor is zero or negative, refused just above. + return Math.Clamp( + rate.Value, + SparkUnilateralExitService.MinFeeRateSatPerVbyte, + SparkUnilateralExitService.MaxFeeRateSatPerVbyte); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // Warning rather than error: nothing is broken by this, and the page still offers a rate to quote at. + _logger.LogWarning(ex, "Could not read a recommended fee rate from {Url}", baseUrl); + + return null; + } + } + + /// One explorer URL's last answer, and when it arrived. + private readonly record struct CachedFeeRate(long? SatPerVbyte, DateTimeOffset FetchedAt); + /// /// The one HTTP round trip both public methods share: the address's confirmed outputs, untagged. /// @@ -422,6 +614,18 @@ private static async Task ReadBoundedAsync(Stream body, CancellationToke _ => exception.Message }; + /// + /// The one field of esplora's GET /v1/fees/recommended this plugin reads, out of the five it reports. + /// + /// + /// Only the half-hour estimate is bound, because it is the one + /// chose and binding the others would suggest a policy this class does not have. Nullable so a body that omits + /// the field is a null rather than a zero; a body that spells it as something other than a number fails the + /// parse, which the caller treats the same way — see the remarks on that method. + /// + private sealed record RecommendedFees( + [property: JsonPropertyName("halfHourFee")] long? HalfHourFee); + /// One entry of esplora's GET /address/{address}/utxo. /// /// Only the four fields the plugin uses are bound. esplora also reports the block height and time of the diff --git a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs index e0011bc..6d3fd90 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs @@ -66,6 +66,20 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService /// internal const long MaxFeeRateSatPerVbyte = 500; + /// + /// The rate the quote form opens at when no recommendation could be had. + /// + /// + /// Two sat/vB, and it is a floor rather than a guess: one is the lowest rate this plugin will quote at all and + /// is also the usual mempool minimum, so a quote priced at exactly one sits on the boundary where a small rise + /// in the minimum makes every transaction in the tree non-relayable — and the exit is a chain of dozens, so a + /// rate that stops relaying halfway is the failure mode this whole flow is built to avoid. Two clears that + /// boundary while still costing an operator almost nothing, which is the right way to be wrong when the + /// alternative is no number at all. It is used off mainnet with no explorer override, and whenever the + /// explorer cannot be read — the operator sees a usable rate and can always type another one. + /// + internal const long DefaultFeeRateSatPerVbyte = 2; + internal const string FeatureDisabled = "Unilateral exit is not enabled on this server."; @@ -84,8 +98,8 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService "Another unilateral-exit operation for this store is already running. Try again in a moment."; internal const string NothingWorthExiting = - "There is nothing worth exiting at this fee rate. Spark selected no leaves, which means every one of them " - + "would cost more to force on-chain than it holds. A lower fee rate may select some."; + "No leaves are large enough to exit properly at the selected fee-rate. Please reduce the fee-rate and " + + "try again, or wait for fees to drop so you can do so."; internal const string ExitNotFound = "This store has no exit with that reference."; @@ -145,7 +159,7 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService /// shape where two "empty" literals drift apart from one another. /// private static UnilateralExitPageData AbsentFeature => - new(false, false, 0, null, [], null, null, null, null, null, false, null); + new(false, false, 0, null, null, [], null, null, null, null, null, false, null); private readonly ISparkStoreSettingsStore _settingsStore; private readonly ISparkStoreRuntime _runtime; @@ -250,6 +264,19 @@ public async Task ReadAsync( keyPath = DescribeKeyPath(active); } + // Only when the quote form will actually render, which is only when nothing is in flight. A store with an + // exit already awaiting funding or built has its rate on the record, so asking the explorer for a market + // rate here would be an external round trip on every page view that decides nothing — and the exit page is + // reachable from any store view. Null, not the fallback, when there is no recommendation: the fallback is + // the caller's decision, and a service that returned a market rate it did not have would be claiming one. + long? recommendedFeeRate = null; + if (active is null) + { + recommendedFeeRate = await _explorer + .RecommendFeeRateSatPerVbyteAsync(Mainnet, exitSettings, cancellationToken) + .ConfigureAwait(false); + } + // Read back and checked here rather than anywhere above: the page renders these, and a malformed column // has to become an explanation on the page instead of an exception in a view. var readable = TryReadTransactions(active, out var transactions); @@ -270,6 +297,7 @@ public async Task ReadAsync( sdk is not null, exitSettings.DisclosureAcknowledged, balance, + recommendedFeeRate, active, history, funding.TotalSat, diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml index b662ba3..df04a42 100644 --- a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml +++ b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml @@ -116,11 +116,12 @@ *@

Exit-state backup

- This wallet keeps the data an exit is built from in its own storage on this server. While - Spark's operators are reachable that data can be fetched again, so a wallet restored from its - recovery phrase rebuilds it. When they are gone and this server's storage is lost, it - cannot be recovered from anywhere — and the leaves it covered can no longer be exited. - Export it here, keep the copy somewhere safe, and an exit built from it goes ahead with the + This wallet keeps the data an exit is built from in its own storage on this server, and + collecting that data needs Spark's operators to be reachable. So the moment you need an exit + is the moment you can no longer collect it. If this server's storage is lost while the + operators are gone, the data goes with it and those leaves can never be exited. A + backup taken now is not an alternative to exiting — it is what keeps exiting possible at all. + Export it, keep the copy somewhere safe, and an exit built from it goes ahead with the operators gone.

@DescribeTxStatus(tx.Status) - @if (tx.Status is SparkExitTxStatus.Unverified) + @if (tx.Status.Readiness is SparkExitTxReadiness.Unverified) {
No chain service answered. Check it yourself before treating it as - either. + either. If it stays unverified, build this exit again once your + chain service is healthy — the SDK's own guidance for this case, + because a rebuild re-reads the chain rather than guessing.
}
@@ -222,9 +218,9 @@
- Every transaction in the tree is priced at this rate, and the tree is many - transactions. A rate that will not confirm strands the exit half-broadcast; a rate - far above the mempool wastes your own funding. + Set the fee rate to use for the entire exit chain. Be sure to set it higher than + the current mempool minimum, as it will be used for the entire exit chain over the + course of several hours or even days.
@@ -235,9 +231,8 @@ id="SparkExitDestination" />
- Where the recovered coins end up. It is signed into the final transaction and - cannot be changed afterwards — an exit built to the wrong address has to - be abandoned and re-funded from scratch. + The address your funds will end up in. Please be sure to choose an address you have + proper access to, as changing this address after starting the exit will be costly!
diff --git a/e2e/btcpay/docker-compose.yml b/e2e/btcpay/docker-compose.yml index 4ce6c8a..bf81a7c 100644 --- a/e2e/btcpay/docker-compose.yml +++ b/e2e/btcpay/docker-compose.yml @@ -96,6 +96,12 @@ services: # is the --docker descriptor, mounted below; it carries in-network addresses, so it is only # meaningful to a process on this network. SPARK_LOCAL_REGTEST_NETWORK: /datadir/spark-network.json + # The experimental unilateral exit, on for this stack on purpose. It is off by default and every + # exit route 404s without it, so leaving it unset would mean the one stack that runs the real + # product against real operators is also the one where the exit cannot be seen at all — and the + # exit is the feature whose whole point is that it has to work when nothing else does. Nothing + # here moves money that the suite did not ask for: quoting is read-only, and a build only signs. + FLINT_EXPERIMENTAL_UNILATERAL_EXIT: "1" ports: # Loopback only. The tests drive it from the host over this port. - "127.0.0.1:${FLINT_BTCPAY_E2E_PORT:-14142}:49392" From b0e6b75554f8d5b2cb2325f9575921f46cd44a04 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:52:25 -0400 Subject: [PATCH 18/22] Render the exit page's states to HTML for copy and layout review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exit is the one screen in this plugin where the wording is load-bearing — it is read by someone whose operators have stopped answering — and it is also the hardest screen to look at: the routes 404 unless the experimental gate is on, and the states that matter (a quote that refused, an exit waiting on funding) only exist after real money has moved. So render them directly instead. Seven facts execute the plugin's own compiled pages over the view models SparkController actually projects, with the real view components, and write standalone HTML carrying BTCPay's own stylesheets. That makes every state reviewable in a browser with no login, no funding and no gate, and re-reviewable after the next copy edit without re-deriving any of this. Its assertions are only 'HTML was produced and is non-empty' — it is a review tool, not a behavioural test, and it should be read that way. Delete it if the maintenance weight ever outweighs that. --- .../LocalRegtest/RenderExitScreensTests.cs | 966 ++++++++++++++++++ 1 file changed, 966 insertions(+) create mode 100644 BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs diff --git a/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs new file mode 100644 index 0000000..576dafd --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs @@ -0,0 +1,966 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection; +using System.Security.Claims; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Services; +using BTCPayServer.Data; +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 BTCPayServer.Security; +using Ganss.Xss; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Razor; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Razor.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Xunit; + +namespace BTCPayServer.Plugins.Flint.Tests.LocalRegtest; + +/// +/// Writes the unilateral-exit page and the Advanced page's exit-state backup section out as standalone HTML +/// files, so the copy and the layout can be read in a browser without a logged-in BTCPay. +/// +/// +/// +/// This is a developer tool, not a behavioural test. Nothing here asserts what the pages say — the +/// assertions are "the page rendered and the file is not empty", because the artefact is the file. Everything +/// about the exit flow's behaviour is pinned in and +/// ; what those cannot do is show a human what the page looks +/// like, which is the whole reason this file exists. +/// +/// +/// The pages are the real compiled Razor views, executed the way +/// executes the setup partials: discovered as the +/// the assemblies carry, instantiated, and run against a real +/// . What surrounds them is MVC's own machinery rather than a transcription of +/// it — AddMvcCore().AddViews() supplies the production IHtmlGenerator (every asp-for, +/// asp-validation-for, label and form on these pages), the production model metadata, the view buffer +/// scope the tag-helper pipeline writes through, and the production view-component invoker, so the markup in +/// the files is generated by the same code that generates it in BTCPay. +/// +/// +/// What is stood in, and why. There is no HTTP request and no host process, so three things are +/// supplied that a real request would get for free, and each one is named here rather than left to be +/// discovered: +/// +/// +/// +/// +/// Routing. composes the plugin's own route shape +/// (/plugins/{storeId}/spark/…) from the action names the views pass their tag helpers. It is not a +/// router and does not pretend to be one: it throws on any action this file has not been told about. Every +/// generated href/action is therefore the URL a real request would have produced, without a +/// route table. +/// +/// +/// +/// +/// Authorisation. grants +/// CanModifyStoreSettings, which is the role the pages are written for: the screenshots are meant to +/// show the operator's view, including the signed-hex table the permission attribute guards. +/// +/// +/// +/// +/// Localisation. is the one fact the localiser plumbing reads off +/// the host. No resource files are found for it, so StringLocalizer returns the English keys the views +/// already carry — which is what a request to an untranslated BTCPay gets. +/// +/// +/// +/// +/// View lookup. answers the names a view engine is asked for +/// (_StatusMessage, Components/TitleHeader/Default) out of the compiled views the two assemblies +/// carry, by the identifier the Razor compiler recorded. There is no file system in play, and a name it cannot +/// place fails with the identifiers it tried rather than rendering a blank space. +/// +/// +/// +/// +/// Nothing else is stubbed: vc:title-header, vc:truncate-center and the vc:icon inside +/// the latter go through MVC's own view-component invoker over the real component classes rendering their real +/// Components/*/Default.cshtml, the _StatusMessage partial is core's own compiled view (so a +/// refusal queued in TempData — the "nothing worth exiting" state — renders as the same alert the +/// operator would see), and the tag helpers are MVC's. +/// +/// +/// One class, in the serialised collection. The exit feature is behind a process-wide environment +/// switch and the Advanced page's backup section only renders behind it, so the class joins +/// and every test restores the variable in a finally. +/// +/// +/// Run it with dotnet test --filter "FullyQualifiedName~RenderExitScreensTests"; the files land in +/// and are inert (they link BTCPay's own stylesheets by +/// absolute URL, so they want the server this repo's checkout already runs). +/// +/// +[Collection(UnilateralExitTestCollection.Name)] +public class RenderExitScreensTests +{ + private const string Store = SparkSurfaceHarness.AttackerStore; + private const string Gate = "FLINT_EXPERIMENTAL_UNILATERAL_EXIT"; + + /// A regtest address, so a destination in a screen reads like one a merchant would type. + private const string Destination = "bcrt1qt8hufshrz62z5vj4q40uqx6c6ytlujy5s03gwm"; + + /// + /// The pre-quote disclosure: acknowledged by nobody, nothing in flight, a balance to quote against. + /// + /// + /// The order is the feature: the disclosure and its acknowledgement come before any balance, fee field or + /// address input, so this is the screen that has to be read for whether that is still true. + /// + [Fact] + public async Task The_disclosure_screen_renders() + { + using var gate = FeatureGate(); + + var html = await RenderExitScreenAsync( + Page(disclosureAcknowledged: false, balanceSats: 250_000)); + + WriteScreen("01-disclosure.html", html); + } + + /// + /// The quote form, opening at the rate the service recommended. + /// + [Fact] + public async Task The_quote_form_renders_at_the_recommended_rate() + { + using var gate = FeatureGate(); + + var html = await RenderExitScreenAsync( + Page(balanceSats: 900_000, recommendedFeeRateSatPerVbyte: 3)); + + WriteScreen("02-quote-form.html", html); + } + + /// + /// The same form with no recommendation to open at — a regtest server with no explorer override, or an + /// explorer that could not be read. The field falls back to the plugin's own floor rather than being blank. + /// + [Fact] + public async Task The_quote_form_renders_without_a_recommendation() + { + using var gate = FeatureGate(); + + var html = await RenderExitScreenAsync( + Page(balanceSats: 900_000, recommendedFeeRateSatPerVbyte: null)); + + WriteScreen("03-quote-form-no-recommendation.html", html); + } + + /// + /// A quote that came back with nothing worth exiting. The refusal is the service's own sentence, carried + /// into the page exactly as the controller carries it — through TempData, which is what the + /// _StatusMessage partial reads. + /// + [Fact] + public async Task The_nothing_worth_exiting_screen_renders() + { + using var gate = FeatureGate(); + + var html = await RenderExitScreenAsync( + Page(balanceSats: 900_000, recommendedFeeRateSatPerVbyte: 3), + errorMessage: SparkUnilateralExitService.NothingWorthExiting); + + WriteScreen("04-nothing-worth-exiting.html", html); + } + + /// + /// A quoted exit waiting for its funding: the funding address, the amount and the rate the record was + /// quoted at. + /// + /// + /// The live recommendation is deliberately a different number from the record's (7 against the record's + /// 12). The field has to show the record's, because that is the rate the leaves in front of the operator + /// were priced at, and a screen where the two agree proves nothing about which one won. + /// + [Fact] + public async Task The_awaiting_funding_screen_renders() + { + using var gate = FeatureGate(); + + var record = AwaitingFunding(); + var html = await RenderExitScreenAsync( + Page( + balanceSats: 900_000, + recommendedFeeRateSatPerVbyte: 7, + activeRecord: record, + // The explorer answered and nothing has arrived yet: zero, not null. Null is "nobody knows", + // and the page says so differently. + fundingReceivedSat: 0, + fundingLargestOutputSat: 0, + leafCount: 2, + fundingKeyPath: "m/84'/1'/4607060'/0/3")); + + WriteScreen("05-awaiting-funding.html", html); + } + + /// The Advanced page with an exit-state backup stored. + [Fact] + public async Task The_advanced_backup_screen_renders_when_one_is_stored() + { + using var gate = FeatureGate(); + + var html = await RenderAdvancedScreenAsync(storedBackup: true); + + WriteScreen("06-advanced-backup.html", html); + } + + /// The same section with nothing stored — the state most stores are in. + [Fact] + public async Task The_advanced_backup_screen_renders_when_none_is_stored() + { + using var gate = FeatureGate(); + + var html = await RenderAdvancedScreenAsync(storedBackup: false); + + WriteScreen("07-advanced-backup-none.html", html); + } + + #region Fixtures + + /// + /// One service read, with every field named, exactly as builds it. + /// + /// + /// Named optional parameters rather than a positional constructor call, so a field added to the record does + /// not silently shift what an existing screen was showing. + /// + private static UnilateralExitPageData Page( + bool walletRunning = true, + bool disclosureAcknowledged = true, + long balanceSats = 0, + long? recommendedFeeRateSatPerVbyte = null, + UnilateralExitRecord? activeRecord = null, + IReadOnlyList? history = null, + long? fundingReceivedSat = null, + long? fundingLargestOutputSat = null, + int? leafCount = null, + string? fundingKeyPath = null, + IReadOnlyList? transactions = null, + bool transactionsUnreadable = false, + IReadOnlyList? pendingBroadcast = null) => + new( + walletRunning, + disclosureAcknowledged, + balanceSats, + recommendedFeeRateSatPerVbyte, + activeRecord, + history ?? [], + fundingReceivedSat, + fundingLargestOutputSat, + leafCount, + fundingKeyPath, + transactions, + transactionsUnreadable, + pendingBroadcast); + + /// + /// A quoted exit waiting for its funding, at 12 sat/vB — a rate no explorer would have recommended, which + /// is the point of it. + /// + private static UnilateralExitRecord AwaitingFunding() => new() + { + Id = "record-7", + StoreId = Store, + Status = UnilateralExitStatus.AwaitingFunding, + // A plausible quote time rather than the epoch the unit-test fixture uses: these screens are read by a + // human, and a 1970 date reads as a broken page instead of as the state being shown. + CreatedUtc = new DateTimeOffset(2026, 9, 14, 9, 12, 0, TimeSpan.Zero), + UpdatedUtc = new DateTimeOffset(2026, 9, 14, 9, 12, 0, TimeSpan.Zero), + DestinationAddress = Destination, + FeeRateSatPerVbyte = 12, + RecoverableValueSat = 400_000, + TotalFeeSat = 9_000, + SingleUtxoFundingSat = 4_300, + FundingAddress = "bcrt1q0lxqmm5qkcvpq7jqmkmvsp0hv7jwqfq38nhdqf" + }; + + /// + /// The exit page's model, projected by the controller — the same call a browser's GET makes. + /// + /// + /// Deliberately not hand-built: owns the projection (including which rate the + /// field opens at, and that a record's destination and rate win over anything the form was posted with), + /// so the screens are of what the page actually receives rather than of what this file believes it would. + /// + private static async Task RenderExitScreenAsync( + UnilateralExitPageData page, string? errorMessage = null) + { + var h = SparkSurfaceHarness.Create( + configureAttackerStore: true, unilateralExit: new ScreenExitService(page)); + + if (errorMessage is not null) + { + // The controller's own channel on a refusal, and the only place the page gets its copy from. + h.Mvc.TempData[WellKnownTempData.ErrorMessage] = errorMessage; + } + + var view = Assert.IsType(await h.Mvc.Exit(Store, CancellationToken.None)); + var model = Assert.IsType(view.Model); + + return await ExitScreenRenderer.RenderAsync( + ExitScreenRenderer.ExitItemPath, model, h.Mvc.TempData); + } + + /// + /// The Advanced page's model, projected by the controller, with the stored backup set the way a saved + /// backup would leave it. + /// + private static async Task RenderAdvancedScreenAsync(bool storedBackup) + { + var h = SparkSurfaceHarness.Create(configureAttackerStore: true); + + // Presence is all the page is told, and all it can be: the blob itself is never written into the model. + h.Settings.Settings[Store]!.UnilateralExit.ExitStateBackup = + storedBackup ? """{"version":1,"leaves":[]}""" : null; + + var view = Assert.IsType(await h.Mvc.Advanced(Store, CancellationToken.None)); + var model = Assert.IsType(view.Model); + + return await ExitScreenRenderer.RenderAsync( + ExitScreenRenderer.AdvancedItemPath, model, h.Mvc.TempData); + } + + /// Writes one screen to the output directory, which is the deliverable. + private static void WriteScreen(string fileName, string html) + { + Directory.CreateDirectory(ExitScreenRenderer.OutputDirectory); + File.WriteAllText(Path.Combine(ExitScreenRenderer.OutputDirectory, fileName), html); + + Assert.False(string.IsNullOrWhiteSpace(html)); + } + + /// + /// Sets the feature switch for one test and puts back whatever was there. + /// + /// + /// The switch is process-wide and Constants.UnilateralExitEnabled is a property precisely so this + /// works. Both pages here need it on: the controller refuses to build the exit model without it, and the + /// Advanced page renders no backup section at all. + /// + private static IDisposable FeatureGate() => new EnvironmentSwitch(Gate, "1"); + + 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 reads: whatever this file says, and nothing else — the write methods are not + /// reachable from a GET and say so rather than returning a plausible success. + /// + private sealed class ScreenExitService(UnilateralExitPageData page) : ISparkUnilateralExitService + { + public Task ReadAsync( + string storeId, CancellationToken cancellationToken = default) => + Task.FromResult(page); + + public Task AcknowledgeDisclosureAsync( + string storeId, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task QuoteAsync( + string storeId, long feeRateSatPerVbyte, string destinationAddress, + CancellationToken cancellationToken = default) => NotAWrite(); + + public Task BuildAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task AbandonAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task MarkCompletedAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task CheckAsync( + string storeId, string recordId, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task SetExitStateBackupAsync( + string storeId, string? exitState, CancellationToken cancellationToken = default) => NotAWrite(); + + public Task SetExplorerUrlAsync( + string storeId, string? esploraApiUrl, CancellationToken cancellationToken = default) => NotAWrite(); + + private static Task NotAWrite() => throw new NotSupportedException( + "These screens are rendered from a GET. Only ReadAsync is reachable, and a screen that reaches " + + "for a write is not a screen this tool can render."); + } + + #endregion +} + +/// +/// Executes the plugin's compiled pages outside a request and wraps the result in the same document skeleton +/// BTCPay's own layout produces, so the files can be opened (or screenshotted) as they are. +/// +/// +/// +/// The skeleton is deliberately not a copy of _Layout.cshtml — that layout needs a signed-in user, a +/// navigation tree and a store, none of which exist without a request. What it reproduces is the three things +/// the styling hangs off: the four stylesheets LayoutHead links (bootstrap, layout, +/// site and the OpenSans face), the body class the layout puts on <body>, and the +/// <main id="mainContent"><section> wrapper layout.css takes its content padding and +/// max-width from. With those, the typography, tables, alerts, forms and the col-xl-8 column all lay out +/// as they do in BTCPay. +/// +/// +/// The links are absolute URLs to a running BTCPay rather than ~/main/… paths, because a file on disk has +/// no app root to resolve those against. The theme file is the light one the layout links through +/// LayoutHeadTheme — it is where BTCPay's colours and type scale live, so without it the alerts and +/// headings render as a browser default. Its dark sibling is left out on purpose: the layout's script is what +/// swaps it in, and no script runs here. Nothing else is loaded: no JS, no vendor CSS, no navigation. +/// +/// +internal static class ExitScreenRenderer +{ + /// Where the screens are written. Cleared of nothing — one file per screen, overwritten in place. + public const string OutputDirectory = "/tmp/flint-screens"; + + /// The compiled item path of the unilateral-exit page. + public const string ExitItemPath = "/Views/Spark/Exit.cshtml"; + + /// The compiled item path of the Advanced page. + public const string AdvancedItemPath = "/Views/Spark/Advanced.cshtml"; + + /// + /// The running BTCPay the splash assets and the plugin's own route prefix are named against, matching the + /// host the local regtest stack serves on. + /// + private const string PublicBaseUrl = "http://127.0.0.1:14142"; + + /// + /// The core assembly: where BTCPay's shared views (the status-message partial) and its view components + /// (the title header, the truncated value, the icon) are compiled. Referenced through a type that is part + /// of the compile, because an assembly name is a string that can go stale silently. + /// + private static readonly Assembly HostAssembly = typeof(BTCPayServer.ViewDataDictionaryExtensions).Assembly; + + /// The stylesheets _Layout links through LayoutHead, in its order. + private const string Stylesheets = + """ + + + + + + """; + + /// + /// Renders one compiled page over the given model and returns a document that can be opened in a browser. + /// + /// The .cshtml path the Razor compiler recorded, e.g. + /// . + /// The page's view model, as the controller built it. + /// + /// The request's TempData, so a status message the page would have shown after a redirect is on the + /// screen rather than missing. + /// + public static async Task RenderAsync( + string compiledItemPath, object model, ITempDataDictionary tempData) + { + var pageType = CompiledPages.PageType(compiledItemPath) + ?? throw new InvalidOperationException( + $"No compiled page carries the identifier '{compiledItemPath}'. Either the view was renamed or " + + "the assembly it is compiled into is not one this renderer searches."); + + var httpContext = new DefaultHttpContext(); + httpContext.RequestServices = BuildRequestServices(httpContext); + // The same channel core's authorization filter writes on a store-authorized request, and where the + // routed URL helper reads the store id from. + httpContext.SetStoreData(new StoreData { Id = StoreIdFor(model) }); + + var viewData = ViewDataFor(pageType, model); + var writer = new StringWriter(CultureInfo.InvariantCulture); + var viewContext = new ViewContext( + new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), + new CompiledView(pageType, compiledItemPath), + viewData, + tempData, + writer, + new HtmlHelperOptions()) + { + ExecutingFilePath = compiledItemPath + }; + + await viewContext.View.RenderAsync(viewContext); + + return Document(viewData.GetTitle() ?? compiledItemPath, writer.ToString()); + } + + /// Wraps a rendered body in the document skeleton described on the class. + private static string Document(string title, string body) => + $""" + + + + + + {title} + {Stylesheets} + + +
+
+ {body} +
+
+ + + """; + + /// + /// The store id both pages are rendered for, read off the view model rather than passed in, because the + /// pages themselves read it the same way. + /// + private static string StoreIdFor(object model) => model switch + { + SparkExitViewModel exit => exit.StoreId, + SparkAdvancedViewModel advanced => advanced.StoreId, + _ => throw new NotSupportedException( + $"This renderer knows the store id on {nameof(SparkExitViewModel)} and {nameof(SparkAdvancedViewModel)} " + + $"only, and was handed a {model.GetType().Name}.") + }; + + /// + /// The page's view data, in the exact generic type the page declares — the compiled page casts + /// ViewData to ViewDataDictionary<TModel> on every read, so an untyped dictionary is an + /// exception rather than a render. + /// + private static ViewDataDictionary ViewDataFor(Type pageType, object model) + { + var modelType = pageType.BaseType is { IsGenericType: true } baseType + ? baseType.GetGenericArguments()[0] + : throw new InvalidOperationException( + $"The compiled page {pageType.Name} does not derive from a generic RazorPage, which is not a " + + "shape this renderer knows how to feed."); + + var viewData = (ViewDataDictionary)Activator.CreateInstance( + typeof(ViewDataDictionary<>).MakeGenericType(modelType), + new EmptyModelMetadataProvider(), + new ModelStateDictionary())!; + viewData.Model = model; + return viewData; + } + + /// + /// The request's service provider: MVC's own view-features services, plus the handful of things a request + /// would have had from the host rather than from MVC. + /// + /// + /// Every override is registered after MVC's own registration so it is the one resolved; the + /// order is load-bearing and MVC registers all of these with TryAdd. + /// + private static ServiceProvider BuildRequestServices(HttpContext httpContext) + { + var services = new ServiceCollection(); + + // The production generator (every asp-for, label, validation message and form on these pages), the + // production model metadata and expression provider, a memory-pooled view buffer scope for the + // tag-helper pipeline, the default view-component machinery, and the TempData dictionary factory. + var mvc = services.AddMvcCore().AddViews().AddViewLocalization(); + // The view components the pages call (the title header, the truncated value, the icon inside it) are + // discovered by MVC from the application's parts, so both assemblies that carry these pages are + // declared as parts — the same registration BTCPay's own plugin loader makes for a plugin assembly. + mvc.PartManager.ApplicationParts.Add(new AssemblyPart(typeof(SparkPlugin).Assembly)); + mvc.PartManager.ApplicationParts.Add(new AssemblyPart(HostAssembly)); + services.AddLocalization(); + // Core's `_ViewImports` injects the concrete ViewLocalizer, and BTCPay registers it itself + // (TranslationsPlugin); the same registration here, over the framework's HTML localizer factory. + services.TryAddSingleton(); + + // The non-generic localizer core's views inject, rooted at the assembly those views live in. No + // resource files exist for it, so every lookup answers with the key the view already carries — which is + // what a request to an untranslated BTCPay gets. + services.TryAddSingleton(sp => sp.GetRequiredService() + .Create(typeof(BTCPayServer.ViewDataDictionaryExtensions))); + + // The tag-helper factory and its activator are internal to MVC and are instantiated here the way MVC's + // DI does it, for the reason SetupTabViewRenderer gives: these views get exactly the tag helpers a real + // request would build for them, including the generated ones behind `vc:`. + services.AddSingleton(CreateDefaultTagHelperFactory()); + + // One listener, serving both the DiagnosticListener a view component asks for and the DiagnosticSource + // the page's own [RazorInject] property asks for. + var diagnostics = new DiagnosticListener("BTCPayServer.Plugins.Flint.Screens"); + services.AddSingleton(diagnostics); + services.AddSingleton(diagnostics); + + // What BTCPay's own container supplies on a real request: the sanitizing HTML helper, the permission + // service behind the `permission` attribute, the context accessor, and the host. + services.AddSingleton(sp => new Safe( + sp.GetRequiredService(), + sp.GetRequiredService(), + new HtmlSanitizer())); + services.AddSingleton(); + services.AddSingleton(new FixedHttpContextAccessor(httpContext)); + services.AddSingleton(); + + // The two stand-ins the class remarks name: route composition, and view lookup over the compiled views + // of the two assemblies above rather than over a file system. + services.AddSingleton(); + services.AddSingleton(); + + return services.BuildServiceProvider(); + } + + /// + /// The production over the production activator, both internal and + /// therefore reflection-instantiated rather than reimplemented. + /// + private static ITagHelperFactory CreateDefaultTagHelperFactory() + { + var assembly = typeof(ITagHelperFactory).Assembly; + var activatorType = assembly.GetType( + "Microsoft.AspNetCore.Mvc.Razor.Infrastructure.DefaultTagHelperActivator", throwOnError: true)!; + var factoryType = assembly.GetType( + "Microsoft.AspNetCore.Mvc.Razor.DefaultTagHelperFactory", throwOnError: true)!; + return (ITagHelperFactory)Activator.CreateInstance( + factoryType, Activator.CreateInstance(activatorType))!; + } + + /// + /// Executes one compiled page the way the Razor view engine does: a fresh instance per render, its + /// [RazorInject] properties filled from the request's services, then the page's own body. + /// + internal static async Task ExecuteAsync(Type pageType, string itemPath, ViewContext viewContext) + { + var page = (IRazorPage)Activator.CreateInstance(pageType)!; + page.Path = itemPath; + page.ViewContext = viewContext; + ActivateInjects(page, viewContext, itemPath); + + await page.ExecuteAsync(); + } + + /// + /// Fills a page's [RazorInject] properties. + /// + /// + /// + /// This is RazorPagePropertyActivator's contract, not an invention: a service is required for every + /// injected property, IViewContextAware services are contextualized with the view context they were + /// injected into, ViewData comes from the context rather than from DI, and a ViewDataDictionary + /// of any other shape than the page's own is an error rather than a silent null. + /// + /// + /// The attribute is matched by name because RazorInjectAttribute is internal to MVC, and every + /// property it decorates is here on purpose: a view that grows an injection this renderer cannot satisfy + /// fails with the property's name in the message rather than rendering something missing. + /// + /// + private static void ActivateInjects(IRazorPage page, ViewContext viewContext, string itemPath) + { + foreach (var property in page.GetType().GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + if (!IsRazorInject(property)) + continue; + + if (!property.CanWrite) + { + throw new InvalidOperationException( + $"{page.GetType().Name}.{property.Name} is an injected property with no setter, so the page " + + $"at '{itemPath}' cannot be activated."); + } + + object? value; + if (typeof(ViewDataDictionary).IsAssignableFrom(property.PropertyType)) + { + value = viewContext.ViewData; + } + else if (property.PropertyType == typeof(IUrlHelper)) + { + value = viewContext.HttpContext.RequestServices + .GetRequiredService().GetUrlHelper(viewContext); + } + else + { + value = viewContext.HttpContext.RequestServices.GetService(property.PropertyType) + ?? throw new InvalidOperationException( + $"The page at '{itemPath}' injects {property.PropertyType.FullName} into " + + $"{property.Name}, and nothing in this renderer's services supplies it. Add it to " + + "BuildRequestServices rather than to the view."); + } + + (value as IViewContextAware)?.Contextualize(viewContext); + property.SetValue(page, value); + } + } + + private static bool IsRazorInject(PropertyInfo property) => + property.GetCustomAttributes(inherit: true) + .Any(attribute => attribute.GetType().FullName == "Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute"); + + /// + /// One compiled .cshtml in one assembly, executed when the view engine asks for it. + /// + private sealed class CompiledView(Type pageType, string itemPath) : IView + { + public string Path { get; } = itemPath; + + public Task RenderAsync(ViewContext viewContext) => ExecuteAsync(pageType, Path, viewContext); + } + + /// + /// Finds a compiled page by the name a view engine would have been given for it. + /// + /// + /// The names are the same ones the Razor view engine searches for: a component view arrives as + /// Components/{Component}/Default, a partial as _StatusMessage, and both are then looked for + /// at the identifier the compiler recorded. There is no file system in play, so the searched locations are + /// the identifiers actually tried. + /// + private sealed class CompiledViewEngine : ICompositeViewEngine + { + /// No nested engines: this one serves the handful of compiled views the plugin's pages call. + public IReadOnlyList ViewEngines => []; + + public ViewEngineResult GetView(string? executingFilePath, string viewPath, bool isMainPage) => + Find(viewPath); + + public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage) => + Find(viewName); + + public ViewEngineResult FindPartialView(ActionContext context, string partialName, bool isMainPage) => + Find(partialName); + + private static ViewEngineResult Find(string viewName) + { + var name = viewName.TrimStart('/'); + if (name.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase)) + name = name[..^".cshtml".Length]; + + var searched = new List + { + $"/{name}.cshtml", + $"/Views/{name}.cshtml", + $"/Views/Shared/{name}.cshtml", + $"/Views/Spark/{name}.cshtml" + }; + + foreach (var identifier in searched) + { + if (CompiledPages.PageType(identifier) is { } pageType) + return ViewEngineResult.Found(viewName, new CompiledView(pageType, identifier)); + } + + return ViewEngineResult.NotFound(viewName, searched); + } + } + + /// + /// The plugin's own route shape, composed from the action names the views pass their tag helpers. + /// + /// + /// routes under plugins/{storeId}/spark with the suffixes below, which + /// is what a real request resolves an asp-action to. Composing them here keeps every generated + /// href and form action looking like the real page's without standing up a route table — and + /// an action this table has never heard of throws rather than rendering a link to nowhere. + /// + private sealed class RoutedUrlHelperFactory : IUrlHelperFactory + { + public IUrlHelper GetUrlHelper(ActionContext context) => new RoutedUrlHelper(context); + } + + private sealed class RoutedUrlHelper(ActionContext actionContext) : IUrlHelper + { + private static readonly Dictionary SparkActionSuffixes = new(StringComparer.Ordinal) + { + ["Status"] = "status", + ["Setup"] = "setup", + ["Sweep"] = "sweep", + ["Advanced"] = "advanced", + ["Deposit"] = "deposit", + ["Exit"] = "exit", + ["Remove"] = "remove", + ["StableBalance"] = "stable-balance", + ["AcknowledgeExit"] = "exit/acknowledge", + ["QuoteExit"] = "exit/quote", + ["BuildExit"] = "exit/build", + ["CheckExit"] = "exit/check", + ["AbandonExit"] = "exit/abandon", + ["CompleteExit"] = "exit/complete", + ["SetExitExplorer"] = "exit/explorer", + ["AdvancedSweep"] = "advanced/sweep", + ["AdvancedApiKey"] = "advanced/api-key", + ["ExportExitState"] = "advanced/exit-state/export", + ["SetExitStateBackup"] = "advanced/exit-state" + }; + + public ActionContext ActionContext { get; } = actionContext; + + /// + /// The one link shape these pages generate, through the extension the tag helpers and + /// DefaultHtmlGenerator both call. + /// + public string? Action(UrlActionContext actionContext) => + Compose(actionContext.Action, actionContext.Values, actionContext.Fragment); + + /// + /// A route-values link: the same composition, because a named route is not something these pages use. + /// + public string? RouteUrl(UrlRouteContext routeContext) + { + if (routeContext.RouteName is not null) + { + throw new NotSupportedException( + $"The rendered screens must not link by route name ('{routeContext.RouteName}'); they use " + + "asp-action, which arrives here as route values."); + } + + return Compose(null, routeContext.Values, routeContext.Fragment); + } + + /// + /// A content path: ~/ is the app root, which a file on disk does not have, so it resolves to the + /// running BTCPay the splash assets are served from. Anything else is already a path. + /// + public string Content(string? contentPath) => contentPath switch + { + null => string.Empty, + { Length: 0 } => string.Empty, + _ when contentPath.StartsWith("~/", StringComparison.Ordinal) => PublicBaseUrl + contentPath[1..], + _ => contentPath + }; + + public string? Link(string? routeName, object? values) => Unused(nameof(Link)); + + public bool IsLocalUrl(string? url) => Unused(nameof(IsLocalUrl)); + + /// + /// /plugins/{storeId}/spark/{suffix}, with the store id the view passed — or, failing that, the + /// one the request was authorized for, which is how a real request's route values are filled. + /// + private string Compose(string? action, object? values, string? fragment) + { + if (action is null) + { + throw new NotSupportedException( + $"A link with no action was rendered ({new RouteValueDictionary(values).Count} route values); " + + "these pages only generate asp-action links, and a guessed URL would be a broken one."); + } + + if (!SparkActionSuffixes.TryGetValue(action, out var suffix)) + { + throw new NotSupportedException( + $"The rendered screens must not link to SparkController.{action}; add its route suffix to " + + "SparkActionSuffixes deliberately if a page starts offering it."); + } + + var routeValues = new RouteValueDictionary(values); + var storeId = routeValues["storeId"] as string + ?? ActionContext.HttpContext.GetStoreDataOrNull()?.Id + ?? throw new InvalidOperationException( + $"A link to SparkController.{action} was rendered without a store id on either " + + "the route values or the request."); + + return $"{PublicBaseUrl}/plugins/{storeId}/spark/{suffix}{fragment}"; + } + + private static T Unused(string member) => throw new NotSupportedException( + $"The rendered exit screens must not call IUrlHelper.{member}; extend RoutedUrlHelper deliberately " + + "if a page starts needing it."); + } + + /// + /// The compiled pages of the two assemblies that carry them, found once and remembered. + /// + /// + /// Discovery is MVC's own: reads the + /// markers the Razor compiler writes, which is the same + /// mechanism the view engine uses to find a view at runtime. The plugin's pages and core's shared views are + /// both in reach from here, and the returned type is the real compiled page, never a transcription of it. + /// + private static class CompiledPages + { + private static readonly Lazy> ByIdentifier = new(() => + { + var loader = new RazorCompiledItemLoader(); + var pages = new Dictionary(StringComparer.Ordinal); + foreach (var assembly in new[] { typeof(SparkPlugin).Assembly, HostAssembly }) + { + foreach (var item in loader.LoadItems(assembly)) + { + if (item.Kind == "mvc.1.0.view" || item.Kind == "mvc.1.0.razor-page") + pages[item.Identifier] = item.Type; + } + } + + return pages; + }); + + public static Type? PageType(string identifier) => + ByIdentifier.Value.TryGetValue(identifier, out var pageType) ? pageType : null; + } + + /// Grants every permission check, so a screen shows the page, not who is looking at it. + private sealed class GrantAllAuthorizationService : IAuthorizationService + { + public Task AuthorizeAsync( + ClaimsPrincipal user, object? resource, IEnumerable requirements) => + Task.FromResult(AuthorizationResult.Success()); + + public Task AuthorizeAsync( + ClaimsPrincipal user, object? resource, string policyName) => + Task.FromResult(AuthorizationResult.Success()); + } + + /// Hands the permission helper the very context the render is running over. + private sealed class FixedHttpContextAccessor(HttpContext httpContext) : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = httpContext; + } + + /// + /// The one fact the localiser plumbing reads off a host. There is no host process here, so it is named + /// rather than discovered; no resource files exist for it, which leaves the views' own English keys in + /// place. + /// + private sealed class ScreenHostEnvironment : IWebHostEnvironment + { + public string ApplicationName { get; set; } = "BTCPayServer"; + + public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider(); + + public string WebRootPath { get; set; } = string.Empty; + + public string EnvironmentName { get; set; } = Environments.Development; + + public string ContentRootPath { get; set; } = string.Empty; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} \ No newline at end of file From 9889f5b26e086121d72917b552ddd2301f3be705 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:24:48 -0400 Subject: [PATCH 19/22] Take the exit-state backup automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backup was the operator's job: press Export, keep the copy, remember to do it again after anything arrives. That is exactly the job an always-on server should be doing, and the moment it matters most is the moment nobody is there to do it — the data an exit is built from needs Spark's operators to be reachable to collect, so the moment you need an exit is the moment you can no longer collect it. The plugin was already being told when leaves appear and throwing it away: the SDK event listener maps NewDeposits and ClaimedDeposits and the consumer only logged ClaimedDeposits. Those, plus any inbound payment, now request a refresh; a two-minute debounce coalesces the bursts an event stream produces, and an hourly safety net covers the channel being bounded and the code already documenting events as unreliable in both directions. Nothing is rewritten when the exported bytes are unchanged, because each write is multi-megabyte. The backup is a secret that grows with the wallet and it now gets written repeatedly, so it no longer lives in the store's JSON settings column, which is read in full on every settings read. It is one owner-only file per store under the data directory, written temp-then-rename so a half-written backup can never be the thing that gets imported, and a store upgrading from the old plugin has its stored blob imported, moved to the file, and the setting cleared — in that order, so the only copy of an exit cannot be lost to the migration. The Advanced page stops asking the operator to do it and starts showing them how current the automation is: the time it was last written, and a Download that hands over the stored copy. That copy is on the same server as the wallet, and the copy says so — a backup that dies with the machine is not a backup, and the page should not imply otherwise. Exporting by hand still works and now also refreshes the stored copy, so the bytes in the operator's clipboard and the bytes on disk cannot disagree. --- .../ExitStateBackupSchedulerTests.cs | 186 ++++++++++ .../Fakes/FakeExitStateBackupStore.cs | 66 ++++ .../Fakes/SparkServiceHarness.cs | 48 ++- .../Fakes/SparkSurfaceHarness.cs | 20 +- .../FileExitStateBackupStoreTests.cs | 157 ++++++++ .../LocalRegtest/RenderExitScreensTests.cs | 11 +- .../SparkExitStateAutoBackupTests.cs | 351 ++++++++++++++++++ .../SparkServiceStartupTests.cs | 68 +++- .../SparkUnilateralExitServiceTests.cs | 96 +++++ BTCPayServer.Plugins.Flint/Constants.cs | 11 + .../Controllers/SparkController.cs | 86 ++++- .../Models/SparkAdvancedViewModel.cs | 19 +- .../Models/SparkExitViewModel.cs | 20 - .../Services/ExitStateBackupScheduler.cs | 229 ++++++++++++ .../Services/ExitStateBackupTask.cs | 36 ++ .../Services/FileExitStateBackupStore.cs | 176 +++++++++ .../Services/IExitStateBackupStore.cs | 60 +++ .../Services/ISparkUnilateralExitService.cs | 4 +- .../Services/SparkService.cs | 253 ++++++++++++- .../Services/SparkUnilateralExitService.cs | 77 +++- BTCPayServer.Plugins.Flint/SparkPlugin.cs | 14 + BTCPayServer.Plugins.Flint/SparkSettings.cs | 28 +- .../Views/Spark/Advanced.cshtml | 63 ++-- 23 files changed, 1963 insertions(+), 116 deletions(-) create mode 100644 BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs create mode 100644 BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs create mode 100644 BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs create mode 100644 BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs create mode 100644 BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs create mode 100644 BTCPayServer.Plugins.Flint/Services/ExitStateBackupTask.cs create mode 100644 BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs create mode 100644 BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs diff --git a/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs b/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs new file mode 100644 index 0000000..01f2a86 --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs @@ -0,0 +1,186 @@ +using BTCPayServer.Plugins.Flint.Services; +using Xunit; + +namespace BTCPayServer.Plugins.Flint.Tests; + +/// +/// The cadence rules of the automatic exit-state backup: a coalescing debounce, a one-hour safety net, +/// and a content hash that keeps an unchanged state from being rewritten. +/// +/// +/// +/// Pure scheduler, no harness: every decision takes its now as an argument, and the one clock the +/// scheduler reads itself — the moment records — is +/// exposed by PendingSince precisely so these assertions run against the value the decisions +/// actually use rather than a second reading of the wall. +/// +/// +/// These are the decisions the whole feature is made of; the IO and the wiring are covered where they +/// live, in FileExitStateBackupStoreTests and SparkExitStateAutoBackupTests. +/// +/// +public class ExitStateBackupSchedulerTests +{ + private const string Store = "store-1"; + + private static readonly DateTimeOffset Base = new(2026, 9, 16, 12, 0, 0, TimeSpan.Zero); + + private static TimeSpan Min(int n) => TimeSpan.FromMinutes(n); + + /// The moment a pending request was recorded, or fail — the debounce has no other start. + private static DateTimeOffset Pending(ExitStateBackupScheduler s) + { + var since = s.PendingSince(Store); + Assert.NotNull(since); + return since.Value; + } + + // ------------------------------------------------------------------ + // The debounce: a burst becomes one window, and a later event cannot move it + // ------------------------------------------------------------------ + + [Fact] + public void A_request_is_not_acted_on_until_the_debounce_interval_has_passed() + { + var s = new ExitStateBackupScheduler(); + + // An existing backup anchors the safety net — a store that has never had one is due at once, + // request or no request, and would answer "yes" here whatever the debounce said. + s.RequestRefresh(Store); + s.MarkTaken(Store, Pending(s)); + + s.RequestRefresh(Store); + var started = Pending(s); + + Assert.False(s.ShouldTake(Store, started + Min(1))); + Assert.True(s.ShouldTake(Store, started + Min(2))); + } + + [Fact] + public void A_second_request_during_a_pending_window_does_not_move_its_deadline() + { + var s = new ExitStateBackupScheduler(); + s.RequestRefresh(Store); + var started = Pending(s); + + // The coalescing rule, read where it is written: a throttle would push this timestamp + // forward, and a wallet with a steady stream of events would then never schedule a backup. + s.RequestRefresh(Store); + Assert.Equal(started, Pending(s)); + } + + [Fact] + public void Serving_a_pending_request_clears_it_and_a_new_request_starts_its_own_window() + { + var s = new ExitStateBackupScheduler(); + s.RequestRefresh(Store); + var dueAt = Pending(s) + Min(2); + Assert.True(s.ShouldTake(Store, dueAt)); + + s.MarkTaken(Store, dueAt); + Assert.Null(s.PendingSince(Store)); + + s.RequestRefresh(Store); + var second = Pending(s); + + // The new window starts at the new request's own stamp and runs two minutes of its own — + // not at the last take, which is already a past fact by then. + Assert.False(s.ShouldTake(Store, second + Min(1))); + Assert.True(s.ShouldTake(Store, second + Min(2))); + } + + // ------------------------------------------------------------------ + // The safety net: silence from the event stream is not a reason to stop backing up + // ------------------------------------------------------------------ + + [Fact] + public void A_store_with_nothing_taken_yet_is_due_immediately() + { + var s = new ExitStateBackupScheduler(); + + // No request, no pass, no history: a wallet that has never had a backup is the first thing + // the first pass takes, not one safety-net interval from now. + Assert.True(s.ShouldTake(Store, Base)); + } + + [Fact] + public void The_safety_net_fires_after_its_interval_with_no_event_having_ever_arrived() + { + var s = new ExitStateBackupScheduler(); + s.MarkTaken(Store, Base); + + // Nothing was ever requested — the events went missing in both directions, which this + // codebase has observed the SDK actually do — and the store still gets its next copy. + Assert.False(s.ShouldTake(Store, Base + Min(30))); + Assert.False(s.ShouldTake(Store, Base + Min(59))); + Assert.True(s.ShouldTake(Store, Base + TimeSpan.FromHours(1))); + } + + [Fact] + public void A_pass_that_found_the_state_unchanged_counts_as_a_pass_for_the_safety_net() + { + var s = new ExitStateBackupScheduler(); + + s.RequestRefresh(Store); + var passAt = Pending(s) + Min(2); + s.MarkSkipped(Store, passAt); + + // "A pass happened and nothing changed" leaves the state known-current at this moment: + // the next check is owed an interval from here, not from the last actual write. + Assert.False(s.ShouldTake(Store, passAt + Min(30))); + Assert.False(s.ShouldTake(Store, passAt + Min(59))); + Assert.True(s.ShouldTake(Store, passAt + TimeSpan.FromHours(1))); + } + + [Fact] + public void A_skipped_pass_serves_the_pending_request() + { + var s = new ExitStateBackupScheduler(); + s.RequestRefresh(Store); + s.MarkSkipped(Store, Base); + + Assert.Null(s.PendingSince(Store)); + Assert.False(s.ShouldTake(Store, Base + TimeSpan.FromSeconds(1))); + } + + // ------------------------------------------------------------------ + // The content hash: what makes "due" not mean "written" + // ------------------------------------------------------------------ + + [Fact] + public void An_unknown_stored_state_is_never_reported_unchanged() + { + var s = new ExitStateBackupScheduler(); + + // The state right after a restart: the file may hold anything, and a scheduler that said + // "unchanged" from ignorance would leave a wallet's fresh state unsaved indefinitely. + Assert.False(s.KnowsStoredContent(Store)); + Assert.False(s.ContentUnchanged(Store, "exported-blob")); + } + + [Fact] + public void Content_matching_what_was_recorded_reads_as_unchanged_and_different_content_does_not() + { + var s = new ExitStateBackupScheduler(); + s.NoteStoredContent(Store, "exported-blob"); + + Assert.True(s.KnowsStoredContent(Store)); + Assert.True(s.ContentUnchanged(Store, "exported-blob")); + Assert.False(s.ContentUnchanged(Store, "exported-bloq")); + } + + [Fact] + public void Recording_no_stored_content_is_a_known_state_and_any_export_is_then_a_change() + { + var s = new ExitStateBackupScheduler(); + s.NoteStoredContent(Store, "exported-blob"); + Assert.True(s.ContentUnchanged(Store, "exported-blob")); + + // "The file is absent" is not the same as "nothing has been seeded yet": it is the answer + // the caller gave about the file, and from it any export — including the same one — is a + // change worth writing back. + s.NoteStoredContent(Store, null); + Assert.False(s.KnowsStoredContent(Store)); + Assert.False(s.ContentUnchanged(Store, "exported-blob")); + } +} diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs new file mode 100644 index 0000000..c500754 --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs @@ -0,0 +1,66 @@ +using BTCPayServer.Plugins.Flint.Services; + +namespace BTCPayServer.Plugins.Flint.Tests.Fakes; + +/// +/// In-memory for tests whose subject is a caller of the +/// store — what it writes, what it refuses, and what it leaves untouched on failure. +/// +/// +/// Deliberately not used by anything that tests the store's own behaviour (the layout, the atomic +/// replace, the permissions): those tests run the real over a +/// temp directory, because a fake would be asserting the fake. This one exists so the exit-service +/// tests can watch the calls and script failures without a filesystem in the way. +/// +public sealed class FakeExitStateBackupStore : IExitStateBackupStore +{ + private readonly Dictionary _files = []; + + /// Every store id the method was called with, in call order. + public List ReadCalls { get; } = []; + + /// Every store id the method was called with, in call order. + public List WriteCalls { get; } = []; + + /// Every store id the method was called with, in call order. + public List DeleteCalls { get; } = []; + + /// Thrown by every read while set. + public Exception? FailReadWith { get; set; } + + /// Thrown by every write while set. + public Exception? FailWriteWith { get; set; } + + /// The write time every stored file reports; the fake keeps one stamp for all of them. + public DateTimeOffset? TakenAt { get; set; } + + /// What is stored for a store, or null when nothing is. Reads the subject's own writes. + public string? Stored(string storeId) => _files.GetValueOrDefault(storeId); + + public Task ReadAsync(string storeId, CancellationToken cancellationToken = default) + { + ReadCalls.Add(storeId); + return FailReadWith is { } failure + ? Task.FromException(failure) + : Task.FromResult(Stored(storeId)); + } + + public Task TakenAtAsync(string storeId, CancellationToken cancellationToken = default) => + Task.FromResult(Stored(storeId) is null ? null : TakenAt); + + public Task WriteAsync(string storeId, string backup, CancellationToken cancellationToken = default) + { + WriteCalls.Add(storeId); + if (FailWriteWith is { } failure) + return Task.FromException(failure); + + _files[storeId] = backup; + return Task.CompletedTask; + } + + public Task DeleteAsync(string storeId, CancellationToken cancellationToken = default) + { + DeleteCalls.Add(storeId); + return Task.FromResult(_files.Remove(storeId)); + } +} diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs index 30b88fa..6d3a13c 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs @@ -33,6 +33,7 @@ public sealed class SparkServiceHarness : IDisposable private readonly string _dataDir; private readonly Durable _durable; private readonly Deadlines _deadlines; + private readonly TimeProvider _timeProvider; private bool _ownsDataDir = true; /// @@ -76,7 +77,9 @@ private SparkServiceHarness( string dataDir, Durable durable, Deadlines deadlines, - ChainName chain) + ChainName chain, + TimeProvider timeProvider, + IExitStateBackupStore exitStateBackups) { Service = service; Sdk = sdk; @@ -86,6 +89,8 @@ private SparkServiceHarness( _durable = durable; _deadlines = deadlines; _chain = chain; + _timeProvider = timeProvider; + ExitStateBackups = exitStateBackups; } public SparkService Service { get; } @@ -116,6 +121,13 @@ private SparkServiceHarness( /// The USDC/USDT path this service routes cross-chain receives to. public StablecoinHarness Stablecoins { get; private init; } = null!; + /// + /// The real file-backed exit-state backup store, over the harness's temp data directory. Tests read + /// what was stored through it rather than by re-spelling the path, so a change to the layout moves + /// the test with the production code instead of pinning either. + /// + public IExitStateBackupStore ExitStateBackups { get; } + /// The BTCPay data directory this service was given, which is where its per-store storage lives. public string DataDir => _dataDir; @@ -153,7 +165,8 @@ public static SparkServiceHarness Create( TimeSpan? confirmStatusDeadline = null, TimeSpan? abandonedConnectGrace = null, bool failWalletAdoption = false, - ChainName? chain = null) + ChainName? chain = null, + TimeProvider? timeProvider = null) { var dataDir = Path.Combine( Path.GetTempPath(), "spark-service-tests", Guid.NewGuid().ToString("N")); @@ -175,7 +188,8 @@ public static SparkServiceHarness Create( // shut down; the release-the-lock test shortens it deliberately. abandonedConnectGrace ?? TimeSpan.FromMinutes(5)), chain ?? ChainName.Regtest, - failWalletAdoption); + failWalletAdoption, + timeProvider); } /// @@ -196,12 +210,12 @@ public SparkServiceHarness Restart() { StopService(); _ownsDataDir = false; - return Create(_dataDir, _durable, _deadlines, _chain); + return Create(_dataDir, _durable, _deadlines, _chain, timeProvider: _timeProvider); } private static SparkServiceHarness Create( string dataDir, Durable durable, Deadlines deadlines, ChainName chain, - bool failWalletAdoption = false) + bool failWalletAdoption = false, TimeProvider? timeProvider = null) { var log = new CapturingLogger(); var logs = new Logs(); @@ -239,13 +253,23 @@ private static SparkServiceHarness Create( // event path's routing of a cross-chain receive can be exercised; with no quotes open it changes nothing. StablecoinHarness? stablecoins = null; + // The real file store over the harness's temp data directory — the layout, the owner-only + // creation, and the atomic replace are what the backup tests assert, and a fake would assert + // the fake. One fresh scheduler per construction, as a process restart gets: its whole point is + // that the first pass after a restart re-seeds itself from the file. + var dataDirectories = Options.Create(new DataDirectories { DataDir = dataDir }); + var exitStateBackups = new FileExitStateBackupStore( + dataDirectories, NullLogger.Instance); + var backupScheduler = new ExitStateBackupScheduler(); + var clock = timeProvider ?? TimeProvider.System; + var service = new TestableSparkService( deadlines.Connect, deadlines.ConfirmStatus, deadlines.AbandonedConnectGrace, new BTCPayServer.EventAggregator(logs), stores, - Options.Create(new DataDirectories { DataDir = dataDir }), + dataDirectories, new BTCPayNetworkProvider([], new NBXplorerNetworkProvider(chain), logs), sdk, invoices, @@ -255,10 +279,12 @@ private static SparkServiceHarness Create( protector, wiring, bolt11Parser, - TimeProvider.System, + clock, () => sweeper ?? throw new InvalidOperationException("harness sweep not wired"), () => stablecoins?.Service ?? throw new InvalidOperationException("harness stablecoins not wired"), NullLoggerFactory.Instance, + exitStateBackups, + backupScheduler, log); stablecoins = new StablecoinHarness(service); @@ -269,7 +295,8 @@ private static SparkServiceHarness Create( service, NullLogger.Instance); - return new SparkServiceHarness(service, sdk, broadcaster, log, dataDir, durable, deadlines, chain) + return new SparkServiceHarness( + service, sdk, broadcaster, log, dataDir, durable, deadlines, chain, clock, exitStateBackups) { Stablecoins = stablecoins }; @@ -359,10 +386,13 @@ public TestableSparkService( Func configSweeperFactory, Func stablecoinsFactory, ILoggerFactory loggerFactory, + IExitStateBackupStore exitStateBackupStore, + ExitStateBackupScheduler exitStateBackupScheduler, ILogger logger) : base(eventAggregator, storeRepository, dataDirectories, networkProvider, sdkClientFactory, invoiceStore, outgoingStore, reconciler, broadcaster, mnemonicProtector, lightningWiring, - bolt11Parser, timeProvider, configSweeperFactory, stablecoinsFactory, loggerFactory, logger) + bolt11Parser, timeProvider, configSweeperFactory, stablecoinsFactory, loggerFactory, + exitStateBackupStore, exitStateBackupScheduler, logger) { _connectDeadline = connectDeadline; _confirmStatusDeadline = confirmStatusDeadline; diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs index 0f3e07a..7ea8deb 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkSurfaceHarness.cs @@ -61,9 +61,11 @@ private SparkSurfaceHarness( SparkDepositService depositService, SparkStableBalanceService stableBalanceService, CrossChainCatalog crossChainCatalog, - StubHttpMessageHandler crossChainRequests) + StubHttpMessageHandler crossChainRequests, + FakeExitStateBackupStore exitStateBackups) { CrossChainCatalog = crossChainCatalog; + ExitStateBackups = exitStateBackups; CrossChainRequests = crossChainRequests; Protector = protector; SweepEngine = sweepEngine; @@ -142,6 +144,13 @@ private SparkSurfaceHarness( /// The USDC/USDT path the MVC controller's switch goes through, over in-memory fakes. public StablecoinHarness Stablecoins { get; private init; } = null!; + /// + /// The exit-state backup store the controller reports ExitStateBackupTakenAt from — empty + /// unless a test stores one, which is what a harness that stored nothing should answer. + /// + public FakeExitStateBackupStore ExitStateBackups { get; } + + public FakeSparkSdkClient VictimWallet => (FakeSparkSdkClient)Runtime.Clients[VictimStore]; public FakeSparkSdkClient WalletOf(string storeId) => (FakeSparkSdkClient)Runtime.Clients[storeId]; @@ -277,9 +286,14 @@ public static SparkSurfaceHarness Create( // a plausible-looking empty page. var exit = unilateralExit ?? new UnavailableUnilateralExitService(); + // The Advanced page reads the backup's TakenAt from the same singleton the service writes, so the + // harness hands one instance to both and lets a page test script it. + var exitStateBackups = new FakeExitStateBackupStore(); + var mvc = new SparkController( settings, provisioner, wiring, seedResolver, statusReader, sweepEngine, sweepSettings, - depositService, stableBalanceService, exit, crossChainCatalog, stablecoins.Service, + depositService, stableBalanceService, exit, runtime, exitStateBackups, + crossChainCatalog, stablecoins.Service, new FakeAuthorizationService(), NullLogger.Instance); var api = new GreenfieldSparkController( @@ -296,7 +310,7 @@ public static SparkSurfaceHarness Create( return new SparkSurfaceHarness( mvc, api, settings, lightning, seedReader, sweepRecords, sweepAddresses, runtime, writeLog, provisionerLog, protector, sweepEngine, depositService, stableBalanceService, - crossChainCatalog, crossChainRequests) + crossChainCatalog, crossChainRequests, exitStateBackups) { Stablecoins = stablecoins }; diff --git a/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs b/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs new file mode 100644 index 0000000..ef4f1f1 --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs @@ -0,0 +1,157 @@ +using BTCPayServer.Configuration; +using BTCPayServer.Plugins.Flint.Services; +using BTCPayServer.Plugins.Flint.Tests.Fakes; +using Microsoft.Extensions.Options; +using Xunit; + +namespace BTCPayServer.Plugins.Flint.Tests; + +/// +/// The real , over a real temp directory: what it stores is a +/// multi-megabyte secret that has to survive being written, and both halves of that sentence are +/// filesystem behaviour a fake cannot stand in for. +/// +/// +/// No feature gate is involved — the store is storage, not the feature; whether anything is written to +/// it is decided above it, and covered in SparkExitStateAutoBackupTests. +/// +public class FileExitStateBackupStoreTests +{ + private const string Store = "store-1"; + + private const UnixFileMode OwnerOnly = + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + [Fact] + public async Task A_store_with_no_file_reads_as_absent_rather_than_failing() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + Assert.Null(await store.ReadAsync(Store)); + Assert.Null(await store.TakenAtAsync(Store)); + Assert.False(await store.DeleteAsync(Store)); + } + + [Fact] + public async Task What_is_written_comes_back_and_the_write_leaves_no_temporary_behind() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + await store.WriteAsync(Store, "the-stored-backup-blob"); + + Assert.Equal("the-stored-backup-blob", await store.ReadAsync(Store)); + + // The atomic write lands via a `.tmp` sibling renamed over the target. A leftover temp is a + // half-written backup sitting in the same directory as the real one — an operator reading + // this directory (and they are the only audience that should) should never find one. + Assert.Empty(Directory.GetFiles(store.StorageDirectory(), "*.tmp")); + + Assert.NotNull(await store.TakenAtAsync(Store)); + } + + [Fact] + public async Task A_second_write_replaces_the_first_rather_than_failing_on_the_existing_file() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + await store.WriteAsync(Store, "first-backup"); + await store.WriteAsync(Store, "second-backup"); + + Assert.Equal("second-backup", await store.ReadAsync(Store)); + } + + [Fact] + public async Task Taken_before_the_first_write_is_null_and_a_deletion_makes_it_null_again() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + Assert.Null(await store.TakenAtAsync(Store)); + + await store.WriteAsync(Store, "the-stored-backup-blob"); + var written = await store.TakenAtAsync(Store); + Assert.NotNull(written); + + // UTC-offset zero, because a caller compares it against its own UTC-sourced clock and an + // unspecified offset would compare two different zones as if they were one. + Assert.Equal(TimeSpan.Zero, written!.Value.Offset); + + Assert.True(await store.DeleteAsync(Store)); + Assert.Null(await store.ReadAsync(Store)); + Assert.Null(await store.TakenAtAsync(Store)); + } + + [Fact] + public async Task The_directory_holds_the_backup_and_nothing_of_the_backup_appears_in_the_log() + { + using var dir = new TempDirectory(); + var log = new CapturingLogger(); + var store = Create(dir, log); + var secret = "blob-that-must-not-be-logged-9f3a"; + + await store.WriteAsync(Store, secret); + Assert.Equal(secret, await store.ReadAsync(Store)); + await store.DeleteAsync(Store); + + Assert.DoesNotContain(secret, log.AllText); + } + + [Fact] + public async Task The_backups_directory_is_owner_only() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "Unix file modes."); + + using var dir = new TempDirectory(); + var store = Create(dir); + + await store.WriteAsync(Store, "the-stored-backup-blob"); + + // The same hardening the SDK's per-store directory and the log directory get — this one + // holds, per store, the wallet's whole exit state as one readable string. + Assert.Equal(OwnerOnly, File.GetUnixFileMode(store.StorageDirectory())); + } + + [Fact] + public void A_store_id_that_could_escape_the_owner_only_directory_is_refused() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + // The id is BTCPay's own, not attacker-controlled — but a separator in it would place a + // backup outside the directory whose permissions are the whole protection, so it is a + // refusal rather than a judgment call. + _ = Assert.Throws(() => store.PathFor("../elsewhere")); + _ = Assert.Throws(() => store.PathFor("")); + } + + private static FileExitStateBackupStore Create( + TempDirectory dir, CapturingLogger? log = null) => + new(Options.Create(new DataDirectories { DataDir = dir.Path }), + log ?? new CapturingLogger()); + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "spark-backup-store-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + } + } +} diff --git a/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs index 576dafd..f156e33 100644 --- a/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/LocalRegtest/RenderExitScreensTests.cs @@ -334,9 +334,13 @@ private static async Task RenderAdvancedScreenAsync(bool storedBackup) { var h = SparkSurfaceHarness.Create(configureAttackerStore: true); - // Presence is all the page is told, and all it can be: the blob itself is never written into the model. - h.Settings.Settings[Store]!.UnilateralExit.ExitStateBackup = - storedBackup ? """{"version":1,"leaves":[]}""" : null; + // Presence is all the page is told, and all it can be: the controller hands the view a timestamp and + // never the blob, so this scripts the file store a real backup would have left behind. + if (storedBackup) + { + await h.ExitStateBackups.WriteAsync(Store, """{"version":1,"leaves":[]}""", CancellationToken.None); + h.ExitStateBackups.TakenAt = new DateTimeOffset(2026, 9, 16, 12, 0, 0, TimeSpan.Zero); + } var view = Assert.IsType(await h.Mvc.Advanced(Store, CancellationToken.None)); var model = Assert.IsType(view.Model); @@ -817,6 +821,7 @@ private sealed class RoutedUrlHelper(ActionContext actionContext) : IUrlHelper ["AdvancedSweep"] = "advanced/sweep", ["AdvancedApiKey"] = "advanced/api-key", ["ExportExitState"] = "advanced/exit-state/export", + ["DownloadExitStateBackup"] = "advanced/exit-state/download", ["SetExitStateBackup"] = "advanced/exit-state" }; diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs new file mode 100644 index 0000000..9cb9206 --- /dev/null +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs @@ -0,0 +1,351 @@ +using System.Numerics; +using Breez.Sdk.Spark; +using BTCPayServer.Plugins.Flint.Sdk; +using BTCPayServer.Plugins.Flint.Services; +using BTCPayServer.Plugins.Flint.Tests.Fakes; +using Xunit; +using SdkPaymentStatus = Breez.Sdk.Spark.PaymentStatus; + +namespace BTCPayServer.Plugins.Flint.Tests; + +/// +/// The automatic pass: over the real service, +/// the real file store and a fake SDK, with a clock the test moves. +/// +/// +/// +/// The cadence decisions themselves are unit-tested in ExitStateBackupSchedulerTests; what is +/// under test here is the collaboration — that the events actually ask for a refresh, that a due store +/// actually gets its file, that a failure actually leaves the previous file alone, and that a failed +/// store actually stops the pass from reaching the stores after it (it does not — that is the point). +/// The scheduler is real and the time is a stub, so the debounce is observed as "the pass immediately +/// after a deposit exports nothing; the pass two minutes and a margin after it does", which is the +/// property rather than a constant. +/// +/// +/// The margin is deliberate. stamps with +/// the real wall clock — it runs on the event path, where reading an injected clock is exactly what +/// that path must not do — while the pass reads the stub. The advance past the two-minute debounce +/// therefore carries half a minute of slack against the milliseconds of real time a test spends, so +/// wall-clock drift between the two clocks cannot decide a test that is about minutes. +/// +/// +/// Serialized with every other test that toggles the feature-gate environment variable, as the exit +/// tests are. +/// +/// +[Collection(UnilateralExitTestCollection.Name)] +public class SparkExitStateAutoBackupTests +{ + private const string StoreId = "store-auto-backup"; + + /// + /// The stub clock's start — at the real one, so the wall-clock stamps RequestRefresh + /// records on the event path land in the same era the tests advance through. + /// + private static readonly DateTimeOffset Base = DateTimeOffset.UtcNow; + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact(Timeout = 60_000)] + public async Task The_first_due_pass_exports_a_running_store_and_stores_it() + { + using var gate = FeatureGate(); + using var h = await StartedAsync(new StubTimeProvider(Base)); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // The success line carries a length, never the content — this is the log an implementation + // would be tempted to make "more helpful", so it is pinned to stay unhelpful. + Assert.DoesNotContain("exit-state-blob", h.Log.AllText); + } + + [Fact(Timeout = 60_000)] + public async Task With_the_feature_off_the_whole_pass_is_inert() + { + // Gate deliberately absent: with the experiment off there is no exit feature for a backup to + // serve, and touching a wallet's export path anyway is acting on a secret for nobody. + using var h = await StartedAsync(new StubTimeProvider(Base)); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Empty(h.Sdk.Clients[StoreId].ExitExportCalls); + Assert.Null(await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task An_unchanged_state_is_not_written_again() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + var firstTakenAt = await h.ExitStateBackups.TakenAtAsync(StoreId, Ct); + Assert.NotNull(firstTakenAt); + + // A full safety-net interval: the second pass is genuinely due, and this is the state an + // idle wallet produces forever — due, unchanged, and not worth a multi-megabyte rewrite. + clock.Advance(TimeSpan.FromHours(1)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + // The export did happen — that is how the pass learns the state is unchanged — but the file + // did not move, and a TakenAt that moved would report a backup re-taken at a moment nothing + // was learned about the wallet. + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + Assert.Equal(firstTakenAt, await h.ExitStateBackups.TakenAtAsync(StoreId, Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task A_changed_state_replaces_the_stored_backup() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + + h.Sdk.Clients[StoreId].ExitStateToExport = "exit-state-blob-next"; + clock.Advance(TimeSpan.FromHours(1)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal("exit-state-blob-next", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task An_export_failure_leaves_the_previous_backup_intact_and_the_pass_does_not_throw() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + + h.Sdk.Clients[StoreId].FailExportWith = new InvalidOperationException("export refused"); + clock.Advance(TimeSpan.FromHours(1)); + + // This call is a scheduled task's whole body; BTCPay's launcher logs what escapes it, and the + // plugin's rule is that nothing does. + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + Assert.Contains("retried", h.Log.AllText); + Assert.DoesNotContain("exit-state-blob", h.Log.AllText); + } + + [Fact(Timeout = 60_000)] + public async Task One_failing_store_does_not_cost_the_stores_after_it_their_backup() + { + const string brokenStore = "store-broken"; + const string healthyStore = "store-healthy"; + + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(timeProvider: clock); + h.SeedStore(brokenStore, SparkServiceHarness.MnemonicFor(1)); + h.SeedStore(healthyStore, SparkServiceHarness.MnemonicFor(2)); + await h.Service.StartAsync(Ct); + + h.Sdk.Clients[brokenStore].FailExportWith = new InvalidOperationException("export refused"); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + // The pass walks every running store per call: a throw out of the first would have ended the + // loop, and the second store's missing file would be the only evidence. + Assert.Null(await h.ExitStateBackups.ReadAsync(brokenStore, Ct)); + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(healthyStore, Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task A_claimed_deposit_event_debounces_before_the_next_backup_is_taken() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + // A first pass, so the pass that answers "not yet" below answers about the event's request + // and not about a scheduler with no history at all. + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + Emit(h, StoreId, SparkEventKind.ClaimedDeposits, payment: null); + await WaitFor(() => h.Log.AllText.Contains("Spark claimed an on-chain deposit"), + "the claimed-deposit event was never consumed"); + + // Requested, not yet due: the export the event earned lands after the debounce, not on the + // event's heels. + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + } + + [Fact(Timeout = 60_000)] + public async Task An_inbound_payment_event_requests_a_refresh_through_the_same_debounce() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // An auto-claimed deposit as a payment event — the branch of the receive path that logs and + // returns early, before any invoice wiring. The refresh is requested once, past the direction + // filter, so this early-returning branch is covered by the same call site. + Emit(h, StoreId, SparkEventKind.PaymentSucceeded, Deposit("dep-auto-1")); + await WaitFor(() => h.Log.AllText.Contains("on-chain deposit"), + "the deposit payment was never consumed"); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + } + + [Fact(Timeout = 60_000)] + public async Task The_safety_net_takes_a_fresh_backup_although_no_event_ever_arrived() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // Nothing was emitted between these two lines — the exact silence the net exists for, given + // an event channel this codebase documents as unreliable in both directions. + clock.Advance(TimeSpan.FromHours(1)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + } + + [Fact(Timeout = 60_000)] + public async Task An_empty_export_stores_nothing() + { + using var gate = FeatureGate(); + using var h = await StartedAsync(new StubTimeProvider(Base)); + h.Sdk.Clients[StoreId].ExitStateToExport = " "; + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + // Not an empty file either: an empty export means the SDK had nothing to say, and a file full + // of nothing imports as a corrupt one — which is worse than the honest "no backup". + Assert.Null(await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + + [Fact(Timeout = 120_000)] + public async Task After_a_restart_the_first_pass_learns_from_the_file_instead_of_rewriting_it() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + var first = await StartedAsync(clock); + SparkServiceHarness? h = null; + try + { + await first.Service.TakeDueExitStateBackupsAsync(Ct); + var firstTakenAt = await first.ExitStateBackups.TakenAtAsync(StoreId, Ct); + Assert.NotNull(firstTakenAt); + + h = first.Restart(); + await h.Service.StartAsync(Ct); + + // A restarted scheduler knows nothing about what is stored. The pass must seed from the + // file — a first pass after every restart that rewrote every store's identical backup + // would spend a multi-megabyte write per store to say "unchanged", on the one path + // (boot) where the process is least able to spare it. + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + Assert.Equal(firstTakenAt, await h.ExitStateBackups.TakenAtAsync(StoreId, Ct)); + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + finally + { + h?.Dispose(); + first.Dispose(); + } + } + + // ------------------------------------------------------------------------------------------------ + // Wiring + // ------------------------------------------------------------------------------------------------ + + /// + /// One configured store, started, on the harness's real file store over a temp data dir. The + /// feature gate is the caller's, held for the whole test — the connect path's warm-up runs + /// RestoreExitStateAsync, which is gated too, so a helper-scoped gate would be off again + /// before the assertions ran. + /// + private static async Task StartedAsync(TimeProvider clock) + { + var h = SparkServiceHarness.Create(timeProvider: clock); + try + { + h.SeedStore(StoreId, SparkServiceHarness.MnemonicFor(1)); + await h.Service.StartAsync(CancellationToken.None); + return h; + } + catch + { + h.Dispose(); + throw; + } + } + + private static void Emit(SparkServiceHarness h, string storeId, SparkEventKind kind, Payment? payment) => + Assert.True( + h.Sdk.EventWriters[storeId].TryWrite(new SparkEventEnvelope(storeId, kind, payment)), + "the event channel refused the envelope"); + + /// An auto-claimed on-chain deposit: a Receive with no payment hash and a claim fee netted out. + private static Payment Deposit(string id, long amount = 99_901, long fees = 99) => + new( + id: id, + paymentType: PaymentType.Receive, + status: SdkPaymentStatus.Completed, + amount: new BigInteger(amount), + fees: new BigInteger(fees), + timestamp: 1_785_847_217, + method: PaymentMethod.Deposit, + details: new PaymentDetails.Deposit("e2e11469", 1), + conversionDetails: null!); + + private static async Task WaitFor(Func condition, string because) + { + var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + while (!condition()) + { + if (DateTimeOffset.UtcNow > deadline) + Assert.Fail($"Timed out waiting for {because}"); + await Task.Delay(20, CancellationToken.None); + } + } + + private static IDisposable FeatureGate() => new EnvironmentSwitch("FLINT_EXPERIMENTAL_UNILATERAL_EXIT"); + + private sealed class EnvironmentSwitch : IDisposable + { + private readonly string _name; + private readonly string? _previous; + + public EnvironmentSwitch(string name) + { + _name = name; + _previous = Environment.GetEnvironmentVariable(name); + Environment.SetEnvironmentVariable(name, "1"); + } + + public void Dispose() => Environment.SetEnvironmentVariable(_name, _previous); + } +} diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs index 19472b1..b79d919 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs @@ -346,7 +346,7 @@ public async Task A_stored_exit_state_backup_is_imported_when_the_wallet_starts( using var gate = FeatureGate(); using var h = SparkServiceHarness.Create(); h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); - WithExitStateBackup(h, BackupStore, "the-stored-backup-blob"); + await WithExitStateBackup(h, BackupStore, "the-stored-backup-blob"); StartWithinTimeout(h); @@ -404,7 +404,7 @@ public async Task The_exit_state_backup_value_never_reaches_the_log() using var gate = FeatureGate(); using var h = SparkServiceHarness.Create(); h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); - WithExitStateBackup(h, BackupStore, secret); + await WithExitStateBackup(h, BackupStore, secret); StartWithinTimeout(h); await WaitUntil( @@ -414,6 +414,53 @@ await WaitUntil( Assert.DoesNotContain(secret, h.Log.AllText); } + /// + /// A backup an earlier version of the plugin left in the store's settings is adopted on connect: + /// imported into the wallet, moved to the plugin's own file, and the old setting cleared. + /// + /// + /// The upgrade path is the whole reason the deprecated setting still deserializes. A store + /// upgrading from the old version may hold its only copy of the backup there; an upgrade that + /// silently stopped reading the slot — or, worse, cleared it without moving it — would lose the + /// exit data of every leaf that version had learned about, which is exactly the loss the backup + /// exists to prevent. Asserted end to end because each step commits only on the last: a build + /// that wrote the file before the import succeeded, or cleared the setting when the write + /// failed, fails one of these three assertions. + /// + [Fact] + public async Task A_backup_left_in_the_old_settings_location_is_adopted_on_connect() + { + const string legacySecret = "legacy-blob-that-must-not-be-logged-4c1b"; + + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(); + h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); + + // The shape the old version left behind: written through the store repository, not the + // in-memory cache, because a real upgrade's value was persisted by a previous run. + var settings = h.Stores.Stored(BackupStore, Constants.StoreSettingsKey)!; + settings.UnilateralExit = new UnilateralExitSettings { ExitStateBackup = legacySecret }; + h.Stores.Seed(BackupStore, Constants.StoreSettingsKey, settings); + + StartWithinTimeout(h); + + // The one wait point after which all three commits are visible: the plugin logs the adoption + // only once the file has taken the value and the setting has been cleared. + await WaitUntil( + () => h.Log.AllText.Contains("adopted an exit-state backup"), + "the legacy backup to be adopted"); + + Assert.Equal([legacySecret], h.Sdk.Clients[BackupStore].ExitImportCalls); + Assert.Equal(legacySecret, await h.ExitStateBackups.ReadAsync(BackupStore)); + Assert.Null( + h.Stores.Stored(BackupStore, Constants.StoreSettingsKey)! + .UnilateralExit!.ExitStateBackup); + + // And the adoption line, like every other on this path, names a length and not the value. + Assert.DoesNotContain(legacySecret, h.Log.AllText); + } + + /// /// Turns the experimental-exit gate on for the duration of a test. /// @@ -441,18 +488,17 @@ public EnvironmentSwitch(string name) } /// - /// Gives a seeded store an exit-state backup in its persisted settings. + /// Places a store's exit-state backup on the plugin's own file — the location a previous run + /// stored it, read back by the connect through the real file store. /// /// - /// Written through the store repository rather than the in-memory cache, because the connect path reads - /// what a previous run persisted — a test that set only the cache would be testing the harness. + /// The harness's store is the real FileExitStateBackupStore over a temp data directory, so + /// this is not a fake agreeing with itself: the import test below reads bytes this wrote, and a + /// connect that looked only at its own cache would find nothing. /// - private static void WithExitStateBackup(SparkServiceHarness h, string storeId, string backup) - { - var settings = h.Stores.Stored(storeId, Constants.StoreSettingsKey)!; - settings.UnilateralExit = new UnilateralExitSettings { ExitStateBackup = backup }; - h.Stores.Seed(storeId, Constants.StoreSettingsKey, settings); - } + private static Task WithExitStateBackup(SparkServiceHarness h, string storeId, string backup) => + h.ExitStateBackups.WriteAsync(storeId, backup); + private static async Task WaitUntil(Func condition, string what) { diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs index c4dfa12..e6950e2 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs @@ -108,6 +108,14 @@ await harness.Service.AbandonAsync(StoreId, "whatever", Ct) Assert.Empty(harness.Sdk.ExitQuoteCalls); Assert.Empty(harness.Records.Records); Assert.Empty(harness.Settings.Writes); + + // And the exit-state surface, whose storage is a file rather than a settings write. + var backupAttempt = await harness.Service.SetExitStateBackupAsync(StoreId, "opaque-blob", Ct); + Assert.False(backupAttempt.Success); + Assert.Equal(SparkUnilateralExitService.FeatureDisabled, backupAttempt.Error); + Assert.Empty(harness.Backups.WriteCalls); + + Assert.Empty(harness.Backups.ReadCalls); } #endregion @@ -1532,6 +1540,90 @@ public async Task Setting_the_explorer_url_on_an_unconfigured_store_is_refused() #endregion + #region The exit-state backup + + /// + /// A backup the operator pastes lands in the file store and not in the settings blob — the + /// value is multi-megabytes and settings are read on every settings read. + /// + [Fact] + public async Task A_pasted_backup_lands_in_the_file_store_and_not_in_the_settings_blob() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + + var result = await harness.Service.SetExitStateBackupAsync(StoreId, "opaque-sdk-backup", Ct); + + Assert.True(result.Success, result.Error); + Assert.Equal("opaque-sdk-backup", harness.Backups.Stored(StoreId)); + // No settings write at all, on the one save action the operator triggers by hand: keeping + // this value out of the settings column is the entire reason the file store exists. + Assert.Empty(harness.Settings.Writes); + } + + /// + /// A paste past the ceiling is refused before anything is stored — almost always a mis-paste, + /// and a file that large is neither safe to hold nor safe to import. + /// + [Fact] + public async Task A_pasted_backup_past_the_size_ceiling_is_refused_before_anything_is_stored() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + var result = await harness.Service.SetExitStateBackupAsync( + StoreId, new string('x', SparkUnilateralExitService.MaxExitStateBackupChars + 1), Ct); + + Assert.False(result.Success); + Assert.NotNull(result.Error); + Assert.Contains("characters", result.Error); + Assert.Empty(harness.Backups.WriteCalls); + Assert.Empty(harness.Backups.DeleteCalls); + } + + /// + /// Re-pasting the backup already stored does not write it again. + /// + [Fact] + public async Task Re_pasting_the_stored_backup_does_not_write_it_again() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + + Assert.True((await harness.Service.SetExitStateBackupAsync(StoreId, "same-blob", Ct)).Success); + Assert.True((await harness.Service.SetExitStateBackupAsync(StoreId, "same-blob", Ct)).Success); + + // A save button pressed twice, a page reloaded with the value still in the textarea: the + // equality check costs one string comparison and spares the disk a multi-megabyte rewrite. + Assert.Single(harness.Backups.WriteCalls); + } + + /// Clearing the backup removes the file. + [Fact] + public async Task Clearing_the_backup_removes_the_file() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + await harness.Service.SetExitStateBackupAsync(StoreId, "to-be-cleared", Ct); + + Assert.True((await harness.Service.SetExitStateBackupAsync(StoreId, null, Ct)).Success); + + Assert.Null(harness.Backups.Stored(StoreId)); + Assert.Contains(StoreId, harness.Backups.DeleteCalls); + } + + [Fact] + public async Task Storing_a_backup_on_an_unconfigured_store_is_refused() + { + using var harness = Harness.Create(); + + var result = await harness.Service.SetExitStateBackupAsync(StoreId, "some-blob", Ct); + + Assert.Equal(SparkUnilateralExitService.NotConfigured, result.Error); + Assert.Empty(harness.Backups.WriteCalls); + } + + #endregion + #region The page read /// The read reports the wallet, the balance, the active exit and the history in one pass. @@ -2100,6 +2192,7 @@ private Harness(bool featureEnabled) Records, Protector, ExplorerClient, + Backups, Network.RegTest, Time, NullLogger.Instance); @@ -2129,6 +2222,9 @@ private Harness(bool featureEnabled) public InMemoryUnilateralExitRecordStore Records { get; } = new(); + /// Where exit-state backups land; the real one is a file per store. + public FakeExitStateBackupStore Backups { get; } = new(); + public SparkMnemonicProtector Protector { get; } /// How many lookups actually reached the explorer. diff --git a/BTCPayServer.Plugins.Flint/Constants.cs b/BTCPayServer.Plugins.Flint/Constants.cs index 06eeda5..1d73415 100644 --- a/BTCPayServer.Plugins.Flint/Constants.cs +++ b/BTCPayServer.Plugins.Flint/Constants.cs @@ -296,6 +296,17 @@ public static class Constants /// public static readonly TimeSpan ConfigSweepInterval = TimeSpan.FromMinutes(30); + /// + /// How often ExitStateBackupTask runs, measured from the end of the previous pass. + /// + /// + /// The task is a passive walker: it asks ExitStateBackupScheduler.ShouldTake per store, and the + /// debounce and safety-net intervals there do the actual deciding. One minute matches the resolution + /// merchants already expect from Lightning checkout elsewhere in this plugin; the export itself costs a + /// live SDK call plus a multi-megabyte write per store that is actually due. + /// + public static readonly TimeSpan ExitStateBackupInterval = TimeSpan.FromMinutes(1); + /// /// Wall clock a single reconciliation pass may spend before it stops starting new stores. /// diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs index 2e9ce86..4ddff91 100644 --- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs +++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; @@ -89,6 +90,7 @@ public class SparkController : Controller private readonly SparkStableBalanceService _stableBalance; private readonly ISparkUnilateralExitService _unilateralExit; private readonly ISparkStoreRuntime _storeRuntime; + private readonly IExitStateBackupStore _exitStateBackupStore; private readonly CrossChainCatalog _crossChainCatalog; private readonly StablecoinPaymentService _stablecoins; private readonly IAuthorizationService _authorizationService; @@ -106,6 +108,7 @@ public SparkController( SparkStableBalanceService stableBalance, ISparkUnilateralExitService unilateralExit, ISparkStoreRuntime storeRuntime, + IExitStateBackupStore exitStateBackupStore, CrossChainCatalog crossChainCatalog, StablecoinPaymentService stablecoins, IAuthorizationService authorizationService, @@ -122,6 +125,7 @@ public SparkController( _stableBalance = stableBalance; _unilateralExit = unilateralExit; _storeRuntime = storeRuntime; + _exitStateBackupStore = exitStateBackupStore; _crossChainCatalog = crossChainCatalog; _stablecoins = stablecoins; _authorizationService = authorizationService; @@ -851,11 +855,12 @@ private async Task BuildAdvancedViewModel( // be using a store's key even though Breez does not treat it as a secret, so the page has no // business printing it into the DOM. HasApiKeyOverride = !string.IsNullOrEmpty(settings?.ApiKeyOverride), - // Same discipline for the exit-state backup, and for the same reason at a higher severity: the - // blob describes the whole wallet's tree. Gated on the experiment too, so the block that would - // display it is never told there is one on a server where that block does not render. - HasExitStateBackup = Constants.UnilateralExitEnabled - && !string.IsNullOrEmpty(settings?.UnilateralExit.ExitStateBackup) + // When the stored backup was last written. Read from the store rather than the settings blob + // because the plugin refreshes this file on its own as the wallet's leaves change — this page is + // showing the operator how current the automation is, not asking whether they want a backup. + ExitStateBackupTakenAt = Constants.UnilateralExitEnabled + ? await _exitStateBackupStore.TakenAtAsync(storeId, cancellationToken).ConfigureAwait(false) + : null }; } @@ -1181,6 +1186,20 @@ public async Task ExportExitState([FromRoute] string storeId, Can model.ExportedExitState = await sdk .ExportUnilateralExitStateAsync(cancellationToken) .ConfigureAwait(false); + + // The same blob goes into the store, so the copy the operator just read and the copy the plugin + // keeps cannot disagree. Without this, pressing Export would hand out bytes *newer* than the + // stored backup and leave the page's "last taken" stamp behind them — which is precisely the + // staleness the automatic refresh exists to remove, reintroduced by the button that reads the + // wallet directly. + if (model.ExportedExitState is { Length: > 0 } exported) + { + await _exitStateBackupStore.WriteAsync(storeId, exported, cancellationToken) + .ConfigureAwait(false); + model.ExitStateBackupTakenAt = await _exitStateBackupStore + .TakenAtAsync(storeId, cancellationToken) + .ConfigureAwait(false); + } } catch (Exception ex) { @@ -1196,6 +1215,59 @@ public async Task ExportExitState([FromRoute] string storeId, Can return View("Advanced", model); } + /// + /// Downloads the stored exit-state backup. + /// + /// + /// + /// The automatic refresh means an operator never has to remember to take a backup; this is how they + /// still get one off this server, which is the only thing that makes the automatic copy worth + /// anything. It serves whatever is stored — the same bytes the next restart will import — rather than + /// exporting afresh, so what lands on disk is exactly what the plugin is holding. + /// + /// + /// POST rather than GET. This hands out the most sensitive blob the plugin holds, and a GET would put + /// the act of taking it into the URL, the access log, and anything that follows a link. + /// + /// + [HttpPost("advanced/exit-state/download")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] + public async Task DownloadExitStateBackup( + [FromRoute] string storeId, + CancellationToken cancellationToken) + { + if (!Constants.UnilateralExitEnabled) + return NotFound(); + + if (!ResolveStore(storeId, out var store)) + return NotFound(); + + storeId = store.Id; + + var backup = await _exitStateBackupStore.ReadAsync(storeId, cancellationToken).ConfigureAwait(false); + if (backup is null) + { + TempData[WellKnownTempData.ErrorMessage] = + "No exit-state backup is stored for this store yet. One is written automatically as the " + + "wallet's leaves change; Export takes one now."; + return RedirectToAction(nameof(Advanced), new { storeId }); + } + + var takenAt = await _exitStateBackupStore.TakenAtAsync(storeId, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Store {StoreId}: served the stored exit-state backup ({Length:N0} characters, taken {TakenAt:u})", + storeId, backup.Length, takenAt); + + // The timestamp is in the name because the file is the artifact the operator is storing off-box, and + // a directory of identical names is one they cannot tell apart a year from now. + var name = takenAt is { } at + ? $"exit-state-backup-{storeId}-{at:yyyyMMdd-HHmmss}.txt" + : $"exit-state-backup-{storeId}.txt"; + + return File(Encoding.UTF8.GetBytes(backup), "application/octet-stream", name); + } + /// /// Stores a pasted exit-state blob, replacing whatever was there. /// @@ -1402,10 +1474,6 @@ private SparkExitViewModel BuildExitViewModel( // that said "I could not tell" into one would put an action block on screen for an unknown set. PendingBroadcast = page.PendingBroadcast, EsploraApiUrl = settings?.UnilateralExit.EsploraApiUrl, - // Presence only, and only behind the feature gate — the section that shows this is gated too, and - // a store on a server with the experiment off has no exit data for the flag to be about. - HasExitStateBackup = Constants.UnilateralExitEnabled - && !string.IsNullOrEmpty(settings?.UnilateralExit.ExitStateBackup), NetworkName = _sweepSettings.Network.ChainName.ToString(), IsMainnet = _sweepSettings.Network.ChainName == ChainName.Mainnet }; diff --git a/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs b/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs index 982a208..b1cb43e 100644 --- a/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs +++ b/BTCPayServer.Plugins.Flint/Models/SparkAdvancedViewModel.cs @@ -1,3 +1,4 @@ +using System; using System.ComponentModel.DataAnnotations; using BTCPayServer.Plugins.Flint.Services; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -58,16 +59,24 @@ public class SparkAdvancedViewModel public bool UseBuiltInKey { get; set; } /// - /// Whether an exit-state backup is currently stored for this store. + /// When the stored exit-state backup was last written, or null when none is stored. /// /// - /// Presence only. The stored blob is never rendered back into this page. It carries every leaf and + /// + /// A timestamp rather than a flag, because the interesting question stopped being "is there one" and + /// became "how stale is it". The plugin refreshes this backup on its own after the wallet's leaves + /// change, so an operator reading this page is checking that the automation is working, not deciding + /// whether to do it by hand. + /// + /// + /// The stored blob is never rendered back into this page. It carries every leaf and /// its transactions for the wallet, so it discloses the balance, how that balance is split and the /// wallet's history — it is the one blob in the plugin that is worth more to a reader than the account it - /// describes. An operator who wants a copy asks for a fresh export. + /// describes. An operator who wants a copy downloads it. + /// /// [BindNever] - public bool HasExitStateBackup { get; set; } + public DateTimeOffset? ExitStateBackupTakenAt { get; set; } /// /// A blob to store, inbound only. The stored blob is never written into this model. @@ -85,7 +94,7 @@ public class SparkAdvancedViewModel /// A freshly exported blob, for one render, so the operator can copy it. /// /// - /// Its own field beside so a render cannot confuse "a backup exists" + /// Its own field beside so a render cannot confuse "a backup exists" /// with "here is that backup": only the export action sets this, and what it sets is what the SDK just /// returned. /// diff --git a/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs index 46e5565..612ff72 100644 --- a/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs +++ b/BTCPayServer.Plugins.Flint/Models/SparkExitViewModel.cs @@ -167,26 +167,6 @@ public class SparkExitViewModel [ValidateNever] public SparkExitVerdict? CheckResult { get; set; } - /// Whether an exit-state backup blob is currently stored for this store. Presence only. - /// - /// The blob itself is never rendered back into the page, for the same reason the Breez API key is not: - /// it carries every leaf and its transactions, so it discloses the balance, how it is split and the - /// history. The Advanced page therefore shows only whether one exists, and an operator who wants a copy - /// asks for a fresh export. - /// - public bool HasExitStateBackup { get; set; } - - /// - /// A freshly exported exit-state blob, held for one render so the operator can copy it. - /// - /// - /// Never a stored blob. This is set only by the export action, from a live SDK call, and is - /// discarded with the response — there is no path that reads the stored backup and puts it in a page. - /// Set as its own field rather than appended to so a render cannot - /// confuse "a backup exists" with "here is the backup". - /// - public string? ExportedExitState { get; set; } - /// The chain this server runs on, named in the copy that depends on it. public string NetworkName { get; set; } = string.Empty; diff --git a/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs new file mode 100644 index 0000000..c4eae0a --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace BTCPayServer.Plugins.Flint.Services; + +/// +/// Decides when a store's automatic exit-state backup is due, and remembers what was last written. +/// +/// +/// +/// Pure decision logic, and deliberately singleton-wide rather than per pass. Every method takes +/// its now as an argument and touches no clock, no file and no SDK, so the cadence rules below are +/// testable without a wallet, a timer or a filesystem. It is one instance for the process because the +/// pending-request marks arrive on per-store event-consumer loops and are consumed by the scheduled pass; +/// two instances would mean the pass never sees the requests. +/// +/// +/// Two rules, because two different failures are real. answers +/// "money just moved, take a fresh copy — but not forty"; answers "the +/// events said nothing, take one anyway". Either one alone is wrong: see each field. +/// +/// +/// The content hash is what keeps the writes rare. Two passes over a quiet wallet export byte-for-byte +/// identical state, and a multi-megabyte write to say "nothing changed" is a write that can fail, can be +/// interrupted, and earns nothing. The scheduler holds the hash of the last content written per store — +/// never the content, which is a secret — and reports whether a fresh export matches it. On restart it +/// starts empty, and the caller seeds it from the stored file (via ); the +/// scheduler never reads the file itself, because deciding what is stored is the store seam's job. +/// +/// +public sealed class ExitStateBackupScheduler +{ + /// + /// How long a refresh request waits before the next pass may act on it. + /// + /// + /// + /// Both of the events that request a refresh arrive as streams, not singletons: an on-chain deposit + /// burst lands as a ClaimedDeposits per claim plus a payment event per credit, and the SDK's + /// own background leaf optimisation emits its own stream independent of anything the merchant did. + /// Every export is a live SDK call producing a multi-megabyte blob and an equally large write, so + /// reacting to each event individually would turn a deposit burst into N full exports of state that + /// differs, at most, by the last claim — and would do it on the store's own event loop's time budget. + /// + /// + /// Two minutes, and it is a floor rather than a guess: the burst is over in seconds, and the exit data + /// is only worth its latest bytes to an operator who is already in the worst week of this wallet's + /// life. A later event during the wait does not push the deadline — the coalescing is what this is + /// for, and a refresh that slides would starve under exactly the busy wallet that needs backups most. + /// + /// + public static readonly TimeSpan DebounceInterval = TimeSpan.FromMinutes(2); + + /// + /// The longest a store may go without a backup pass, whatever the event stream said. + /// + /// + /// This is the safety net only an hour because the request channel above cannot be trusted to carry a + /// request at all: the SDK event channel is bounded, its listener reports drops rather than + /// backpressuring, and the codebase already documents the stream being unreliable in both directions — + /// a completed receive has been observed emitting only PaymentPending. A refresh mark that was + /// never delivered must not mean a store silently never gets another backup. One hour also bounds how + /// far a backup can lag the money: at most an hour's leaves exist in the wallet with no copy anywhere + /// that survives the device. + /// + public static readonly TimeSpan SafetyNetInterval = TimeSpan.FromHours(1); + + /// + /// One store's marks. Immutable; the dictionary swaps whole records under + /// 's own atomicity. + /// + /// When an unserved refresh arrived; null when none is pending. + /// When a pass last reported on this store's state, taken or skipped. + /// + /// Hash of the last content believed stored, or null while nothing is believed stored — including the + /// state right after a restart, before the caller has seeded it. + /// + private sealed record Marks(DateTimeOffset? RequestedAt, DateTimeOffset? LastPassAt, string? ContentHash); + + private readonly ConcurrentDictionary _marks = new(); + + /// + /// Records that something changed and a fresh backup will be worth taking soon. + /// + /// + /// + /// Never blocks and never throws: it is called from a store's event-consumer path, where the rule for + /// anything reached from an SDK callback is that it may not fail or stall. There is deliberately no + /// argument check that could throw — a degenerate store id is dropped, because losing the mark is a + /// missed backup and throwing would cost the store's whole event loop, which is the worse half. + /// + /// + /// An empty request slot records with now; a pending one is left at + /// its original time. Overwriting a pending time with a later one is what a throttle does and + /// would let a steady stream of events defer the backup forever; this is a debounce that coalesces. + /// + /// + public void RequestRefresh(string storeId) + { + if (string.IsNullOrEmpty(storeId)) + return; + + _marks.AddOrUpdate( + storeId, + _ => new Marks(DateTimeOffset.UtcNow, null, null), + (_, current) => current.RequestedAt is null + ? current with { RequestedAt = DateTimeOffset.UtcNow } + : current); + } + + /// + /// Whether a backup is due for a store: a request has been pending for at least + /// , or nothing has been taken for at least . + /// + /// + /// A store the scheduler has never seen is due immediately — a wallet that has been up since before + /// this process started has no backup, and waiting a further safety-net interval after every restart + /// for the one thing restarts are a risk to would leave the window open exactly where it matters. + /// + public bool ShouldTake(string storeId, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + if (!_marks.TryGetValue(storeId, out var marks)) + return true; + + if (marks.RequestedAt is { } requested && now - requested >= DebounceInterval) + return true; + + return marks.LastPassAt is not { } last || now - last >= SafetyNetInterval; + } + + /// + /// When a pending refresh request arrived, or null when none is. + /// + /// + /// Exists for the same reason every other decision method takes its now: the one clock this + /// class reads on its own is the real one inside , and a caller — today, + /// the tests — that wants to reason about the debounce needs the moment that was actually recorded, + /// not a second, differently-timed reading of the wall. It is also the direct read of the coalescing + /// rule: two requests, one timestamp. + /// + public DateTimeOffset? PendingSince(string storeId) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + return _marks.TryGetValue(storeId, out var marks) ? marks.RequestedAt : null; + } + + /// + /// Records that a backup was taken: serves any pending request and restarts the safety net. + /// + public void MarkTaken(string storeId, DateTimeOffset now) => MarkPass(storeId, now); + + /// + /// Records that a pass reported on this store's state and found the stored copy unchanged, so nothing + /// was written. Serves any pending request and restarts the safety net, exactly like a take — the pass + /// happened and the state is known current. + /// + public void MarkSkipped(string storeId, DateTimeOffset now) => MarkPass(storeId, now); + + /// + /// Whether the scheduler knows what content is believed stored for a store. + /// + /// + /// False right after a restart — the file may hold a year of backups while the scheduler holds nothing. + /// The caller uses this to seed from the file () before it asks + /// whether a fresh export is worth writing. + /// + public bool KnowsStoredContent(string storeId) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + return _marks.TryGetValue(storeId, out var marks) && marks.ContentHash is not null; + } + + /// + /// Records the bytes the caller believes are now stored, hashing them and keeping only the hash. + /// + /// + /// Called by the caller with the file's content when seeding after a restart, and again with the + /// content it just wrote after a successful write. Null content means "the file is absent", which is + /// not the same as unknown: a subsequent export of anything is a change worth writing. + /// + public void NoteStoredContent(string storeId, string? content) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + var hash = content is null ? null : Hash(content); + _marks.AddOrUpdate( + storeId, + _ => new Marks(null, null, hash), + (_, current) => current with { ContentHash = hash }); + } + + /// + /// Whether a fresh export is byte-identical to the content believed stored. + /// + /// + /// False when the scheduler does not know what is stored — unknown is not "unchanged", and a caller + /// that treated it as unchanged would never write anything on a freshly restarted server. + /// + public bool ContentUnchanged(string storeId, string content) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + ArgumentNullException.ThrowIfNull(content); + + return _marks.TryGetValue(storeId, out var marks) + && marks.ContentHash is { } hash + && string.Equals(hash, Hash(content), StringComparison.Ordinal); + } + + private void MarkPass(string storeId, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + _marks.AddOrUpdate( + storeId, + _ => new Marks(null, now, null), + (_, current) => current with { RequestedAt = null, LastPassAt = now }); + } + + /// + /// A digest of the content, compared for equality only. Never logged: it authenticates a secret, and + /// a hash of a low-entropy value would not stay one. + /// + private static string Hash(string content) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(content))); +} diff --git a/BTCPayServer.Plugins.Flint/Services/ExitStateBackupTask.cs b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupTask.cs new file mode 100644 index 0000000..eabb6c9 --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupTask.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.HostedServices; + +namespace BTCPayServer.Plugins.Flint.Services; + +/// +/// Periodically takes the automatic exit-state backups that are due. +/// +/// +/// +/// The safety half of the arrangement, not the trigger half: has +/// already decided that an event-driven refresh is worth taking, and a pass whose only job would be to ask +/// ShouldTake costs one dictionary read per store. One minute matches the resolution every other +/// scheduled pass in this plugin works at, and it is what bounds the latency of the debounced refreshes — +/// a deposit burst schedules its backup, and this task is when it lands. +/// +/// +/// Registered through BTCPay's AddScheduledTask, which runs on a fixed interval +/// and logs rather than rethrows. additionally +/// guarantees it does not throw on per-store failures, so a wallet that cannot export cannot wedge this +/// task or any other store's backup. +/// +/// +public class ExitStateBackupTask : IPeriodicTask +{ + private readonly SparkService _sparkService; + + public ExitStateBackupTask(SparkService sparkService) + { + _sparkService = sparkService; + } + + public Task Do(CancellationToken cancellationToken) => + _sparkService.TakeDueExitStateBackupsAsync(cancellationToken); +} diff --git a/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs new file mode 100644 index 0000000..ed9a971 --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Plugins.Flint.Sdk; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Plugins.Flint.Services; + +/// +/// Exit-state backups as one owner-only file per store under +/// <DataDir>/Plugins/Flint/exit-state/<storeId>.txt. +/// +/// +/// +/// A file, deliberately, not a column. The backup is a multi-megabyte secret, and the store's +/// settings blob is deserialized on every settings read — a several-megabyte value in that column would be +/// carried through the settings cache, cloned on every read, and re-serialized on every save, for a blob +/// nothing reads except on connect. On disk it is read once and written atomically, and a settings write +/// that races a backup can neither lose nor half-overwrite it. +/// +/// +/// A sibling of the SDK's per-store directory, not a child of it. The SDK's directory is handed to +/// the SDK, so the plugin cannot assume it stays stable in shape or existence, and a file the plugin owns +/// next to one it does not is one it can create, restrict and clean up on its own terms. +/// +/// +/// The write is over a temporary, always. A +/// half-written backup that imports as a corrupt one is worse than no write at all: the operator would +/// believe their exit data was stored while it could never be restored. The rename is the only way to make +/// "either the old backup or the new one, never a mixture" true without a locking protocol this plugin +/// would then have to get right. +/// +/// +/// Nothing here interprets the content. It is read and written as one string and never parsed, +/// validated or trimmed — see for why that is a hard rule. +/// +/// +public sealed class FileExitStateBackupStore : IExitStateBackupStore +{ + /// The directory every store's backup file lives in. + private const string Subdirectory = "exit-state"; + + private readonly IOptions _dataDirectories; + private readonly ILogger _logger; + + public FileExitStateBackupStore( + IOptions dataDirectories, + ILogger logger) + { + _dataDirectories = dataDirectories; + _logger = logger; + } + + /// + public async Task ReadAsync(string storeId, CancellationToken cancellationToken = default) + { + var path = PathFor(storeId); + + // Absent is a fact and returns null; a directory that cannot be read or a file that cannot be + // opened throws, because a caller that treated "unreadable" as "none stored" would clear a + // merchant's only exit data on a transient error. + return File.Exists(path) + ? await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false) + : null; + } + + /// + public Task TakenAtAsync(string storeId, CancellationToken cancellationToken = default) + { + var path = PathFor(storeId); + if (!File.Exists(path)) + return Task.FromResult(null); + + // The filesystem's write time is the answer, UTC so the offset is unambiguous. What a reader wants + // from it is "how stale is this?", and only a UTC stamp can be compared against a store's own + // monotonic pass times without knowing the host's zone. + var written = new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero); + return Task.FromResult(written); + } + + /// + public async Task WriteAsync(string storeId, string backup, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + ArgumentNullException.ThrowIfNull(backup); + + var path = PathFor(storeId); + var temporary = path + ".tmp"; + EnsureDirectory(); + + // Written to a sibling rather than in place, then renamed over the target. The overwrite flag is + // what makes the move a replace: without it the second backup for a store would throw on the + // existing file. + await File.WriteAllTextAsync(temporary, backup, cancellationToken).ConfigureAwait(false); + + try + { + File.Move(temporary, path, overwrite: true); + } + catch + { + // The half-written temp has no value once the replace failed, and leaving it beside every + // store's real backup is how a directory of secrets accumulates debris. + TryDelete(temporary); + throw; + } + } + + /// + public Task DeleteAsync(string storeId, CancellationToken cancellationToken = default) + { + var path = PathFor(storeId); + if (!File.Exists(path)) + return Task.FromResult(false); + + File.Delete(path); + return Task.FromResult(true); + } + + /// + /// The directory this store owns: a sibling of the SDK's per-store storage, created and restricted + /// exactly as that one is. + /// + internal string StorageDirectory() => Path.Combine( + _dataDirectories.Value.DataDir, "Plugins", Constants.WorkDirName, Subdirectory); + + /// One store's backup file. + internal string PathFor(string storeId) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + // Every path in the plugin's own layout is built from a store id BTCPay generated. The id is not + // attacker-controlled — it is a store's own identifier, resolved from an authorised request — but a + // store id containing a path separator would place the file outside the owner-only directory this + // class exists to keep it in, so it is refused rather than reasoned about. + if (storeId.IndexOfAny(Path.GetInvalidPathChars()) >= 0 + || storeId.Contains(Path.DirectorySeparatorChar) + || storeId.Contains(Path.AltDirectorySeparatorChar)) + { + throw new ArgumentException( + $"A store id cannot be used as a file name because it contains a path separator: {storeId}", + nameof(storeId)); + } + + return Path.Combine(StorageDirectory(), storeId + ".txt"); + } + + private void EnsureDirectory() + { + var directory = StorageDirectory(); + + // Owner-only from the first instant it exists, never created at the umask and restricted + // afterwards — the same pairing FileSparkStorageProvider.GetTarget applies to the SDK's directory, + // so a backup is never momentarily world-readable in the window before a chmod. + SparkDirectoryPermissions.CreateOwnerOnly(directory); + SparkDirectoryPermissions.RestrictToOwner(directory, _logger); + } + + private void TryDelete(string path) + { + try + { + File.Delete(path); + } + catch (Exception ex) + { + // Nothing names the content: the temp holds a backup, and a failure to delete it is the + // operator's cleanup problem, not a reason to put the secret in a log line. + _logger.LogWarning(ex, + "Could not delete the temporary exit-state backup at {Path}", path); + } + } +} diff --git a/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs new file mode 100644 index 0000000..de8944b --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace BTCPayServer.Plugins.Flint.Services; + +/// +/// The stored unilateral-exit state backup of a store, read and written as one opaque string. +/// +/// +/// +/// What this is for: the transactions an exit is built from live only in the SDK's local storage. +/// While the Spark operators are reachable they can be fetched again; when that storage is gone and the +/// operators are not, they cannot be recovered from anywhere and the leaves they cover can no longer be +/// exited. The blob this store keeps is the copy that survives the device, taken automatically by +/// rather than left to an operator remembering to export one. +/// +/// +/// It is a secret and every implementation must treat it as one. It carries every leaf of the +/// wallet and the transactions under them, which discloses the balance, how it is split, and what the +/// wallet has received and spent. It must never be logged, echoed in an exception message, or returned to +/// a page — an implementation may log the store id and a length, and nothing of the content. +/// +/// +/// Its content is not validated on the way in, anywhere in this plugin. The encoding is +/// the SDK's own and the SDK is the only thing that can judge it; a check invented here would reject a +/// valid backup from a future SDK, and a rejected backup is a lost exit. The only bound any caller applies +/// is a length cap against a wrong paste. +/// +/// +/// This is the only seam through which the backup is read or written. Nothing else opens the file — +/// including nothing that parses it. +/// +/// +public interface IExitStateBackupStore +{ + /// + /// The stored backup for a store, or null when none is stored. + /// + /// + /// A genuine IO failure is not swallowed into a null: a read that cannot be answered is a different + /// fact from one that answered "absent", and the caller decides what to do with it. + /// + Task ReadAsync(string storeId, CancellationToken cancellationToken = default); + + /// + /// When the backup was written, or null when none is stored. + /// + Task TakenAtAsync(string storeId, CancellationToken cancellationToken = default); + + /// + /// Stores a backup, atomically replacing any previous one. + /// + Task WriteAsync(string storeId, string backup, CancellationToken cancellationToken = default); + + /// + /// Removes the stored backup. Returns whether there was one to remove. + /// + Task DeleteAsync(string storeId, CancellationToken cancellationToken = default); +} diff --git a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs index 422de96..269c05d 100644 --- a/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/ISparkUnilateralExitService.cs @@ -112,8 +112,8 @@ Task CheckAsync( CancellationToken cancellationToken = default); /// - /// Stores an exported unilateral-exit backup blob on the store's exit settings, or clears it when the - /// argument is null or blank. + /// Stores an exported unilateral-exit backup blob where the automatic backups are kept (see + /// ), or clears it when the argument is null or blank. /// /// /// diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index bf54e5d..f1d1531 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -192,6 +192,24 @@ public class SparkService : EventHostedServiceBase, ISparkClientResolver, ISpark /// payment-method handler dictionary this service must not be built from inside (see SparkPlugin). /// private readonly Func _stablecoinsFactory; + /// Where each store's automatic exit-state backup is kept. One file per store, in a directory of the + /// plugin's own; nothing else in this class opens it. + /// + private readonly IExitStateBackupStore _exitStateBackupStore; + + /// + /// The debounce/safety-net decisions for those backups, plus the per-store content hash that stops an + /// unchanged state being rewritten. Singleton because the requests arrive on per-store event loops and + /// the scheduled pass consumes them from another thread entirely. + /// + private readonly ExitStateBackupScheduler _exitStateBackupScheduler; + + /// + /// The clock every scheduled decision reads. Kept as a field because the backup pass compares it + /// against times the event path recorded through the scheduler; a test advances one and observes the + /// other. + /// + private readonly TimeProvider _timeProvider; public SparkService( EventAggregator eventAggregator, @@ -210,6 +228,8 @@ public SparkService( Func configSweeperFactory, Func stablecoinsFactory, ILoggerFactory loggerFactory, + IExitStateBackupStore exitStateBackupStore, + ExitStateBackupScheduler exitStateBackupScheduler, ILogger logger) : base(eventAggregator, logger) { ArgumentNullException.ThrowIfNull(timeProvider); @@ -235,6 +255,9 @@ public SparkService( _stablecoinsFactory = stablecoinsFactory; _loggerFactory = loggerFactory; _logger = logger; + _timeProvider = timeProvider; + _exitStateBackupStore = exitStateBackupStore; + _exitStateBackupScheduler = exitStateBackupScheduler; } #region Hosted service lifecycle @@ -884,27 +907,60 @@ private async Task RestoreExitStateAsync(string storeId, ISparkSdkClient sdk) if (!Constants.UnilateralExitEnabled) return; + // The plugin's own file first: the backup is a multi-megabyte secret and the settings blob is + // deserialized on every settings read, which is exactly why it stopped living there. string? backup; try { - var settings = await Get(storeId).ConfigureAwait(false); - backup = settings?.UnilateralExit?.ExitStateBackup; + backup = await _exitStateBackupStore.ReadAsync(storeId, CancellationToken.None) + .ConfigureAwait(false); } catch (Exception ex) { _logger.LogWarning(ex, - "Store {StoreId}: its settings could not be read, so a stored exit-state backup was not " + "Store {StoreId}: its stored exit-state backup could not be read, so a backup was not " + "imported on this connect", storeId); return; } + // Adoption: a store upgrading from a plugin version that kept the backup in its settings still has + // its only copy there, and losing a backup on an upgrade is losing the exit data of every leaf + // the old version had learned about. Import from the old location first; the move is committed + // only once the import has succeeded, so a blob this SDK refuses stays where it is. + var adopting = false; if (string.IsNullOrWhiteSpace(backup)) - return; + { + string? legacy; + try + { + var settings = await Get(storeId).ConfigureAwait(false); + legacy = settings?.UnilateralExit?.ExitStateBackup; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Store {StoreId}: its settings could not be read, so a stored exit-state backup was not " + + "imported on this connect", storeId); + return; + } + + if (string.IsNullOrWhiteSpace(legacy)) + return; + + backup = legacy; + adopting = true; + } try { var imported = await sdk.ImportUnilateralExitStateAsync(backup).ConfigureAwait(false); + // After the import, never before: the old location is the only copy until the wallet has + // demonstrably taken the blob back, and an adoption that cleared it on a failed import would + // trade the backup for nothing. + if (adopting) + await AdoptLegacyBackupAsync(storeId, backup).ConfigureAwait(false); + // Logged at information even when nothing was restored, because "the backup did not cover this // wallet" is a fact the operator needs and cannot see anywhere else: the page only reports that a // backup is stored. The conflicting count is called out separately because it is the one that @@ -933,6 +989,77 @@ private async Task RestoreExitStateAsync(string storeId, ISparkSdkClient sdk) } } + /// + /// Commits the adoption of a backup found at the old settings location: writes it to the file store, + /// then clears the setting. + /// + /// + /// + /// Only ever called after a successful import — the caller owns that ordering, because the setting is + /// the only copy until the wallet has provably taken the blob back. + /// + /// + /// The two steps fail differently and neither is worth an exception: while the write fails, the old + /// location still holds the backup and the next connect adopts again; once the write has landed, the + /// copy that counts is stored, and a setting that still names the value is inert — adoption is only + /// ever consulted when the file is absent — so a failed clear costs one retry at most. + /// + /// + /// The setting is rewritten through the repository, not : Set reconciles the + /// running instance, which would tear down and reconnect the wallet this very connect is warming up, + /// on a path where no operator asked for anything. A concurrent reconfiguration racing this write + /// rewrites the slot from a fresh read rather than a cached one for the same reason: the cached blob + /// may predate a change this method has no business reverting. + /// + /// + /// Logs the length and nothing else, as everywhere this blob is handled. + /// + /// + private async Task AdoptLegacyBackupAsync(string storeId, string backup) + { + try + { + await _exitStateBackupStore.WriteAsync(storeId, backup, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning( + "Store {StoreId}: its exit-state backup imported but could not be written to the plugin's " + + "own file ({ExceptionType}); it stays at its old location and a later connect will " + + "attempt the move again", + storeId, ex.GetType().Name); + return; + } + + try + { + var stored = await _storeRepository + .GetSettingAsync(storeId, Constants.StoreSettingsKey) + .ConfigureAwait(false); + + if (stored?.UnilateralExit is { } exit && !string.IsNullOrEmpty(exit.ExitStateBackup)) + { + exit.ExitStateBackup = null; + await _storeRepository + .UpdateSetting(storeId, Constants.StoreSettingsKey, stored) + .ConfigureAwait(false); + } + + _logger.LogInformation( + "Store {StoreId}: adopted an exit-state backup left by an earlier version of this plugin " + + "into its own file and cleared the old setting ({Length} characters)", + storeId, backup.Length); + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Store {StoreId}: its exit-state backup was adopted into the plugin's own file, but the " + + "old setting could not be cleared; the stored file is the copy that counts", + storeId); + } + } + /// /// Shuts one store's instance down. Caller must hold . Idempotent. /// @@ -1036,6 +1163,12 @@ private async Task HandleEventAsync(SparkEventEnvelope envelope, SparkStoreInsta // Real money arriving on-chain, and the SDK claims it automatically. Worth an operator-level // line; the individual amounts show up as Deposit payments on the same event stream. _logger.LogInformation("Store {StoreId}: Spark claimed an on-chain deposit", envelope.StoreId); + + // A claimed deposit changes the wallet's leaf set, so it is the strongest signal that a fresh + // exit-state backup is worth having. Never take one here — this runs on the store's event + // consumer and an export is a live multi-megabyte SDK call; the scheduler coalesces the burst + // and the scheduled pass pays it. + _exitStateBackupScheduler.RequestRefresh(envelope.StoreId); return; default: @@ -1063,6 +1196,11 @@ private async Task HandleReceiveEventAsync(SparkStoreInstance instance, Breez.Sd return; } + // Any inbound payment moves exit data, so a backup refresh is warranted whether it arrives as a + // Lightning receive or an on-chain deposit. One call here, past the direction filter, rather than + // threaded into each branch: it fires before the Deposit branch below returns, so both are covered. + _exitStateBackupScheduler.RequestRefresh(storeId); + if (payment.Method is SparkPaymentMethod.Deposit) { // An auto-claimed on-chain static deposit. It has no payment hash by nature and settles no @@ -1256,6 +1394,113 @@ public async Task ReconcileAllStoresAsync(CancellationToken cancellationTok .ConfigureAwait(false); } + /// + /// Takes the automatic exit-state backup of every running store that is due, per + /// . + /// + /// + /// + /// Driven solely by . Only running instances are enumerated, which is + /// the skip-the-dead-wallet rule for free: an export is a live SDK call needing a connected wallet, + /// and an instance being in _instances is the plugin's own definition of having one. A store + /// whose wallet never starts carries no fresh exit data anyway — nothing it holds has changed since + /// it stopped answering. + /// + /// + /// This method must never throw into the task loop. Every per-store failure is caught, logged + /// without the blob, and left for a later pass: the pass is the retry, and an exception that escaped + /// would end the walk over the stores behind it, turning one broken wallet into every store on the + /// server losing its backups. Cancellation is the one rethrow — the host is going down, and a + /// several-megabyte export into a half-written file is exactly what cancellation is for. + /// + /// + /// The blob never reaches the log on any path here — not the success line (which carries a + /// length, nothing else), and not the failure line (which names the exception type and not the + /// exception, for the same reason does not use + /// SparkErrors.Describe: an SDK that echoed the export argument back would put the whole + /// wallet history in the log). + /// + /// + public async Task TakeDueExitStateBackupsAsync(CancellationToken cancellationToken) + { + // The feature gate first, on the whole pass: with the experiment off nothing here is reachable + // from the UI either, and exporting a wallet's exit data on a server that has no exit feature is + // this plugin acting on a secret for no one. + if (!Constants.UnilateralExitEnabled) + return; + + await _startupGate.Task.ConfigureAwait(false); + + // One clock reading for the whole walk, so two stores cannot disagree about the same pass. + var now = _timeProvider.GetUtcNow(); + + // Snapshotted like the reconciliation walk: a store reconfigured mid-pass mutates the dictionary + // being enumerated, and a store that goes away under an in-flight export loses only this pass. + foreach (var instance in _instances.Values.ToList()) + { + var storeId = instance.StoreId; + try + { + if (!_exitStateBackupScheduler.ShouldTake(storeId, now)) + continue; + + var exported = await instance.Sdk.ExportUnilateralExitStateAsync(cancellationToken) + .ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(exported)) + { + // No MarkSkipped: an empty answer is not a report on the wallet's state, and serving + // a pending request with nothing would silently drop it. The next pass asks again. + _logger.LogInformation( + "Store {StoreId}: its exit-state export came back empty, so nothing was stored", + storeId); + continue; + } + + // Seed once per process from the file, before judging a fresh export: a restarted server + // knows nothing about what is stored, and a first pass that rewrote the file on every + // store would spend a multi-megabyte write per store to say "unchanged". + if (!_exitStateBackupScheduler.KnowsStoredContent(storeId)) + { + _exitStateBackupScheduler.NoteStoredContent( + storeId, + await _exitStateBackupStore.ReadAsync(storeId, cancellationToken) + .ConfigureAwait(false)); + } + + if (_exitStateBackupScheduler.ContentUnchanged(storeId, exported)) + { + _exitStateBackupScheduler.MarkSkipped(storeId, now); + continue; + } + + await _exitStateBackupStore.WriteAsync(storeId, exported, cancellationToken) + .ConfigureAwait(false); + _exitStateBackupScheduler.NoteStoredContent(storeId, exported); + _exitStateBackupScheduler.MarkTaken(storeId, now); + + _logger.LogInformation( + "Store {StoreId}: stored an automatic exit-state backup ({Length} characters)", + storeId, exported.Length); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Type name only — see the remarks. And the pass deliberately records nothing here: + // the pending request (if any) and the safety-net clock both stay armed, so the next + // pass retries this store. Whatever backup was stored before this one is untouched, + // which is what a failure must mean. + _logger.LogWarning( + "Store {StoreId}: its automatic exit-state backup failed ({ExceptionType}). A previous " + + "backup, if one exists, is still stored; this will be retried on a later pass", + storeId, ex.GetType().Name); + } + } + } + /// /// Absolute path to the SDK storage directory for a store: /// <DataDir>/Plugins/Spark/<storeId>. diff --git a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs index 6d3fd90..74d9878 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs @@ -170,6 +170,14 @@ public sealed class SparkUnilateralExitService : ISparkUnilateralExitService private readonly TimeProvider _timeProvider; private readonly ILogger _logger; + /// + /// Where a stored exit-state backup actually lives: one owner-only file per store, outside the + /// settings blob. The settings write below is gone from this path deliberately — a several-megabyte + /// value in the store's settings is deserialized on every settings read, and storing one there + /// tore down and reconnected the store's wallet. + /// + private readonly IExitStateBackupStore _backups; + /// /// Stores with an exit operation in progress. Membership is the lock, and there is deliberately no queueing: /// see the class remarks. @@ -188,6 +196,7 @@ public SparkUnilateralExitService( IUnilateralExitRecordStore records, SparkMnemonicProtector mnemonicProtector, SparkExitFundingExplorer explorer, + IExitStateBackupStore backups, Network? network, TimeProvider timeProvider, ILogger logger) @@ -197,6 +206,7 @@ public SparkUnilateralExitService( _records = records; _mnemonicProtector = mnemonicProtector; _explorer = explorer; + _backups = backups; _network = network ?? Network.Main; _timeProvider = timeProvider; _logger = logger; @@ -560,26 +570,69 @@ public async Task SetExitStateBackupAsync( if (settings is null) return Refuse(NotConfigured); - var current = (settings.UnilateralExit ?? new UnilateralExitSettings()).ExitStateBackup; - if (string.Equals(current, normalised, StringComparison.Ordinal)) + // Read the stored value only to answer "does this press change anything" — a full read of a + // multi-megabyte blob, on a press an operator makes rarely at most, and what it buys is that a + // re-paste cannot move the file's timestamp: a rewrite would report the backup as taken at a + // moment when nothing was actually learned about the wallet. A failed read is not a refusal — + // "unchanged" has to be earned from a read that answered, so this press just proceeds to its + // own write, which is the same failure or success the comparison was guarding. + string? current = null; + var compared = false; + try { - // No write for a press that changes nothing: storing settings tears down and reconnects the - // store's wallet, which is not a thing to do to confirm the status quo. Compared by value rather - // than by reference, so a re-paste of the same blob is also a no-op. + current = await _backups.ReadAsync(storeId, cancellationToken).ConfigureAwait(false); + compared = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Store {StoreId}: its stored exit-state backup could not be read before a save, so the " + + "write proceeded without comparing to what was there", + storeId); + } + + if (compared && string.Equals(current, normalised, StringComparison.Ordinal)) + { + // No write for a press that changes nothing, and the comparison is by value rather than + // by reference so a re-paste of the same blob is also a no-op. This used to be required + // because a settings write reconnected the wallet; it is kept because a write that moves + // the TakenAt of a backup that did not change lies about the backup, not just about cost. return new UnilateralExitOpResult(true, null, null); } - // The subject and the log's description deliberately say nothing about the value: it discloses the - // store's balance and history, and this method is the one place in the plugin that handles it. - return await SaveExitSettingsAsync( + // The subject and the log's description deliberately say nothing about the value: it discloses + // the store's balance and history, and this method is one of the two places in the plugin + // that handles it. + try + { + if (normalised is null) + { + await _backups.DeleteAsync(storeId, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( + "Store {StoreId}: the stored exit-state backup was cleared", storeId); + } + else + { + await _backups.WriteAsync(storeId, normalised, cancellationToken).ConfigureAwait(false); + _logger.LogInformation( + "Store {StoreId}: stored an exit-state backup from the page ({Length} characters)", + storeId, normalised.Length); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "Store {StoreId}: could not store {What} ({Reason})", storeId, - settings, - exit => exit.ExitStateBackup = normalised, normalised is null ? "the unilateral-exit state backup being cleared" : "a unilateral-exit state backup", - "The exit-state backup") - .ConfigureAwait(false); + SparkErrors.Describe(ex)); + + return Refuse($"The exit-state backup could not be saved: {SparkErrors.Describe(ex)}"); + } + + return new UnilateralExitOpResult(true, null, null); } finally { diff --git a/BTCPayServer.Plugins.Flint/SparkPlugin.cs b/BTCPayServer.Plugins.Flint/SparkPlugin.cs index 62164a4..b2a4034 100644 --- a/BTCPayServer.Plugins.Flint/SparkPlugin.cs +++ b/BTCPayServer.Plugins.Flint/SparkPlugin.cs @@ -142,6 +142,13 @@ public override void Execute(IServiceCollection services) services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(provider => provider.GetRequiredService()); + // Automatic unilateral-exit backups: the owner-only file each store's backup lives in, the + // debounce/safety-net decisions, and the pass that applies them. Registered unconditionally like + // the rest of the exit surface — the gate is enforced inside the pass, not by whether the + // types exist. + services.AddSingleton(); + services.AddSingleton(); + // The setup flow's decisions, kept out of the controller so they can be tested. services.AddSingleton(); @@ -285,6 +292,7 @@ public override void Execute(IServiceCollection services) provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), SparkNetworks.ToNBitcoinNetwork(networkProvider.NetworkType), provider.GetRequiredService(), provider.GetRequiredService>()); @@ -341,6 +349,12 @@ public override void Execute(IServiceCollection services) // arrival is attributed to the invoice it paid. AddStablecoinPayments(services); + // Automatic exit-state backups. The task is the passive half: the interesting timing lives in + // ExitStateBackupScheduler, and a pass whose only new work is asking ShouldTake costs one + // dictionary read per store. One minute matches the resolution every other pass here works at, + // and it is what bounds the latency of a debounced post-deposit backup. + services.AddScheduledTask(Constants.ExitStateBackupInterval); + // UI extension points. Paths are relative to Views/Shared/ and resolved as partials. services.AddUIExtension("ln-payment-method-setup-tabhead", "Spark/LNPaymentMethodSetupTabhead"); services.AddUIExtension("ln-payment-method-setup-tab", "Spark/LNPaymentMethodSetupTab"); diff --git a/BTCPayServer.Plugins.Flint/SparkSettings.cs b/BTCPayServer.Plugins.Flint/SparkSettings.cs index 0caa0db..f32abbe 100644 --- a/BTCPayServer.Plugins.Flint/SparkSettings.cs +++ b/BTCPayServer.Plugins.Flint/SparkSettings.cs @@ -531,26 +531,24 @@ public class UnilateralExitSettings public string? EsploraApiUrl { get; set; } /// - /// A backup of the SDK's unilateral-exit state, as produced by its export and accepted by its import. Null - /// when none has been stored. + /// Deprecated. Where an exit-state backup used to live; it now lives in an owner-only file in + /// BTCPay's data directory (), and nothing new is + /// ever written here. Null for any store written by the current version. /// /// /// - /// What this is for: the transactions an exit is built from live only in the SDK's local storage. - /// While the operators are reachable they can be fetched again; when that storage is gone and the operators - /// are not, they cannot be recovered from anywhere and the leaves they cover can no longer be exited. This - /// blob is the copy that survives the device. + /// It exists only so an upgrade cannot lose a backup. A store provisioned by an earlier + /// plugin version may still hold its only copy of the blob in this slot, and if it were not + /// deserialized the adoption path would have nothing to read — an upgrade that silently dropped + /// an operator's backup is exactly the loss this field's own contents guard against. The value is + /// adopted, imported, moved to the file, and cleared on the first connect after an upgrade + /// (SparkService.AdoptLegacyBackupAsync). /// /// - /// It is sensitive and the plugin treats it as such. It carries every leaf of the wallet and its - /// transactions, which discloses the balance, how it is split and what the wallet has received and spent. It - /// must never be written to a log, echoed in an error, or sent back to a page. - /// - /// - /// Its content is not validated on the way in. The encoding is the SDK's own and the SDK is the only - /// thing that can judge it; a check invented here would reject a valid backup from a future SDK, and a - /// rejected backup is a lost exit. Only its length is bounded, because a value past the SDK's own few - /// megabytes is a paste error rather than a backup. + /// It was moved because it is a multi-megabyte secret and the settings blob is deserialized on + /// every settings read. Nothing may treat a non-null value here as "a backup is stored": the file + /// is the only answer after a connect has run, and before it neither is complete. Nothing may + /// write a live value here either — the page and the automation both use the file store. /// /// public string? ExitStateBackup { get; set; } diff --git a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml index df04a42..3232b8a 100644 --- a/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml +++ b/BTCPayServer.Plugins.Flint/Views/Spark/Advanced.cshtml @@ -118,45 +118,62 @@

This wallet keeps the data an exit is built from in its own storage on this server, and collecting that data needs Spark's operators to be reachable. So the moment you need an exit - is the moment you can no longer collect it. If this server's storage is lost while the - operators are gone, the data goes with it and those leaves can never be exited. A - backup taken now is not an alternative to exiting — it is what keeps exiting possible at all. - Export it, keep the copy somewhere safe, and an exit built from it goes ahead with the - operators gone. + is the moment you can no longer collect it. +

+

+ Flint takes this backup for you. It is rewritten on its own as this wallet's + leaves change, so the copy stored here is never far behind the wallet, and the next restart + imports it automatically. Nothing on this page has to be pressed for that to keep happening. + That copy is on the same server as the wallet, though, and a backup that dies with the + machine is not a backup. Download it and keep the file somewhere else — that is the + copy that lets an exit go ahead with the operators gone.

- @if (Model.HasExitStateBackup) + @if (Model.ExitStateBackupTakenAt is { } takenAt) { Stored - An exit-state backup is stored for this store and is imported automatically when the - wallet next starts. It is not shown here — export a fresh copy if you need one. If the - import itself fails, the wallet still runs and the reason is in the server log, so check - there after a restart rather than assuming a stored backup was applied. + Last written + . + It is imported automatically when this store's wallet next starts; it is not applied now. + If the import itself fails, the wallet still runs and the reason is in the server log, so + check there after a restart rather than assuming a stored backup was applied. } else { None stored - No exit-state backup is stored, so a restart imports nothing. Export one below and - keep the copy. + Nothing is stored yet, so a restart imports nothing. One is written automatically as + soon as this wallet's leaves change; Export takes one now. }

-
- -
- Reads the current data out of this store's running wallet. It changes nothing - and can fail if the wallet is not running — try again once it is. -
- +
+
+ + +
+ + +
+
+ Download backup is the stored copy, as a file to keep somewhere other than + this server. Export exit state reads the running wallet now, refreshes that + stored copy, and shows it below — use it when you want one current this second, or want to + paste the value by hand. Exporting changes nothing and can fail if the wallet + is not running; try again once it is. +
@if (Model.ExportedExitState is { Length: > 0 } exported) { From 8cdb275b99d9fe8dc77a94743d4259c759a406f3 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:36:58 -0400 Subject: [PATCH 20/22] Fix what the verifier found in the automatic backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh-context pass over 1f02f4d returned CONFIRMED with six findings. Four were real; two were correct as designed and one of those was the verifier being careful rather than the code being wrong. The one that mattered: adoption cleared the deprecated settings slot in the database but not in the service's settings cache, and both whole-settings writers rebuild their payload from that cache. So for a store upgrading from the old plugin, the first settings action after a successful adoption — acknowledging the disclosure, setting the explorer URL — would write the multi-megabyte blob straight back into the settings column the move existed to get it out of. No loss and no new exposure, but the cost came back permanently, since adoption is never consulted again once the file is non-empty. Clearing now empties the cached instance too, and the clear is a seam of its own rather than a SetAsync, because a clear must not reconcile the running wallet. Second: clearing a backup only deleted the file. For a store whose legacy value had not been adopted yet, the next connect adopted it back and imported exactly the leaves the operator had asked to discard, while the page told them a restart would import nothing. A clear now clears both locations. Third: only the rename was guarded, so a failure inside the write itself — disk full, an IO error part-way through several megabytes — left a partial copy of the secret beside the real backup with nothing to remove it. The whole write path is guarded now; the target file is still only ever reached by the rename. The download's cache control needed no code: this controller already carries [ResponseCache(NoStore = true)] for every action, so the advisory was the verifier being right to ask and wrong about the answer. That is now pinned by a test rather than by an argument, along with the download's bytes and its nothing-stored redirect. The remaining advisory is recorded, not fixed: the whole automation is behind FLINT_EXPERIMENTAL_UNILATERAL_EXIT and is inert on a default deployment, like the rest of the exit feature. --- .../Fakes/FakeSparkStoreSettingsStore.cs | 15 +++ .../SparkExitPageTests.cs | 113 ++++++++++++++++++ .../SparkServiceStartupTests.cs | 47 ++++++++ .../SparkUnilateralExitServiceTests.cs | 31 +++++ .../Controllers/SparkController.cs | 9 ++ .../Services/FileExitStateBackupStore.cs | 11 +- .../Services/ISparkStoreSettingsStore.cs | 20 ++++ .../Services/SparkService.cs | 77 +++++++++--- .../Services/SparkUnilateralExitService.cs | 14 ++- BTCPayServer.Plugins.Flint/SparkSettings.cs | 5 +- 10 files changed, 321 insertions(+), 21 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkStoreSettingsStore.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkStoreSettingsStore.cs index 2af7312..0e46049 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkStoreSettingsStore.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeSparkStoreSettingsStore.cs @@ -134,6 +134,21 @@ public async Task SetAsync(string storeId, SparkSettings? return SparkSettingsApplied.Running; } + /// + /// Clears the deprecated exit-state backup slot, as SparkService does. Deliberately not recorded + /// in : the real one empties the slot through the store repository rather than + /// through , precisely so a press that stores no settings blob does not reconcile + /// the wallet — and a caller that reached for instead is the thing a test needs + /// to see. + /// + public Task ClearExitStateBackupSlotAsync(string storeId) + { + if (Settings.TryGetValue(storeId, out var settings) && settings?.UnilateralExit is { } exit) + exit.ExitStateBackup = null; + + return Task.CompletedTask; + } + /// /// Replaces the store's live handle, disposing the old one, as SparkService.Set does. /// diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs index b74d7c7..47606a5 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs @@ -1,11 +1,18 @@ using System.Runtime.CompilerServices; +using System.Text; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Plugins.Flint.Controllers; 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 Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Xunit; namespace BTCPayServer.Plugins.Flint.Tests; @@ -711,6 +718,112 @@ public async Task A_failure_with_no_reason_still_produces_a_banner() #endregion + #region The stored-backup download + + /// + /// The download hands over exactly the bytes the store holds, and its response cannot be cached. + /// + /// + /// + /// This is the one action on the surface that returns the backup itself rather than a page about it, so + /// it is the one whose body has to be byte-identical with what the next restart will import. A download + /// that re-encoded, normalised or truncated the blob would put an operator off with a copy of their exit + /// data that fails at import time — the worst possible moment to discover it, and the reason the payload + /// is asserted as bytes rather than as a string the controller was handed. + /// + /// + /// The caching is not asserted as a header string this controller was told to set. The + /// controller-level ResponseCache attribute is MVC's filter, and it runs before the action; so + /// the filter is run here, in that same position, against the context the harness built, and what the + /// response carries afterwards is what is checked. Nothing about the download would fail if the policy + /// were removed — which is exactly why it is asserted rather than assumed. + /// + /// + [Fact] + public async Task The_download_returns_the_stored_bytes_and_the_response_is_not_cacheable() + { + using var gate = FeatureGate(enabled: true); + + const string secret = "exit-state-blob-that-must-not-be-logged-2f7c"; + var takenAt = new DateTimeOffset(2026, 9, 16, 12, 0, 0, TimeSpan.Zero); + + var h = SparkSurfaceHarness.Create(configureAttackerStore: true); + await h.ExitStateBackups.WriteAsync(Store, secret, CancellationToken.None); + h.ExitStateBackups.TakenAt = takenAt; + + ApplyControllerCachePolicy(h.Mvc); + + var file = Assert.IsType( + await h.Mvc.DownloadExitStateBackup(Store, CancellationToken.None)); + + Assert.Equal(Encoding.UTF8.GetBytes(secret), file.FileContents); + Assert.Equal("application/octet-stream", file.ContentType); + + // The stamp is in the name because the artifact lands in a directory the operator keeps, where two + // files of one name are indistinguishable a year later. + Assert.Equal($"exit-state-backup-{Store}-{takenAt:yyyyMMdd-HHmmss}.txt", file.FileDownloadName); + + var cacheControl = h.Mvc.HttpContext.Response.Headers.CacheControl.ToString(); + Assert.Contains("no-store", cacheControl); + Assert.DoesNotContain("max-age", cacheControl); + } + + /// + /// A download with nothing stored redirects, and says why in the sentence the page shows. + /// + /// + /// Not an empty file: a zero-byte download reads as a successful one, and a backup that imports as + /// nothing is worse than the honest absence — the operator believes they have a copy. + /// + [Fact] + public async Task A_download_with_nothing_stored_redirects_with_the_reason() + { + using var gate = FeatureGate(enabled: true); + + var h = SparkSurfaceHarness.Create(configureAttackerStore: true); + + var redirect = Assert.IsType( + await h.Mvc.DownloadExitStateBackup(Store, CancellationToken.None)); + + Assert.Equal(nameof(h.Mvc.Advanced), redirect.ActionName); + Assert.Null(h.Mvc.TempData[WellKnownTempData.SuccessMessage]); + + var message = Assert.IsType(h.Mvc.TempData[WellKnownTempData.ErrorMessage]); + Assert.Contains("No exit-state backup is stored", message); + + // Where one comes from, since the page's own copy says the plugin takes them automatically and the + // operator has just been told there is nothing to download. + Assert.Contains("automatically", message); + } + + /// + /// Runs the controller's caching filter where MVC runs it — as an action filter, before the action. + /// + /// + /// Not a re-statement of what the attribute says: this is the framework's own filter, built from the + /// attribute the controller actually carries, writing to the request context the harness built for it. + /// A file result is a body like any other to the filter, which is the property being checked. + /// + private static void ApplyControllerCachePolicy(SparkController mvc) + { + var attribute = typeof(SparkController) + .GetCustomAttributes(typeof(ResponseCacheAttribute), inherit: false) + .Cast() + .Single(); + + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(Options.Create(new MvcOptions())); + + var filter = (IActionFilter)((IFilterFactory)attribute) + .CreateInstance(services.BuildServiceProvider()); + + filter.OnActionExecuting(new ActionExecutingContext( + mvc.ControllerContext, [], new Dictionary(), mvc)); + } + + #endregion + #region Fixtures /// The page's view model from one GET, with the boilerplate of unwrapping it out of the way. diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs index b79d919..73f3b37 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkServiceStartupTests.cs @@ -461,6 +461,53 @@ await WaitUntil( } + /// + /// An adopted backup leaves no copy reachable from the settings cache either. + /// + /// + /// + /// The database being clear is only half the clear. Every reader gets the cached instance — + /// SparkService.Get hands back a clone of it — and both whole-settings writers rebuild from that + /// clone: SparkUnilateralExitService.SaveExitSettingsAsync clones the settings and the exit + /// section before storing, and a re-provision carries the previous exit settings across. A cache still + /// holding the blob is therefore a blob the next disclosure acknowledgement writes back into the + /// settings column, where it stays for good — adoption is never consulted again once the file exists, + /// which is precisely when nothing is left to clear it. + /// + /// + /// The two are read through different doors on purpose: the repository for the row, the service for the + /// cache. A fake repository that handed back the very instance the cache holds would let an + /// implementation that cleared neither pass this. + /// + /// + [Fact] + public async Task An_adopted_backup_leaves_no_copy_behind_in_the_settings_cache() + { + const string legacySecret = "legacy-blob-that-must-not-be-logged-7d22"; + + using var gate = FeatureGate(); + using var h = SparkServiceHarness.Create(); + h.SeedStore(BackupStore, SparkServiceHarness.MnemonicFor(1)); + + var seeded = h.Stores.Stored(BackupStore, Constants.StoreSettingsKey)!; + seeded.UnilateralExit = new UnilateralExitSettings { ExitStateBackup = legacySecret }; + h.Stores.Seed(BackupStore, Constants.StoreSettingsKey, seeded); + + StartWithinTimeout(h); + await WaitUntil( + () => h.Log.AllText.Contains("adopted an exit-state backup"), + "the legacy backup to be adopted"); + + Assert.Null( + h.Stores.Stored(BackupStore, Constants.StoreSettingsKey)! + .UnilateralExit!.ExitStateBackup); + + // The read every later whole-settings write is built from. + var cached = await h.Service.Get(BackupStore); + Assert.NotNull(cached); + Assert.Null(cached.UnilateralExit!.ExitStateBackup); + } + /// /// Turns the experimental-exit gate on for the duration of a test. /// diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs index e6950e2..0b5b2dd 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkUnilateralExitServiceTests.cs @@ -1611,6 +1611,37 @@ public async Task Clearing_the_backup_removes_the_file() Assert.Contains(StoreId, harness.Backups.DeleteCalls); } + /// + /// A clear press clears the deprecated settings slot as well, without a whole-blob settings write. + /// + /// + /// + /// The banner the operator is shown is the thing at stake. Clearing reports that no backup is + /// stored now and that a restart will import none — and the connect adopts from the deprecated slot + /// whenever the file is empty. So a store that still held a value there (upgraded while the feature was + /// off, or not reconnected since, or adoption's file write having failed) took the blob back on its next + /// connect, under that sentence. + /// + /// + /// Nothing is stored here before the press, which is the state the defect needed: a file that already + /// reads as empty is what the unchanged-value comparison answers about, and the press used to stop there + /// having touched nothing. The empty Writes is the other half — writing the whole settings blob to + /// null one field would reconcile the store's wallet, and a press that stores nothing must not. + /// + /// + [Fact] + public async Task Clearing_the_backup_clears_the_deprecated_slot_without_a_settings_write() + { + using var harness = Harness.Create(); + harness.Configure(acknowledged: true); + harness.Settings.Settings[StoreId]!.UnilateralExit.ExitStateBackup = "legacy-blob"; + + Assert.True((await harness.Service.SetExitStateBackupAsync(StoreId, null, Ct)).Success); + + Assert.Null(harness.Settings.Settings[StoreId]!.UnilateralExit.ExitStateBackup); + Assert.Empty(harness.Settings.Writes); + } + [Fact] public async Task Storing_a_backup_on_an_unconfigured_store_is_refused() { diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs index 4ddff91..32e64d1 100644 --- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs +++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs @@ -1229,6 +1229,15 @@ await _exitStateBackupStore.WriteAsync(storeId, exported, cancellationToken) /// POST rather than GET. This hands out the most sensitive blob the plugin holds, and a GET would put /// the act of taking it into the URL, the access log, and anything that follows a link. /// + /// + /// Not cacheable, and it inherits that. The response is the wallet's whole exit state, and a + /// stored copy of it would live on browser or proxy machinery this plugin does not control. The + /// controller-level [ResponseCache(NoStore = true, Location = None)] is what prevents it: MVC + /// runs that filter before the action, so the header is on the response whatever the action returns — a + /// file body included. Per-controller rather than per-action is this class's own convention (see the + /// note above the attribute); the download's share of it is pinned by a test, since it is the one + /// action on this controller whose body is a secret rather than a page. + /// /// [HttpPost("advanced/exit-state/download")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] diff --git a/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs index ed9a971..169ec07 100644 --- a/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs +++ b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs @@ -94,15 +94,20 @@ public async Task WriteAsync(string storeId, string backup, CancellationToken ca // Written to a sibling rather than in place, then renamed over the target. The overwrite flag is // what makes the move a replace: without it the second backup for a store would throw on the // existing file. - await File.WriteAllTextAsync(temporary, backup, cancellationToken).ConfigureAwait(false); - + // + // The guard covers the write as well as the rename, because a half-written backup is debris + // whichever of the two failed: a full disk, an IO error mid-write, or a cancellation part way + // through a multi-megabyte blob all leave a partial copy of the secret behind, and nothing else + // ever removes it. The target is only ever reached by the rename, so a failed pass here leaves the + // backup that was stored before it exactly as it was — old or new, never a mixture. try { + await File.WriteAllTextAsync(temporary, backup, cancellationToken).ConfigureAwait(false); File.Move(temporary, path, overwrite: true); } catch { - // The half-written temp has no value once the replace failed, and leaving it beside every + // The temp has no value once the write or the replace failed, and leaving it beside every // store's real backup is how a directory of secrets accumulates debris. TryDelete(temporary); throw; diff --git a/BTCPayServer.Plugins.Flint/Services/ISparkStoreSettingsStore.cs b/BTCPayServer.Plugins.Flint/Services/ISparkStoreSettingsStore.cs index 3d493b5..061791d 100644 --- a/BTCPayServer.Plugins.Flint/Services/ISparkStoreSettingsStore.cs +++ b/BTCPayServer.Plugins.Flint/Services/ISparkStoreSettingsStore.cs @@ -57,6 +57,26 @@ public interface ISparkStoreSettingsStore /// Persists settings and reconciles the running instance. Null removes the configuration. Task SetAsync(string storeId, SparkSettings? settings); + + /// + /// Clears a store's deprecated slot — the + /// persisted row and the cached copy alike. Nothing else about the settings moves, and the store's + /// running SDK instance is left alone. + /// + /// + /// + /// The one write on this seam that is not , and it exists because of what + /// costs: it reconciles the live instance, which tears the wallet down and + /// reconnects it. The two callers that empty this slot — a legacy backup being adopted on connect, and + /// an operator clearing the backup from the page — have no business interrupting a wallet for a write + /// that stores nothing. + /// + /// + /// Idempotent, and silent about the value: the slot is where an earlier version of this plugin kept a + /// store's multi-megabyte exit-state secret. + /// + /// + Task ClearExitStateBackupSlotAsync(string storeId); } /// diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index f1d1531..6700677 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -1005,11 +1005,9 @@ private async Task RestoreExitStateAsync(string storeId, ISparkSdkClient sdk) /// ever consulted when the file is absent — so a failed clear costs one retry at most. /// /// - /// The setting is rewritten through the repository, not : Set reconciles the - /// running instance, which would tear down and reconnect the wallet this very connect is warming up, - /// on a path where no operator asked for anything. A concurrent reconfiguration racing this write - /// rewrites the slot from a fresh read rather than a cached one for the same reason: the cached blob - /// may predate a change this method has no business reverting. + /// The clearing itself is 's, shared with the page's own clear of + /// the backup — including why the row is rewritten through the repository rather than + /// . /// /// /// Logs the length and nothing else, as everywhere this blob is handled. @@ -1034,17 +1032,7 @@ await _exitStateBackupStore.WriteAsync(storeId, backup, CancellationToken.None) try { - var stored = await _storeRepository - .GetSettingAsync(storeId, Constants.StoreSettingsKey) - .ConfigureAwait(false); - - if (stored?.UnilateralExit is { } exit && !string.IsNullOrEmpty(exit.ExitStateBackup)) - { - exit.ExitStateBackup = null; - await _storeRepository - .UpdateSetting(storeId, Constants.StoreSettingsKey, stored) - .ConfigureAwait(false); - } + await ClearExitStateBackupSlot(storeId).ConfigureAwait(false); _logger.LogInformation( "Store {StoreId}: adopted an exit-state backup left by an earlier version of this plugin " @@ -1060,6 +1048,59 @@ await _storeRepository } } + /// + /// Clears a store's deprecated slot — the + /// persisted row and the cached instance alike. + /// + /// + /// + /// Both, or the clear did not happen. The cached instance is what every reader gets: + /// hands back a clone of it, and both whole-settings writers rebuild from that clone + /// — SparkUnilateralExitService.SaveExitSettingsAsync clones the settings and the exit section + /// before storing, and SparkStoreProvisioner carries the previous exit settings across a + /// re-provision. A row cleared while the cache kept the blob is therefore a row the next disclosure + /// acknowledgement fills back in, and adoption is never consulted again once the file exists. + /// + /// + /// The row is read fresh rather than taken from the cache, and written through the repository rather + /// than through . Set reconciles the running instance, which would tear + /// down and reconnect a wallet on a path where no operator asked for anything — the connect that is + /// adopting right now, or a merchant's browser. A concurrent reconfiguration racing this write + /// rewrites the slot from a fresh read rather than a cached one for the same reason: the cached blob + /// may predate a change this method has no business reverting. + /// + /// + /// The cache is cleared in place, not dropped. A store missing from the cache reads as "no + /// Spark configuration" to every reader — , , + /// — so invalidating the entry would trade a stale field for a + /// lie about the store existing at all. + /// + /// + /// Row first, cache second, the way orders the same two steps: a process that died + /// between them converges on the stored row at the next startup. Nothing names the value; it is the + /// wallet's whole exit state. + /// + /// + private async Task ClearExitStateBackupSlot(string storeId) + { + var stored = await _storeRepository + .GetSettingAsync(storeId, Constants.StoreSettingsKey) + .ConfigureAwait(false); + + if (stored?.UnilateralExit is { } exit && !string.IsNullOrEmpty(exit.ExitStateBackup)) + { + exit.ExitStateBackup = null; + await _storeRepository + .UpdateSetting(storeId, Constants.StoreSettingsKey, stored) + .ConfigureAwait(false); + } + + // Unconditionally, not only when the row moved: an empty row and a populated cache is a state this + // method has been called to end regardless of which half is holding it. + if (_settings.TryGetValue(storeId, out var cached) && cached.UnilateralExit is { } cachedExit) + cachedExit.ExitStateBackup = null; + } + /// /// Shuts one store's instance down. Caller must hold . Idempotent. /// @@ -1744,6 +1785,10 @@ await _storeRepository Task ISparkStoreSettingsStore.SetAsync(string storeId, SparkSettings? settings) => Set(storeId, settings); + /// + Task ISparkStoreSettingsStore.ClearExitStateBackupSlotAsync(string storeId) => + ClearExitStateBackupSlot(storeId); + /// string ISparkStoreRuntime.GetStorageDirectory(string storeId) => GetWorkDir(storeId); diff --git a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs index 74d9878..5107961 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkUnilateralExitService.cs @@ -591,7 +591,12 @@ public async Task SetExitStateBackupAsync( storeId); } - if (compared && string.Equals(current, normalised, StringComparison.Ordinal)) + // A clear never takes this shortcut. "Nothing stored" is what the file answers for a store whose + // deprecated settings slot still holds a blob — upgraded while the feature was off, or not yet + // reconnected since, or a file write that failed during adoption — and the press that empties + // one location has to empty the other with it. + if (compared && normalised is not null && + string.Equals(current, normalised, StringComparison.Ordinal)) { // No write for a press that changes nothing, and the comparison is by value rather than // by reference so a re-paste of the same blob is also a no-op. This used to be required @@ -608,6 +613,13 @@ public async Task SetExitStateBackupAsync( if (normalised is null) { await _backups.DeleteAsync(storeId, cancellationToken).ConfigureAwait(false); + + // Both locations, or the word "cleared" is a promise the next connect breaks: + // RestoreExitStateAsync adopts from the deprecated slot whenever the file is empty, so a + // store that still holds a value there takes back the blob this press removed — under a + // banner telling the operator a restart will import nothing. + await _settingsStore.ClearExitStateBackupSlotAsync(storeId).ConfigureAwait(false); + _logger.LogInformation( "Store {StoreId}: the stored exit-state backup was cleared", storeId); } diff --git a/BTCPayServer.Plugins.Flint/SparkSettings.cs b/BTCPayServer.Plugins.Flint/SparkSettings.cs index f32abbe..61514b3 100644 --- a/BTCPayServer.Plugins.Flint/SparkSettings.cs +++ b/BTCPayServer.Plugins.Flint/SparkSettings.cs @@ -548,7 +548,10 @@ public class UnilateralExitSettings /// It was moved because it is a multi-megabyte secret and the settings blob is deserialized on /// every settings read. Nothing may treat a non-null value here as "a backup is stored": the file /// is the only answer after a connect has run, and before it neither is complete. Nothing may - /// write a live value here either — the page and the automation both use the file store. + /// write a live value here either — the page and the automation both use the file store, and + /// clearing the backup from the page empties this slot with it + /// (SparkService.ClearExitStateBackupSlot), because a leftover here would be adopted back + /// over the cleared file on the next connect. /// /// public string? ExitStateBackup { get; set; } From b07b1cc184acc9717e540468dbbd9d84b9ce73b9 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:57:18 -0400 Subject: [PATCH 21/22] Give the new-deposit event the refresh request its claim gets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The automatic-backup commit's own message says NewDeposits and ClaimedDeposits both request a backup refresh, but only the claim was given a case: a NewDeposits envelope falls through the consumer's default arm and is logged at trace, so money detected on-chain before its claim requested nothing. The listener maps it; the consumer threw it away. NewDeposits is the claim's precursor, not a leaf yet, so today's exit state does not cover it — but the claim usually lands well inside the two-minute debounce, and a refresh requested here means the pass that runs after it exports the leaf even when the claim event itself is one of the drops the event channel is documented as producing. Logged at debug: the claim's own operator-level line follows it, and a precursor that duplicates it would double-log every deposit. The harness now carries the real scheduler, so the test reads the pending mark directly instead of waiting on a debug log line. --- .../Fakes/SparkServiceHarness.cs | 10 +++++-- .../SparkExitStateAutoBackupTests.cs | 26 +++++++++++++++++++ .../Services/SparkService.cs | 11 ++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs index 6d3a13c..5cfe782 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs @@ -79,7 +79,8 @@ private SparkServiceHarness( Deadlines deadlines, ChainName chain, TimeProvider timeProvider, - IExitStateBackupStore exitStateBackups) + IExitStateBackupStore exitStateBackups, + ExitStateBackupScheduler backupScheduler) { Service = service; Sdk = sdk; @@ -91,6 +92,7 @@ private SparkServiceHarness( _chain = chain; _timeProvider = timeProvider; ExitStateBackups = exitStateBackups; + BackupScheduler = backupScheduler; } public SparkService Service { get; } @@ -128,6 +130,9 @@ private SparkServiceHarness( /// public IExitStateBackupStore ExitStateBackups { get; } + /// The real scheduler the service decides with; the tests read its marks directly. + public ExitStateBackupScheduler BackupScheduler { get; } + /// The BTCPay data directory this service was given, which is where its per-store storage lives. public string DataDir => _dataDir; @@ -296,7 +301,8 @@ private static SparkServiceHarness Create( NullLogger.Instance); return new SparkServiceHarness( - service, sdk, broadcaster, log, dataDir, durable, deadlines, chain, clock, exitStateBackups) + service, sdk, broadcaster, log, dataDir, durable, deadlines, chain, clock, exitStateBackups, + backupScheduler) { Stablecoins = stablecoins }; diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs index 9cb9206..b8c2d6e 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs @@ -187,6 +187,32 @@ await WaitFor(() => h.Log.AllText.Contains("Spark claimed an on-chain deposit"), Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); } + [Fact(Timeout = 60_000)] + public async Task A_new_deposit_event_requests_a_refresh_through_the_same_debounce() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // NewDeposits is the claim's precursor: the listener maps it, and it requests a refresh on the + // same terms as the claim that follows it, so a pass that runs after the debounce exports the + // leaf even if the claim event itself never arrives. The wait is on the scheduler's pending + // mark rather than a log line, because a precursor event is deliberately not operator-level. + Emit(h, StoreId, SparkEventKind.NewDeposits, payment: null); + await WaitFor(() => h.BackupScheduler.PendingSince(StoreId) is not null, + "the new-deposit event never requested a refresh"); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + } + [Fact(Timeout = 60_000)] public async Task An_inbound_payment_event_requests_a_refresh_through_the_same_debounce() { diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index 6700677..1054f51 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -192,6 +192,8 @@ public class SparkService : EventHostedServiceBase, ISparkClientResolver, ISpark /// payment-method handler dictionary this service must not be built from inside (see SparkPlugin). ///
private readonly Func _stablecoinsFactory; + + /// /// Where each store's automatic exit-state backup is kept. One file per store, in a directory of the /// plugin's own; nothing else in this class opens it. /// @@ -1200,6 +1202,15 @@ private async Task HandleEventAsync(SparkEventEnvelope envelope, SparkStoreInsta _logger.LogDebug("Store {StoreId}: Spark payment failed", envelope.StoreId); return; + case SparkEventKind.NewDeposits: + // On-chain money detected before its claim. Not a leaf yet, so today's exit state does not + // cover it — but the claim usually lands well inside the debounce, and a refresh requested + // here means the pass that runs after it exports the leaf even if the claim event itself is + // one of the drops the event channel is documented as producing. + _logger.LogDebug("Store {StoreId}: Spark detected a new on-chain deposit", envelope.StoreId); + _exitStateBackupScheduler.RequestRefresh(envelope.StoreId); + return; + case SparkEventKind.ClaimedDeposits: // Real money arriving on-chain, and the SDK claims it automatically. Worth an operator-level // line; the individual amounts show up as Deposit payments on the same event stream. From afd183fe5859a21a9425e5fcdbe5aa10140c9426 Mon Sep 17 00:00:00 2001 From: Seth For Privacy <40500387+sethforprivacy@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:37:18 -0400 Subject: [PATCH 22/22] Close out the four follow-ups the automatic-backup review found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler's belief about what is stored now moves with every write through the store seam, not just the scheduled pass. A decorator (TrackedExitStateBackupStore) composes the file store with the scheduler in DI, so the manual export, the paste, the clear and the legacy adoption cannot leave the belief stale behind their writes, and no future fifth writer can forget the bookkeeping — at a call site, WriteAsync looks complete without it. Reads stay pure pass-throughs: the pass seeds its own belief from a ReadAsync it makes itself, and incidental reads must not re-seed it. A delete notes null unconditionally once it returns, so a cleared backup self-heals on the next due pass — the page already promises the plugin keeps the backup current on its own. An export that comes back empty with nothing pending now records an idle pass. The pass ran and learned nothing, and recording that is the only thing that stops a wallet with no exit state yet from being asked at the task's one-minute cadence forever; the safety net re-asks it on schedule. A request that IS pending records nothing, exactly as before: it was earned by a real event, an empty answer does not serve it, and the next pass asks again on the event's behalf (ExitStateBackupScheduler.MarkIdlePass). The download streams instead of holding the secret twice in memory: a string read re-encoded to a byte array was the whole multi-megabyte blob twice over to produce one pass-through copy (IExitStateBackupStore.OpenReadAsync, FileShare.Read because a concurrent temp-rename replace is safe on POSIX — the open handle keeps the old inode). The backup file itself is now owner-only, not just the directory holding it: the temp is SetUnixFileMode(0600) before the rename, so no umask window ever carries a readable copy of the wallet's exit data, independent of the directory's 0700. The download's type pin moved FileContentResult to FileStreamResult with every asserted fact kept; the two new OpenReadAsync tests pin the absent-vs-stored answers and the rename-while-open semantics against the real filesystem. A fresh-context verifier confirmed the full acceptance: decorator truth incl. the seeding-after-restart path, streamed bytes byte-for-byte, 0600 mode, both cadence behaviors, and the whole suite (1613 total, 1480 passed, 0 failed, 133 env-gated skips). --- .../ExitStateBackupSchedulerTests.cs | 51 +++++++++ .../Fakes/FakeExitStateBackupStore.cs | 16 +++ .../Fakes/SparkServiceHarness.cs | 12 +- .../FileExitStateBackupStoreTests.cs | 60 ++++++++++ .../SparkExitPageTests.cs | 10 +- .../SparkExitStateAutoBackupTests.cs | 104 ++++++++++++++++++ .../Controllers/SparkController.cs | 20 +++- .../Services/ExitStateBackupScheduler.cs | 31 ++++++ .../Services/FileExitStateBackupStore.cs | 34 ++++++ .../Services/IExitStateBackupStore.cs | 25 +++++ .../Services/SparkService.cs | 17 ++- .../Services/TrackedExitStateBackupStore.cs | 96 ++++++++++++++++ BTCPayServer.Plugins.Flint/SparkPlugin.cs | 10 +- 13 files changed, 473 insertions(+), 13 deletions(-) create mode 100644 BTCPayServer.Plugins.Flint/Services/TrackedExitStateBackupStore.cs diff --git a/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs b/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs index 01f2a86..df2dc1b 100644 --- a/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs @@ -143,6 +143,40 @@ public void A_skipped_pass_serves_the_pending_request() Assert.False(s.ShouldTake(Store, Base + TimeSpan.FromSeconds(1))); } + [Fact] + public void An_idle_pass_with_nothing_pending_waits_the_safety_net_before_being_asked_again() + { + var s = new ExitStateBackupScheduler(); + + // The unfunded wallet: asked, and answering nothing. That answer was still a pass, so the + // next ask is an interval from here rather than the task's next minute — the difference + // between one live export an hour and one every minute forever, for a wallet that has no + // exit state to give. + s.MarkIdlePass(Store, Base); + + Assert.False(s.ShouldTake(Store, Base + Min(30))); + Assert.False(s.ShouldTake(Store, Base + Min(59))); + Assert.True(s.ShouldTake(Store, Base + TimeSpan.FromHours(1))); + } + + [Fact] + public void An_idle_pass_serves_nothing_and_a_later_take_serves_the_request() + { + var s = new ExitStateBackupScheduler(); + s.RequestRefresh(Store); + var started = Pending(s); + + // Empty export with a request pending: the pass learned nothing about the state, so the + // request a real event earned is still owed. Only a pass that can report on the wallet + // serves it. + s.MarkIdlePass(Store, Base); + Assert.Equal(started, s.PendingSince(Store)); + + s.MarkTaken(Store, Base + Min(2)); + Assert.Null(s.PendingSince(Store)); + Assert.False(s.ShouldTake(Store, Base + Min(3))); + } + // ------------------------------------------------------------------ // The content hash: what makes "due" not mean "written" // ------------------------------------------------------------------ @@ -183,4 +217,21 @@ public void Recording_no_stored_content_is_a_known_state_and_any_export_is_then_ Assert.False(s.KnowsStoredContent(Store)); Assert.False(s.ContentUnchanged(Store, "exported-blob")); } + + [Fact] + public void An_idle_pass_leaves_the_belief_about_stored_content_exactly_as_it_found_it() + { + var s = new ExitStateBackupScheduler(); + + // Nothing noted: an export that came back empty is not evidence that the file is absent, and + // a belief of "absent" would have the next due pass rewrite whatever the file does hold. + s.MarkIdlePass(Store, Base); + Assert.False(s.KnowsStoredContent(Store)); + + // And a belief the caller already seeded from the file survives the pass: the empty answer + // says nothing about the file, so it cannot be what the scheduler's belief is rewritten from. + s.NoteStoredContent(Store, "exported-blob"); + s.MarkIdlePass(Store, Base + Min(30)); + Assert.True(s.ContentUnchanged(Store, "exported-blob")); + } } diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs index c500754..4817537 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs @@ -1,3 +1,5 @@ +using System.IO; +using System.Text; using BTCPayServer.Plugins.Flint.Services; namespace BTCPayServer.Plugins.Flint.Tests.Fakes; @@ -45,6 +47,20 @@ public sealed class FakeExitStateBackupStore : IExitStateBackupStore : Task.FromResult(Stored(storeId)); } + // A fresh stream per call, as a file would give: the caller owns and disposes what it opens. The + // bytes are the UTF-8 encoding the file store's own read would produce, so a controller served by + // this fake sees the same payload a controller served by the real store would. + public Task OpenReadAsync(string storeId, CancellationToken cancellationToken = default) + { + if (FailReadWith is { } failure) + return Task.FromException(failure); + + var stored = Stored(storeId); + return stored is null + ? Task.FromResult(null) + : Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(stored))); + } + public Task TakenAtAsync(string storeId, CancellationToken cancellationToken = default) => Task.FromResult(Stored(storeId) is null ? null : TakenAt); diff --git a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs index 5cfe782..79f8ab4 100644 --- a/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs +++ b/BTCPayServer.Plugins.Flint.Tests/Fakes/SparkServiceHarness.cs @@ -261,11 +261,17 @@ private static SparkServiceHarness Create( // The real file store over the harness's temp data directory — the layout, the owner-only // creation, and the atomic replace are what the backup tests assert, and a fake would assert // the fake. One fresh scheduler per construction, as a process restart gets: its whole point is - // that the first pass after a restart re-seeds itself from the file. + // that the first pass after a restart re-seeds itself from the file. And composed exactly as + // the container composes it — the tracked decorator over the file store, both service and test + // handed the decorator — because a test that writes through the store manually (a paste, an + // export) has to see the scheduler's belief move the way it moves in production, or the test + // is wiring a composition production does not have. var dataDirectories = Options.Create(new DataDirectories { DataDir = dataDir }); - var exitStateBackups = new FileExitStateBackupStore( - dataDirectories, NullLogger.Instance); var backupScheduler = new ExitStateBackupScheduler(); + var exitStateBackups = new TrackedExitStateBackupStore( + new FileExitStateBackupStore( + dataDirectories, NullLogger.Instance), + backupScheduler); var clock = timeProvider ?? TimeProvider.System; var service = new TestableSparkService( diff --git a/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs b/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs index ef4f1f1..8a58384 100644 --- a/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/FileExitStateBackupStoreTests.cs @@ -114,6 +114,66 @@ public async Task The_backups_directory_is_owner_only() Assert.Equal(OwnerOnly, File.GetUnixFileMode(store.StorageDirectory())); } + [Fact(Timeout = 60_000)] + public async Task The_backup_file_itself_is_owner_only() + { + Assert.SkipWhen( + !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS(), "Unix file modes."); + + using var dir = new TempDirectory(); + var store = Create(dir); + + await store.WriteAsync(Store, "the-stored-backup-blob"); + + // The 0700 directory keeps other accounts out; this is the second line of defence for the + // one that is already in — the mode is set on the temporary before the rename carries it to + // the target, so the secret is never world-readable under either name, and a reader that + // reaches past the directory still meets a file it cannot open. + Assert.Equal( + UnixFileMode.UserRead | UnixFileMode.UserWrite, + File.GetUnixFileMode(store.PathFor(Store))); + } + + [Fact(Timeout = 60_000)] + public async Task A_stream_with_nothing_stored_answers_null_and_an_open_one_answers_the_stored_bytes() + { + using var dir = new TempDirectory(); + var store = Create(dir); + + // Null, not an empty stream — the download answers "there is no backup" only to a null, and a + // zero-byte file would have read as a backup that imports as nothing. + await using (var absent = await store.OpenReadAsync(Store)) + Assert.Null(absent); + + await store.WriteAsync(Store, "the-stored-backup-blob"); + + // The bytes a download hands over, read back off an open handle: this seam never interprets + // the content, and the streaming read is the same rule — what comes out is what went in. + await using var stream = await store.OpenReadAsync(Store) + ?? throw new InvalidOperationException("the stored backup did not open"); + using var reader = new StreamReader(stream); + Assert.Equal("the-stored-backup-blob", await reader.ReadToEndAsync()); + } + + [Fact(Timeout = 60_000)] + public async Task An_open_download_is_neither_blocked_by_the_next_write_nor_cut_short_by_it() + { + using var dir = new TempDirectory(); + var store = Create(dir); + await store.WriteAsync(Store, "first-backup"); + + // The FileShare promise, as a fact about a filesystem rather than a comment: a pass that + // refreshes the backup halfway through a download renames a new file over the one being + // served, and the handle already open reads to a clean end of the bytes it opened — the + // response is one whole backup or another, never an error and never a mixture. + await using var serving = await store.OpenReadAsync(Store) + ?? throw new InvalidOperationException("the stored backup did not open"); + await store.WriteAsync(Store, "second-backup-longer"); + + using var reader = new StreamReader(serving); + Assert.Equal("first-backup", await reader.ReadToEndAsync()); + } + [Fact] public void A_store_id_that_could_escape_the_owner_only_directory_is_refused() { diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs index 47606a5..aa30ef2 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitPageTests.cs @@ -753,10 +753,16 @@ public async Task The_download_returns_the_stored_bytes_and_the_response_is_not_ ApplyControllerCachePolicy(h.Mvc); - var file = Assert.IsType( + var file = Assert.IsType( await h.Mvc.DownloadExitStateBackup(Store, CancellationToken.None)); - Assert.Equal(Encoding.UTF8.GetBytes(secret), file.FileContents); + // Bytes, still — read out of the stream the action handed the response rather than off a + // byte array the action was holding, because pinning what the operator would actually save is + // the whole point, and a stream the test drains is the download's only full copy. + using var delivered = new MemoryStream(); + await file.FileStream.CopyToAsync(delivered, CancellationToken.None); + Assert.Equal(Encoding.UTF8.GetBytes(secret), delivered.ToArray()); + Assert.Equal("application/octet-stream", file.ContentType); // The stamp is in the name because the artifact lands in a directory the operator keeps, where two diff --git a/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs index b8c2d6e..354bd34 100644 --- a/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs +++ b/BTCPayServer.Plugins.Flint.Tests/SparkExitStateAutoBackupTests.cs @@ -270,6 +270,67 @@ public async Task An_empty_export_stores_nothing() Assert.Null(await h.ExitStateBackups.ReadAsync(StoreId, Ct)); } + [Fact(Timeout = 60_000)] + public async Task A_store_that_always_exports_empty_is_asked_again_only_at_the_safety_net() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + h.Sdk.Clients[StoreId].ExitStateToExport = ""; + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // The first pass learned what this wallet has to give: nothing. That is still a pass, so the + // half-hour pass is not due and the SDK is not asked again — left unrecorded, every minute of + // the task's own cadence would pay for a live export of the same nothing, forever. + clock.Advance(TimeSpan.FromMinutes(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // And the safety net is what re-asks it, because the wallet may have been funded since: an + // hour of silence is exactly what the net exists for. + clock.Advance(TimeSpan.FromMinutes(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + + // Asked twice, answered empty twice, and no file either way. + Assert.Null(await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + + [Fact(Timeout = 60_000)] + public async Task A_pending_request_keeps_asking_on_every_pass_rather_than_the_safety_net() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + h.Sdk.Clients[StoreId].ExitStateToExport = ""; + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Single(h.Sdk.Clients[StoreId].ExitExportCalls); + + // A claim earns a refresh from a wallet that has been answering empty so far. + Emit(h, StoreId, SparkEventKind.ClaimedDeposits, payment: null); + await WaitFor(() => h.BackupScheduler.PendingSince(StoreId) is not null, + "the claimed-deposit event never requested a refresh"); + + clock.Advance(TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(30)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + // The request the event earned is not served by an empty answer: the export ran, the request + // stays pending, and the safety-net clock does not move — which is why the pass a minute + // later is due on the request's own terms rather than an hour from the empty pass. + Assert.Equal(2, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + Assert.NotNull(h.BackupScheduler.PendingSince(StoreId)); + + clock.Advance(TimeSpan.FromMinutes(1)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal(3, h.Sdk.Clients[StoreId].ExitExportCalls.Count); + Assert.NotNull(h.BackupScheduler.PendingSince(StoreId)); + Assert.Null(await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + [Fact(Timeout = 120_000)] public async Task After_a_restart_the_first_pass_learns_from_the_file_instead_of_rewriting_it() { @@ -303,6 +364,49 @@ public async Task After_a_restart_the_first_pass_learns_from_the_file_instead_of } } + [Fact(Timeout = 60_000)] + public async Task A_write_through_the_store_moves_the_scheduler_s_belief_with_the_write() + { + using var gate = FeatureGate(); + using var h = await StartedAsync(new StubTimeProvider(Base)); + + // One manual writer: the page's export, a paste, an adoption all write through this same + // seam without saying anything to the scheduler — and through this seam they cannot, because + // the tracked store moves the scheduler's belief as part of the write itself. + await h.ExitStateBackups.WriteAsync(StoreId, "pasted-exit-state", Ct); + + // What a due pass will ask is now answered from that write rather than from a second read of + // the file: the scheduler knows something is stored, and these exact bytes are it. Left + // stale, the next due pass would compare a fresh export against the nothing it believes + // stored and rewrite these identical bytes once to learn what the paste already knew. + Assert.True(h.BackupScheduler.KnowsStoredContent(StoreId)); + Assert.True(h.BackupScheduler.ContentUnchanged(StoreId, "pasted-exit-state")); + } + + [Fact(Timeout = 60_000)] + public async Task A_cleared_backup_is_written_afresh_by_the_next_due_pass() + { + var clock = new StubTimeProvider(Base); + using var gate = FeatureGate(); + using var h = await StartedAsync(clock); + + await h.Service.TakeDueExitStateBackupsAsync(Ct); + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + + // The page's own clear. The belief has to empty with the file: one that outlived the bytes + // would let a due pass call the next export "unchanged" and skip a store holding nothing — + // a wallet left without its only device-proof copy by the press that was meant to restart + // its coverage. "Absent" is also what the pass then acts on: nothing is believed stored, so + // a clear self-heals on the next due pass rather than waiting on an operator to notice. + Assert.True(await h.ExitStateBackups.DeleteAsync(StoreId, Ct)); + Assert.False(h.BackupScheduler.ContentUnchanged(StoreId, "exit-state-blob")); + + clock.Advance(TimeSpan.FromHours(1)); + await h.Service.TakeDueExitStateBackupsAsync(Ct); + + Assert.Equal("exit-state-blob", await h.ExitStateBackups.ReadAsync(StoreId, Ct)); + } + // ------------------------------------------------------------------------------------------------ // Wiring // ------------------------------------------------------------------------------------------------ diff --git a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs index 32e64d1..b279ad6 100644 --- a/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs +++ b/BTCPayServer.Plugins.Flint/Controllers/SparkController.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; @@ -1238,6 +1237,16 @@ await _exitStateBackupStore.WriteAsync(storeId, exported, cancellationToken) /// note above the attribute); the download's share of it is pinned by a test, since it is the one /// action on this controller whose body is a secret rather than a page. /// + /// + /// Streamed, by design. Nothing on this path reads the content — the point is that the file + /// the plugin holds and the file the operator keeps are one sequence of bytes — so the action never + /// holds it either: a string read of a multi-megabyte blob re-encoded to a byte array for a content + /// result is the secret in memory twice to produce a single pass-through copy, and it is the whole + /// secret at once rather than a buffer at a time. The stream from + /// goes to the response as it is, and + /// disposes the handle when the response pipeline has finished with + /// it. + /// /// [HttpPost("advanced/exit-state/download")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)] @@ -1253,7 +1262,8 @@ public async Task DownloadExitStateBackup( storeId = store.Id; - var backup = await _exitStateBackupStore.ReadAsync(storeId, cancellationToken).ConfigureAwait(false); + var backup = await _exitStateBackupStore.OpenReadAsync(storeId, cancellationToken) + .ConfigureAwait(false); if (backup is null) { TempData[WellKnownTempData.ErrorMessage] = @@ -1264,8 +1274,10 @@ public async Task DownloadExitStateBackup( var takenAt = await _exitStateBackupStore.TakenAtAsync(storeId, cancellationToken).ConfigureAwait(false); + // The length and nothing else, as everywhere this blob is handled — and a file's length now, + // in bytes, which is exactly the size of the artifact being handed over. _logger.LogInformation( - "Store {StoreId}: served the stored exit-state backup ({Length:N0} characters, taken {TakenAt:u})", + "Store {StoreId}: served the stored exit-state backup ({Length:N0} bytes, taken {TakenAt:u})", storeId, backup.Length, takenAt); // The timestamp is in the name because the file is the artifact the operator is storing off-box, and @@ -1274,7 +1286,7 @@ public async Task DownloadExitStateBackup( ? $"exit-state-backup-{storeId}-{at:yyyyMMdd-HHmmss}.txt" : $"exit-state-backup-{storeId}.txt"; - return File(Encoding.UTF8.GetBytes(backup), "application/octet-stream", name); + return new FileStreamResult(backup, "application/octet-stream") { FileDownloadName = name }; } /// diff --git a/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs index c4eae0a..54aa35a 100644 --- a/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs +++ b/BTCPayServer.Plugins.Flint/Services/ExitStateBackupScheduler.cs @@ -160,6 +160,37 @@ public bool ShouldTake(string storeId, DateTimeOffset now) /// public void MarkSkipped(string storeId, DateTimeOffset now) => MarkPass(storeId, now); + /// + /// Records that a pass ran and learned nothing worth acting on: the safety net restarts, and + /// nothing else moves — a pending request stays pending, and no stored content is noted. + /// + /// + /// + /// For the pass an empty export produces: the wallet was asked and said nothing, so the pass has + /// no report on the state to serve a request with, and no bytes to update a belief about the file + /// with. Recording it anyway is the point — a wallet with no exit state yet has a null + /// LastPassAt, which reads as due on every pass, so an unrecorded empty export leaves the + /// task spending a live SDK call on that store every minute, forever. With the pass on the record, + /// the next ask comes at : the cadence an answering-nothing wallet + /// deserves. + /// + /// + /// is the contrast, and the reason this is not the same call: a skip is a + /// report on the wallet's state — the export came back and matched what is stored — so it serves + /// the pending request. An idle pass is no such report, and a request a real event earned stays + /// owed until a pass can serve it. + /// + /// + public void MarkIdlePass(string storeId, DateTimeOffset now) + { + ArgumentException.ThrowIfNullOrEmpty(storeId); + + _marks.AddOrUpdate( + storeId, + _ => new Marks(null, now, null), + (_, current) => current with { LastPassAt = now }); + } + /// /// Whether the scheduler knows what content is believed stored for a store. /// diff --git a/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs index 169ec07..9fc3183 100644 --- a/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs +++ b/BTCPayServer.Plugins.Flint/Services/FileExitStateBackupStore.cs @@ -103,6 +103,18 @@ public async Task WriteAsync(string storeId, string backup, CancellationToken ca try { await File.WriteAllTextAsync(temporary, backup, cancellationToken).ConfigureAwait(false); + + // Owner-only before it is ever a stored backup, never afterwards: the rename carries the + // temp's mode onto the target, so this is the only moment the mode can be set without a + // window in which the file sits under its final name at the umask — readable by every + // account on the host for as long as a correction takes, and nothing here corrects it + // afterwards. The 0700 directory is the first line of defence for this blob; a file that is + // itself 0600 means a second one even against a reader that reaches the directory some + // other way — the same account running BTCPay under a different path, an operator's own + // backup tool reading the tree. + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); + File.Move(temporary, path, overwrite: true); } catch @@ -125,6 +137,28 @@ public Task DeleteAsync(string storeId, CancellationToken cancellationToke return Task.FromResult(true); } + /// + public Task OpenReadAsync(string storeId, CancellationToken cancellationToken = default) + { + var path = PathFor(storeId); + + // Absent answers null, as ReadAsync does, and a file that cannot be opened throws for the same + // reason: the caller redirects on "none stored" and reports "unreadable", and fusing the two + // would tell an operator who pressed Download that they have no backup. Opening is a handle, not + // the content, so there is nothing to await. + // + // FileShare.Read: opening a download must not exclude anything else from the file — a second + // download, or a ReadAsync comparing a paste — and no writer ever opens the target, because + // WriteAsync reaches it only by renaming a temp over it. That replacement is exactly the + // concurrent write this share pattern tolerates: on POSIX a rename swaps the directory entry + // while an already-open handle keeps reading the old inode to a clean end, so a pass that + // refreshes the backup halfway through a download serves one whole file or the other, never a + // mixture, and never a sharing error. + return File.Exists(path) + ? Task.FromResult(File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + : Task.FromResult(null); + } + /// /// The directory this store owns: a sibling of the SDK's per-store storage, created and restricted /// exactly as that one is. diff --git a/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs index de8944b..64807a4 100644 --- a/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs +++ b/BTCPayServer.Plugins.Flint/Services/IExitStateBackupStore.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading; using System.Threading.Tasks; @@ -43,6 +44,30 @@ public interface IExitStateBackupStore /// Task ReadAsync(string storeId, CancellationToken cancellationToken = default); + /// + /// Opens the stored backup as a readable stream, or null when none is stored. + /// + /// + /// + /// The read for a caller that only moves the content. A download wants the file's bytes on a + /// response and never looks at them; answering it with plus an encoding of the + /// result puts a multi-megabyte secret in memory twice — the string read, then the byte array handed + /// on — to produce a single pass-through copy. A stream is the same answer a buffer at a time, and it + /// is the only way this seam serves the blob without holding it. + /// + /// + /// Distinct from , not a second spelling of it. The string read is for a + /// caller that must judge the content — compare it against a paste, import it, decide what it + /// says; this one is for a caller that must not, and a caller that streams should never read the + /// stream back into a string. + /// + /// + /// Like , an open that cannot be answered is not swallowed into a null: + /// "absent" is a fact the caller redirects with; "unreadable" is a fault the caller surfaces. + /// + /// + Task OpenReadAsync(string storeId, CancellationToken cancellationToken = default); + /// /// When the backup was written, or null when none is stored. /// diff --git a/BTCPayServer.Plugins.Flint/Services/SparkService.cs b/BTCPayServer.Plugins.Flint/Services/SparkService.cs index 1054f51..c149ac9 100644 --- a/BTCPayServer.Plugins.Flint/Services/SparkService.cs +++ b/BTCPayServer.Plugins.Flint/Services/SparkService.cs @@ -1501,8 +1501,16 @@ public async Task TakeDueExitStateBackupsAsync(CancellationToken cancellationTok if (string.IsNullOrWhiteSpace(exported)) { - // No MarkSkipped: an empty answer is not a report on the wallet's state, and serving - // a pending request with nothing would silently drop it. The next pass asks again. + // No MarkSkipped on either path: an empty answer is not a report on the wallet's + // state, and serving a pending request with nothing would silently drop it. With + // nothing pending, though, the pass itself is worth recording — it is the only + // thing that stops a wallet with no exit state to export from being asked on every + // scheduled pass forever, and the safety net re-asks it on schedule. A request that + // is pending was earned by a real event and an empty answer does not serve it: + // nothing is recorded, so the next pass asks again on the event's behalf. + if (_exitStateBackupScheduler.PendingSince(storeId) is null) + _exitStateBackupScheduler.MarkIdlePass(storeId, now); + _logger.LogInformation( "Store {StoreId}: its exit-state export came back empty, so nothing was stored", storeId); @@ -1528,7 +1536,10 @@ await _exitStateBackupStore.ReadAsync(storeId, cancellationToken) await _exitStateBackupStore.WriteAsync(storeId, exported, cancellationToken) .ConfigureAwait(false); - _exitStateBackupScheduler.NoteStoredContent(storeId, exported); + // The tracked store seam has already moved the scheduler's belief to these bytes — + // a second note here would only be a second place the same fact gets stated, and the + // one that a manual writer's path does not share. MarkTaken is this pass's own + // report, ordered after the write, and nothing else can serve the pending request. _exitStateBackupScheduler.MarkTaken(storeId, now); _logger.LogInformation( diff --git a/BTCPayServer.Plugins.Flint/Services/TrackedExitStateBackupStore.cs b/BTCPayServer.Plugins.Flint/Services/TrackedExitStateBackupStore.cs new file mode 100644 index 0000000..ac21f2b --- /dev/null +++ b/BTCPayServer.Plugins.Flint/Services/TrackedExitStateBackupStore.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace BTCPayServer.Plugins.Flint.Services; + +/// +/// The everything else resolves: the file store, with every write +/// and every delete moving the 's belief about what is stored. +/// +/// +/// +/// Why a decorator rather than a call at each writer. The scheduler keeps a per-store hash of +/// the content it believes is stored, and a hash that disagrees with the file is a decision made +/// against a fiction in both directions: a belief left stale by a manual write calls the next due +/// pass's export "unchanged" only until it does not — one stale pass rewrites a multi-megabyte blob to +/// say what the manual write already said — and a belief that survives a deletion lets a pass skip a +/// store whose file no longer exists at all, which is a store silently left without its only +/// device-proof copy. The writers that reach this seam are not one: the page's export, the paste and +/// the clear, the adoption of a backup left at the old settings location, and the scheduled pass all +/// write through it, and a fifth is one feature away from forgetting the scheduler call — because at +/// a call site, store.WriteAsync looks complete without it. Through this type the promise is +/// structural: the only published path to the file carries the bookkeeping with it. +/// +/// +/// Reads stay pure pass-throughs, deliberately. The scheduled pass seeds the scheduler from a +/// it makes itself, at the one moment the seed is wanted — first contact with a +/// store after a restart — and hooks here would take that decision away from it: every incidental read +/// (a paste comparison, a page's staleness check, a download) would silently re-seed the belief from +/// whatever was on disk, and a read racing a write would launder a half-truth into the scheduler as +/// knowledge. +/// +/// +/// The belief follows the call's outcome, never its intent. A write that throws leaves the +/// previous file exactly as it was, so the note belongs only after a successful one, with the content +/// that write carried. A delete is the interesting case and its note is unconditional, not +/// if (deleted): after a delete that returned, the file is absent whether it removed something +/// or found nothing to remove, and "nothing stored" is the only belief true for both answers — a +/// caller that learned "there was none" while keeping the old hash would be keeping a claim about a +/// file it has just confirmed is not there. A delete that throws is different again and notes nothing: +/// whether the file survived a failed removal is unknown, and the previous belief, however stale, +/// beats guessing a store empty when it may hold the wallet's only copy. The consequence of the null +/// is intended and worth stating plainly: a cleared backup is believed absent, so the next due pass +/// treats whatever it exports as changed and writes it — a clear self-heals rather than leaving the +/// store uncovered, which is what the page promises when it says the plugin keeps the backup current +/// on its own. +/// +/// +public sealed class TrackedExitStateBackupStore : IExitStateBackupStore +{ + private readonly IExitStateBackupStore _inner; + private readonly ExitStateBackupScheduler _scheduler; + + public TrackedExitStateBackupStore( + IExitStateBackupStore inner, + ExitStateBackupScheduler scheduler) + { + _inner = inner; + _scheduler = scheduler; + } + + /// + public Task ReadAsync(string storeId, CancellationToken cancellationToken = default) => + _inner.ReadAsync(storeId, cancellationToken); + + /// + public Task TakenAtAsync(string storeId, CancellationToken cancellationToken = default) => + _inner.TakenAtAsync(storeId, cancellationToken); + + /// + public Task OpenReadAsync(string storeId, CancellationToken cancellationToken = default) => + _inner.OpenReadAsync(storeId, cancellationToken); + + /// + public async Task WriteAsync(string storeId, string backup, CancellationToken cancellationToken = default) + { + await _inner.WriteAsync(storeId, backup, cancellationToken).ConfigureAwait(false); + + // Only after the write landed — noting a failed one would teach the scheduler a file the + // catch in every caller knows was never written. The content stays a reference long enough to + // hash inside the scheduler, which keeps only the digest. + _scheduler.NoteStoredContent(storeId, backup); + } + + /// + public async Task DeleteAsync(string storeId, CancellationToken cancellationToken = default) + { + var deleted = await _inner.DeleteAsync(storeId, cancellationToken).ConfigureAwait(false); + + // Unconditional — see the type remarks: the call having returned is itself the whole + // justification, and whether it removed something or not adds nothing to it. + _scheduler.NoteStoredContent(storeId, null); + return deleted; + } +} diff --git a/BTCPayServer.Plugins.Flint/SparkPlugin.cs b/BTCPayServer.Plugins.Flint/SparkPlugin.cs index b2a4034..aa7e15e 100644 --- a/BTCPayServer.Plugins.Flint/SparkPlugin.cs +++ b/BTCPayServer.Plugins.Flint/SparkPlugin.cs @@ -146,8 +146,16 @@ public override void Execute(IServiceCollection services) // debounce/safety-net decisions, and the pass that applies them. Registered unconditionally like // the rest of the exit surface — the gate is enforced inside the pass, not by whether the // types exist. - services.AddSingleton(); + // + // The file store is registered as itself only, and the seam published to callers is the + // tracking decorator: every write and every delete through the store has to move the + // scheduler's belief about what is stored with it, and a caller cannot forget the bookkeeping + // when the only published route to the file passes through it. + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(provider => new TrackedExitStateBackupStore( + provider.GetRequiredService(), + provider.GetRequiredService())); // The setup flow's decisions, kept out of the controller so they can be tested. services.AddSingleton();