Merge pull request #1464 from NicolasDorier/payjoin/persistance

Persist planned transactions and locks for payjoin
This commit is contained in:
Nicolas Dorier
2020-04-13 16:57:13 +09:00
committed by GitHub
8 changed files with 288 additions and 45 deletions

View File

@@ -35,6 +35,9 @@ namespace BTCPayServer.Data
get; set;
}
public DbSet<PlannedTransaction> PlannedTransactions { get; set; }
public DbSet<PayjoinLock> PayjoinLocks { get; set; }
public DbSet<AppData> Apps
{
get; set;

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Data
{
/// <summary>
/// We represent the locks of the PayjoinRepository
/// with this table. (Both, our utxo we locked as part of a payjoin
/// and the utxo of the payer which were used to pay us)
/// </summary>
public class PayjoinLock
{
[Key]
[MaxLength(100)]
public string Id { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Data
{
public class PlannedTransaction
{
[Key]
[MaxLength(100)]
// Id in the format [cryptocode]-[txid]
public string Id { get; set; }
public DateTimeOffset BroadcastAt { get; set; }
public byte[] Blob { get; set; }
}
}

View File

@@ -0,0 +1,46 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20200413052418_PlannedTransactions")]
public partial class PlannedTransactions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlannedTransactions",
columns: table => new
{
Id = table.Column<string>(maxLength: 100, nullable: false),
BroadcastAt = table.Column<DateTimeOffset>(nullable: false),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlannedTransactions", x => x.Id);
});
migrationBuilder.CreateTable(
name: "PayjoinLocks",
columns: table => new
{
Id = table.Column<string>(maxLength: 100, nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_PayjoinLocks", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PayjoinLocks");
migrationBuilder.DropTable(
name: "PlannedTransactions");
}
}
}

View File

@@ -297,6 +297,17 @@ namespace BTCPayServer.Migrations
b.ToTable("PairingCodes");
});
modelBuilder.Entity("BTCPayServer.Data.PayjoinLock", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(100);
b.HasKey("Id");
b.ToTable("PayjoinLocks");
});
modelBuilder.Entity("BTCPayServer.Data.PaymentData", b =>
{
b.Property<string>("Id")
@@ -356,6 +367,23 @@ namespace BTCPayServer.Migrations
b.ToTable("PendingInvoices");
});
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(100);
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("BroadcastAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PlannedTransactions");
});
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
{
b.Property<string>("Id")

View File

@@ -23,6 +23,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel;
using NBitcoin;
using NBitcoin.Altcoins;
using NBitcoin.Payment;
using NBitpayClient;
using OpenQA.Selenium;
@@ -41,6 +42,67 @@ namespace BTCPayServer.Tests
Logs.LogProvider = new XUnitLogProvider(helper);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseTheDelayedBroadcaster()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
await broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromDays(500), RandomTransaction(network), network);
var tx = RandomTransaction(network);
await broadcaster.Schedule(DateTimeOffset.UtcNow - TimeSpan.FromDays(5), tx, network);
// twice on same tx should be noop
await broadcaster.Schedule(DateTimeOffset.UtcNow - TimeSpan.FromDays(5), tx, network);
broadcaster.Disable();
Assert.Equal(0, await broadcaster.ProcessAll());
broadcaster.Enable();
Assert.Equal(1, await broadcaster.ProcessAll());
Assert.Equal(0, await broadcaster.ProcessAll());
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUsePayjoinRepository()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
var repo = tester.PayTester.GetService<PayJoinRepository>();
var outpoint = RandomOutpoint();
// Should not be locked
Assert.False(await repo.TryUnlock(outpoint));
// Can lock input
Assert.True(await repo.TryLockInputs(new [] { outpoint }));
// Can't twice
Assert.False(await repo.TryLockInputs(new [] { outpoint }));
Assert.False(await repo.TryUnlock(outpoint));
// Lock and unlock outpoint utxo
Assert.True(await repo.TryLock(outpoint));
Assert.True(await repo.TryUnlock(outpoint));
Assert.False(await repo.TryUnlock(outpoint));
}
}
private Transaction RandomTransaction(BTCPayNetwork network)
{
var tx = network.NBitcoinNetwork.CreateTransaction();
tx.Inputs.Add(new OutPoint(RandomUtils.GetUInt256(), 0), Script.Empty);
tx.Outputs.Add(Money.Coins(1.0m), new Key().ScriptPubKey);
return tx;
}
private OutPoint RandomOutpoint()
{
return new OutPoint(RandomUtils.GetUInt256(), 0);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanOnlyUseCorrectAddressFormatsForPayjoin()

View File

@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBXplorer.Models;
@@ -9,40 +11,69 @@ namespace BTCPayServer.Payments.PayJoin
{
public class PayJoinRepository
{
HashSet<OutPoint> _Outpoints = new HashSet<OutPoint>();
HashSet<OutPoint> _LockedInputs = new HashSet<OutPoint>();
public Task<bool> TryLock(OutPoint outpoint)
private readonly ApplicationDbContextFactory _dbContextFactory;
public PayJoinRepository(ApplicationDbContextFactory dbContextFactory)
{
lock (_Outpoints)
_dbContextFactory = dbContextFactory;
}
public async Task<bool> TryLock(OutPoint outpoint)
{
using var ctx = _dbContextFactory.CreateContext();
ctx.PayjoinLocks.Add(new PayjoinLock()
{
return Task.FromResult(_Outpoints.Add(outpoint));
Id = outpoint.ToString()
});
try
{
return await ctx.SaveChangesAsync() == 1;
}
catch (DbUpdateException e)
{
return false;
}
}
public Task<bool> TryUnlock(params OutPoint[] outPoints)
public async Task<bool> TryUnlock(params OutPoint[] outPoints)
{
if (outPoints.Length == 0)
return Task.FromResult(true);
lock (_Outpoints)
using var ctx = _dbContextFactory.CreateContext();
foreach (OutPoint outPoint in outPoints)
{
bool r = true;
foreach (var outpoint in outPoints)
ctx.PayjoinLocks.Remove(new PayjoinLock()
{
r &= _Outpoints.Remove(outpoint);
}
return Task.FromResult(r);
Id = outPoint.ToString()
});
}
try
{
return await ctx.SaveChangesAsync() == outPoints.Length;
}
catch (DbUpdateException e)
{
return false;
}
}
public Task<bool> TryLockInputs(OutPoint[] outPoint)
public async Task<bool> TryLockInputs(OutPoint[] outPoints)
{
lock (_LockedInputs)
using var ctx = _dbContextFactory.CreateContext();
foreach (OutPoint outPoint in outPoints)
{
foreach (var o in outPoint)
if (!_LockedInputs.Add(o))
return Task.FromResult(false);
ctx.PayjoinLocks.Add(new PayjoinLock()
{
// Random flag so it does not lock same id
// as the lock utxo
Id = "K-" + outPoint.ToString()
});
}
try
{
return await ctx.SaveChangesAsync() == outPoints.Length;
}
catch (DbUpdateException e)
{
return false;
}
return Task.FromResult(true);
}
}
}

