mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-31 20:54:31 +01:00
328 lines
13 KiB
C#
328 lines
13 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using BTCPayServer.Client.Models;
|
|
using BTCPayServer.Controllers;
|
|
using BTCPayServer.Data;
|
|
using BTCPayServer.Events;
|
|
using BTCPayServer.HostedServices.Webhooks;
|
|
using BTCPayServer.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
using NBitcoin;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
|
|
|
|
namespace BTCPayServer.HostedServices;
|
|
|
|
public class PendingTransactionService(
|
|
DelayedTransactionBroadcaster broadcaster,
|
|
BTCPayNetworkProvider networkProvider,
|
|
ApplicationDbContextFactory dbContextFactory,
|
|
EventAggregator eventAggregator,
|
|
ILogger<PendingTransactionService> logger,
|
|
ExplorerClientProvider explorerClientProvider)
|
|
: EventHostedServiceBase(eventAggregator, logger), IPeriodicTask, IWebhookProvider
|
|
{
|
|
protected override void SubscribeToEvents()
|
|
{
|
|
Subscribe<NewOnChainTransactionEvent>();
|
|
base.SubscribeToEvents();
|
|
}
|
|
|
|
public Task Do(CancellationToken cancellationToken)
|
|
{
|
|
PushEvent(new CheckForExpiryEvent());
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public class CheckForExpiryEvent { }
|
|
|
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
|
{
|
|
if (evt is CheckForExpiryEvent)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var pendingTransactions = await ctx.PendingTransactions
|
|
.Where(p => p.Expiry <= DateTimeOffset.UtcNow && p.State == PendingTransactionState.Pending)
|
|
.ToArrayAsync(cancellationToken: cancellationToken);
|
|
foreach (var pendingTransaction in pendingTransactions)
|
|
{
|
|
pendingTransaction.State = PendingTransactionState.Expired;
|
|
}
|
|
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
}
|
|
else if (evt is NewOnChainTransactionEvent newTransactionEvent)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var txInputs = newTransactionEvent.NewTransactionEvent.TransactionData.Transaction.Inputs
|
|
.Select(i => i.PrevOut.ToString()).ToArray();
|
|
var txHash = newTransactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString();
|
|
var pendingTransactions = await ctx.PendingTransactions
|
|
.Where(p => p.TransactionId == txHash || p.OutpointsUsed.Any(o => txInputs.Contains(o)))
|
|
.ToArrayAsync(cancellationToken: cancellationToken);
|
|
if (!pendingTransactions.Any())
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var pendingTransaction in pendingTransactions)
|
|
{
|
|
if (pendingTransaction.TransactionId == txHash)
|
|
{
|
|
pendingTransaction.State = PendingTransactionState.Broadcast;
|
|
continue;
|
|
}
|
|
|
|
if (pendingTransaction.OutpointsUsed.Any(o => txInputs.Contains(o)))
|
|
{
|
|
pendingTransaction.State = PendingTransactionState.Invalidated;
|
|
}
|
|
}
|
|
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
await base.ProcessEvent(evt, cancellationToken);
|
|
}
|
|
|
|
public async Task<PendingTransaction> CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt,
|
|
DateTimeOffset? expiry = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
|
if (network is null)
|
|
{
|
|
throw new NotSupportedException("CryptoCode not supported");
|
|
}
|
|
|
|
var txId = psbt.GetGlobalTransaction().GetHash();
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var pendingTransaction = new PendingTransaction
|
|
{
|
|
CryptoCode = cryptoCode,
|
|
TransactionId = txId.ToString(),
|
|
State = PendingTransactionState.Pending,
|
|
OutpointsUsed = psbt.Inputs.Select(i => i.PrevOut.ToString()).ToArray(),
|
|
Expiry = expiry,
|
|
StoreId = storeId,
|
|
};
|
|
pendingTransaction.SetBlob(new PendingTransactionBlob { PSBT = psbt.ToBase64() });
|
|
ctx.PendingTransactions.Add(pendingTransaction);
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
return pendingTransaction;
|
|
}
|
|
|
|
public async Task<PendingTransaction?> CollectSignature(string cryptoCode, PSBT psbt, bool broadcastIfComplete,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var network = networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
|
if (network is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var txId = psbt.GetGlobalTransaction().GetHash();
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var pendingTransaction =
|
|
await ctx.PendingTransactions.FindAsync(new object[] { cryptoCode, txId.ToString() }, cancellationToken);
|
|
if (pendingTransaction is null || pendingTransaction.State != PendingTransactionState.Pending)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var blob = pendingTransaction.GetBlob();
|
|
if (blob?.PSBT is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var originalPsbtWorkingCopy = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork);
|
|
|
|
// Deduplicate: Check if this exact PSBT (Base64) was already collected
|
|
var newPsbtBase64 = psbt.ToBase64();
|
|
if (blob.CollectedSignatures.Any(s => s.ReceivedPSBT == newPsbtBase64))
|
|
{
|
|
return pendingTransaction; // Avoid duplicate signature collection
|
|
}
|
|
|
|
foreach (var collectedSignature in blob.CollectedSignatures)
|
|
{
|
|
var collectedPsbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork);
|
|
originalPsbtWorkingCopy.Combine(collectedPsbt); // combine changes the object
|
|
}
|
|
|
|
var originalPsbtWorkingCopyWithNewPsbt = originalPsbtWorkingCopy.Clone(); // Clone before modifying
|
|
originalPsbtWorkingCopyWithNewPsbt.Combine(psbt);
|
|
|
|
// Check if new signatures were actually added
|
|
bool newSignaturesCollected = false;
|
|
for (int i = 0; i < originalPsbtWorkingCopy.Inputs.Count; i++)
|
|
{
|
|
if (originalPsbtWorkingCopyWithNewPsbt.Inputs[i].PartialSigs.Count >
|
|
originalPsbtWorkingCopy.Inputs[i].PartialSigs.Count)
|
|
{
|
|
newSignaturesCollected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (newSignaturesCollected)
|
|
{
|
|
blob.CollectedSignatures.Add(new CollectedSignature
|
|
{
|
|
ReceivedPSBT = newPsbtBase64,
|
|
Timestamp = DateTimeOffset.UtcNow
|
|
});
|
|
pendingTransaction.SetBlob(blob);
|
|
}
|
|
|
|
if (originalPsbtWorkingCopyWithNewPsbt.TryFinalize(out _))
|
|
{
|
|
pendingTransaction.State = PendingTransactionState.Signed;
|
|
}
|
|
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
|
|
if (broadcastIfComplete && pendingTransaction.State == PendingTransactionState.Signed)
|
|
{
|
|
var explorerClient = explorerClientProvider.GetExplorerClient(network);
|
|
var tx = originalPsbtWorkingCopyWithNewPsbt.ExtractTransaction();
|
|
var result = await explorerClient.BroadcastAsync(tx, cancellationToken);
|
|
if (result.Success)
|
|
{
|
|
pendingTransaction.State = PendingTransactionState.Broadcast;
|
|
await ctx.SaveChangesAsync(cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
await broadcaster.Schedule(DateTimeOffset.Now, tx, network);
|
|
}
|
|
}
|
|
|
|
return pendingTransaction;
|
|
}
|
|
|
|
|
|
|
|
public async Task<PendingTransaction?> GetPendingTransaction(string cryptoCode, string storeId, string txId)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
return await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
|
|
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == txId);
|
|
}
|
|
|
|
public async Task<PendingTransaction[]> GetPendingTransactions(string cryptoCode, string storeId)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
return await ctx.PendingTransactions.Where(p =>
|
|
p.CryptoCode == cryptoCode && p.StoreId == storeId && (p.State == PendingTransactionState.Pending ||
|
|
p.State == PendingTransactionState.Signed))
|
|
.ToArrayAsync();
|
|
}
|
|
|
|
public async Task CancelPendingTransaction(string cryptoCode, string storeId, string transactionId)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
|
|
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
|
|
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
|
|
if (pt is null) return;
|
|
pt.State = PendingTransactionState.Cancelled;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task Broadcasted(string cryptoCode, string storeId, string transactionId)
|
|
{
|
|
await using var ctx = dbContextFactory.CreateContext();
|
|
var pt = await ctx.PendingTransactions.FirstOrDefaultAsync(p =>
|
|
p.CryptoCode == cryptoCode && p.StoreId == storeId && p.TransactionId == transactionId &&
|
|
(p.State == PendingTransactionState.Pending || p.State == PendingTransactionState.Signed));
|
|
if (pt is null) return;
|
|
pt.State = PendingTransactionState.Broadcast;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
|
|
public const string PendingTransactionCreated = "PendingTransactionCreated";
|
|
public const string PendingTransactionSigned = "PendingTransactionSigned";
|
|
|
|
public Dictionary<string, string> GetSupportedWebhookTypes()
|
|
{
|
|
return new Dictionary<string, string>
|
|
{
|
|
{PendingTransactionCreated, "Pending Transaction - Created"},
|
|
{PendingTransactionSigned, "Pending Transaction - Signed"}
|
|
};
|
|
}
|
|
|
|
public WebhookEvent CreateTestEvent(string type, params object[] args)
|
|
{
|
|
var storeId = args[0].ToString();
|
|
return new WebhookPendingTransactionEvent(type, storeId)
|
|
{
|
|
AppId = "__test__" + Guid.NewGuid() + "__test__",
|
|
SubscriptionId = "__test__" + Guid.NewGuid() + "__test__",
|
|
Status = "Active"
|
|
};
|
|
}
|
|
|
|
public class WebhookPendingTransactionEvent : StoreWebhookEvent
|
|
{
|
|
public WebhookPendingTransactionEvent(string type, string storeId)
|
|
{
|
|
if (!type.StartsWith("subscription", StringComparison.InvariantCultureIgnoreCase))
|
|
throw new ArgumentException("Invalid event type", nameof(type));
|
|
Type = type;
|
|
StoreId = storeId;
|
|
}
|
|
|
|
|
|
[JsonProperty(Order = 2)] public string AppId { get; set; }
|
|
|
|
[JsonProperty(Order = 3)] public string SubscriptionId { get; set; }
|
|
[JsonProperty(Order = 4)] public string Status { get; set; }
|
|
[JsonProperty(Order = 5)] public string PaymentRequestId { get; set; }
|
|
[JsonProperty(Order = 6)] public string Email { get; set; }
|
|
}
|
|
|
|
public class SubscriptionWebhookDeliveryRequest(
|
|
string receiptUrl,
|
|
string? webhookId,
|
|
WebhookPendingTransactionEvent webhookEvent,
|
|
WebhookDeliveryData? delivery,
|
|
WebhookBlob? webhookBlob,
|
|
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
|
|
: WebhookSender.WebhookDeliveryRequest(webhookId!, webhookEvent, delivery!, webhookBlob!)
|
|
{
|
|
public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
|
|
UIStoresController.StoreEmailRule storeEmailRule)
|
|
{
|
|
if (storeEmailRule.CustomerEmail &&
|
|
MailboxAddressValidator.TryParse(webhookEvent.Email, out var bmb))
|
|
{
|
|
req.Email ??= string.Empty;
|
|
req.Email += $",{bmb}";
|
|
}
|
|
|
|
req.Subject = Interpolate(req.Subject);
|
|
req.Body = Interpolate(req.Body);
|
|
return Task.FromResult(req)!;
|
|
}
|
|
|
|
private string Interpolate(string str)
|
|
{
|
|
var res = str.Replace("{Subscription.SubscriptionId}", webhookEvent.SubscriptionId)
|
|
.Replace("{Subscription.Status}", webhookEvent.Status)
|
|
.Replace("{Subscription.PaymentRequestId}", webhookEvent.PaymentRequestId)
|
|
.Replace("{Subscription.AppId}", webhookEvent.AppId);
|
|
|
|
return res;
|
|
}
|
|
}
|
|
}
|