Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ private async Task<CreateSenderPsbtResult> 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);
}
Expand Down Expand Up @@ -233,9 +233,9 @@ private async Task<PSBT> 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))
Expand Down Expand Up @@ -355,13 +355,23 @@ private async Task<PSBT> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
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 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);

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 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()
{
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<PayjoinAccountingBridgeState> 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<PayjoinPluginDbContext> _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<PayjoinPluginDbContext>()
.UseInMemoryDatabase(databaseName, SharedDatabaseRoot)
.Options;

using var db = CreateContext();
db.Database.EnsureCreated();
}

public override PayjoinPluginDbContext CreateContext(Action<NpgsqlDbContextOptionsBuilder>? npgsqlOptionsAction = null)
{
return new PayjoinPluginDbContext(_dbContextOptions);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi
public Task<IReadOnlyCollection<PayjoinAccountingBridgeState>> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyCollection<PayjoinAccountingBridgeState>>([]);
public Task<PayjoinAccountingBridgeAttentionResult> GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0));
public Task<PayjoinAccountingBridgeState?> TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult<PayjoinAccountingBridgeState?>(null);
public Task<PayjoinAccountingBridgeState?> ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult<PayjoinAccountingBridgeState?>(null);
}

private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService
Expand Down Expand Up @@ -335,6 +336,7 @@ public Task<IReadOnlyCollection<PayjoinAccountingBridgeState>> GetPendingAsync(D
LastMarkedFailedMessage = failureMessage;
return Task.FromResult<PayjoinAccountingBridgeState?>(null);
}
public Task<PayjoinAccountingBridgeState?> ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult<PayjoinAccountingBridgeState?>(null);
public Task<IReadOnlyCollection<PayjoinAccountingBridgeState>> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken)
{
ExpirePendingInvocationCount++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -219,5 +219,6 @@ private sealed class RecordingBridgeService(PayjoinAccountingBridgeState? bridge
public Task<IReadOnlyCollection<PayjoinAccountingBridgeState>> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<PayjoinAccountingBridgeAttentionResult> GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<PayjoinAccountingBridgeState?> TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<PayjoinAccountingBridgeState?> ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => throw new NotSupportedException();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ private sealed class NoOpAccountingBridgeService : IPayjoinAccountingBridgeServi
public Task<IReadOnlyCollection<PayjoinAccountingBridgeState>> ExpirePendingAsync(DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyCollection<PayjoinAccountingBridgeState>>([]);
public Task<PayjoinAccountingBridgeAttentionResult> GetRequiringAttentionAsync(string storeId, CancellationToken cancellationToken) => Task.FromResult(new PayjoinAccountingBridgeAttentionResult([], 0));
public Task<PayjoinAccountingBridgeState?> TryRetryAsync(string invoiceId, string storeId, DateTimeOffset now, CancellationToken cancellationToken) => Task.FromResult<PayjoinAccountingBridgeState?>(null);
public Task<PayjoinAccountingBridgeState?> ResetForNewSessionAsync(string invoiceId, long? effectiveInvoiceValueSats, DateTimeOffset? expiresAt, CancellationToken cancellationToken) => Task.FromResult<PayjoinAccountingBridgeState?>(null);
}

private sealed class NoOpAccountingPaymentService : IPayjoinAccountingPaymentService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task<ActionResult<RunTestPaymentResponse>> 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}");
}
Expand Down
Loading
Loading