Implement UTXO banning functionality
ADD
WalletWasabi/JsonConverters/Timing/DefaultValueTimeSpanAttribute.cs
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace WalletWasabi.JsonConverters.Timing
+{
+ public class DefaultValueTimeSpanAttribute : DefaultValueAttribute
+ {
+ public DefaultValueTimeSpanAttribute(string json) : base(TimeSpanJsonConverter.Parse(json))
+ {
+ }
+ }
+}

ADD
WalletWasabi/JsonConverters/Timing/TimeSpanJsonConverter.cs
+using System;
+using System.ComponentModel;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace WalletWasabi.JsonConverters.Timing
+{
+ public class TimeSpanJsonConverter : JsonConverter
+ {
+ /// <inheritdoc />
+ public override bool CanConvert(Type objectType)
+ {
+ return objectType == typeof(TimeSpan);
+ }
+
+ /// <inheritdoc />
+ public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ {
+ var stringValue = reader.Value as string;
+ return Parse(stringValue);
+ }
+
+ public static TimeSpan Parse(string? stringValue)
+ {
+ if (string.IsNullOrWhiteSpace(stringValue))
+ {
+ return default;
+ }
+ else
+ {
+ var daysParts = stringValue.Split('d');
+ var days = int.Parse(daysParts[0].Trim());
+ var hoursParts = daysParts[1].Split('h');
+ var hours = int.Parse(hoursParts[0].Trim());
+ var minutesParts = hoursParts[1].Split('m');
+ var minutes = int.Parse(minutesParts[0].Trim());
+ var secondsParts = minutesParts[1].Split('s');
+ var seconds = int.Parse(secondsParts[0].Trim());
+ return new TimeSpan(days, hours, minutes, seconds);
+ }
+ }
+
+ /// <inheritdoc />
+ public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ {
+ var ts = (TimeSpan)value;
+ writer.WriteValue($"{ts.Days}d{ts.Hours}h{ts.Minutes}m{ts.Seconds}s");
+ }
+ }
+}

