#nullable enable using System; using System.Collections.Generic; using System.Data.Common; using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; using System.Text; using System.Text.Encodings.Web; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.BIP78.Sender; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Components.LabelSelector; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Plugins.Wallets.Views.ViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payouts; using BTCPayServer.Rating; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Labels; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets.Export; using BTCPayServer.Services.Wallets.Import; using BTCPayServer.Plugins.Wallets; using Dapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using NBitcoin; using NBXplorer; using NBXplorer.DerivationStrategy; using NBXplorer.Models; using Newtonsoft.Json; using static BTCPayServer.Plugins.Wallets.Views.ViewModels.WalletBumpFeeViewModel; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers { [Route("wallets")] [Area(WalletsPlugin.Area)] [Authorize(Policy = WalletPolicies.CanViewWallet, AuthenticationSchemes = AuthenticationSchemes.Cookie)] //16mb psbts [RequestFormLimits(ValueLengthLimit = FormReader.DefaultValueLengthLimit * 4)] public partial class UIWalletsController( PendingTransactionService pendingTransactionService, StoreRepository repo, WalletRepository walletRepository, CurrencyNameTable currencyTable, BTCPayNetworkProvider networkProvider, NBXplorerDashboard dashboard, WalletHistogramService walletHistogramService, RateFetcher rateProvider, IAuthorizationService authorizationService, ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, BTCPayWalletProvider walletProvider, WalletReceiveService walletReceiveService, DelayedTransactionBroadcaster broadcaster, PayjoinClient payjoinClient, IServiceProvider serviceProvider, PullPaymentHostedService pullPaymentHostedService, PermissionService permissionService, LabelService labelService, DefaultRulesCollection defaultRules, PaymentMethodHandlerDictionary handlers, Dictionary paymentModelExtensions, IStringLocalizer stringLocalizer, TransactionLinkProviders transactionLinkProviders, InvoiceRepository invoiceRepository, DisplayFormatter displayFormatter, HotwalletSafe hotwalletSafe) : Controller { private StoreRepository Repository { get; } = repo; private WalletRepository WalletRepository { get; } = walletRepository; private BTCPayNetworkProvider NetworkProvider { get; } = networkProvider; private ExplorerClientProvider ExplorerClientProvider { get; } = explorerProvider; private IServiceProvider ServiceProvider { get; } = serviceProvider; private RateFetcher RateFetcher { get; } = rateProvider; private IStringLocalizer StringLocalizer { get; } = stringLocalizer; [HttpGet("{walletId}/pending/{pendingTransactionId}/cancel")] [Authorize(Policy = WalletPolicies.CanCancelWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task CancelPendingTransaction( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string pendingTransactionId) { 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/{pendingTransactionId}/cancel")] [Authorize(Policy = WalletPolicies.CanCancelWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task CancelPendingTransactionConfirmed( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string pendingTransactionId) { await pendingTransactionService.CancelPendingTransaction(GetPendingTxId(walletId, pendingTransactionId)); TempData.SetStatusMessageModel(new StatusMessageModel() { Severity = StatusMessageModel.StatusSeverity.Success, Message = $"Aborted Pending Transaction {pendingTransactionId}" }); return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } [HttpGet("{walletId}/pending/{pendingTransactionId}")] public async Task ViewPendingTransaction( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string pendingTransactionId) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); if (network is null) return NotFound(); var pendingTransaction = await pendingTransactionService.GetPendingTransaction(GetPendingTxId(walletId, pendingTransactionId)); if (pendingTransaction is null) return NotFound(); if (pendingTransaction.State is not (PendingTransactionState.Pending or PendingTransactionState.Signed)) { return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString(), searchText = pendingTransaction.TransactionId }); } var canSign = (await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded; var canBroadcastSigned = pendingTransaction.State == PendingTransactionState.Signed && (await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanBroadcastWalletTransactions)).Succeeded; if (!canSign && !canBroadcastSigned) return Forbid(); 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 = pendingTransactionId, PSBT = currentPsbt.ToBase64(), }, }; await FetchTransactionDetails(walletId, derivationSchemeSettings, vm, network); await vm.GetPSBT(network.NBitcoinNetwork, ModelState); return View("WalletPSBTDecoded", vm); } private PendingTransactionService.PendingTransactionFullId GetPendingTxId(WalletId walletId, string pendingTransactionId) => new (walletId.CryptoCode, walletId.StoreId, pendingTransactionId); [Route("{walletId}/transactions/bump")] [Route("{walletId}/transactions/{transactionId}/bump")] [Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletBumpFee([ModelBinder(typeof(WalletIdModelBinder))] [FromQuery] WalletId walletId, WalletBumpFeeViewModel model, CancellationToken cancellationToken = default) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod is null) return NotFound(); var wallet = walletProvider.GetWallet(walletId.CryptoCode); var bumpable = await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken); var bumpTarget = model.GetBumpTarget() // Remove from the selected targets everything that isn't bumpable .Filter(bumpable.Where(o => (o.Value.CPFP || o.Value.RBF) && o.Value.ReplacementInfo != null).Select(o => o.Key).ToHashSet()); var explorer = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var txs = await GetUnconfWalletTxInfo(explorer, paymentMethod.AccountDerivation, bumpTarget.GetTransactionIds(), cancellationToken); // Remove from the selected targets everything for which we don't have the transaction info bumpTarget = bumpTarget.Filter(txs.Select(t => t.Key).ToHashSet()); model.ReturnUrl ??= Url.WalletTransactions(walletId)!; decimal minBumpFee; if (bumpTarget.GetSingleTransactionId() is { } txId) { var inf = bumpable[txId]; if (inf.RBF) model.BumpFeeMethods.Add(new("RBF", "RBF")); if (inf.CPFP) model.BumpFeeMethods.Add(new("CPFP", "CPFP")); // We calculate the effective fee rate using all the ancestors and descendant. model.CurrentFeeSatoshiPerByte = inf.ReplacementInfo!.GetEffectiveFeeRate().SatoshiPerByte; minBumpFee = inf.ReplacementInfo.CalculateNewMinFeeRate().SatoshiPerByte; } else if (bumpTarget.GetTransactionIds().Any()) { model.BumpFeeMethods.Add(new("CPFP", "CPFP")); // If we bump multiple transactions, we calculate the effective fee rate without // taking into account descendants. This isn't super correct... but good enough for our purposes. // This is because we would have the risk of double counting the fees otherwise. var currentFeeRate = GetTransactionsFeeInfo(bumpTarget, txs, null).CurrentFeeRate.SatoshiPerByte; model.CurrentFeeSatoshiPerByte = currentFeeRate; minBumpFee = currentFeeRate + 1.0m; } else { this.TempData.SetStatusMessageModel(new StatusMessageModel() { Severity = StatusMessageModel.StatusSeverity.Error, Message = bumpable switch { { Support: BumpableSupport.NotCompatible } => StringLocalizer["This version of NBXplorer is not compatible. Please update to 2.5.22 or above"], { Support: BumpableSupport.NotConfigured } => StringLocalizer["Please set NBXPlorer's PostgreSQL connection string to make this feature available."], { Support: BumpableSupport.NotSynched } => StringLocalizer["Please wait for your node to be synched"], _ => StringLocalizer["None of the selected transaction can be fee bumped"] } }); return LocalRedirect(model.ReturnUrl); } model.IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer; var recommendedFees = await GetRecommendedFees(wallet.Network, feeRateProvider); foreach (var option in recommendedFees) { if (option is null) continue; if (option.FeeRate < minBumpFee) option.FeeRate = minBumpFee; } model.RecommendedSatoshiPerByte = recommendedFees.Where(option => option != null).ToList(); model.FeeSatoshiPerByte ??= recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; if (HttpContext.Request.Method != HttpMethods.Post) { model.Command = null; } if (!ModelState.IsValid || model.Command is null || model.FeeSatoshiPerByte is null) return View(nameof(WalletBumpFee), model); var targetFeeRate = new FeeRate(model.FeeSatoshiPerByte.Value); model.BumpMethod ??= model.BumpFeeMethods switch { { Count: 1 } => model.BumpFeeMethods[0].Value, _ => "RBF" }; PSBT? psbt = null; SigningContextModel? signingContext = null; var feeBumpUrl = Url.Action(nameof(WalletBumpFee), new { walletId, transactionId = bumpTarget.GetSingleTransactionId(), model.FeeSatoshiPerByte, model.BumpMethod, model.TransactionHashes, model.Outpoints })!; if (model.BumpMethod == "CPFP") { var utxos = await explorer.GetUTXOsAsync(paymentMethod.AccountDerivation, cancellationToken); List bumpableUTXOs = bumpTarget.GetMatchedOutpoints(utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0).Select(u => u.Outpoint)); if (bumpableUTXOs.Count == 0) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["There isn't any UTXO available to bump fee with CPFP"].Value; return LocalRedirect(model.ReturnUrl); } var createPSBT = new CreatePSBTRequest() { RBF = true, AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo, IncludeGlobalXPub = paymentMethod.IsMultiSigOnServer, IncludeOnlyOutpoints = bumpableUTXOs, SpendAllMatchingOutpoints = true, FeePreference = new FeePreference() { ExplicitFee = GetTransactionsFeeInfo(bumpTarget, txs, targetFeeRate).MissingFee, ExplicitFeeRate = targetFeeRate } }; try { var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken); psbtResponse.PSBT.AddWitnessUtxoToSegwitInputs(); signingContext = new SigningContextModel { EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, ChangeAddress = psbtResponse.ChangeAddress?.ToString(), PSBT = psbtResponse.PSBT.ToHex() }; psbt = psbtResponse.PSBT; } catch (Exception ex) { TempData[WellKnownTempData.ErrorMessage] = ex.Message; return LocalRedirect(model.ReturnUrl); } } else if (model.BumpMethod == "RBF") { // RBF is only supported for a single tx var tx = txs[bumpTarget.GetSingleTransactionId()!]; var changeOutput = tx.Outputs.FirstOrDefault(o => o.Feature == DerivationFeature.Change); if (changeOutput is null && tx is { Transaction: { Outputs: [{ ScriptPubKey: {} singleAddress }] }}) changeOutput = new() { ScriptPubKey = singleAddress, Index = 0 }; if (tx.Inputs.Count != tx.Transaction?.Inputs.Count || changeOutput is null) { this.ModelState.AddModelError(nameof(model.BumpMethod), StringLocalizer["This transaction can't be RBF'd"]); return View(nameof(WalletBumpFee), model); } IActionResult ChangeTooSmall(WalletBumpFeeViewModel vm, Money? missing) { if (missing is not null) ModelState.AddModelError(nameof(vm.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee. (Missing {0} BTC)", missing.ToDecimal(MoneyUnit.BTC)]); else ModelState.AddModelError(nameof(vm.FeeSatoshiPerByte), StringLocalizer["The change output is too small to pay for additional fee."]); return View(nameof(WalletBumpFee), vm); } var bumpableTx = bumpable[tx.TransactionId].ReplacementInfo!; if (targetFeeRate < bumpableTx.CalculateNewMinFeeRate()) { ModelState.AddModelError(nameof(model.FeeSatoshiPerByte), StringLocalizer["The selected fee rate is too small. The minimum is {0} sat/byte", bumpableTx.CalculateNewMinFeeRate().SatoshiPerByte]); return View(nameof(WalletBumpFee), model); } var bumpResult = bumpableTx.CalculateBumpResult(targetFeeRate); var createPSBT = new CreatePSBTRequest() { RBF = true, AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo, IncludeGlobalXPub = paymentMethod.IsMultiSigOnServer, IncludeOnlyOutpoints = tx.Transaction.Inputs.Select(i => i.PrevOut).ToList(), SpendAllMatchingOutpoints = true, DisableFingerprintRandomization = true, FeePreference = new FeePreference() { ExplicitFee = bumpResult.NewTxFee }, ExplicitChangeAddress = changeOutput switch { { Address: {} addr } => PSBTDestination.Create(addr), { ScriptPubKey: {} scriptPubKey } => PSBTDestination.Create(scriptPubKey), _ => throw new InvalidOperationException("Invalid change output") }, Destinations = tx.Transaction.Outputs.AsIndexedOutputs() .Select(o => new CreatePSBTDestination() { Amount = o.N == changeOutput.Index ? o.TxOut.Value - bumpResult.BumpTxFee : o.TxOut.Value, Destination = o.TxOut.ScriptPubKey, }).ToList() }; var missingFundsOutput = createPSBT.Destinations.FirstOrDefault(d => d.Amount < Money.Zero); if (missingFundsOutput is not null) return ChangeTooSmall(model, -missingFundsOutput.Amount); try { var psbtResponse = await explorer.CreatePSBTAsync(paymentMethod.AccountDerivation, createPSBT, cancellationToken); psbtResponse.PSBT.AddWitnessUtxoToSegwitInputs(); signingContext = new SigningContextModel { EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, ChangeAddress = psbtResponse.ChangeAddress?.ToString(), PSBT = psbtResponse.PSBT.ToHex(), BalanceChangeFromReplacement = (-(Money)tx.BalanceChange).Satoshi }; psbt = psbtResponse.PSBT; } catch (NBXplorerException ex) when (ex.Error.Code == "output-too-small") { return ChangeTooSmall(model, null); } catch (NBXplorerException ex) { ModelState.AddModelError(nameof(model.TransactionId), StringLocalizer["Unable to create the replacement transaction ({0})", ex.Error.Message]); return View(nameof(WalletBumpFee), model); } } if (psbt is not null && signingContext is not null) { if (psbt.TryGetFinalizedHash(out var hash)) await this.WalletRepository.EnsureWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Tx, hash.ToString()), new Newtonsoft.Json.Linq.JObject() { ["bumpFeeMethod"] = model.BumpMethod }); switch (model.Command) { case "createpending": await pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl()); return RedirectToWalletList(walletId); case "sign": if (!(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded) return Forbid(); return await WalletSign(walletId, new WalletPSBTViewModel() { SigningContext = signingContext, BackUrl = feeBumpUrl, ReturnUrl = model.ReturnUrl }); case "analyze-psbt": if (!(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded) return Forbid(); return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), BackUrl = feeBumpUrl, ReturnUrl = model.ReturnUrl }); default: return View(nameof(WalletBumpFee), model); } } // Ask choice to user return View(nameof(WalletBumpFee), model); } private async Task> GetUnconfWalletTxInfo(ExplorerClient client, DerivationStrategyBase derivationStrategyBase, HashSet txs, CancellationToken cancellationToken) { var txWalletInfo = new Dictionary(); var getTransactionAsync = txs.Select(t => client.GetTransactionAsync(derivationStrategyBase, t, cancellationToken)).ToArray(); await Task.WhenAll(getTransactionAsync); foreach (var t in getTransactionAsync) { var r = await t; if (r is not { Confirmations: 0, Transaction: not null }) continue; txWalletInfo.Add(r.TransactionId, r); } return txWalletInfo; } private (Money MissingFee, FeeRate CurrentFeeRate) GetTransactionsFeeInfo(BumpTarget target, Dictionary txs, FeeRate? newFeeRate) { Money missingFee = Money.Zero; int totalSize = 0; Money totalFee = Money.Zero; // In theory, we should calculate using the effective fee rate of all bumped transactions. // In practice, it's a bit complicated to get... meh, that's good enough. foreach (var bumpedTx in target.GetTransactionIds().Select(o => txs[o])) { var size = bumpedTx.Metadata?.VirtualSize ?? bumpedTx.Transaction?.GetVirtualSize() ?? 200; var feePaid = bumpedTx.Metadata?.Fees; if (feePaid is null) // This shouldn't normally happen, as NBX indexes the fee if the transaction is in the mempool continue; if (newFeeRate is not null) { var expectedFeePaid = newFeeRate.GetFee(size); missingFee += Money.Max(Money.Zero, expectedFeePaid - feePaid); } totalSize += size; totalFee += feePaid; } return (missingFee, new FeeRate(totalFee, totalSize)); } private IActionResult RedirectToWalletList(WalletId walletId) { return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } [HttpPost] [Route("{walletId}")] [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task ModifyTransaction( // We need addlabel and addlabelclick. addlabel is the + button if the label does not exists, // addlabelclick is if the user click on existing label. For some reason, reusing the same name attribute for both // does not work [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string transactionId, string? addlabel = null, string? addlabelclick = null, string? addcomment = null, string? removelabel = null) { addlabel = addlabel ?? addlabelclick; // Hack necessary when the user enter a empty comment and submit. // For some reason asp.net consider addcomment null instead of empty string... try { if (addcomment == null && Request.Form.TryGetValue(nameof(addcomment), out _)) { addcomment = string.Empty; } } catch { } ///////// var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId); if (addlabel != null) { await WalletRepository.AddWalletObjectLabels(txObjId, addlabel); } else if (removelabel != null) { await WalletRepository.RemoveWalletObjectLabels(txObjId, removelabel); } else if (addcomment != null) { await WalletRepository.SetWalletObjectComment(txObjId, addcomment); } return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } [HttpGet] [AllowAnonymous] public async Task ListWallets() { if (GetUserId() is not string userId) { return Challenge(AuthenticationSchemes.Cookie); } var wallets = new ListWalletsViewModel(); var stores = await Repository.GetStoresByUserId(userId); var onChainWallets = stores .SelectMany(s => s.GetPaymentMethodConfigs(handlers) .Select(d => ( Wallet: walletProvider.GetWallet(((IHasNetwork)handlers[d.Key]).Network), DerivationStrategy: d.Value.AccountDerivation, Network: ((IHasNetwork)handlers[d.Key]).Network)) .Where(o => o.Wallet != null && o.Network.WalletSupported) .Where(_ => s.HasPolicy(userId, WalletPolicies.CanViewWallet, permissionService)) .Select(o => (Wallet: o.Wallet, Store: s, Balance: GetBalanceString(o.Wallet, o.DerivationStrategy), DerivationStrategy: o.DerivationStrategy, Network: o.Network))) .ToList(); foreach (var wallet in onChainWallets) { ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel(); wallets.Wallets.Add(walletVm); walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode; walletVm.CryptoCode = wallet.Network.CryptoCode; walletVm.StoreId = wallet.Store.Id; walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode); walletVm.StoreName = wallet.Store.StoreName; var money = await GetBalanceAsMoney(wallet.Wallet, wallet.DerivationStrategy); wallets.BalanceForCryptoCode[wallet.Network] = wallets.BalanceForCryptoCode.ContainsKey(wallet.Network) ? wallets.BalanceForCryptoCode[wallet.Network].Add(money) : money; } return View(wallets); } internal sealed class WalletTransactionsFilter { public required SearchString Search { get; init; } public string SearchTerm { get; init; } = string.Empty; public string SearchText { get; init; } = string.Empty; public string TextSearch { get; init; } = string.Empty; public DateTimeOffset? StartDate { get; init; } public DateTimeOffset? EndDate { get; init; } public IReadOnlyList LabelFilters { get; init; } = Array.Empty(); public bool IncludeNoLabel { get; init; } public bool? Positive { get; init; } public bool HasLabelFilter => IncludeNoLabel || LabelFilters.Count > 0; public bool HasFilters => !string.IsNullOrWhiteSpace(SearchText) || StartDate is not null || EndDate is not null || HasLabelFilter || Positive is not null; } internal static WalletTransactionsFilter BuildWalletTransactionsFilter(SearchString search) { var labelFilters = new List(search.GetFilterArray("label") ?? Array.Empty()); var includeNoLabel = search.GetFilterBool("nolabel") is true; labelFilters = labelFilters .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); var textSearch = search.TextSearch ?? string.Empty; var directionFilters = search.GetFilterArray("direction"); bool? positive = null; if (directionFilters?.Any() is true) { var hasIncoming = directionFilters.Any(value => value.Equals("in", StringComparison.OrdinalIgnoreCase)); var hasOutgoing = directionFilters.Any(value => value.Equals("out", StringComparison.OrdinalIgnoreCase)); if (hasIncoming && !hasOutgoing) { positive = true; } else if (!hasIncoming && hasOutgoing) { positive = false; } } var period = search.GetDateRange(TimeZoneInfo.Utc); return new WalletTransactionsFilter { Search = search, SearchTerm = search.ToString(SearchStringFormat.OnlyUIFilters), SearchText = textSearch, TextSearch = textSearch, StartDate = period.StartDate, EndDate = period.EndDate, LabelFilters = labelFilters, IncludeNoLabel = includeNoLabel, Positive = positive }; } private static bool MatchesWalletTransactionFilter( TransactionHistoryLine tx, WalletTransactionInfo? transactionInfo, IReadOnlyCollection labels, IReadOnlyCollection tags, WalletTransactionsFilter filter, BTCPayNetwork network) { if (!MatchesWalletTransactionBasicFilter(tx, filter, network)) return false; if (filter.HasLabelFilter) { var hasLabels = labels.Count > 0; var matchesLabel = (filter.IncludeNoLabel && !hasLabels) || (filter.LabelFilters.Count > 0 && labels.Any(label => filter.LabelFilters.Any(filterLabel => filterLabel.Equals(label, StringComparison.OrdinalIgnoreCase)))); if (!matchesLabel) return false; } if (!string.IsNullOrWhiteSpace(filter.TextSearch)) { var searchText = filter.TextSearch; var matchesSearch = tx.TransactionId.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) || (transactionInfo?.Comment?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) || labels.Any(label => label.Contains(searchText, StringComparison.OrdinalIgnoreCase)) || tags.Any(tag => tag.Text.Contains(searchText, StringComparison.OrdinalIgnoreCase)) || (transactionInfo?.Attachments.Any(attachment => attachment.Id.Contains(searchText, StringComparison.OrdinalIgnoreCase) || attachment.Type.Contains(searchText, StringComparison.OrdinalIgnoreCase)) ?? false) || (transactionInfo?.Attachments.Any(attachment => attachment.Data?["title"]?.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) ?? false) || (transactionInfo?.Attachments.Any(attachment => attachment.Data?["text"]?.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false) ?? false); if (!matchesSearch) return false; } return true; } private static bool MatchesWalletTransactionBasicFilter( TransactionHistoryLine tx, WalletTransactionsFilter filter, BTCPayNetwork network) { if (filter.StartDate is { } startDate && tx.SeenAt < startDate) return false; if (filter.EndDate is { } endDate && tx.SeenAt > endDate) return false; if (filter.Positive is not null) { var positive = tx.BalanceChange.GetValue(network) >= 0; if (positive != filter.Positive.Value) return false; } return true; } private static bool RequiresWalletTransactionMetadataFiltering(WalletTransactionsFilter filter) => filter.HasLabelFilter || !string.IsNullOrWhiteSpace(filter.TextSearch); [HttpGet("{walletId}")] [HttpGet("{walletId}/transactions")] public async Task WalletTransactions( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool loadTransactions = false, ListTransactionsViewModel? model = null, CancellationToken cancellationToken = default ) { model ??= new(); var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network; var wallet = walletProvider.GetWallet(network); var fs = model.GetSearch(); if (model.FilterCommand is not null) return model.Redirect(Request); var filter = BuildWalletTransactionsFilter(fs); var filterAtSource = !filter.HasFilters; var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter); model.SearchText = filter.SearchText; model.SearchTerm = filter.SearchTerm; model.HasFilters = filter.HasFilters; model.PaginationQuery = new Dictionary { { "searchTerm", filter.SearchTerm }, { "searchText", filter.SearchText } }; model.PendingTransactions = await pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId); model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList(); var labelsWithUsage = await WalletRepository.GetWalletLabelsByLinkedTypeWithUsage(walletId, WalletObjectData.Types.Tx, includeUnusedLabels: true); model.Labels.AddRange(labelsWithUsage .Select(c => new LabelSelectorItemViewModel() { Text = c.Label, Color = c.Color, TextColor = ColorPalette.Default.TextColor(c.Color), UsageCount = c.UsageCount })); IList? transactions = null; Dictionary? walletTransactionsInfo = null; if (loadTransactions) { transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, filterAtSource ? model.Skip : null, filterAtSource ? model.Count : null, cancellationToken: cancellationToken); if (!filterAtSource) { transactions = transactions .Where(tx => MatchesWalletTransactionBasicFilter(tx, filter, network)) .ToList(); model.Total = transactions.Count; if (!requiresMetadataFiltering) { transactions = transactions.Skip(model.Skip).Take(model.Count).ToList(); } } walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray()); } if (transactions == null || walletTransactionsInfo is null) { model.Transactions = new List(); } else { var bumpable = transactions.Any(tx => tx.Confirmations == 0) ? await wallet.GetBumpableTransactions(paymentMethod.AccountDerivation, cancellationToken) : new(); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(walletId.CryptoCode); foreach (var tx in transactions) { WalletTransactionInfo? transactionInfo = null; var labelValues = Array.Empty(); TransactionTagModel[] tags = []; if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var info)) { transactionInfo = info; tags = labelService.CreateTransactionTagModels(info, Request).ToArray(); labelValues = info.LabelColors.Keys.ToArray(); } if (requiresMetadataFiltering && !MatchesWalletTransactionFilter(tx, transactionInfo, labelValues, tags, filter, network)) continue; var vm = new ListTransactionsViewModel.TransactionViewModel { Id = tx.TransactionId.ToString(), Link = transactionLinkProviders.GetTransactionLink(pmi, tx.TransactionId.ToString()), Timestamp = tx.SeenAt, Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0, Balance = tx.BalanceChange.ShowMoney(wallet.Network), IsConfirmed = tx.Confirmations != 0, HistoryLine = tx, CanBumpFee = tx.Confirmations == 0 && (bumpable.Support is not BumpableSupport.Ok || (bumpable.TryGetValue(tx.TransactionId, out var i) ? i.RBF || i.CPFP : false)) }; if (transactionInfo is not null) { vm.Tags.AddRange(tags); vm.Comment = transactionInfo.Comment; vm.InvoiceId = transactionInfo.Attachments.FirstOrDefault(a => a.Type == WalletObjectData.Types.Invoice)?.Id; vm.WalletRateBook = transactionInfo.Rates; } model.Transactions.Add(vm); } var trackedCurrencies = GetCurrentStore().GetStoreBlob().GetTrackedRates(); var rates = await invoiceRepository.GetRatesOfInvoices(model.Transactions.Select(r => r.InvoiceId).Where(r => r is not null).ToHashSet()); foreach (var vm in model.Transactions) { if (vm.InvoiceId is null) continue; rates.TryGetValue(vm.InvoiceId, out var book); vm.InvoiceRateBook = book; } foreach (var vm in model.Transactions) { var book = vm.InvoiceRateBook ?? new(); if (vm.WalletRateBook is not null) book.AddRates(vm.WalletRateBook); foreach (var trackedCurrency in trackedCurrencies) { var exists = book.TryGetRate(new CurrencyPair(network.CryptoCode, trackedCurrency), out var rate); vm.Rates.Add(exists ? displayFormatter.Currency(rate, trackedCurrency) : null); } } if (requiresMetadataFiltering) { model.Total = model.Transactions.Count; model.Transactions = model.Transactions.Skip(model.Skip).Take(model.Count).ToList(); } } model.CryptoCode = walletId.CryptoCode; //If ajax call then load the partial view ViewData.SetPageTimeZone(fs); return Request.Headers["X-Requested-With"] == "XMLHttpRequest" ? PartialView("_WalletTransactionsList", model) : View(model); } [HttpGet("{walletId}/histogram/{type}")] public async Task WalletHistogram( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, HistogramType type) { var store = GetCurrentStore(); var data = await walletHistogramService.GetHistogram(store, walletId, type); if (data == null) return NotFound(); return Json(data); } [HttpGet("{walletId}/receive")] public async Task WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromQuery] string? returnUrl = null) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var network = NetworkProvider.GetNetwork(walletId.CryptoCode); if (network == null) return NotFound(); var store = GetCurrentStore(); var address = (await walletReceiveService.GetOrGenerate(walletId)).Address; var allowedPayjoin = paymentMethod.IsHotWallet && store.GetStoreBlob().PayJoinEnabled; var bip21 = network.GenerateBIP21(address?.ToString(), null); if (allowedPayjoin) { var endpoint = Url.ActionAbsolute(Request, nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new { cryptoCode = walletId.CryptoCode }).ToString(); bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, endpoint); } string[]? labels = null; if (address is not null) { var info = await WalletRepository.GetWalletObject(new WalletObjectId(walletId, WalletObjectData.Types.Address, address.ToString())); labels = info?.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label) .Select(data => data.Id).ToArray(); } return View(new WalletReceiveViewModel { CryptoCode = walletId.CryptoCode, Address = address?.ToString(), CryptoImage = GetImage(network), PaymentLink = bip21.ToString(), ReturnUrl = returnUrl, SelectedLabels = labels ?? Array.Empty() }); } [HttpPost("{walletId}/receive")] public async Task WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletReceiveViewModel vm, string command) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var network = NetworkProvider.GetNetwork(walletId.CryptoCode); if (network == null) return NotFound(); switch (command) { case "generate-new-address": await walletReceiveService.GetOrGenerate(walletId, true); break; case "fill-wallet": var cheater = ServiceProvider.GetService(); if (cheater != null) await SendFreeMoney(cheater, walletId, paymentMethod); break; } return RedirectToAction(nameof(WalletReceive), new { walletId, returnUrl = vm.ReturnUrl }); } [HttpGet("{walletId}/addresses")] public async Task ReservedAddresses( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var labeledAddresses = await WalletRepository.GetReservedAddressesWithDetails(walletId); if (labeledAddresses.Count != 0) { var connectionFactory = ServiceProvider.GetRequiredService(); if (!connectionFactory.Available) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses requires access to the NBXplorer database."].Value; return RedirectToAction(nameof(WalletReceive), new { walletId }); } try { labeledAddresses = await FilterReservedAddressesToCurrentDerivation(connectionFactory, walletId, paymentMethod, labeledAddresses); } catch (DbException) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Reserved Addresses is temporarily unavailable because the NBXplorer database cannot be reached."].Value; return RedirectToAction(nameof(WalletReceive), new { walletId }); } } var vm = new ReservedAddressesViewModel { WalletId = walletId.ToString(), CryptoCode = walletId.CryptoCode, Addresses = labeledAddresses }; return View(vm); } private async Task> FilterReservedAddressesToCurrentDerivation( NBXplorerConnectionFactory connectionFactory, WalletId walletId, DerivationSchemeSettings paymentMethod, List labeledAddresses) { if (labeledAddresses.Count == 0 || paymentMethod.AccountDerivation is null) return labeledAddresses; var addresses = labeledAddresses.Select(a => a.Address).ToArray(); var currentWalletId = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id( walletId.CryptoCode, paymentMethod.AccountDerivation.ToString()); await using var conn = await connectionFactory.OpenConnection(); var activeAddresses = (await conn.QueryAsync( """ SELECT DISTINCT searched.addr FROM unnest(@addresses) AS searched(addr) JOIN scripts s ON s.code = @code AND s.addr = searched.addr JOIN wallets_scripts ws ON ws.code = s.code AND ws.script = s.script WHERE ws.wallet_id = @walletId """, new { addresses, code = walletId.CryptoCode, walletId = currentWalletId })).ToHashSet(StringComparer.Ordinal); return labeledAddresses .Where(address => activeAddresses.Contains(address.Address)) .ToList(); } private async Task SendFreeMoney(Cheater cheater, WalletId walletId, DerivationSchemeSettings paymentMethod) { var c = this.ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var cashCow = cheater.GetCashCow(walletId.CryptoCode); if (walletId.CryptoCode == "LBTC") { await cashCow.SendCommandAsync("rescanblockchain"); } var addresses = Enumerable.Range(0, 10).Select(_ => c.GetUnusedAsync(paymentMethod.AccountDerivation, DerivationFeature.Deposit, reserve: true)).ToArray(); await Task.WhenAll(addresses); await cashCow.GenerateAsync(addresses.Length / 8); var b = cashCow.PrepareBatch(); Random r = new Random(); List> sending = new List>(); foreach (var a in addresses) { sending.Add(b.SendToAddressAsync((await a).Address, Money.Coins(0.1m) + Money.Satoshis(r.Next(0, 90_000_000)))); } await b.SendBatchAsync(); await cashCow.GenerateAsync(1); var factory = ServiceProvider.GetRequiredService(); // Wait it sync... await Task.Delay(1000); await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).WaitServerStartedAsync(); await Task.Delay(1000); await using var conn = await factory.OpenConnection(); var txIds = sending.Select(s => s.Result.ToString()).ToArray(); await conn.ExecuteAsync( "UPDATE txs t SET seen_at=(NOW() - (random() * (interval '90 days'))) " + "FROM unnest(@txIds) AS r (tx_id) WHERE r.tx_id=t.tx_id;", new { txIds }); await Task.Delay(1000); await conn.ExecuteAsync("REFRESH MATERIALIZED VIEW wallets_history;"); } [HttpGet("{walletId}/send")] [Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletSend( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string? defaultDestination = null, string? defaultAmount = null, string[]? bip21 = null, [FromQuery] string? returnUrl = null) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var network = this.NetworkProvider.GetNetwork(walletId.CryptoCode); if (network == null || network.ReadonlyWallet) return NotFound(); double.TryParse(defaultAmount, out var amount); var model = new WalletSendModel { CryptoCode = walletId.CryptoCode, ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath, IsMultiSigOnServer = paymentMethod.IsMultiSigOnServer, AlwaysIncludeNonWitnessUTXO = paymentMethod.DefaultIncludeNonWitnessUtxo }; if (bip21?.Any() is true) { var messagePresent = TempData.HasStatusMessage(); foreach (var link in bip21) { if (!string.IsNullOrEmpty(link)) { await LoadFromBIP21(walletId, model, link, network, messagePresent); } } } if (!(model.Outputs?.Any() is true)) { model.Outputs = new List() { new WalletSendModel.TransactionOutput() { Amount = Convert.ToDecimal(amount), DestinationAddress = defaultDestination } }; } var recommendedFeesAsync = GetRecommendedFees(network, feeRateProvider); var balance = walletProvider.GetWallet(network).GetBalance(paymentMethod.AccountDerivation); model.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId); var Balance = await balance; model.CurrentBalance = (Balance.Available ?? Balance.Total).GetValue(network); if (Balance.Immature is null) model.ImmatureBalance = 0; else model.ImmatureBalance = Balance.Immature.GetValue(network); var recommendedFees = await recommendedFeesAsync; model.RecommendedSatoshiPerByte = recommendedFees.Where(option => option != null).ToList(); model.FeeSatoshiPerByte = recommendedFees.Skip(1).FirstOrDefault()?.FeeRate; model.CryptoDivisibility = network.Divisibility; try { var r = await FetchRate(walletId); model.Rate = r.Rate; model.FiatDivisibility = currencyTable.GetNumberFormatInfo(r.Fiat, true) .CurrencyDecimalDigits; model.Fiat = r.Fiat; } catch (Exception ex) { model.RateError = ex.Message; } return View(model); } private async Task CanAutoSignWithHotWallet(WalletId walletId) => (await hotwalletSafe.TryUnlock(User, walletId))?.CanSign is true; public record FiatRate(decimal Rate, string Fiat); private async Task FetchRate(WalletId walletId) { var store = await Repository.FindStore(walletId.StoreId); if (store is null) throw new Exception("Store not found"); var storeData = store.GetStoreBlob(); var rateRules = storeData.GetRateRules(defaultRules); storeData.Spread = 0.0m; var currencyPair = new CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency); using CancellationTokenSource cts = new(); cts.CancelAfter(TimeSpan.FromSeconds(5)); var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(store.Id), cts.Token) .WithCancellation(cts.Token); if (result.BidAsk == null) { throw new Exception( $"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType().ToArray())})"); } return new (result.BidAsk.Center, currencyPair.Right); } private static async Task GetRecommendedFees(BTCPayNetwork network, IFeeProviderFactory feeProviderFactory) { var feeProvider = feeProviderFactory.CreateFeeProvider(network); List options = new(); foreach (var time in new[] { TimeSpan.FromMinutes(10.0), TimeSpan.FromMinutes(60.0), TimeSpan.FromHours(6.0), TimeSpan.FromHours(24.0), }) { try { var result = await feeProvider.GetFeeRateAsync((int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); options.Add(new WalletSendModel.FeeRateOption() { Target = time, FeeRate = result.SatoshiPerByte }); } catch (Exception) { options.Add(null); } } return options.ToArray(); } [HttpPost("{walletId}/send")] [Authorize(Policy = WalletPolicies.CanCreateWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletSend( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default, string? bip21 = "") { var required = command switch { "sign" => WalletPolicies.CanSignWalletTransactions, "analyze-psbt" => WalletPolicies.CanSignWalletTransactions, "schedule" => Policies.CanManagePayouts, _ => null }; if (required is not null && !(await authorizationService.AuthorizeAsync(User, walletId.StoreId, required)).Succeeded) return Forbid(); var store = await Repository.FindStore(walletId.StoreId); if (store == null) return NotFound(); var network = NetworkProvider.GetNetwork(walletId.CryptoCode); if (network == null || network.ReadonlyWallet) return NotFound(); vm.NBXSeedAvailable = await CanAutoSignWithHotWallet(walletId); if (!string.IsNullOrEmpty(bip21)) { vm.Outputs?.Clear(); await LoadFromBIP21(walletId, vm, bip21, network, TempData.HasStatusMessage()); } decimal transactionAmountSum = 0; if (command == "toggle-input-selection") { vm.InputSelection = !vm.InputSelection; } if (vm.InputSelection) { var schemeSettings = GetDerivationSchemeSettings(walletId); if (schemeSettings is null) return NotFound(); var utxos = await walletProvider.GetWallet(network) .GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation); var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(vm.CryptoCode); var walletTransactionsInfoAsync = await this.WalletRepository.GetWalletTransactionsInfo(walletId, utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray()); vm.InputsAvailable = utxos.Select(coin => { walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info1); walletTransactionsInfoAsync.TryGetValue(coin.Address.ToString(), out var info2); walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.ToString(), out var info3); var info = WalletRepository.Merge(info1, info2, info3); return new WalletSendModel.InputSelectionOption() { Outpoint = coin.OutPoint.ToString(), Amount = coin.Value.GetValue(network), Comment = info?.Comment, Labels = labelService.CreateTransactionTagModels(info, Request), Link = transactionLinkProviders.GetTransactionLink(pmi, coin.OutPoint.ToString()), Confirmations = coin.Confirmations, Timestamp = coin.Timestamp }; }).ToArray(); } if (command == "toggle-input-selection") { ModelState.Clear(); return View(vm); } vm.Outputs ??= new(); if (!string.IsNullOrEmpty(bip21)) { if (!vm.Outputs.Any()) { vm.Outputs.Add(new WalletSendModel.TransactionOutput()); } return View(vm); } if (command == "add-output") { ModelState.Clear(); vm.Outputs.Add(new WalletSendModel.TransactionOutput()); return View(vm); } if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase)) { ModelState.Clear(); var index = int.Parse( command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture); vm.Outputs.RemoveAt(index); return View(vm); } if (!vm.Outputs.Any()) { ModelState.AddModelError(string.Empty, "Please add at least one transaction output"); return View(vm); } var bypassBalanceChecks = command == "schedule"; var subtractFeesOutputsCount = new List(); var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput); for (var i = 0; i < vm.Outputs.Count; i++) { var transactionOutput = vm.Outputs[i]; if (transactionOutput.SubtractFeesFromOutput) { subtractFeesOutputsCount.Add(i); } transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty; var inputName = string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].", i.ToString(CultureInfo.InvariantCulture)) + nameof(transactionOutput.DestinationAddress); try { var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork); if (address is TaprootAddress) { var supportTaproot = dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities ?.CanSupportTaproot; if (!(supportTaproot is true)) { ModelState.AddModelError(inputName, "You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address."); } } } catch { ModelState.AddModelError(inputName, "Invalid address"); } if (!bypassBalanceChecks && transactionOutput.Amount.HasValue) { transactionAmountSum += transactionOutput.Amount.Value; if (vm.CurrentBalance == transactionOutput.Amount.Value && !transactionOutput.SubtractFeesFromOutput) vm.AddModelError(model => model.Outputs[i].SubtractFeesFromOutput, "You are sending your entire balance to the same destination, you should subtract the fees", this); } } if (!bypassBalanceChecks) { if (subtractFeesOutputsCount.Count > 1) { foreach (var subtractFeesOutput in subtractFeesOutputsCount) { vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput, "You can only subtract fees from one output", this); } } else if (vm.CurrentBalance == transactionAmountSum && !substractFees) { ModelState.AddModelError(string.Empty, "You are sending your entire balance, you should subtract the fees from an output"); } if (vm.CurrentBalance < transactionAmountSum) { for (var i = 0; i < vm.Outputs.Count; i++) { vm.AddModelError(model => model.Outputs[i].Amount, "You are sending more than what you own", this); } } if (vm.FeeSatoshiPerByte is decimal fee) { if (fee < 0) { vm.AddModelError(model => model.FeeSatoshiPerByte, "The fee rate should be above 0", this); } } } if (!ModelState.IsValid) return View(vm); var canManageWalletTransactions = (await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanManageWalletTransactions)).Succeeded; if (canManageWalletTransactions) { foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true)) { var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray(); var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress); var obj = await WalletRepository.GetWalletObject(walletObjectAddress); if (obj is null) { await WalletRepository.EnsureWalletObject(walletObjectAddress); } await WalletRepository.AddWalletObjectLabels(walletObjectAddress, labels); } } var derivationScheme = GetDerivationSchemeSettings(walletId); if (derivationScheme is null) return NotFound(); CreatePSBTResponse psbtResponse; if (command == "schedule") { var pmi = PayoutTypes.CHAIN.GetPayoutMethodId(walletId.CryptoCode); var claims = vm.Outputs.Where(output => string.IsNullOrEmpty(output.PayoutId)).Select(output => new ClaimRequest() { Destination = new AddressClaimDestination( BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)), ClaimedAmount = output.Amount, PayoutMethodId = pmi, StoreId = walletId.StoreId, PreApprove = true, }).ToArray(); var someFailed = false; string? message = null; string? errorMessage = null; var result = new Dictionary(); foreach (ClaimRequest claimRequest in claims) { var response = await pullPaymentHostedService.Claim(claimRequest); result.Add(claimRequest, response.Result); if (response.Result == ClaimRequest.ClaimResult.Ok) { if (message is null) { message = "Payouts scheduled:
"; } message += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination}
"; } else { someFailed = true; if (errorMessage is null) { errorMessage = "Payouts failed to be scheduled:
"; } switch (response.Result) { case ClaimRequest.ClaimResult.Duplicate: errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination} - address reuse
"; break; case ClaimRequest.ClaimResult.AmountTooLow: errorMessage += $"{claimRequest.ClaimedAmount} to {claimRequest.Destination} - amount too low
"; break; } } } if (message is not null && errorMessage is not null) { message += $"

