Auto label utxos based on invoice and payjoin (#1499)

* Auto label utxos based on invoice and payjoin

This PR introduces automatic labelling to transactions.
* If it is an incoming tx to an invoice, it will tag it as such.
* If it was a payjoin tx , it will tag it as such.
* If a transaction's inputs were exposed to a payjoin sender, we tag it as such.

* wip

* wip

* support in coinselection

* remove ugly hack

* support wallet transactions page

* remove messy loop

* better label template helpers

* add tests and susbcribe to event

* simplify data

* fix test

* fix label  + color

* fix remove label

* renove useless call

* add toString

* fix potential crash by txid

* trim json keyword in manual label

* format file
This commit is contained in:
Andrew Camilleri
2020-04-28 08:06:28 +02:00
committed by GitHub
parent 3801eeec43
commit b31fb1a269
16 changed files with 254 additions and 49 deletions

View File

@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
@@ -24,12 +22,43 @@ namespace BTCPayServer.Data
throw new ArgumentNullException(nameof(value));
if (color == null)
throw new ArgumentNullException(nameof(color));
if (value.StartsWith("{"))
{
var jObj = JObject.Parse(value);
if (jObj.ContainsKey("value"))
{
switch (jObj["value"].Value<string>())
{
case "invoice":
Value = "invoice";
Tooltip = $"Received through an invoice ({jObj["id"].Value<string>()})";
Link = jObj.ContainsKey("id") ? $"/invoices/{jObj["id"].Value<string>()}" : "";
break;
case "pj-exposed":
Value = "payjoin-exposed";
Tooltip = $"This utxo was exposed through a payjoin proposal for an invoice ({jObj["id"].Value<string>()})";
Link = jObj.ContainsKey("id") ? $"/invoices/{jObj["id"].Value<string>()}" : "";
break;
default:
Value = value;
break;
}
}
}
else
{
Value = value;
}
RawValue = value;
Color = color;
}
public string Value { get; }
public string RawValue { get; }
public string Color { get; }
public string Link { get; }
public string Tooltip { get; }
public override bool Equals(object obj)
{

View File

@@ -18,6 +18,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Wallets;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -258,7 +259,7 @@ namespace BTCPayServer.Tests
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
s.GoToWalletSend(senderWalletId);
s.GoToWallet(senderWalletId, WalletsNavPages.Send);
s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
@@ -293,7 +294,7 @@ namespace BTCPayServer.Tests
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
s.GoToWalletSend(senderWalletId);
s.GoToWallet(senderWalletId, WalletsNavPages.Send);
s.Driver.FindElement(By.Id("bip21parse")).Click();
s.Driver.SwitchTo().Alert().SendKeys(bip21);
s.Driver.SwitchTo().Alert().Accept();
@@ -304,7 +305,7 @@ namespace BTCPayServer.Tests
s.Driver.ScrollTo(By.Id("SendMenu"));
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
var txId = await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
{
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
return Task.CompletedTask;
@@ -344,6 +345,15 @@ namespace BTCPayServer.Tests
.FindElement(By.ClassName("payment-value"));
Assert.False(paymentValueRowColumn.Text.Contains("payjoin",
StringComparison.InvariantCultureIgnoreCase));
TestUtils.Eventually(() =>
{
s.GoToWallet(receiverWalletId, WalletsNavPages.Transactions);
Assert.Contains(invoiceId, s.Driver.PageSource);
Assert.Contains("payjoin", s.Driver.PageSource);
//this label does not always show since input gets used
// Assert.Contains("payjoin-exposed", s.Driver.PageSource);
});
}
}
}

View File

@@ -22,6 +22,7 @@ using BTCPayServer.Models;
using BTCPayServer.Services;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium.Interactions;
@@ -318,7 +319,7 @@ namespace BTCPayServer.Tests
public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m)
{
GoToWalletReceive(walletId);
GoToWallet(walletId, WalletsNavPages.Receive);
Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
@@ -335,7 +336,7 @@ namespace BTCPayServer.Tests
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
GoToWalletSend(walletId);
GoToWallet(walletId, WalletsNavPages.Send);
Driver.FindElement(By.Id("bip21parse")).Click();
Driver.SwitchTo().Alert().SendKeys(bip21);
Driver.SwitchTo().Alert().Accept();
@@ -371,14 +372,13 @@ namespace BTCPayServer.Tests
}
public void GoToWalletSend(WalletId walletId)
public void GoToWallet(WalletId walletId, WalletsNavPages navPages = WalletsNavPages.Send)
{
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}/send"));
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}"));
if (navPages != WalletsNavPages.Transactions)
{
Driver.FindElement(By.Id($"Wallet{navPages}")).Click();
}
internal void GoToWalletReceive(WalletId walletId)
{
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}/receive"));
}
}
}

