Refactor labels (#4179)

* Create new tables

* wip

* wip

* Refactor LegacyLabel

* Remove LabelFactory

* Add migration

* wip

* wip

* Add pull-payment attachment to tx

* Address kukks points
This commit is contained in:
Nicolas Dorier 2022-10-11 17:34:29 +09:00 committed by GitHub
parent 895462ac7f
commit a2fa688cde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1303 additions and 729 deletions

View file

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
[Obsolete]
public class LabelData
{
public string Type { get; set; }

View file

@ -13,7 +13,9 @@ namespace BTCPayServer.Client.Models
public uint256 TransactionHash { get; set; }
public string Comment { get; set; }
#pragma warning disable CS0612 // Type or member is obsolete
public Dictionary<string, LabelData> Labels { get; set; } = new Dictionary<string, LabelData>();
#pragma warning restore CS0612 // Type or member is obsolete
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }

View file

@ -15,7 +15,9 @@ namespace BTCPayServer.Client.Models
[JsonConverter(typeof(OutpointJsonConverter))]
public OutPoint Outpoint { get; set; }
public string Link { get; set; }
#pragma warning disable CS0612 // Type or member is obsolete
public Dictionary<string, LabelData> Labels { get; set; }
#pragma warning restore CS0612 // Type or member is obsolete
[JsonConverter(typeof(DateTimeToUnixTimeConverter))]
public DateTimeOffset Timestamp { get; set; }
[JsonConverter(typeof(KeyPathJsonConverter))]

View file

@ -59,7 +59,11 @@ namespace BTCPayServer.Data
public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<Fido2Credential> Fido2Credentials { get; set; }
public DbSet<UserStore> UserStore { get; set; }
[Obsolete]
public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletObjectData> WalletObjects { get; set; }
public DbSet<WalletObjectLinkData> WalletObjectLinks { get; set; }
[Obsolete]
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
public DbSet<WebhookDeliveryData> WebhookDeliveries { get; set; }
public DbSet<WebhookData> Webhooks { get; set; }
@ -109,7 +113,11 @@ namespace BTCPayServer.Data
Fido2Credential.OnModelCreating(builder);
BTCPayServer.Data.UserStore.OnModelCreating(builder);
//WalletData.OnModelCreating(builder);
WalletObjectData.OnModelCreating(builder, Database);
WalletObjectLinkData.OnModelCreating(builder, Database);
#pragma warning disable CS0612 // Type or member is obsolete
WalletTransactionData.OnModelCreating(builder);
#pragma warning restore CS0612 // Type or member is obsolete
WebhookDeliveryData.OnModelCreating(builder);
LightningAddressData.OnModelCreating(builder);
PayoutProcessorData.OnModelCreating(builder);

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer.Data
{
[Obsolete]
public class WalletData
{
[System.ComponentModel.DataAnnotations.Key]

View file

@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
public class WalletObjectData
{
public class Types
{
public const string Label = "label";
public const string Tx = "tx";
}
public string WalletId { get; set; }
public string Type { get; set; }
public string Id { get; set; }
public string Data { get; set; }
public List<WalletObjectLinkData> ChildLinks { get; set; }
public List<WalletObjectLinkData> ParentLinks { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<WalletObjectData>().HasKey(o =>
new
{
o.WalletId,
o.Type,
o.Id,
});
if (databaseFacade.IsNpgsql())
{
builder.Entity<WalletObjectData>()
.Property(o => o.Data)
.HasColumnType("JSONB");
}
}
}
}

View file

@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
public class WalletObjectLinkData
{
public string WalletId { get; set; }
public string ParentType { get; set; }
public string ParentId { get; set; }
public string ChildType { get; set; }
public string ChildId { get; set; }
public string Data { get; set; }
public WalletObjectData Parent { get; set; }
public WalletObjectData Child { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<WalletObjectLinkData>().HasKey(o =>
new
{
o.WalletId,
o.ParentType,
o.ParentId,
o.ChildType,
o.ChildId,
});
builder.Entity<WalletObjectLinkData>().HasIndex(o => new
{
o.WalletId,
o.ChildType,
o.ChildId,
});
builder.Entity<WalletObjectLinkData>()
.HasOne(o => o.Parent)
.WithMany(o => o.ChildLinks)
.HasForeignKey(o => new { o.WalletId, o.ParentType, o.ParentId })
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<WalletObjectLinkData>()
.HasOne(o => o.Child)
.WithMany(o => o.ParentLinks)
.HasForeignKey(o => new { o.WalletId, o.ChildType, o.ChildId })
.OnDelete(DeleteBehavior.Cascade);
if (databaseFacade.IsNpgsql())
{
builder.Entity<WalletObjectLinkData>()
.Property(o => o.Data)
.HasColumnType("JSONB");
}
}
}
}

View file

@ -1,7 +1,9 @@
using System;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
{
[Obsolete]
public class WalletTransactionData
{
public string WalletDataId { get; set; }

View file

@ -0,0 +1,77 @@
// <auto-generated />
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20220929132704_label")]
public partial class label : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "WalletObjects",
columns: table => new
{
WalletId = table.Column<string>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", nullable: false),
Id = table.Column<string>(type: "TEXT", nullable: false),
Data = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WalletObjects", x => new { x.WalletId, x.Type, x.Id });
});
migrationBuilder.CreateTable(
name: "WalletObjectLinks",
columns: table => new
{
WalletId = table.Column<string>(type: "TEXT", nullable: false),
ParentType = table.Column<string>(type: "TEXT", nullable: false),
ParentId = table.Column<string>(type: "TEXT", nullable: false),
ChildType = table.Column<string>(type: "TEXT", nullable: false),
ChildId = table.Column<string>(type: "TEXT", nullable: false),
Data = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WalletObjectLinks", x => new { x.WalletId, x.ParentType, x.ParentId, x.ChildType, x.ChildId });
table.ForeignKey(
name: "FK_WalletObjectLinks_WalletObjects_WalletId_ChildType_ChildId",
columns: x => new { x.WalletId, x.ChildType, x.ChildId },
principalTable: "WalletObjects",
principalColumns: new[] { "WalletId", "Type", "Id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_WalletObjectLinks_WalletObjects_WalletId_ParentType_ParentId",
columns: x => new { x.WalletId, x.ParentType, x.ParentId },
principalTable: "WalletObjects",
principalColumns: new[] { "WalletId", "Type", "Id" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_WalletObjectLinks_WalletId_ChildType_ChildId",
table: "WalletObjectLinks",
columns: new[] { "WalletId", "ChildType", "ChildId" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WalletObjectLinks");
migrationBuilder.DropTable(
name: "WalletObjects");
}
}
}

View file

@ -16,7 +16,7 @@ namespace BTCPayServer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.1");
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
{
@ -189,6 +189,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
@ -845,6 +846,52 @@ namespace BTCPayServer.Migrations
b.ToTable("Wallets");
});
modelBuilder.Entity("BTCPayServer.Data.WalletObjectData", b =>
{
b.Property<string>("WalletId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.HasColumnType("TEXT");
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Data")
.HasColumnType("TEXT");
b.HasKey("WalletId", "Type", "Id");
b.ToTable("WalletObjects");
});
modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b =>
{
b.Property<string>("WalletId")
.HasColumnType("TEXT");
b.Property<string>("ParentType")
.HasColumnType("TEXT");
b.Property<string>("ParentId")
.HasColumnType("TEXT");
b.Property<string>("ChildType")
.HasColumnType("TEXT");
b.Property<string>("ChildId")
.HasColumnType("TEXT");
b.Property<string>("Data")
.HasColumnType("TEXT");
b.HasKey("WalletId", "ParentType", "ParentId", "ChildType", "ChildId");
b.HasIndex("WalletId", "ChildType", "ChildId");
b.ToTable("WalletObjectLinks");
});
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.Property<string>("WalletDataId")
@ -1333,6 +1380,25 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b =>
{
b.HasOne("BTCPayServer.Data.WalletObjectData", "Child")
.WithMany("ParentLinks")
.HasForeignKey("WalletId", "ChildType", "ChildId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.WalletObjectData", "Parent")
.WithMany("ChildLinks")
.HasForeignKey("WalletId", "ParentType", "ParentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Child");
b.Navigation("Parent");
});
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.HasOne("BTCPayServer.Data.WalletData", "WalletData")
@ -1475,6 +1541,13 @@ namespace BTCPayServer.Migrations
b.Navigation("WalletTransactions");
});
modelBuilder.Entity("BTCPayServer.Data.WalletObjectData", b =>
{
b.Navigation("ChildLinks");
b.Navigation("ParentLinks");
});
modelBuilder.Entity("BTCPayServer.Data.WebhookData", b =>
{
b.Navigation("Deliveries");

View file

@ -483,93 +483,6 @@ namespace BTCPayServer.Tests
}
#endif
[Fact]
public void CanParseLegacyLabels()
{
static void AssertContainsRawLabel(WalletTransactionInfo info)
{
foreach (var item in new[] { "blah", "lol", "hello" })
{
Assert.True(info.Labels.ContainsKey(item));
var rawLabel = Assert.IsType<RawLabel>(info.Labels[item]);
Assert.Equal("raw", rawLabel.Type);
Assert.Equal(item, rawLabel.Text);
}
}
var data = new WalletTransactionData();
data.Labels = "blah,lol,hello,lol";
var info = data.GetBlobInfo();
Assert.Equal(3, info.Labels.Count);
AssertContainsRawLabel(info);
data.SetBlobInfo(info);
Assert.Contains("raw", data.Labels);
Assert.Contains("{", data.Labels);
Assert.Contains("[", data.Labels);
info = data.GetBlobInfo();
AssertContainsRawLabel(info);
data = new WalletTransactionData()
{
Labels = "pos",
Blob = Encoders.Hex.DecodeData("1f8b08000000000000037abf7b7fb592737e6e6e6a5e89929592522d000000ffff030036bc6ad911000000")
};
info = data.GetBlobInfo();
var label = Assert.Single(info.Labels);
Assert.Equal("raw", label.Value.Type);
Assert.Equal("pos", label.Value.Text);
Assert.Equal("pos", label.Key);
static void AssertContainsLabel(WalletTransactionInfo info)
{
Assert.Equal(2, info.Labels.Count);
var invoiceLabel = Assert.IsType<ReferenceLabel>(info.Labels["invoice"]);
Assert.Equal("BFm1MCJPBCDeRoWXvPcwnM", invoiceLabel.Reference);
Assert.Equal("invoice", invoiceLabel.Text);
Assert.Equal("invoice", invoiceLabel.Type);
var appLabel = Assert.IsType<ReferenceLabel>(info.Labels["app"]);
Assert.Equal("87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe", appLabel.Reference);
Assert.Equal("app", appLabel.Text);
Assert.Equal("app", appLabel.Type);
}
data = new WalletTransactionData()
{
Labels = "[\"{\\n \\\"value\\\": \\\"invoice\\\",\\n \\\"id\\\": \\\"BFm1MCJPBCDeRoWXvPcwnM\\\"\\n}\",\"{\\n \\\"value\\\": \\\"app\\\",\\n \\\"id\\\": \\\"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\\\"\\n}\"]",
};
info = data.GetBlobInfo();
AssertContainsLabel(info);
data.SetBlobInfo(info);
info = data.GetBlobInfo();
AssertContainsLabel(info);
static void AssertPayoutLabel(WalletTransactionInfo info)
{
Assert.Single(info.Labels);
var l = Assert.IsType<PayoutLabel>(info.Labels["payout"]);
Assert.Single(Assert.Single(l.PullPaymentPayouts, k => k.Key == "pullPaymentId").Value, "payoutId");
Assert.Equal("walletId", l.WalletId);
}
var payoutId = "payoutId";
var pullPaymentId = "pullPaymentId";
var walletId = "walletId";
// How it was serialized before
data = new WalletTransactionData()
{
Labels = new JArray(JObject.FromObject(new { value = "payout", id = payoutId, pullPaymentId, walletId })).ToString()
};
info = data.GetBlobInfo();
AssertPayoutLabel(info);
data.SetBlobInfo(info);
info = data.GetBlobInfo();
AssertPayoutLabel(info);
}
[Fact]
public void DeterministicUTXOSorter()
{

View file

@ -2270,6 +2270,7 @@ namespace BTCPayServer.Tests
Assert.Equal(transaction.TransactionHash, txdata.TransactionHash);
Assert.Equal(String.Empty, transaction.Comment);
#pragma warning disable CS0612 // Type or member is obsolete
Assert.Equal(new Dictionary<string, LabelData>(), transaction.Labels);
// transaction patch tests
@ -2290,7 +2291,7 @@ namespace BTCPayServer.Tests
}.ToJson(),
patchedTransaction.Labels.ToJson()
);
#pragma warning restore CS0612 // Type or member is obsolete
await AssertHttpError(403, async () =>
{
await viewOnlyClient.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode);

View file

@ -40,6 +40,7 @@ using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Storage.Models;
@ -51,6 +52,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.Payment;
@ -785,9 +787,9 @@ namespace BTCPayServer.Tests
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Text));
Assert.Contains("test2", tx.Labels.Select(l => l.Text));
Assert.Equal(2, tx.Labels.GroupBy(l => l.Color).Count());
Assert.Contains("test", tx.Tags.Select(l => l.Text));
Assert.Contains("test2", tx.Tags.Select(l => l.Text));
Assert.Equal(2, tx.Tags.GroupBy(l => l.Color).Count());
Assert.IsType<RedirectToActionResult>(
await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2"));
@ -797,12 +799,9 @@ namespace BTCPayServer.Tests
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Text));
Assert.DoesNotContain("test2", tx.Labels.Select(l => l.Text));
Assert.Single(tx.Labels.GroupBy(l => l.Color));
var walletInfo = await tester.PayTester.GetService<WalletRepository>().GetWalletInfo(walletId);
Assert.Single(walletInfo.LabelColors); // the test2 color should have been removed
Assert.Contains("test", tx.Tags.Select(l => l.Text));
Assert.DoesNotContain("test2", tx.Tags.Select(l => l.Text));
Assert.Single(tx.Tags.GroupBy(l => l.Color));
}
[Fact(Timeout = LongRunningTestTimeout)]
@ -2521,6 +2520,79 @@ namespace BTCPayServer.Tests
Assert.True(lnMethod.IsInternalNode);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
[Obsolete]
public async Task CanDoLabelMigrations()
{
using var tester = CreateServerTester(newDb: true);
await tester.StartAsync();
var dbf = tester.PayTester.GetService<ApplicationDbContextFactory>();
int walletCount = 1000;
var wallet = "walletttttttttttttttttttttttttttt";
using (var db = dbf.CreateContext())
{
for (int i = 0; i < walletCount; i++)
{
var walletData = new WalletData() { Id = $"S-{wallet}{i}-BTC" };
walletData.Blob = ZipUtils.Zip("{\"LabelColors\": { \"label1\" : \"black\", \"payout\":\"green\" }}");
db.Wallets.Add(walletData);
}
await db.SaveChangesAsync();
}
uint256 firstTxId = null;
using (var db = dbf.CreateContext())
{
int transactionCount = 10_000;
for (int i = 0; i < transactionCount; i++)
{
var txId = RandomUtils.GetUInt256();
var wt = new WalletTransactionData()
{
WalletDataId = $"S-{wallet}{i % walletCount}-BTC",
TransactionId = txId.ToString(),
};
firstTxId ??= txId;
if (i != 10)
wt.Blob = ZipUtils.Zip("{\"Comment\":\"test\"}");
if (i % 1240 != 0)
{
wt.Labels = "[{\"type\":\"raw\", \"text\":\"label1\"}]";
}
else if (i == 0)
{
wt.Labels = "[{\"type\":\"raw\", \"text\":\"label1\"},{\"type\":\"raw\", \"text\":\"labelo" + i + "\"}, " +
"{\"type\":\"payout\", \"text\":\"payout\", \"pullPaymentPayouts\":{\"pp1\":[\"p1\",\"p2\"],\"pp2\":[\"p3\"]}}]";
}
else
{
wt.Labels = "[{\"type\":\"raw\", \"text\":\"label1\"},{\"type\":\"raw\", \"text\":\"labelo" + i + "\"}]";
}
db.WalletTransactions.Add(wt);
}
await db.SaveChangesAsync();
}
await RestartMigration(tester);
var migrator = tester.PayTester.GetService<IEnumerable<IHostedService>>().OfType<DbMigrationsHostedService>().First();
await migrator.MigratedTransactionLabels(0);
var walletRepo = tester.PayTester.GetService<WalletRepository>();
var wi1 = await walletRepo.GetWalletLabels(new WalletId($"{wallet}0", "BTC"));
Assert.Equal(3, wi1.Length);
Assert.Contains(wi1, o => o.Label == "label1" && o.Color == "black");
Assert.Contains(wi1, o => o.Label == "labelo0" && o.Color == "#000");
Assert.Contains(wi1, o => o.Label == "payout" && o.Color == "green");
var txInfo = await walletRepo.GetWalletTransactionsInfo(new WalletId($"{wallet}0", "BTC"), new[] { firstTxId.ToString() });
Assert.Equal("test", txInfo.Values.First().Comment);
// Should have the 2 raw labels, and one legacy label for payouts
Assert.Equal(3, txInfo.Values.First().LegacyLabels.Count);
var payoutLabel = txInfo.Values.First().LegacyLabels.Select(l => l.Value).OfType<PayoutLabel>().First();
Assert.Equal(2, payoutLabel.PullPaymentPayouts.Count);
Assert.Equal(2, payoutLabel.PullPaymentPayouts["pp1"].Count);
Assert.Single(payoutLabel.PullPaymentPayouts["pp2"]);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]

View file

@ -0,0 +1,57 @@
using System;
using System.Drawing;
using System.Text;
using NBitcoin.Crypto;
namespace BTCPayServer
{
public class ColorPalette
{
public string TextColor(string bgColor)
{
int nThreshold = 105;
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);
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
public static readonly ColorPalette Default = new ColorPalette(new string[] {
"#fbca04",
"#0e8a16",
"#ff7619",
"#84b6eb",
"#5319e7",
"#cdcdcd",
"#cc317c",
});
private ColorPalette(string[] labels)
{
Labels = labels;
}
public readonly string[] Labels;
public string DeterministicColor(string label)
{
switch (label)
{
case "payjoin":
return "#51b13e";
case "invoice":
return "#cedc21";
case "payment-request":
return "#489D77";
case "app":
return "#5093B6";
case "pj-exposed":
return "#51b13e";
case "payout":
return "#3F88AF";
default:
var num = NBitcoin.Utils.ToUInt32(Hashes.SHA256(Encoding.UTF8.GetBytes(label)), 0, true);
return Labels[num % Labels.Length];
}
}
}
}

View file

@ -52,7 +52,6 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly EventAggregator _eventAggregator;
private readonly WalletReceiveService _walletReceiveService;
private readonly IFeeProviderFactory _feeProviderFactory;
private readonly LabelFactory _labelFactory;
private readonly UTXOLocker _utxoLocker;
public GreenfieldStoreOnChainWalletsController(
@ -69,7 +68,6 @@ namespace BTCPayServer.Controllers.Greenfield
EventAggregator eventAggregator,
WalletReceiveService walletReceiveService,
IFeeProviderFactory feeProviderFactory,
LabelFactory labelFactory,
UTXOLocker utxoLocker
)
{
@ -86,7 +84,6 @@ namespace BTCPayServer.Controllers.Greenfield
_eventAggregator = eventAggregator;
_walletReceiveService = walletReceiveService;
_feeProviderFactory = feeProviderFactory;
_labelFactory = labelFactory;
_utxoLocker = utxoLocker;
}
@ -202,7 +199,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (!string.IsNullOrWhiteSpace(labelFilter))
{
walletTransactionsInfoAsync.TryGetValue(t.TransactionId.ToString(), out var transactionInfo);
if (transactionInfo?.Labels.ContainsKey(labelFilter) is true)
if (transactionInfo?.LabelColors.ContainsKey(labelFilter) is true)
filteredList.Add(t);
}
if (statusFilter?.Any() is true)
@ -270,36 +267,18 @@ namespace BTCPayServer.Controllers.Greenfield
}
var walletId = new WalletId(storeId, cryptoCode);
var walletTransactionsInfoAsync = _walletRepository.GetWalletTransactionsInfo(walletId);
if (!(await walletTransactionsInfoAsync).TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
var txObjectId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
if (request.Comment != null)
{
walletTransactionInfo.Comment = request.Comment.Trim().Truncate(WalletTransactionDataExtensions.MaxCommentSize);
await _walletRepository.SetWalletObjectComment(txObjectId, request.Comment);
}
if (request.Labels != null)
{
var walletBlobInfo = await _walletRepository.GetWalletInfo(walletId);
foreach (string label in request.Labels)
{
var rawLabel = await _labelFactory.BuildLabel(
walletBlobInfo,
Request,
walletTransactionInfo,
walletId,
transactionId,
label
);
walletTransactionInfo.Labels.TryAdd(rawLabel.Text, rawLabel);
}
await _walletRepository.AddWalletObjectLabels(txObjectId, request.Labels.ToArray());
}
await _walletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
var walletTransactionsInfo =
(await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId }))
.Values
@ -319,19 +298,20 @@ namespace BTCPayServer.Controllers.Greenfield
var wallet = _btcPayWalletProvider.GetWallet(network);
var walletId = new WalletId(storeId, cryptoCode);
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId);
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation);
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId, utxos.Select(u => u.OutPoint.Hash.ToString()).ToHashSet().ToArray());
return Ok(utxos.Select(coin =>
{
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
var labels = info?.Labels ?? new Dictionary<string, LabelData>();
return new OnChainWalletUTXOData()
{
Outpoint = coin.OutPoint,
Amount = coin.Value.GetValue(network),
Comment = info?.Comment,
Labels = info?.Labels,
#pragma warning disable CS0612 // Type or member is obsolete
Labels = info?.LegacyLabels ?? new Dictionary<string, LabelData>(),
#pragma warning restore CS0612 // Type or member is obsolete
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink,
coin.OutPoint.Hash.ToString()),
Timestamp = coin.Timestamp,
@ -592,8 +572,7 @@ namespace BTCPayServer.Controllers.Greenfield
payjoinPSBT.Finalize();
var payjoinTransaction = payjoinPSBT.ExtractTransaction();
var hash = payjoinTransaction.GetHash();
_eventAggregator.Publish(new UpdateTransactionLabel(new WalletId(Store.Id, cryptoCode), hash,
UpdateTransactionLabel.PayjoinLabelTemplate()));
await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode), hash, Attachment.Payjoin());
broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
if (broadcastResult.Success)
{
@ -676,7 +655,9 @@ namespace BTCPayServer.Controllers.Greenfield
{
TransactionHash = tx.TransactionId,
Comment = walletTransactionsInfoAsync?.Comment ?? string.Empty,
Labels = walletTransactionsInfoAsync?.Labels ?? new Dictionary<string, LabelData>(),
#pragma warning disable CS0612 // Type or member is obsolete
Labels = walletTransactionsInfoAsync?.LegacyLabels ?? new Dictionary<string, LabelData>(),
#pragma warning restore CS0612 // Type or member is obsolete
Amount = tx.BalanceChange.GetValue(wallet.Network),
BlockHash = tx.BlockHash,
BlockHeight = tx.Height,

View file

@ -460,7 +460,7 @@ namespace BTCPayServer.Controllers
vm.SigningContext.OriginalPSBT = psbt.ToBase64();
proposedPayjoin.Finalize();
var hash = proposedPayjoin.ExtractTransaction().GetHash();
_EventAggregator.Publish(new UpdateTransactionLabel(walletId, hash, UpdateTransactionLabel.PayjoinLabelTemplate()));
await WalletRepository.AddWalletTransactionAttachment(walletId, hash, Attachment.Payjoin());
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,

View file

@ -39,6 +39,8 @@ using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using StoreData = BTCPayServer.Data.StoreData;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Controllers
{
@ -60,11 +62,10 @@ namespace BTCPayServer.Controllers
private readonly IFeeProviderFactory _feeRateProvider;
private readonly BTCPayWalletProvider _walletProvider;
private readonly WalletReceiveService _walletReceiveService;
private readonly EventAggregator _EventAggregator;
private readonly SettingsRepository _settingsRepository;
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient;
private readonly LabelFactory _labelFactory;
private readonly LinkGenerator _linkGenerator;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly UTXOLocker _utxoLocker;
private readonly WalletHistogramService _walletHistogramService;
@ -84,16 +85,16 @@ namespace BTCPayServer.Controllers
IFeeProviderFactory feeRateProvider,
BTCPayWalletProvider walletProvider,
WalletReceiveService walletReceiveService,
EventAggregator eventAggregator,
SettingsRepository settingsRepository,
DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient,
LabelFactory labelFactory,
IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService,
UTXOLocker utxoLocker)
UTXOLocker utxoLocker,
LinkGenerator linkGenerator)
{
_currencyTable = currencyTable;
_linkGenerator = linkGenerator;
Repository = repo;
WalletRepository = walletRepository;
RateFetcher = rateProvider;
@ -105,11 +106,9 @@ namespace BTCPayServer.Controllers
_feeRateProvider = feeRateProvider;
_walletProvider = walletProvider;
_walletReceiveService = walletReceiveService;
_EventAggregator = eventAggregator;
_settingsRepository = settingsRepository;
_broadcaster = broadcaster;
_payjoinClient = payjoinClient;
_labelFactory = labelFactory;
_pullPaymentHostedService = pullPaymentHostedService;
_utxoLocker = utxoLocker;
ServiceProvider = serviceProvider;
@ -146,58 +145,19 @@ namespace BTCPayServer.Controllers
if (paymentMethod == null)
return NotFound();
var walletBlobInfoAsync = WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletBlobInfo = await walletBlobInfoAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
if (addlabel != null)
{
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
var rawLabel = await _labelFactory.BuildLabel(
walletBlobInfo,
Request!,
walletTransactionInfo,
walletId,
transactionId,
addlabel
);
if (walletTransactionInfo.Labels.TryAdd(rawLabel.Text, rawLabel))
{
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
}
await WalletRepository.AddWalletObjectLabels(txObjId, addlabel);
}
else if (removelabel != null)
{
removelabel = removelabel.Trim();
if (walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
if (walletTransactionInfo.Labels.Remove(removelabel))
{
var canDeleteColor =
!walletTransactionsInfo.Any(txi => txi.Value.Labels.ContainsKey(removelabel));
if (canDeleteColor)
{
walletBlobInfo.LabelColors.Remove(removelabel);
await WalletRepository.SetWalletInfo(walletId, walletBlobInfo);
}
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
}
}
await WalletRepository.RemoveWalletObjectLabels(txObjId, removelabel);
}
else if (addcomment != null)
{
addcomment = addcomment.Trim().Truncate(WalletTransactionDataExtensions.MaxCommentSize);
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
walletTransactionInfo.Comment = addcomment;
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
await WalletRepository.SetWalletObjectComment(txObjId, addcomment);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
@ -267,15 +227,17 @@ namespace BTCPayServer.Controllers
return NotFound();
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletBlobAsync = WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
// We can't filter at the database level if we need to apply label filter
var preFiltering = string.IsNullOrEmpty(labelFilter);
var transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null);
var walletBlob = await walletBlobAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
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)))
);
if (labelFilter != null)
{
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
@ -305,14 +267,13 @@ namespace BTCPayServer.Controllers
if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
{
var labels = _labelFactory.ColorizeTransactionLabels(walletBlob, transactionInfo, Request);
vm.Labels.AddRange(labels);
model.Labels.AddRange(labels);
var labels = CreateTransactionTagModels(transactionInfo);
vm.Tags.AddRange(labels);
vm.Comment = transactionInfo.Comment;
}
if (labelFilter == null ||
vm.Labels.Any(l => l.Text.Equals(labelFilter, StringComparison.OrdinalIgnoreCase)))
vm.Tags.Any(l => l.Text.Equals(labelFilter, StringComparison.OrdinalIgnoreCase)))
model.Transactions.Add(vm);
}
@ -613,17 +574,15 @@ namespace BTCPayServer.Controllers
var schemeSettings = GetDerivationSchemeSettings(walletId);
if (schemeSettings is null)
return NotFound();
var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId);
var utxos = await _walletProvider.GetWallet(network)
.GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation);
var walletTransactionsInfoAsync = await this.WalletRepository.GetWalletTransactionsInfo(walletId, utxos.Select(u => u.OutPoint.Hash.ToString()).Distinct().ToArray());
vm.InputsAvailable = utxos.Select(coin =>
{
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
var labels = info?.Labels == null
? new List<ColoredLabel>()
: _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request).ToList();
var labels = CreateTransactionTagModels(info).ToList();
return new WalletSendModel.InputSelectionOption()
{
Outpoint = coin.OutPoint.ToString(),
@ -1359,6 +1318,117 @@ namespace BTCPayServer.Controllers
private string GetUserId() => _userManager.GetUserId(User);
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
public IEnumerable<TransactionTagModel> CreateTransactionTagModels(WalletTransactionInfo? transactionInfo)
{
if (transactionInfo is null)
return Array.Empty<TransactionTagModel>();
string PayoutTooltip(IGrouping<string, string>? payoutsByPullPaymentId = null)
{
if (payoutsByPullPaymentId is null)
{
return "Paid a payout";
}
else if (payoutsByPullPaymentId.Count() == 1)
{
var pp = payoutsByPullPaymentId.Key;
var payout = payoutsByPullPaymentId.First();
if (!string.IsNullOrEmpty(pp))
return $"Paid a payout ({payout}) of a pull payment ({pp})";
else
return $"Paid a payout {payout}";
}
else
{
var pp = payoutsByPullPaymentId.Key;
if (!string.IsNullOrEmpty(pp))
return $"Paid {payoutsByPullPaymentId.Count()} payouts of a pull payment ({pp})";
else
return $"Paid {payoutsByPullPaymentId.Count()} payouts";
}
}
var models = new Dictionary<string, TransactionTagModel>();
foreach (var tag in transactionInfo.Attachments)
{
if (models.ContainsKey(tag.Type))
continue;
if (!transactionInfo.LabelColors.TryGetValue(tag.Type, out var color))
continue;
var model = new TransactionTagModel
{
Text = tag.Type,
Color = color,
TextColor = ColorPalette.Default.TextColor(color)
};
models.Add(tag.Type, model);
if (tag.Type == "payout")
{
var payoutsByPullPaymentId =
transactionInfo.Attachments.Where(t => t.Type == "payout")
.GroupBy(t => t.Data?["pullPaymentId"]?.Value<string>() ?? "",
k => k.Id).ToList();
model.Tooltip = payoutsByPullPaymentId.Count switch
{
0 => PayoutTooltip(),
1 => PayoutTooltip(payoutsByPullPaymentId.First()),
_ =>
$"<ul>{string.Join(string.Empty, payoutsByPullPaymentId.Select(pair => $"<li>{PayoutTooltip(pair)}</li>"))}</ul>"
};
model.Link = _linkGenerator.PayoutLink(transactionInfo.WalletId.ToString(), null, PayoutState.Completed, Request.Scheme, Request.Host,
Request.PathBase);
}
else if (tag.Type == "payjoin")
{
model.Tooltip = $"This UTXO was part of a PayJoin transaction.";
}
else if (tag.Type == "invoice")
{
model.Tooltip = $"Received through an invoice {tag.Id}";
model.Link = string.IsNullOrEmpty(tag.Id)
? null
: _linkGenerator.InvoiceLink(tag.Id, Request.Scheme, Request.Host, Request.PathBase);
}
else if (tag.Type == "payment-request")
{
model.Tooltip = $"Received through a payment request {tag.Id}";
model.Link = _linkGenerator.PaymentRequestLink(tag.Id, Request.Scheme, Request.Host, Request.PathBase);
}
else if (tag.Type == "app")
{
model.Tooltip = $"Received through an app {tag.Id}";
model.Link = _linkGenerator.AppLink(tag.Id, Request.Scheme, Request.Host, Request.PathBase);
}
else if (tag.Type == "pj-exposed")
{
if (tag.Id.Length != 0)
{
model.Tooltip = $"This UTXO was exposed through a PayJoin proposal for an invoice ({tag.Id})";
model.Link = _linkGenerator.InvoiceLink(tag.Id, Request.Scheme, Request.Host, Request.PathBase);
}
else
{
model.Tooltip = $"This UTXO was exposed through a PayJoin proposal";
}
}
else if (tag.Type == "payjoin")
{
model.Tooltip = $"This UTXO was part of a PayJoin transaction.";
}
}
foreach (var label in transactionInfo.LabelColors)
models.TryAdd(label.Key, new TransactionTagModel
{
Text = label.Key,
Color = label.Value,
TextColor = ColorPalette.Default.TextColor(label.Value)
});
return models.Values.OrderBy(v => v.Text);
}
}
public class WalletReceiveViewModel

View file

@ -34,22 +34,24 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly EventAggregator _eventAggregator;
private readonly NotificationSender _notificationSender;
private readonly Logs Logs;
public WalletRepository WalletRepository { get; }
public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider,
WalletRepository walletRepository,
ExplorerClientProvider explorerClientProvider,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
ApplicationDbContextFactory dbContextFactory,
EventAggregator eventAggregator,
NotificationSender notificationSender,
Logs logs)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
WalletRepository = walletRepository;
_explorerClientProvider = explorerClientProvider;
_jsonSerializerSettings = jsonSerializerSettings;
_dbContextFactory = dbContextFactory;
_eventAggregator = eventAggregator;
_notificationSender = notificationSender;
this.Logs = logs;
}
@ -426,13 +428,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
if (isInternal)
{
payout.State = PayoutState.InProgress;
var walletId = new WalletId(payout.StoreDataId, newTransaction.CryptoCode);
_eventAggregator.Publish(new UpdateTransactionLabel(walletId,
await WalletRepository.AddWalletTransactionAttachment(
new WalletId(payout.StoreDataId, newTransaction.CryptoCode),
newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(new ()
{
{payout.PullPaymentDataId?? "", new List<string>{payout.Id}}
}, walletId.ToString())));
Attachment.Payout(payout.PullPaymentDataId, payout.Id));
}
else
{

View file

@ -1,27 +0,0 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public static class WalletDataExtensions
{
public static WalletBlobInfo GetBlobInfo(this WalletData walletData)
{
if (walletData.Blob == null || walletData.Blob.Length == 0)
{
return new WalletBlobInfo();
}
var blobInfo = JsonConvert.DeserializeObject<WalletBlobInfo>(ZipUtils.Unzip(walletData.Blob));
return blobInfo;
}
public static void SetBlobInfo(this WalletData walletData, WalletBlobInfo blobInfo)
{
if (blobInfo == null)
{
walletData.Blob = Array.Empty<byte>();
return;
}
walletData.Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo));
}
}
}