View File

@@ -7,7 +7,9 @@ using System.Threading.Tasks;
using NBXplorer;
using System.Threading.Channels;
using System.Threading;
using BTCPayServer.Data;
using BTCPayServer.Logging;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services
{
@@ -15,52 +17,68 @@ namespace BTCPayServer.Services
{
class Record
{
public DateTimeOffset Recorded;
public string Id;
public DateTimeOffset BroadcastTime;
public Transaction Transaction;
public BTCPayNetwork Network;
}
Channel<Record> _Records = Channel.CreateUnbounded<Record>();
private readonly ExplorerClientProvider _explorerClientProvider;
public DelayedTransactionBroadcaster(ExplorerClientProvider explorerClientProvider)
private readonly BTCPayNetworkProvider _networkProvider;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly ApplicationDbContextFactory _dbContextFactory;
public DelayedTransactionBroadcaster(
BTCPayNetworkProvider networkProvider,
ExplorerClientProvider explorerClientProvider,
Data.ApplicationDbContextFactory dbContextFactory)
{
if (explorerClientProvider == null)
throw new ArgumentNullException(nameof(explorerClientProvider));
_networkProvider = networkProvider;
_explorerClientProvider = explorerClientProvider;
_dbContextFactory = dbContextFactory;
}
public Task Schedule(DateTimeOffset broadcastTime, Transaction transaction, BTCPayNetwork network)
public async Task Schedule(DateTimeOffset broadcastTime, Transaction transaction, BTCPayNetwork network)
{
if (transaction == null)
throw new ArgumentNullException(nameof(transaction));
if (network == null)
throw new ArgumentNullException(nameof(network));
var now = DateTimeOffset.UtcNow;
var record = new Record()
using (var db = _dbContextFactory.CreateContext())
{
Recorded = now,
BroadcastTime = broadcastTime,
Transaction = transaction,
Network = network
};
_Records.Writer.TryWrite(record);
// TODO: persist
return Task.CompletedTask;
db.PlannedTransactions.Add(new PlannedTransaction()
{
Id = $"{network.CryptoCode}-{transaction.GetHash()}",
BroadcastAt = broadcastTime,
Blob = transaction.ToBytes()
});
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
}
}
}
public async Task ProcessAll(CancellationToken cancellationToken = default)
public async Task<int> ProcessAll(CancellationToken cancellationToken = default)
{
if (disabled)
return;
var now = DateTimeOffset.UtcNow;
List<Record> rescheduled = new List<Record>();
return 0;
List<Record> scheduled = new List<Record>();
List<Record> broadcasted = new List<Record>();
while (_Records.Reader.TryRead(out var r))
using (var db = _dbContextFactory.CreateContext())
{
(r.BroadcastTime > now ? rescheduled : scheduled).Add(r);
scheduled = (await db.PlannedTransactions
.ToListAsync()).Select(ToRecord)
.Where(r => r != null)
// Client side filtering because entity framework is retarded.
.Where(r => r.BroadcastTime < DateTimeOffset.UtcNow).ToList();
}
List<Record> rescheduled = new List<Record>();
List<Record> broadcasted = new List<Record>();
var broadcasts = scheduled.Select(async (record) =>
{
@@ -89,11 +107,30 @@ namespace BTCPayServer.Services
var needReschedule = await broadcasts[i];
(needReschedule ? rescheduled : broadcasted).Add(scheduled[i]);
}
foreach (var record in rescheduled)
using (var db = _dbContextFactory.CreateContext())
{
_Records.Writer.TryWrite(record);
foreach (Record record in broadcasted)
{
db.PlannedTransactions.Remove(new PlannedTransaction() {Id = record.Id});
}
return await db.SaveChangesAsync();
}
// TODO: Remove everything in broadcasted from DB
}
private Record ToRecord(PlannedTransaction plannedTransaction)
{
var s = plannedTransaction.Id.Split('-');
var network = _networkProvider.GetNetwork(s[0]) as BTCPayNetwork;
if (network is null)
return null;
return new Record()
{
Id = plannedTransaction.Id,
Network = network,
Transaction = Transaction.Load(plannedTransaction.Blob, network.NBitcoinNetwork),
BroadcastTime = plannedTransaction.BroadcastAt
};
}
private bool disabled = false;
@@ -101,5 +138,10 @@ namespace BTCPayServer.Services
{
disabled = true;
}
public void Enable()
{
disabled = false;
}
}
}