View File

@@ -14,6 +14,7 @@ using NBitcoin.Payment;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Wallets;
namespace BTCPayServer.Tests
{
@@ -432,7 +433,7 @@ namespace BTCPayServer.Tests
var storeId = s.CreateNewStore().storeId;
s.GenerateWallet("BTC", "", false, true);
var walletId = new WalletId(storeId, "BTC");
s.GoToWalletReceive(walletId);
s.GoToWallet(walletId, WalletsNavPages.Receive);
s.Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = s.Driver.FindElement(By.Id("vue-address")).GetProperty("value");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
@@ -456,7 +457,7 @@ namespace BTCPayServer.Tests
coin => coin.OutPoint == spentOutpoint);
});
await s.Server.ExplorerNode.GenerateAsync(1);
s.GoToWalletSend(walletId);
s.GoToWallet(walletId, WalletsNavPages.Send);
s.Driver.FindElement(By.Id("advancedSettings")).Click();
s.Driver.FindElement(By.Id("toggleInputSelection")).Click();
s.Driver.WaitForElement(By.Id(spentOutpoint.ToString()));

View File

@@ -143,7 +143,7 @@ namespace BTCPayServer.Controllers
var walletTransactionsInfo = await walletTransactionsInfoAsync;
if (addlabel != null)
{
addlabel = addlabel.Trim().ToLowerInvariant().Replace(',',' ').Truncate(MaxLabelSize);
addlabel = addlabel.Trim().TrimStart('{').ToLowerInvariant().Replace(',',' ').Truncate(MaxLabelSize);
var labels = walletBlobInfo.GetLabels();
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
@@ -170,7 +170,7 @@ namespace BTCPayServer.Controllers
}
else if (removelabel != null)
{
removelabel = removelabel.Trim().ToLowerInvariant().Truncate(MaxLabelSize);
removelabel = removelabel.Trim();
if (walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
if (walletTransactionInfo.Labels.Remove(removelabel))

View File

@@ -2,7 +2,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.Internal;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
@@ -17,7 +19,15 @@ namespace BTCPayServer.Data
var blobInfo = JsonConvert.DeserializeObject<WalletTransactionInfo>(ZipUtils.Unzip(walletTransactionData.Blob));
if (!string.IsNullOrEmpty(walletTransactionData.Labels))
{
blobInfo.Labels.AddRange(walletTransactionData.Labels.Split(',', StringSplitOptions.RemoveEmptyEntries));
if (walletTransactionData.Labels.StartsWith('['))
{
blobInfo.Labels.AddRange(JArray.Parse(walletTransactionData.Labels).Values<string>());
}
else
{
blobInfo.Labels.AddRange(walletTransactionData.Labels.Split(',',
StringSplitOptions.RemoveEmptyEntries));
}
}
return blobInfo;
}
@@ -29,9 +39,8 @@ namespace BTCPayServer.Data
walletTransactionData.Blob = Array.Empty<byte>();
return;
}
if (blobInfo.Labels.Any(l => l.Contains(',', StringComparison.OrdinalIgnoreCase)))
throw new ArgumentException(paramName: nameof(blobInfo), message: "Labels must not contains ','");
walletTransactionData.Labels = String.Join(',', blobInfo.Labels);
walletTransactionData.Labels = JArray.FromObject(blobInfo.Labels).ToString();
walletTransactionData.Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo));
}
}

View File

@@ -13,7 +13,7 @@ namespace BTCPayServer.HostedServices
{
private readonly AppService _AppService;
protected override void SubscibeToEvents()
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
}

View File

