diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index ae16f26c0..707aae83a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -727,7 +727,7 @@ namespace BTCPayServer.Tests [Fact] [Trait("Integration", "Integration")] - public void CanRescanWallet() + public async Task CanRescanWallet() { using (var tester = ServerTester.Create()) { @@ -789,6 +789,32 @@ namespace BTCPayServer.Tests transactions = Assert.IsType(Assert.IsType(walletController.WalletTransactions(walletId).Result).Model); var tx = Assert.Single(transactions.Transactions); Assert.Equal(tx.Id, txId.ToString()); + + // Hijack the test to see if we can add label and comments + Assert.IsType(await walletController.ModifyTransaction(walletId, tx.Id, addlabel: "test")); + Assert.IsType(await walletController.ModifyTransaction(walletId, tx.Id, addlabelclick: "test2")); + Assert.IsType(await walletController.ModifyTransaction(walletId, tx.Id, addcomment: "hello")); + + transactions = Assert.IsType(Assert.IsType(walletController.WalletTransactions(walletId).Result).Model); + tx = Assert.Single(transactions.Transactions); + + Assert.Equal("hello", tx.Comment); + Assert.Contains("test", tx.Labels.Select(l => l.Value)); + Assert.Contains("test2", tx.Labels.Select(l => l.Value)); + Assert.Equal(2, tx.Labels.GroupBy(l => l.Color).Count()); + + Assert.IsType(await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2")); + + transactions = Assert.IsType(Assert.IsType(walletController.WalletTransactions(walletId).Result).Model); + tx = Assert.Single(transactions.Transactions); + + Assert.Equal("hello", tx.Comment); + Assert.Contains("test", tx.Labels.Select(l => l.Value)); + Assert.DoesNotContain("test2", tx.Labels.Select(l => l.Value)); + Assert.Single(tx.Labels.GroupBy(l => l.Color)); + + var walletInfo = await tester.PayTester.GetService().GetWalletInfo(walletId); + Assert.Single(walletInfo.LabelColors); // the test2 color should have been removed } } diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 575c3bfba..f0daa867d 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -39,6 +39,7 @@ namespace BTCPayServer.Controllers public partial class WalletsController : Controller { public StoreRepository Repository { get; } + public WalletRepository WalletRepository { get; } public BTCPayNetworkProvider NetworkProvider { get; } public ExplorerClientProvider ExplorerClientProvider { get; } @@ -54,6 +55,7 @@ namespace BTCPayServer.Controllers CurrencyNameTable _currencyTable; public WalletsController(StoreRepository repo, + WalletRepository walletRepository, CurrencyNameTable currencyTable, BTCPayNetworkProvider networkProvider, UserManager userManager, @@ -66,6 +68,7 @@ namespace BTCPayServer.Controllers { _currencyTable = currencyTable; Repository = repo; + WalletRepository = walletRepository; RateFetcher = rateProvider; NetworkProvider = networkProvider; _userManager = userManager; @@ -76,6 +79,93 @@ namespace BTCPayServer.Controllers _walletProvider = walletProvider; } + // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md + string[] LabelColorScheme = new string[] + { + "#fbca04", + "#0e8a16", + "#ff7619", + "#84b6eb", + "#5319e7", + "#000000", + "#cc317c", + }; + [HttpPost] + [Route("{walletId}")] + 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; + DerivationSchemeSettings paymentMethod = await GetDerivationSchemeSettings(walletId); + if (paymentMethod == null) + return NotFound(); + + var walletBlobInfoAsync = WalletRepository.GetWalletInfo(walletId); + var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId); + var wallet = _walletProvider.GetWallet(paymentMethod.Network); + var walletBlobInfo = await walletBlobInfoAsync; + var walletTransactionsInfo = await walletTransactionsInfoAsync; + if (addlabel != null) + { + addlabel = addlabel.Trim().ToLowerInvariant(); + var labels = walletBlobInfo.GetLabels(); + if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo)) + { + walletTransactionInfo = new WalletTransactionInfo(); + } + if (!labels.Any(l => l.Value.Equals(addlabel, StringComparison.OrdinalIgnoreCase))) + { + List allColors = new List(); + allColors.AddRange(LabelColorScheme); + allColors.AddRange(labels.Select(l => l.Color)); + var chosenColor = + allColors + .GroupBy(k => k) + .OrderBy(k => k.Count()) + .ThenBy(k => Array.IndexOf(LabelColorScheme, k.Key)) + .First().Key; + walletBlobInfo.LabelColors.Add(addlabel, chosenColor); + await WalletRepository.SetWalletInfo(walletId, walletBlobInfo); + } + if (walletTransactionInfo.Labels.Add(addlabel)) + { + await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo); + } + } + else if (removelabel != null) + { + removelabel = removelabel.Trim().ToLowerInvariant(); + if (walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo)) + { + if (walletTransactionInfo.Labels.Remove(removelabel)) + { + var canDelete = !walletTransactionsInfo.SelectMany(txi => txi.Value.Labels).Any(l => l == removelabel); + if (canDelete) + { + walletBlobInfo.LabelColors.Remove(removelabel); + await WalletRepository.SetWalletInfo(walletId, walletBlobInfo); + } + await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo); + } + } + } + else if (addcomment != null) + { + addcomment = addcomment.Trim(); + if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo)) + { + walletTransactionInfo = new WalletTransactionInfo(); + } + walletTransactionInfo.Comment = addcomment; + await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo); + } + return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); + } + public async Task ListWallets() { var wallets = new ListWalletsViewModel(); @@ -125,10 +215,13 @@ namespace BTCPayServer.Controllers return NotFound(); var wallet = _walletProvider.GetWallet(paymentMethod.Network); + var walletBlobAsync = WalletRepository.GetWalletInfo(walletId); + var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId); var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation); - + var walletBlob = await walletBlobAsync; + var walletTransactionsInfo = await walletTransactionsInfoAsync; var model = new ListTransactionsViewModel(); - foreach (var tx in transactions.UnconfirmedTransactions.Transactions.Concat(transactions.ConfirmedTransactions.Transactions)) + foreach (var tx in transactions.UnconfirmedTransactions.Transactions.Concat(transactions.ConfirmedTransactions.Transactions).ToArray()) { var vm = new ListTransactionsViewModel.TransactionViewModel(); model.Transactions.Add(vm); @@ -138,11 +231,23 @@ namespace BTCPayServer.Controllers vm.Positive = tx.BalanceChange >= Money.Zero; vm.Balance = tx.BalanceChange.ToString(); vm.IsConfirmed = tx.Confirmations != 0; + + if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo)) + { + var labels = walletBlob.GetLabels(transactionInfo); + vm.Labels.AddRange(labels); + model.Labels.AddRange(labels); + vm.Comment = transactionInfo.Comment; + } } model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).ToList(); return View(model); } + private static string GetLabelTarget(WalletId walletId, uint256 txId) + { + return $"{walletId}:{txId}"; + } [HttpGet] [Route("{walletId}/send")] diff --git a/BTCPayServer/Data/ApplicationDbContext.cs b/BTCPayServer/Data/ApplicationDbContext.cs index 6bea11df1..f1fe43964 100644 --- a/BTCPayServer/Data/ApplicationDbContext.cs +++ b/BTCPayServer/Data/ApplicationDbContext.cs @@ -61,6 +61,9 @@ namespace BTCPayServer.Data get; set; } + public DbSet Wallets { get; set; } + public DbSet WalletTransactions { get; set; } + public DbSet Stores { get; set; @@ -231,6 +234,18 @@ namespace BTCPayServer.Data builder.Entity() .HasIndex(o => o.Status); + builder.Entity() + .HasKey(o => new + { + o.WalletDataId, +#pragma warning disable CS0618 + o.TransactionId +#pragma warning restore CS0618 + }); + builder.Entity() + .HasOne(o => o.WalletData) + .WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade); + builder.UseOpenIddict, BTCPayOpenIdToken, string>(); } diff --git a/BTCPayServer/Data/WalletData.cs b/BTCPayServer/Data/WalletData.cs new file mode 100644 index 000000000..a977b0542 --- /dev/null +++ b/BTCPayServer/Data/WalletData.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace BTCPayServer.Data +{ + public class WalletData + { + [System.ComponentModel.DataAnnotations.Key] + public string Id { get; set; } + + public List WalletTransactions { get; set; } + + public byte[] Blob { get; set; } + + public WalletBlobInfo GetBlobInfo() + { + if (Blob == null || Blob.Length == 0) + { + return new WalletBlobInfo(); + } + var blobInfo = JsonConvert.DeserializeObject(ZipUtils.Unzip(Blob)); + return blobInfo; + } + public void SetBlobInfo(WalletBlobInfo blobInfo) + { + if (blobInfo == null) + { + Blob = Array.Empty(); + return; + } + Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo)); + } + } + + public class Label + { + public Label(string value, string color) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + if (color == null) + throw new ArgumentNullException(nameof(color)); + Value = value; + Color = color; + } + + public string Value { get; } + public string Color { get; } + + public override bool Equals(object obj) + { + Label item = obj as Label; + if (item == null) + return false; + return Value.Equals(item.Value, StringComparison.OrdinalIgnoreCase); + } + public static bool operator ==(Label a, Label b) + { + if (System.Object.ReferenceEquals(a, b)) + return true; + if (((object)a == null) || ((object)b == null)) + return false; + return a.Value == b.Value; + } + + public static bool operator !=(Label a, Label b) + { + return !(a == b); + } + + public override int GetHashCode() + { + return Value.GetHashCode(StringComparison.OrdinalIgnoreCase); + } + } + + public class WalletBlobInfo + { + public Dictionary LabelColors { get; set; } = new Dictionary(); + + public IEnumerable