Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
495f8c8
Add unilateral exit quote/build methods to the SDK seam
sethforprivacy Aug 20, 2026
e4fdd78
Add unilateral exit settings section and experimental feature gate
sethforprivacy Aug 20, 2026
8580ede
Add the unilateral exit record store with a one-active-exit constraint
sethforprivacy Aug 20, 2026
3ce4e61
Add the unilateral exit service: quoting, funding discovery, and signing
sethforprivacy Aug 20, 2026
f508c65
Add the unilateral exit page behind Advanced settings
sethforprivacy Aug 20, 2026
995f42e
Update docs and copy for the experimental unilateral exit
sethforprivacy Aug 20, 2026
bce853e
Bump Breez.Sdk.Spark to 0.25.0 and rework the exit seam for the new API
sethforprivacy Sep 14, 2026
3dcf19e
Rework the unilateral exit for the Breez SDK 0.25 flow: check-in, sta…
sethforprivacy Sep 15, 2026
b2d2cff
Ground the CheckAsync leaf placeholder in the SDK's documented contract
sethforprivacy Sep 15, 2026
cc27413
Import the exit-state backup when the wallet starts
sethforprivacy Sep 15, 2026
d3cfd20
Say where a failed exit-state import shows up
sethforprivacy Sep 15, 2026
25f2e32
Import the exit-state backup before the wallet's first sync, as docum…
sethforprivacy Sep 15, 2026
a2f57fb
Keep the lock files' transitive pins at what main resolves
sethforprivacy Sep 15, 2026
25c7282
Test a unilateral exit end to end against the local Spark stack
sethforprivacy Sep 15, 2026
9a90fcf
Regenerate both lock files against the pinned btcpayserver, not the l…
sethforprivacy Sep 15, 2026
b49e6b9
Document what the end-to-end exit test costs the fixture
sethforprivacy Sep 15, 2026
91f9129
Exit page copy, and a mempool.space-backed fee-rate default
sethforprivacy Sep 16, 2026
b0e6b75
Render the exit page's states to HTML for copy and layout review
sethforprivacy Sep 16, 2026
9889f5b
Take the exit-state backup automatically
sethforprivacy Sep 16, 2026
8cdb275
Fix what the verifier found in the automatic backup
sethforprivacy Sep 16, 2026
b07b1cc
Give the new-deposit event the refresh request its claim gets
sethforprivacy Sep 24, 2026
afd183f
Close out the four follow-ups the automatic-backup review found
sethforprivacy Sep 24, 2026
f602951
Merge the USDC/USDT branch (#85) into the unilateral exit branch
sethforprivacy Sep 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/local-regtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
237 changes: 237 additions & 0 deletions BTCPayServer.Plugins.Flint.Tests/ExitStateBackupSchedulerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
using BTCPayServer.Plugins.Flint.Services;
using Xunit;

namespace BTCPayServer.Plugins.Flint.Tests;

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// Pure scheduler, no harness: every decision takes its <c>now</c> as an argument, and the one clock the
/// scheduler reads itself — the moment <see cref="ExitStateBackupScheduler.RequestRefresh"/> records — is
/// exposed by <c>PendingSince</c> precisely so these assertions run against the value the decisions
/// actually use rather than a second reading of the wall.
/// </para>
/// <para>
/// These are the decisions the whole feature is made of; the IO and the wiring are covered where they
/// live, in <c>FileExitStateBackupStoreTests</c> and <c>SparkExitStateAutoBackupTests</c>.
/// </para>
/// </remarks>
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);

/// <summary>The moment a pending request was recorded, or fail — the debounce has no other start.</summary>
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)));
}

[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"
// ------------------------------------------------------------------

[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"));
}

[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"));
}
}
82 changes: 82 additions & 0 deletions BTCPayServer.Plugins.Flint.Tests/Fakes/FakeExitStateBackupStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.IO;
using System.Text;
using BTCPayServer.Plugins.Flint.Services;

namespace BTCPayServer.Plugins.Flint.Tests.Fakes;

/// <summary>
/// In-memory <see cref="IExitStateBackupStore"/> for tests whose subject is a <em>caller</em> of the
/// store — what it writes, what it refuses, and what it leaves untouched on failure.
/// </summary>
/// <remarks>
/// Deliberately not used by anything that tests the store's own behaviour (the layout, the atomic
/// replace, the permissions): those tests run the real <see cref="FileExitStateBackupStore"/> 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.
/// </remarks>
public sealed class FakeExitStateBackupStore : IExitStateBackupStore
{
private readonly Dictionary<string, string> _files = [];

/// <summary>Every store id the method was called with, in call order.</summary>
public List<string> ReadCalls { get; } = [];

/// <summary>Every store id the method was called with, in call order.</summary>
public List<string> WriteCalls { get; } = [];

/// <summary>Every store id the method was called with, in call order.</summary>
public List<string> DeleteCalls { get; } = [];

/// <summary>Thrown by every read while set.</summary>
public Exception? FailReadWith { get; set; }

/// <summary>Thrown by every write while set.</summary>
public Exception? FailWriteWith { get; set; }

/// <summary>The write time every stored file reports; the fake keeps one stamp for all of them.</summary>
public DateTimeOffset? TakenAt { get; set; }

/// <summary>What is stored for a store, or null when nothing is. Reads the subject's own writes.</summary>
public string? Stored(string storeId) => _files.GetValueOrDefault(storeId);

public Task<string?> ReadAsync(string storeId, CancellationToken cancellationToken = default)
{
ReadCalls.Add(storeId);
return FailReadWith is { } failure
? Task.FromException<string?>(failure)
: 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<Stream?> OpenReadAsync(string storeId, CancellationToken cancellationToken = default)
{
if (FailReadWith is { } failure)
return Task.FromException<Stream?>(failure);

var stored = Stored(storeId);
return stored is null
? Task.FromResult<Stream?>(null)
: Task.FromResult<Stream?>(new MemoryStream(Encoding.UTF8.GetBytes(stored)));
}

public Task<DateTimeOffset?> 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<bool> DeleteAsync(string storeId, CancellationToken cancellationToken = default)
{
DeleteCalls.Add(storeId);
return Task.FromResult(_files.Remove(storeId));
}
}
Loading
Loading