@@ -50,7 +50,7 @@ namespace BTCPayServer.HostedServices
}
protected virtual void SubscibeToEvents()
protected virtual void SubscribeToEvents()
{
}
@@ -63,7 +63,7 @@ namespace BTCPayServer.HostedServices
public virtual Task StartAsync(CancellationToken cancellationToken)
{
_Subscriptions = new List<IEventAggregatorSubscription>();
SubscibeToEvents();
SubscribeToEvents();
_Cts = new CancellationTokenSource();
_ProcessingEvents = ProcessEvents(_Cts.Token);
return Task.CompletedTask;

View File

@@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services;
using NBitcoin;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.HostedServices
{
public class TransactionLabelMarkerHostedService : EventHostedServiceBase
{
private readonly EventAggregator _eventAggregator;
private readonly WalletRepository _walletRepository;
public TransactionLabelMarkerHostedService(EventAggregator eventAggregator, WalletRepository walletRepository) :
base(eventAggregator)
{
_eventAggregator = eventAggregator;
_walletRepository = walletRepository;
}
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<UpdateTransactionLabel>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent && invoiceEvent.Name == InvoiceEvent.ReceivedPayment &&
invoiceEvent.Payment.GetPaymentMethodId().PaymentType == BitcoinPaymentType.Instance &&
invoiceEvent.Payment.GetCryptoPaymentData() is BitcoinLikePaymentData bitcoinLikePaymentData)
{
var walletId = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
var transactionId = bitcoinLikePaymentData.Outpoint.Hash;
var labels = new List<(string color, string label)>
{
UpdateTransactionLabel.InvoiceLabelTemplate(invoiceEvent.Invoice.Id)
};
if (invoiceEvent.Invoice.GetPayments(invoiceEvent.Payment.GetCryptoCode()).Any(entity =>
entity.GetCryptoPaymentData() is BitcoinLikePaymentData pData &&
pData.PayjoinInformation?.CoinjoinTransactionHash == transactionId))
{
labels.Add(UpdateTransactionLabel.PayjoinLabelTemplate());
}
_eventAggregator.Publish(new UpdateTransactionLabel()
{
WalletId = walletId,
TransactionLabels =
new Dictionary<uint256, List<(string color, string label)>>() {{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 =>
{
if (!walletTransactionsInfo.TryGetValue(pair.Key.ToString(), out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
foreach (var label in pair.Value)
{
walletBlobInfo.LabelColors.TryAdd(label.label, label.color);
}
await _walletRepository.SetWalletInfo(updateTransactionLabel.WalletId, walletBlobInfo);
var update = false;
foreach (var label in pair.Value)
{
if (walletTransactionInfo.Labels.Add(label.label))
{
update = true;
}
}
if (update)
{
await _walletRepository.SetWalletTransactionInfo(updateTransactionLabel.WalletId,
pair.Key.ToString(), walletTransactionInfo);
}
}));
}
}
}
public class UpdateTransactionLabel
{
public static (string color, string label) PayjoinLabelTemplate()
{
return ("#51b13e", "payjoin");
}
public static (string color, string label) InvoiceLabelTemplate(string invoice)
{
return ("#cedc21", JObject.FromObject(new {value = "invoice", id = invoice}).ToString());
}
public static (string color, string label) PayjoinExposedLabelTemplate(string invoice)
{
return ("#51b13e", JObject.FromObject(new {value = "pj-exposed", id = invoice}).ToString());
}
public WalletId WalletId { get; set; }
public Dictionary<uint256, List<(string color, string label)>> TransactionLabels { get; set; }
public override string ToString()
{
var result = new StringBuilder();
foreach (var transactionLabel in TransactionLabels)
{
result.AppendLine(
$"Adding {transactionLabel.Value.Count} labels to {transactionLabel.Key} in wallet {WalletId}");
}
return result.ToString();
}
}
}

View File

@@ -28,7 +28,7 @@ namespace BTCPayServer.HostedServices
_generator = generator;
}
protected override void SubscibeToEvents()
protected override void SubscribeToEvents()
{
Subscribe<UserRegisteredEvent>();
}

View File

@@ -205,6 +205,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
services.AddSingleton<IHostedService, AppHubStreamer>();
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
services.AddSingleton<IHostedService, UserEventHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>();

View File

@@ -139,7 +139,7 @@ namespace BTCPayServer.PaymentRequest
await (_CheckingPendingPayments ?? Task.CompletedTask);
}
protected override void SubscibeToEvents()
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<PaymentRequestUpdated>();

View File

@@ -21,6 +21,7 @@ using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
using NBXplorer.DerivationStrategy;
using System.Diagnostics.CodeAnalysis;
using BTCPayServer.Data;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Payments.PayJoin
@@ -86,6 +87,7 @@ namespace BTCPayServer.Payments.PayJoin
private readonly EventAggregator _eventAggregator;
private readonly NBXplorerDashboard _dashboard;
private readonly DelayedTransactionBroadcaster _broadcaster;
private readonly WalletRepository _walletRepository;
public PayJoinEndpointController(BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository, ExplorerClientProvider explorerClientProvider,
@@ -93,7 +95,8 @@ namespace BTCPayServer.Payments.PayJoin
PayJoinRepository payJoinRepository,
EventAggregator eventAggregator,
NBXplorerDashboard dashboard,
DelayedTransactionBroadcaster broadcaster)
DelayedTransactionBroadcaster broadcaster,
WalletRepository walletRepository)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_invoiceRepository = invoiceRepository;
@@ -104,6 +107,7 @@ namespace BTCPayServer.Payments.PayJoin
_eventAggregator = eventAggregator;
_dashboard = dashboard;
_broadcaster = broadcaster;
_walletRepository = walletRepository;
}
[HttpPost("")]
@@ -475,6 +479,17 @@ namespace BTCPayServer.Payments.PayJoin
}
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(originalTx);
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) {Payment = payment});
_eventAggregator.Publish(new UpdateTransactionLabel()
{
WalletId = new WalletId(invoice.StoreId, network.CryptoCode),
TransactionLabels = selectedUTXOs.GroupBy(pair => pair.Key.Hash ).Select(utxo =>
new KeyValuePair<uint256, List<(string color, string label)>>(utxo.Key,
new List<(string color, string label)>()
{
UpdateTransactionLabel.PayjoinExposedLabelTemplate(invoice.Id)
}))
.ToDictionary(pair => pair.Key, pair => pair.Value)
});
if (psbtFormat && HexEncoder.IsWellFormed(rawBody))
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
@@ -37,7 +37,7 @@ namespace BTCPayServer.Services.Apps
_HubContext = hubContext;
}
protected override void SubscibeToEvents()
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
Subscribe<AppsController.AppUpdated>();