View file

@ -1,74 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Labels;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace BTCPayServer.Data
{
public class WalletTransactionInfo
{
public string Comment { get; set; } = string.Empty;
[JsonIgnore]
public Dictionary<string, LabelData> Labels { get; set; } = new Dictionary<string, LabelData>();
}
public static class WalletTransactionDataExtensions
{
public static int MaxCommentSize = 200;
public static WalletTransactionInfo GetBlobInfo(this WalletTransactionData walletTransactionData)
{
WalletTransactionInfo blobInfo;
if (walletTransactionData.Blob == null || walletTransactionData.Blob.Length == 0)
blobInfo = new WalletTransactionInfo();
else
blobInfo = JsonConvert.DeserializeObject<WalletTransactionInfo>(ZipUtils.Unzip(walletTransactionData.Blob));
if (!string.IsNullOrEmpty(walletTransactionData.Labels))
{
if (walletTransactionData.Labels.StartsWith('['))
{
foreach (var jtoken in JArray.Parse(walletTransactionData.Labels))
{
var l = jtoken.Type == JTokenType.String ? Label.Parse(jtoken.Value<string>())
: Label.Parse(jtoken.ToString());
blobInfo.Labels.TryAdd(l.Text, l);
}
}
else
{
// Legacy path
foreach (var token in walletTransactionData.Labels.Split(',',
StringSplitOptions.RemoveEmptyEntries))
{
var l = Label.Parse(token);
blobInfo.Labels.TryAdd(l.Text, l);
}
}
}
return blobInfo;
}
static JsonSerializerSettings LabelSerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
Formatting = Formatting.None
};
public static void SetBlobInfo(this WalletTransactionData walletTransactionData, WalletTransactionInfo blobInfo)
{
if (blobInfo == null)
{
walletTransactionData.Labels = string.Empty;
walletTransactionData.Blob = Array.Empty<byte>();
return;
}
walletTransactionData.Labels = new JArray(
blobInfo.Labels.Select(l => JsonConvert.SerializeObject(l.Value, LabelSerializerSettings))
.Select(l => JObject.Parse(l))
.OfType<JToken>()
.ToArray()).ToString();
walletTransactionData.Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo));
}
}
}

