diff --git a/BasicTests/BasicTests.csproj b/BasicTests/BasicTests.csproj new file mode 100644 index 0000000..01ff9db --- /dev/null +++ b/BasicTests/BasicTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + diff --git a/BasicTests/GlobalUsings.cs b/BasicTests/GlobalUsings.cs new file mode 100644 index 0000000..3244567 --- /dev/null +++ b/BasicTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; diff --git a/BasicTests/NostrBufTests.cs b/BasicTests/NostrBufTests.cs new file mode 100644 index 0000000..d19cff5 --- /dev/null +++ b/BasicTests/NostrBufTests.cs @@ -0,0 +1,44 @@ +using Newtonsoft.Json; +using Nostr.Client.Json; +using Nostr.Client.Messages; +using NostrRelay; + +namespace BasicTests; + +public class Tests +{ + private NostrEvent _testEvent = null!; + + [SetUp] + public void Setup() + { + var json = + "{\"id\":\"0552df8c6617c8919f1872ce8a0b2834f3f28100e4bf07be8b4aa262e9d59321\",\"pubkey\":\"27154fb873badf69c3ea83a0da6e65d6a150d2bf8f7320fc3314248d74645c64\",\"created_at\":1705062495,\"kind\":1,\"tags\":[[\"r\",\"https://www.coindesk.com/business/2024/01/12/bitcoin-miner-outflows-hit-six-year-highs-ahead-of-halving-sparking-mixed-signals/\"]],\"content\":\"‚Miner outflow has hit a multi-year high as tens of thousands of bitcoin (BTC), worth over $1 billion, have been sent to exchanges.‘\\n\\nhttps://www.coindesk.com/business/2024/01/12/bitcoin-miner-outflows-hit-six-year-highs-ahead-of-halving-sparking-mixed-signals/\",\"sig\":\"ba2523600bea47637bcced3b75ec446253dca184783a98ec807bf0a4fc90031152c225e7a39392bb3af23ceafc64aa67d9c10874e8ec6d2c1af268235a554d93\"}"; + + _testEvent = JsonConvert.DeserializeObject(json, NostrSerializer.Settings)!; + } + + [Test] + public void TestNostrBuf() + { + var data = NostrBuf.Encode(_testEvent); + Assert.That(data.Length, Is.EqualTo(NostrBuf.CalculateSize(_testEvent))); + + var ev = NostrBuf.Decode(data); + Assert.That(ev.Id, Is.EqualTo(_testEvent.Id)); + Assert.That(ev.Pubkey, Is.EqualTo(_testEvent.Pubkey)); + Assert.That(ev.Sig, Is.EqualTo(_testEvent.Sig)); + Assert.That(ev.CreatedAt, Is.EqualTo(_testEvent.CreatedAt)); + Assert.That(ev.Kind, Is.EqualTo(_testEvent.Kind)); + Assert.That(ev.Tags!.Count, Is.EqualTo(_testEvent.Tags!.Count)); + for (var tx = 0; tx < ev.Tags.Count; tx++) + { + Assert.That(ev.Tags[tx].TagIdentifier, Is.EqualTo(_testEvent.Tags[tx].TagIdentifier)); + Assert.That(ev.Tags[tx].AdditionalData.Length, Is.EqualTo(_testEvent.Tags[tx].AdditionalData.Length)); + for (var ty = 0; ty < ev.Tags[tx].AdditionalData.Length; ty++) + { + Assert.That(ev.Tags[tx].AdditionalData[ty], Is.EqualTo(_testEvent.Tags[tx].AdditionalData[ty])); + } + } + } +} diff --git a/FASTERlay/.gitignore b/FASTERlay/.gitignore new file mode 100644 index 0000000..adbb97d --- /dev/null +++ b/FASTERlay/.gitignore @@ -0,0 +1 @@ +data/ \ No newline at end of file diff --git a/FASTERlay/Dockerfile b/FASTERlay/Dockerfile new file mode 100644 index 0000000..4281adb --- /dev/null +++ b/FASTERlay/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FASTERlay/FASTERlay.csproj", "FASTERlay/"] +RUN dotnet restore "FASTERlay/FASTERlay.csproj" +COPY . . +WORKDIR "/src/FASTERlay" +RUN dotnet build "FASTERlay.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "FASTERlay.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FASTERlay.dll"] diff --git a/FASTERlay/Extensions.cs b/FASTERlay/Extensions.cs new file mode 100644 index 0000000..b2ea89c --- /dev/null +++ b/FASTERlay/Extensions.cs @@ -0,0 +1,5 @@ +namespace FASTERlay; + +public static class Extensions +{ +} diff --git a/FASTERlay/FASTERRelay.cs b/FASTERlay/FASTERRelay.cs new file mode 100644 index 0000000..5ed5e4e --- /dev/null +++ b/FASTERlay/FASTERRelay.cs @@ -0,0 +1,118 @@ +using FASTER.core; +using Nostr.Client.Messages; +using Nostr.Client.Requests; +using NostrRelay; + +namespace FASTERlay; + +public class FasterRelay : INostrRelay, IDisposable +{ + private readonly ClientSession> _session; + private readonly ClientSession _tagsSession; + + public FasterRelay(NostrStore store) + { + _session = store.MainStore.For(new SimpleFunctions()).NewSession>(); + _tagsSession = store.TagStore.For(new TagSetFunctions()).NewSession(); + } + + public ValueTask AcceptConnection(NostrClientContext context) + { + return ValueTask.FromResult(true); + } + + public async IAsyncEnumerable HandleRequest(NostrClientContext context, NostrRequest req) + { + var iter = _session.Iterate(); + var filter = req.NostrFilter; + while (iter.GetNext(out _, out _, out var bytes)) + { + var ev = NostrBuf.Decode(bytes); + if (ev != default) + { + if (filter.Since.HasValue && ev.CreatedAt <= filter.Since) + { + continue; + } + + if (filter.Until.HasValue && ev.CreatedAt > filter.Until) + { + continue; + } + + if (filter.Ids != default && !filter.Ids.Contains(ev.Id!)) + { + continue; + } + + if (filter.Authors != default && !filter.Authors.Contains(ev.Pubkey!)) + { + continue; + } + + if (filter.Kinds != default && !filter.Kinds.Contains(ev.Kind)) + { + continue; + } + + yield return ev; + } + } + } + + public async ValueTask HandleEvent(NostrClientContext context, NostrEvent ev) + { + await SaveEvent(ev); + return new(true, null); + } + + public void Dispose() + { + _session.Dispose(); + _tagsSession.Dispose(); + } + + private async Task SaveEvent(NostrEvent ev) + { + var idBytes = Convert.FromHexString(ev.Id!); + var eventBytes = NostrBuf.Encode(ev); + + await _session.UpsertAsync(ref idBytes, ref eventBytes); + + var indexTags = ev.Tags!.Where(a => a.TagIdentifier.Length == 1); + foreach (var tag in indexTags) + { + var kIndex = $"{tag.TagIdentifier}.{tag.AdditionalData[0]}"; + await _tagsSession.RMWAsync(ref kIndex, ref idBytes); + } + + await _session.CompletePendingAsync(); + await _tagsSession.CompletePendingAsync(); + } + + private async Task GetEvent(string id) + { + var idBytes = Convert.FromHexString(id); + var read = await _session.ReadAsync(ref idBytes); + if (read.Status.Found) + { + var data = read.Complete().output; + return NostrBuf.Decode(data); + } + + return default; + } + + private class TagSetFunctions : SimpleFunctions + { + public TagSetFunctions() : base((l, r) => + { + var output = new byte[l.Length + r.Length]; + Buffer.BlockCopy(l, 0, output, 0, l.Length); + Buffer.BlockCopy(r, 0, output, l.Length, r.Length); + return output; + }) + { + } + } +} diff --git a/FASTERlay/FASTERlay.csproj b/FASTERlay/FASTERlay.csproj new file mode 100644 index 0000000..5547258 --- /dev/null +++ b/FASTERlay/FASTERlay.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + diff --git a/FASTERlay/NostrStore.cs b/FASTERlay/NostrStore.cs new file mode 100644 index 0000000..8ee9429 --- /dev/null +++ b/FASTERlay/NostrStore.cs @@ -0,0 +1,58 @@ +using FASTER.core; + +namespace FASTERlay; + +public class NostrStore : IDisposable +{ + private readonly ILogger _logger; + private readonly CancellationTokenSource _cts = new(); + private readonly FasterKVSettings _storeSettings; + private readonly FasterKV _store; + private readonly FasterKVSettings _tagStoreSettings; + private readonly FasterKV _tagStore; + + private readonly Task _checkpointer; + + public NostrStore(string dir, ILogger logger) + { + _logger = logger; + _storeSettings = new FasterKVSettings(Path.Combine(dir, "main"), false, _logger); + _store = new(_storeSettings); + var version = _store.GetLatestCheckpointVersion(); + if (version != -1) + { + _store.Recover(); + } + + _tagStoreSettings = new FasterKVSettings(Path.Combine(dir, "tags"), false, _logger); + _tagStore = new(_tagStoreSettings); + var keyVersion = _tagStore.GetLatestCheckpointVersion(); + if (keyVersion != -1) + { + _tagStore.Recover(); + } + + _checkpointer = Task.Factory.StartNew(async () => + { + while (!_cts.IsCancellationRequested) + { + await Task.Delay(10_000); + await _store.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true); + await _tagStore.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true); + } + }); + } + public FasterKV MainStore => _store; + public FasterKV TagStore => _tagStore; + + public void Dispose() + { + _cts.Cancel(); + _checkpointer.ConfigureAwait(false).GetAwaiter().GetResult(); + + _store.Dispose(); + _storeSettings.Dispose(); + _tagStore.Dispose(); + _tagStoreSettings.Dispose(); + } +} diff --git a/FASTERlay/Program.cs b/FASTERlay/Program.cs new file mode 100644 index 0000000..177f63f --- /dev/null +++ b/FASTERlay/Program.cs @@ -0,0 +1,77 @@ +using System.Net.WebSockets; +using FASTERlay; +using Newtonsoft.Json; +using Nostr.Client.Json; +using NostrRelay; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton(svc => +{ + var logger = svc.GetRequiredService>(); + return new NostrStore("./data", logger); +}); + +builder.Services.AddTransient(); + +var app = builder.Build(); + + +app.UseWebSockets(); +app.MapGet("/", async ctx => +{ + if (ctx.WebSockets.IsWebSocketRequest) + { + var logger = app.Services.GetRequiredService>>(); + using var handler = app.Services.GetRequiredService(); + try + { + var ws = await ctx.WebSockets.AcceptWebSocketAsync(); + var wsCtx = new NostrClientContext(ws, ctx.Connection.RemoteIpAddress!, + ctx.Request.Headers.UserAgent.FirstOrDefault() ?? string.Empty); + + var nostrRelay = new NostrRelay(handler, wsCtx, ctx.RequestAborted, logger); + + await nostrRelay.Read(); + + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, null, ctx.RequestAborted); + } + catch (Exception ex) + { + logger.LogDebug(ex.Message); + } + } + else + { + var isRelayDocRequest = ctx.Request.Headers.Accept.FirstOrDefault() + ?.Equals("application/nostr+json", StringComparison.InvariantCultureIgnoreCase) ?? false; + + if (isRelayDocRequest) + { + var doc = new RelayDocument + { + Name = "", + Description = "C# FASTER relay", + Pubkey = "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", + SupportedNips = [1], + Software = "NostrServices", + Icon = "https://void.cat/d/CtXsF5EvqNLG3K85TgezCt.webp", + Limitation = new() + { + RestrictedWrites = false, + AuthRequired = false, + PaymentRequired = false + } + }; + + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(doc, NostrSerializer.Settings)); + } + else + { + await ctx.Response.WriteAsync("Hey"); + } + } +}); + +app.Run(); diff --git a/FASTERlay/Properties/launchSettings.json b/FASTERlay/Properties/launchSettings.json new file mode 100644 index 0000000..e4a92cd --- /dev/null +++ b/FASTERlay/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5042", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/FASTERlay/appsettings.Development.json b/FASTERlay/appsettings.Development.json new file mode 100644 index 0000000..afc0632 --- /dev/null +++ b/FASTERlay/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "NostrRelay": "Debug", + "FASTER": "Debug" + } + } +} diff --git a/FASTERlay/appsettings.json b/FASTERlay/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/FASTERlay/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/NostrRelay/NostrBuf.cs b/NostrRelay/NostrBuf.cs new file mode 100644 index 0000000..d8aea26 --- /dev/null +++ b/NostrRelay/NostrBuf.cs @@ -0,0 +1,118 @@ +using System.Text; +using Nostr.Client.Messages; + +namespace NostrRelay; + +/// +/// Simple fixed length nostr event storage +/// +public static class NostrBuf +{ + public static byte[] Encode(NostrEvent ev) + { + var buf = new byte[CalculateSize(ev)]; + var span = buf.AsSpan(); + span[0] = 0x01; // version 1 + Convert.FromHexString(ev.Id!).CopyTo(span[1..33]); + Convert.FromHexString(ev.Pubkey!).CopyTo(span[33..65]); + Convert.FromHexString(ev.Sig!).CopyTo(span[65..129]); + BitConverter.GetBytes((ulong)ToUnixTime(ev.CreatedAt!.Value)).CopyTo(span[129..137]); + BitConverter.GetBytes((uint)ev.Kind).CopyTo(span[137..141]); + var contentBytes = Encoding.UTF8.GetBytes(ev.Content!); + BitConverter.GetBytes((uint)contentBytes.Length).CopyTo(span[141..145]); + var pos = 145; + contentBytes.CopyTo(span[pos..(pos += contentBytes.Length)]); + BitConverter.GetBytes((ushort)ev.Tags!.Count).CopyTo(span[pos..(pos += 2)]); + + foreach (var tag in ev.Tags) + { + var tagKey = Encoding.UTF8.GetBytes(tag.TagIdentifier); + span[pos++] = (byte)tagKey.Length; + tagKey.CopyTo(span[pos..(pos += tagKey.Length)]); + + span[pos++] = (byte)tag.AdditionalData.Length; + foreach (var tagAdd in tag.AdditionalData) + { + var tagAddBytes = Encoding.UTF8.GetBytes(tagAdd); + BitConverter.GetBytes((ushort)tagAddBytes.Length).CopyTo(span[pos..(pos += 2)]); + tagAddBytes.CopyTo(span[pos..(pos += tagAddBytes.Length)]); + } + } + + return buf; + } + + public static NostrEvent? Decode(Span data) + { + var version = data[0]; + if (version != 0x01) throw new Exception("Version not supported"); + + var id = Convert.ToHexString(data[1..33]).ToLower(); + var pubkey = Convert.ToHexString(data[33..65]).ToLower(); + var sig = Convert.ToHexString(data[65..129]).ToLower(); + var createdAt = BitConverter.ToUInt64(data[129..137]); + var kind = BitConverter.ToUInt32(data[137..141]); + var contentLen = BitConverter.ToUInt32(data[141..145]); + var pos = 145; + var content = Encoding.UTF8.GetString(data[pos..(pos += (int)contentLen)]); + var nTags = BitConverter.ToUInt16(data[pos..(pos += 2)]); + + var tags = new List(); + for (var x = 0; x < nTags; x++) + { + var keyLen = data[pos++]; + var keyString = Encoding.UTF8.GetString(data[pos..(pos += keyLen)]); + + var nElements = data[pos++]; + var elms = new string[nElements]; + for (var y = 0; y < nElements; y++) + { + var elmLen = BitConverter.ToUInt16(data[pos..(pos += 2)]); + var elmString = Encoding.UTF8.GetString(data[pos..(pos += elmLen)]); + elms[y] = elmString; + } + + tags.Add(new(keyString, elms)); + } + + return new NostrEvent() + { + Id = id, + Pubkey = pubkey, + Sig = sig, + CreatedAt = DateTimeOffset.FromUnixTimeSeconds((long)createdAt).UtcDateTime, + Kind = (NostrKind)kind, + Content = content, + Tags = new NostrEventTags(tags) + }; + } + + public static long CalculateSize(NostrEvent ev) + { + const long fixedPart = + 1 // version byte + + 32 // id + + 32 // pubkey + + 64 // sig + + 8 // created_at (uint64) + + 4 // kind (uint32) + + 4 // len(content) (uint32) + + 2; // len(tags) (uint16) + + var variableLength = + Encoding.UTF8.GetByteCount(ev.Content!) + + ev.Tags!.Sum(a => + 1 + // 1 byte for length of first tag element (key) + Encoding.UTF8.GetByteCount(a.TagIdentifier) + // length of the tag key + a.AdditionalData.Length + // 1 byte - number of elements + a.AdditionalData.Sum(b => Encoding.UTF8.GetByteCount(b)) + // length of the content of the items in the tag + (a.AdditionalData.Length * 2)); // 2 bytes per tag element for length prefix + + return fixedPart + variableLength; + } + + private static long ToUnixTime(DateTime dt) + { + return dt.ToUniversalTime().Ticks / 10000000L - 62135596800L; + } +} diff --git a/NostrRelay/Relay.cs b/NostrRelay/Relay.cs index 0ee02b0..9b7e242 100644 --- a/NostrRelay/Relay.cs +++ b/NostrRelay/Relay.cs @@ -27,7 +27,7 @@ public class NostrRelay where THandler : INostrRelay private async Task WriteResponse(T obj) { var rspJson = JsonConvert.SerializeObject(obj, NostrSerializer.Settings); - _logger.LogDebug("Sending {msg}", rspJson); + _logger.LogTrace("Sending {msg}", rspJson); await _ctx.WebSocket.SendAsync(Encoding.UTF8.GetBytes(rspJson), WebSocketMessageType.Text, true, _cts.Token); } @@ -36,10 +36,11 @@ public class NostrRelay where THandler : INostrRelay if (!await _handler.AcceptConnection(_ctx)) return; var offset = 0; - var mem = MemoryPool.Shared.Rent(1024 * 1024); + var mem = MemoryPool.Shared.Rent(1024 * 1024 * 32); while (!_cts.IsCancellationRequested) { var msg = await _ctx.WebSocket.ReceiveAsync(mem.Memory[offset..], _cts.Token); + if (msg.MessageType is WebSocketMessageType.Text) { var buff = mem.Memory[..(offset + msg.Count)]; @@ -47,49 +48,100 @@ public class NostrRelay where THandler : INostrRelay if (!msg.EndOfMessage) continue; var str = Encoding.UTF8.GetString(buff.Span); - _logger.LogDebug("Got msg {msg}", str); - if (str.StartsWith("[\"REQ\"")) + _logger.LogTrace("Got msg {msg}", str); + try { - var req = JsonConvert.DeserializeObject(str, NostrSerializer.Settings); - if (req != default) + if (str.StartsWith("[\"REQ\"")) { - await foreach (var ev in _handler.HandleRequest(_ctx, req)) + var req = JsonConvert.DeserializeObject(str, NostrSerializer.Settings); + if (req != default) { - await WriteResponse(new NostrEventResponse() + await foreach (var ev in _handler.HandleRequest(_ctx, req)) { - MessageType = "EVENT", - Subscription = req.Subscription, - Event = ev - }); + await WriteResponse(new NostrEventResponse() + { + MessageType = "EVENT", + Subscription = req.Subscription, + Event = ev + }); + } + + var rsp = new NostrEoseResponse + { + MessageType = "EOSE", + Subscription = req.Subscription + }; + + await WriteResponse(rsp); } - - var rsp = new NostrEoseResponse + } + else if (str.StartsWith("[\"EVENT\"")) + { + var req = JsonConvert.DeserializeObject(str, NostrSerializer.Settings); + if (req != default) { - MessageType = "EOSE", - Subscription = req.Subscription - }; + if (!req.Event.IsSignatureValid()) + { + var rsp = new NostrOkResponse() + { + MessageType = "OK", + EventId = req.Event.Id, + Accepted = false, + Message = "invalid: sig check failed" + }; - await WriteResponse(rsp); + await WriteResponse(rsp); + } + else + { + var result = await _handler.HandleEvent(_ctx, req.Event); + var rsp = new NostrOkResponse() + { + MessageType = "OK", + EventId = req.Event.Id, + Accepted = result.Ok, + Message = result.Message ?? "" + }; + + await WriteResponse(rsp); + } + } } } - else if (str.StartsWith("[\"EVENT\"")) + catch (Exception ex) { - var req = JsonConvert.DeserializeObject(str, NostrSerializer.Settings); - if (req != default) - { - var result = await _handler.HandleEvent(_ctx, req.Event); - var rsp = new NostrOkResponse() - { - MessageType = "OK", - EventId = req.Event.Id, - Accepted = result.Ok, - Message = result.Message ?? "" - }; - - await WriteResponse(rsp); - } + _logger.LogWarning("Failed to process msg {error}", ex.Message); + offset = 0; } } } } } + +[JsonConverter(typeof(ArrayConverter))] +public class NostrInboundRequest +{ + public NostrInboundRequest() + { + } + + public NostrInboundRequest(string subscription, NostrFilter nostrFilter) + { + this.Subscription = subscription; + this.NostrFilter = nostrFilter; + } + + public static implicit operator NostrRequest(NostrInboundRequest req) + { + return new(req.Subscription, req.NostrFilter); + } + + [ArrayProperty(0)] + public string Type { get; init; } = "REQ"; + + [ArrayProperty(1)] + public string Subscription { get; init; } + + [ArrayProperty(2)] + public NostrFilter NostrFilter { get; init; } +} diff --git a/NostrRelay/RelayDocument.cs b/NostrRelay/RelayDocument.cs new file mode 100644 index 0000000..e4f38ed --- /dev/null +++ b/NostrRelay/RelayDocument.cs @@ -0,0 +1,103 @@ +using Newtonsoft.Json; + +namespace NostrRelay; + +public class Fees +{ + [JsonProperty("subscription")] + public List Subscription { get; set; } +} + +public class Limitation +{ + [JsonProperty("payment_required")] + public bool? PaymentRequired { get; set; } = false; + + [JsonProperty("max_message_length")] + public int? MaxMessageLength { get; set; } + + /// + /// Maximum number of filter values in each subscription. Must be one or higher. + /// + [JsonProperty("max_filters")] + public int? MaxFilters { get; init; } = 1; + + [JsonProperty("max_event_tags")] + public int? MaxEventTags { get; set; } + + [JsonProperty("max_subscriptions")] + public int? MaxSubscriptions { get; set; } + + [JsonProperty("auth_required")] + public bool? AuthRequired { get; set; } + + [JsonProperty("restricted_writes")] + public bool? RestrictedWrites { get; set; } +} + +public class RelayDocument +{ + [JsonProperty("description")] + public string? Description { get; set; } + + [JsonProperty("name")] + public string? Name { get; set; } + + [JsonProperty("pubkey")] + public string? Pubkey { get; set; } + + [JsonProperty("software")] + public string? Software { get; set; } + + + [JsonProperty("supported_nips")] + public List? SupportedNips { get; set; } + + [JsonProperty("version")] + public string? Version { get; set; } + + /// + /// A URL pointing to an image to be used as an icon for the relay + /// + [JsonProperty("icon")] + public string? Icon { get; init; } + + [JsonProperty("limitation")] + public Limitation? Limitation { get; set; } + + [JsonProperty("payments_url")] + public string? PaymentsUrl { get; set; } + + [JsonProperty("fees")] + public Fees? Fees { get; set; } + + [JsonProperty("relay_countries")] + public List? RelayCountries { get; init; } + + /// + /// Ordered list of IETF language tags indicating the major languages spoken on the relay. + /// + [JsonProperty("language_tags")] + public List? LanguageTags { get; init; } + + [JsonProperty("tags")] + public List? Tags { get; init; } + + /// + /// Link to a human-readable page which specifies the community policies for the relay + /// + [JsonProperty("posting_policy")] + public string? PostingPolicy { get; set; } +} + +public class Subscription +{ + [JsonProperty("amount")] + public long? Amount { get; set; } + + [JsonProperty("unit")] + public string Unit { get; set; } = "msats"; + + [JsonProperty("period")] + public int? Period { get; set; } +} diff --git a/NostrServices.sln b/NostrServices.sln index 48d1596..be0a416 100644 --- a/NostrServices.sln +++ b/NostrServices.sln @@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrRelay", "NostrRelay\NostrRelay.csproj", "{FBCF209E-9C58-45EB-BC59-99569B28D811}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FASTERlay", "FASTERlay\FASTERlay.csproj", "{A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicTests", "BasicTests\BasicTests.csproj", "{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -25,5 +29,13 @@ Global {FBCF209E-9C58-45EB-BC59-99569B28D811}.Debug|Any CPU.Build.0 = Debug|Any CPU {FBCF209E-9C58-45EB-BC59-99569B28D811}.Release|Any CPU.ActiveCfg = Release|Any CPU {FBCF209E-9C58-45EB-BC59-99569B28D811}.Release|Any CPU.Build.0 = Release|Any CPU + {A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9BD2D9D-9BAA-463C-8FC1-027D86A17E91}.Release|Any CPU.Build.0 = Release|Any CPU + {1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal