Merge branch 'master' into mobile-working-branch

This commit is contained in:
Dennis Reimann 2024-12-11 12:33:46 +01:00
commit 979519d34c
No known key found for this signature in database
GPG Key ID: 5009E1797F03F8D0
27 changed files with 890 additions and 243 deletions

View File

@ -47,7 +47,6 @@ jobs:
docker buildx create --use
DOCKER_BUILDX_OPTS="--platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg GIT_COMMIT=${GIT_COMMIT} --push"
docker buildx build $DOCKER_BUILDX_OPTS -t $DOCKERHUB_REPO:$LATEST_TAG .
docker buildx build $DOCKER_BUILDX_OPTS -t $DOCKERHUB_REPO:$LATEST_TAG-altcoins --build-arg CONFIGURATION_NAME=Altcoins-Release .
workflows:
version: 2
build_and_test:

View File

@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.App.BackupStorage;
@ -66,6 +68,7 @@ namespace BTCPayServer.Data
public DbSet<LightningAddressData> LightningAddresses { get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@ -111,7 +114,7 @@ namespace BTCPayServer.Data
WebhookData.OnModelCreating(builder, Database);
FormData.OnModelCreating(builder, Database);
StoreRole.OnModelCreating(builder, Database);
PendingTransaction.OnModelCreating(builder, Database);
}
}
}

View 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; }
}

View File

@ -49,6 +49,7 @@ namespace BTCPayServer.Data
public IEnumerable<FormData> Forms { get; set; }
public IEnumerable<StoreRole> StoreRoles { get; set; }
public bool Archived { get; set; }
public IEnumerable<PendingTransaction> PendingTransactions { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{

View File

@ -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");
}
}
}

View File

@ -666,6 +666,36 @@ namespace BTCPayServer.Migrations
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 =>
{
b.Property<string>("Id")
@ -1364,6 +1394,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")
@ -1624,6 +1664,8 @@ namespace BTCPayServer.Migrations
b.Navigation("Payouts");
b.Navigation("PendingTransactions");
b.Navigation("PullPayments");
b.Navigation("Settings");

View File

@ -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));

View File

@ -141,12 +141,15 @@ namespace BTCPayServer.Controllers
var psbt = PSBT.Parse(o["psbt"].Value<string>(), 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;

View File

@ -28,7 +28,7 @@ namespace BTCPayServer.Controllers
public async Task<CreatePSBTResponse> 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<OutPoint>();
@ -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":

View File

@ -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<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]
[Route("{walletId}")]
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
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<IActionResult> 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<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
{
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(),

View File

@ -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<NBXplorer.Models.PSBTRebaseKeyRules> 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();
}

View 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();
}
}

View File

@ -355,6 +355,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<StoreRepository>();
services.TryAddSingleton<PaymentRequestRepository>();
services.TryAddSingleton<BTCPayWalletProvider>();
services.AddSingleton<PendingTransactionService>();
services.AddScheduledTask<PendingTransactionService>(TimeSpan.FromMinutes(10));
services.TryAddSingleton<WalletReceiveService>();
services.AddSingleton<IHostedService>(provider => provider.GetService<WalletReceiveService>());

View File

@ -25,10 +25,21 @@ namespace BTCPayServer.Models.StoreViewModels
public string SelectedSigningKey { get; set; }
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 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

View File

@ -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<TransactionViewModel> Transactions { get; set; } = new();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }
public PendingTransaction[] PendingTransactions { get; set; }
}
}

View File

@ -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; }
}
}

View File