View file

@ -0,0 +1,87 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Labels;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace BTCPayServer.Data
{
public class WalletTransactionInfo
{
public WalletTransactionInfo(WalletId walletId)
{
WalletId = walletId;
}
[JsonIgnore]
public WalletId WalletId { get; }
public string Comment { get; set; } = string.Empty;
[JsonIgnore]
public List<Attachment> Attachments { get; set; } = new List<Attachment>();
[JsonIgnore]
public Dictionary<string, string> LabelColors { get; set; } = new Dictionary<string, string>();
[Obsolete]
Dictionary<string, LabelData>? _LegacyLabels;
[JsonIgnore]
[Obsolete]
public Dictionary<string, LabelData> LegacyLabels
{
get
{
if (_LegacyLabels is null)
{
var legacyLabels = new Dictionary<string, LabelData>();
foreach (var tag in Attachments)
{
switch (tag.Type)
{
case "payout":
PayoutLabel legacyPayoutLabel;
if (legacyLabels.TryGetValue(tag.Type, out var existing) &&
existing is PayoutLabel)
{
legacyPayoutLabel = (PayoutLabel)existing;
}
else
{
legacyPayoutLabel = new PayoutLabel();
legacyLabels.Add(tag.Type, legacyPayoutLabel);
}
var ppid = tag.Data?["pullPaymentId"]?.Value<string>() ?? "";
if (!legacyPayoutLabel.PullPaymentPayouts.TryGetValue(ppid, out var payouts))
{
payouts = new List<string>();
legacyPayoutLabel.PullPaymentPayouts.Add(ppid, payouts);
}
payouts.Add(tag.Id);
break;
case "payjoin":
case "payment-request":
case "app":
case "pj-exposed":
case "invoice":
legacyLabels.TryAdd(tag.Type, new ReferenceLabel(tag.Type, tag.Id));
break;
default:
continue;
}
}
foreach (var label in LabelColors)
{
legacyLabels.TryAdd(label.Key, new RawLabel(label.Key));
}
_LegacyLabels = legacyLabels;
}
return _LegacyLabels;
}
}
}
}

View file

