mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
Multisig/watchonly wallet transaction creation flow proof of concept (#5743)
This commit is contained in:
parent
cc915df10e
commit
b797cc9af8
22 changed files with 834 additions and 232 deletions
|
@ -1,4 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
|
@ -61,6 +63,7 @@ namespace BTCPayServer.Data
|
||||||
public DbSet<LightningAddressData> LightningAddresses { get; set; }
|
public DbSet<LightningAddressData> LightningAddresses { get; set; }
|
||||||
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
|
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
|
||||||
public DbSet<FormData> Forms { get; set; }
|
public DbSet<FormData> Forms { get; set; }
|
||||||
|
public DbSet<PendingTransaction> PendingTransactions { get; set; }
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
|
@ -106,7 +109,7 @@ namespace BTCPayServer.Data
|
||||||
WebhookData.OnModelCreating(builder, Database);
|
WebhookData.OnModelCreating(builder, Database);
|
||||||
FormData.OnModelCreating(builder, Database);
|
FormData.OnModelCreating(builder, Database);
|
||||||
StoreRole.OnModelCreating(builder, Database);
|
StoreRole.OnModelCreating(builder, Database);
|
||||||
|
PendingTransaction.OnModelCreating(builder, Database);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
63
BTCPayServer.Data/Data/PendingTransaction.cs
Normal file
63
BTCPayServer.Data/Data/PendingTransaction.cs
Normal file
|
@ -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<PendingTransactionBlob>
|
||||||
|
{
|
||||||
|
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<PendingTransaction>()
|
||||||
|
.HasOne(o => o.Store)
|
||||||
|
.WithMany(i => i.PendingTransactions)
|
||||||
|
.HasForeignKey(i => i.StoreId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
builder.Entity<PendingTransaction>().HasKey(transaction => new {transaction.CryptoCode, transaction.TransactionId});
|
||||||
|
|
||||||
|
builder.Entity<PendingTransaction>()
|
||||||
|
.Property(o => o.Blob2)
|
||||||
|
.HasColumnType("JSONB");
|
||||||
|
builder.Entity<PendingTransaction>()
|
||||||
|
.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<CollectedSignature> CollectedSignatures { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CollectedSignature
|
||||||
|
{
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
public string ReceivedPSBT { get; set; }
|
||||||
|
}
|
|
@ -49,6 +49,7 @@ namespace BTCPayServer.Data
|
||||||
public IEnumerable<FormData> Forms { get; set; }
|
public IEnumerable<FormData> Forms { get; set; }
|
||||||
public IEnumerable<StoreRole> StoreRoles { get; set; }
|
public IEnumerable<StoreRole> StoreRoles { get; set; }
|
||||||
public bool Archived { get; set; }
|
public bool Archived { get; set; }
|
||||||
|
public IEnumerable<PendingTransaction> PendingTransactions { get; set; }
|
||||||
|
|
||||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PendingTransactions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
TransactionId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
CryptoCode = table.Column<string>(type: "text", nullable: false),
|
||||||
|
StoreId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Expiry = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
State = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OutpointsUsed = table.Column<string[]>(type: "text[]", nullable: true),
|
||||||
|
Blob2 = table.Column<string>(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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PendingTransactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -637,6 +637,36 @@ namespace BTCPayServer.Migrations
|
||||||
b.ToTable("PayoutProcessors");
|
b.ToTable("PayoutProcessors");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.PendingTransaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("CryptoCode")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Blob2")
|
||||||
|
.HasColumnType("JSONB");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("Expiry")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string[]>("OutpointsUsed")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("StoreId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("CryptoCode", "TransactionId");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
|
b.ToTable("PendingTransactions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
|
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
@ -1324,6 +1354,16 @@ namespace BTCPayServer.Migrations
|
||||||
b.Navigation("Store");
|
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 =>
|
modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||||
|
@ -1582,6 +1622,8 @@ namespace BTCPayServer.Migrations
|
||||||
|
|
||||||
b.Navigation("Payouts");
|
b.Navigation("Payouts");
|
||||||
|
|
||||||
|
b.Navigation("PendingTransactions");
|
||||||
|
|
||||||
b.Navigation("PullPayments");
|
b.Navigation("PullPayments");
|
||||||
|
|
||||||
b.Navigation("Settings");
|
b.Navigation("Settings");
|
||||||
|
|
|
@ -440,7 +440,10 @@ public partial class UIStoresController
|
||||||
CanUsePayJoin = canUseHotWallet && network.SupportPayJoin && derivation.IsHotWallet,
|
CanUsePayJoin = canUseHotWallet && network.SupportPayJoin && derivation.IsHotWallet,
|
||||||
CanUseHotWallet = canUseHotWallet,
|
CanUseHotWallet = canUseHotWallet,
|
||||||
CanUseRPCImport = rpcImport,
|
CanUseRPCImport = rpcImport,
|
||||||
StoreName = store.StoreName
|
StoreName = store.StoreName,
|
||||||
|
CanSetupMultiSig = derivation.AccountKeySettings.Length > 1,
|
||||||
|
IsMultiSigOnServer = derivation.IsMultiSigOnServer,
|
||||||
|
DefaultIncludeNonWitnessUtxo = derivation.DefaultIncludeNonWitnessUtxo
|
||||||
};
|
};
|
||||||
|
|
||||||
ViewData["ReplaceDescription"] = WalletReplaceWarning(derivation.IsHotWallet);
|
ViewData["ReplaceDescription"] = WalletReplaceWarning(derivation.IsHotWallet);
|
||||||
|
@ -477,10 +480,14 @@ public partial class UIStoresController
|
||||||
if (payjoinChanged && network.SupportPayJoin) storeBlob.PayJoinEnabled = vm.PayJoinEnabled;
|
if (payjoinChanged && network.SupportPayJoin) storeBlob.PayJoinEnabled = vm.PayJoinEnabled;
|
||||||
if (needUpdate) store.SetStoreBlob(storeBlob);
|
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;
|
needUpdate = true;
|
||||||
derivation.Label = vm.Label;
|
derivation.Label = vm.Label;
|
||||||
|
derivation.IsMultiSigOnServer = vm.IsMultiSigOnServer;
|
||||||
|
derivation.DefaultIncludeNonWitnessUtxo = vm.DefaultIncludeNonWitnessUtxo;
|
||||||
}
|
}
|
||||||
|
|
||||||
var signingKey = string.IsNullOrEmpty(vm.SelectedSigningKey)
|
var signingKey = string.IsNullOrEmpty(vm.SelectedSigningKey)
|
||||||
|
@ -494,16 +501,14 @@ public partial class UIStoresController
|
||||||
|
|
||||||
for (int i = 0; i < derivation.AccountKeySettings.Length; i++)
|
for (int i = 0; i < derivation.AccountKeySettings.Length; i++)
|
||||||
{
|
{
|
||||||
KeyPath accountKeyPath;
|
|
||||||
HDFingerprint? rootFingerprint;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
accountKeyPath = string.IsNullOrWhiteSpace(vm.AccountKeys[i].AccountKeyPath)
|
var strKeyPath = vm.AccountKeys[i].AccountKeyPath;
|
||||||
? null
|
var accountKeyPath = string.IsNullOrWhiteSpace(strKeyPath) ? null : new KeyPath(strKeyPath);
|
||||||
: new KeyPath(vm.AccountKeys[i].AccountKeyPath);
|
|
||||||
|
|
||||||
if (accountKeyPath != null && derivation.AccountKeySettings[i].AccountKeyPath != accountKeyPath)
|
bool pathsDiffer = accountKeyPath != derivation.AccountKeySettings[i].AccountKeyPath;
|
||||||
|
|
||||||
|
if (pathsDiffer)
|
||||||
{
|
{
|
||||||
needUpdate = true;
|
needUpdate = true;
|
||||||
derivation.AccountKeySettings[i].AccountKeyPath = accountKeyPath;
|
derivation.AccountKeySettings[i].AccountKeyPath = accountKeyPath;
|
||||||
|
@ -516,7 +521,7 @@ public partial class UIStoresController
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint)
|
HDFingerprint? rootFingerprint = string.IsNullOrWhiteSpace(vm.AccountKeys[i].MasterFingerprint)
|
||||||
? null
|
? null
|
||||||
: new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint));
|
: new HDFingerprint(Encoders.Hex.DecodeData(vm.AccountKeys[i].MasterFingerprint));
|
||||||
|
|
||||||
|
|
|
@ -141,12 +141,15 @@ namespace BTCPayServer.Controllers
|
||||||
var psbt = PSBT.Parse(o["psbt"].Value<string>(), network.NBitcoinNetwork);
|
var psbt = PSBT.Parse(o["psbt"].Value<string>(), network.NBitcoinNetwork);
|
||||||
var derivationSettings = GetDerivationSchemeSettings(walletId);
|
var derivationSettings = GetDerivationSchemeSettings(walletId);
|
||||||
derivationSettings.RebaseKeyPaths(psbt);
|
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);
|
await websocketHelper.Send("{ \"error\": \"wrong-wallet\"}", cancellationToken);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// otherwise, let the device check if it can sign anything
|
||||||
var signableInputs = psbt.Inputs
|
var signableInputs = psbt.Inputs
|
||||||
.SelectMany(i => i.HDKeyPaths)
|
.SelectMany(i => i.HDKeyPaths)
|
||||||
.Where(i => i.Value.MasterFingerprint == fingerprint)
|
.Where(i => i.Value.MasterFingerprint == fingerprint)
|
||||||
|
@ -159,12 +162,24 @@ namespace BTCPayServer.Controllers
|
||||||
await websocketHelper.Send("{ \"error\": \"wrong-keypath\"}", cancellationToken);
|
await websocketHelper.Send("{ \"error\": \"wrong-keypath\"}", cancellationToken);
|
||||||
continue;
|
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
|
try
|
||||||
{
|
{
|
||||||
psbt = await device.SignPSBTAsync(psbt, cancellationToken);
|
psbt = await device.SignPSBTAsync(psbt, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Hwi.HwiException)
|
catch (HwiException)
|
||||||
{
|
{
|
||||||
await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken);
|
await websocketHelper.Send("{ \"error\": \"user-reject\"}", cancellationToken);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
@ -28,7 +28,7 @@ namespace BTCPayServer.Controllers
|
||||||
public async Task<CreatePSBTResponse> CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken)
|
public async Task<CreatePSBTResponse> CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var nbx = ExplorerClientProvider.GetExplorerClient(network);
|
var nbx = ExplorerClientProvider.GetExplorerClient(network);
|
||||||
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
|
CreatePSBTRequest psbtRequest = new();
|
||||||
if (sendModel.InputSelection)
|
if (sendModel.InputSelection)
|
||||||
{
|
{
|
||||||
psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList() ?? new List<OutPoint>();
|
psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList() ?? new List<OutPoint>();
|
||||||
|
@ -250,6 +250,9 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
case "createpending":
|
||||||
|
var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
|
||||||
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||||
case "sign":
|
case "sign":
|
||||||
return await WalletSign(walletId, vm);
|
return await WalletSign(walletId, vm);
|
||||||
case "decode":
|
case "decode":
|
||||||
|
@ -288,7 +291,7 @@ namespace BTCPayServer.Controllers
|
||||||
});
|
});
|
||||||
|
|
||||||
case "broadcast":
|
case "broadcast":
|
||||||
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
|
return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
|
||||||
{
|
{
|
||||||
SigningContext = new SigningContextModel(psbt),
|
SigningContext = new SigningContextModel(psbt),
|
||||||
ReturnUrl = vm.ReturnUrl,
|
ReturnUrl = vm.ReturnUrl,
|
||||||
|
@ -604,6 +607,12 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
return LocalRedirect(vm.ReturnUrl);
|
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() });
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||||
}
|
}
|
||||||
case "analyze-psbt":
|
case "analyze-psbt":
|
||||||
|
|
|
@ -33,7 +33,9 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Internal;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
@ -77,9 +79,12 @@ namespace BTCPayServer.Controllers
|
||||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||||
private readonly WalletHistogramService _walletHistogramService;
|
private readonly WalletHistogramService _walletHistogramService;
|
||||||
|
|
||||||
|
private readonly PendingTransactionService _pendingTransactionService;
|
||||||
readonly CurrencyNameTable _currencyTable;
|
readonly CurrencyNameTable _currencyTable;
|
||||||
|
|
||||||
public UIWalletsController(StoreRepository repo,
|
public UIWalletsController(
|
||||||
|
PendingTransactionService pendingTransactionService,
|
||||||
|
StoreRepository repo,
|
||||||
WalletRepository walletRepository,
|
WalletRepository walletRepository,
|
||||||
CurrencyNameTable currencyTable,
|
CurrencyNameTable currencyTable,
|
||||||
BTCPayNetworkProvider networkProvider,
|
BTCPayNetworkProvider networkProvider,
|
||||||
|
@ -104,6 +109,7 @@ namespace BTCPayServer.Controllers
|
||||||
IStringLocalizer stringLocalizer,
|
IStringLocalizer stringLocalizer,
|
||||||
TransactionLinkProviders transactionLinkProviders)
|
TransactionLinkProviders transactionLinkProviders)
|
||||||
{
|
{
|
||||||
|
_pendingTransactionService = pendingTransactionService;
|
||||||
_currencyTable = currencyTable;
|
_currencyTable = currencyTable;
|
||||||
_labelService = labelService;
|
_labelService = labelService;
|
||||||
_defaultRules = defaultRules;
|
_defaultRules = defaultRules;
|
||||||
|
@ -130,6 +136,67 @@ namespace BTCPayServer.Controllers
|
||||||
StringLocalizer = stringLocalizer;
|
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<IActionResult> 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<IActionResult> ViewPendingTransaction(
|
||||||
|
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
||||||
|
string transactionId)
|
||||||
|
{
|
||||||
|
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(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]
|
[HttpPost]
|
||||||
[Route("{walletId}")]
|
[Route("{walletId}")]
|
||||||
public async Task<IActionResult> ModifyTransaction(
|
public async Task<IActionResult> ModifyTransaction(
|
||||||
|
@ -243,6 +310,9 @@ namespace BTCPayServer.Controllers
|
||||||
// We can't filter at the database level if we need to apply label filter
|
// We can't filter at the database level if we need to apply label filter
|
||||||
var preFiltering = string.IsNullOrEmpty(labelFilter);
|
var preFiltering = string.IsNullOrEmpty(labelFilter);
|
||||||
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
||||||
|
|
||||||
|
model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
|
||||||
|
|
||||||
model.Labels.AddRange(
|
model.Labels.AddRange(
|
||||||
(await WalletRepository.GetWalletLabels(walletId))
|
(await WalletRepository.GetWalletLabels(walletId))
|
||||||
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
|
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
|
||||||
|
@ -452,7 +522,9 @@ namespace BTCPayServer.Controllers
|
||||||
var model = new WalletSendModel
|
var model = new WalletSendModel
|
||||||
{
|
{
|
||||||
CryptoCode = walletId.CryptoCode,
|
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)
|
if (bip21?.Any() is true)
|
||||||
{
|
{
|
||||||
|
@ -849,6 +921,9 @@ namespace BTCPayServer.Controllers
|
||||||
};
|
};
|
||||||
switch (command)
|
switch (command)
|
||||||
{
|
{
|
||||||
|
case "createpending":
|
||||||
|
var pt = await _pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt);
|
||||||
|
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||||
case "sign":
|
case "sign":
|
||||||
return await WalletSign(walletId, new WalletPSBTViewModel
|
return await WalletSign(walletId, new WalletPSBTViewModel
|
||||||
{
|
{
|
||||||
|
@ -949,10 +1024,10 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{walletId}/vault")]
|
[HttpPost("{walletId}/vault")]
|
||||||
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
public async Task<IActionResult> WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
|
||||||
WalletSendVaultModel model)
|
WalletSendVaultModel model)
|
||||||
{
|
{
|
||||||
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
|
return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
|
||||||
{
|
{
|
||||||
SigningContext = model.SigningContext,
|
SigningContext = model.SigningContext,
|
||||||
ReturnUrl = model.ReturnUrl,
|
ReturnUrl = model.ReturnUrl,
|
||||||
|
@ -960,8 +1035,17 @@ namespace BTCPayServer.Controllers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm)
|
private async Task<IActionResult> RedirectToWalletPSBTReady(WalletId walletId, WalletPSBTReadyViewModel vm)
|
||||||
{
|
{
|
||||||
|
if (vm.SigningContext.PendingTransactionId is not null)
|
||||||
|
{
|
||||||
|
var psbt = PSBT.Parse(vm.SigningContext.PSBT, NetworkProvider.GetNetwork<BTCPayNetwork>(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
|
var redirectVm = new PostRedirectViewModel
|
||||||
{
|
{
|
||||||
AspController = "UIWallets",
|
AspController = "UIWallets",
|
||||||
|
@ -1003,6 +1087,7 @@ namespace BTCPayServer.Controllers
|
||||||
redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
|
redirectVm.FormParameters.Add("SigningContext.EnforceLowR",
|
||||||
signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
|
signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture));
|
||||||
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
|
redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress);
|
||||||
|
redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
|
private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm)
|
||||||
|
@ -1119,7 +1204,7 @@ namespace BTCPayServer.Controllers
|
||||||
ModelState.Remove(nameof(viewModel.SigningContext.PSBT));
|
ModelState.Remove(nameof(viewModel.SigningContext.PSBT));
|
||||||
viewModel.SigningContext ??= new();
|
viewModel.SigningContext ??= new();
|
||||||
viewModel.SigningContext.PSBT = psbt?.ToBase64();
|
viewModel.SigningContext.PSBT = psbt?.ToBase64();
|
||||||
return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel
|
return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel
|
||||||
{
|
{
|
||||||
SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
|
SigningKey = signingKey.GetWif(network.NBitcoinNetwork).ToString(),
|
||||||
SigningKeyPath = rootedKeyPath?.ToString(),
|
SigningKeyPath = rootedKeyPath?.ToString(),
|
||||||
|
|
|
@ -33,7 +33,6 @@ namespace BTCPayServer
|
||||||
|
|
||||||
public DerivationSchemeSettings()
|
public DerivationSchemeSettings()
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public DerivationSchemeSettings(DerivationStrategyBase derivationStrategy, BTCPayNetwork network)
|
public DerivationSchemeSettings(DerivationStrategyBase derivationStrategy, BTCPayNetwork network)
|
||||||
|
@ -48,16 +47,16 @@ namespace BTCPayServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
BitcoinExtPubKey _SigningKey;
|
private BitcoinExtPubKey _signingKey;
|
||||||
public BitcoinExtPubKey SigningKey
|
public BitcoinExtPubKey SigningKey
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
return _SigningKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault();
|
return _signingKey ?? AccountKeySettings?.Select(k => k.AccountKey).FirstOrDefault();
|
||||||
}
|
}
|
||||||
set
|
set
|
||||||
{
|
{
|
||||||
_SigningKey = value;
|
_signingKey = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public string Source { get; set; }
|
public string Source { get; set; }
|
||||||
|
@ -84,11 +83,7 @@ namespace BTCPayServer
|
||||||
return AccountKeySettings.Single(a => a.AccountKey == SigningKey);
|
return AccountKeySettings.Single(a => a.AccountKey == SigningKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public AccountKeySettings[] AccountKeySettings
|
public AccountKeySettings[] AccountKeySettings { get; set; }
|
||||||
{
|
|
||||||
get;
|
|
||||||
set;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IEnumerable<NBXplorer.Models.PSBTRebaseKeyRules> GetPSBTRebaseKeyRules()
|
public IEnumerable<NBXplorer.Models.PSBTRebaseKeyRules> GetPSBTRebaseKeyRules()
|
||||||
{
|
{
|
||||||
|
@ -107,6 +102,14 @@ namespace BTCPayServer
|
||||||
|
|
||||||
public string Label { get; set; }
|
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()
|
public override string ToString()
|
||||||
{
|
{
|
||||||
return AccountDerivation.ToString();
|
return AccountDerivation.ToString();
|
||||||
|
@ -114,7 +117,7 @@ namespace BTCPayServer
|
||||||
public string ToPrettyString()
|
public string ToPrettyString()
|
||||||
{
|
{
|
||||||
return !string.IsNullOrEmpty(Label) ? Label :
|
return !string.IsNullOrEmpty(Label) ? Label :
|
||||||
!String.IsNullOrEmpty(AccountOriginal) ? AccountOriginal :
|
!string.IsNullOrEmpty(AccountOriginal) ? AccountOriginal :
|
||||||
ToString();
|
ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
222
BTCPayServer/HostedServices/PendingTransactionService.cs
Normal file
222
BTCPayServer/HostedServices/PendingTransactionService.cs
Normal file
|
@ -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<PendingTransactionService> logger,
|
||||||
|
ExplorerClientProvider explorerClientProvider)
|
||||||
|
: EventHostedServiceBase(eventAggregator, logger), IPeriodicTask
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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<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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -352,6 +352,8 @@ namespace BTCPayServer.Hosting
|
||||||
services.TryAddSingleton<StoreRepository>();
|
services.TryAddSingleton<StoreRepository>();
|
||||||
services.TryAddSingleton<PaymentRequestRepository>();
|
services.TryAddSingleton<PaymentRequestRepository>();
|
||||||
services.TryAddSingleton<BTCPayWalletProvider>();
|
services.TryAddSingleton<BTCPayWalletProvider>();
|
||||||
|
services.AddSingleton<PendingTransactionService>();
|
||||||
|
services.AddScheduledTask<PendingTransactionService>(TimeSpan.FromMinutes(10));
|
||||||
services.TryAddSingleton<WalletReceiveService>();
|
services.TryAddSingleton<WalletReceiveService>();
|
||||||
services.AddSingleton<IHostedService>(provider => provider.GetService<WalletReceiveService>());
|
services.AddSingleton<IHostedService>(provider => provider.GetService<WalletReceiveService>());
|
||||||
|
|
||||||
|
|
|
@ -25,10 +25,21 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||||
public string SelectedSigningKey { get; set; }
|
public string SelectedSigningKey { get; set; }
|
||||||
public bool IsMultiSig => AccountKeys.Count > 1;
|
public bool IsMultiSig => AccountKeys.Count > 1;
|
||||||
|
|
||||||
public List<WalletSettingsAccountKeyViewModel> AccountKeys { get; set; } = new List<WalletSettingsAccountKeyViewModel>();
|
public List<WalletSettingsAccountKeyViewModel> AccountKeys { get; set; } = new();
|
||||||
public bool NBXSeedAvailable { get; set; }
|
public bool NBXSeedAvailable { get; set; }
|
||||||
public string StoreName { get; set; }
|
public string StoreName { get; set; }
|
||||||
public string UriScheme { 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
|
public class WalletSettingsAccountKeyViewModel
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
namespace BTCPayServer.Models.WalletViewModels
|
namespace BTCPayServer.Models.WalletViewModels
|
||||||
{
|
{
|
||||||
|
@ -20,5 +21,6 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public List<TransactionViewModel> Transactions { get; set; } = new();
|
public List<TransactionViewModel> Transactions { get; set; } = new();
|
||||||
public override int CurrentPageCount => Transactions.Count;
|
public override int CurrentPageCount => Transactions.Count;
|
||||||
public string CryptoCode { get; set; }
|
public string CryptoCode { get; set; }
|
||||||
|
public PendingTransaction[] PendingTransactions { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,5 +17,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
public string PayJoinBIP21 { get; set; }
|
public string PayJoinBIP21 { get; set; }
|
||||||
public bool? EnforceLowR { get; set; }
|
public bool? EnforceLowR { get; set; }
|
||||||
public string ChangeAddress { get; set; }
|
public string ChangeAddress { get; set; }
|
||||||
|
|
||||||
|
public string PendingTransactionId { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||||
|
|
||||||
public string BackUrl { get; set; }
|
public string BackUrl { get; set; }
|
||||||
public string ReturnUrl { get; set; }
|
public string ReturnUrl { get; set; }
|
||||||
|
public bool IsMultiSigOnServer { get; set; }
|
||||||
|
|
||||||
public class InputSelectionOption
|
public class InputSelectionOption
|
||||||
{
|
{
|
||||||
|
|
|
@ -16,17 +16,19 @@
|
||||||
<script src="~/vendor/vue-qrcode-reader/VueQrcodeReader.umd.min.js" asp-append-version="true"></script>
|
<script src="~/vendor/vue-qrcode-reader/VueQrcodeReader.umd.min.js" asp-append-version="true"></script>
|
||||||
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
|
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
|
||||||
}
|
}
|
||||||
<h2 class="mb-2 mb-lg-3" text-translate="true">@ViewData["Title"]</h2>
|
|
||||||
<partial name="_StatusMessage" />
|
<div class="sticky-header">
|
||||||
<div class="row">
|
<h2 text-translate="true">@ViewData["Title"]</h2>
|
||||||
<div class="col-xl-8 col-xxl-constrain">
|
<div>
|
||||||
<div class="mb-5">
|
|
||||||
<div class="mb-3 d-flex align-items-center">
|
|
||||||
<span title="@Model.Source" data-bs-toggle="tooltip" class="me-3">@(Model.IsHotWallet ? StringLocalizer["Hot wallet"] : StringLocalizer["Watch-only wallet"])</span>
|
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
|
<button class="btn btn-secondary dropdown-toggle"
|
||||||
|
type="button" id="ActionsDropdownToggle"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-haspopup="true" aria-expanded="false" text-translate="true">
|
||||||
Actions
|
Actions
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Modal For Actions DropDownToggle, need to be outside of form -->
|
||||||
<div class="dropdown-menu" aria-labelledby="ActionsDropdownToggle">
|
<div class="dropdown-menu" aria-labelledby="ActionsDropdownToggle">
|
||||||
<a class="dropdown-item" asp-controller="UIWallets" asp-action="WalletRescan" asp-route-walletId="@Model.WalletId" id="Rescan" text-translate="true">Rescan wallet for missing transactions</a>
|
<a class="dropdown-item" asp-controller="UIWallets" asp-action="WalletRescan" asp-route-walletId="@Model.WalletId" id="Rescan" text-translate="true">Rescan wallet for missing transactions</a>
|
||||||
<form method="post" asp-controller="UIWallets" asp-action="WalletActions" asp-route-walletId="@Model.WalletId">
|
<form method="post" asp-controller="UIWallets" asp-action="WalletActions" asp-route-walletId="@Model.WalletId">
|
||||||
|
@ -71,9 +73,16 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" asp-action="UpdateWalletSettings" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode">
|
<a class="btn btn-secondary" asp-controller="UIWallets" asp-action="WalletLabels"
|
||||||
|
asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" id="SaveWalletSettings" form="walletSettingsForm" text-translate="true">Save Wallet Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<form id="walletSettingsForm" method="post" asp-action="UpdateWalletSettings" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode">
|
||||||
<div class="form-group my-4">
|
<div class="form-group my-4">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<input asp-for="Enabled" type="checkbox" class="btcpay-toggle me-3"/>
|
<input asp-for="Enabled" type="checkbox" class="btcpay-toggle me-3"/>
|
||||||
|
@ -94,6 +103,23 @@
|
||||||
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
|
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (Model.CanSetupMultiSig)
|
||||||
|
{
|
||||||
|
<div class="form-group my-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input asp-for="IsMultiSigOnServer" type="checkbox" class="btcpay-toggle me-3"/>
|
||||||
|
<label asp-for="IsMultiSigOnServer" class="form-check-label"></label>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="IsMultiSigOnServer" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group my-4">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input asp-for="DefaultIncludeNonWitnessUtxo" type="checkbox" class="btcpay-toggle me-3"/>
|
||||||
|
<label asp-for="DefaultIncludeNonWitnessUtxo" class="form-check-label"></label>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="DefaultIncludeNonWitnessUtxo" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Label" class="form-label"></label>
|
<label asp-for="Label" class="form-label"></label>
|
||||||
<input asp-for="Label" class="form-control" style="max-width:24em;" />
|
<input asp-for="Label" class="form-control" style="max-width:24em;" />
|
||||||
|
@ -131,7 +157,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group" data-clipboard="@Model.AccountKeys[i].AccountKey">
|
<div class="input-group" data-clipboard="@Model.AccountKeys[i].AccountKey">
|
||||||
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly/>
|
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly />
|
||||||
<button type="button" class="btn btn-outline-secondary px-3">
|
<button type="button" class="btn btn-outline-secondary px-3">
|
||||||
<vc:icon symbol="actions-copy" />
|
<vc:icon symbol="actions-copy" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -147,24 +173,15 @@
|
||||||
<input asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-control" style="max-width:16ch;" />
|
<input asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-control" style="max-width:16ch;" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (Model.IsMultiSig)
|
@if (Model is { IsMultiSig: true, IsMultiSigOnServer: false })
|
||||||
{
|
{
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input asp-for="SelectedSigningKey" class="form-check-input" type="radio" value="@Model.AccountKeys[i].AccountKey"/>
|
<input asp-for="SelectedSigningKey" class="form-check-input" type="radio" value="@Model.AccountKeys[i].AccountKey" />
|
||||||
<label asp-for="SelectedSigningKey" class="form-check-label"></label>
|
<label asp-for="SelectedSigningKey" class="form-check-label"></label>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<button type="submit" class="btn btn-primary mt-2" id="SaveWalletSettings" text-translate="true">Save Wallet Settings</button>
|
</form>
|
||||||
</form>
|
|
||||||
|
|
||||||
<h3 class="mt-5" text-translate="true">Labels</h3>
|
|
||||||
<p>
|
|
||||||
<a asp-controller="UIWallets" asp-action="WalletLabels" asp-route-walletId="@Model.WalletId" text-translate="true">Manage labels</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["{0} wallet", Model.CryptoCode], StringLocalizer["Change"], "Update"))" />
|
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["{0} wallet", Model.CryptoCode], StringLocalizer["Change"], "Update"))" />
|
||||||
<partial name="ShowQR"/>
|
<partial name="ShowQR"/>
|
||||||
|
|
|
@ -7,4 +7,5 @@
|
||||||
<input type="hidden" asp-for="PayJoinBIP21" value="@Model.PayJoinBIP21"/>
|
<input type="hidden" asp-for="PayJoinBIP21" value="@Model.PayJoinBIP21"/>
|
||||||
<input type="hidden" asp-for="EnforceLowR" value="@Model.EnforceLowR" />
|
<input type="hidden" asp-for="EnforceLowR" value="@Model.EnforceLowR" />
|
||||||
<input type="hidden" asp-for="ChangeAddress" value="@Model.ChangeAddress" />
|
<input type="hidden" asp-for="ChangeAddress" value="@Model.ChangeAddress" />
|
||||||
|
<input type="hidden" asp-for="PendingTransactionId" value="@Model.PendingTransactionId" />
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
@model WalletPSBTViewModel
|
@model WalletPSBTViewModel
|
||||||
@{
|
@{
|
||||||
var walletId = Context.GetRouteValue("walletId").ToString();
|
var walletId = Context.GetRouteValue("walletId").ToString();
|
||||||
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new { walletId });
|
var cancelUrl = Model.ReturnUrl ?? Url.Action(nameof(UIWalletsController.WalletTransactions), new {walletId});
|
||||||
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
|
var backUrl = Model.BackUrl != null ? $"{Model.BackUrl}?returnUrl={Model.ReturnUrl}" : null;
|
||||||
var isReady = !Model.HasErrors;
|
var isReady = !Model.HasErrors;
|
||||||
var isSignable = !isReady;
|
var isSignable = !isReady;
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
@section PageHeadContent {
|
@section PageHeadContent {
|
||||||
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
|
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
|
||||||
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
|
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
|
||||||
<style>
|
<style>
|
||||||
.nav-pills .nav-link.active {
|
.nav-pills .nav-link.active {
|
||||||
color: var(--btcpay-secondary-text-active);
|
color: var(--btcpay-secondary-text-active);
|
||||||
|
@ -98,14 +98,26 @@
|
||||||
@if (isSignable)
|
@if (isSignable)
|
||||||
{
|
{
|
||||||
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="my-5">
|
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="my-5">
|
||||||
<input type="hidden" asp-for="CryptoCode"/>
|
<input type="hidden" asp-for="CryptoCode" />
|
||||||
<input type="hidden" asp-for="NBXSeedAvailable"/>
|
<input type="hidden" asp-for="NBXSeedAvailable" />
|
||||||
<input type="hidden" asp-for="PSBT"/>
|
<input type="hidden" asp-for="PSBT" />
|
||||||
<input type="hidden" asp-for="FileName"/>
|
<input type="hidden" asp-for="FileName" />
|
||||||
<input type="hidden" asp-for="ReturnUrl" />
|
<input type="hidden" asp-for="ReturnUrl" />
|
||||||
<input type="hidden" asp-for="BackUrl" />
|
<input type="hidden" asp-for="BackUrl" />
|
||||||
<div class="d-flex flex-column flex-sm-row flex-wrap justify-content-center align-items-sm-center">
|
|
||||||
|
<partial name="SigningContext" for="SigningContext" />
|
||||||
|
<div class="d-flex flex-column flex-sm-row flex-wrap justify-content-center align-items-sm-center gap-2">
|
||||||
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
|
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
|
||||||
|
@if (Model.SigningContext.PendingTransactionId is null && !Model.NBXSeedAvailable)
|
||||||
|
{
|
||||||
|
<button type="submit" id="CreatePendingTransaction" name="command" value="createpending"
|
||||||
|
class="btn btn-primary">Create pending transaction</button>
|
||||||
|
}
|
||||||
|
else if (Model.SigningContext.PendingTransactionId is not null)
|
||||||
|
{
|
||||||
|
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
|
||||||
|
asp-route-transactionId="@Model.SigningContext.PendingTransactionId" class="btn btn-danger">Cancel</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
@ -141,14 +153,14 @@ else
|
||||||
<h2 class="accordion-header" id="PSBTOptionsExportHeader">
|
<h2 class="accordion-header" id="PSBTOptionsExportHeader">
|
||||||
<button type="button" class="accordion-button @(needsExport ? "" : "collapsed")" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsExportContent" aria-controls="PSBTOptionsExportContent" aria-expanded="@(needsExport ? "true" : "false")">
|
<button type="button" class="accordion-button @(needsExport ? "" : "collapsed")" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsExportContent" aria-controls="PSBTOptionsExportContent" aria-expanded="@(needsExport ? "true" : "false")">
|
||||||
<span class="h5">Export PSBT @(isReady ? "" : "for signing")</span>
|
<span class="h5">Export PSBT @(isReady ? "" : "for signing")</span>
|
||||||
<vc:icon symbol="caret-down"/>
|
<vc:icon symbol="caret-down" />
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="PSBTOptionsExportContent" class="accordion-collapse collapse @(needsExport ? "show" : "")" aria-labelledby="PSBTOptionsExportHeader" data-bs-parent="#PSBTOptions">
|
<div id="PSBTOptionsExportContent" class="accordion-collapse collapse @(needsExport ? "show" : "")" aria-labelledby="PSBTOptionsExportHeader" data-bs-parent="#PSBTOptions">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
|
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
|
||||||
<input type="hidden" asp-for="CryptoCode"/>
|
<input type="hidden" asp-for="CryptoCode" />
|
||||||
<input type="hidden" asp-for="PSBT"/>
|
<input type="hidden" asp-for="PSBT" />
|
||||||
<input type="hidden" asp-for="ReturnUrl" />
|
<input type="hidden" asp-for="ReturnUrl" />
|
||||||
<input type="hidden" asp-for="BackUrl" />
|
<input type="hidden" asp-for="BackUrl" />
|
||||||
<div class="d-flex flex-column flex-sm-row flex-wrap align-items-sm-center">
|
<div class="d-flex flex-column flex-sm-row flex-wrap align-items-sm-center">
|
||||||
|
@ -192,7 +204,7 @@ else
|
||||||
<h2 class="accordion-header" id="PSBTOptionsImportHeader">
|
<h2 class="accordion-header" id="PSBTOptionsImportHeader">
|
||||||
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsImportContent" aria-controls="PSBTOptionsImportContent" aria-expanded="false">
|
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsImportContent" aria-controls="PSBTOptionsImportContent" aria-expanded="false">
|
||||||
<span class="h5" text-translate="true">Provide updated PSBT</span>
|
<span class="h5" text-translate="true">Provide updated PSBT</span>
|
||||||
<vc:icon symbol="caret-down"/>
|
<vc:icon symbol="caret-down" />
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="PSBTOptionsImportContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsImportHeader" data-bs-parent="#PSBTOptions">
|
<div id="PSBTOptionsImportContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsImportHeader" data-bs-parent="#PSBTOptions">
|
||||||
|
@ -221,13 +233,13 @@ else
|
||||||
<h2 class="accordion-header" id="PSBTOptionsAdvancedHeader">
|
<h2 class="accordion-header" id="PSBTOptionsAdvancedHeader">
|
||||||
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsAdvancedContent" aria-controls="PSBTOptionsAdvancedContent" aria-expanded="false">
|
<button type="button" class="accordion-button collapsed" data-bs-toggle="collapse" data-bs-target="#PSBTOptionsAdvancedContent" aria-controls="PSBTOptionsAdvancedContent" aria-expanded="false">
|
||||||
<span class="h5">Add metadata to PSBT (advanced)</span>
|
<span class="h5">Add metadata to PSBT (advanced)</span>
|
||||||
<vc:icon symbol="caret-down"/>
|
<vc:icon symbol="caret-down" />
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="PSBTOptionsAdvancedContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsAdvancedHeader" data-bs-parent="#PSBTOptions">
|
<div id="PSBTOptionsAdvancedContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsAdvancedHeader" data-bs-parent="#PSBTOptions">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
|
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
|
||||||
<input type="hidden" asp-for="PSBT"/>
|
<input type="hidden" asp-for="PSBT" />
|
||||||
<input type="hidden" asp-for="ReturnUrl" />
|
<input type="hidden" asp-for="ReturnUrl" />
|
||||||
<input type="hidden" asp-for="BackUrl" />
|
<input type="hidden" asp-for="BackUrl" />
|
||||||
<p class="mb-2">For exporting the signed PSBT and transaction information to a wallet, update the PSBT.</p>
|
<p class="mb-2">For exporting the signed PSBT and transaction information to a wallet, update the PSBT.</p>
|
||||||
|
@ -240,5 +252,5 @@ else
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<partial name="ShowQR"/>
|
<partial name="ShowQR" />
|
||||||
<partial name="CameraScanner"/>
|
<partial name="CameraScanner" />
|
||||||
|
|
|
@ -226,8 +226,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid d-sm-flex flex-wrap gap-3 buttons">
|
<div class="d-grid d-sm-flex flex-wrap gap-3 buttons">
|
||||||
|
@Html.HiddenFor(a=>a.IsMultiSigOnServer)
|
||||||
|
@if (Model.IsMultiSigOnServer)
|
||||||
|
{
|
||||||
|
<button type="submit" id="CreatePendingTransaction" name="command" value="createpending" class="btn btn-primary">Create pending transaction</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
|
<button type="submit" id="SignTransaction" name="command" value="sign" class="btn btn-primary" text-translate="true">Sign transaction</button>
|
||||||
<button type="submit" id="ScheduleTransaction" name="command" value="schedule" class="btn btn-secondary" text-translate="true">Schedule transaction</button>
|
<button type="submit" id="ScheduleTransaction" name="command" value="schedule" class="btn btn-secondary" text-translate="true">Schedule transaction</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<vc:ui-extension-point location="onchain-wallet-send" model="@Model"/>
|
<vc:ui-extension-point location="onchain-wallet-send" model="@Model"/>
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
|
|
||||||
const count = @Safe.Json(Model.Count);
|
const count = @Safe.Json(Model.Count);
|
||||||
const skipInitial = @Safe.Json(Model.Skip);
|
const skipInitial = @Safe.Json(Model.Skip);
|
||||||
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new { walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true }));
|
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
|
||||||
// The next time we load transactions, skip will become 0
|
// The next time we load transactions, skip will become 0
|
||||||
let skip = @Safe.Json(Model.Skip) - count;
|
let skip = @Safe.Json(Model.Skip) - count;
|
||||||
|
|
||||||
|
@ -173,6 +173,50 @@
|
||||||
</div>
|
</div>
|
||||||
<div style="clear:both"></div>
|
<div style="clear:both"></div>
|
||||||
|
|
||||||
|
@if (Model.PendingTransactions?.Any() == true)
|
||||||
|
{
|
||||||
|
<div class="table-responsive-md">
|
||||||
|
<table class="table table-hover ">
|
||||||
|
<thead>
|
||||||
|
<th>
|
||||||
|
Id
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
State
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Signature count
|
||||||
|
</th>
|
||||||
|
|
||||||
|
<th>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
@foreach (var pendingTransaction in Model.PendingTransactions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@pendingTransaction.TransactionId
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@pendingTransaction.State
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@pendingTransaction.GetBlob().CollectedSignatures.Count
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">
|
||||||
|
@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")
|
||||||
|
</a>-
|
||||||
|
<a asp-action="CancelPendingTransaction"
|
||||||
|
asp-route-walletId="@walletId" asp-route-transactionId="@pendingTransaction.TransactionId">Abort</a>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
<div id="WalletTransactions" class="table-responsive-md">
|
<div id="WalletTransactions" class="table-responsive-md">
|
||||||
<table class="table table-hover mass-action">
|
<table class="table table-hover mass-action">
|
||||||
<thead class="mass-action-head">
|
<thead class="mass-action-head">
|
||||||
|
@ -221,7 +265,7 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<noscript>
|
<noscript>
|
||||||
<vc:pager view-model="Model"/>
|
<vc:pager view-model="Model" />
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<div class="text-center only-for-js d-none" id="LoadingIndicator">
|
<div class="text-center only-for-js d-none" id="LoadingIndicator">
|
||||||
|
|
|
@ -50,6 +50,7 @@ var vaultui = (function () {
|
||||||
signingTransaction: new VaultFeedback("?", "Please review and confirm the transaction on your device...", "ask-signing"),
|
signingTransaction: new VaultFeedback("?", "Please review and confirm the transaction on your device...", "ask-signing"),
|
||||||
reviewAddress: new VaultFeedback("?", "Sending... Please review the address on your device...", "ask-signing"),
|
reviewAddress: new VaultFeedback("?", "Sending... Please review the address on your device...", "ask-signing"),
|
||||||
signingRejected: new VaultFeedback("failed", "The user refused to sign the transaction", "user-reject"),
|
signingRejected: new VaultFeedback("failed", "The user refused to sign the transaction", "user-reject"),
|
||||||
|
alreadySignedPsbt: new VaultFeedback("failed", "This device already signed PSBT.", "already-signed-psbt"),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Add table
Reference in a new issue