Add `StatusEvent` (+ `BootstrapStatusEvent`) and test.
ADD
WalletWasabi.Tests/UnitTests/Tor/Control/Messages/Events/StatusEvents/StatusEventTests.cs
+using System.Threading.Tasks;
+using WalletWasabi.Tor.Control.Messages;
+using WalletWasabi.Tor.Control.Messages.Events.StatusEvents;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Tor.Control.Messages.Events.StatusEvents
+{
+ /// <summary>
+ /// Tests for <see cref="StatusEvent"/> class.
+ /// </summary>
+ public class StatusEventTests
+ {
+ [Fact]
+ public async Task ParseBootstrapStatusEvent1Async()
+ {
+ string data = "650 STATUS_CLIENT NOTICE BOOTSTRAP PROGRESS=14 TAG=handshake SUMMARY=\"Handshaking with a relay\"\r\n";
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ StatusEvent statusEvent = StatusEvent.FromReply(rawReply);
+
+ Assert.Equal(StatusType.STATUS_CLIENT, statusEvent.Type);
+ Assert.Equal(StatusSeverity.NOTICE, statusEvent.Severity);
+ Assert.Equal("BOOTSTRAP", statusEvent.Action);
+ Assert.Equal(3, statusEvent.Arguments.Count);
+ Assert.Equal("14", statusEvent.Arguments["PROGRESS"]);
+ Assert.Equal("handshake", statusEvent.Arguments["TAG"]);
+ Assert.Equal("Handshaking with a relay", statusEvent.Arguments["SUMMARY"]);
+ }
+
+ [Fact]
+ public async Task ParseBootstrapStatusEvent2Async()
+ {
+ string data = "650 STATUS_CLIENT NOTICE BOOTSTRAP PROGRESS=95 TAG=circuit_create SUMMARY=\"Establishing a Tor circuit\"\r\n";
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ StatusEvent statusEvent = StatusEvent.FromReply(rawReply);
+
+ Assert.Equal(StatusType.STATUS_CLIENT, statusEvent.Type);
+ Assert.Equal(StatusSeverity.NOTICE, statusEvent.Severity);
+ Assert.Equal("BOOTSTRAP", statusEvent.Action);
+ Assert.Equal(3, statusEvent.Arguments.Count);
+ Assert.Equal("95", statusEvent.Arguments["PROGRESS"]);
+ Assert.Equal("circuit_create", statusEvent.Arguments["TAG"]);
+ Assert.Equal("Establishing a Tor circuit", statusEvent.Arguments["SUMMARY"]);
+ }
+
+ [Fact]
+ public async Task ParseCircuitEstablishedStatusEventAsync()
+ {
+ string data = "650 STATUS_CLIENT NOTICE CIRCUIT_ESTABLISHED\r\n";
+
+ TorControlReply rawReply = await TorControlReplyReaderTest.ParseAsync(data);
+ StatusEvent statusEvent = StatusEvent.FromReply(rawReply);
+
+ Assert.Equal(StatusType.STATUS_CLIENT, statusEvent.Type);
+ Assert.Equal(StatusSeverity.NOTICE, statusEvent.Severity);
+ Assert.Equal("CIRCUIT_ESTABLISHED", statusEvent.Action);
+ Assert.Empty(statusEvent.Arguments);
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/Events/StatusEvents/BootstrapStatusEvent.cs
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace WalletWasabi.Tor.Control.Messages.Events.StatusEvents
+{
+ /// <summary>Status event of type <see cref="StatusType.STATUS_CLIENT"/> and ACTION=BOOTSTRAP.</summary>
+ public record BootstrapStatusEvent : StatusEvent
+ {
+ public BootstrapStatusEvent(string action, Dictionary<string, string> arguments) : base(action, arguments)
+ {
+ }
+
+ /// <summary>A number between <c>0</c> and <c>100</c>.</summary>
+ public int Progress => int.Parse(Arguments["PROGRESS"], NumberStyles.None, NumberFormatInfo.InvariantInfo);
+
+ /// <summary>Denotes a bootstrap phase.</summary>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt">5.5.2. Phases in Bootstrap Stage 1.</seealso>
+ /// <example><c>starting</c> for phase 0, <c>handshake</c> for phase 14, <c>done</c> for phase 100</example>
+ public string Tag => Arguments["TAG"];
+
+ /// <summary>String that can be displayed to the user to describe the *next* task that Tor will tackle.</summary>
+ /// <remarks>That is, the task it is working on after sending the status event.</remarks>
+ public string Summary => Arguments["SUMMARY"];
+
+ /// <summary>Human readable string with any hints Tor has to offer about why it's having troubles bootstrapping or <c>null</c>.</summary>
+ public string? Warning
+ {
+ get
+ {
+ _ = Arguments.TryGetValue("WARNING", out string? result);
+ return result;
+ }
+ }
+
+ /// <summary>Long-term-stable controller-facing tags to identify particular issues in a bootstrapping step or <c>null</c>.</summary>
+ public string? Reason
+ {
+ get
+ {
+ _ = Arguments.TryGetValue("REASON", out string? result);
+ return result;
+ }
+ }
+
+ /// <remarks>
+ /// Two values are possible at the moment:
+ /// <list type="bullet">
+ /// <item><c>ignore</c> - the controller can accumulate the string in a pile of problems to show the user if the user asks,</item>
+ /// <item><c>warn</c> - the controller should alert the user that Tor is pretty sure there's a bootstrapping problem.</item>
+ /// </list>
+ /// </remarks>
+ public string? Recommendation
+ {
+ get
+ {
+ _ = Arguments.TryGetValue("RECOMMENDATION", out string? result);
+ return result;
+ }
+ }
+ }
+}

ADD
WalletWasabi/Tor/Control/Messages/Events/StatusEvents/StatusEvent.cs
+using System;
+using System.Collections.Generic;
+using WalletWasabi.Tor.Control.Exceptions;
+using WalletWasabi.Tor.Control.Utils;
+
+namespace WalletWasabi.Tor.Control.Messages.Events.StatusEvents
+{
+ public enum StatusType
+ {
+ STATUS_GENERAL,
+ STATUS_CLIENT,
+ STATUS_SERVER
+ }
+
+ public enum StatusSeverity
+ {
+ NOTICE,
+ WARN,
+ ERR
+ }
+
+ /// <summary>Base representation of a status event as specified in <c>4.1.10. Status events</c> spec.</summary>
+ /// <remarks>
+ /// Grammar is as follows:
+ /// <code>
+ /// "650" SP StatusType SP StatusSeverity SP StatusAction
+ /// [SP StatusArguments] CRLF
+ ///
+ /// StatusType = "STATUS_GENERAL" / "STATUS_CLIENT" / "STATUS_SERVER"
+ /// StatusSeverity = "NOTICE" / "WARN" / "ERR"
+ /// StatusAction = 1*ALPHA
+ /// StatusArguments = StatusArgument *(SP StatusArgument)
+ /// StatusArgument = StatusKeyword '=' StatusValue
+ /// StatusKeyword = 1*(ALNUM / "_")
+ /// StatusValue = 1*(ALNUM / '_') / QuotedString
+ /// </code>
+ /// </remarks>
+ /// <seealso href="https://gitweb.torproject.org/torspec.git/tree/control-spec.txt"/>
+ public record StatusEvent : IAsyncEvent
+ {
+ public StatusEvent(string action, Dictionary<string, string> arguments)
+ {
+ Action = action;
+ Arguments = arguments;
+ }
+
+ public StatusType Type { get; init; }
+
+ public StatusSeverity Severity { get; init; }
+
+ public string Action { get; init; }
+
+ public Dictionary<string, string> Arguments { get; init; }
+
+ /// <exception cref="TorControlReplyParseException"/>
+ public static StatusEvent FromReply(TorControlReply reply)
+ {
+ if (reply.StatusCode != StatusCode.AsynchronousEventNotify)
+ {
+ throw new TorControlReplyParseException($"StatusEvent: Expected {StatusCode.AsynchronousEventNotify} status code.");
+ }
+
+ string remainder = reply.ResponseLines[0];
+ string value;
+
+ (value, remainder) = Tokenizer.ReadUntilSeparator(remainder);
+
+ if (!Enum.TryParse(value, out StatusType statusType))
+ {
+ throw new TorControlReplyParseException("StatusEvent: Unknown status type.");
+ }
+
+ (value, remainder) = Tokenizer.ReadUntilSeparator(remainder);
+
+ if (!Enum.TryParse(value, out StatusSeverity statusSeverity))
+ {
+ throw new TorControlReplyParseException("StatusEvent: Unknown status type.");
+ }
+
+ // Mandatory piece of information per spec.
+ string action;
+ (action, remainder) = Tokenizer.ReadUntilSeparator(remainder);
+
+ Dictionary<string, string> arguments = new();
+
+ // Optional pieces of information per spec.
+ while (remainder != string.Empty)
+ {
+ string key;
+ (key, value, remainder) = Tokenizer.ReadKeyValueAssignment(remainder, allowValueAsQuotedString: true);
+
+ if (!arguments.TryAdd(key, value))
+ {
+ throw new TorControlReplyParseException("StatusEvent: Argument already defined.");
+ }
+ }
+
+ if (action == "BOOTSTRAP")
+ {
+ return new BootstrapStatusEvent(action, arguments)
+ {
+ Type = statusType,
+ Severity = statusSeverity,
+ };
+ }
+ else
+ {
+ return new StatusEvent(action, arguments)
+ {
+ Type = statusType,
+ Severity = statusSeverity,
+ };
+ }
+ }
+ }
+}

Add `StatusEvent` (+ `BootstrapStatusEvent`) and test.