View File

@@ -49,8 +49,17 @@
v-for="label of item.labels"
v-bind:style="{ 'background-color': label.color}"
key="label.value">
<span v-if="!label.link" data-toggle="tooltip" v-tooltip="label.tooltip">
{{label.value}}
</span>
<a :href="label.link" target="_blank"v-if="label.link" data-toggle="tooltip" v-tooltip="label.tooltip">
{{label.value}}
<i class="fa fa-info-circle"></i>
</a>
</span>
</div>
<span class="text-muted ml-2">{{item.amount}}</span>
</div>

View File

@@ -90,8 +90,8 @@
<td style="text-align:left">
@foreach (var label in transaction.Labels)
{
<a
asp-route-labelFilter="@label.Value"
<div
class="badge transactionLabel"
style="
background-color: @label.Color;
@@ -100,24 +100,27 @@
padding-right: 16px;
position: relative;
"
>
@label.Value
title="@label.Tooltip">
<a asp-route-labelFilter="@label.RawValue" class="text-white">@label.Value</a>
@if (!string.IsNullOrEmpty(label.Link))
{
<a href="@label.Link" target="_blank" class="fa fa-info-circle"></a>
}
<form
asp-route-walletId="@this.Context.GetRouteValue("walletId")"
asp-action="ModifyTransaction"
method="post"
class="removeTransactionLabelForm"
>
<input type="hidden" name="transactionId" value="@transaction.Id" />
class="removeTransactionLabelForm">
<input type="hidden" name="transactionId" value="@transaction.Id"/>
<button
name="removelabel"
type="submit"
value="@label.Value"
>
value="@label.RawValue">
<span class="fa fa-close"></span>
</button>
</form>
</a>
</div>
}
</td>
<td class="smMaxWidth text-truncate @(transaction.IsConfirmed ? "" : "unconf")">