{errorMessage}"; } else if (message is null && errorMessage is not null) { message = errorMessage; } TempData.SetStatusMessageModel(new StatusMessageModel() { Severity = someFailed ? StatusMessageModel.StatusSeverity.Warning : StatusMessageModel.StatusSeverity.Success, Html = message }); return RedirectToAction("Payouts", "UIStorePullPayments", new { area = "", storeId = walletId.StoreId, PaymentMethodId = pmi.ToString(), payoutState = PayoutState.AwaitingPayment, }); } try { psbtResponse = await CreatePSBT(walletId.StoreId, network, derivationScheme, vm, cancellation); } catch (NBXplorerException ex) { ModelState.AddModelError(string.Empty, ex.Error.Message); return View(vm); } catch (NotSupportedException) { ModelState.AddModelError(string.Empty, "You need to update your version of NBXplorer"); return View(vm); } var psbt = psbtResponse.PSBT; derivationScheme.RebaseKeyPaths(psbt); var signingContext = new SigningContextModel { PayJoinBIP21 = vm.PayJoinBIP21, EnforceLowR = psbtResponse.Suggestions?.ShouldEnforceLowR, ChangeAddress = psbtResponse.ChangeAddress?.ToString(), PSBT = psbt.ToHex(), Comment = canManageWalletTransactions ? vm.Comment : null }; if (!psbt.IsReadyToSign() && command == "sign") command = "analyze-psbt"; switch (command) { case "createpending": await pendingTransactionService.CreatePendingTransaction(walletId.StoreId, walletId.CryptoCode, psbt, Request.GetRequestBaseUrl()); return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); case "sign": return await WalletSign(walletId, new WalletPSBTViewModel { SigningContext = signingContext, ReturnUrl = vm.ReturnUrl, BackUrl = this.Url.WalletSend(walletId) }); case "analyze-psbt": var name = $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt"; return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name }); default: return View(vm); } } private async Task LoadFromBIP21(WalletId walletId, WalletSendModel vm, string bip21, BTCPayNetwork network, bool statusMessagePresent) { BitcoinAddress? address = null; vm.Outputs ??= new(); try { var uriBuilder = new NBitcoin.Payment.BitcoinUrlBuilder(bip21, network.NBitcoinNetwork); var output = new WalletSendModel.TransactionOutput { Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC), DestinationAddress = uriBuilder.Address?.ToString(), SubtractFeesFromOutput = false, PayoutId = uriBuilder.UnknownParameters.ContainsKey("payout") ? uriBuilder.UnknownParameters["payout"] : null }; if (!string.IsNullOrEmpty(uriBuilder.Label)) { output.Labels = output.Labels.Append(uriBuilder.Label).ToArray(); } vm.Outputs.Add(output); address = uriBuilder.Address; // only set SetStatusMessageModel if there is not message already or there is label / message in uri builder if (!statusMessagePresent) { if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message)) { var html = (HtmlEncoder.Default.Encode(uriBuilder.Label ?? ""), HtmlEncoder.Default.Encode(uriBuilder.Message ?? "")) switch { ({ Length: > 0 } label, { Length: > 0 } message) => StringLocalizer["Payment to {0} for {1}",label, message], ({ Length: > 0 } label, _) => StringLocalizer["Payment to {0}", label], (_, { Length: > 0 } message) => StringLocalizer["Payment for {0}", message], _ => null }; if (html != null) TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Info, Html = html }); } } if (uriBuilder.TryGetPayjoinEndpoint(out _)) vm.PayJoinBIP21 = uriBuilder.ToString(); } catch { try { address = BitcoinAddress.Create(bip21, network.NBitcoinNetwork); vm.Outputs.Add(new WalletSendModel.TransactionOutput { DestinationAddress = address.ToString() }); } catch { TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Error, Message = StringLocalizer["The provided BIP21 payment URI was malformed"].Value }); } } ModelState.Clear(); if (address is not null) { var addressLabels = await WalletRepository.GetWalletLabels(new WalletObjectId(walletId, WalletObjectData.Types.Address, address.ToString())); vm.Outputs.Last().Labels = vm.Outputs.Last().Labels.Concat(addressLabels.Select(tuple => tuple.Label)).ToArray(); } } private IActionResult ViewVault(WalletId walletId, WalletPSBTViewModel vm) { return View(nameof(WalletSendVault), new WalletSendVaultModel { SigningContext = vm.SigningContext, WalletId = walletId.ToString(), ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); } [HttpPost("{walletId}/vault")] [Authorize(Policy = WalletPolicies.CanSignWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletSendVaultModel model) { TempData.SetStatusSuccess(StringLocalizer["Transaction successfully signed"].Value); return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel { SigningContext = model.SigningContext, ReturnUrl = model.ReturnUrl, BackUrl = model.BackUrl }); } private async Task RedirectToWalletPSBTReady(WalletId walletId, WalletPSBTReadyViewModel vm) { if (vm.SigningContext.PendingTransactionId is not null) { if (!(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded) return Forbid(); var network = NetworkProvider.GetNetwork(walletId.CryptoCode)?.NBitcoinNetwork; if (network is null) return NotFound(); var psbt = PSBT.Parse(vm.SigningContext.PSBT, network); var pendingTransaction = await pendingTransactionService.CollectSignature( GetPendingTxId(walletId, vm.SigningContext.PendingTransactionId), psbt, CancellationToken.None, GetUserId()); if (pendingTransaction != null) return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } else if (!(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanSignWalletTransactions)).Succeeded) { return Forbid(); } var redirectVm = new PostRedirectViewModel { AspController = "UIWallets", AspAction = nameof(WalletPSBTReady), RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } }, FormParameters = { { "SigningKey", vm.SigningKey }, { "UseHotWallet", vm.UseHotWallet.ToString() }, { "SigningKeyPath", vm.SigningKeyPath }, { "command", "decode" } } }; AddSigningContext(redirectVm, vm.SigningContext); if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT) && !string.IsNullOrEmpty(vm.SigningContext.PSBT)) { //if a hw device signed a payjoin, we want it broadcast instantly redirectVm.FormParameters.Remove("command"); redirectVm.FormParameters.Add("command", "broadcast"); } if (vm.ReturnUrl != null) { redirectVm.FormParameters.Add("returnUrl", vm.ReturnUrl); } if (vm.BackUrl != null) { redirectVm.FormParameters.Add("backUrl", vm.BackUrl); } return View("PostRedirect", redirectVm); } private void AddSigningContext(PostRedirectViewModel redirectVm, SigningContextModel signingContext) { if (signingContext is null) return; redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT); redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT); redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21); redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); redirectVm.FormParameters.Add("SigningContext.PendingTransactionId", signingContext.PendingTransactionId); redirectVm.FormParameters.Add("SigningContext.BalanceChangeFromReplacement", signingContext.BalanceChangeFromReplacement.ToString()); redirectVm.FormParameters.Add("SigningContext.Comment", signingContext.Comment); } private IActionResult RedirectToWalletPSBT(WalletPSBTViewModel vm) { var redirectVm = new PostRedirectViewModel { AspController = "UIWallets", AspAction = nameof(WalletPSBT), RouteParameters = { { "walletId", RouteData.Values["walletId"]?.ToString() } }, FormParameters = { { "psbt", vm.PSBT }, { "fileName", vm.FileName }, { "backUrl", vm.BackUrl }, { "returnUrl", vm.ReturnUrl }, { "command", "decode" } } }; return View("PostRedirect", redirectVm); } [HttpGet("{walletId}/psbt/seed")] [Authorize(Policy = WalletPolicies.CanSignWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, SigningContextModel signingContext, string? returnUrl, string? backUrl) { return View(nameof(SignWithSeed), new SignWithSeedViewModel { SigningContext = signingContext, ReturnUrl = returnUrl, BackUrl = backUrl }); } [HttpPost("{walletId}/psbt/seed")] [Authorize(Policy = WalletPolicies.CanSignWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, SignWithSeedViewModel viewModel) { if (!ModelState.IsValid) { return View("SignWithSeed", viewModel); } var network = NetworkProvider.GetNetwork(walletId.CryptoCode); if (network == null) throw new FormatException("Invalid value for crypto code"); var extKey = await GetExtKey(walletId, viewModel, network); if (extKey is null) { ModelState.AddModelError(nameof(viewModel.SeedOrKey), "Seed or Key was not in a valid format. It is either the 12/24 words or starts with xprv"); } var psbt = PSBT.Parse(viewModel.SigningContext.PSBT, network.NBitcoinNetwork); if (!psbt.IsReadyToSign(out var errors)) { ModelState.AddModelError(nameof(viewModel.SigningContext.PSBT), BuildErrorMessage(errors)); } if (!ModelState.IsValid) { return View("SignWithSeed", viewModel); } // It will never throw, this make nullable check below happy ArgumentNullException.ThrowIfNull(extKey); ExtKey? signingKey = null; var settings = GetDerivationSchemeSettings(walletId); if (settings is null) return NotFound(); var signingKeySettings = settings.GetAccountKeySettingsFromRoot(extKey); if (signingKeySettings is null) { // Let's try best effort if RootFingerprint isn't configured, but AccountKeyPath is signingKeySettings = settings.AccountKeySettings .FirstOrDefault(a => a.RootFingerprint is null && a.AccountKeyPath is not null); if (signingKeySettings is not null) signingKeySettings.RootFingerprint = extKey.GetPublicKey().GetHDFingerPrint(); } RootedKeyPath? rootedKeyPath = signingKeySettings?.GetRootedKeyPath(); if (rootedKeyPath is null || signingKeySettings is null) { ModelState.AddModelError(nameof(viewModel.SeedOrKey), "The master fingerprint and/or account key path of your seed are not set in the wallet settings."); return View(nameof(SignWithSeed), viewModel); } // The user gave the root key, let's try to rebase the PSBT, and derive the account private key psbt.RebaseKeyPaths(signingKeySettings.AccountKey, rootedKeyPath); signingKey = extKey.Derive(rootedKeyPath.KeyPath); psbt.Settings.SigningOptions = new SigningOptions() { EnforceLowR = !(viewModel.SigningContext?.EnforceLowR is false) }; var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); if (!changed) { var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation }; update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList(); psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT; changed = psbt is not null && psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); if (!changed) { ModelState.AddModelError(nameof(viewModel.SeedOrKey), "Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed."); return View(nameof(SignWithSeed), viewModel); } } ModelState.Remove(nameof(viewModel.SigningContext.PSBT)); viewModel.SigningContext ??= new(); viewModel.SigningContext.PSBT = psbt?.ToBase64(); return await RedirectToWalletPSBTReady(walletId, new WalletPSBTReadyViewModel { SigningKey = viewModel.UseHotWallet ? null : signingKey.GetWif(network.NBitcoinNetwork).ToString(), UseHotWallet = viewModel.UseHotWallet, SigningKeyPath = rootedKeyPath?.ToString(), SigningContext = viewModel.SigningContext, ReturnUrl = viewModel.ReturnUrl, BackUrl = viewModel.BackUrl }); } private async Task GetExtKey(WalletId walletId, SignWithSeedViewModel viewModel, BTCPayNetwork network) { return viewModel.UseHotWallet && await hotwalletSafe.TryUnlock(User, walletId) is { CanSign: true, ExtKey: var k } ? k : viewModel.GetExtKey(network.NBitcoinNetwork); } private async Task GetSigningExtKey(WalletId walletId, WalletPSBTReadyViewModel viewModel, BTCPayNetwork network) { if (viewModel.UseHotWallet) { var key = await hotwalletSafe.TryUnlock(User, walletId) is { CanSign: true, ExtKey: var k } ? k : null; if (!RootedKeyPath.TryParse(viewModel.SigningKeyPath ?? "", out var pp)) return null; return key?.Derive(pp.KeyPath); } else { if (string.IsNullOrWhiteSpace(viewModel.SigningKey)) return null; return ExtKey.Parse(viewModel.SigningKey, network.NBitcoinNetwork); } } private static string BuildErrorMessage(PSBTError[] errors) { StringBuilder errorMessage = new(); errorMessage.Append("PSBT is not ready to be signed."); if (errors.Length == 1) { errorMessage.Append($" ({errors[0]})"); } else { errorMessage.AppendLine(); foreach (var error in errors.Take(5)) { errorMessage.AppendLine(error.ToString()); } } if (errors.Length > 5) errorMessage.Append($"{errors.Length - 5} more errors..."); return errorMessage.ToString(); } private WalletPSBTReadyViewModel.StringAmounts ValueToString(Money v, BTCPayNetworkBase network, FiatRate? rate) => new( CryptoAmount : displayFormatter.Currency(v.ToDecimal(MoneyUnit.BTC), network.CryptoCode), FiatAmount : rate is null ? null : displayFormatter.Currency(rate.Rate * v.ToDecimal(MoneyUnit.BTC), rate.Fiat) ); [HttpGet("{walletId}/rescan")] [Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletRescan( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) { if (walletId?.StoreId == null) return NotFound(); var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var vm = new RescanWalletModel(); vm.IsFullySync = dashboard.IsFullySynched(walletId.CryptoCode, out var unused); vm.IsServerAdmin = (await authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)) .Succeeded; vm.IsSupportedByCurrency = dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true; var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation); if (scanProgress != null) { vm.PreviousError = scanProgress.Error; if (scanProgress.Status == ScanUTXOStatus.Queued || scanProgress.Status == ScanUTXOStatus.Pending) { if (scanProgress.Progress == null) { vm.Progress = 0; } else { vm.Progress = scanProgress.Progress.OverallProgress; vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint(); } } if (scanProgress.Status == ScanUTXOStatus.Complete) { vm.LastSuccess = scanProgress.Progress; vm.TimeOfScan = (scanProgress.Progress!.CompletedAt!.Value - scanProgress.Progress.StartedAt) .PrettyPrint(); } } return View(vm); } [HttpPost("{walletId}/rescan")] [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletRescan( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, RescanWalletModel vm) { if (!(await authorizationService.AuthorizeAsync(User, walletId.StoreId, WalletPolicies.CanManageWalletSettings)).Succeeded) return Forbid(); if (walletId?.StoreId == null) return NotFound(); var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); try { await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit, vm.StartingIndex); walletProvider.GetWallet(walletId.CryptoCode).InvalidateCache(paymentMethod.AccountDerivation); } catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress") { } return RedirectToAction(); } internal DerivationSchemeSettings? GetDerivationSchemeSettings(WalletId walletId) => GetCurrentStore().Id != walletId?.StoreId ? null : GetDerivationSchemeSettings(walletId.CryptoCode); internal DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode) => GetCurrentStore().GetDerivationSchemeSettings(handlers, cryptoCode); private static async Task GetBalanceAsMoney(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy) { using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { var b = await wallet.GetBalance(derivationStrategy, cts.Token); return b.Available ?? b.Total; } catch { return Money.Zero; } } internal async Task GetBalanceString(BTCPayWallet wallet, DerivationStrategyBase? derivationStrategy) { if (derivationStrategy is null) return "--"; try { return (await GetBalanceAsMoney(wallet, derivationStrategy)).ShowMoney(wallet.Network); } catch { return "--"; } } [HttpPost("{walletId}/actions")] public async Task WalletActions( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string command, string[] selectedTransactions, CancellationToken cancellationToken = default) { if (command is not ("cpfp" or "prune" or "clear")) return NotFound(); var required = command switch { "cpfp" => WalletPolicies.CanCreateWalletTransactions, "prune" => WalletPolicies.CanManageWalletSettings, "clear" => Policies.CanModifyServerSettings, _ => null }; if (required is null || !(await authorizationService.AuthorizeAsync(User, walletId.StoreId, required)).Succeeded) return Forbid(); var derivationScheme = GetDerivationSchemeSettings(walletId); var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network; if (derivationScheme == null || network.ReadonlyWallet) return NotFound(); switch (command) { case "cpfp": { selectedTransactions ??= Array.Empty(); if (selectedTransactions.Length == 0) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["No transaction selected"].Value; return RedirectToAction(nameof(WalletTransactions), new { walletId }); } var parameters = new MultiValueDictionary(); parameters.Add("walletId", walletId.ToString()); int i = 0; foreach (var tx in selectedTransactions) { parameters.Add($"transactionHashes[{i}]", tx); i++; } return View("PostRedirect", new PostRedirectViewModel { AspController = "UIWallets", AspAction = nameof(WalletBumpFee), RouteParameters = { { "walletId", walletId.ToString() } }, FormParameters = parameters }); } case "prune": { var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) .PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken); if (result.TotalPruned == 0) { TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The wallet is already pruned"].Value; } else { TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The wallet has been successfully pruned ({0} transactions have been removed from the history)", result.TotalPruned].Value; } return RedirectToAction(nameof(WalletTransactions), new { walletId }); } case "clear": { if (Version.TryParse(dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0", out var v) && v < new Version(2, 2, 4)) { TempData[WellKnownTempData.ErrorMessage] = "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above"; } else { await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) .WipeAsync(derivationScheme.AccountDerivation, cancellationToken); TempData[WellKnownTempData.SuccessMessage] = "The transactions have been wiped out, to restore your balance, rescan the wallet."; } return RedirectToAction(nameof(WalletTransactions), new { walletId }); } default: return NotFound(); } } [HttpGet("{walletId}/export")] public async Task Export( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string format, string? searchTerm = null, string? searchText = null, CancellationToken cancellationToken = default) { var paymentMethod = GetDerivationSchemeSettings(walletId); if (paymentMethod == null) return NotFound(); var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network; var wallet = walletProvider.GetWallet(network); var fs = SearchString.Combine([searchTerm, searchText]); var filter = BuildWalletTransactionsFilter(fs); var requiresMetadataFiltering = RequiresWalletTransactionMetadataFiltering(filter); var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken); if (filter.HasFilters) { input = input .Where(tx => MatchesWalletTransactionBasicFilter(tx, filter, network)) .ToList(); } var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, input.Select(tx => tx.TransactionId.ToString()).ToArray()); if (requiresMetadataFiltering) { input = input .Where(tx => { walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var info); var tags = info is null ? [] : labelService.CreateTransactionTagModels(info, Request).ToArray(); var labels = info?.LabelColors.Keys.ToArray() ?? Array.Empty(); return MatchesWalletTransactionFilter(tx, info, labels, tags, filter, network); }) .ToList(); } var export = new TransactionsExport(wallet, walletTransactionsInfo); var res = export.Process(input, format); var fileType = format switch { "csv" => "csv", "json" => "json", "bip329" => "jsonl", _ => throw new ArgumentOutOfRangeException(nameof(format), format, null) }; var mimeType = format switch { "csv" => "text/csv", "json" => "application/json", "bip329" => "application/jsonl", // Ongoing discussion: https://github.com/wardi/jsonlines/issues/19 _ => throw new ArgumentOutOfRangeException(nameof(format), format, null) }; var cd = new ContentDisposition { FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{fileType}", Inline = true }; Response.Headers.Add("Content-Disposition", cd.ToString()); Response.Headers.Add("X-Content-Type-Options", "nosniff"); return Content(res, mimeType); } public class UpdateLabelsRequest { public string? Id { get; set; } public string? Type { get; set; } public string[]? Labels { get; set; } } [HttpPost("{walletId}/update-labels")] [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task UpdateLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request) { if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null) return BadRequest(); var objid = new WalletObjectId(walletId, request.Type, request.Id); var obj = await WalletRepository.GetWalletObject(objid); if (obj is null) { await WalletRepository.EnsureWalletObject(objid); } else { var currentLabels = obj.GetNeighbours().Where(data => data.Type == WalletObjectData.Types.Label).ToArray(); var toRemove = currentLabels.Where(data => !request.Labels.Contains(data.Id)).Select(data => data.Id).ToArray(); await WalletRepository.RemoveWalletObjectLabels(objid, toRemove); } await WalletRepository.AddWalletObjectLabels(objid, request.Labels); return Ok(); } [HttpGet("{walletId}/labels.json")] [IgnoreAntiforgeryToken] public async Task LabelsJson( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes, string? type = null, string? id = null, string? linkedType = null) { var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id) ? new WalletObjectId(walletId, type, id) : null; var labels = walletObjectId != null ? await WalletRepository.GetWalletLabels(walletObjectId) : !string.IsNullOrEmpty(linkedType) ? await WalletRepository.GetWalletLabelsByLinkedType(walletId, linkedType) : await WalletRepository.GetWalletLabels(walletId); return Ok(labels .Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label)) .Select(tuple => new WalletLabelModel { Label = tuple.Label, Color = tuple.Color, TextColor = ColorPalette.Default.TextColor(tuple.Color) })); } [HttpGet("{walletId}/labels")] [Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task WalletLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) { var labels = await WalletRepository.GetWalletLabels(walletId); var vm = new WalletLabelsModel { WalletId = walletId, Labels = labels .Where(l => !WalletObjectData.Types.AllTypes.Contains(l.Label)) .Select(tuple => new WalletLabelModel { Label = tuple.Label, Color = tuple.Color, TextColor = ColorPalette.Default.TextColor(tuple.Color) }) }; return View(vm); } [HttpPost("{walletId}/labels/{id}/delete")] [Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task DeleteWalletLabel( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string id) { var labels = new[] { id }; if (await WalletRepository.RemoveWalletLabels(walletId, labels)) { TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully deleted."].Value; } else { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be deleted."].Value; } return RedirectToAction(nameof(WalletLabels), new { walletId }); } [HttpPost("{walletId}/labels/{id}/edit")] [Authorize(Policy = WalletPolicies.CanManageWalletSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task EditWalletLabel( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string id, string newLabel) { if (string.IsNullOrWhiteSpace(newLabel)) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Label name cannot be empty."].Value; return RedirectToAction(nameof(WalletLabels), new { walletId }); } newLabel = newLabel.Trim(); if (newLabel == id) { return RedirectToAction(nameof(WalletLabels), new { walletId }); } if (await WalletRepository.RenameWalletLabel(walletId, id, newLabel)) { TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["The label has been successfully renamed."].Value; } else { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The label could not be renamed."].Value; } return RedirectToAction(nameof(WalletLabels), new { walletId }); } const int MaxLabelImportSize = 1_000_000; [HttpPost("{walletId}/labels/import")] [Authorize(Policy = WalletPolicies.CanManageWalletTransactions, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task ImportWalletLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, IFormFile? file) { if (file is null || file.Length == 0) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["Please select a BIP-329 file to import."].Value; return RedirectToAction(nameof(WalletLabels), new { walletId }); } if (file.Length > MaxLabelImportSize) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["The import file is too big (1 MB maximum)."].Value; return RedirectToAction(nameof(WalletLabels), new { walletId }); } var network = handlers.GetBitcoinHandler(walletId.CryptoCode).Network; using var reader = new StreamReader(file.OpenReadStream()); var import = await Bip329Import.Parse(reader, network.NBitcoinNetwork); if (import.Labels.Count == 0) { TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["No labels could be imported from this file. Expected BIP-329 format: one JSON object per line."].Value; return RedirectToAction(nameof(WalletLabels), new { walletId }); } var reqs = import.Labels .GroupBy(l => (l.ObjectType, l.ObjectId)) .Select(g => (new WalletObjectId(walletId, g.Key.ObjectType, g.Key.ObjectId), g.Select(l => l.Label).ToArray())) .ToArray(); await WalletRepository.AddWalletObjectLabels(reqs); TempData[WellKnownTempData.SuccessMessage] = import.SkippedLines == 0 ? StringLocalizer["Imported {0} label(s).", import.Labels.Count].Value : StringLocalizer["Imported {0} label(s), skipped {1} line(s).", import.Labels.Count, import.SkippedLines].Value; return RedirectToAction(nameof(WalletLabels), new { walletId }); } private string? GetImage(BTCPayNetwork network) { var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); if (paymentModelExtensions.TryGetValue(pmi, out var extension)) { return Request.GetRelativePathOrAbsolute(Url.Content(extension.Image)); } return null; } private string? GetUserId() => User.GetIdOrNull(); private StoreData GetCurrentStore() => HttpContext.GetStoreData(); } public class WalletReceiveViewModel { public string? CryptoImage { get; set; } public string? CryptoCode { get; set; } public string? Address { get; set; } public string? PaymentLink { get; set; } public string? ReturnUrl { get; set; } public string[]? SelectedLabels { get; set; } } public class SendToAddressResult { [JsonProperty("psbt")] public string? PSBT { get; set; } } }