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 System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models namespace BTCPayServer.Client.Models
{ {
[Obsolete]
public class LabelData public class LabelData
{ {
public string Type { get; set; } public string Type { get; set; }

View file

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

View file

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

View file

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

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
[Obsolete]
public class WalletData public class WalletData
{ {
[System.ComponentModel.DataAnnotations.Key] [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; using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
[Obsolete]
public class WalletTransactionData public class WalletTransactionData
{ {
public string WalletDataId { get; set; } 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) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
{ {
@ -189,6 +189,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -845,6 +846,52 @@ namespace BTCPayServer.Migrations
b.ToTable("Wallets"); 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 => modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{ {
b.Property<string>("WalletDataId") b.Property<string>("WalletDataId")
@ -1333,6 +1380,25 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData"); 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 => modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{ {
b.HasOne("BTCPayServer.Data.WalletData", "WalletData") b.HasOne("BTCPayServer.Data.WalletData", "WalletData")
@ -1475,6 +1541,13 @@ namespace BTCPayServer.Migrations
b.Navigation("WalletTransactions"); b.Navigation("WalletTransactions");
}); });
modelBuilder.Entity("BTCPayServer.Data.WalletObjectData", b =>
{
b.Navigation("ChildLinks");
b.Navigation("ParentLinks");
});
modelBuilder.Entity("BTCPayServer.Data.WebhookData", b => modelBuilder.Entity("BTCPayServer.Data.WebhookData", b =>
{ {
b.Navigation("Deliveries"); b.Navigation("Deliveries");

View file

@ -483,93 +483,6 @@ namespace BTCPayServer.Tests
} }
#endif #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] [Fact]
public void DeterministicUTXOSorter() public void DeterministicUTXOSorter()
{ {

View file

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

View file

@ -40,6 +40,7 @@ using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Storage.Models; using BTCPayServer.Storage.Models;
@ -51,6 +52,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBitcoin.Payment; using NBitcoin.Payment;
@ -785,9 +787,9 @@ namespace BTCPayServer.Tests
tx = Assert.Single(transactions.Transactions); tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment); Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Text)); Assert.Contains("test", tx.Tags.Select(l => l.Text));
Assert.Contains("test2", tx.Labels.Select(l => l.Text)); Assert.Contains("test2", tx.Tags.Select(l => l.Text));
Assert.Equal(2, tx.Labels.GroupBy(l => l.Color).Count()); Assert.Equal(2, tx.Tags.GroupBy(l => l.Color).Count());
Assert.IsType<RedirectToActionResult>( Assert.IsType<RedirectToActionResult>(
await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2")); await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2"));
@ -797,12 +799,9 @@ namespace BTCPayServer.Tests
tx = Assert.Single(transactions.Transactions); tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment); Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Text)); Assert.Contains("test", tx.Tags.Select(l => l.Text));
Assert.DoesNotContain("test2", tx.Labels.Select(l => l.Text)); Assert.DoesNotContain("test2", tx.Tags.Select(l => l.Text));
Assert.Single(tx.Labels.GroupBy(l => l.Color)); Assert.Single(tx.Tags.GroupBy(l => l.Color));
var walletInfo = await tester.PayTester.GetService<WalletRepository>().GetWalletInfo(walletId);
Assert.Single(walletInfo.LabelColors); // the test2 color should have been removed
} }
[Fact(Timeout = LongRunningTestTimeout)] [Fact(Timeout = LongRunningTestTimeout)]
@ -2521,6 +2520,79 @@ namespace BTCPayServer.Tests
Assert.True(lnMethod.IsInternalNode); 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)] [Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")] [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 EventAggregator _eventAggregator;
private readonly WalletReceiveService _walletReceiveService; private readonly WalletReceiveService _walletReceiveService;
private readonly IFeeProviderFactory _feeProviderFactory; private readonly IFeeProviderFactory _feeProviderFactory;
private readonly LabelFactory _labelFactory;
private readonly UTXOLocker _utxoLocker; private readonly UTXOLocker _utxoLocker;
public GreenfieldStoreOnChainWalletsController( public GreenfieldStoreOnChainWalletsController(
@ -69,7 +68,6 @@ namespace BTCPayServer.Controllers.Greenfield
EventAggregator eventAggregator, EventAggregator eventAggregator,
WalletReceiveService walletReceiveService, WalletReceiveService walletReceiveService,
IFeeProviderFactory feeProviderFactory, IFeeProviderFactory feeProviderFactory,
LabelFactory labelFactory,
UTXOLocker utxoLocker UTXOLocker utxoLocker
) )
{ {
@ -86,7 +84,6 @@ namespace BTCPayServer.Controllers.Greenfield
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_walletReceiveService = walletReceiveService; _walletReceiveService = walletReceiveService;
_feeProviderFactory = feeProviderFactory; _feeProviderFactory = feeProviderFactory;
_labelFactory = labelFactory;
_utxoLocker = utxoLocker; _utxoLocker = utxoLocker;
} }
@ -202,7 +199,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (!string.IsNullOrWhiteSpace(labelFilter)) if (!string.IsNullOrWhiteSpace(labelFilter))
{ {
walletTransactionsInfoAsync.TryGetValue(t.TransactionId.ToString(), out var transactionInfo); 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); filteredList.Add(t);
} }
if (statusFilter?.Any() is true) if (statusFilter?.Any() is true)
@ -270,36 +267,18 @@ namespace BTCPayServer.Controllers.Greenfield
} }
var walletId = new WalletId(storeId, cryptoCode); var walletId = new WalletId(storeId, cryptoCode);
var walletTransactionsInfoAsync = _walletRepository.GetWalletTransactionsInfo(walletId); var txObjectId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
if (!(await walletTransactionsInfoAsync).TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
if (request.Comment != null) if (request.Comment != null)
{ {
walletTransactionInfo.Comment = request.Comment.Trim().Truncate(WalletTransactionDataExtensions.MaxCommentSize); await _walletRepository.SetWalletObjectComment(txObjectId, request.Comment);
} }
if (request.Labels != null) if (request.Labels != null)
{ {
var walletBlobInfo = await _walletRepository.GetWalletInfo(walletId); await _walletRepository.AddWalletObjectLabels(txObjectId, request.Labels.ToArray());
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.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
var walletTransactionsInfo = var walletTransactionsInfo =
(await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId })) (await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId }))
.Values .Values
@ -319,19 +298,20 @@ namespace BTCPayServer.Controllers.Greenfield
var wallet = _btcPayWalletProvider.GetWallet(network); var wallet = _btcPayWalletProvider.GetWallet(network);
var walletId = new WalletId(storeId, cryptoCode); var walletId = new WalletId(storeId, cryptoCode);
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId);
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation); 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 => return Ok(utxos.Select(coin =>
{ {
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
var labels = info?.Labels ?? new Dictionary<string, LabelData>();
return new OnChainWalletUTXOData() return new OnChainWalletUTXOData()
{ {
Outpoint = coin.OutPoint, Outpoint = coin.OutPoint,
Amount = coin.Value.GetValue(network), Amount = coin.Value.GetValue(network),
Comment = info?.Comment, 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, Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink,
coin.OutPoint.Hash.ToString()), coin.OutPoint.Hash.ToString()),
Timestamp = coin.Timestamp, Timestamp = coin.Timestamp,
@ -592,8 +572,7 @@ namespace BTCPayServer.Controllers.Greenfield
payjoinPSBT.Finalize(); payjoinPSBT.Finalize();
var payjoinTransaction = payjoinPSBT.ExtractTransaction(); var payjoinTransaction = payjoinPSBT.ExtractTransaction();
var hash = payjoinTransaction.GetHash(); var hash = payjoinTransaction.GetHash();
_eventAggregator.Publish(new UpdateTransactionLabel(new WalletId(Store.Id, cryptoCode), hash, await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode), hash, Attachment.Payjoin());
UpdateTransactionLabel.PayjoinLabelTemplate()));
broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction); broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
if (broadcastResult.Success) if (broadcastResult.Success)
{ {
@ -676,7 +655,9 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
TransactionHash = tx.TransactionId, TransactionHash = tx.TransactionId,
Comment = walletTransactionsInfoAsync?.Comment ?? string.Empty, 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), Amount = tx.BalanceChange.GetValue(wallet.Network),
BlockHash = tx.BlockHash, BlockHash = tx.BlockHash,
BlockHeight = tx.Height, BlockHeight = tx.Height,

View file

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

View file

@ -39,6 +39,8 @@ using NBXplorer.DerivationStrategy;
using NBXplorer.Models; using NBXplorer.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
using StoreData = BTCPayServer.Data.StoreData; using StoreData = BTCPayServer.Data.StoreData;
using Microsoft.AspNetCore.Routing;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Controllers namespace BTCPayServer.Controllers
{ {
@ -60,11 +62,10 @@ namespace BTCPayServer.Controllers
private readonly IFeeProviderFactory _feeRateProvider; private readonly IFeeProviderFactory _feeRateProvider;
private readonly BTCPayWalletProvider _walletProvider; private readonly BTCPayWalletProvider _walletProvider;
private readonly WalletReceiveService _walletReceiveService; private readonly WalletReceiveService _walletReceiveService;
private readonly EventAggregator _EventAggregator;
private readonly SettingsRepository _settingsRepository; private readonly SettingsRepository _settingsRepository;
private readonly DelayedTransactionBroadcaster _broadcaster; private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly PayjoinClient _payjoinClient; private readonly PayjoinClient _payjoinClient;
private readonly LabelFactory _labelFactory; private readonly LinkGenerator _linkGenerator;
private readonly PullPaymentHostedService _pullPaymentHostedService; private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly UTXOLocker _utxoLocker; private readonly UTXOLocker _utxoLocker;
private readonly WalletHistogramService _walletHistogramService; private readonly WalletHistogramService _walletHistogramService;
@ -84,16 +85,16 @@ namespace BTCPayServer.Controllers
IFeeProviderFactory feeRateProvider, IFeeProviderFactory feeRateProvider,
BTCPayWalletProvider walletProvider, BTCPayWalletProvider walletProvider,
WalletReceiveService walletReceiveService, WalletReceiveService walletReceiveService,
EventAggregator eventAggregator,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
DelayedTransactionBroadcaster broadcaster, DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient, PayjoinClient payjoinClient,
LabelFactory labelFactory,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService, PullPaymentHostedService pullPaymentHostedService,
UTXOLocker utxoLocker) UTXOLocker utxoLocker,
LinkGenerator linkGenerator)
{ {
_currencyTable = currencyTable; _currencyTable = currencyTable;
_linkGenerator = linkGenerator;
Repository = repo; Repository = repo;
WalletRepository = walletRepository; WalletRepository = walletRepository;
RateFetcher = rateProvider; RateFetcher = rateProvider;
@ -105,11 +106,9 @@ namespace BTCPayServer.Controllers
_feeRateProvider = feeRateProvider; _feeRateProvider = feeRateProvider;
_walletProvider = walletProvider; _walletProvider = walletProvider;
_walletReceiveService = walletReceiveService; _walletReceiveService = walletReceiveService;
_EventAggregator = eventAggregator;
_settingsRepository = settingsRepository; _settingsRepository = settingsRepository;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_payjoinClient = payjoinClient; _payjoinClient = payjoinClient;
_labelFactory = labelFactory;
_pullPaymentHostedService = pullPaymentHostedService; _pullPaymentHostedService = pullPaymentHostedService;
_utxoLocker = utxoLocker; _utxoLocker = utxoLocker;
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
@ -146,58 +145,19 @@ namespace BTCPayServer.Controllers
if (paymentMethod == null) if (paymentMethod == null)
return NotFound(); return NotFound();
var walletBlobInfoAsync = WalletRepository.GetWalletInfo(walletId); var txObjId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
var wallet = _walletProvider.GetWallet(paymentMethod.Network); var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletBlobInfo = await walletBlobInfoAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
if (addlabel != null) if (addlabel != null)
{ {
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo)) await WalletRepository.AddWalletObjectLabels(txObjId, addlabel);
{
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);
}
} }
else if (removelabel != null) else if (removelabel != null)
{ {
removelabel = removelabel.Trim(); await WalletRepository.RemoveWalletObjectLabels(txObjId, removelabel);
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);
}
}
} }
else if (addcomment != null) else if (addcomment != null)
{ {
addcomment = addcomment.Trim().Truncate(WalletTransactionDataExtensions.MaxCommentSize); await WalletRepository.SetWalletObjectComment(txObjId, addcomment);
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() }); return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
} }
@ -267,15 +227,17 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
var wallet = _walletProvider.GetWallet(paymentMethod.Network); 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 // We can't filter at the database level if we need to apply label filter
var preFiltering = string.IsNullOrEmpty(labelFilter); var preFiltering = string.IsNullOrEmpty(labelFilter);
var transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null); var transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null);
var walletBlob = await walletBlobAsync; var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var model = new ListTransactionsViewModel { Skip = skip, Count = count }; 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) if (labelFilter != null)
{ {
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } }; model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
@ -305,14 +267,13 @@ namespace BTCPayServer.Controllers
if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo)) if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
{ {
var labels = _labelFactory.ColorizeTransactionLabels(walletBlob, transactionInfo, Request); var labels = CreateTransactionTagModels(transactionInfo);
vm.Labels.AddRange(labels); vm.Tags.AddRange(labels);
model.Labels.AddRange(labels);
vm.Comment = transactionInfo.Comment; vm.Comment = transactionInfo.Comment;
} }
if (labelFilter == null || 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); model.Transactions.Add(vm);
} }
@ -613,17 +574,15 @@ namespace BTCPayServer.Controllers
var schemeSettings = GetDerivationSchemeSettings(walletId); var schemeSettings = GetDerivationSchemeSettings(walletId);
if (schemeSettings is null) if (schemeSettings is null)
return NotFound(); return NotFound();
var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId);
var utxos = await _walletProvider.GetWallet(network) var utxos = await _walletProvider.GetWallet(network)
.GetUnspentCoins(schemeSettings.AccountDerivation, false, cancellation); .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 => vm.InputsAvailable = utxos.Select(coin =>
{ {
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
var labels = info?.Labels == null var labels = CreateTransactionTagModels(info).ToList();
? new List<ColoredLabel>()
: _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request).ToList();
return new WalletSendModel.InputSelectionOption() return new WalletSendModel.InputSelectionOption()
{ {
Outpoint = coin.OutPoint.ToString(), Outpoint = coin.OutPoint.ToString(),
@ -1359,6 +1318,117 @@ namespace BTCPayServer.Controllers
private string GetUserId() => _userManager.GetUserId(User); private string GetUserId() => _userManager.GetUserId(User);
private StoreData GetCurrentStore() => HttpContext.GetStoreData(); 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 public class WalletReceiveViewModel

View file

@ -34,22 +34,24 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
private readonly ExplorerClientProvider _explorerClientProvider; private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly ApplicationDbContextFactory _dbContextFactory; private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly EventAggregator _eventAggregator;
private readonly NotificationSender _notificationSender; private readonly NotificationSender _notificationSender;
private readonly Logs Logs; private readonly Logs Logs;
public WalletRepository WalletRepository { get; }
public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider, public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider,
WalletRepository walletRepository,
ExplorerClientProvider explorerClientProvider, ExplorerClientProvider explorerClientProvider,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
ApplicationDbContextFactory dbContextFactory, ApplicationDbContextFactory dbContextFactory,
EventAggregator eventAggregator,
NotificationSender notificationSender, NotificationSender notificationSender,
Logs logs) Logs logs)
{ {
_btcPayNetworkProvider = btcPayNetworkProvider; _btcPayNetworkProvider = btcPayNetworkProvider;
WalletRepository = walletRepository;
_explorerClientProvider = explorerClientProvider; _explorerClientProvider = explorerClientProvider;
_jsonSerializerSettings = jsonSerializerSettings; _jsonSerializerSettings = jsonSerializerSettings;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_eventAggregator = eventAggregator;
_notificationSender = notificationSender; _notificationSender = notificationSender;
this.Logs = logs; this.Logs = logs;
} }
@ -426,13 +428,10 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
if (isInternal) if (isInternal)
{ {
payout.State = PayoutState.InProgress; payout.State = PayoutState.InProgress;
var walletId = new WalletId(payout.StoreDataId, newTransaction.CryptoCode); await WalletRepository.AddWalletTransactionAttachment(
_eventAggregator.Publish(new UpdateTransactionLabel(walletId, new WalletId(payout.StoreDataId, newTransaction.CryptoCode),
newTransaction.NewTransactionEvent.TransactionData.TransactionHash, newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(new () Attachment.Payout(payout.PullPaymentDataId, payout.Id));
{
{payout.PullPaymentDataId?? "", new List<string>{payout.Id}}
}, walletId.ToString())));
} }
else 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.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices namespace BTCPayServer.HostedServices
{ {
@ -45,10 +50,235 @@ namespace BTCPayServer.HostedServices
{ {
await MigratedInvoiceTextSearchToDb(settings.MigratedInvoiceTextSearchPages ?? 0); await MigratedInvoiceTextSearchToDb(settings.MigratedInvoiceTextSearchPages ?? 0);
} }
if (settings.MigratedTransactionLabels != int.MaxValue)
// Refresh settings since these operations may run for very long time {
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) private async Task MigratedInvoiceTextSearchToDb(int startFromPage)
{ {
// deleting legacy DBriize database if present // deleting legacy DBriize database if present
@ -97,7 +327,7 @@ namespace BTCPayServer.HostedServices
textSearch.Add(invoice.RefundMail); textSearch.Add(invoice.RefundMail);
// TODO: Are there more things to cache? PaymentData? // TODO: Are there more things to cache? PaymentData?
InvoiceRepository.AddToTextSearch(ctx, InvoiceRepository.AddToTextSearch(ctx,
new InvoiceData { Id = invoice.Id, InvoiceSearchData = new List<InvoiceSearchData>() }, new Data.InvoiceData { Id = invoice.Id, InvoiceSearchData = new List<InvoiceSearchData>() },
textSearch.ToArray()); textSearch.ToArray());
} }

View file

@ -1,3 +1,4 @@
#nullable enable
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
@ -14,6 +15,8 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Labels; using BTCPayServer.Services.Labels;
using BTCPayServer.Services.PaymentRequests; using BTCPayServer.Services.PaymentRequests;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices namespace BTCPayServer.HostedServices
{ {
@ -32,7 +35,6 @@ namespace BTCPayServer.HostedServices
protected override void SubscribeToEvents() protected override void SubscribeToEvents()
{ {
Subscribe<InvoiceEvent>(); Subscribe<InvoiceEvent>();
Subscribe<UpdateTransactionLabel>();
} }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) 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 walletId = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
var transactionId = bitcoinLikePaymentData.Outpoint.Hash; 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)) 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)) foreach (var appId in AppService.GetAppInternalTags(invoiceEvent.Invoice))
{ {
labels.Add(UpdateTransactionLabel.AppLabelTemplate(appId)); labels.Add(Attachment.App(appId));
} }
await _walletRepository.AddWalletTransactionAttachment(walletId, transactionId, labels);
_eventAggregator.Publish(new UpdateTransactionLabel(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<SettingsRepository>();
services.TryAddSingleton<ISettingsRepository>(provider => provider.GetService<SettingsRepository>()); services.TryAddSingleton<ISettingsRepository>(provider => provider.GetService<SettingsRepository>());
services.TryAddSingleton<IStoreRepository>(provider => provider.GetService<StoreRepository>()); services.TryAddSingleton<IStoreRepository>(provider => provider.GetService<StoreRepository>());
services.TryAddSingleton<LabelFactory>();
services.TryAddSingleton<TorServices>(); services.TryAddSingleton<TorServices>();
services.AddSingleton<IHostedService>(provider => provider.GetRequiredService<TorServices>()); services.AddSingleton<IHostedService>(provider => provider.GetRequiredService<TorServices>());
services.AddSingleton<ISwaggerProvider, DefaultSwaggerProvider>(); services.AddSingleton<ISwaggerProvider, DefaultSwaggerProvider>();

View file

@ -28,6 +28,7 @@ using Microsoft.Extensions.Options;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
using NBXplorer; using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using PeterO.Cbor; using PeterO.Cbor;
using PayoutData = BTCPayServer.Data.PayoutData; using PayoutData = BTCPayServer.Data.PayoutData;
@ -215,6 +216,12 @@ namespace BTCPayServer.Hosting
settings.MigrateEmailServerDisableTLSCerts = true; settings.MigrateEmailServerDisableTLSCerts = true;
await _Settings.UpdateSetting(settings); await _Settings.UpdateSetting(settings);
} }
if (!settings.MigrateWalletColors)
{
await MigrateMigrateLabels();
settings.MigrateWalletColors = true;
await _Settings.UpdateSetting(settings);
}
} }
catch (Exception ex) 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. // 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. // 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. // 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 string Link { get; set; }
public bool Positive { get; set; } public bool Positive { get; set; }
public string Balance { 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 List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
public override int CurrentPageCount => Transactions.Count; public override int CurrentPageCount => Transactions.Count;
public string CryptoCode { get; set; } public string CryptoCode { get; set; }

View file

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

View file

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

View file

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

View file

@ -39,6 +39,7 @@ namespace BTCPayServer.PayoutProcessors.OnChain
ILoggerFactory logger, ILoggerFactory logger,
BitcoinLikePayoutHandler bitcoinLikePayoutHandler, BitcoinLikePayoutHandler bitcoinLikePayoutHandler,
EventAggregator eventAggregator, EventAggregator eventAggregator,
WalletRepository walletRepository,
StoreRepository storeRepository, StoreRepository storeRepository,
PayoutProcessorData payoutProcesserSettings, PayoutProcessorData payoutProcesserSettings,
PullPaymentHostedService pullPaymentHostedService, PullPaymentHostedService pullPaymentHostedService,
@ -51,8 +52,11 @@ namespace BTCPayServer.PayoutProcessors.OnChain
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_bitcoinLikePayoutHandler = bitcoinLikePayoutHandler; _bitcoinLikePayoutHandler = bitcoinLikePayoutHandler;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
WalletRepository = walletRepository;
} }
public WalletRepository WalletRepository { get; }
protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts) protected override async Task Process(ISupportedPaymentMethod paymentMethod, List<PayoutData> payouts)
{ {
var storePaymentMethod = paymentMethod as DerivationSchemeSettings; var storePaymentMethod = paymentMethod as DerivationSchemeSettings;
@ -171,12 +175,9 @@ namespace BTCPayServer.PayoutProcessors.OnChain
var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode); var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode);
foreach (PayoutData payoutData in transfersProcessing) foreach (PayoutData payoutData in transfersProcessing)
{ {
_eventAggregator.Publish(new UpdateTransactionLabel(walletId, await WalletRepository.AddWalletTransactionAttachment(walletId,
txHash, txHash,
UpdateTransactionLabel.PayoutTemplate(new () Attachment.Payout(payoutData.PullPaymentDataId, payoutData.Id));
{
{payoutData.PullPaymentDataId?? "", new List<string>{payoutData.Id}}
}, walletId.ToString())));
} }
await Task.WhenAny(tcs.Task, task); 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 namespace BTCPayServer.Services.Labels
{ {
[Obsolete]
public abstract class Label : LabelData public abstract class Label : LabelData
{ {
public virtual Label Merge(LabelData other)
{
return this;
}
static void FixLegacy(JObject jObj, ReferenceLabel refLabel) static void FixLegacy(JObject jObj, ReferenceLabel refLabel)
{ {
if (refLabel.Reference is null && jObj.ContainsKey("id")) if (refLabel.Reference is null && jObj.ContainsKey("id"))
@ -49,6 +45,18 @@ namespace BTCPayServer.Services.Labels
rawLabel.Type = "raw"; rawLabel.Type = "raw";
FixLegacy(jObj, (Label)rawLabel); 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) public static Label Parse(string str)
{ {
ArgumentNullException.ThrowIfNull(str); ArgumentNullException.ThrowIfNull(str);
@ -95,6 +103,7 @@ namespace BTCPayServer.Services.Labels
} }
} }
[Obsolete]
public class RawLabel : Label public class RawLabel : Label
{ {
public RawLabel() public RawLabel()
@ -106,6 +115,7 @@ namespace BTCPayServer.Services.Labels
Text = text; Text = text;
} }
} }
[Obsolete]
public class ReferenceLabel : Label public class ReferenceLabel : Label
{ {
public ReferenceLabel() public ReferenceLabel()
@ -121,6 +131,7 @@ namespace BTCPayServer.Services.Labels
[JsonProperty("ref")] [JsonProperty("ref")]
public string Reference { get; set; } public string Reference { get; set; }
} }
[Obsolete]
public class PayoutLabel : Label public class PayoutLabel : Label
{ {
public PayoutLabel() public PayoutLabel()
@ -130,21 +141,5 @@ namespace BTCPayServer.Services.Labels
} }
public Dictionary<string, List<string>> PullPaymentPayouts { get; set; } = new(); 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 // Done in DbMigrationsHostedService
public int? MigratedInvoiceTextSearchPages { get; set; } public int? MigratedInvoiceTextSearchPages { get; set; }
public int? MigratedTransactionLabels { get; set; }
public bool MigrateAppCustomOption { get; set; } public bool MigrateAppCustomOption { get; set; }
public bool MigratePayoutDestinationId { get; set; } public bool MigratePayoutDestinationId { get; set; }
public bool AddInitialUserBlob { get; set; } public bool AddInitialUserBlob { get; set; }
@ -32,5 +33,6 @@ namespace BTCPayServer.Services
public bool LighingAddressDatabaseMigration { get; set; } public bool LighingAddressDatabaseMigration { get; set; }
public bool AddStoreToPayout { get; set; } public bool AddStoreToPayout { get; set; }
public bool MigrateEmailServerDisableTLSCerts { get; set; } public bool MigrateEmailServerDisableTLSCerts { get; set; }
public bool MigrateWalletColors { get; set; }
} }
} }