@ -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
{

View File

@ -160,17 +160,25 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (await Throttle(appId))
return new TooManyRequestsResult(ZoneLimits.PublicInvoices);
// Distinguish JSON requests coming via the mobile app
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
if (app == null)
return wantsJson
? Json(new { error = "App not found" })
: NotFound();
// not allowing negative tips or discounts
if (tip < 0 || discount < 0)
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
return wantsJson
? Json(new { error = "Negative tip or discount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
if (app == null)
return NotFound();
return wantsJson
? Json(new { error = "Negative amount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
@ -180,6 +188,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
}
var jposData = TryParseJObject(posData);
string title;
decimal? price;
@ -235,9 +244,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
switch (itemChoice.Inventory)
{
case <= 0:
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
case { } inventory when inventory < cartItem.Count:
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
return wantsJson
? Json(new { error = $"Inventory for {itemChoice.Title} exhausted: {itemChoice.Inventory} available" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
}
@ -262,9 +272,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var store = await _appService.GetStore(app);
var storeBlob = store.GetStoreBlob();
var posFormId = settings.FormId;
// skip forms feature for JSON requests (from the app)
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
var formData = wantsJson ? null : await FormDataService.GetForm(posFormId);
JObject formResponseJObject = null;
switch (formData)

View File

@ -407,6 +407,7 @@ namespace BTCPayServer.Services
"Default currency": "",
"Default Currency Pairs": "",
"Default dictionary changed to {0}": "",
"Default Include NonWitness Utxo in PSBTs": "",
"Default language on checkout": "",
"Default Payment Method": "",
"Default payment method on checkout": "",
@ -745,6 +746,7 @@ namespace BTCPayServer.Services
"Invoices are documents issued by the seller to a buyer to collect payment.": "",
"Is administrator?": "",
"Is hot wallet": "",
"Is MultiSig on Server": "",
"is settled": "",
"Is signing key": "",
"Is unconfirmed": "",

View File

@ -16,156 +16,173 @@
<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"/>
}
<h2 class="mb-2 mb-lg-3" text-translate="true">@ViewData["Title"]</h2>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<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">
<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
</button>
<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>
<form method="post" asp-controller="UIWallets" asp-action="WalletActions" asp-route-walletId="@Model.WalletId">
<button name="command" type="submit" class="dropdown-item" value="prune" text-translate="true">Prune old transactions from history</button>
@if (User.IsInRole(Roles.ServerAdmin))
{
<button name="command" type="submit" class="dropdown-item" value="clear" text-translate="true">Clear all transactions from history</button>
}
</form>
@if (Model.UriScheme == "bitcoin")
{
<button type="button" class="dropdown-item" id="RegisterWallet" data-store="@Model.StoreName" data-scheme="@Model.UriScheme" data-url="@Url.Action("WalletSend", "UIWallets", new {walletId = Model.WalletId, bip21 = "%s"})" hidden text-translate="true">Register wallet for payment links</button>
}
<div class="dropdown-divider"></div>
@if (Model.NBXSeedAvailable)
{
<a asp-action="WalletSeed" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" class="dropdown-item" id="ViewSeed" text-translate="true">View seed</a>
}
<a asp-controller="UIStores" asp-action="ReplaceWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode"
id="ChangeWalletLink"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Replace {0} wallet", Model.CryptoCode]"
data-description="@ViewData["ReplaceDescription"]"
data-confirm="@StringLocalizer["Setup new wallet"]"
data-confirm-input="@StringLocalizer["REPLACE"]"
text-translate="true">
Replace wallet
</a>
<form method="get" asp-action="DeleteWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" class="d-inline">
<button type="submit"
id="Delete"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Remove {0} wallet", Model.CryptoCode]"
data-description="@ViewData["RemoveDescription"]"
data-confirm="@StringLocalizer["Remove"]"
data-confirm-input="@StringLocalizer["REMOVE"]"
text-translate="true">Remove wallet</button>
</form>
</div>
</div>
</div>
<form method="post" asp-action="UpdateWalletSettings" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode">
<div class="form-group my-4">
<div class="d-flex align-items-center">
<input asp-for="Enabled" type="checkbox" class="btcpay-toggle me-3"/>
<label asp-for="Enabled" class="form-check-label"></label>
</div>
<span asp-validation-for="Enabled" class="text-danger"></span>
</div>
@if (Model.CanUsePayJoin)
{
<div class="form-group mt-n2">
<div class="d-flex align-items-center">
<input asp-for="PayJoinEnabled" type="checkbox" class="btcpay-toggle me-3"/>
<label asp-for="PayJoinEnabled" class="form-check-label me-1"></label>
<a href="https://docs.btcpayserver.org/Payjoin/" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
<vc:icon symbol="info"/>
</a>
</div>
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
</div>
}
<div class="form-group">
<label asp-for="Label" class="form-label"></label>
<input asp-for="Label" class="form-control" style="max-width:24em;" />
<span asp-validation-for="Label" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="DerivationScheme" class="form-label"></label>
<div class="input-group" data-clipboard="@Model.DerivationScheme">
<input asp-for="DerivationScheme" class="form-control" readonly />
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme)
{
<div class="form-group">
<label asp-for="DerivationSchemeInput" class="form-label"></label>
<div class="input-group" data-clipboard="@Model.DerivationSchemeInput">
<input asp-for="DerivationSchemeInput" class="form-control" readonly/>
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
}
@for (var i = 0; i < Model.AccountKeys.Count; i++)
{
<h4 class="mt-5 mb-3">@StringLocalizer["Account Key"] @i</h4>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between">
<label asp-for="@Model.AccountKeys[i].AccountKey" class="form-label"></label>
<button type="button" class="d-inline-block ms-2 btn text-primary btn-link p-0 mb-2" data-account-key="@i">
<vc:icon symbol="qr-code" /> @StringLocalizer["Show export QR"]
</button>
</div>
<div class="input-group" data-clipboard="@Model.AccountKeys[i].AccountKey">
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly/>
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
<div class="row">
<div class="form-group col-auto">
<label asp-for="@Model.AccountKeys[i].MasterFingerprint" class="form-label"></label>
<input asp-for="@Model.AccountKeys[i].MasterFingerprint" class="form-control" style="max-width:16ch;" />
</div>
<div class="form-group col-auto">
<label asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-label"></label>
<input asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-control" style="max-width:16ch;" />
</div>
</div>
@if (Model.IsMultiSig)
<div class="sticky-header">
<h2 text-translate="true">@ViewData["Title"]</h2>
<div>
<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">
Actions
</button>
<!-- Modal For Actions DropDownToggle, need to be outside of form -->
<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>
<form method="post" asp-controller="UIWallets" asp-action="WalletActions" asp-route-walletId="@Model.WalletId">
<button name="command" type="submit" class="dropdown-item" value="prune" text-translate="true">Prune old transactions from history</button>
@if (User.IsInRole(Roles.ServerAdmin))
{
<div class="form-check">
<input asp-for="SelectedSigningKey" class="form-check-input" type="radio" value="@Model.AccountKeys[i].AccountKey"/>
<label asp-for="SelectedSigningKey" class="form-check-label"></label>
</div>
<button name="command" type="submit" class="dropdown-item" value="clear" text-translate="true">Clear all transactions from history</button>
}
</form>
@if (Model.UriScheme == "bitcoin")
{
<button type="button" class="dropdown-item" id="RegisterWallet" data-store="@Model.StoreName" data-scheme="@Model.UriScheme" data-url="@Url.Action("WalletSend", "UIWallets", new {walletId = Model.WalletId, bip21 = "%s"})" hidden text-translate="true">Register wallet for payment links</button>
}
<button type="submit" class="btn btn-primary mt-2" id="SaveWalletSettings" text-translate="true">Save Wallet Settings</button>
</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 class="dropdown-divider"></div>
@if (Model.NBXSeedAvailable)
{
<a asp-action="WalletSeed" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" class="dropdown-item" id="ViewSeed" text-translate="true">View seed</a>
}
<a asp-controller="UIStores" asp-action="ReplaceWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode"
id="ChangeWalletLink"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Replace {0} wallet", Model.CryptoCode]"
data-description="@ViewData["ReplaceDescription"]"
data-confirm="@StringLocalizer["Setup new wallet"]"
data-confirm-input="@StringLocalizer["REPLACE"]"
text-translate="true">
Replace wallet
</a>
<form method="get" asp-action="DeleteWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" class="d-inline">
<button type="submit"
id="Delete"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#ConfirmModal"
data-title="@StringLocalizer["Remove {0} wallet", Model.CryptoCode]"
data-description="@ViewData["RemoveDescription"]"
data-confirm="@StringLocalizer["Remove"]"
data-confirm-input="@StringLocalizer["REMOVE"]"
text-translate="true">Remove wallet</button>
</form>
</div>
</div>
<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="d-flex align-items-center">
<input asp-for="Enabled" type="checkbox" class="btcpay-toggle me-3"/>
<label asp-for="Enabled" class="form-check-label"></label>
</div>
<span asp-validation-for="Enabled" class="text-danger"></span>
</div>
@if (Model.CanUsePayJoin)
{
<div class="form-group mt-n2">
<div class="d-flex align-items-center">
<input asp-for="PayJoinEnabled" type="checkbox" class="btcpay-toggle me-3"/>
<label asp-for="PayJoinEnabled" class="form-check-label me-1"></label>
<a href="https://docs.btcpayserver.org/Payjoin/" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
<vc:icon symbol="info"/>
</a>
</div>
<span asp-validation-for="PayJoinEnabled" class="text-danger"></span>
</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">
<label asp-for="Label" class="form-label"></label>
<input asp-for="Label" class="form-control" style="max-width:24em;" />
<span asp-validation-for="Label" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="DerivationScheme" class="form-label"></label>
<div class="input-group" data-clipboard="@Model.DerivationScheme">
<input asp-for="DerivationScheme" class="form-control" readonly />
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.DerivationSchemeInput) && Model.DerivationSchemeInput != Model.DerivationScheme)
{
<div class="form-group">
<label asp-for="DerivationSchemeInput" class="form-label"></label>
<div class="input-group" data-clipboard="@Model.DerivationSchemeInput">
<input asp-for="DerivationSchemeInput" class="form-control" readonly/>
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
}
@for (var i = 0; i < Model.AccountKeys.Count; i++)
{
<h4 class="mt-5 mb-3">@StringLocalizer["Account Key"] @i</h4>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between">
<label asp-for="@Model.AccountKeys[i].AccountKey" class="form-label"></label>
<button type="button" class="d-inline-block ms-2 btn text-primary btn-link p-0 mb-2" data-account-key="@i">
<vc:icon symbol="qr-code" /> @StringLocalizer["Show export QR"]
</button>
</div>
<div class="input-group" data-clipboard="@Model.AccountKeys[i].AccountKey">
<input asp-for="@Model.AccountKeys[i].AccountKey" class="form-control" readonly />
<button type="button" class="btn btn-outline-secondary px-3">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
<div class="row">
<div class="form-group col-auto">
<label asp-for="@Model.AccountKeys[i].MasterFingerprint" class="form-label"></label>
<input asp-for="@Model.AccountKeys[i].MasterFingerprint" class="form-control" style="max-width:16ch;" />
</div>
<div class="form-group col-auto">
<label asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-label"></label>
<input asp-for="@Model.AccountKeys[i].AccountKeyPath" class="form-control" style="max-width:16ch;" />
</div>
</div>
@if (Model is { IsMultiSig: true, IsMultiSigOnServer: false })
{
<div class="form-check">
<input asp-for="SelectedSigningKey" class="form-check-input" type="radio" value="@Model.AccountKeys[i].AccountKey" />
<label asp-for="SelectedSigningKey" class="form-check-label"></label>
</div>
}
}
</form>
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["{0} wallet", Model.CryptoCode], StringLocalizer["Change"], "Update"))" />
<partial name="ShowQR"/>

View File

@ -7,4 +7,5 @@
<input type="hidden" asp-for="PayJoinBIP21" value="@Model.PayJoinBIP21"/>
<input type="hidden" asp-for="EnforceLowR" value="@Model.EnforceLowR" />
<input type="hidden" asp-for="ChangeAddress" value="@Model.ChangeAddress" />
<input type="hidden" asp-for="PendingTransactionId" value="@Model.PendingTransactionId" />
}

View File

@ -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 {
<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>
.nav-pills .nav-link.active {
color: var(--btcpay-secondary-text-active);
@ -80,7 +80,7 @@
@section Navbar {
@if (backUrl != null)
{
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<a href="@Url.EnsureLocal(backUrl, Context.Request)" id="GoBack">
<vc:icon symbol="back" />
</a>
}
@ -98,17 +98,29 @@
@if (isSignable)
{
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="my-5">
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="NBXSeedAvailable"/>
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="FileName"/>
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="NBXSeedAvailable" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="FileName" />
<input type="hidden" asp-for="ReturnUrl" />
<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>
@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>
</form>
}
}
else if (isReady)
{
<form method="post" asp-action="WalletPSBTReady" asp-route-walletId="@walletId" class="my-5">
@ -141,14 +153,14 @@ else
<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")">
<span class="h5">Export PSBT @(isReady ? "" : "for signing")</span>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<div id="PSBTOptionsExportContent" class="accordion-collapse collapse @(needsExport ? "show" : "")" aria-labelledby="PSBTOptionsExportHeader" data-bs-parent="#PSBTOptions">
<div class="accordion-body">
<form method="post" asp-action="WalletPSBT" asp-route-walletId="@walletId" class="mb-2">
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="PSBT"/>
<input type="hidden" asp-for="CryptoCode" />
<input type="hidden" asp-for="PSBT" />
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="BackUrl" />
<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">
<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>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<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">
<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>
<vc:icon symbol="caret-down"/>
<vc:icon symbol="caret-down" />
</button>
</h2>
<div id="PSBTOptionsAdvancedContent" class="accordion-collapse collapse" aria-labelledby="PSBTOptionsAdvancedHeader" data-bs-parent="#PSBTOptions">
<div class="accordion-body">
<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="BackUrl" />
<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>
<partial name="ShowQR"/>
<partial name="CameraScanner"/>
<partial name="ShowQR" />
<partial name="CameraScanner" />

View File

@ -226,8 +226,16 @@
</div>
</div>
<div class="d-grid d-sm-flex flex-wrap gap-3 buttons">
<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>
@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="ScheduleTransaction" name="command" value="schedule" class="btn btn-secondary" text-translate="true">Schedule transaction</button>
}
</div>
<vc:ui-extension-point location="onchain-wallet-send" model="@Model"/>

View File

@ -74,7 +74,7 @@
const count = @Safe.Json(Model.Count);
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
let skip = @Safe.Json(Model.Skip) - count;
@ -173,57 +173,101 @@
</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">
<table class="table table-hover mass-action">
<thead class="mass-action-head">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th class="date-col">
<div class="d-flex align-items-center gap-1">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th class="date-col">
<div class="d-flex align-items-center gap-1">
<span text-translate="true">Date</span>
<button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="@StringLocalizer["Switch date format"]">
<vc:icon symbol="time" />
</button>
</div>
</th>
</div>
</th>
<th text-translate="true" class="text-start">Label</th>
<th text-translate="true">Transaction</th>
<th text-translate="true" class="amount-col">Amount</th>
<th></th>
</tr>
<th></th>
</tr>
</thead>
<thead class="mass-action-actions">
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th colspan="5">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
<tr>
<th class="only-for-js mass-action-select-col">
<input type="checkbox" class="form-check-input mass-action-select-all" />
</th>
<th colspan="5">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<div>
<strong class="mass-action-selected-count">0</strong>
<span text-translate="true">selected</span>
</div>
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@walletId" permission="@Policies.CanModifyStoreSettings" class="d-inline-flex align-items-center gap-3">
<button id="BumpFee" name="command" type="submit" value="cpfp" class="btn btn-link">
</div>
<form id="WalletActions" method="post" asp-action="WalletActions" asp-route-walletId="@walletId" permission="@Policies.CanModifyStoreSettings" class="d-inline-flex align-items-center gap-3">
<button id="BumpFee" name="command" type="submit" value="cpfp" class="btn btn-link">
<vc:icon symbol="actions-send" />
<span text-translate="true">Bump fee</span>
</button>
</form>
</div>
</th>
</tr>
</button>
</form>
</div>
</th>
</tr>
</thead>
<tbody id="WalletTransactionsList">
<partial name="_WalletTransactionsList" model="Model" />
<partial name="_WalletTransactionsList" model="Model" />
</tbody>
</table>
</div>
<noscript>
<vc:pager view-model="Model"/>
<vc:pager view-model="Model" />
</noscript>
<div class="text-center only-for-js d-none" id="LoadingIndicator">
<div class="spinner-border spinner-border-sm text-secondary ms-2" role="status">
<span class="visually-hidden" text-translate="true">Loading...</span>

View File

@ -50,6 +50,7 @@ var vaultui = (function () {
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"),
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"),
};
/**

View File

@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>2.0.3</Version>
<Version>2.0.4</Version>
</PropertyGroup>
</Project>

View File

@ -1,5 +1,40 @@
# Changelog
## 2.0.4
### New features
* Add QR Code with link to invitation email (#6438) @dennisreimann
* Add rate providers for Norwegian exchanges (Bitmynt and Bare Bitcoin) (#6452) @schjonhaug
* Greenfield: Improve store users API (#6427) @dennisreimann
* Adds an endpoint to update store users (before they had to be removed and re-added)
* Checks for the existence of a user and responds with 404 in that case (fixes #6423)
* Allows retrieval of user by user id or email for add and update (consistent with the other endpoints)
* Improves the API docs for the store users endpoint
* Adds details to store user data
### Bug fixes
* Fix: correct ` <` plugin dependency implementation (#6420) @jackstar12
* Fix: Point of Sale as PWA on iOS no longer working in Lockdown mode (#6422 #6424) @leesalminen
* Greenfield: Users API fixes (#6425) @dennisreimann
* Do not crash when creating users without a password
* More precise error message for user approval toggling
* App: Sales stats should only include paid invoices (#6444) @dennisreimann
* Fix: Combination of status filters on invoices page causes 500 fatal server error (#6437) @NicolasDorier
* Fix: Payment Requests should show payments of invalid invoices (#6412) @NicolasDorier
* Bugfix: Providing updated PSBT with QR Code was not possible (#6459 #6460) @Orcinus21
### Improvements
* UI: Move App's invoices link to the top (#6429) @dennisreimann
* Account: Sign in users after accepting an invitation or resetting a password (#6442) @dennisreimann
* Improve display for the PoS editor (#6441 #6436) @dennisreimann
* Fix: Truncate center CSS for icons (#6465) @jackstar12
* Do not throttle authenticated users on a PoS application (#6415) @Kukks
* Plugin: Add `IGlobalCheckoutModelExtension` to allow a plugin to customize checkout experience regardless of the payment method (#6470) @NicolasDorier
* Plugin: Add `IExtendedLightningClient` to allow a plugin to better validate a lightning connection string, and customize display stringss. (#6467) @NicolasDorier
## 2.0.3
If you are using Boltcards, we advise you to update to this release.