diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index b76a55924..1b55a543b 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1374,11 +1374,11 @@ namespace BTCPayServer.Tests // Can add a label? await TestUtils.EventuallyAsync(async () => { - s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).Click(); + s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).Click(); await Task.Delay(500); - s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("test-label" + Keys.Enter); + s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("test-label" + Keys.Enter); await Task.Delay(500); - s.Driver.WaitForElement(By.CssSelector("div.label-manager input ")).SendKeys("label2" + Keys.Enter); + s.Driver.WaitForElement(By.CssSelector("div.label-manager input")).SendKeys("label2" + Keys.Enter); }); TestUtils.Eventually(() => diff --git a/BTCPayServer/ColorPalette.cs b/BTCPayServer/ColorPalette.cs index 8e7e09ef0..2b15d35b8 100644 --- a/BTCPayServer/ColorPalette.cs +++ b/BTCPayServer/ColorPalette.cs @@ -19,7 +19,7 @@ namespace BTCPayServer var bg = ColorTranslator.FromHtml(bgColor); int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114)); Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White; - return ColorTranslator.ToHtml(color); + return ColorTranslator.ToHtml(color).ToLowerInvariant(); } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md public static readonly ColorPalette Default = new ColorPalette(new string[] { diff --git a/BTCPayServer/Components/LabelManager/Default.cshtml b/BTCPayServer/Components/LabelManager/Default.cshtml index 949ab6e34..79970cd90 100644 --- a/BTCPayServer/Components/LabelManager/Default.cshtml +++ b/BTCPayServer/Components/LabelManager/Default.cshtml @@ -1,104 +1,22 @@ @using NBitcoin.DataEncoders @using NBitcoin -@using BTCPayServer.Abstractions.TagHelpers @model BTCPayServer.Components.LabelManager.LabelViewModel -@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery @{ - var commonCall = Model.ObjectId.Type + Model.ObjectId.Id; var elementId = "a" + Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); + var fetchUrl = Url.Action("GetLabels", "UIWallets", new { + walletId = Model.WalletObjectId.WalletId, + excludeTypes = Safe.Json(Model.ExcludeTypes) + }); + var updateUrl = Model.AutoUpdate? Url.Action("UpdateLabels", "UIWallets", new { + walletId = Model.WalletObjectId.WalletId + }): string.Empty; } - - - - - - + diff --git a/BTCPayServer/Components/LabelManager/LabelManager.cs b/BTCPayServer/Components/LabelManager/LabelManager.cs index af0607ce2..fb6ad26d8 100644 --- a/BTCPayServer/Components/LabelManager/LabelManager.cs +++ b/BTCPayServer/Components/LabelManager/LabelManager.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using BTCPayServer.Services; using Microsoft.AspNetCore.Mvc; @@ -5,14 +7,25 @@ namespace BTCPayServer.Components.LabelManager { public class LabelManager : ViewComponent { - public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels) + public IViewComponentResult Invoke(WalletObjectId walletObjectId, string[] selectedLabels, bool excludeTypes = true, bool displayInline = false, Dictionary richLabelInfo = null, bool autoUpdate = true, string selectElement = null) { var vm = new LabelViewModel { - ObjectId = walletObjectId, - SelectedLabels = selectedLabels + ExcludeTypes = excludeTypes, + WalletObjectId = walletObjectId, + SelectedLabels = selectedLabels?? Array.Empty(), + DisplayInline = displayInline, + RichLabelInfo = richLabelInfo, + AutoUpdate = autoUpdate, + SelectElement = selectElement }; return View(vm); } } + + public class RichLabelInfo + { + public string Link { get; set; } + public string Tooltip { get; set; } + } } diff --git a/BTCPayServer/Components/LabelManager/LabelViewModel.cs b/BTCPayServer/Components/LabelManager/LabelViewModel.cs index 139af2d0f..d0b9bafd9 100644 --- a/BTCPayServer/Components/LabelManager/LabelViewModel.cs +++ b/BTCPayServer/Components/LabelManager/LabelViewModel.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using BTCPayServer.Services; namespace BTCPayServer.Components.LabelManager @@ -5,6 +6,11 @@ namespace BTCPayServer.Components.LabelManager public class LabelViewModel { public string[] SelectedLabels { get; set; } - public WalletObjectId ObjectId { get; set; } + public WalletObjectId WalletObjectId { get; set; } + public bool ExcludeTypes { get; set; } + public bool DisplayInline { get; set; } + public Dictionary RichLabelInfo { get; set; } + public bool AutoUpdate { get; set; } + public string SelectElement { get; set; } } } diff --git a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs index 728bab2a7..e54a1f0e8 100644 --- a/BTCPayServer/Controllers/UIWalletsController.PSBT.cs +++ b/BTCPayServer/Controllers/UIWalletsController.PSBT.cs @@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.BIP78.Sender; +using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; using BTCPayServer.Models; @@ -266,7 +267,7 @@ namespace BTCPayServer.Controllers ModelState.Remove(nameof(vm.PSBT)); ModelState.Remove(nameof(vm.FileName)); ModelState.Remove(nameof(vm.UploadedPSBTFile)); - await FetchTransactionDetails(derivationSchemeSettings, vm, network); + await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network); return View("WalletPSBTDecoded", vm); case "save-psbt": @@ -320,7 +321,7 @@ namespace BTCPayServer.Controllers return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token); } - private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network) + private async Task FetchTransactionDetails(WalletId walletId, DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network) { var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork); if (!psbtObject.IsAllFinalized()) @@ -371,17 +372,29 @@ namespace BTCPayServer.Controllers vm.Positive = balanceChange >= Money.Zero; } vm.Inputs = new List(); + var inputToObjects = new Dictionary(); + var outputToObjects = new Dictionary(); foreach (var input in psbtObject.Inputs) { var inputVm = new WalletPSBTReadyViewModel.InputViewModel(); vm.Inputs.Add(inputVm); + var txOut = input.GetTxOut(); var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any(); - var balanceChange2 = input.GetTxOut()?.Value ?? Money.Zero; + var balanceChange2 = txOut?.Value ?? Money.Zero; if (mine) balanceChange2 = -balanceChange2; inputVm.BalanceChange = ValueToString(balanceChange2, network); inputVm.Positive = balanceChange2 >= Money.Zero; inputVm.Index = (int)input.Index; + + var walletObjectIds = new List(); + walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Utxo, input.PrevOut.ToString())); + walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Tx, input.PrevOut.Hash.ToString())); + var address = txOut?.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString(); + if(address != null) + walletObjectIds.Add(new ObjectTypeId(WalletObjectData.Types.Address, address)); + inputToObjects.Add(input.Index, walletObjectIds.ToArray()); + } vm.Destinations = new List(); foreach (var output in psbtObject.Outputs) @@ -395,6 +408,10 @@ namespace BTCPayServer.Controllers dest.Balance = ValueToString(balanceChange2, network); dest.Positive = balanceChange2 >= Money.Zero; dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString(); + var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString(); + if(address != null) + outputToObjects.Add(dest.Destination, new ObjectTypeId(WalletObjectData.Types.Address, address)); + } if (psbtObject.TryGetFee(out var fee)) @@ -420,6 +437,38 @@ namespace BTCPayServer.Controllers { vm.SetErrors(errors); } + + var combinedTypeIds = inputToObjects.Values.SelectMany(ids => ids).Concat(outputToObjects.Values) + .DistinctBy(id => $"{id.Type}:{id.Id}").ToArray(); + + var labelInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, combinedTypeIds); + foreach (KeyValuePair inputToObject in inputToObjects) + { + var keys = inputToObject.Value.Select(id => id.Id).ToArray(); + WalletTransactionInfo ix = null; + foreach (var key in keys) + { + if (!labelInfo.TryGetValue(key, out var i)) continue; + if (ix is null) + { + ix = i; + } + else + { + ix.Merge(i); + } + } + if (ix is null) continue; + var input = vm.Inputs.First(model => model.Index == inputToObject.Key); + input.Labels = ix.LabelColors; + } + foreach (var outputToObject in outputToObjects) + { + if (!labelInfo.TryGetValue(outputToObject.Value.Id, out var ix)) continue; + var destination = vm.Destinations.First(model => model.Destination == outputToObject.Key); + destination.Labels = ix.LabelColors; + } + } [HttpPost("{walletId}/psbt/ready")] @@ -439,7 +488,7 @@ namespace BTCPayServer.Controllers if (derivationSchemeSettings == null) return NotFound(); - await FetchTransactionDetails(derivationSchemeSettings, vm, network); + await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network); switch (command) { @@ -570,7 +619,7 @@ namespace BTCPayServer.Controllers BackUrl = vm.BackUrl }); case "decode": - await FetchTransactionDetails(derivationSchemeSettings, vm, network); + await FetchTransactionDetails(walletId,derivationSchemeSettings, vm, network); return View("WalletPSBTDecoded", vm); default: vm.Errors.Add("Unknown command"); diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 76dc7fe9b..0c4d88756 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -235,8 +235,7 @@ namespace BTCPayServer.Controllers var model = new ListTransactionsViewModel { Skip = skip, Count = count }; model.Labels.AddRange( (await WalletRepository.GetWalletLabels(walletId)) - .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))) - ); + .Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color)))); if (labelFilter != null) { @@ -733,6 +732,18 @@ namespace BTCPayServer.Controllers if (!ModelState.IsValid) return View(vm); + 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.ToLowerInvariant()); + 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(); @@ -1324,18 +1335,21 @@ namespace BTCPayServer.Controllers public class UpdateLabelsRequest { - public string? Address { get; set; } + public string? Id { get; set; } + public string? Type { get; set; } public string[]? Labels { get; set; } } [HttpPost("{walletId}/update-labels")] [IgnoreAntiforgeryToken] - public async Task UpdateLabels([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, [FromBody] UpdateLabelsRequest request) + public async Task UpdateLabels( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + [FromBody] UpdateLabelsRequest request) { - if (string.IsNullOrEmpty(request.Address) || request.Labels is null) + if (string.IsNullOrEmpty(request.Type) || string.IsNullOrEmpty(request.Id) || request.Labels is null) return BadRequest(); - var objid = new WalletObjectId(walletId, WalletObjectData.Types.Address, request.Address); + var objid = new WalletObjectId(walletId, request.Type, request.Id); var obj = await WalletRepository.GetWalletObject(objid); if (obj is null) { @@ -1353,17 +1367,26 @@ namespace BTCPayServer.Controllers [HttpGet("{walletId}/labels")] [IgnoreAntiforgeryToken] - public async Task GetLabels( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, bool excludeTypes) + public async Task GetLabels( + [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + bool excludeTypes, + string? type = null, + string? id = null) { - - return Ok(( await WalletRepository.GetWalletLabels(walletId)) - .Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label)) - .Select(tuple => new - { - label = tuple.Label, - color = tuple.Color, - textColor = ColorPalette.Default.TextColor(tuple.Color) - })); + var walletObjectId = !string.IsNullOrEmpty(type) && !string.IsNullOrEmpty(id) + ? new WalletObjectId(walletId, type, id) + : null; + var labels = walletObjectId == null + ? await WalletRepository.GetWalletLabels(walletId) + : await WalletRepository.GetWalletLabels(walletObjectId); + return Ok(labels + .Where(l => !excludeTypes || !WalletObjectData.Types.AllTypes.Contains(l.Label)) + .Select(tuple => new + { + label = tuple.Label, + color = tuple.Color, + textColor = ColorPalette.Default.TextColor(tuple.Color) + })); } private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network) diff --git a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs index 7309f8100..b8cc16fc3 100644 --- a/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/ListTransactionsViewModel.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using BTCPayServer.Services.Labels; namespace BTCPayServer.Models.WalletViewModels { @@ -15,10 +14,10 @@ namespace BTCPayServer.Models.WalletViewModels public string Link { get; set; } public bool Positive { get; set; } public string Balance { get; set; } - public HashSet Tags { get; set; } = new HashSet(); + public HashSet Tags { get; set; } = new (); } - public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new HashSet<(string Text, string Color, string TextColor)>(); - public List Transactions { get; set; } = new List(); + public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new (); + public List Transactions { get; set; } = new (); public override int CurrentPageCount => Transactions.Count; public string CryptoCode { get; set; } } diff --git a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs index effb866ad..4807e07b2 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletPSBTReadyViewModel.cs @@ -16,6 +16,7 @@ namespace BTCPayServer.Models.WalletViewModels public bool Positive { get; set; } public string Destination { get; set; } public string Balance { get; set; } + public Dictionary Labels { get; set; } = new(); } public class InputViewModel @@ -24,6 +25,7 @@ namespace BTCPayServer.Models.WalletViewModels public string Error { get; set; } public bool Positive { get; set; } public string BalanceChange { get; set; } + public Dictionary Labels { get; set; } = new(); } public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error)); public string BalanceChange { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 6f8bc7d8b..c87fa4122 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -34,6 +34,8 @@ namespace BTCPayServer.Models.WalletViewModels public bool SubtractFeesFromOutput { get; set; } public string PayoutId { get; set; } + + public string[] Labels { get; set; } = Array.Empty(); } public decimal CurrentBalance { get; set; } public decimal ImmatureBalance { get; set; } diff --git a/BTCPayServer/Services/WalletRepository.cs b/BTCPayServer/Services/WalletRepository.cs index dbf95d9cf..17994b6fc 100644 --- a/BTCPayServer/Services/WalletRepository.cs +++ b/BTCPayServer/Services/WalletRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; @@ -309,21 +310,33 @@ namespace BTCPayServer.Services #nullable enable public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId) + { + return await GetWalletLabels(w => + w.WalletId == walletId.ToString() && + w.Type == WalletObjectData.Types.Label); + } + + public async Task<(string Label, string Color)[]> GetWalletLabels(WalletObjectId objectId) + { + return await GetWalletLabels(w => + w.WalletId == objectId.WalletId.ToString() && + w.Type == objectId.Type && + w.Id == objectId.Id); + } + + private async Task<(string Label, string Color)[]> GetWalletLabels(Expression> predicate) { await using var ctx = _ContextFactory.CreateContext(); - return (await - ctx.WalletObjects.AsNoTracking().Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label) - .ToArrayAsync()) - .Select(o => - { - if (o.Data is null) - { - return (o.Id, ColorPalette.Default.DeterministicColor(o.Id)); - } - return (o.Id, - JObject.Parse(o.Data)["color"]?.Value() ?? - ColorPalette.Default.DeterministicColor(o.Id)); - }).ToArray(); + return (await ctx.WalletObjects.AsNoTracking().Where(predicate).ToArrayAsync()) + .Select(FormatToLabel).ToArray(); + } + + private (string Label, string Color) FormatToLabel(WalletObjectData o) + { + return o.Data is null + ? (o.Id, ColorPalette.Default.DeterministicColor(o.Id)) + : (o.Id, + JObject.Parse(o.Data)["color"]?.Value() ?? ColorPalette.Default.DeterministicColor(o.Id)); } public async Task RemoveWalletObjects(WalletObjectId walletObjectId) diff --git a/BTCPayServer/Views/UIWallets/WalletReceive.cshtml b/BTCPayServer/Views/UIWallets/WalletReceive.cshtml index 5c1849481..3a9bdec81 100644 --- a/BTCPayServer/Views/UIWallets/WalletReceive.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletReceive.cshtml @@ -13,6 +13,8 @@ @section PageHeadContent { + + } @section Navbar { diff --git a/BTCPayServer/Views/UIWallets/WalletSend.cshtml b/BTCPayServer/Views/UIWallets/WalletSend.cshtml index 3e7dbb234..d7b3acfc9 100644 --- a/BTCPayServer/Views/UIWallets/WalletSend.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletSend.cshtml @@ -1,6 +1,10 @@ @inject BTCPayServer.Security.ContentSecurityPolicies csp @using Microsoft.AspNetCore.Mvc.ModelBinding @using BTCPayServer.Controllers +@using BTCPayServer.Services +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Components.LabelManager +@using BTCPayServer.Components.UIExtensionPoint @inject BTCPayServer.Security.ContentSecurityPolicies Csp @model WalletSendModel @{ @@ -35,6 +39,8 @@ border-bottom-right-radius: .2rem !important; } + + } @section PageFootContent @@ -115,6 +121,21 @@ } +
+ + + +
} else { @@ -158,6 +179,21 @@ +
+ + + +
} diff --git a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml index 870e5ce09..28449bb1a 100644 --- a/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletTransactions.cshtml @@ -9,6 +9,8 @@ } @section PageHeadContent { + +