mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
native nostr coinjoins
This commit is contained in:
@@ -206,7 +206,7 @@ public class BTCPayCoinjoinCoinSelector : IRoundCoinSelector
|
|||||||
//if we're less than the max output registration, we should be more aggressive in adding coins
|
//if we're less than the max output registration, we should be more aggressive in adding coins
|
||||||
var chance = consolidationMode ? (isLessThanMaxOutputRegistration? 100: 90 ): 100m - Math.Min(maxCoinCapacityPercentage, isLessThanMaxOutputRegistration ? 10m : maxCoinCapacityPercentage);
|
var chance = consolidationMode ? (isLessThanMaxOutputRegistration? 100: 90 ): 100m - Math.Min(maxCoinCapacityPercentage, isLessThanMaxOutputRegistration ? 10m : maxCoinCapacityPercentage);
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
$"coin selection: no payms left but at {solution.Coins.Count()} coins. random chance to add another coin if: {chance} <= {rand} (random 0-100) ");
|
$"coin selection: no payms left but at {solution.Coins.Count()} coins. random chance to add another coin if: {chance} <= {rand} (random 0-100) {chance <= rand} ");
|
||||||
if (chance <= rand)
|
if (chance <= rand)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -93,11 +93,22 @@ public class WabisabiCoordinatorService : PeriodicRunner
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (existing.Enabled &&
|
else if (existing.Enabled)
|
||||||
_instanceManager.HostedServices.TryGetValue("local", out var instance))
|
{
|
||||||
|
if (_instanceManager.HostedServices.TryGetValue("local", out var instance))
|
||||||
{
|
{
|
||||||
instance.TermsConditions = wabisabiCoordinatorSettings.TermsConditions;
|
instance.TermsConditions = wabisabiCoordinatorSettings.TermsConditions;
|
||||||
}
|
}
|
||||||
|
if(wabisabiCoordinatorSettings.Enabled &&
|
||||||
|
(existing.NostrIdentity != wabisabiCoordinatorSettings.NostrIdentity || existing.NostrRelay != wabisabiCoordinatorSettings.NostrRelay))
|
||||||
|
{
|
||||||
|
var nostr = HostedServices.Get<NostrWabisabiApiServer>();
|
||||||
|
nostr.UpdateSettings(wabisabiCoordinatorSettings);
|
||||||
|
await nostr.StopAsync(CancellationToken.None);
|
||||||
|
await nostr.StartAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
await _settingsRepository.UpdateSetting(wabisabiCoordinatorSettings, nameof(WabisabiCoordinatorSettings));
|
await _settingsRepository.UpdateSetting(wabisabiCoordinatorSettings, nameof(WabisabiCoordinatorSettings));
|
||||||
@@ -202,7 +213,10 @@ public class WabisabiCoordinatorService : PeriodicRunner
|
|||||||
WabiSabiCoordinator = new WabiSabiCoordinator(coordinatorParameters, rpc, coinJoinIdStore, coinJoinScriptStore,
|
WabiSabiCoordinator = new WabiSabiCoordinator(coordinatorParameters, rpc, coinJoinIdStore, coinJoinScriptStore,
|
||||||
_httpClientFactory);
|
_httpClientFactory);
|
||||||
HostedServices.Register<WabiSabiCoordinator>(() => WabiSabiCoordinator, "WabiSabi Coordinator");
|
HostedServices.Register<WabiSabiCoordinator>(() => WabiSabiCoordinator, "WabiSabi Coordinator");
|
||||||
|
|
||||||
var settings = await GetSettings();
|
var settings = await GetSettings();
|
||||||
|
WabisabiApiServer = new NostrWabisabiApiServer(WabiSabiCoordinator.Arena, settings, _logger);
|
||||||
|
HostedServices.Register<NostrWabisabiApiServer>(() => WabisabiApiServer, "WabiSabi Coordinator Nostr");
|
||||||
|
|
||||||
if (settings.Enabled)
|
if (settings.Enabled)
|
||||||
{
|
{
|
||||||
@@ -218,6 +232,8 @@ public class WabisabiCoordinatorService : PeriodicRunner
|
|||||||
await base.StartAsync(cancellationToken);
|
await base.StartAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public NostrWabisabiApiServer WabisabiApiServer { get; set; }
|
||||||
|
|
||||||
public async Task StartCoordinator(CancellationToken cancellationToken)
|
public async Task StartCoordinator(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Starting local coordinator");
|
_logger.LogInformation("Starting local coordinator");
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.WebSockets;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -10,6 +12,7 @@ using Newtonsoft.Json;
|
|||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using NNostr.Client;
|
using NNostr.Client;
|
||||||
using WalletWasabi.Logging;
|
using WalletWasabi.Logging;
|
||||||
|
using WalletWasabi.Tor.Socks5.Pool.Circuits;
|
||||||
using WalletWasabi.WabiSabi;
|
using WalletWasabi.WabiSabi;
|
||||||
using WalletWasabi.WabiSabi.Backend.Models;
|
using WalletWasabi.WabiSabi.Backend.Models;
|
||||||
using WalletWasabi.WabiSabi.Backend.PostRequests;
|
using WalletWasabi.WabiSabi.Backend.PostRequests;
|
||||||
@@ -18,36 +21,65 @@ using WalletWasabi.WabiSabi.Models.Serialization;
|
|||||||
|
|
||||||
namespace BTCPayServer.Plugins.Wabisabi;
|
namespace BTCPayServer.Plugins.Wabisabi;
|
||||||
|
|
||||||
public class NostrWabiSabiApiClient : IWabiSabiApiRequestHandler, IHostedService
|
public class NostrWabiSabiApiClient : IWabiSabiApiRequestHandler, IHostedService, IDisposable
|
||||||
{
|
{
|
||||||
public static int RoundStateKind = 15750;
|
public static int RoundStateKind = 15750;
|
||||||
public static int CommunicationKind = 25750;
|
public static int CommunicationKind = 25750;
|
||||||
private readonly NostrClient _client;
|
private NostrClient _client;
|
||||||
|
private readonly Uri _relay;
|
||||||
|
private readonly WebProxy _webProxy;
|
||||||
private readonly ECXOnlyPubKey _coordinatorKey;
|
private readonly ECXOnlyPubKey _coordinatorKey;
|
||||||
|
private readonly INamedCircuit _circuit;
|
||||||
private string _coordinatorKeyHex => _coordinatorKey.ToHex();
|
private string _coordinatorKeyHex => _coordinatorKey.ToHex();
|
||||||
private readonly string _coordinatorFilterId;
|
private readonly string _coordinatorFilterId;
|
||||||
|
|
||||||
public NostrWabiSabiApiClient(NostrClient client, ECXOnlyPubKey coordinatorKey)
|
public NostrWabiSabiApiClient(Uri relay, WebProxy webProxy , ECXOnlyPubKey coordinatorKey, INamedCircuit? circuit)
|
||||||
{
|
{
|
||||||
_client = client;
|
_relay = relay;
|
||||||
|
_webProxy = webProxy;
|
||||||
_coordinatorKey = coordinatorKey;
|
_coordinatorKey = coordinatorKey;
|
||||||
|
_circuit = circuit;
|
||||||
_coordinatorFilterId = new Guid().ToString();
|
_coordinatorFilterId = new Guid().ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!_circuit.IsActive)
|
||||||
|
{
|
||||||
|
Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_circuit is OneOffCircuit)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
// we dont bootstrap, we do it on demand for a request instead
|
||||||
|
}
|
||||||
|
|
||||||
|
await Init(cancellationToken, _circuit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Init(CancellationToken cancellationToken, INamedCircuit circuit)
|
||||||
|
{
|
||||||
|
_client = CreateClient(_relay, _webProxy, circuit);
|
||||||
|
|
||||||
_ = _client.ListenForMessages();
|
_ = _client.ListenForMessages();
|
||||||
var filter = new NostrSubscriptionFilter()
|
var filter = new NostrSubscriptionFilter()
|
||||||
{
|
{
|
||||||
Authors = new[] {_coordinatorKey.ToHex()},
|
Authors = new[] {_coordinatorKey.ToHex()},
|
||||||
Kinds = new[] {RoundStateKind, CommunicationKind},
|
Kinds = new[] {RoundStateKind, CommunicationKind},
|
||||||
Since = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(1))
|
Since = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(1)),
|
||||||
|
Limit = 0
|
||||||
};
|
};
|
||||||
await _client.CreateSubscription(_coordinatorFilterId, new[] {filter}, cancellationToken);
|
await _client.CreateSubscription(_coordinatorFilterId, new[] {filter}, cancellationToken);
|
||||||
_client.EventsReceived += EventsReceived;
|
_client.EventsReceived += EventsReceived;
|
||||||
|
|
||||||
await _client.ConnectAndWaitUntilConnected(cancellationToken);
|
await _client.ConnectAndWaitUntilConnected(cancellationToken);
|
||||||
|
_circuit.IsolationIdChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
_client.Dispose();
|
||||||
|
_ = StartAsync(CancellationToken.None);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private RoundStateResponse _lastRoundState { get; set; }
|
private RoundStateResponse _lastRoundState { get; set; }
|
||||||
@@ -77,6 +109,16 @@ public class NostrWabiSabiApiClient : IWabiSabiApiRequestHandler, IHostedService
|
|||||||
private async Task<TResponse> SendAndWaitForReply<TRequest, TResponse>(RemoteAction action, TRequest request,
|
private async Task<TResponse> SendAndWaitForReply<TRequest, TResponse>(RemoteAction action, TRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
if(_circuit is OneOffCircuit)
|
||||||
|
{
|
||||||
|
using var subClient =
|
||||||
|
new NostrWabiSabiApiClient(_relay, _webProxy, _coordinatorKey, new PersonCircuit());
|
||||||
|
await subClient.StartAsync(cancellationToken);
|
||||||
|
return await subClient.SendAndWaitForReply<TRequest, TResponse>(action, request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var newKey = ECPrivKey.Create(RandomUtils.GetBytes(32));
|
var newKey = ECPrivKey.Create(RandomUtils.GetBytes(32));
|
||||||
var pubkey = newKey.CreateXOnlyPubKey();
|
var pubkey = newKey.CreateXOnlyPubKey();
|
||||||
var evt = new NostrEvent()
|
var evt = new NostrEvent()
|
||||||
@@ -164,14 +206,14 @@ public class NostrWabiSabiApiClient : IWabiSabiApiRequestHandler, IHostedService
|
|||||||
catch (OperationCanceledException e)
|
catch (OperationCanceledException e)
|
||||||
{
|
{
|
||||||
_client.EventsReceived -= OnClientEventsReceived;
|
_client.EventsReceived -= OnClientEventsReceived;
|
||||||
|
_circuit.IncrementIsolationId();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _client.CloseSubscription(_coordinatorFilterId, cancellationToken);
|
Dispose();
|
||||||
_client.EventsReceived -= EventsReceived;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RoundStateResponse> GetStatusAsync(RoundStateRequest request, CancellationToken cancellationToken)
|
public async Task<RoundStateResponse> GetStatusAsync(RoundStateRequest request, CancellationToken cancellationToken)
|
||||||
@@ -237,4 +279,23 @@ public class NostrWabiSabiApiClient : IWabiSabiApiRequestHandler, IHostedService
|
|||||||
GetStatus,
|
GetStatus,
|
||||||
ReadyToSign
|
ReadyToSign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_client?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NostrClient CreateClient(Uri relay, WebProxy webProxy, INamedCircuit namedCircuit )
|
||||||
|
{
|
||||||
|
return new NostrClient(relay, socket =>
|
||||||
|
{
|
||||||
|
if (socket is ClientWebSocket clientWebSocket && webProxy is { })
|
||||||
|
{
|
||||||
|
var proxy = new WebProxy(webProxy.Address, true, null,
|
||||||
|
new NetworkCredential(namedCircuit.Name,
|
||||||
|
namedCircuit.IsolationId.ToString()));
|
||||||
|
clientWebSocket.Options.Proxy = proxy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,14 @@ using System.Threading;
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.Secp256k1;
|
using NBitcoin.Secp256k1;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using NNostr.Client;
|
using NNostr.Client;
|
||||||
using WabiSabi.Crypto;
|
using WabiSabi.Crypto;
|
||||||
|
using WalletWasabi.Backend.Controllers;
|
||||||
using WalletWasabi.Logging;
|
using WalletWasabi.Logging;
|
||||||
using WalletWasabi.WabiSabi;
|
using WalletWasabi.WabiSabi;
|
||||||
using WalletWasabi.WabiSabi.Backend.Models;
|
using WalletWasabi.WabiSabi.Backend.Models;
|
||||||
@@ -26,17 +28,18 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
public static int RoundStateKind = 15750;
|
public static int RoundStateKind = 15750;
|
||||||
public static int CommunicationKind = 25750;
|
public static int CommunicationKind = 25750;
|
||||||
private readonly Arena _arena;
|
private readonly Arena _arena;
|
||||||
private readonly NostrClient _client;
|
private WabisabiCoordinatorSettings _coordinatorSettings;
|
||||||
private readonly ECPrivKey _coordinatorKey;
|
private NostrClient _client;
|
||||||
private string _coordinatorKeyHex => _coordinatorKey.CreateXOnlyPubKey().ToHex();
|
private readonly ILogger _logger;
|
||||||
private readonly string _coordinatorFilterId;
|
private readonly string _coordinatorFilterId;
|
||||||
|
|
||||||
private Channel<NostrEvent> PendingEvents { get; } = Channel.CreateUnbounded<NostrEvent>();
|
private Channel<NostrEvent> PendingEvents { get; } = Channel.CreateUnbounded<NostrEvent>();
|
||||||
public NostrWabisabiApiServer(Arena arena,NostrClient client, ECPrivKey coordinatorKey)
|
public NostrWabisabiApiServer(Arena arena, WabisabiCoordinatorSettings coordinatorSettings,
|
||||||
|
ILogger logger)
|
||||||
{
|
{
|
||||||
_arena = arena;
|
_arena = arena;
|
||||||
_client = client;
|
_coordinatorSettings = coordinatorSettings;
|
||||||
_coordinatorKey = coordinatorKey;
|
_logger = logger;
|
||||||
_coordinatorFilterId = new Guid().ToString();
|
_coordinatorFilterId = new Guid().ToString();
|
||||||
_serializer = JsonSerializer.Create(JsonSerializationOptions.Default.Settings);
|
_serializer = JsonSerializer.Create(JsonSerializationOptions.Default.Settings);
|
||||||
}
|
}
|
||||||
@@ -44,12 +47,20 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_ = _client.ListenForMessages();
|
if (_coordinatorSettings.NostrRelay is null || string.IsNullOrEmpty(_coordinatorSettings.NostrIdentity))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("NOSTR SERVER: No Nostr relay/identity configured, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_client = new NostrClient(_coordinatorSettings.NostrRelay);
|
||||||
|
await _client.Connect(cancellationToken);
|
||||||
|
_logger.LogInformation($"NOSTR SERVER: CONNECTED TO {_coordinatorSettings.NostrRelay}");
|
||||||
var filter = new NostrSubscriptionFilter()
|
var filter = new NostrSubscriptionFilter()
|
||||||
{
|
{
|
||||||
ReferencedPublicKeys = new[] {_coordinatorKey.ToHex()},
|
ReferencedPublicKeys = new[] {_coordinatorSettings.GetPubKey().ToHex()},
|
||||||
Kinds = new[] { CommunicationKind},
|
Kinds = new[] { CommunicationKind},
|
||||||
Since = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(1))
|
Limit = 0
|
||||||
};
|
};
|
||||||
await _client.CreateSubscription(_coordinatorFilterId, new[] {filter}, cancellationToken);
|
await _client.CreateSubscription(_coordinatorFilterId, new[] {filter}, cancellationToken);
|
||||||
_client.EventsReceived += EventsReceived;
|
_client.EventsReceived += EventsReceived;
|
||||||
@@ -65,7 +76,7 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
if (e.subscriptionId != _coordinatorFilterId) return;
|
if (e.subscriptionId != _coordinatorFilterId) return;
|
||||||
var requests = e.events.Where(evt =>
|
var requests = e.events.Where(evt =>
|
||||||
evt.Kind == CommunicationKind &&
|
evt.Kind == CommunicationKind &&
|
||||||
evt.GetTaggedData("p").Any(s => s == _coordinatorKeyHex) && evt.Verify());
|
evt.GetTaggedData("p").Any(s => s == _coordinatorSettings.GetPubKey().ToHex()) && evt.Verify());
|
||||||
foreach (var request in requests)
|
foreach (var request in requests)
|
||||||
PendingEvents.Writer.TryWrite(request);
|
PendingEvents.Writer.TryWrite(request);
|
||||||
}
|
}
|
||||||
@@ -79,11 +90,12 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
var nostrEvent = new NostrEvent()
|
var nostrEvent = new NostrEvent()
|
||||||
{
|
{
|
||||||
Kind = RoundStateKind,
|
Kind = RoundStateKind,
|
||||||
PublicKey = _coordinatorKeyHex,
|
PublicKey = _coordinatorSettings.GetPubKey().ToHex(),
|
||||||
CreatedAt = DateTimeOffset.Now,
|
CreatedAt = DateTimeOffset.Now,
|
||||||
Content = Serialize(response)
|
Content = Serialize(response)
|
||||||
};
|
};
|
||||||
await _client.PublishEvent(nostrEvent, cancellationToken);
|
await _client.PublishEvent(nostrEvent, cancellationToken);
|
||||||
|
_logger.LogInformation($"NOSTR SERVER: PUBLISHED ROUND STATE {nostrEvent.Id}");
|
||||||
await Task.Delay(1000, cancellationToken);
|
await Task.Delay(1000, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +107,7 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
PendingEvents.Reader.TryRead(out var evt))
|
PendingEvents.Reader.TryRead(out var evt))
|
||||||
{
|
{
|
||||||
evt.Kind = 4;
|
evt.Kind = 4;
|
||||||
var content = JObject.Parse(await evt.DecryptNip04EventAsync(_coordinatorKey));
|
var content = JObject.Parse(await evt.DecryptNip04EventAsync(_coordinatorSettings.GetKey()));
|
||||||
if (content.TryGetValue("action", out var actionJson) &&
|
if (content.TryGetValue("action", out var actionJson) &&
|
||||||
actionJson.Value<string>(actionJson) is { } actionString &&
|
actionJson.Value<string>(actionJson) is { } actionString &&
|
||||||
Enum.TryParse<RemoteAction>(actionString, out var action) &&
|
Enum.TryParse<RemoteAction>(actionString, out var action) &&
|
||||||
@@ -103,6 +115,7 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation($"NOSTR SERVER: Received request {evt.Id} {action}");
|
||||||
switch (action)
|
switch (action)
|
||||||
{
|
{
|
||||||
case RemoteAction.GetStatus:
|
case RemoteAction.GetStatus:
|
||||||
@@ -191,26 +204,33 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
private async Task Reply<TResponse>(NostrEvent originaltEvent,TResponse response,
|
private async Task Reply<TResponse>(NostrEvent originaltEvent,TResponse response,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
_logger.LogInformation($"NOSTR SERVER: REPLYING TO {originaltEvent.Id} WITH {response}");
|
||||||
var evt = new NostrEvent()
|
var evt = new NostrEvent()
|
||||||
{
|
{
|
||||||
Content = Serialize(response),
|
Content = Serialize(response),
|
||||||
PublicKey = _coordinatorKeyHex,
|
PublicKey = _coordinatorSettings.GetPubKey().ToHex(),
|
||||||
Kind = 4,
|
Kind = 4,
|
||||||
CreatedAt = DateTimeOffset.Now
|
CreatedAt = DateTimeOffset.Now
|
||||||
};
|
};
|
||||||
evt.SetTag("p", originaltEvent.PublicKey);
|
evt.SetTag("p", originaltEvent.PublicKey);
|
||||||
evt.SetTag("e", originaltEvent.Id);
|
evt.SetTag("e", originaltEvent.Id);
|
||||||
|
|
||||||
await evt.EncryptNip04EventAsync(_coordinatorKey);
|
await evt.EncryptNip04EventAsync(_coordinatorSettings.GetKey());
|
||||||
evt.Kind = CommunicationKind;
|
evt.Kind = CommunicationKind;
|
||||||
await evt.ComputeIdAndSignAsync(_coordinatorKey);
|
await evt.ComputeIdAndSignAsync(_coordinatorSettings.GetKey());
|
||||||
await _client.PublishEvent(evt, cancellationToken);
|
await _client.PublishEvent(evt, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (_client is not null)
|
||||||
|
{
|
||||||
|
|
||||||
await _client.CloseSubscription(_coordinatorFilterId, cancellationToken);
|
await _client.CloseSubscription(_coordinatorFilterId, cancellationToken);
|
||||||
_client.EventsReceived -= EventsReceived;
|
_client.EventsReceived -= EventsReceived;
|
||||||
|
_client = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Serialize<T>(T obj)
|
private static string Serialize<T>(T obj)
|
||||||
@@ -226,4 +246,9 @@ public class NostrWabisabiApiServer: IHostedService
|
|||||||
GetStatus,
|
GetStatus,
|
||||||
ReadyToSign
|
ReadyToSign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void UpdateSettings(WabisabiCoordinatorSettings wabisabiCoordinatorSettings)
|
||||||
|
{
|
||||||
|
_coordinatorSettings = wabisabiCoordinatorSettings;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
|
||||||
@using BTCPayServer.Plugins.Wabisabi
|
@using BTCPayServer.Plugins.Wabisabi
|
||||||
|
@using NNostr.Client
|
||||||
|
@using NNostr.Client.Protocols
|
||||||
|
@using WalletWasabi.Backend.Controllers
|
||||||
@model WalletWasabi.Backend.Controllers.WabisabiCoordinatorSettings
|
@model WalletWasabi.Backend.Controllers.WabisabiCoordinatorSettings
|
||||||
|
@inject WabisabiCoordinatorService WabisabiCoordinatorService
|
||||||
@{
|
@{
|
||||||
Layout = "../Shared/_NavLayout.cshtml";
|
Layout = "../Shared/_NavLayout.cshtml";
|
||||||
ViewData["NavPartialName"] = "../UIServer/_Nav";
|
ViewData["NavPartialName"] = "../UIServer/_Nav";
|
||||||
@@ -59,6 +62,28 @@
|
|||||||
<button name="command" value="generate-nostr-key" type="submit" class="btn btn-secondary btn-sm">Generate</button>
|
<button name="command" value="generate-nostr-key" type="submit" class="btn btn-secondary btn-sm">Generate</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if(WabisabiCoordinatorService.Started && WabisabiCoordinatorService.WabisabiApiServer != null && Model.NostrRelay is not null && Model.NostrIdentity is not null)
|
||||||
|
{
|
||||||
|
var nprofile = new NIP19.NosteProfileNote()
|
||||||
|
{
|
||||||
|
PubKey = Model.GetPubKey().ToHex(),
|
||||||
|
Relays = new[] {Model.NostrRelay.ToString()}
|
||||||
|
}.ToNIP19();
|
||||||
|
|
||||||
|
<div class="form-group mt-4" >
|
||||||
|
<label class="form-label">EXPERIMENTAL: NOSTR COORDINATOR ENDPOINT ACTIVE</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="ss-result-txt" class="form-control" readonly="readonly" value="nostr:@nprofile"/>
|
||||||
|
<button type="button" class="btn btn-secondary" data-clipboard-target="#ss-result-txt">
|
||||||
|
<vc:icon symbol="copy"/>
|
||||||
|
</button> <button type="button" class="btn btn-secondary" id="advertise-nostr" onclick="document.getElementById('UriToAdvertise').value='nostr:@nprofile'">
|
||||||
|
Set as url to advertise
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<p id="ss-result-additional-info"></p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="CoordinatorDescription" class="form-label">Description</label>
|
<label asp-for="CoordinatorDescription" class="form-label">Description</label>
|
||||||
<textarea asp-for="CoordinatorDescription" class="form-control"></textarea>
|
<textarea asp-for="CoordinatorDescription" class="form-control"></textarea>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
@@ -7,10 +8,13 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Payments.PayJoin;
|
using BTCPayServer.Payments.PayJoin;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
|
using ExchangeSharp;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using NNostr.Client;
|
||||||
|
using NNostr.Client.Protocols;
|
||||||
using WalletWasabi.Backend.Controllers;
|
using WalletWasabi.Backend.Controllers;
|
||||||
using WalletWasabi.Tor.Socks5.Pool.Circuits;
|
using WalletWasabi.Tor.Socks5.Pool.Circuits;
|
||||||
using WalletWasabi.Userfacing;
|
using WalletWasabi.Userfacing;
|
||||||
@@ -21,6 +25,7 @@ using WalletWasabi.WabiSabi.Client.RoundStateAwaiters;
|
|||||||
using WalletWasabi.WabiSabi.Client.StatusChangedEvents;
|
using WalletWasabi.WabiSabi.Client.StatusChangedEvents;
|
||||||
using WalletWasabi.Wallets;
|
using WalletWasabi.Wallets;
|
||||||
using WalletWasabi.WebClients.Wasabi;
|
using WalletWasabi.WebClients.Wasabi;
|
||||||
|
using ClientWebSocket = System.Net.WebSockets.ClientWebSocket;
|
||||||
|
|
||||||
namespace BTCPayServer.Plugins.Wabisabi;
|
namespace BTCPayServer.Plugins.Wabisabi;
|
||||||
|
|
||||||
@@ -105,7 +110,7 @@ public class WabisabiCoordinatorClientInstanceManager:IHostedService
|
|||||||
var instance = new WabisabiCoordinatorClientInstance(
|
var instance = new WabisabiCoordinatorClientInstance(
|
||||||
displayName,
|
displayName,
|
||||||
name, url is null? null: new Uri(url), _provider.GetService<ILoggerFactory>(), _provider, UTXOLocker,
|
name, url is null? null: new Uri(url), _provider.GetService<ILoggerFactory>(), _provider, UTXOLocker,
|
||||||
_provider.GetService<WalletProvider>(), termsConditions, description);
|
_provider.GetService<WalletProvider>(), termsConditions, description,_provider.GetRequiredService<Socks5HttpClientHandler>());
|
||||||
if (HostedServices.TryAdd(instance.CoordinatorName, instance))
|
if (HostedServices.TryAdd(instance.CoordinatorName, instance))
|
||||||
{
|
{
|
||||||
if(started)
|
if(started)
|
||||||
@@ -127,7 +132,82 @@ public class WabisabiCoordinatorClientInstanceManager:IHostedService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WabisabiCoordinatorClientInstance
|
public class NostrWabisabiClientFactory: IWasabiHttpClientFactory, IHostedService
|
||||||
|
{
|
||||||
|
private readonly Socks5HttpClientHandler _socks5HttpClientHandler;
|
||||||
|
private readonly NIP19.NosteProfileNote _nostrProfileNote;
|
||||||
|
|
||||||
|
public NostrWabisabiClientFactory(Socks5HttpClientHandler socks5HttpClientHandler,
|
||||||
|
NIP19.NosteProfileNote nostrProfileNote)
|
||||||
|
{
|
||||||
|
_socks5HttpClientHandler = socks5HttpClientHandler;
|
||||||
|
_nostrProfileNote = nostrProfileNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConcurrentDictionary<string, NostrWabiSabiApiClient> _clients = new();
|
||||||
|
|
||||||
|
private bool _started = false;
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.WhenAll(_clients.Select(pair => pair.Value.StartAsync(cancellationToken)));
|
||||||
|
|
||||||
|
_started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var nostrWabiSabiApiClient in _clients)
|
||||||
|
{
|
||||||
|
nostrWabiSabiApiClient.Value.Dispose();
|
||||||
|
}
|
||||||
|
_clients.Clear();
|
||||||
|
_started = false;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IWabiSabiApiRequestHandler NewWabiSabiApiRequestHandler(Mode mode, ICircuit circuit = null)
|
||||||
|
{
|
||||||
|
if (mode == Mode.DefaultCircuit || _socks5HttpClientHandler.Proxy is null)
|
||||||
|
{
|
||||||
|
circuit = DefaultCircuit.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == Mode.NewCircuitPerRequest)
|
||||||
|
{
|
||||||
|
circuit = new OneOffCircuit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (circuit is not INamedCircuit namedCircuit)
|
||||||
|
throw new ArgumentException("circuit must be a INamedCircuit");
|
||||||
|
var result = _clients.GetOrAdd(namedCircuit.Name, name =>
|
||||||
|
{
|
||||||
|
var result = new NostrWabiSabiApiClient(new Uri(_nostrProfileNote.Relays.First()),
|
||||||
|
_socks5HttpClientHandler.Proxy as WebProxy, NostrExtensions.ParsePubKey(_nostrProfileNote.PubKey),
|
||||||
|
namedCircuit);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public class LocalWabisabiClientFactory: IWasabiHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly WabiSabiController _wabiSabiController;
|
||||||
|
|
||||||
|
public LocalWabisabiClientFactory(WabiSabiController wabiSabiController)
|
||||||
|
{
|
||||||
|
_wabiSabiController = wabiSabiController;
|
||||||
|
}
|
||||||
|
public IWabiSabiApiRequestHandler NewWabiSabiApiRequestHandler(Mode mode, ICircuit circuit = null)
|
||||||
|
{
|
||||||
|
return _wabiSabiController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WabisabiCoordinatorClientInstance:IHostedService
|
||||||
{
|
{
|
||||||
private readonly IUTXOLocker _utxoLocker;
|
private readonly IUTXOLocker _utxoLocker;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
@@ -136,12 +216,13 @@ public class WabisabiCoordinatorClientInstance
|
|||||||
public Uri Coordinator { get; set; }
|
public Uri Coordinator { get; set; }
|
||||||
public WalletProvider WalletProvider { get; }
|
public WalletProvider WalletProvider { get; }
|
||||||
public string TermsConditions { get; set; }
|
public string TermsConditions { get; set; }
|
||||||
public WasabiHttpClientFactory WasabiHttpClientFactory { get; set; }
|
public IWasabiHttpClientFactory WasabiHttpClientFactory { get; set; }
|
||||||
public RoundStateUpdater RoundStateUpdater { get; set; }
|
public RoundStateUpdater RoundStateUpdater { get; set; }
|
||||||
public CoinPrison CoinPrison { get; private set; }
|
public CoinPrison CoinPrison { get; private set; }
|
||||||
public WasabiCoordinatorStatusFetcher WasabiCoordinatorStatusFetcher { get; set; }
|
public WasabiCoordinatorStatusFetcher WasabiCoordinatorStatusFetcher { get; set; }
|
||||||
public CoinJoinManager CoinJoinManager { get; set; }
|
public CoinJoinManager CoinJoinManager { get; set; }
|
||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
|
private readonly WalletWasabi.Services.HostedServices _hostedServices = new();
|
||||||
|
|
||||||
public WabisabiCoordinatorClientInstance(string coordinatorDisplayName,
|
public WabisabiCoordinatorClientInstance(string coordinatorDisplayName,
|
||||||
string coordinatorName,
|
string coordinatorName,
|
||||||
@@ -149,17 +230,20 @@ public class WabisabiCoordinatorClientInstance
|
|||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
IUTXOLocker utxoLocker,
|
IUTXOLocker utxoLocker,
|
||||||
WalletProvider walletProvider, string termsConditions, string description,string coordinatorIdentifier = "CoinJoinCoordinatorIdentifier")
|
WalletProvider walletProvider, string termsConditions, string description,
|
||||||
|
Socks5HttpClientHandler socks5HttpClientHandler, string coordinatorIdentifier = "CoinJoinCoordinatorIdentifier"
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
|
||||||
_utxoLocker = utxoLocker;
|
_utxoLocker = utxoLocker;
|
||||||
var config = serviceProvider.GetService<IConfiguration>();
|
var config = serviceProvider.GetService<IConfiguration>();
|
||||||
var socksEndpoint = config.GetValue<string>("socksendpoint");
|
var socksEndpoint = config.GetValue<string>("socksendpoint");
|
||||||
EndPointParser.TryParse(socksEndpoint,9050, out var torEndpoint);
|
EndPointParser.TryParse(socksEndpoint, 9050, out var torEndpoint);
|
||||||
if (torEndpoint is not null && torEndpoint is DnsEndPoint dnsEndPoint)
|
if (torEndpoint is not null && torEndpoint is DnsEndPoint dnsEndPoint)
|
||||||
{
|
{
|
||||||
torEndpoint = new IPEndPoint(Dns.GetHostAddresses(dnsEndPoint.Host).First(), dnsEndPoint.Port);
|
torEndpoint = new IPEndPoint(Dns.GetHostAddresses(dnsEndPoint.Host).First(), dnsEndPoint.Port);
|
||||||
}
|
}
|
||||||
|
|
||||||
CoordinatorDisplayName = coordinatorDisplayName;
|
CoordinatorDisplayName = coordinatorDisplayName;
|
||||||
CoordinatorName = coordinatorName;
|
CoordinatorName = coordinatorName;
|
||||||
Coordinator = coordinator;
|
Coordinator = coordinator;
|
||||||
@@ -167,22 +251,37 @@ public class WabisabiCoordinatorClientInstance
|
|||||||
TermsConditions = termsConditions;
|
TermsConditions = termsConditions;
|
||||||
Description = description;
|
Description = description;
|
||||||
_logger = loggerFactory.CreateLogger(coordinatorName);
|
_logger = loggerFactory.CreateLogger(coordinatorName);
|
||||||
IWabiSabiApiRequestHandler sharedWabisabiClient;
|
IWabiSabiApiRequestHandler sharedWabisabiClient = null;
|
||||||
|
|
||||||
|
var roundStateUpdaterCircuit = new PersonCircuit();
|
||||||
|
|
||||||
if (coordinatorName == "local")
|
if (coordinatorName == "local")
|
||||||
{
|
{
|
||||||
sharedWabisabiClient = serviceProvider.GetRequiredService<WabiSabiController>();
|
WasabiHttpClientFactory = new LocalWabisabiClientFactory( serviceProvider.GetRequiredService<WabiSabiController>());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
else if (coordinator.Scheme == "nostr" &&
|
||||||
|
coordinator.Host.FromNIP19Note() is NIP19.NosteProfileNote nostrProfileNote)
|
||||||
|
{
|
||||||
|
var factory = new NostrWabisabiClientFactory(socks5HttpClientHandler, nostrProfileNote);
|
||||||
|
WasabiHttpClientFactory = factory;
|
||||||
|
_hostedServices.Register<NostrWabisabiClientFactory>(() => factory, "NostrWabisabiClientFactory");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
WasabiHttpClientFactory = new WasabiHttpClientFactory(torEndpoint, () => Coordinator);
|
WasabiHttpClientFactory = new WasabiHttpClientFactory(torEndpoint, () => Coordinator);
|
||||||
var roundStateUpdaterCircuit = new PersonCircuit();
|
|
||||||
var roundStateUpdaterHttpClient =
|
|
||||||
WasabiHttpClientFactory.NewHttpClient(Mode.SingleCircuitPerLifetime, roundStateUpdaterCircuit);
|
}
|
||||||
if (termsConditions is null)
|
|
||||||
|
sharedWabisabiClient =
|
||||||
|
WasabiHttpClientFactory.NewWabiSabiApiRequestHandler(Mode.SingleCircuitPerLifetime,
|
||||||
|
roundStateUpdaterCircuit);
|
||||||
|
|
||||||
|
if (termsConditions is null && sharedWabisabiClient is WabiSabiHttpApiClient wabiSabiHttpApiClient)
|
||||||
{
|
{
|
||||||
_ = new WasabiClient(roundStateUpdaterHttpClient)
|
|
||||||
.GetLegalDocumentsAsync(CancellationToken.None)
|
_ = wabiSabiHttpApiClient.GetLegalDocumentsAsync(CancellationToken.None)
|
||||||
.ContinueWith(task =>
|
.ContinueWith(task =>
|
||||||
{
|
{
|
||||||
if (task.Status == TaskStatus.RanToCompletion)
|
if (task.Status == TaskStatus.RanToCompletion)
|
||||||
@@ -191,29 +290,24 @@ public class WabisabiCoordinatorClientInstance
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sharedWabisabiClient = new WabiSabiHttpApiClient(roundStateUpdaterHttpClient);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
WasabiCoordinatorStatusFetcher = new WasabiCoordinatorStatusFetcher(sharedWabisabiClient, _logger);
|
WasabiCoordinatorStatusFetcher = new WasabiCoordinatorStatusFetcher(sharedWabisabiClient, _logger);
|
||||||
|
|
||||||
RoundStateUpdater = new RoundStateUpdater(TimeSpan.FromSeconds(5),sharedWabisabiClient, WasabiCoordinatorStatusFetcher);
|
RoundStateUpdater =
|
||||||
|
new RoundStateUpdater(TimeSpan.FromSeconds(5), sharedWabisabiClient, WasabiCoordinatorStatusFetcher);
|
||||||
|
|
||||||
CoinPrison = SettingsCoinPrison.CreateFromCoordinatorName(serviceProvider.GetRequiredService<SettingsRepository>(),
|
CoinPrison = SettingsCoinPrison.CreateFromCoordinatorName(
|
||||||
|
serviceProvider.GetRequiredService<SettingsRepository>(),
|
||||||
CoordinatorName).GetAwaiter().GetResult();
|
CoordinatorName).GetAwaiter().GetResult();
|
||||||
if (coordinatorName == "local")
|
|
||||||
{
|
|
||||||
CoinJoinManager = new CoinJoinManager(coordinatorName, WalletProvider, RoundStateUpdater,
|
|
||||||
sharedWabisabiClient, null,
|
|
||||||
WasabiCoordinatorStatusFetcher, coordinatorIdentifier, CoinPrison);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
CoinJoinManager = new CoinJoinManager(coordinatorName,WalletProvider, RoundStateUpdater,null, WasabiHttpClientFactory,
|
|
||||||
WasabiCoordinatorStatusFetcher, coordinatorIdentifier, CoinPrison);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
CoinJoinManager = new CoinJoinManager(coordinatorName, WalletProvider, RoundStateUpdater,
|
||||||
|
WasabiHttpClientFactory,
|
||||||
|
WasabiCoordinatorStatusFetcher, coordinatorIdentifier, CoinPrison);
|
||||||
CoinJoinManager.StatusChanged += OnStatusChanged;
|
CoinJoinManager.StatusChanged += OnStatusChanged;
|
||||||
|
|
||||||
|
_hostedServices.Register<RoundStateUpdater>(() => RoundStateUpdater, "RoundStateUpdater");
|
||||||
|
_hostedServices.Register<WasabiCoordinatorStatusFetcher>(() => WasabiCoordinatorStatusFetcher, "WasabiCoordinatorStatusFetcher");
|
||||||
|
_hostedServices.Register<CoinJoinManager>(() => CoinJoinManager, "WasabiCoordinatorStatusFetcher");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StopWallet(IWallet wallet)
|
public async Task StopWallet(IWallet wallet)
|
||||||
@@ -274,17 +368,14 @@ public class WabisabiCoordinatorClientInstance
|
|||||||
|
|
||||||
public Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RoundStateUpdater.StartAsync(cancellationToken);
|
_ = _hostedServices.StartAllAsync(cancellationToken);
|
||||||
WasabiCoordinatorStatusFetcher.StartAsync(cancellationToken);
|
|
||||||
CoinJoinManager.StartAsync(cancellationToken);
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RoundStateUpdater.StopAsync(cancellationToken);
|
_ = _hostedServices.StopAllAsync(cancellationToken);
|
||||||
WasabiCoordinatorStatusFetcher.StopAsync(cancellationToken);
|
|
||||||
CoinJoinManager.StopAsync(cancellationToken);
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule submodules/btcpayserver updated: 25af9c4227...a921504bcf
Submodule submodules/walletwasabi updated: e3b22a8b0e...39b3c00afd
Reference in New Issue
Block a user