From 2703f10453999dd836ee3caf27d78c88fe96d265 Mon Sep 17 00:00:00 2001 From: chavic Date: Tue, 28 Jul 2026 16:06:26 +0200 Subject: [PATCH 1/3] Bump rust-payjoin to 1.0.0-rc.6 Move the submodule from f4a6008a to the payjoin-1.0.0-rc.6 release commit and adapt to the FFI surface changes: - PjParseException is now UriParseException. - BuildRecommended throws SenderInputException, and persisted sender errors are now SenderPersistedException.Transient/Fatal wrapping a SenderException, so the receiver-rejection handling matches SenderException.Response through the wrapper. - IPayjoinProposal drops UtxosToBeLocked and gains ProposalTxidIsStable. rc.5-format closed-session logs recorded scripts and witnesses in Closed(Success); rc.6 records a txid, so such logs fail replay after this bump. Every replay site already catches ReceiverReplayException and removes the session, which is the same terminal outcome a closed session gets on the next poller tick. --- .../TestUtils/PayjoinTestPayer.cs | 20 ++++++++++++++----- .../PayjoinSettlementFlowTests.cs | 2 +- .../PayjoinReceiverProposalFinalizerTests.cs | 4 ++-- .../Controllers/UIPayJoinController.cs | 2 +- .../PayjoinInvoicePaymentUrlService.cs | 2 +- .../Services/RunTestPaymentService.cs | 16 ++++++++++++--- rust-payjoin | 2 +- 7 files changed, 34 insertions(+), 14 deletions(-) diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs index 4f75042..5d77373 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinTestPayer.cs @@ -138,7 +138,7 @@ private async Task CreateSenderPsbtAsync(SystemUri payme paymentAmount = Money.Satoshis(checked((long)amountSats.Value)).ToDecimal(MoneyUnit.BTC); using var _ = parsedUri.CheckPjSupported(); } - catch (PjParseException ex) + catch (UriParseException ex) { throw new InvalidOperationException($"Invalid BIP21 URI '{paymentUrl}': {ex.Message}", ex); } @@ -233,9 +233,9 @@ private async Task RequestProposalAsync(SystemUri paymentUrl, IReadOnlyLis } proposalPsbtBase64 = await PollForProposalAsync(current, senderPersister, ohttpRelayUrls, cancellationToken).ConfigureAwait(false); } - catch (SenderPersistedException.ResponseException ex) + catch (SenderPersistedException ex) when (SenderResponseOf(ex) is { } senderResponse) { - throw CreateSenderResponseFailure(ex); + throw CreateSenderResponseFailure(ex, senderResponse); } if (string.IsNullOrWhiteSpace(proposalPsbtBase64)) @@ -355,13 +355,23 @@ private async Task RequestProposalAsync(SystemUri paymentUrl, IReadOnlyLis throw new InvalidOperationException($"All configured OHTTP relays failed for payer store '{_payer.StoreId}'. LastError='{lastError?.Message}'", lastError); } - private InvalidOperationException CreateSenderResponseFailure(SenderPersistedException.ResponseException ex) + private InvalidOperationException CreateSenderResponseFailure(SenderPersistedException ex, global::Payjoin.ResponseException senderResponse) { return new InvalidOperationException( - $"Payjoin receiver rejected the proposal for payer store '{_payer.StoreId}': {FormatSenderResponseException(ex.v1)}", + $"Payjoin receiver rejected the proposal for payer store '{_payer.StoreId}': {FormatSenderResponseException(senderResponse)}", ex); } + private static global::Payjoin.ResponseException? SenderResponseOf(SenderPersistedException exception) + { + return exception switch + { + SenderPersistedException.Fatal { v1: SenderException.Response response } => response.v1, + SenderPersistedException.Transient { v1: SenderException.Response response } => response.v1, + _ => null + }; + } + private static string FormatSenderResponseException(global::Payjoin.ResponseException responseException) { return responseException switch diff --git a/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs index 807f288..8da97f9 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/PayjoinSettlementFlowTests.cs @@ -407,7 +407,7 @@ private sealed class FixedPsbtProposal(string psbt) : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => psbt; - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class NoOpSessionProcessor : IPayjoinReceiverSessionProcessor diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs index b44b857..d2fbd49 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs @@ -170,7 +170,7 @@ private sealed class FixedPsbtProposal(string psbt) : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => psbt; - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class ThrowingProposal : IPayjoinProposal @@ -179,7 +179,7 @@ private sealed class ThrowingProposal : IPayjoinProposal public RequestResponse CreatePostRequest(string ohttpRelay) => throw new NotSupportedException(); public PayjoinProposalTransition ProcessResponse(byte[] body, ClientResponse ohttpContext) => throw new NotSupportedException(); public string Psbt() => throw new NotSupportedException(); - public PayjoinOutPoint[] UtxosToBeLocked() => throw new NotSupportedException(); + public bool ProposalTxidIsStable() => throw new NotSupportedException(); } private sealed class UnusedRelayRequestSender : IPayjoinReceiverRelayRequestSender diff --git a/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs b/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs index 3a5fced..c80f560 100644 --- a/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs +++ b/BTCPayServer.Plugins.Payjoin/Controllers/UIPayJoinController.cs @@ -145,7 +145,7 @@ public async Task> RunTestPayment([FromBody paymentAmount = Money.Satoshis(checked((long)amountSats.Value)).ToDecimal(MoneyUnit.BTC); using var _ = parsedUri.CheckPjSupported(); } - catch (PjParseException ex) + catch (UriParseException ex) { return RunTestPaymentFailure($"Invalid BIP21 URI: {ex.Message}"); } diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs index 07cec2b..70fe649 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinInvoicePaymentUrlService.cs @@ -151,7 +151,7 @@ private static bool IsPayjoinEnabled(string paymentUrl) using var _ = parsedUri.CheckPjSupported(); return true; } - catch (PjParseException) + catch (UriParseException) { return false; } diff --git a/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs b/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs index 2af20a7..a0636d9 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/RunTestPaymentService.cs @@ -272,13 +272,13 @@ private async Task RequestProposalAsync( current.Dispose(); } } - catch (BuildSenderException ex) + catch (SenderInputException ex) { throw new RunTestPaymentExecutionException($"Sender build failed: {ex.Message}", ex); } - catch (SenderPersistedException.ResponseException ex) + catch (SenderPersistedException ex) when (SenderResponseOf(ex) is { } senderResponse) { - throw new RunTestPaymentExecutionException($"Sender rejected by receiver: {FormatSenderResponseException(ex.v1)}", ex); + throw new RunTestPaymentExecutionException($"Sender rejected by receiver: {FormatSenderResponseException(senderResponse)}", ex); } catch (HttpRequestException ex) { @@ -442,6 +442,16 @@ private static IReadOnlyList GetConfiguredRelayUrls(RunTestPaymentCon throw new RunTestPaymentExecutionException("No OHTTP relays configured for test payment."); } + private static global::Payjoin.ResponseException? SenderResponseOf(SenderPersistedException exception) + { + return exception switch + { + SenderPersistedException.Fatal { v1: SenderException.Response response } => response.v1, + SenderPersistedException.Transient { v1: SenderException.Response response } => response.v1, + _ => null + }; + } + private static string FormatSenderResponseException(global::Payjoin.ResponseException responseException) { return responseException switch diff --git a/rust-payjoin b/rust-payjoin index f4a6008..dee54d6 160000 --- a/rust-payjoin +++ b/rust-payjoin @@ -1 +1 @@ -Subproject commit f4a6008a3a531530434bdd6911db864bcb42fc66 +Subproject commit dee54d6512f6860ffefefa93681694a623ab2328 From 03f2fb9023ea33ca9d3629ebbb6d6199d01656ad Mon Sep 17 00:00:00 2001 From: chavic Date: Fri, 3 Jul 2026 16:56:44 +0200 Subject: [PATCH 2/3] Reset the accounting record when a receiver session is recreated An invoice can get a fresh receiver session while its accounting record still carries tracking data from a previous session. The fallback-attach guard would then hold on to the old fallback while later writes replace the settlement script and expected transaction, leaving one record describing two different sessions and reconciliation targeting the wrong outpoint. Creating a fresh session now clears the previous session's tracking data from a pending record and revives an unexpired-by-review record for the new attempt; reconciled records are final and failed or expired records that already awaited a final transaction stay untouched for operator review. --- .../PayjoinAccountingBridgeResetTests.cs | 156 ++++++++++++++++++ .../Services/PayjoinReceiverPollerTests.cs | 2 + .../PayjoinReceiverProposalFinalizerTests.cs | 1 + .../PayjoinReceiverSessionProcessorTests.cs | 1 + .../PayjoinAccountingBridgeService.cs | 48 ++++++ .../Services/PayjoinUriSessionService.cs | 16 +- 6 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs new file mode 100644 index 0000000..defb677 --- /dev/null +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs @@ -0,0 +1,156 @@ +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Plugins.Payjoin.Data; +using BTCPayServer.Plugins.Payjoin.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Options; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; +using System.Linq; +using Xunit; + +namespace BTCPayServer.Plugins.Payjoin.Tests.Services; + +public class PayjoinAccountingBridgeResetTests +{ + private const string ExpectedTransactionId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string FallbackTransactionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + [Fact] + public async Task ResetForNewSessionAsyncClearsPriorSessionTrackingOnPendingBridges() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + await service.AttachFallbackAsync("invoice-1", FallbackTransactionId, 0, 900, 900, "CCDD", CancellationToken.None); + await service.SetExpectedFinalTransactionAsync("invoice-1", ExpectedTransactionId, 1, 950, CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1200, now.AddHours(2), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFallback, reset!.Status); + Assert.Null(reset.FallbackTransactionId); + Assert.Null(reset.SettlementScript); + Assert.Null(reset.ExpectedFinalTransactionId); + Assert.Null(reset.ExpectedFinalOutputIndex); + Assert.Null(reset.ExpectedFinalValueSats); + Assert.Equal(1200, reset.EffectiveInvoiceValueSats); + Assert.Equal(now.AddHours(2), reset.ExpiresAt); + } + + [Fact] + public async Task ResetForNewSessionAsyncRevivesUnarmedExpiredBridges() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddMinutes(-5)); + await service.ExpirePendingAsync(now, CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1000, now.AddHours(1), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFallback, reset!.Status); + Assert.Equal(now.AddHours(1), reset.ExpiresAt); + } + + [Fact] + public async Task ResetForNewSessionAsyncLeavesArmedExpiredAndFailedBridgesForReview() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-armed", now.AddHours(1), ExpectedTransactionId); + await CreateBridgeAsync(service, "invoice-failed", now.AddHours(1)); + await service.MarkFailedAsync("invoice-failed", "problem", CancellationToken.None); + using (var db = context.CreateDbContext()) + { + var armed = db.AccountingBridges.Single(x => x.InvoiceId == "invoice-armed"); + armed.Status = PayjoinAccountingBridgeStatus.Expired; + db.SaveChanges(); + } + + var armedResult = await service.ResetForNewSessionAsync("invoice-armed", 1000, now.AddHours(2), CancellationToken.None); + var failedResult = await service.ResetForNewSessionAsync("invoice-failed", 1000, now.AddHours(2), CancellationToken.None); + + Assert.Equal(PayjoinAccountingBridgeStatus.Expired, armedResult!.Status); + Assert.Equal(ExpectedTransactionId, armedResult.ExpectedFinalTransactionId); + Assert.Equal(PayjoinAccountingBridgeStatus.Failed, failedResult!.Status); + Assert.Equal("problem", failedResult.FailureMessage); + } + + [Fact] + public async Task ResetForNewSessionAsyncLeavesFreshPendingBridgesUntouched() + { + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + var created = await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 5000, now.AddHours(3), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(created.EffectiveInvoiceValueSats, reset!.EffectiveInvoiceValueSats); + Assert.Equal(created.ExpiresAt, reset.ExpiresAt); + } + + private static Task CreateBridgeAsync( + PayjoinAccountingBridgeService service, + string invoiceId, + DateTimeOffset? expiresAt, + string? expectedFinalTransactionId = null) + { + return service.CreateOrGetAsync( + new CreatePayjoinAccountingBridgeRequest( + invoiceId, + "store-1", + PayjoinConstants.BitcoinCode, + "BTC-BTC", + expiresAt, + EffectiveInvoiceValueSats: 1000, + ExpectedFinalTransactionId: expectedFinalTransactionId), + CancellationToken.None); + } + + private sealed class TestContext : IDisposable + { + private readonly TestDbContextFactory _dbContextFactory = new(); + private readonly PostgresPayjoinUniqueConstraintViolationDetector _uniqueConstraintViolationDetector = new(); + + public PayjoinAccountingBridgeService CreateService() => new(_dbContextFactory, _uniqueConstraintViolationDetector); + + public PayjoinPluginDbContext CreateDbContext() => _dbContextFactory.CreateContext(); + + public void Dispose() + { + using var db = _dbContextFactory.CreateContext(); + db.Database.EnsureDeleted(); + } + } + + private sealed class TestDbContextFactory : PayjoinPluginDbContextFactory + { + private static readonly InMemoryDatabaseRoot SharedDatabaseRoot = new(); + private readonly DbContextOptions _dbContextOptions; + + public TestDbContextFactory() + : base(Options.Create(new DatabaseOptions + { + ConnectionString = "Host=localhost;Database=payjoin-plugin-tests;Username=postgres" + })) + { + var databaseName = $"payjoin-bridge-reset-tests-{Guid.NewGuid():N}"; + _dbContextOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName, SharedDatabaseRoot) + .Options; + + using var db = CreateContext(); + db.Database.EnsureCreated(); + } + + public override PayjoinPluginDbContext CreateContext(Action? npgsqlOptionsAction = null) + { + return new PayjoinPluginDbContext(_dbContextOptions); + } + } +} diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs index 2b42bd2..3aaa68a 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs @@ -297,6 +297,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult>([]); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0)); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService @@ -335,6 +336,7 @@ public Task> GetPendingAsync(D LastMarkedFailedMessage = failureMessage; return Task.FromResult(null); } + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) { ExpirePendingInvocationCount++; diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs index d2fbd49..9acceb7 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverProposalFinalizerTests.cs @@ -219,5 +219,6 @@ private sealed class RecordingBridgeService(PayjoinAccountingBridgeState? bridge public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => throw new NotSupportedException(); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => throw new NotSupportedException(); } } diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs index baaa431..072db33 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverSessionProcessorTests.cs @@ -290,6 +290,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi public Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult>([]); public Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0)); public Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult(null); + public Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult(null); } private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs index 949d651..86cdce0 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs @@ -74,6 +74,8 @@ internal interface IPayjoinAccountingBridgeService Task GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken); Task TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken); + + Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken); } internal sealed class PayjoinAccountingBridgeService : IPayjoinAccountingBridgeService @@ -240,6 +242,52 @@ public async Task> GetPendingA cancellationToken); } + public async Task ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) + { + using var context = _dbContextFactory.CreateContext(); + var bridge = await TryLoadByInvoiceIdAsync(context, invoiceId, cancellationToken).ConfigureAwait(false); + if (bridge is null) + { + return null; + } + + // A freshly created receiver session produces its own fallback, settlement script and final + // transaction, so tracking data from a previous session on the same invoice no longer applies: + // the fallback-attach guard would hold on to the old fallback while later writes would overwrite + // the rest, leaving the record describing two different sessions at once. Reconciled records are + // final, and failed or expired records that already awaited a final transaction stay untouched + // for operator review. + var isResettablePending = bridge.Status is PayjoinAccountingBridgeStatus.PendingFallback or PayjoinAccountingBridgeStatus.PendingFinalTransaction; + var isResettableExpired = bridge.Status == PayjoinAccountingBridgeStatus.Expired && bridge.ExpectedFinalTransactionId is null; + if (!isResettablePending && !isResettableExpired) + { + return ToState(bridge); + } + + var hasPriorSessionData = bridge.FallbackTransactionId is not null || + bridge.SettlementScript is not null || + bridge.ExpectedFinalTransactionId is not null; + if (!hasPriorSessionData && bridge.Status != PayjoinAccountingBridgeStatus.Expired) + { + return ToState(bridge); + } + + bridge.FallbackTransactionId = null; + bridge.FallbackOutputIndex = null; + bridge.FallbackValueSats = null; + bridge.SettlementScript = null; + bridge.ExpectedFinalTransactionId = null; + bridge.ExpectedFinalOutputIndex = null; + bridge.ExpectedFinalValueSats = null; + bridge.EffectiveInvoiceValueSats = effectiveInvoiceValueSats ?? bridge.EffectiveInvoiceValueSats; + bridge.FailureMessage = null; + bridge.Status = PayjoinAccountingBridgeStatus.PendingFallback; + bridge.ExpiresAt = expiresAt ?? bridge.ExpiresAt; + bridge.UpdatedAt = DateTimeOffset.UtcNow; + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return ToState(bridge); + } + public async Task> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) { using var context = _dbContextFactory.CreateContext(); diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs index a1ff1a3..03ffdb7 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinUriSessionService.cs @@ -127,6 +127,7 @@ public async Task BuildAsync( } } + var createdFreshSession = false; if (session is null) { var selectedRelay = await _mailroomManager.SelectBootstrapRouteAsync( @@ -148,13 +149,14 @@ public async Task BuildAsync( storeId, monitoringExpiresAt, bootstrapPersister.Load()); + createdFreshSession = true; } var persister = _receiverSessionStore.CreatePersister(session); using var replay = PayjoinMethods.ReplayReceiverEventLog(persister); using var history = replay.SessionHistory(); - await EnsureAccountingBridgeAsync(invoiceId, storeId, cryptoCode, due, monitoringExpiresAt, cancellationToken).ConfigureAwait(false); + await EnsureAccountingBridgeAsync(invoiceId, storeId, cryptoCode, due, monitoringExpiresAt, createdFreshSession, cancellationToken).ConfigureAwait(false); using var pjUri = history.PjUri(); var payjoinUri = pjUri.AsString(); if (string.IsNullOrWhiteSpace(payjoinUri)) @@ -215,19 +217,20 @@ internal static ulong ToExpirationSeconds(DateTimeOffset monitoringExpiresAt) return (ulong)Math.Min(remainingSeconds, uint.MaxValue); } - private Task EnsureAccountingBridgeAsync( + private async Task EnsureAccountingBridgeAsync( string invoiceId, string storeId, string cryptoCode, decimal due, DateTimeOffset monitoringExpiresAt, + bool createdFreshSession, CancellationToken cancellationToken) { var effectiveInvoiceValueSats = due > 0m ? Money.Coins(due).Satoshi : (long?)null; - return _accountingBridgeService.CreateOrGetAsync( + await _accountingBridgeService.CreateOrGetAsync( new CreatePayjoinAccountingBridgeRequest( invoiceId, storeId, @@ -235,7 +238,12 @@ private Task EnsureAccountingBridgeAsync( PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode).ToString(), monitoringExpiresAt, EffectiveInvoiceValueSats: effectiveInvoiceValueSats), - cancellationToken); + cancellationToken).ConfigureAwait(false); + + if (createdFreshSession) + { + await _accountingBridgeService.ResetForNewSessionAsync(invoiceId, effectiveInvoiceValueSats, monitoringExpiresAt, cancellationToken).ConfigureAwait(false); + } } private string LogExpectedFallbackAndReturnBip21(string bip21, string invoiceId, string reason) From 73a029c73b7c056f939c8b9189fadd81433553df Mon Sep 17 00:00:00 2001 From: chavic Date: Mon, 20 Jul 2026 18:42:29 +0200 Subject: [PATCH 3/3] Keep armed bridges live through session recreation Resetting the accounting record for a fresh session treated any pending bridge as replaceable, but a bridge that already knows its expected final transaction describes a signed proposal the previous session handed to the sender. That transaction can still confirm, and the recorded expectation is the only thing that makes its settlement output creditable, so wiping it would make the payment invisible. Armed bridges now survive recreation and the new session's own writes take the record over stage by stage; unarmed pending bridges reset as before. --- .../PayjoinAccountingBridgeResetTests.cs | 31 +++++++++++++++++-- .../PayjoinAccountingBridgeService.cs | 15 +++++++-- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs index defb677..5d6cf88 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinAccountingBridgeResetTests.cs @@ -16,14 +16,13 @@ public class PayjoinAccountingBridgeResetTests private const string FallbackTransactionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; [Fact] - public async Task ResetForNewSessionAsyncClearsPriorSessionTrackingOnPendingBridges() + public async Task ResetForNewSessionAsyncClearsPriorSessionTrackingOnUnarmedPendingBridges() { using var context = new TestContext(); var service = context.CreateService(); var now = DateTimeOffset.UtcNow; await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); await service.AttachFallbackAsync("invoice-1", FallbackTransactionId, 0, 900, 900, "CCDD", CancellationToken.None); - await service.SetExpectedFinalTransactionAsync("invoice-1", ExpectedTransactionId, 1, 950, CancellationToken.None); var reset = await service.ResetForNewSessionAsync("invoice-1", 1200, now.AddHours(2), CancellationToken.None); @@ -38,6 +37,34 @@ public async Task ResetForNewSessionAsyncClearsPriorSessionTrackingOnPendingBrid Assert.Equal(now.AddHours(2), reset.ExpiresAt); } + [Fact] + public async Task ResetForNewSessionAsyncPreservesArmedBridgesSoTheOldProposalStillReconciles() + { + // The previous session already handed a signed proposal to the sender: the expected final + // transaction is the only handle that makes that settlement creditable, so recreating the + // session must not wipe it. The old accounting flow stays live until the new session + // produces its own finalized proposal and overwrites the expectation itself. + using var context = new TestContext(); + var service = context.CreateService(); + var now = DateTimeOffset.UtcNow; + await CreateBridgeAsync(service, "invoice-1", now.AddHours(1)); + await service.AttachFallbackAsync("invoice-1", FallbackTransactionId, 0, 900, 900, "CCDD", CancellationToken.None); + await service.SetExpectedFinalTransactionAsync("invoice-1", ExpectedTransactionId, 1, 950, CancellationToken.None); + + var reset = await service.ResetForNewSessionAsync("invoice-1", 1200, now.AddHours(2), CancellationToken.None); + + Assert.NotNull(reset); + Assert.Equal(PayjoinAccountingBridgeStatus.PendingFinalTransaction, reset!.Status); + Assert.Equal(ExpectedTransactionId, reset.ExpectedFinalTransactionId); + Assert.Equal(FallbackTransactionId, reset.FallbackTransactionId); + Assert.Equal("CCDD", reset.SettlementScript); + + // The old session's final transaction becomes observable afterwards and still reconciles. + var reconciled = await service.MarkReconciledAsync("invoice-1", ExpectedTransactionId, 1, 950, now, CancellationToken.None); + Assert.Equal(PayjoinAccountingBridgeStatus.Reconciled, reconciled!.Status); + Assert.Equal(ExpectedTransactionId, reconciled.ExpectedFinalTransactionId); + } + [Fact] public async Task ResetForNewSessionAsyncRevivesUnarmedExpiredBridges() { diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs index 86cdce0..7e5c02b 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinAccountingBridgeService.cs @@ -257,7 +257,17 @@ public async Task> GetPendingA // the rest, leaving the record describing two different sessions at once. Reconciled records are // final, and failed or expired records that already awaited a final transaction stay untouched // for operator review. - var isResettablePending = bridge.Status is PayjoinAccountingBridgeStatus.PendingFallback or PayjoinAccountingBridgeStatus.PendingFinalTransaction; + // + // A pending bridge that is already armed is the exception: its expected final transaction + // describes a signed proposal the previous session handed to the sender, which can still + // confirm, and that expectation is the only thing that makes the settlement creditable (the + // settlement output is not an invoice address the platform tracks on its own). The old + // accounting flow therefore stays live through session recreation, and the new session's own + // writes take the record over stage by stage: attaching its fallback replaces the fallback + // data, committing outputs replaces the settlement script, and finalizing its proposal + // replaces the expected final transaction. + var isResettablePending = (bridge.Status is PayjoinAccountingBridgeStatus.PendingFallback or PayjoinAccountingBridgeStatus.PendingFinalTransaction) && + bridge.ExpectedFinalTransactionId is null; var isResettableExpired = bridge.Status == PayjoinAccountingBridgeStatus.Expired && bridge.ExpectedFinalTransactionId is null; if (!isResettablePending && !isResettableExpired) { @@ -265,8 +275,7 @@ public async Task> GetPendingA } var hasPriorSessionData = bridge.FallbackTransactionId is not null || - bridge.SettlementScript is not null || - bridge.ExpectedFinalTransactionId is not null; + bridge.SettlementScript is not null; if (!hasPriorSessionData && bridge.Status != PayjoinAccountingBridgeStatus.Expired) { return ToState(bridge);