mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2026-01-08 08:34:25 +01:00
Merge pull request #1464 from NicolasDorier/payjoin/persistance
Persist planned transactions and locks for payjoin
This commit is contained in:
@@ -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;
|
||||
|
||||
16
BTCPayServer.Data/Data/PayjoinLock.cs
Normal file
16
BTCPayServer.Data/Data/PayjoinLock.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
15
BTCPayServer.Data/Data/PlannedTransaction.cs
Normal file
15
BTCPayServer.Data/Data/PlannedTransaction.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user