@ -2,15 +2,20 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices
{
@ -45,10 +50,235 @@ namespace BTCPayServer.HostedServices
{
await MigratedInvoiceTextSearchToDb(settings.MigratedInvoiceTextSearchPages ?? 0);
}
// Refresh settings since these operations may run for very long time
if (settings.MigratedTransactionLabels != int.MaxValue)
{
await MigratedTransactionLabels(settings.MigratedTransactionLabels ?? 0);
}
}
#pragma warning disable CS0612 // Type or member is obsolete
class LegacyWalletTransactionInfo
{
public string Comment { get; set; } = string.Empty;
[JsonIgnore]
public Dictionary<string, LabelData> Labels { get; set; } = new Dictionary<string, LabelData>();
}
static LegacyWalletTransactionInfo GetBlobInfo(WalletTransactionData walletTransactionData)
{
LegacyWalletTransactionInfo blobInfo;
if (walletTransactionData.Blob == null || walletTransactionData.Blob.Length == 0)
blobInfo = new LegacyWalletTransactionInfo();
else
blobInfo = JsonConvert.DeserializeObject<LegacyWalletTransactionInfo>(ZipUtils.Unzip(walletTransactionData.Blob));
if (!string.IsNullOrEmpty(walletTransactionData.Labels))
{
if (walletTransactionData.Labels.StartsWith('['))
{
foreach (var jtoken in JArray.Parse(walletTransactionData.Labels))
{
var l = jtoken.Type == JTokenType.String ? Label.Parse(jtoken.Value<string>())
: Label.Parse(jtoken.ToString());
blobInfo.Labels.TryAdd(l.Text, l);
}
}
else
{
// Legacy path
foreach (var token in walletTransactionData.Labels.Split(',',
StringSplitOptions.RemoveEmptyEntries))
{
var l = Label.Parse(token);
blobInfo.Labels.TryAdd(l.Text, l);
}
}
}
return blobInfo;
}
internal async Task MigratedTransactionLabels(int startFromOffset)
{
// Only of 1000, that's what EF does anyway under the hood by default
int batchCount = 1000;
int total = 0;
HashSet<(string WalletId, string LabelId)> existingLabels;
using (var db = _dbContextFactory.CreateContext())
{
total = await db.WalletTransactions.CountAsync();
existingLabels = (await (
db.WalletObjects.AsNoTracking()
.Where(wo => wo.Type == WalletObjectData.Types.Label)
.Select(wl => new { wl.WalletId, wl.Id })
.ToListAsync()))
.Select(o => (o.WalletId, o.Id)).ToHashSet();
}
next:
// var insertedObjectInDBContext
// Need to keep track of this hack, or then EF has a bug where he crash on the .Add and get internally
// corrupted.
var ifuckinghateentityframework = new HashSet<(string WalletId, string Type, string Id)>();
using (var db = _dbContextFactory.CreateContext())
{
Logs.PayServer.LogInformation($"Wallet transaction label importing transactions {startFromOffset}/{total}");
var txs = await db.WalletTransactions
.OrderByDescending(wt => wt.WalletDataId).ThenBy(wt => wt.TransactionId)
.Skip(startFromOffset)
.Take(batchCount)
.ToArrayAsync();
foreach (var tx in txs)
{
// Same as above
var ifuckinghateentityframework2 = new HashSet<(string Type, string Id)>();
var blob = GetBlobInfo(tx);
db.WalletObjects.Add(new Data.WalletObjectData()
{
WalletId = tx.WalletDataId,
Type = Data.WalletObjectData.Types.Tx,
Id = tx.TransactionId,
Data = string.IsNullOrEmpty(blob.Comment) ? null : new JObject() { ["comment"] = blob.Comment }.ToString()
});
foreach (var label in blob.Labels)
{
var labelId = label.Key;
if (labelId.StartsWith("{", StringComparison.OrdinalIgnoreCase))
{
try
{
labelId = JObject.Parse(label.Key)["value"].Value<string>();
}
catch
{
}
}
if (!existingLabels.Contains((tx.WalletDataId, labelId)))
{
JObject labelData = new JObject();
labelData.Add("color", "#000");
db.WalletObjects.Add(new WalletObjectData()
{
WalletId = tx.WalletDataId,
Type = WalletObjectData.Types.Label,
Id = labelId,
Data = labelData.ToString()
});
existingLabels.Add((tx.WalletDataId, labelId));
}
if (ifuckinghateentityframework2.Add((Data.WalletObjectData.Types.Label, labelId)))
db.WalletObjectLinks.Add(new WalletObjectLinkData()
{
WalletId = tx.WalletDataId,
ChildType = Data.WalletObjectData.Types.Tx,
ChildId = tx.TransactionId,
ParentType = Data.WalletObjectData.Types.Label,
ParentId = labelId
});
if (label.Value is ReferenceLabel reflabel)
{
if (IsReferenceLabel(reflabel.Type))
{
if (ifuckinghateentityframework.Add((tx.WalletDataId, reflabel.Type, reflabel.Reference ?? String.Empty)))
db.WalletObjects.Add(new WalletObjectData()
{
WalletId = tx.WalletDataId,
Type = reflabel.Type,
Id = reflabel.Reference ?? String.Empty
});
if (ifuckinghateentityframework2.Add((reflabel.Type, reflabel.Reference ?? String.Empty)))
db.WalletObjectLinks.Add(new WalletObjectLinkData()
{
WalletId = tx.WalletDataId,
ChildType = Data.WalletObjectData.Types.Tx,
ChildId = tx.TransactionId,
ParentType = reflabel.Type,
ParentId = reflabel.Reference ?? String.Empty
});
}
}
else if (label.Value is PayoutLabel payoutLabel)
{
foreach (var pp in payoutLabel.PullPaymentPayouts)
{
foreach (var payout in pp.Value)
{
var payoutData = string.IsNullOrEmpty(pp.Key) ? null : new JObject()
{
["pullPaymentId"] = pp.Key
};
if (ifuckinghateentityframework.Add((tx.WalletDataId, "payout", payout)))
db.WalletObjects.Add(new WalletObjectData()
{
WalletId = tx.WalletDataId,
Type = "payout",
Id = payout,
Data = payoutData?.ToString()
});
if (ifuckinghateentityframework2.Add(("payout", payout)))
db.WalletObjectLinks.Add(new WalletObjectLinkData()
{
WalletId = tx.WalletDataId,
ChildType = Data.WalletObjectData.Types.Tx,
ChildId = tx.TransactionId,
ParentType = "payout",
ParentId = payout
});
}
}
}
}
}
int retry = 0;
retrySave:
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (retry < 10)
{
foreach (var entry in ex.Entries)
{
if (entry.Entity is WalletObjectData wo && (IsReferenceLabel(wo.Type) || wo.Type == "payout"))
{
await entry.ReloadAsync();
}
}
retry++;
goto retrySave;
}
if (txs.Length < batchCount)
{
var settings = await _settingsRepository.GetSettingAsync<MigrationSettings>();
settings.MigratedTransactionLabels = int.MaxValue;
await _settingsRepository.UpdateSetting(settings);
Logs.PayServer.LogInformation($"Wallet transaction label successfully migrated");
return;
}
else
{
startFromOffset += batchCount;
var settings = await _settingsRepository.GetSettingAsync<MigrationSettings>();
settings.MigratedTransactionLabels = startFromOffset;
await _settingsRepository.UpdateSetting(settings);
goto next;
}
}
}
private static bool IsReferenceLabel(string type)
{
return type == "invoice" ||
type == "payment-request" ||
type == "app" ||
type == "pj-exposed";
}
#pragma warning restore CS0612 // Type or member is obsolete
private async Task MigratedInvoiceTextSearchToDb(int startFromPage)
{
// deleting legacy DBriize database if present
@ -97,7 +327,7 @@ namespace BTCPayServer.HostedServices
textSearch.Add(invoice.RefundMail);
// TODO: Are there more things to cache? PaymentData?
InvoiceRepository.AddToTextSearch(ctx,
new InvoiceData { Id = invoice.Id, InvoiceSearchData = new List<InvoiceSearchData>() },
new Data.InvoiceData { Id = invoice.Id, InvoiceSearchData = new List<InvoiceSearchData>() },
textSearch.ToArray());
}

View file

@ -1,3 +1,4 @@
#nullable enable
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@ -14,6 +15,8 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.PaymentRequests;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices
{
@ -32,7 +35,6 @@ namespace BTCPayServer.HostedServices
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<UpdateTransactionLabel>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
@ -42,136 +44,21 @@ namespace BTCPayServer.HostedServices
{
var walletId = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
var transactionId = bitcoinLikePaymentData.Outpoint.Hash;
var labels = new List<(string color, Label label)>
var labels = new List<Attachment>
{
UpdateTransactionLabel.InvoiceLabelTemplate(invoiceEvent.Invoice.Id)
Attachment.Invoice(invoiceEvent.Invoice.Id)
};
foreach (var paymentId in PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice))
{
labels.Add(UpdateTransactionLabel.PaymentRequestLabelTemplate(paymentId));
labels.Add(Attachment.PaymentRequest(paymentId));
}
foreach (var appId in AppService.GetAppInternalTags(invoiceEvent.Invoice))
{
labels.Add(UpdateTransactionLabel.AppLabelTemplate(appId));
labels.Add(Attachment.App(appId));
}
_eventAggregator.Publish(new UpdateTransactionLabel(walletId, transactionId, labels));
await _walletRepository.AddWalletTransactionAttachment(walletId, transactionId, labels);
}
else if (evt is UpdateTransactionLabel updateTransactionLabel)
{
var walletTransactionsInfo =
await _walletRepository.GetWalletTransactionsInfo(updateTransactionLabel.WalletId);
var walletBlobInfo = await _walletRepository.GetWalletInfo(updateTransactionLabel.WalletId);
await Task.WhenAll(updateTransactionLabel.TransactionLabels.Select(async pair =>
{
var txId = pair.Key.ToString();
var coloredLabels = pair.Value;
if (!walletTransactionsInfo.TryGetValue(txId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
bool walletNeedUpdate = false;
foreach (var cl in coloredLabels)
{
if (walletBlobInfo.LabelColors.TryGetValue(cl.label.Text, out var currentColor))
{
if (currentColor != cl.color)
{
walletNeedUpdate = true;
walletBlobInfo.LabelColors[cl.label.Text] = currentColor;
}
}
else
{
walletNeedUpdate = true;
walletBlobInfo.LabelColors.AddOrReplace(cl.label.Text, cl.color);
}
}
if (walletNeedUpdate)
await _walletRepository.SetWalletInfo(updateTransactionLabel.WalletId, walletBlobInfo);
foreach (var cl in coloredLabels)
{
var label = cl.label;
if (walletTransactionInfo.Labels.TryGetValue(label.Text, out var existingLabel))
{
label = label.Merge(existingLabel);
}
walletTransactionInfo.Labels.AddOrReplace(label.Text, label);
}
await _walletRepository.SetWalletTransactionInfo(updateTransactionLabel.WalletId,
txId, walletTransactionInfo);
}));
}
}
}
public class UpdateTransactionLabel
{
public UpdateTransactionLabel()
{
}
public UpdateTransactionLabel(WalletId walletId, uint256 txId, (string color, Label label) colorLabel)
{
WalletId = walletId;
TransactionLabels = new Dictionary<uint256, List<(string color, Label label)>>();
TransactionLabels.Add(txId, new List<(string color, Label label)>() { colorLabel });
}
public UpdateTransactionLabel(WalletId walletId, uint256 txId, List<(string color, Label label)> colorLabels)
{
WalletId = walletId;
TransactionLabels = new Dictionary<uint256, List<(string color, Label label)>>();
TransactionLabels.Add(txId, colorLabels);
}
public static (string color, Label label) PayjoinLabelTemplate()
{
return ("#51b13e", new RawLabel("payjoin"));
}
public static (string color, Label label) InvoiceLabelTemplate(string invoice)
{
return ("#cedc21", new ReferenceLabel("invoice", invoice));
}
public static (string color, Label label) PaymentRequestLabelTemplate(string paymentRequestId)
{
return ("#489D77", new ReferenceLabel("payment-request", paymentRequestId));
}
public static (string color, Label label) AppLabelTemplate(string appId)
{
return ("#5093B6", new ReferenceLabel("app", appId));
}
public static (string color, Label label) PayjoinExposedLabelTemplate(string invoice)
{
return ("#51b13e", new ReferenceLabel("pj-exposed", invoice));
}
public static (string color, Label label) PayoutTemplate(Dictionary<string, List<string>> pullPaymentToPayouts, string walletId)
{
return ("#3F88AF", new PayoutLabel()
{
PullPaymentPayouts = pullPaymentToPayouts,
WalletId = walletId
});
}
public WalletId WalletId { get; set; }
public Dictionary<uint256, List<(string color, Label label)>> TransactionLabels { get; set; }
public override string ToString()
{
var result = new StringBuilder();
foreach (var transactionLabel in TransactionLabels)
{
result.AppendLine(CultureInfo.InvariantCulture,
$"Adding {transactionLabel.Value.Count} labels to {transactionLabel.Key} in wallet {WalletId}");
}
return result.ToString();
}
}
}

