diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index d876ec5..aeb2988 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -49,7 +49,6 @@ jobs: dotnet test "BTCPayServer.Plugins.Payjoin.IntegrationTests/BTCPayServer.Plugins.Payjoin.IntegrationTests.csproj" -c Release --no-build --no-restore -- --minimum-expected-tests 1 --explicit ${{ env.XUNIT_EXPLICIT_MODE }} - - name: Print Docker diagnostics if: failure() shell: bash diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/PayjoinCliIntegrationTests.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/PayjoinCliIntegrationTests.cs index f0551a1..12ec6ce 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/PayjoinCliIntegrationTests.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/PayjoinCliIntegrationTests.cs @@ -1,5 +1,11 @@ +using BTCPayServer.Client.Models; +using BTCPayServer.Data; using BTCPayServer.Plugins.Payjoin.IntegrationTests.TestUtils; +using BTCPayServer.Plugins.Payjoin.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; using BTCPayServer.Tests; +using Microsoft.Extensions.Hosting; using Xunit; namespace BTCPayServer.Plugins.Payjoin.IntegrationTests; @@ -7,8 +13,16 @@ namespace BTCPayServer.Plugins.Payjoin.IntegrationTests; [Collection(nameof(NonParallelizableCollectionDefinition))] public class PayjoinCliIntegrationTests : UnitTestBase { + private const string OriginalPsbtRejectedMarker = "The receiver rejected the original PSBT."; private static readonly TimeSpan CliTestTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan PreSendReceiverDelay = TimeSpan.FromSeconds(60); + private static readonly TimeSpan ShortInvoiceLifetime = TimeSpan.FromMinutes(1); + private static readonly TimeSpan ShortMonitoringLifetime = TimeSpan.FromSeconds(1); + private static readonly Uri[] UnavailableRelayUrls = + [ + new("https://127.0.0.1:1/"), + new("https://127.0.0.1:2/") + ]; public PayjoinCliIntegrationTests(ITestOutputHelper helper) : base(helper) { @@ -20,12 +34,16 @@ public async Task CreateInvoiceAndPayItThroughThePayjoinPluginWithPayjoinCli() { using var cts = new CancellationTokenSource(CliTestTimeout); using var tester = CreateServerTester(newDb: true); - var payjoinCliOptions = new BitcoindNodeOptions(); var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); - var paymentResult = await PayjoinCliIntegrationTestSupport.CreateAndPayInvoiceWithInvoiceIdAsync(tester, context.Merchant, context.Network, payjoinCliOptions, cts.Token).ConfigureAwait(true); + var paymentResult = await PayjoinCliIntegrationTestSupport.CreateAndPayInvoiceWithInvoiceIdAsync( + tester, + context.Merchant, + context.Network, + preSendReceiverPollDelay: TimeSpan.Zero, + cancellationToken: cts.Token).ConfigureAwait(true); PayjoinIntegrationTestSupport.AssertSuccessfulPayjoinTransaction((paymentResult.PayjoinTransaction, paymentResult.InvoiceScript, paymentResult.TransactionId)); await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyRemovedAsync(tester, paymentResult.InvoiceId, cts.Token).ConfigureAwait(true); @@ -37,7 +55,6 @@ public async Task CreateInvoiceAndPayItThroughThePayjoinPluginWithPayjoinCliWhen { using var cts = new CancellationTokenSource(CliTestTimeout); using var tester = CreateServerTester(newDb: true); - var payjoinCliOptions = new BitcoindNodeOptions(); var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); @@ -46,11 +63,414 @@ public async Task CreateInvoiceAndPayItThroughThePayjoinPluginWithPayjoinCliWhen tester, context.Merchant, context.Network, - payjoinCliOptions, - PreSendReceiverDelay, - cts.Token).ConfigureAwait(true); + preSendReceiverPollDelay: PreSendReceiverDelay, + cancellationToken: cts.Token).ConfigureAwait(true); PayjoinIntegrationTestSupport.AssertSuccessfulPayjoinTransaction((paymentResult.PayjoinTransaction, paymentResult.InvoiceScript, paymentResult.TransactionId)); await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyRemovedAsync(tester, paymentResult.InvoiceId, cts.Token).ConfigureAwait(true); } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliDoesNotBroadcastWhenAllSenderRelaysAreUnavailable() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + using var payjoinCliPayer = new PayjoinCliPayer(senderWallet); + var failure = await payjoinCliPayer.PayExpectingFailureAsync( + payjoinContext.PaymentUrl, + UnavailableRelayUrls, + payjoinContext.InvoiceScript, + cts.Token).ConfigureAwait(true); + + var diagnostics = $"{failure.StandardOutput}\n{failure.StandardError}"; + Assert.Contains("No valid relays available", diagnostics, StringComparison.OrdinalIgnoreCase); + Assert.All(UnavailableRelayUrls, relay => + Assert.Contains(relay.AbsoluteUri, diagnostics, StringComparison.Ordinal)); + Assert.False(string.IsNullOrWhiteSpace(failure.SessionId)); + var senderSessionId = failure.SessionId!; + + var history = await payjoinCliPayer.GetHistoryAsync( + UnavailableRelayUrls, + cts.Token).ConfigureAwait(true); + Assert.Contains(senderSessionId, history.StandardOutput, StringComparison.Ordinal); + Assert.Contains("Waiting for proposal", history.StandardOutput, StringComparison.Ordinal); + + var cancellation = await payjoinCliPayer.CancelSessionWithoutBroadcastAsync( + senderSessionId, + UnavailableRelayUrls, + payjoinContext.InvoiceScript, + cts.Token).ConfigureAwait(true); + + var repeatedCancellation = await payjoinCliPayer.CancelAlreadyCancelledSessionWithoutBroadcastAsync( + senderSessionId, + UnavailableRelayUrls, + payjoinContext.InvoiceScript, + cts.Token).ConfigureAwait(true); + Assert.Equal(cancellation.ToHex(), repeatedCancellation.ToHex()); + + var receiverSession = PayjoinReceiverTestHelper.GetRequiredReceiverSession( + tester, + payjoinContext.InvoiceId); + Assert.False(receiverSession.IsCloseRequested); + Assert.False(receiverSession.TryGetContributedInput(out _)); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliIsRejectedWhenInvoiceIsAlreadyInvalid() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + var invoiceRepository = tester.PayTester.GetService(); + var marked = await invoiceRepository + .MarkInvoiceStatus(payjoinContext.InvoiceId, InvoiceStatus.Invalid) + .WaitAsync(cts.Token) + .ConfigureAwait(true); + Assert.True(marked); + await PayjoinInvoiceTestHelper.AssertInvoiceStatusEventuallyAsync( + tester, + payjoinContext.InvoiceId, + InvoiceStatus.Invalid, + cts.Token).ConfigureAwait(true); + await AssertPayjoinCliRejectedAndReceiverSessionRemovedAsync( + tester, + senderWallet, + payjoinContext, + InvoiceStatus.Invalid, + cts.Token).ConfigureAwait(true); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliIsRejectedWhenInvoiceIsAlreadyExpired() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + var invoiceRepository = tester.PayTester.GetService(); + await invoiceRepository + .UpdateInvoiceExpiry(payjoinContext.InvoiceId, TimeSpan.Zero) + .WaitAsync(cts.Token) + .ConfigureAwait(true); + await PayjoinInvoiceTestHelper.AssertInvoiceStatusEventuallyAsync( + tester, + payjoinContext.InvoiceId, + InvoiceStatus.Expired, + cts.Token).ConfigureAwait(true); + await AssertPayjoinCliRejectedAndReceiverSessionRemovedAsync( + tester, + senderWallet, + payjoinContext, + InvoiceStatus.Expired, + cts.Token).ConfigureAwait(true); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliIsRejectedWhenInvoiceIsAlreadyPaid() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + await context.Merchant.PayInvoice(payjoinContext.InvoiceId).WaitAsync(cts.Token).ConfigureAwait(true); + await context.Merchant.WaitInvoicePaid(payjoinContext.InvoiceId).WaitAsync(cts.Token).ConfigureAwait(true); + await AssertPayjoinCliRejectedAndReceiverSessionRemovedAsync( + tester, + senderWallet, + payjoinContext, + InvoiceStatus.Processing, + cts.Token).ConfigureAwait(true); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliSendsPluginChangeToConfiguredColdWallet() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + var coldDerivation = await PayjoinIntegrationTestSupport.CreateTrackedColdWalletAsync(tester, cts.Token).ConfigureAwait(true); + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, settings => + { + settings.ColdWalletDerivationScheme = coldDerivation.ToString(); + }, cts.Token).ConfigureAwait(true); + + var paymentResult = await PayjoinCliIntegrationTestSupport.CreateAndPayInvoiceWithInvoiceIdAsync( + tester, + context.Merchant, + context.Network, + preSendReceiverPollDelay: TimeSpan.Zero, + cancellationToken: cts.Token).ConfigureAwait(true); + + PayjoinIntegrationTestSupport.AssertSuccessfulPayjoinTransaction((paymentResult.PayjoinTransaction, paymentResult.InvoiceScript, paymentResult.TransactionId)); + await PayjoinIntegrationTestSupport.AssertColdWalletReceivedPayjoinChangeAsync( + tester, + coldDerivation, + (paymentResult.PayjoinTransaction, paymentResult.InvoiceScript, paymentResult.TransactionId), + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyRemovedAsync( + tester, + paymentResult.InvoiceId, + cts.Token).ConfigureAwait(true); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliInvoiceTransitionsFromProcessingToSettledAfterConfirmation() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + using var payjoinCliPayer = new PayjoinCliPayer(senderWallet); + var cliPayment = await payjoinCliPayer.PayAsync( + payjoinContext.PaymentUrl, + payjoinContext.OhttpRelayUrls, + payjoinContext.InvoiceScript, + cts.Token).ConfigureAwait(true); + await PayjoinCliIntegrationTestSupport.AssertSuccessfulSenderSessionStateAsync( + payjoinCliPayer, + cliPayment, + payjoinContext.OhttpRelayUrls, + cts.Token).ConfigureAwait(true); + + await PayjoinInvoiceTestHelper.AssertInvoiceProcessingThenSettledAsync( + tester, + payjoinContext.InvoiceId, + async cancellationToken => + { + await tester.ExplorerNode.GenerateAsync(1, cancellationToken).ConfigureAwait(true); + }, + cts.Token).ConfigureAwait(true); + + var paymentResult = await PayjoinInvoiceTestHelper.FinalizePayjoinPaymentAsync( + tester, + context.Merchant, + payjoinContext, + cliPayment.TransactionId, + cts.Token).ConfigureAwait(true); + PayjoinIntegrationTestSupport.AssertSuccessfulPayjoinTransaction(paymentResult); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyRemovedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + } + + [Fact(Explicit = true)] + [Trait("Integration", "Integration")] + public async Task PayjoinCliLeavesFallbackWhenPluginReceiverSessionExpires() + { + using var cts = new CancellationTokenSource(CliTestTimeout); + using var tester = CreateServerTester(newDb: true); + var context = await PayjoinAccountTestHelper.CreateInitializedTestContextAsync(tester, cancellationToken: cts.Token).ConfigureAwait(true); + + await PayjoinIntegrationTestSupport.EnablePayjoinAsync(tester, context.Merchant.StoreId, cancellationToken: cts.Token).ConfigureAwait(true); + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync( + tester, + context.Network, + cts.Token).ConfigureAwait(true); + + await ConfigureExpiryTestLifetimeAsync( + tester, + context.Merchant.StoreId, + invoiceLifetime: ShortInvoiceLifetime, + monitoringLifetime: ShortMonitoringLifetime, + cancellationToken: cts.Token).ConfigureAwait(true); + + var receiverPoller = tester.PayTester.GetService>() + .OfType() + .Single(); + await receiverPoller.StopAsync(cts.Token).ConfigureAwait(true); + + var payjoinContext = await PayjoinInvoiceTestHelper.PreparePayjoinInvoiceAsync( + tester, + context.Merchant, + context.Network, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + using var payjoinCliPayer = new PayjoinCliPayer(senderWallet); + var expiryResult = await payjoinCliPayer.PayExpectingExpiryAsync( + payjoinContext.PaymentUrl, + payjoinContext.OhttpRelayUrls, + payjoinContext.InvoiceScript, + cts.Token).ConfigureAwait(true); + + var history = await payjoinCliPayer.GetHistoryAsync( + payjoinContext.OhttpRelayUrls, + cts.Token).ConfigureAwait(true); + Assert.Contains(expiryResult.SessionId, history.StandardOutput, StringComparison.Ordinal); + Assert.Contains("Session expired at", history.StandardOutput, StringComparison.Ordinal); + + var repeatedCancellation = await payjoinCliPayer.CancelExpiredSessionAgainWithoutBroadcastAsync( + expiryResult.SessionId, + payjoinContext.OhttpRelayUrls, + cts.Token).ConfigureAwait(true); + Assert.Equal(expiryResult.FallbackTransaction.ToHex(), repeatedCancellation.ToHex()); + + await PayjoinInvoiceTestHelper.AssertInvoiceStatusEventuallyAsync( + tester, + payjoinContext.InvoiceId, + InvoiceStatus.Expired, + cts.Token).ConfigureAwait(true); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCloseRequestedAsync( + tester, + payjoinContext.InvoiceId, + cts.Token).ConfigureAwait(true); + + await receiverPoller.ProcessTickOnceAsync(cts.Token).ConfigureAwait(true); + var sessionStore = tester.PayTester.GetService(); + Assert.False( + sessionStore.TryGetSession(payjoinContext.InvoiceId, out _), + $"Expected the manual receiver poll tick to remove expired session '{payjoinContext.InvoiceId}'."); + } + + private static async Task AssertPayjoinCliRejectedAsync( + PayjoinCliPayer payjoinCliPayer, + PayjoinInvoiceTestHelper.PayjoinInvoiceContext payjoinContext, + CancellationToken cancellationToken) + { + var failure = await payjoinCliPayer.PayExpectingFailureAsync( + payjoinContext.PaymentUrl, + payjoinContext.OhttpRelayUrls, + payjoinContext.InvoiceScript, + cancellationToken).ConfigureAwait(true); + + var diagnostics = $"{failure.StandardOutput}\n{failure.StandardError}"; + Assert.Contains(OriginalPsbtRejectedMarker, diagnostics, StringComparison.Ordinal); + } + + private static async Task AssertPayjoinCliRejectedAndReceiverSessionRemovedAsync( + ServerTester tester, + PayjoinCliSenderWallet senderWallet, + PayjoinInvoiceTestHelper.PayjoinInvoiceContext payjoinContext, + InvoiceStatus expectedCloseStatus, + CancellationToken cancellationToken) + { + using var payjoinCliPayer = new PayjoinCliPayer(senderWallet); + var closeRequestedTask = PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCloseRequestedAsync( + tester, + payjoinContext.InvoiceId, + cancellationToken); + var rejectionTask = AssertPayjoinCliRejectedAsync( + payjoinCliPayer, + payjoinContext, + cancellationToken); + + await Task.WhenAll(rejectionTask, closeRequestedTask).ConfigureAwait(true); + var receiverSession = await closeRequestedTask.ConfigureAwait(true); + Assert.Equal(expectedCloseStatus, receiverSession.CloseInvoiceStatus); + await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyRemovedAsync( + tester, + payjoinContext.InvoiceId, + cancellationToken).ConfigureAwait(true); + } + + private static async Task ConfigureExpiryTestLifetimeAsync( + ServerTester tester, + string storeId, + TimeSpan invoiceLifetime, + TimeSpan monitoringLifetime, + CancellationToken cancellationToken) + { + var storeRepository = tester.PayTester.GetService(); + var store = await storeRepository.FindStore(storeId).WaitAsync(cancellationToken).ConfigureAwait(true); + Assert.NotNull(store); + + var storeBlob = store.GetStoreBlob(); + storeBlob.InvoiceExpiration = invoiceLifetime; + storeBlob.MonitoringExpiration = monitoringLifetime; + Assert.True(store.SetStoreBlob(storeBlob)); + + await storeRepository.UpdateStore(store).WaitAsync(cancellationToken).ConfigureAwait(true); + } } diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliIntegrationTestSupport.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliIntegrationTestSupport.cs index 668710d..5b8fb91 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliIntegrationTestSupport.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliIntegrationTestSupport.cs @@ -1,5 +1,6 @@ using BTCPayServer.Tests; using NBitcoin; +using Xunit; namespace BTCPayServer.Plugins.Payjoin.IntegrationTests.TestUtils; @@ -9,26 +10,6 @@ internal static class PayjoinCliIntegrationTestSupport ServerTester tester, TestAccount merchant, BTCPayNetwork network, - CancellationToken cancellationToken) - { - return await CreateAndPayInvoiceWithInvoiceIdAsync(tester, merchant, network, options: null, cancellationToken: cancellationToken).ConfigureAwait(true); - } - - public static async Task<(string InvoiceId, Transaction PayjoinTransaction, Script InvoiceScript, string TransactionId)> CreateAndPayInvoiceWithInvoiceIdAsync( - ServerTester tester, - TestAccount merchant, - BTCPayNetwork network, - BitcoindNodeOptions? options, - CancellationToken cancellationToken) - { - return await CreateAndPayInvoiceWithInvoiceIdAsync(tester, merchant, network, options, TimeSpan.Zero, cancellationToken).ConfigureAwait(true); - } - - public static async Task<(string InvoiceId, Transaction PayjoinTransaction, Script InvoiceScript, string TransactionId)> CreateAndPayInvoiceWithInvoiceIdAsync( - ServerTester tester, - TestAccount merchant, - BTCPayNetwork network, - BitcoindNodeOptions? options, TimeSpan preSendReceiverPollDelay, CancellationToken cancellationToken) { @@ -36,7 +17,7 @@ internal static class PayjoinCliIntegrationTestSupport await PayjoinReceiverTestHelper.AssertReceiverSessionEventuallyCreatedAsync(tester, payjoinContext.InvoiceId, cancellationToken).ConfigureAwait(true); var receiverDiagnosticsBeforeSend = await PayjoinReceiverTestHelper.GetReceiverSideDiagnosticsAsync(tester, payjoinContext.InvoiceId, cancellationToken).ConfigureAwait(true); - using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync(tester, network, options, cancellationToken).ConfigureAwait(true); + using var senderWallet = await PayjoinCliSenderWallet.CreateInitializedAsync(tester, network, cancellationToken).ConfigureAwait(true); using var payjoinCliPayer = new PayjoinCliPayer(senderWallet); if (preSendReceiverPollDelay > TimeSpan.Zero) @@ -78,23 +59,32 @@ internal static class PayjoinCliIntegrationTestSupport } } - public static async Task<(Transaction PayjoinTransaction, Script InvoiceScript, string TransactionId)> CreateAndPayInvoiceAsync( - ServerTester tester, - TestAccount merchant, - BTCPayNetwork network, + public static async Task AssertSuccessfulSenderSessionStateAsync( + PayjoinCliPayer payjoinCliPayer, + PayjoinCliPaymentResult paymentResult, + IReadOnlyList ohttpRelayUrls, CancellationToken cancellationToken) { - return await CreateAndPayInvoiceAsync(tester, merchant, network, options: null, cancellationToken: cancellationToken).ConfigureAwait(true); - } + ArgumentNullException.ThrowIfNull(payjoinCliPayer); + ArgumentNullException.ThrowIfNull(paymentResult); + ArgumentNullException.ThrowIfNull(ohttpRelayUrls); - public static async Task<(Transaction PayjoinTransaction, Script InvoiceScript, string TransactionId)> CreateAndPayInvoiceAsync( - ServerTester tester, - TestAccount merchant, - BTCPayNetwork network, - BitcoindNodeOptions? options, - CancellationToken cancellationToken) - { - var paymentResult = await CreateAndPayInvoiceWithInvoiceIdAsync(tester, merchant, network, options, cancellationToken).ConfigureAwait(true); - return (paymentResult.PayjoinTransaction, paymentResult.InvoiceScript, paymentResult.TransactionId); + Assert.False(string.IsNullOrWhiteSpace(paymentResult.SessionId), "payjoin-cli did not report a sender session ID."); + var sessionId = paymentResult.SessionId!; + var history = await payjoinCliPayer.GetHistoryAsync( + ohttpRelayUrls, + cancellationToken).ConfigureAwait(true); + Assert.Contains(sessionId, history.StandardOutput, StringComparison.Ordinal); + Assert.Contains("Session success", history.StandardOutput, StringComparison.Ordinal); + + await payjoinCliPayer.CancelCompletedSessionWithoutBroadcastAsync( + sessionId, + paymentResult.TransactionId, + ohttpRelayUrls, + cancellationToken).ConfigureAwait(true); + + await payjoinCliPayer.ResumeExpectingNoSessionsAsync( + ohttpRelayUrls, + cancellationToken).ConfigureAwait(true); } } diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliPayer.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliPayer.cs index cee4a0e..5506b00 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliPayer.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinCliPayer.cs @@ -9,10 +9,18 @@ namespace BTCPayServer.Plugins.Payjoin.IntegrationTests.TestUtils; internal sealed class PayjoinCliPayer : IDisposable { private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(3); + private static readonly TimeSpan ResumeProcessTimeout = TimeSpan.FromSeconds(15); private static readonly TimeSpan TransactionDetectionTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan TransactionDetectionPollInterval = TimeSpan.FromMilliseconds(250); private const string DefaultFeeRateSatsPerVByte = "1"; private const string PayjoinCliSentTransactionIdMarker = "Payjoin sent. TXID:"; + private const string PayjoinCliExpiredFallbackMarker = "Session expired. Broadcast the original transaction manually:"; + private const string PayjoinCliCancelledFallbackMarker = "Session cancelled. Broadcast the original transaction manually:"; + private const string PayjoinCliAlreadyCancelledFallbackMarker = "Session was already cancelled. Broadcast the original transaction manually:"; + private const string PayjoinCliFallbackTransactionMarker = "Broadcast the original transaction manually:"; + private const string PayjoinCliSessionEstablishedMarker = "Session established"; + private const string PayjoinCliNoSessionsToResumeMarker = "No sessions to resume."; + private const string PayjoinCliCompletedSessionMarker = "Cannot cancel a completed session."; private readonly PayjoinCliSenderWallet _senderWallet; private readonly string _workingDirectory; @@ -30,15 +38,309 @@ public PayjoinCliPayer(PayjoinCliSenderWallet senderWallet) public async Task PayAsync(Uri paymentUrl, IReadOnlyList ohttpRelayUrls, Script expectedInvoiceScript, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(paymentUrl); - ArgumentNullException.ThrowIfNull(ohttpRelayUrls); - if (ohttpRelayUrls.Count == 0) + ValidateSendArguments(paymentUrl, ohttpRelayUrls, expectedInvoiceScript); + + var knownTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var commandResult = await RunSendAsync(paymentUrl, ohttpRelayUrls, cancellationToken).ConfigureAwait(false); + var sessionId = TryParseSenderSessionId(commandResult.StandardOutput); + + var transactionId = await GetNewTransactionIdAsync( + knownTransactionIds, + expectedInvoiceScript, + commandResult, + cancellationToken).ConfigureAwait(false); + + return new PayjoinCliPaymentResult( + transactionId, + sessionId, + commandResult.StandardOutput, + commandResult.StandardError); + } + + public async Task PayExpectingExpiryAsync( + Uri paymentUrl, + IReadOnlyList ohttpRelayUrls, + Script expectedInvoiceScript, + CancellationToken cancellationToken) + { + ValidateSendArguments(paymentUrl, ohttpRelayUrls, expectedInvoiceScript); + + var knownTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var commandResult = await RunSendAsync( + paymentUrl, + ohttpRelayUrls, + cancellationToken).ConfigureAwait(false); + var sessionId = GetRequiredSenderSessionId(commandResult); + + if (!commandResult.StandardOutput.Contains(PayjoinCliExpiredFallbackMarker, StringComparison.Ordinal)) { - throw new InvalidOperationException("At least one OHTTP relay URL is required for payjoin-cli."); + throw new InvalidOperationException(CreateFailureMessage( + $"payjoin-cli exited successfully without reporting '{PayjoinCliExpiredFallbackMarker}'.", + commandResult)); + } + + var fallbackTransaction = TryParseFallbackTransaction(commandResult.StandardOutput); + if (fallbackTransaction is null) + { + throw new InvalidOperationException(CreateFailureMessage( + "payjoin-cli reported expiry without printing a parseable fallback transaction.", + commandResult)); + } + + if (!fallbackTransaction.Outputs.Any(output => output.ScriptPubKey == expectedInvoiceScript)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"The fallback transaction did not pay the expected invoice script '{expectedInvoiceScript}'.", + commandResult)); + } + + await AssertNoNewWalletTransactionsAsync( + knownTransactionIds, + "payjoin-cli broadcast a transaction while handling expiry.", + commandResult, + cancellationToken).ConfigureAwait(false); + + return new PayjoinCliExpiryResult( + fallbackTransaction, + sessionId); + } + + public async Task PayExpectingFailureAsync( + Uri paymentUrl, + IReadOnlyList ohttpRelayUrls, + Script expectedInvoiceScript, + CancellationToken cancellationToken) + { + ValidateSendArguments(paymentUrl, ohttpRelayUrls, expectedInvoiceScript); + + var knownTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var commandResult = await RunSendAsync( + paymentUrl, + ohttpRelayUrls, + cancellationToken, + throwOnNonZeroExit: false).ConfigureAwait(false); + + if (commandResult.ExitCode == 0) + { + throw new InvalidOperationException(CreateFailureMessage( + "payjoin-cli unexpectedly completed a send that was expected to fail.", + commandResult)); + } + + var sessionId = TryParseSenderSessionId(commandResult.StandardOutput); + + await AssertNoNewWalletTransactionsAsync( + knownTransactionIds, + "payjoin-cli broadcast a transaction while handling an expected send failure.", + commandResult, + cancellationToken).ConfigureAwait(false); + + return new PayjoinCliFailureResult( + sessionId, + commandResult.StandardOutput, + commandResult.StandardError); + } + + public async Task GetHistoryAsync( + IReadOnlyList ohttpRelayUrls, + CancellationToken cancellationToken) + { + ValidateOhttpRelayUrls(ohttpRelayUrls); + var commandResult = await RunCommandAsync( + ohttpRelayUrls, + ["history"], + throwOnNonZeroExit: true, + processTimeout: null, + cancellationToken).ConfigureAwait(false); + return commandResult; + } + + public async Task ResumeExpectingNoSessionsAsync( + IReadOnlyList ohttpRelayUrls, + CancellationToken cancellationToken) + { + ValidateOhttpRelayUrls(ohttpRelayUrls); + PayjoinCliCommandResult commandResult; + try + { + commandResult = await RunCommandAsync( + ohttpRelayUrls, + ["resume"], + throwOnNonZeroExit: true, + processTimeout: ResumeProcessTimeout, + cancellationToken).ConfigureAwait(false); + } + catch (InvalidOperationException ex) when ( + !cancellationToken.IsCancellationRequested && + ex.InnerException is OperationCanceledException) + { + throw new InvalidOperationException( + $"payjoin-cli resume did not report '{PayjoinCliNoSessionsToResumeMarker}' within {ResumeProcessTimeout.TotalSeconds:0} seconds; an active sender session may remain. {ex.Message}", + ex); + } + + if (!commandResult.StandardOutput.Contains(PayjoinCliNoSessionsToResumeMarker, StringComparison.Ordinal)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"payjoin-cli resume did not report '{PayjoinCliNoSessionsToResumeMarker}'.", + commandResult)); } + + return commandResult; + } + + public async Task CancelExpiredSessionAgainWithoutBroadcastAsync( + string sessionId, + IReadOnlyList ohttpRelayUrls, + CancellationToken cancellationToken) + { + return await CancelSessionWithoutBroadcastExpectingFallbackAsync( + sessionId, + ohttpRelayUrls, + PayjoinCliExpiredFallbackMarker, + expectedInvoiceScript: null, + cancellationToken).ConfigureAwait(false); + } + + public async Task CancelSessionWithoutBroadcastAsync( + string sessionId, + IReadOnlyList ohttpRelayUrls, + Script expectedInvoiceScript, + CancellationToken cancellationToken) + { ArgumentNullException.ThrowIfNull(expectedInvoiceScript); + return await CancelSessionWithoutBroadcastExpectingFallbackAsync( + sessionId, + ohttpRelayUrls, + PayjoinCliCancelledFallbackMarker, + expectedInvoiceScript, + cancellationToken).ConfigureAwait(false); + } + + public async Task CancelAlreadyCancelledSessionWithoutBroadcastAsync( + string sessionId, + IReadOnlyList ohttpRelayUrls, + Script expectedInvoiceScript, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(expectedInvoiceScript); + return await CancelSessionWithoutBroadcastExpectingFallbackAsync( + sessionId, + ohttpRelayUrls, + PayjoinCliAlreadyCancelledFallbackMarker, + expectedInvoiceScript, + cancellationToken).ConfigureAwait(false); + } + + public async Task CancelCompletedSessionWithoutBroadcastAsync( + string sessionId, + string transactionId, + IReadOnlyList ohttpRelayUrls, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(transactionId); + ValidateOhttpRelayUrls(ohttpRelayUrls); + + var knownTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var commandResult = await RunCommandAsync( + ohttpRelayUrls, + ["cancel", sessionId, "--no-broadcast"], + throwOnNonZeroExit: true, + processTimeout: null, + cancellationToken).ConfigureAwait(false); + + if (!commandResult.StandardOutput.Contains(PayjoinCliCompletedSessionMarker, StringComparison.Ordinal) || + !commandResult.StandardOutput.Contains(sessionId, StringComparison.Ordinal) || + !commandResult.StandardOutput.Contains(transactionId, StringComparison.Ordinal)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"payjoin-cli cancel did not identify completed sender session '{sessionId}' and transaction '{transactionId}'.", + commandResult)); + } + + await AssertNoNewWalletTransactionsAsync( + knownTransactionIds, + "payjoin-cli broadcast a transaction while refusing cancellation of a completed session.", + commandResult, + cancellationToken).ConfigureAwait(false); + + return commandResult; + } + + private async Task CancelSessionWithoutBroadcastExpectingFallbackAsync( + string sessionId, + IReadOnlyList ohttpRelayUrls, + string expectedMarker, + Script? expectedInvoiceScript, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedMarker); + ValidateOhttpRelayUrls(ohttpRelayUrls); var knownTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var commandResult = await RunCommandAsync( + ohttpRelayUrls, + ["cancel", sessionId, "--no-broadcast"], + throwOnNonZeroExit: true, + processTimeout: null, + cancellationToken).ConfigureAwait(false); + + if (!commandResult.StandardOutput.Contains(expectedMarker, StringComparison.Ordinal) || + !commandResult.StandardOutput.Contains(sessionId, StringComparison.Ordinal)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"payjoin-cli cancel did not report sender session '{sessionId}' with '{expectedMarker}'.", + commandResult)); + } + + var fallbackTransaction = TryParseFallbackTransaction(commandResult.StandardOutput); + if (fallbackTransaction is null) + { + throw new InvalidOperationException(CreateFailureMessage( + "payjoin-cli cancel did not print the persisted fallback transaction.", + commandResult)); + } + + if (expectedInvoiceScript is not null && + !fallbackTransaction.Outputs.Any(output => output.ScriptPubKey == expectedInvoiceScript)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"The persisted fallback transaction did not pay the expected invoice script '{expectedInvoiceScript}'.", + commandResult)); + } + + await AssertNoNewWalletTransactionsAsync( + knownTransactionIds, + "payjoin-cli broadcast a transaction while cancelling with --no-broadcast.", + commandResult, + cancellationToken).ConfigureAwait(false); + + return fallbackTransaction; + } + + private async Task RunSendAsync( + Uri paymentUrl, + IReadOnlyList ohttpRelayUrls, + CancellationToken cancellationToken, + bool throwOnNonZeroExit = true) + { + return await RunCommandAsync( + ohttpRelayUrls, + ["send", paymentUrl.OriginalString, "--fee-rate", DefaultFeeRateSatsPerVByte], + throwOnNonZeroExit, + processTimeout: null, + cancellationToken).ConfigureAwait(false); + } + + private async Task RunCommandAsync( + IReadOnlyList ohttpRelayUrls, + IReadOnlyList arguments, + bool throwOnNonZeroExit, + TimeSpan? processTimeout, + CancellationToken cancellationToken) + { await WriteConfigAsync(ohttpRelayUrls, cancellationToken).ConfigureAwait(false); var startInfo = new ProcessStartInfo @@ -52,10 +354,10 @@ public async Task PayAsync(Uri paymentUrl, IReadOnlyLis }; startInfo.Environment["RUST_LOG"] = "debug"; - startInfo.ArgumentList.Add("send"); - startInfo.ArgumentList.Add(paymentUrl.OriginalString); - startInfo.ArgumentList.Add("--fee-rate"); - startInfo.ArgumentList.Add(DefaultFeeRateSatsPerVByte); + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } using var process = new Process { StartInfo = startInfo }; try @@ -73,8 +375,9 @@ public async Task PayAsync(Uri paymentUrl, IReadOnlyLis var stdoutTask = process.StandardOutput.ReadToEndAsync(CancellationToken.None); var stderrTask = process.StandardError.ReadToEndAsync(CancellationToken.None); + var effectiveProcessTimeout = processTimeout ?? ProcessTimeout; using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(ProcessTimeout); + timeoutCts.CancelAfter(effectiveProcessTimeout); try { @@ -86,10 +389,8 @@ public async Task PayAsync(Uri paymentUrl, IReadOnlyLis var stdout = await stdoutTask.ConfigureAwait(false); var stderr = await stderrTask.ConfigureAwait(false); throw new InvalidOperationException(CreateFailureMessage( - $"payjoin-cli timed out after {ProcessTimeout.TotalSeconds:0} seconds.", - process, - stdout, - stderr), ex); + $"payjoin-cli timed out after {effectiveProcessTimeout.TotalSeconds:0} seconds.", + CreateCommandResult(process, stdout, stderr)), ex); } catch (OperationCanceledException ex) { @@ -98,9 +399,7 @@ public async Task PayAsync(Uri paymentUrl, IReadOnlyLis var stderr = await stderrTask.ConfigureAwait(false); throw new InvalidOperationException(CreateFailureMessage( "payjoin-cli was canceled by the parent test token.", - process, - stdout, - stderr), ex); + CreateCommandResult(process, stdout, stderr)), ex); } catch { @@ -110,25 +409,16 @@ public async Task PayAsync(Uri paymentUrl, IReadOnlyLis var stdoutText = await stdoutTask.ConfigureAwait(false); var stderrText = await stderrTask.ConfigureAwait(false); + var commandResult = CreateCommandResult(process, stdoutText, stderrText); - if (process.ExitCode != 0) + if (throwOnNonZeroExit && process.ExitCode != 0) { throw new InvalidOperationException(CreateFailureMessage( $"payjoin-cli exited with code {process.ExitCode}.", - process, - stdoutText, - stderrText)); + commandResult)); } - var transactionId = await GetNewTransactionIdAsync( - knownTransactionIds, - expectedInvoiceScript, - process, - stdoutText, - stderrText, - cancellationToken).ConfigureAwait(false); - - return new PayjoinCliPaymentResult(transactionId, stdoutText, stderrText); + return commandResult; } public void Dispose() @@ -181,12 +471,10 @@ private async Task WriteConfigAsync(IReadOnlyList ohttpRelayUrls, Cancellat private async Task GetNewTransactionIdAsync( HashSet knownTransactionIds, Script expectedInvoiceScript, - Process process, - string stdout, - string stderr, + PayjoinCliCommandResult commandResult, CancellationToken cancellationToken) { - var stdoutTransactionId = TryParseSentTransactionIdFromStdout(stdout); + var stdoutTransactionId = TryParseSentTransactionIdFromStdout(commandResult.StandardOutput); if (!string.IsNullOrWhiteSpace(stdoutTransactionId)) { await AsyncPolling.WaitUntilAsync( @@ -200,9 +488,7 @@ await AsyncPolling.WaitUntilAsync( BitcoindNode.IsTransientRpcException, lastException => CreateFailureMessage( $"payjoin-cli reported TXID '{stdoutTransactionId}', but the dedicated sender wallet did not expose it within {TransactionDetectionTimeout.TotalSeconds:0} seconds. LastTransientError='{BitcoindNode.DescribeException(lastException)}'.", - process, - stdout, - stderr), + commandResult), cancellationToken).ConfigureAwait(false); return stdoutTransactionId; @@ -237,9 +523,7 @@ await AsyncPolling.WaitUntilAsync( BitcoindNode.IsTransientRpcException, lastException => CreateFailureMessage( $"payjoin-cli completed successfully but the dedicated sender wallet did not expose exactly one unified receiver-output transaction within {TransactionDetectionTimeout.TotalSeconds:0} seconds. CandidateCount={candidateTransactionIds.Length}, Candidates='{string.Join(",", candidateTransactionIds)}', MatchingCount={matchingTransactionIds.Length}, MatchingCandidates='{string.Join(",", matchingTransactionIds)}', ExpectedInvoiceScript='{expectedInvoiceScript}', LastTransientError='{BitcoindNode.DescribeException(lastException)}'.", - process, - stdout, - stderr), + commandResult), cancellationToken).ConfigureAwait(false); return detectedTransactionId!; @@ -268,6 +552,103 @@ await AsyncPolling.WaitUntilAsync( return null; } + private static string GetRequiredSenderSessionId(PayjoinCliCommandResult commandResult) + { + var sessionId = TryParseSenderSessionId(commandResult.StandardOutput); + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new InvalidOperationException(CreateFailureMessage( + $"payjoin-cli did not report a sender '{PayjoinCliSessionEstablishedMarker}' line.", + commandResult)); + } + + return sessionId; + } + + private static string? TryParseSenderSessionId(string stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + using var reader = new StringReader(stdout); + while (reader.ReadLine() is { } line) + { + if (!line.Contains(PayjoinCliSessionEstablishedMarker, StringComparison.Ordinal)) + { + continue; + } + + var prefixStart = line.IndexOf("[Sender", StringComparison.Ordinal); + if (prefixStart < 0) + { + continue; + } + + var prefixEnd = line.IndexOf(']', prefixStart + 1); + if (prefixEnd < 0) + { + continue; + } + + var prefixParts = line[(prefixStart + 1)..prefixEnd] + .Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (prefixParts.Length == 2 && string.Equals(prefixParts[0], "Sender", StringComparison.Ordinal)) + { + return prefixParts[1]; + } + } + + return null; + } + + private Transaction? TryParseFallbackTransaction(string stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + { + return null; + } + + using var reader = new StringReader(stdout); + while (reader.ReadLine() is { } line) + { + if (!line.Contains(PayjoinCliFallbackTransactionMarker, StringComparison.Ordinal)) + { + continue; + } + + var transactionHex = reader.ReadLine(); + return transactionHex is not null && Transaction.TryParse( + transactionHex.Trim(), + _senderWallet.WalletRpcClient.Network, + out var transaction) + ? transaction + : null; + } + + return null; + } + + private async Task AssertNoNewWalletTransactionsAsync( + HashSet knownTransactionIds, + string reason, + PayjoinCliCommandResult commandResult, + CancellationToken cancellationToken) + { + var currentTransactionIds = await GetWalletTransactionIdsAsync(cancellationToken).ConfigureAwait(false); + var unexpectedTransactionIds = currentTransactionIds + .Where(transactionId => !knownTransactionIds.Contains(transactionId)) + .Order(StringComparer.Ordinal) + .ToArray(); + if (unexpectedTransactionIds.Length > 0) + { + throw new InvalidOperationException(CreateFailureMessage( + $"{reason} UnexpectedTransactions='{string.Join(",", unexpectedTransactionIds)}'.", + commandResult)); + } + } + private static bool IsValidTransactionId(string? txid) { if (string.IsNullOrWhiteSpace(txid) || txid.Length != 64) @@ -385,9 +766,20 @@ private static string GetPayjoinCliExecutableName() return OperatingSystem.IsWindows() ? "payjoin-cli.exe" : "payjoin-cli"; } - private static string CreateFailureMessage(string reason, Process process, string stdout, string stderr) + private PayjoinCliCommandResult CreateCommandResult(Process process, string stdout, string stderr) + { + return new PayjoinCliCommandResult( + process.HasExited ? process.ExitCode : null, + process.StartInfo.FileName, + process.StartInfo.WorkingDirectory, + _databasePath, + stdout, + stderr); + } + + private static string CreateFailureMessage(string reason, PayjoinCliCommandResult commandResult) { - return $"{reason} Executable='{process.StartInfo.FileName}', WorkingDirectory='{process.StartInfo.WorkingDirectory}', DbPath='{Path.Combine(process.StartInfo.WorkingDirectory, "payjoin.sqlite")}', Stdout='{EscapeMultiline(stdout)}', Stderr='{EscapeMultiline(stderr)}'"; + return $"{reason} Executable='{commandResult.ExecutablePath}', WorkingDirectory='{commandResult.WorkingDirectory}', DbPath='{commandResult.DatabasePath}', ExitCode='{commandResult.ExitCode}', Stdout='{EscapeMultiline(commandResult.StandardOutput)}', Stderr='{EscapeMultiline(commandResult.StandardError)}'"; } private static void TryKill(Process process) @@ -403,6 +795,43 @@ private static void TryKill(Process process) { } } + + private static void ValidateSendArguments(Uri paymentUrl, IReadOnlyList ohttpRelayUrls, Script expectedInvoiceScript) + { + ArgumentNullException.ThrowIfNull(paymentUrl); + ValidateOhttpRelayUrls(ohttpRelayUrls); + ArgumentNullException.ThrowIfNull(expectedInvoiceScript); + } + + private static void ValidateOhttpRelayUrls(IReadOnlyList ohttpRelayUrls) + { + ArgumentNullException.ThrowIfNull(ohttpRelayUrls); + if (ohttpRelayUrls.Count == 0) + { + throw new InvalidOperationException("At least one OHTTP relay URL is required for payjoin-cli."); + } + } } -internal sealed record PayjoinCliPaymentResult(string TransactionId, string StandardOutput, string StandardError); +internal sealed record PayjoinCliPaymentResult( + string TransactionId, + string? SessionId, + string StandardOutput, + string StandardError); + +internal sealed record PayjoinCliExpiryResult( + Transaction FallbackTransaction, + string SessionId); + +internal sealed record PayjoinCliFailureResult( + string? SessionId, + string StandardOutput, + string StandardError); + +internal sealed record PayjoinCliCommandResult( + int? ExitCode, + string ExecutablePath, + string WorkingDirectory, + string DatabasePath, + string StandardOutput, + string StandardError); diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinInvoiceTestHelper.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinInvoiceTestHelper.cs index 7235d2d..fd19fcb 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinInvoiceTestHelper.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinInvoiceTestHelper.cs @@ -132,16 +132,13 @@ public static async Task AssertInvoiceProcessingThenSettledAsync( Func confirmFinalTransactionAsync, CancellationToken cancellationToken) { - var observedStatus = await AssertInvoiceStatusEventuallyAsync( + await AssertInvoiceStatusEventuallyAsync( tester, invoiceId, - [InvoiceStatus.Processing, InvoiceStatus.Settled], + InvoiceStatus.Processing, cancellationToken).ConfigureAwait(true); - if (observedStatus != InvoiceStatus.Settled) - { - await confirmFinalTransactionAsync(cancellationToken).ConfigureAwait(true); - } + await confirmFinalTransactionAsync(cancellationToken).ConfigureAwait(true); await AssertInvoiceStatusEventuallyAsync(tester, invoiceId, InvoiceStatus.Settled, cancellationToken).ConfigureAwait(true); } @@ -176,15 +173,10 @@ private static async Task AssertInvoiceAccountedEventuallyAsync( return null!; } - public static async Task AssertInvoiceStatusEventuallyAsync(ServerTester tester, string invoiceId, InvoiceStatus expectedStatus, CancellationToken cancellationToken) - { - return await AssertInvoiceStatusEventuallyAsync(tester, invoiceId, [expectedStatus], cancellationToken).ConfigureAwait(true); - } - - public static async Task AssertInvoiceStatusEventuallyAsync( + public static async Task AssertInvoiceStatusEventuallyAsync( ServerTester tester, string invoiceId, - IReadOnlyCollection expectedStatuses, + InvoiceStatus expectedStatus, CancellationToken cancellationToken) { var invoiceRepository = tester.PayTester.GetService(); @@ -197,17 +189,16 @@ public static async Task AssertInvoiceStatusEventuallyAsync( if (invoice is not null) { var status = invoice.GetInvoiceState().Status; - if (expectedStatuses.Contains(status)) + if (status == expectedStatus) { - return status; + return; } } await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(true); } - Assert.Fail($"Expected invoice '{invoiceId}' status to become one of: {string.Join(", ", expectedStatuses)}."); - return default; + Assert.Fail($"Expected invoice '{invoiceId}' status to become '{expectedStatus}'."); } private static void AssertHasReceiverContribution(Transaction payjoinTx, HashSet receiverOutpointsBeforePayment) diff --git a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinReceiverTestHelper.cs b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinReceiverTestHelper.cs index d672ab2..5b95d11 100644 --- a/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinReceiverTestHelper.cs +++ b/BTCPayServer.Plugins.Payjoin.IntegrationTests/TestUtils/PayjoinReceiverTestHelper.cs @@ -23,7 +23,7 @@ public static Task AssertReceiverSessionEventuallyRemovedAsync(ServerTester test return AssertReceiverSessionStateAsync(tester, invoiceId, shouldExist: false, cancellationToken); } - public static async Task AssertReceiverSessionEventuallyCloseRequestedAsync(ServerTester tester, string invoiceId, CancellationToken cancellationToken) + public static async Task AssertReceiverSessionEventuallyCloseRequestedAsync(ServerTester tester, string invoiceId, CancellationToken cancellationToken) { var sessionStore = tester.PayTester.GetService(); var maxAttempts = GetAttemptCount(ReceiverSessionRemovalTimeout); @@ -34,13 +34,14 @@ public static async Task AssertReceiverSessionEventuallyCloseRequestedAsync(Serv if (sessionStore.TryGetSession(invoiceId, out var session) && session?.IsCloseRequested == true) { - return; + return session; } await Task.Delay(PollInterval, cancellationToken).ConfigureAwait(true); } Assert.Fail($"Expected receiver session for invoice '{invoiceId}' to be marked for closure while still present."); + return null!; } public static async Task AssertReceiverSessionEventuallyHasContributedInputsAsync(ServerTester tester, string invoiceId, CancellationToken cancellationToken) diff --git a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs index 3aaa68a..c31710b 100644 --- a/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs +++ b/BTCPayServer.Plugins.Payjoin.Tests/Services/PayjoinReceiverPollerTests.cs @@ -38,6 +38,9 @@ public async Task ExecuteAsyncLogsAndContinuesAfterGetSessionsFailure() await poller.StopAsync(CancellationToken.None).ConfigureAwait(true); // Assert + var executeTask = poller.ExecuteTask; + Assert.NotNull(executeTask); + await executeTask.ConfigureAwait(true); var logEntry = Assert.Single(logger.Entries); Assert.Equal(LogLevel.Warning, logEntry.LogLevel); Assert.Equal(new EventId(1, "LogPayjoinReceiverPollingFailed"), logEntry.EventId); diff --git a/BTCPayServer.Plugins.Payjoin/Services/PayjoinReceiverPoller.cs b/BTCPayServer.Plugins.Payjoin/Services/PayjoinReceiverPoller.cs index ffadcc4..72535b4 100644 --- a/BTCPayServer.Plugins.Payjoin/Services/PayjoinReceiverPoller.cs +++ b/BTCPayServer.Plugins.Payjoin/Services/PayjoinReceiverPoller.cs @@ -118,20 +118,26 @@ await _accountingBridgeService.MarkReconciledAsync( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); - while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) + try { - try - { - await ProcessTickOnceAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false)) { - return; - } - catch (Exception ex) - { - LogPayjoinReceiverPollingFailed(_logger, ex); + try + { + await ProcessTickOnceAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + LogPayjoinReceiverPollingFailed(_logger, ex); + } } } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } } }