diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs index 6bdb16718..bd3d0e32c 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -61,6 +63,7 @@ namespace BTCPayServer.Data public DbSet LightningAddresses { get; set; } public DbSet PayoutProcessors { get; set; } public DbSet Forms { get; set; } + public DbSet PendingTransactions { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -106,7 +109,7 @@ namespace BTCPayServer.Data WebhookData.OnModelCreating(builder, Database); FormData.OnModelCreating(builder, Database); StoreRole.OnModelCreating(builder, Database); + PendingTransaction.OnModelCreating(builder, Database); } } - } diff --git a/BTCPayServer.Data/Data/PendingTransaction.cs b/BTCPayServer.Data/Data/PendingTransaction.cs new file mode 100644 index 000000000..2241e6145 --- /dev/null +++ b/BTCPayServer.Data/Data/PendingTransaction.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace BTCPayServer.Data; + +public class PendingTransaction: IHasBlob + { + public string TransactionId { get; set; } + public string CryptoCode { get; set; } + public string StoreId { get; set; } + public StoreData Store { get; set; } + public DateTimeOffset? Expiry { get; set; } + public PendingTransactionState State { get; set; } + public string[] OutpointsUsed { get; set; } + + [NotMapped][Obsolete("Use Blob2 instead")] + public byte[] Blob { get; set; } + + public string Blob2 { get; set; } + + + internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) + { + builder.Entity() + .HasOne(o => o.Store) + .WithMany(i => i.PendingTransactions) + .HasForeignKey(i => i.StoreId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity().HasKey(transaction => new {transaction.CryptoCode, transaction.TransactionId}); + + builder.Entity() + .Property(o => o.Blob2) + .HasColumnType("JSONB"); + builder.Entity() + .Property(o => o.OutpointsUsed) + .HasColumnType("text[]"); + } + } + public enum PendingTransactionState + { + Pending, + Cancelled, + Expired, + Invalidated, + Signed, + Broadcast + } + + public class PendingTransactionBlob + { + public string PSBT { get; set; } + public List CollectedSignatures { get; set; } = new(); + } + + public class CollectedSignature + { + public DateTimeOffset Timestamp { get; set; } + public string ReceivedPSBT { get; set; } + } diff --git a/BTCPayServer.Data/Data/StoreData.cs b/BTCPayServer.Data/Data/StoreData.cs index d42e14d6d..ad46b8f9c 100644 --- a/BTCPayServer.Data/Data/StoreData.cs +++ b/BTCPayServer.Data/Data/StoreData.cs @@ -49,6 +49,7 @@ namespace BTCPayServer.Data public IEnumerable Forms { get; set; } public IEnumerable StoreRoles { get; set; } public bool Archived { get; set; } + public IEnumerable PendingTransactions { get; set; } internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) { diff --git a/BTCPayServer.Data/Migrations/20241029163147_AddingPendingTransactionsTable.cs b/BTCPayServer.Data/Migrations/20241029163147_AddingPendingTransactionsTable.cs new file mode 100644 index 000000000..bc4ba6880 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20241029163147_AddingPendingTransactionsTable.cs @@ -0,0 +1,53 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241029163147_AddingPendingTransactionsTable")] + public partial class AddingPendingTransactionsTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PendingTransactions", + columns: table => new + { + TransactionId = table.Column(type: "text", nullable: false), + CryptoCode = table.Column(type: "text", nullable: false), + StoreId = table.Column(type: "text", nullable: true), + Expiry = table.Column(type: "timestamp with time zone", nullable: true), + State = table.Column(type: "integer", nullable: false), + OutpointsUsed = table.Column(type: "text[]", nullable: true), + Blob2 = table.Column(type: "JSONB", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PendingTransactions", x => new { x.CryptoCode, x.TransactionId }); + table.ForeignKey( + name: "FK_PendingTransactions_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PendingTransactions_StoreId", + table: "PendingTransactions", + column: "StoreId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PendingTransactions"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 4ea527453..a11dab6d2 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -637,6 +637,36 @@ namespace BTCPayServer.Migrations b.ToTable("PayoutProcessors"); }); + modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b => + { + b.Property("CryptoCode") + .HasColumnType("text"); + + b.Property("TransactionId") + .HasColumnType("text"); + + b.Property("Blob2") + .HasColumnType("JSONB"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone"); + + b.Property("OutpointsUsed") + .HasColumnType("text[]"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("text"); + + b.HasKey("CryptoCode", "TransactionId"); + + b.HasIndex("StoreId"); + + b.ToTable("PendingTransactions"); + }); + modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b => { b.Property("Id") @@ -1324,6 +1354,16 @@ namespace BTCPayServer.Migrations b.Navigation("Store"); }); + modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithMany("PendingTransactions") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Store"); + }); + modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b => { b.HasOne("BTCPayServer.Data.StoreData", "StoreData") @@ -1582,6 +1622,8 @@ namespace BTCPayServer.Migrations b.Navigation("Payouts"); + b.Navigation("PendingTransactions"); + b.Navigation("PullPayments"); b.Navigation("Settings"); diff --git a/BTCPayServer/Controllers/UIStoresController.Onchain.cs b/BTCPayServer/Controllers/UIStoresController.Onchain.cs index bb7cd94ed..06d287929 100644 --- a/BTCPayServer/Controllers/UIStoresController.Onchain.cs +++ b/BTCPayServer/Controllers/UIStoresController.Onchain.cs @@ -440,7 +440,10 @@ public partial class UIStoresController CanUsePayJoin = canUseHotWallet && network.SupportPayJoin && derivation.IsHotWallet, CanUseHotWallet = canUseHotWallet, CanUseRPCImport = rpcImport, - StoreName = store.StoreName + StoreName = store.StoreName, + CanSetupMultiSig = derivation.AccountKeySettings.Length > 1, + IsMultiSigOnServer = derivation.IsMultiSigOnServer, + DefaultIncludeNonWitnessUtxo = derivation.DefaultIncludeNonWitnessUtxo }; ViewData["ReplaceDescription"] = WalletReplaceWarning(derivation.IsHotWallet); @@ -477,10 +480,14 @@ public partial class UIStoresController if (payjoinChanged && network.SupportPayJoin) storeBlob.PayJoinEnabled = vm.PayJoinEnabled; if (needUpdate) store.SetStoreBlob(storeBlob); - if (derivation.Label != vm.Label) + if (derivation.Label != vm.Label || + derivation.IsMultiSigOnServer != vm.IsMultiSigOnServer || + derivation.DefaultIncludeNonWitnessUtxo != vm.DefaultIncludeNonWitnessUtxo) { needUpdate = true; derivation.Label = vm.Label; + derivation.IsMultiSigOnServer = vm.IsMultiSigOnServer; + derivation.DefaultIncludeNonWitnessUtxo = vm.DefaultIncludeNonWitnessUtxo; } var signingKey = string.IsNullOrEmpty(vm.SelectedSigningKey) @@ -494,16 +501,14 @@ public partial class UIStoresController for (int i = 0; i < derivation.AccountKeySettings.Length; i++) { - KeyPath accountKeyPath; - HDFingerprint? rootFingerprint; - try { - accountKeyPath = string.IsNullOrWhiteSpace(vm.AccountKeys[i].AccountKeyPath) - ? null - : new KeyPath(vm.AccountKeys[i].AccountKeyPath); + var strKeyPath = vm.AccountKeys[i].AccountKeyPath; + var accountKeyPath = string.IsNullOrWhiteSpace(strKeyPath) ? null : new KeyPath(strKeyPath); - if (accountKeyPath != null && derivation.AccountKeySettings[i].AccountKeyPath != accountKeyPath) + bool pathsDiffer = accountKeyPath != derivation.AccountKeySettings[i].AccountKeyPath; + + if (pathsDiffer) { needUpdate = true; derivation.AccountKeySettings[i].AccountKeyPath = accountKeyPath; @@ -516,7 +521,7 @@ public partial class UIStoresController try { - rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint) + HDFingerprint? rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint) ? null : new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint)); diff --git a/BTCPayServer/Controllers/UIVaultController.cs b/BTCPayServer/Controllers/UIVaultController.cs index 1d65c8ffd..e2bd9bec6 100644 --- a/BTCPayServer/Controllers/UIVaultController.cs +++ b/BTCPayServer/Controllers/UIVaultController.cs @@ -141,12 +141,15 @@ namespace BTCPayServer.Controllers var psbt = PSBT.Parse(o["psbt"].Value(), network.NBitcoinNetwork); var derivationSettings = GetDerivationSchemeSettings(walletId); derivationSettings.RebaseKeyPaths(psbt); - var signing = derivationSettings.GetSigningAccountKeySettings(); - if (signing.GetRootedKeyPath()?.MasterFingerprint != fingerprint) + + // we ensure that the device fingerprint is part of the derivation settings + if (derivationSettings.AccountKeySettings.All(a => a.RootFingerprint != fingerprint)) { await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken); continue; } + + // otherwise, let the device check if it can sign anything var signableInputs = psbt.Inputs .SelectMany(i => i.HDKeyPaths) .Where(i => i.Value.MasterFingerprint == fingerprint) @@ -159,12 +162,24 @@ namespace BTCPayServer.Controllers await websocketHelper.Send("{ \"error\": \"wrong-keypath\"}", cancellationToken); continue; } + + if (derivationSettings.IsMultiSigOnServer) + { + var alreadySigned = psbt.Inputs.Any(a => + a.PartialSigs.Any(a => a.Key == actualPubKey)); + if (alreadySigned) + { + await websocketHelper.Send("{ \"error\": \"already-signed-psbt\"}", cancellationToken); + continue; + } + } } + try { psbt = await device.SignPSBTAsync(psbt, cancellationToken); } - catch (Hwi.HwiException) + catch (HwiException) { await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken); continue; diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs index d1d868c1b..dac759aae 100644 --- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs @@ -28,7 +28,7 @@ namespace BTCPayServer.Controllers public async Task CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken) { var nbx = ExplorerClientProvider.GetExplorerClient(network); - CreatePSBTRequest psbtRequest = new CreatePSBTRequest(); + CreatePSBTRequest psbtRequest = new(); if (sendModel.InputSelection) { psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList() ?? new List(); @@ -250,6 +250,9 @@ namespace BTCPayServer.Controllers } switch (command) { + case "createpending": + var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt); + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); case "sign": return await WalletSign(walletId, vm); case "decode": @@ -288,7 +291,7 @@ namespace BTCPayServer.Controllers }); case "broadcast": - return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel + return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel { SigningContext = new SigningContextModel(psbt), ReturnUrl = vm.ReturnUrl, @@ -604,6 +607,12 @@ namespace BTCPayServer.Controllers { return LocalRedirect(vm.ReturnUrl); } + + if (vm.SigningContext.PendingTransactionId is not null) + { + await _pendingTransactionService.Broadcasted(walletId.CryptoCode, walletId.StoreId, + vm.SigningContext.PendingTransactionId); + } return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } case "analyze-psbt": diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 7d9e62f65..c583e5b42 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -33,7 +33,9 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using NBitcoin; @@ -77,9 +79,12 @@ namespace BTCPayServer.Controllers private readonly PullPaymentHostedService _pullPaymentHostedService; private readonly WalletHistogramService _walletHistogramService; + private readonly PendingTransactionService _pendingTransactionService; readonly CurrencyNameTable _currencyTable; - public UIWalletsController(StoreRepository repo, + public UIWalletsController( + PendingTransactionService pendingTransactionService, + StoreRepository repo, WalletRepository walletRepository, CurrencyNameTable currencyTable, BTCPayNetworkProvider networkProvider, @@ -104,6 +109,7 @@ namespace BTCPayServer.Controllers IStringLocalizer stringLocalizer, TransactionLinkProviders transactionLinkProviders) { + _pendingTransactionService = pendingTransactionService; _currencyTable = currencyTable; _labelService = labelService; _defaultRules = defaultRules; @@ -130,6 +136,67 @@ namespace BTCPayServer.Controllers StringLocalizer = stringLocalizer; } + [HttpGet("{walletId}/pending/{transactionId}/cancel")] + public IActionResult CancelPendingTransaction( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + string transactionId) + { + return View("Confirm", new ConfirmModel("Abort Pending Transaction", + "Proceeding with this action will invalidate Pending Transaction and all accepted signatures.", + "Confirm Abort")); + } + [HttpPost("{walletId}/pending/{transactionId}/cancel")] + public async Task CancelPendingTransactionConfirmed( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + string transactionId) + { + await _pendingTransactionService.CancelPendingTransaction(walletId.CryptoCode, walletId.StoreId, transactionId); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = $"Aborted Pending Transaction {transactionId}" + }); + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); + } + + + [HttpGet("{walletId}/pending/{transactionId}")] + public async Task ViewPendingTransaction( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + string transactionId) + { + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + var pendingTransaction = + await _pendingTransactionService.GetPendingTransaction(walletId.CryptoCode, walletId.StoreId, + transactionId); + if (pendingTransaction is null) + return NotFound(); + var blob = pendingTransaction.GetBlob(); + if (blob?.PSBT is null) + return NotFound(); + var currentPsbt = PSBT.Parse(blob.PSBT, network.NBitcoinNetwork); + foreach (CollectedSignature collectedSignature in blob.CollectedSignatures) + { + var psbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork); + currentPsbt = currentPsbt.Combine(psbt); + } + + var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); + + var vm = new WalletPSBTViewModel() + { + CryptoCode = network.CryptoCode, + SigningContext = new SigningContextModel(currentPsbt) + { + PendingTransactionId = transactionId, PSBT = currentPsbt.ToBase64(), + }, + }; + await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network); + await vm.GetPSBT(network.NBitcoinNetwork, ModelState); + return View("WalletPSBTDecoded", vm); + } + + [HttpPost] [Route("{walletId}")] public async Task ModifyTransaction( @@ -243,6 +310,9 @@ namespace BTCPayServer.Controllers // We can't filter at the database level if we need to apply label filter var preFiltering = string.IsNullOrEmpty(labelFilter); var model = new ListTransactionsViewModel { Skip = skip, Count = count }; + + model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId); + model.Labels.AddRange( (await WalletRepository.GetWalletLabels(walletId)) .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))); @@ -452,7 +522,9 @@ namespace BTCPayServer.Controllers var model = new WalletSendModel { CryptoCode = walletId.CryptoCode, - ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath + ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath, + IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer, + AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo }; if (bip21?.Any() is true) { @@ -849,6 +921,9 @@ namespace BTCPayServer.Controllers }; switch (command) { + case "createpending": + var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt); + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); case "sign": return await WalletSign(walletId, new WalletPSBTViewModel { @@ -949,10 +1024,10 @@ namespace BTCPayServer.Controllers } [HttpPost("{walletId}/vault")] - public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + public async Task WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { - return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel + return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel { SigningContext = model.SigningContext, ReturnUrl = model.ReturnUrl, @@ -960,8 +1035,17 @@ namespace BTCPayServer.Controllers }); } - private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm) + private async Task RedirectToWalletPSBTReady(WalletId walletId, WalletPSBTReadyViewModel vm) { + if (vm.SigningContext.PendingTransactionId is not null) + { + var psbt = PSBT.Parse(vm.SigningContext.PSBT, NetworkProvider.GetNetwork(walletId.CryptoCode).NBitcoinNetwork); + var pendingTransaction = await _pendingTransactionService.CollectSignature(walletId.CryptoCode, psbt, false, CancellationToken.None); + + if (pendingTransaction != null) + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); + } + var redirectVm = new PostRedirectViewModel { AspController = "UIWallets", @@ -1003,6 +1087,7 @@ namespace BTCPayServer.Controllers redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); + redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId); } private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm) @@ -1119,7 +1204,7 @@ namespace BTCPayServer.Controllers ModelState.Remove(nameof(viewModel.SigningContext.PSBT)); viewModel.SigningContext ??= new(); viewModel.SigningContext.PSBT = psbt?.ToBase64(); - return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel + return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel { SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(), SigningKeyPath = rootedKeyPath?.ToString(), diff --git a/BTCPayServer/DerivationSchemeSettings.cs b/BTCPayServer/DerivationSchemeSettings.cs index 13cd24e7c..fd57e421d 100644 --- a/BTCPayServer/DerivationSchemeSettings.cs +++ b/BTCPayServer/DerivationSchemeSettings.cs @@ -33,7 +33,6 @@ namespace BTCPayServer public DerivationSchemeSettings() { - } public DerivationSchemeSettings(DerivationStrategyBase derivationStrategy, BTCPayNetwork network) @@ -48,16 +47,16 @@ namespace BTCPayServer } - BitcoinExtPubKey _SigningKey; + private BitcoinExtPubKey _signingKey; public BitcoinExtPubKey SigningKey { get { - return _SigningKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault(); + return _signingKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault(); } set { - _SigningKey = value; + _signingKey = value; } } public string Source { get; set; } @@ -84,11 +83,7 @@ namespace BTCPayServer return AccountKeySettings.Single(a => a.AccountKey == SigningKey); } - public AccountKeySettings[] AccountKeySettings - { - get; - set; - } + public AccountKeySettings[] AccountKeySettings { get; set; } public IEnumerable GetPSBTRebaseKeyRules() { @@ -107,6 +102,14 @@ namespace BTCPayServer public string Label { get; set; } + #region MultiSig related settings + public bool IsMultiSigOnServer { get; set; } + + // some hardware devices like Jade require sending full input transactions if there are multiple inputs + // https://github.com/Blockstream/Jade/blob/0d6ce77bf23ef2b5dc43cdae3967b4207e8cad52/main/process/sign_tx.c#L586 + public bool DefaultIncludeNonWitnessUtxo { get; set; } + #endregion + public override string ToString() { return AccountDerivation.ToString(); @@ -114,7 +117,7 @@ namespace BTCPayServer public string ToPrettyString() { return !string.IsNullOrEmpty(Label) ? Label : - !String.IsNullOrEmpty(AccountOriginal) ? AccountOriginal : + !string.IsNullOrEmpty(AccountOriginal) ? AccountOriginal : ToString(); } diff --git a/BTCPayServer/HostedServices/PendingTransactionService.cs b/BTCPayServer/HostedServices/PendingTransactionService.cs new file mode 100644 index 000000000..90d1e2373 --- /dev/null +++ b/BTCPayServer/HostedServices/PendingTransactionService.cs @@ -0,0 +1,222 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NBitcoin; + +namespace BTCPayServer.HostedServices; + +public class PendingTransactionService( + DelayedTransactionBroadcaster broadcaster, + BTCPayNetworkProvider networkProvider, + ApplicationDbContextFactory dbContextFactory, + EventAggregator eventAggregator, + ILogger logger, + ExplorerClientProvider explorerClientProvider) + : EventHostedServiceBase(eventAggregator, logger), IPeriodicTask +{ + protected override void SubscribeToEvents() + { + Subscribe(); + 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 CreatePendingTransaction(string storeId, string cryptoCode, PSBT psbt, + DateTimeOffset? expiry = null, CancellationToken cancellationToken = default) + { + var network = networkProvider.GetNetwork(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 CollectSignature(string cryptoCode, PSBT psbt, bool broadcastIfComplete, + CancellationToken cancellationToken) + { + var network = networkProvider.GetNetwork(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) + { + return null; + } + + if (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); + foreach (var collectedSignature in blob.CollectedSignatures) + { + var collectedPsbt = PSBT.Parse(collectedSignature.ReceivedPSBT, network.NBitcoinNetwork); + originalPsbtWorkingCopy = originalPsbtWorkingCopy.Combine(collectedPsbt); + } + + var originalPsbtWorkingCopyWithNewPsbt = originalPsbtWorkingCopy.Combine(psbt); + //check if we have more signatures than before + if (originalPsbtWorkingCopyWithNewPsbt.Inputs.All(i => + i.PartialSigs.Count >= originalPsbtWorkingCopy.Inputs[(int)i.Index].PartialSigs.Count)) + { + blob.CollectedSignatures.Add(new CollectedSignature + { + ReceivedPSBT = psbt.ToBase64(), 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 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 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(); + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index f0bcb870c..5eaa302be 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -352,6 +352,8 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.AddSingleton(); + services.AddScheduledTask(TimeSpan.FromMinutes(10)); services.TryAddSingleton(); services.AddSingleton(provider => provider.GetService()); diff --git a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs index 4a3d65d89..092293aaf 100644 --- a/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/WalletSettingsViewModel.cs @@ -25,10 +25,21 @@ namespace BTCPayServer.Models.StoreViewModels public string SelectedSigningKey { get; set; } public bool IsMultiSig => AccountKeys.Count > 1; - public List AccountKeys { get; set; } = new List(); + public List AccountKeys { get; set; } = new(); public bool NBXSeedAvailable { get; set; } public string StoreName { get; set; } public string UriScheme { get; set; } + + #region MultiSig related settings + public bool CanSetupMultiSig { get; set; } + [Display(Name = "Is MultiSig on Server")] + public bool IsMultiSigOnServer { get; set; } + + // some hardware devices like Jade require sending full input transactions if there are multiple inputs + // https://github.com/Blockstream/Jade/blob/0d6ce77bf23ef2b5dc43cdae3967b4207e8cad52/main/process/sign_tx.c#L586 + [Display(Name = "Default Include NonWitness Utxo in PSBTs")] + public bool DefaultIncludeNonWitnessUtxo { get; set; } + #endregion } public class WalletSettingsAccountKeyViewModel diff --git a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs index bd21a111b..96725d60b 100644 --- a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using BTCPayServer.Data; namespace BTCPayServer.Models.WalletViewModels { @@ -20,5 +21,6 @@ namespace BTCPayServer.Models.WalletViewModels public List Transactions { get; set; } = new(); public override int CurrentPageCount => Transactions.Count; public string CryptoCode { get; set; } + public PendingTransaction[] PendingTransactions { get; set; } } } diff --git a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs index b123c27f3..ce4ab9932 100644 --- a/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs +++ b/BTCPayServer/Models/WalletViewModels/SigningContextModel.cs @@ -17,5 +17,7 @@ namespace BTCPayServer.Models.WalletViewModels public string PayJoinBIP21 { get; set; } public bool? EnforceLowR { get; set; } public string ChangeAddress { get; set; } + + public string PendingTransactionId { get; set; } } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 485c3261c..ab2f82fcd 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -69,6 +69,7 @@ namespace BTCPayServer.Models.WalletViewModels public string BackUrl { get; set; } public string ReturnUrl { get; set; } + public bool IsMultiSigOnServer { get; set; } public class InputSelectionOption { diff --git a/BTCPayServer/Views/UIStores/WalletSettings.cshtml b/BTCPayServer/Views/UIStores/WalletSettings.cshtml index 94d480189..bdc7e02d5 100644 --- a/BTCPayServer/Views/UIStores/WalletSettings.cshtml +++ b/BTCPayServer/Views/UIStores/WalletSettings.cshtml @@ -16,156 +16,173 @@ } -

@ViewData["Title"]

- -
-
-
-
- @(Model.IsHotWallet ? StringLocalizer["Hot wallet"] : StringLocalizer["Watch-only wallet"]) - -
- -
-
-
- - -
- -
- @if (Model.CanUsePayJoin) - { -
-
- - - - - -
- -
- } -
- - - -
-
- -
- - -
-
- @if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme) - { -
- -
- - -
-
- } - @for (var i = 0; i < Model.AccountKeys.Count; i++) - { -

@StringLocalizer["Account Key"] @i

-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- @if (Model.IsMultiSig) + +
+ +
+
+
+ + +
+ +
+ @if (Model.CanUsePayJoin) + { +
+
+ + + + + +
+ +
+ } + @if (Model.CanSetupMultiSig) + { +
+
+ + +
+ +
+
+
+ + +
+ +
+ } +
+ + + +
+
+ +
+ + +
+
+ @if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme) + { +
+ +
+ + +
+
+ } + @for (var i = 0; i < Model.AccountKeys.Count; i++) + { +

@StringLocalizer["Account Key"] @i

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ @if (Model is { IsMultiSig: true, IsMultiSigOnServer: false }) + { +
+ + +
+ } + } +
+ diff --git a/BTCPayServer/Views/UIWallets/SigningContext.cshtml b/BTCPayServer/Views/UIWallets/SigningContext.cshtml index 9e7d97e10..b1b6546f7 100644 --- a/BTCPayServer/Views/UIWallets/SigningContext.cshtml +++ b/BTCPayServer/Views/UIWallets/SigningContext.cshtml @@ -7,4 +7,5 @@ + } diff --git a/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml b/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml index d90794883..61cb07a78 100644 --- a/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletPSBTDecoded.cshtml @@ -2,20 +2,20 @@ @inject BTCPayServer.Security.ContentSecurityPolicies Csp @model WalletPSBTViewModel @{ - var walletId = Context.GetRouteValue("walletId").ToString(); - var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId }); - var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null; - var isReady = !Model.HasErrors; - var isSignable = !isReady; - var needsExport = !isSignable && !isReady; - Layout = "_LayoutWizard"; + var walletId = Context.GetRouteValue("walletId").ToString(); + var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new {walletId}); + var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null; + var isReady = !Model.HasErrors; + var isSignable = !isReady; + var needsExport = !isSignable && !isReady; + Layout = "_LayoutWizard"; ViewData.SetActivePage(WalletsNavPages.PSBT, isReady ? StringLocalizer["Confirm broadcasting this transaction"] : StringLocalizer["Transaction Details"], walletId); - Csp.UnsafeEval(); + Csp.UnsafeEval(); } @section PageHeadContent { - +