View file

@ -103,7 +103,6 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<SettingsRepository>();
services.TryAddSingleton<ISettingsRepository>(provider => provider.GetService<SettingsRepository>());
services.TryAddSingleton<IStoreRepository>(provider => provider.GetService<StoreRepository>());
services.TryAddSingleton<LabelFactory>();
services.TryAddSingleton<TorServices>();
services.AddSingleton<IHostedService>(provider => provider.GetRequiredService<TorServices>());
services.AddSingleton<ISwaggerProvider, DefaultSwaggerProvider>();

View file

@ -28,6 +28,7 @@ using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PeterO.Cbor;
using PayoutData = BTCPayServer.Data.PayoutData;
@ -215,6 +216,12 @@ namespace BTCPayServer.Hosting
settings.MigrateEmailServerDisableTLSCerts = true;
await _Settings.UpdateSetting(settings);
}
if (!settings.MigrateWalletColors)
{
await MigrateMigrateLabels();
settings.MigrateWalletColors = true;
await _Settings.UpdateSetting(settings);
}
}
catch (Exception ex)
{
@ -223,6 +230,59 @@ namespace BTCPayServer.Hosting
}
}
#pragma warning disable CS0612 // Type or member is obsolete
static WalletBlobInfo GetBlobInfo(WalletData walletData)
{
if (walletData.Blob == null || walletData.Blob.Length == 0)
{
return new WalletBlobInfo();
}
var blobInfo = JsonConvert.DeserializeObject<WalletBlobInfo>(ZipUtils.Unzip(walletData.Blob));
return blobInfo;
}
private async Task MigrateMigrateLabels()
{
await using var ctx = _DBContextFactory.CreateContext();
var wallets = await ctx.Wallets.AsNoTracking().ToArrayAsync();
foreach (var wallet in wallets)
{
var blob = GetBlobInfo(wallet);
HashSet<string> labels = new HashSet<string>(blob.LabelColors.Count);
foreach (var label in blob.LabelColors)
{
var labelId = label.Key;
if (labelId.StartsWith("{", StringComparison.OrdinalIgnoreCase))
{
try
{
labelId = JObject.Parse(label.Key)["value"].Value<string>();
}
catch
{
}
}
if (!labels.Add(labelId))
continue;
var obj = new JObject();
obj.Add("color", label.Value);
var labelObjId = new WalletObjectId(WalletId.Parse(wallet.Id),
WalletObjectData.Types.Label,
labelId);
ctx.WalletObjects.Add(new WalletObjectData()
{
WalletId = wallet.Id,
Type = WalletObjectData.Types.Label,
Id = labelId,
Data = obj.ToString()
});
}
}
await ctx.SaveChangesAsync();
}
#pragma warning restore CS0612 // Type or member is obsolete
// In the past, if a server was considered local network, then we would disable TLS checks.
// Now we don't do it anymore, as we have an explicit flag (DisableCertificateCheck) to control the behavior.
// But we need to migrate old users that relied on the behavior before.

View file

@ -15,9 +15,9 @@ namespace BTCPayServer.Models.WalletViewModels
public string Link { get; set; }
public bool Positive { get; set; }
public string Balance { get; set; }
public HashSet<ColoredLabel> Labels { get; set; } = new HashSet<ColoredLabel>();
public HashSet<TransactionTagModel> Tags { get; set; } = new HashSet<TransactionTagModel>();
}
public HashSet<ColoredLabel> Labels { get; set; } = new HashSet<ColoredLabel>();
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new HashSet<(string Text, string Color, string TextColor)>();
public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; }

View file

@ -1,28 +1,24 @@
using System;
namespace BTCPayServer.Services.Labels
namespace BTCPayServer.Models.WalletViewModels
{
public class ColoredLabel
public class TransactionTagModel
{
internal ColoredLabel()
{
}
public string Text { get; internal set; }
public string Color { get; internal set; }
public string TextColor { get; internal set; }
public string Link { get; internal set; }
public string Tooltip { get; internal set; }
public string Tooltip { get; internal set; } = String.Empty;
public override bool Equals(object obj)
{
ColoredLabel item = obj as ColoredLabel;
TransactionTagModel item = obj as TransactionTagModel;
if (item == null)
return false;
return Text.Equals(item.Text, StringComparison.OrdinalIgnoreCase);
}
public static bool operator ==(ColoredLabel a, ColoredLabel b)
public static bool operator ==(TransactionTagModel a, TransactionTagModel b)
{
if (System.Object.ReferenceEquals(a, b))
return true;
@ -31,7 +27,7 @@ namespace BTCPayServer.Services.Labels
return a.Text == b.Text;
}
public static bool operator !=(ColoredLabel a, ColoredLabel b)
public static bool operator !=(TransactionTagModel a, TransactionTagModel b)
{
return !(a == b);
}

View file

@ -73,7 +73,7 @@ namespace BTCPayServer.Models.WalletViewModels
public class InputSelectionOption
{
public IEnumerable<ColoredLabel> Labels { get; set; }
public IEnumerable<TransactionTagModel> Labels { get; set; }
public string Comment { get; set; }
public decimal Amount { get; set; }
public string Outpoint { get; set; }

View file

@ -81,6 +81,7 @@ namespace BTCPayServer.Payments.PayJoin
}
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly InvoiceRepository _invoiceRepository;
private readonly WalletRepository _walletRepository;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayWalletProvider _btcPayWalletProvider;
private readonly UTXOLocker _utxoLocker;
@ -96,6 +97,7 @@ namespace BTCPayServer.Payments.PayJoin
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
WalletRepository walletRepository,
BTCPayWalletProvider btcPayWalletProvider,
UTXOLocker utxoLocker,
EventAggregator eventAggregator,
@ -109,6 +111,7 @@ namespace BTCPayServer.Payments.PayJoin
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
_walletRepository = walletRepository;
_explorerClientProvider = explorerClientProvider;
_btcPayWalletProvider = btcPayWalletProvider;
_utxoLocker = utxoLocker;
@ -503,23 +506,14 @@ namespace BTCPayServer.Payments.PayJoin
}
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction);
var labels = selectedUTXOs.GroupBy(pair => pair.Key.Hash).Select(utxo =>
new KeyValuePair<uint256, List<(string color, Label label)>>(utxo.Key,
new List<(string color, Label label)>()
{
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice?.Id)
}))
.ToDictionary(pair => pair.Key, pair => pair.Value);
foreach (var utxo in selectedUTXOs)
{
await _walletRepository.AddWalletTransactionAttachment(walletId, utxo.Key.Hash, Attachment.PayjoinExposed(invoice?.Id));
}
await _walletRepository.AddWalletTransactionAttachment(walletId, originalPaymentData.PayjoinInformation.CoinjoinTransactionHash, Attachment.Payjoin());
labels.Add(originalPaymentData.PayjoinInformation.CoinjoinTransactionHash, new List<(string color, Label label)>()
{
UpdateTransactionLabel.PayjoinLabelTemplate()
});
_eventAggregator.Publish(new UpdateTransactionLabel()
{
WalletId = walletId,
TransactionLabels = labels
});
ctx.Success();
// BTCPay Server support PSBT set as hex
if (psbtFormat && HexEncoder.IsWellFormed(rawBody))

View file

@ -39,6 +39,7 @@ namespace BTCPayServer.PayoutProcessors.OnChain
ILoggerFactory logger,
BitcoinLikePayoutHandler bitcoinLikePayoutHandler,
EventAggregator eventAggregator,
WalletRepository walletRepository,
StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings,
PullPaymentHostedService pullPaymentHostedService,
@ -51,8 +52,11 @@ namespace BTCPayServer.PayoutProcessors.OnChain
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_bitcoinLikePayoutHandler = bitcoinLikePayoutHandler;
_eventAggregator = eventAggregator;
WalletRepository = walletRepository;
}
public WalletRepository WalletRepository { get; }
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
{
var storePaymentMethod = paymentMethod as DerivationSchemeSettings;
@ -171,12 +175,9 @@ namespace BTCPayServer.PayoutProcessors.OnChain
var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (PayoutData payoutData in transfersProcessing)
{
_eventAggregator.Publish(new UpdateTransactionLabel(walletId,
await WalletRepository.AddWalletTransactionAttachment(walletId,
txHash,
UpdateTransactionLabel.PayoutTemplate(new ()
{
{payoutData.PullPaymentDataId?? "", new List<string>{payoutData.Id}}
}, walletId.ToString())));
Attachment.Payout(payoutData.PullPaymentDataId, payoutData.Id));
}
await Task.WhenAny(tcs.Task, task);
}

View file

@ -0,0 +1,59 @@
#nullable enable
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Labels;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services
{
public class Attachment
{
public string Type { get; }
public string Id { get; }
public JObject? Data { get; }
public Attachment(string type, string? id = null, JObject? data = null)
{
Type = type;
Id = id ?? string.Empty;
Data = data;
}
public static Attachment Payjoin()
{
return new Attachment("payjoin");
}
public static Attachment Invoice(string invoice)
{
return new Attachment("invoice", invoice);
}
public static Attachment PaymentRequest(string paymentRequestId)
{
return new Attachment("payment-request", paymentRequestId);
}
public static Attachment App(string appId)
{
return new Attachment("app", appId);
}
public static Attachment PayjoinExposed(string? invoice)
{
return new Attachment("pj-exposed", invoice);
}
public static IEnumerable<Attachment> Payout(string? pullPaymentId, string payoutId)
{
if (string.IsNullOrEmpty(pullPaymentId))
{
yield return new Attachment("payout", payoutId);
}
else
{
yield return new Attachment("payout", payoutId, new JObject()
{
["pullPaymentId"] = pullPaymentId
});
yield return new Attachment("pull-payment", pullPaymentId);
}
}
}
}

View file