ADD
WalletWasabi/WabiSabi/Backend/Banning/Prison.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+
+namespace WalletWasabi.WabiSabi.Backend.Banning
+{
+ /// <summary>
+ /// Malicious UTXOs are sent here.
+ /// </summary>
+ public class Prison
+ {
+ public Prison(IEnumerable<Inmate> inmates)
+ {
+ Inmates = inmates.ToDictionary(x => x.Utxo, x => x);
+ }
+
+ private Dictionary<OutPoint, Inmate> Inmates { get; }
+ public object Lock { get; } = new object();
+
+ /// <summary>
+ /// To identify the latest change happened in the prison.
+ /// </summary>
+ public Guid ChangeId { get; private set; } = Guid.NewGuid();
+
+ public (int noted, int banned) CountInmates()
+ {
+ lock (Lock)
+ {
+ return (Inmates.Count(x => x.Value.Punishment == Punishment.Noted), Inmates.Count(x => x.Value.Punishment == Punishment.Banned));
+ }
+ }
+
+ public void Punish(OutPoint utxo, Punishment punishment, ulong lastDisruptedRoundId)
+ => Punish(new Inmate(utxo, punishment, DateTimeOffset.UtcNow, lastDisruptedRoundId));
+
+ private void Punish(Inmate inmate)
+ {
+ lock (Lock)
+ {
+ var utxo = inmate.Utxo;
+
+ // If successfully removed, then it contained it previously, so make the punishment banned and restart its time.
+ Inmate inmateToPunish = inmate;
+ if (Inmates.Remove(utxo))
+ {
+ // If it was noted before, then no matter the specified punishment, it must be banned.
+ // Both the started and the last disrupted round parameters must be updated.
+ inmateToPunish = new Inmate(utxo, Punishment.Banned, inmate.Started, inmate.LastDisruptedRoundId);
+ }
+
+ Inmates.Add(utxo, inmateToPunish);
+
+ ChangeId = Guid.NewGuid();
+ }
+ }
+
+ public bool TryRelease(Inmate inmate)
+ => TryRelease(inmate.Utxo, out _);
+
+ private bool TryRelease(OutPoint utxo, [NotNullWhen(returnValue: true)] out Inmate? inmate)
+ {
+ lock (Lock)
+ {
+ if (Inmates.TryGetValue(utxo, out inmate))
+ {
+ Inmates.Remove(utxo);
+ ChangeId = Guid.NewGuid();
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+ }
+
+ public IEnumerable<Inmate> ReleaseEligibleInmates(TimeSpan time)
+ {
+ lock (Lock)
+ {
+ var released = new List<Inmate>();
+
+ foreach (var inmate in Inmates.Values.ToList())
+ {
+ if (inmate.TimeSpent > time)
+ {
+ Inmates.Remove(inmate.Utxo);
+ released.Add(inmate);
+ }
+ }
+
+ if (released.Any())
+ {
+ ChangeId = Guid.NewGuid();
+ }
+
+ return released;
+ }
+ }
+
+ public bool TryGet(OutPoint utxo, [NotNullWhen(returnValue: true)] out Inmate? inmate)
+ {
+ lock (Lock)
+ {
+ return Inmates.TryGetValue(utxo, out inmate);
+ }
+ }
+
+ public IEnumerable<Inmate> GetInamtes()
+ {
+ lock (Lock)
+ {
+ return Inmates.Select(x => x.Value).ToList();
+ }
+ }
+ }
+}

DELETE
WalletWasabi/WabiSabi/Backend/Banning/UtxoJudge.cs
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using WalletWasabi.Bases;
-using WalletWasabi.Logging;
-
-namespace WalletWasabi.WabiSabi.Backend.Banning
-{
- /// <summary>
- /// Judge sends UTXOs to prison.
- /// </summary>
- public class UtxoJudge : PeriodicRunner
- {
- /// <param name="period">How often to make judgements.</param>
- public UtxoJudge(TimeSpan period, string prisonFilePath) : base(period)
- {
- Prison = UtxoPrison.FromFileOrEmpty(prisonFilePath);
- var inmateCount = Prison.CountInmates();
- if (inmateCount != 0)
- {
- Logger.LogInfo($"{inmateCount} UTXOs are found in prison. {Prison.CountInmates(Punishment.Banned)} are banned {Prison.CountInmates(Punishment.Noted)} are noted.");
- }
- }
-
- public UtxoPrison Prison { get; }
-
- protected override async Task ActionAsync(CancellationToken cancel)
- {
- var change = false;
-
- if (change)
- {
- await Prison.ToFileAsync().ConfigureAwait(false);
- }
- }
- }
-}

DELETE
WalletWasabi/WabiSabi/Backend/Banning/UtxoPrison.cs
-using NBitcoin;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using WalletWasabi.Logging;
-
-namespace WalletWasabi.WabiSabi.Backend.Banning
-{
- /// <summary>
- /// Malicious UTXOs are sent here.
- /// </summary>
- public class UtxoPrison
- {
- private UtxoPrison(string filePath, Dictionary<OutPoint, Inmate> inmates)
- {
- FilePath = filePath;
- Inmates = inmates;
- }
-
- private Dictionary<OutPoint, Inmate> Inmates { get; }
-
- private string FilePath { get; }
-
- public static UtxoPrison FromFileOrEmpty(string filePath)
- {
- var inmates = new Dictionary<OutPoint, Inmate>();
- if (File.Exists(filePath))
- {
- try
- {
- foreach (var inmate in File.ReadAllLines(filePath).Select(Inmate.FromString))
- {
- inmates.Add(inmate.Utxo, inmate);
- }
- }
- catch (Exception ex)
- {
- Logger.LogError(ex);
- Logger.LogWarning($"Deleting {filePath}");
- File.Delete(filePath);
- }
- }
-
- return new UtxoPrison(filePath, inmates);
- }
-
- public async Task ToFileAsync()
- {
- IoHelpers.EnsureContainingDirectoryExists(FilePath);
- await File.WriteAllLinesAsync(FilePath, Inmates.Select(x => x.ToString())).ConfigureAwait(false);
- }
-
- public int CountInmates(Punishment? punishment = null)
- {
- if (punishment is null)
- {
- return Inmates.Count;
- }
- else
- {
- return Inmates.Where(x => x.Value.Punishment == punishment).Count();
- }
- }
- }
-}

ADD
WalletWasabi/WabiSabi/Backend/Banning/Warden.cs
+using NBitcoin;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Bases;
+using WalletWasabi.Logging;
+
+namespace WalletWasabi.WabiSabi.Backend.Banning
+{
+ /// <summary>
+ /// Serializes and releases the prison population periodically.
+ /// </summary>
+ public class Warden : PeriodicRunner
+ {
+ /// <param name="period">How often to serialize and release inmates.</param>
+ public Warden(TimeSpan period, string prisonFilePath, WabiSabiConfig config) : base(period)
+ {
+ PrisonFilePath = prisonFilePath;
+ Config = config;
+ Prison = DeserializePrison(PrisonFilePath);
+ LastKnownChange = Prison.ChangeId;
+ }
+
+ public Prison Prison { get; }
+ public Guid LastKnownChange { get; private set; }
+
+ private string PrisonFilePath { get; }
+ public WabiSabiConfig Config { get; }
+
+ private static Prison DeserializePrison(string prisonFilePath)
+ {
+ IoHelpers.EnsureContainingDirectoryExists(prisonFilePath);
+ var inmates = new List<Inmate>();
+ if (File.Exists(prisonFilePath))
+ {
+ try
+ {
+ foreach (var inmate in File.ReadAllLines(prisonFilePath).Select(Inmate.FromString))
+ {
+ inmates.Add(inmate);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(ex);
+ Logger.LogWarning($"Deleting {prisonFilePath}");
+ File.Delete(prisonFilePath);
+ }
+ }
+
+ var prison = new Prison(inmates);
+
+ var (noted, banned) = prison.CountInmates();
+ if (noted > 0)
+ {
+ Logger.LogInfo($"{noted} noted UTXOs are found in prison.");
+ }
+
+ if (banned > 0)
+ {
+ Logger.LogInfo($"{banned} banned UTXOs are found in prison.");
+ }
+
+ return prison;
+ }
+
+ public async Task SerializePrisonAsync()
+ {
+ IoHelpers.EnsureContainingDirectoryExists(PrisonFilePath);
+ await File.WriteAllLinesAsync(PrisonFilePath, Prison.GetInamtes().Select(x => x.ToString())).ConfigureAwait(false);
+ }
+
+ protected override async Task ActionAsync(CancellationToken cancel)
+ {
+ var count = Prison.ReleaseEligibleInmates(Config.ReleaseUtxoFromPrisonAfter).Count();
+ if (count > 0)
+ {
+ Logger.LogInfo($"{count} UTXOs are released from prison.");
+ }
+
+ // If something changed, send prison to file.
+ if (LastKnownChange != Prison.ChangeId)
+ {
+ await SerializePrisonAsync().ConfigureAwait(false);
+ }
+ }
+ }
+}

MODIFY
WalletWasabi/WabiSabi/Backend/WabiSabiConfig.cs
WalletWasabi/WabiSabi/Backend/WabiSabiConfig.cs
@@ -7,6 +7,7 @@ using System.Text;
using System.Threading.Tasks;
using WalletWasabi.Bases;
using WalletWasabi.Helpers;
+using WalletWasabi.JsonConverters.Timing;
namespace WalletWasabi.WabiSabi.Backend
{
@@ -24,5 +25,10 @@ namespace WalletWasabi.WabiSabi.Backend
[DefaultValue(Constants.OneDayConfirmationTarget)]
[JsonProperty(PropertyName = "ConfirmationTarget", DefaultValueHandling = DefaultValueHandling.Populate)]
public int ConfirmationTarget { get; set; }
+
+ [DefaultValueTimeSpan("0d 3h 0m 0s")]
+ [JsonProperty(PropertyName = "ReleaseUtxoFromPrisonAfter", DefaultValueHandling = DefaultValueHandling.Populate)]
+ [JsonConverter(typeof(TimeSpanJsonConverter))]
+ public TimeSpan ReleaseUtxoFromPrisonAfter { get; set; }
}
}

MODIFY
WalletWasabi/WabiSabi/CoordinatorParameters.cs
WalletWasabi/WabiSabi/CoordinatorParameters.cs
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
+using WalletWasabi.WabiSabi.Backend;
namespace WalletWasabi.WabiSabi
{
@@ -10,13 +12,33 @@ namespace WalletWasabi.WabiSabi
{
public CoordinatorParameters(string dataDir)
{
- DataDir = dataDir;
+ ApplicationDataDir = dataDir;
+ IoHelpers.EnsureDirectoryExists(CoordinatorDataDir);
+
+ var runtimeConfigurationFilePath = Path.Combine(ApplicationDataDir, "WabiSabiConfig.json");
+ RuntimeCoordinatorConfig = new(runtimeConfigurationFilePath);
+ RuntimeCoordinatorConfig.LoadOrCreateDefaultFile();
}
/// <summary>
/// The main data directory of the application.
/// </summary>
- public string DataDir { get; }
+ public string ApplicationDataDir { get; }
+
+ /// <summary>
+ /// The main data directory of the coordinator.
+ /// </summary>
+ public string CoordinatorDataDir => Path.Combine(ApplicationDataDir, "WabiSabi");
+
+ /// <summary>
+ /// Banned UTXOs are serialized here.
+ /// </summary>
+ public string PrisonFilePath => Path.Combine(CoordinatorDataDir, "Prison.txt");
+
+ /// <summary>
+ /// Runtime adjustable configuration of the coordinator.
+ /// </summary>
+ public WabiSabiConfig RuntimeCoordinatorConfig { get; }
/// <summary>
/// Configuration of coinjoins can be modified runtime.
@@ -27,6 +49,6 @@ namespace WalletWasabi.WabiSabi
/// <summary>
/// How often should UTXOs be sent to prison.
/// </summary>
- public TimeSpan UtxoJudgementPeriod { get; init; } = TimeSpan.FromSeconds(1);
+ public TimeSpan UtxoJudgementPeriod { get; init; } = TimeSpan.FromSeconds(7);
}
}

MODIFY
WalletWasabi/WabiSabi/WabiSabiCoordinator.cs
WalletWasabi/WabiSabi/WabiSabiCoordinator.cs
@@ -17,42 +17,37 @@ namespace WalletWasabi.WabiSabi
{
public WabiSabiCoordinator(CoordinatorParameters parameters)
{
- DataDir = parameters.DataDir;
- WorkDir = Path.Combine(DataDir, "WabiSabi");
- IoHelpers.EnsureDirectoryExists(WorkDir);
+ Parameters = parameters;
- var configFilePath = Path.Combine(DataDir, "WabiSabiConfig.json");
- Config = new(configFilePath);
- Config.LoadOrCreateDefaultFile();
+ Warden = new(parameters.UtxoJudgementPeriod, parameters.PrisonFilePath, Config);
ConfigWatcher = new(parameters.ConfigChangeMonitoringPeriod, Config, () => { });
-
- var prisonFilePath = Path.Combine(WorkDir, "Prison.txt");
- Judge = new(parameters.UtxoJudgementPeriod, prisonFilePath);
}
- public string DataDir { get; }
- public string WorkDir { get; }
- public WabiSabiConfig Config { get; }
public ConfigWatcher ConfigWatcher { get; }
- public UtxoJudge Judge { get; }
+ public Warden Warden { get; }
+
+ public CoordinatorParameters Parameters { get; }
+ public string WorkDir => Parameters.CoordinatorDataDir;
+ public Prison Prison => Warden.Prison;
+ public WabiSabiConfig Config => Parameters.RuntimeCoordinatorConfig;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ConfigWatcher.StartAsync(stoppingToken).ConfigureAwait(false);
- await Judge.StartAsync(stoppingToken).ConfigureAwait(false);
+ await Warden.StartAsync(stoppingToken).ConfigureAwait(false);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken).ConfigureAwait(false);
await ConfigWatcher.StopAsync(cancellationToken).ConfigureAwait(false);
- await Judge.StopAsync(cancellationToken).ConfigureAwait(false);
+ await Warden.StopAsync(cancellationToken).ConfigureAwait(false);
}
public override void Dispose()
{
ConfigWatcher.Dispose();
- Judge.Dispose();
+ Warden.Dispose();
base.Dispose();
}
}

Implement UTXO banning functionality