View file

@ -1,12 +1,22 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Labels;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.Crypto;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services namespace BTCPayServer.Services
{ {
#nullable enable
public record WalletObjectId(WalletId WalletId, string Type, string Id);
#nullable restore
public class WalletRepository public class WalletRepository
{ {
private readonly ApplicationDbContextFactory _ContextFactory; private readonly ApplicationDbContextFactory _ContextFactory;
@ -16,74 +26,256 @@ namespace BTCPayServer.Services
_ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); _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) public async Task<Dictionary<string, WalletTransactionInfo>> GetWalletTransactionsInfo(WalletId walletId, string[] transactionIds = null)
{ {
ArgumentNullException.ThrowIfNull(walletId); ArgumentNullException.ThrowIfNull(walletId);
using var ctx = _ContextFactory.CreateContext(); using var ctx = _ContextFactory.CreateContext();
return (await ctx.WalletTransactions
.Where(w => w.WalletDataId == walletId.ToString())
.Where(data => transactionIds == null || transactionIds.Contains(data.TransactionId)) IQueryable<WalletObjectLinkData> wols;
.Select(w => w) IQueryable<WalletObjectData> wos;
.ToArrayAsync())
.ToDictionary(w => w.TransactionId, w => w.GetBlobInfo()); // 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(); using var ctx = _ContextFactory.CreateContext();
var data = await ctx.Wallets return (await ctx.WalletObjects
.Where(w => w.Id == walletId.ToString()) .AsNoTracking()
.Select(w => w) .Where(w => w.WalletId == walletId.ToString() && w.Type == WalletObjectData.Types.Label)
.FirstOrDefaultAsync(); .Select(o => new { o.Id, o.Data })
return data?.GetBlobInfo() ?? new WalletBlobInfo(); .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(); using var ctx = _ContextFactory.CreateContext();
var walletData = new WalletTransactionData() { WalletDataId = walletId.ToString(), TransactionId = transactionId }; var l = new WalletObjectLinkData()
walletData.SetBlobInfo(walletTransactionInfo); {
var entity = await ctx.WalletTransactions.AddAsync(walletData); WalletId = parent.WalletId.ToString(),
entity.State = EntityState.Modified; ChildType = child.Type,
ChildId = child.Id,
ParentType = parent.Type,
ParentId = parent.Id
};
ctx.WalletObjectLinks.Add(l);
try try
{ {
await ctx.SaveChangesAsync(); 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 try
{ {
await ctx.SaveChangesAsync(); 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)) 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; model.Comment = transactionInfo.Comment;
} }

View file

@ -10,10 +10,10 @@
@transaction.Timestamp.ToBrowserDate() @transaction.Timestamp.ToBrowserDate()
</td> </td>
<td class="text-start"> <td class="text-start">
@if (transaction.Labels.Any()) @if (transaction.Tags.Any())
{ {
<div class="d-flex flex-wrap gap-2 align-items-center"> <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-container rounded-2" style="background-color:@label.Color;">
<div class="badge transactionLabel position-relative d-block" <div class="badge transactionLabel position-relative d-block"
@ -79,7 +79,7 @@
<div class="py-2 px-3 d-flex flex-wrap gap-2"> <div class="py-2 px-3 d-flex flex-wrap gap-2">
@foreach (var label in Model.Labels) @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> <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": { "LabelData": {
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
"deprecated": true,
"properties": { "properties": {
"type": { "type": {
"type": "string", "type": "string",
@ -762,6 +763,7 @@
}, },
"labels": { "labels": {
"description": "Labels linked to this transaction", "description": "Labels linked to this transaction",
"deprecated": true,
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/LabelData" "$ref": "#/components/schemas/LabelData"
@ -810,6 +812,7 @@
}, },
"labels": { "labels": {
"description": "Labels linked to this transaction", "description": "Labels linked to this transaction",
"deprecated": true,
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/components/schemas/LabelData" "$ref": "#/components/schemas/LabelData"
@ -904,6 +907,7 @@
}, },
"labels": { "labels": {
"nullable": true, "nullable": true,
"deprecated": true,
"description": "Transaction labels", "description": "Transaction labels",
"type": "array", "type": "array",
"items": { "items": {