@ -9,13 +9,9 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Labels
{
[Obsolete]
public abstract class Label : LabelData
{
public virtual Label Merge(LabelData other)
{
return this;
}
static void FixLegacy(JObject jObj, ReferenceLabel refLabel)
{
if (refLabel.Reference is null && jObj.ContainsKey("id"))
@ -49,6 +45,18 @@ namespace BTCPayServer.Services.Labels
rawLabel.Type = "raw";
FixLegacy(jObj, (Label)rawLabel);
}
static Label()
{
SerializerSettings = new JsonSerializerSettings()
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
};
Serializer = JsonSerializer.Create(SerializerSettings);
}
public static JsonSerializerSettings SerializerSettings;
public static JsonSerializer Serializer;
public static Label Parse(string str)
{
ArgumentNullException.ThrowIfNull(str);
@ -95,6 +103,7 @@ namespace BTCPayServer.Services.Labels
}
}
[Obsolete]
public class RawLabel : Label
{
public RawLabel()
@ -106,6 +115,7 @@ namespace BTCPayServer.Services.Labels
Text = text;
}
}
[Obsolete]
public class ReferenceLabel : Label
{
public ReferenceLabel()
@ -121,6 +131,7 @@ namespace BTCPayServer.Services.Labels
[JsonProperty("ref")]
public string Reference { get; set; }
}
[Obsolete]
public class PayoutLabel : Label
{
public PayoutLabel()
@ -130,21 +141,5 @@ namespace BTCPayServer.Services.Labels
}
public Dictionary<string, List<string>> PullPaymentPayouts { get; set; } = new();
public string WalletId { get; set; }
public override Label Merge(LabelData other)
{
if (other is not PayoutLabel otherPayoutLabel) return base.Merge(other);
foreach (var pullPaymentPayout in otherPayoutLabel.PullPaymentPayouts)
{
if (!PullPaymentPayouts.TryGetValue(pullPaymentPayout.Key, out var pullPaymentPayouts))
{
pullPaymentPayouts = new List<string>();
PullPaymentPayouts.Add(pullPaymentPayout.Key, pullPaymentPayouts);
}
pullPaymentPayouts.AddRange(pullPaymentPayout.Value);
}
return base.Merge(other);
}
}
}

View file

@ -1,197 +0,0 @@
#nullable enable
using System;
using System.Drawing;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using BTCPayServer.Data;
using BTCPayServer.Client.Models;
using BTCPayServer.Abstractions.Extensions;
namespace BTCPayServer.Services.Labels
{
public class LabelFactory
{
private readonly LinkGenerator _linkGenerator;
private readonly WalletRepository _walletRepository;
public LabelFactory(
LinkGenerator linkGenerator,
WalletRepository walletRepository
)
{
_linkGenerator = linkGenerator;
_walletRepository = walletRepository;
}
public IEnumerable<ColoredLabel> ColorizeTransactionLabels(WalletBlobInfo walletBlobInfo, WalletTransactionInfo transactionInfo,
HttpRequest request)
{
foreach (var label in transactionInfo.Labels)
{
walletBlobInfo.LabelColors.TryGetValue(label.Value.Text, out var color);
yield return CreateLabel(label.Value, color, request);
}
}
public IEnumerable<ColoredLabel> GetWalletColoredLabels(WalletBlobInfo walletBlobInfo, HttpRequest request)
{
foreach (var kv in walletBlobInfo.LabelColors)
{
yield return CreateLabel(new RawLabel() { Text = kv.Key }, kv.Value, request);
}
}
const string DefaultColor = "#000";
private ColoredLabel CreateLabel(LabelData uncoloredLabel, string? color, HttpRequest request)
{
ArgumentNullException.ThrowIfNull(uncoloredLabel);
color ??= DefaultColor;
ColoredLabel coloredLabel = new ColoredLabel
{
Text = uncoloredLabel.Text,
Color = color,
Tooltip = "",
TextColor = TextColor(color)
};
string PayoutLabelText(KeyValuePair<string, List<string>>? pair = null)
{
if (pair is null)
{
return "Paid a payout";
}
return pair.Value.Value.Count == 1 ? $"Paid a payout {(string.IsNullOrEmpty(pair.Value.Key)? string.Empty: $"of a pull payment ({pair.Value.Key})")}" : $"Paid {pair.Value.Value.Count} payouts {(string.IsNullOrEmpty(pair.Value.Key)? string.Empty: $"of a pull payment ({pair.Value.Key})")}";
}
if (uncoloredLabel is ReferenceLabel refLabel)
{
var refInLabel = string.IsNullOrEmpty(refLabel.Reference) ? string.Empty : $"({refLabel.Reference})";
switch (uncoloredLabel.Type)
{
case "invoice":
coloredLabel.Tooltip = $"Received through an invoice {refInLabel}";
coloredLabel.Link = string.IsNullOrEmpty(refLabel.Reference)
? null
: _linkGenerator.InvoiceLink(refLabel.Reference, request.Scheme, request.Host, request.PathBase);
break;
case "payment-request":
coloredLabel.Tooltip = $"Received through a payment request {refInLabel}";
coloredLabel.Link = string.IsNullOrEmpty(refLabel.Reference)
? null
: _linkGenerator.PaymentRequestLink(refLabel.Reference, request.Scheme, request.Host, request.PathBase);
break;
case "app":
coloredLabel.Tooltip = $"Received through an app {refInLabel}";
coloredLabel.Link = string.IsNullOrEmpty(refLabel.Reference)
? null
: _linkGenerator.AppLink(refLabel.Reference, request.Scheme, request.Host, request.PathBase);
break;
case "pj-exposed":
coloredLabel.Tooltip = $"This UTXO was exposed through a PayJoin proposal for an invoice {refInLabel}";
coloredLabel.Link = string.IsNullOrEmpty(refLabel.Reference)
? null
: _linkGenerator.InvoiceLink(refLabel.Reference, request.Scheme, request.Host, request.PathBase);
break;
}
}
else if (uncoloredLabel is PayoutLabel payoutLabel)
{
coloredLabel.Tooltip = payoutLabel.PullPaymentPayouts?.Count switch
{
null => PayoutLabelText(),
0 => PayoutLabelText(),
1 => PayoutLabelText(payoutLabel.PullPaymentPayouts.First()),
_ =>
$"<ul>{string.Join(string.Empty, payoutLabel.PullPaymentPayouts.Select(pair => $"<li>{PayoutLabelText(pair)}</li>"))}</ul>"
};
coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.WalletId)
? null
: _linkGenerator.PayoutLink(payoutLabel.WalletId, null, PayoutState.Completed, request.Scheme, request.Host,
request.PathBase);
}
else if (uncoloredLabel.Text == "payjoin")
{
coloredLabel.Tooltip = $"This UTXO was part of a PayJoin transaction.";
}
return coloredLabel;
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
readonly string[] LabelColorScheme =
{
"#fbca04",
"#0e8a16",
"#ff7619",
"#84b6eb",
"#5319e7",
"#cdcdcd",
"#cc317c",
};
readonly int MaxLabelSize = 20;
async public Task<RawLabel> BuildLabel(
WalletBlobInfo walletBlobInfo,
HttpRequest request,
WalletTransactionInfo walletTransactionInfo,
WalletId walletId,
string transactionId,
string label
)
{
label = label.Trim().TrimStart('{').ToLowerInvariant().Replace(',', ' ').Truncate(MaxLabelSize);
var labels = GetWalletColoredLabels(walletBlobInfo, request);
if (!labels.Any(l => l.Text.Equals(label, StringComparison.OrdinalIgnoreCase)))
{
var chosenColor = ChooseBackgroundColor(walletBlobInfo, request);
walletBlobInfo.LabelColors.Add(label, chosenColor);
await _walletRepository.SetWalletInfo(walletId, walletBlobInfo);
}
return new RawLabel(label);
}
private string ChooseBackgroundColor(
WalletBlobInfo walletBlobInfo,
HttpRequest request
)
{
var labels = GetWalletColoredLabels(walletBlobInfo, request);
List<string> allColors = new List<string>();
allColors.AddRange(LabelColorScheme);
allColors.AddRange(labels.Select(l => l.Color));
var chosenColor =
allColors
.GroupBy(k => k)
.OrderBy(k => k.Count())
.ThenBy(k =>
{
var indexInColorScheme = Array.IndexOf(LabelColorScheme, k.Key);
// Ensures that any label color which may not be in our label color scheme is given the least priority
return indexInColorScheme == -1 ? double.PositiveInfinity : indexInColorScheme;
})
.First().Key;
return chosenColor;
}
private string TextColor(string bgColor)
{
int nThreshold = 105;
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);
}
}
}

View file

@ -25,6 +25,7 @@ namespace BTCPayServer.Services
// Done in DbMigrationsHostedService
public int? MigratedInvoiceTextSearchPages { get; set; }
public int? MigratedTransactionLabels { get; set; }
public bool MigrateAppCustomOption { get; set; }
public bool MigratePayoutDestinationId { get; set; }
public bool AddInitialUserBlob { get; set; }
@ -32,5 +33,6 @@ namespace BTCPayServer.Services
public bool LighingAddressDatabaseMigration { get; set; }
public bool AddStoreToPayout { get; set; }
public bool MigrateEmailServerDisableTLSCerts { get; set; }
public bool MigrateWalletColors { get; set; }
}
}

View file

@ -1,12 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Labels;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.Crypto;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services
{
#nullable enable
public record WalletObjectId(WalletId WalletId, string Type, string Id);
#nullable restore
public class WalletRepository
{
private readonly ApplicationDbContextFactory _ContextFactory;
@ -16,74 +26,256 @@ namespace BTCPayServer.Services
_ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory));
}
public async Task SetWalletInfo(WalletId walletId, WalletBlobInfo blob)
{
ArgumentNullException.ThrowIfNull(walletId);
using var ctx = _ContextFactory.CreateContext();
var walletData = new WalletData() { Id = walletId.ToString() };
walletData.SetBlobInfo(blob);
var entity = await ctx.Wallets.AddAsync(walletData);
entity.State = EntityState.Modified;
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // Does not exists
{
entity.State = EntityState.Added;
await ctx.SaveChangesAsync();
}
}
public async Task<Dictionary<string, WalletTransactionInfo>> GetWalletTransactionsInfo(WalletId walletId, string[] transactionIds = null)
{
ArgumentNullException.ThrowIfNull(walletId);
using var ctx = _ContextFactory.CreateContext();
return (await ctx.WalletTransactions
.Where(w => w.WalletDataId == walletId.ToString())
.Where(data => transactionIds == null || transactionIds.Contains(data.TransactionId))
.Select(w => w)
.ToArrayAsync())
.ToDictionary(w => w.TransactionId, w => w.GetBlobInfo());
IQueryable<WalletObjectLinkData> wols;
IQueryable<WalletObjectData> wos;
// If we are using postgres, the `transactionIds.Contains(w.ChildId)` result in a long query like `ANY(@txId1, @txId2, @txId3, @txId4)`
// Such request isn't well optimized by postgres, and create different requests clogging up
// pg_stat_statements output, making it impossible to analyze the performance impact of this query.
if (ctx.Database.IsNpgsql() && transactionIds is not null)
{
wos = ctx.WalletObjects
.FromSqlInterpolated($"SELECT wos.* FROM unnest({transactionIds}) t JOIN \"WalletObjects\" wos ON wos.\"WalletId\"={walletId.ToString()} AND wos.\"Type\"={WalletObjectData.Types.Tx} AND wos.\"Id\"=t")
.AsNoTracking();
wols = ctx.WalletObjectLinks
.FromSqlInterpolated($"SELECT wol.* FROM unnest({transactionIds}) t JOIN \"WalletObjectLinks\" wol ON wol.\"WalletId\"={walletId.ToString()} AND wol.\"ChildType\"={WalletObjectData.Types.Tx} AND wol.\"ChildId\"=t")
.AsNoTracking();
}
else // Unefficient path
{
wos = ctx.WalletObjects
.AsNoTracking()
.Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Tx && (transactionIds == null || transactionIds.Contains(w.Id)));
wols = ctx.WalletObjectLinks
.AsNoTracking()
.Where(w => w.WalletId == walletId.ToString() && w.ChildType == WalletObjectData.Types.Tx && (transactionIds == null || transactionIds.Contains(w.ChildId)));
}
var links = await wols
.Select(tx =>
new
{
TxId = tx.ChildId,
AssociatedDataId = tx.ParentId,
AssociatedDataType = tx.ParentType,
AssociatedData = tx.Parent.Data
})
.ToArrayAsync();
var objs = await wos
.Select(tx =>
new
{
TxId = tx.Id,
Data = tx.Data
})
.ToArrayAsync();
var result = new Dictionary<string, WalletTransactionInfo>(objs.Length);
foreach (var obj in objs)
{
var data = obj.Data is null ? null : JObject.Parse(obj.Data);
result.Add(obj.TxId, new WalletTransactionInfo(walletId)
{
Comment = data?["comment"]?.Value<string>()
});
}
foreach (var row in links)
{
JObject data = row.AssociatedData is null ? null : JObject.Parse(row.AssociatedData);
var info = result[row.TxId];
if (row.AssociatedDataType == WalletObjectData.Types.Label)
{
info.LabelColors.TryAdd(row.AssociatedDataId, data["color"]?.Value<string>() ?? "#000");
}
else
{
info.Attachments.Add(new Attachment(row.AssociatedDataType, row.AssociatedDataId, row.AssociatedData is null ? null : JObject.Parse(row.AssociatedData)));
}
}
return result;
}
public async Task<WalletBlobInfo> GetWalletInfo(WalletId walletId)
#nullable enable
public async Task<(string Label, string Color)[]> GetWalletLabels(WalletId walletId)
{
ArgumentNullException.ThrowIfNull(walletId);
using var ctx = _ContextFactory.CreateContext();
var data = await ctx.Wallets
.Where(w => w.Id == walletId.ToString())
.Select(w => w)
.FirstOrDefaultAsync();
return data?.GetBlobInfo() ?? new WalletBlobInfo();
return (await ctx.WalletObjects
.AsNoTracking()
.Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label)
.Select(o => new { o.Id, o.Data })
.ToArrayAsync())
.Select(o => (o.Id, JObject.Parse(o.Data)["color"]!.Value<string>()!))
.ToArray();
}
public async Task SetWalletTransactionInfo(WalletId walletId, string transactionId, WalletTransactionInfo walletTransactionInfo)
public async Task EnsureWalletObjectLink(WalletObjectId parent, WalletObjectId child)
{
ArgumentNullException.ThrowIfNull(walletId);
ArgumentNullException.ThrowIfNull(transactionId);
using var ctx = _ContextFactory.CreateContext();
var walletData = new WalletTransactionData() { WalletDataId = walletId.ToString(), TransactionId = transactionId };
walletData.SetBlobInfo(walletTransactionInfo);
var entity = await ctx.WalletTransactions.AddAsync(walletData);
entity.State = EntityState.Modified;
var l = new WalletObjectLinkData()
{
WalletId = parent.WalletId.ToString(),
ChildType = child.Type,
ChildId = child.Id,
ParentType = parent.Type,
ParentId = parent.Id
};
ctx.WalletObjectLinks.Add(l);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // Does not exists
catch (DbUpdateException) // already exists
{
entity.State = EntityState.Added;
}
}
public static int MaxCommentSize = 200;
public async Task SetWalletObjectComment(WalletObjectId id, string comment)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(comment);
if (!string.IsNullOrEmpty(comment))
await ModifyWalletObjectData(id, (o) => o["comment"] = comment.Trim().Truncate(MaxCommentSize));
else
await ModifyWalletObjectData(id, (o) => o.Remove("comment"));
}
static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null)
{
return new WalletObjectData()
{
WalletId = id.WalletId.ToString(),
Type = id.Type,
Id = id.Id,
Data = data?.ToString()
};
}
public async Task ModifyWalletObjectData(WalletObjectId id, Action<JObject> modify)
{
ArgumentNullException.ThrowIfNull(id);
ArgumentNullException.ThrowIfNull(modify);
using var ctx = _ContextFactory.CreateContext();
var obj = await ctx.WalletObjects.FindAsync(id.WalletId.ToString(), id.Type, id.Id);
if (obj is null)
{
obj = NewWalletObjectData(id);
ctx.WalletObjects.Add(obj);
}
var currentData = obj.Data is null ? new JObject() : JObject.Parse(obj.Data);
modify(currentData);
obj.Data = currentData.ToString();
if (obj.Data == "{}")
obj.Data = null;
await ctx.SaveChangesAsync();
}
const int MaxLabelSize = 50;
public async Task AddWalletObjectLabels(WalletObjectId id, params string[] labels)
{
ArgumentNullException.ThrowIfNull(id);
await EnsureWalletObject(id);
foreach (var l in labels.Select(l => l.Trim().Truncate(MaxLabelSize)))
{
var labelObjId = new WalletObjectId(id.WalletId, WalletObjectData.Types.Label, l);
await EnsureWalletObject(labelObjId, new JObject()
{
["color"] = ColorPalette.Default.DeterministicColor(l)
});
await EnsureWalletObjectLink(labelObjId, id);
}
}
public Task AddWalletTransactionAttachment(WalletId walletId, uint256 txId, Attachment attachment)
{
return AddWalletTransactionAttachment(walletId, txId, new[] { attachment });
}
public async Task AddWalletTransactionAttachment(WalletId walletId, uint256 txId, IEnumerable<Attachment> attachments)
{
ArgumentNullException.ThrowIfNull(walletId);
ArgumentNullException.ThrowIfNull(txId);
var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, txId.ToString());
await EnsureWalletObject(txObjId);
foreach (var attachment in attachments)
{
var labelObjId = new WalletObjectId(walletId, WalletObjectData.Types.Label, attachment.Type);
await EnsureWalletObject(labelObjId, new JObject()
{
["color"] = ColorPalette.Default.DeterministicColor(attachment.Type)
});
await EnsureWalletObjectLink(labelObjId, txObjId);
if (attachment.Data is not null || attachment.Id.Length != 0)
{
var data = new WalletObjectId(walletId, attachment.Type, attachment.Id);
await EnsureWalletObject(data, attachment.Data);
await EnsureWalletObjectLink(data, txObjId);
}
}
}
public async Task RemoveWalletObjectLabels(WalletObjectId id, params string[] labels)
{
ArgumentNullException.ThrowIfNull(id);
foreach (var l in labels.Select(l => l.Trim()))
{
var labelObjId = new WalletObjectId(id.WalletId, WalletObjectData.Types.Label, l);
using var ctx = _ContextFactory.CreateContext();
ctx.WalletObjectLinks.Remove(new WalletObjectLinkData()
{
WalletId = id.WalletId.ToString(),
ChildId = id.Id,
ChildType = id.Type,
ParentId = labelObjId.Id,
ParentType = labelObjId.Type
});
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // the Wallet does not exists in the DB
catch (DbUpdateException) // Already deleted, do nothing
{
await SetWalletInfo(walletId, new WalletBlobInfo());
await ctx.SaveChangesAsync();
}
}
}
public async Task SetWalletObject(WalletObjectId id, JObject? data)
{
ArgumentNullException.ThrowIfNull(id);
using var ctx = _ContextFactory.CreateContext();
var o = NewWalletObjectData(id, data);
ctx.WalletObjects.Add(o);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
ctx.Entry(o).State = EntityState.Modified;
await ctx.SaveChangesAsync();
}
}
public async Task EnsureWalletObject(WalletObjectId id, JObject? data = null)
{
ArgumentNullException.ThrowIfNull(id);
using var ctx = _ContextFactory.CreateContext();
ctx.WalletObjects.Add(NewWalletObjectData(id, data));
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
}
}
#nullable restore
}
}

View file

@ -41,7 +41,7 @@ namespace BTCPayServer.Services.Wallets.Export
if (_walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
{
model.Labels = transactionInfo.Labels?.Select(l => l.Value.Text).ToList();
model.Labels = transactionInfo.LabelColors?.Select(l => l.Key).ToList();
model.Comment = transactionInfo.Comment;
}

View file

@ -10,10 +10,10 @@
@transaction.Timestamp.ToBrowserDate()
</td>
<td class="text-start">
@if (transaction.Labels.Any())
@if (transaction.Tags.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in transaction.Labels)
@foreach (var label in transaction.Tags)
{
<div class="badge-container rounded-2" style="background-color:@label.Color;">
<div class="badge transactionLabel position-relative d-block"
@ -79,7 +79,7 @@
<div class="py-2 px-3 d-flex flex-wrap gap-2">
@foreach (var label in Model.Labels)
{
@if (transaction.Labels.Contains(label))
@if (transaction.Tags.Any(l => l.Text == label.Text))
{
<button name="removelabel" class="bg-transparent border-0 p-0" type="submit" value="@label.Text"><span class="badge" style="background-color:@label.Color;color:@label.TextColor"><span class="fa fa-check"></span> @label.Text</span></button>
}

View file

@ -708,6 +708,7 @@
"LabelData": {
"type": "object",
"additionalProperties": true,
"deprecated": true,
"properties": {
"type": {
"type": "string",
@ -762,6 +763,7 @@
},
"labels": {
"description": "Labels linked to this transaction",
"deprecated": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/LabelData"
@ -810,6 +812,7 @@
},
"labels": {
"description": "Labels linked to this transaction",
"deprecated": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/LabelData"
@ -904,6 +907,7 @@
},
"labels": {
"nullable": true,
"deprecated": true,
"description": "Transaction labels",
"type": "array",
"items": {