FASTER relay
This commit is contained in:
24
BasicTests/BasicTests.csproj
Normal file
24
BasicTests/BasicTests.csproj
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
|
||||||
|
<PackageReference Include="NUnit" Version="3.13.3"/>
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
|
||||||
|
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
1
BasicTests/GlobalUsings.cs
Normal file
1
BasicTests/GlobalUsings.cs
Normal file
@ -0,0 +1 @@
|
|||||||
|
global using NUnit.Framework;
|
44
BasicTests/NostrBufTests.cs
Normal file
44
BasicTests/NostrBufTests.cs
Normal file
@ -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<NostrEvent>(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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
FASTERlay/.gitignore
vendored
Normal file
1
FASTERlay/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
data/
|
23
FASTERlay/Dockerfile
Normal file
23
FASTERlay/Dockerfile
Normal file
@ -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"]
|
5
FASTERlay/Extensions.cs
Normal file
5
FASTERlay/Extensions.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace FASTERlay;
|
||||||
|
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
}
|
118
FASTERlay/FASTERRelay.cs
Normal file
118
FASTERlay/FASTERRelay.cs
Normal file
@ -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<byte[], byte[], byte[], byte[], Empty, SimpleFunctions<byte[], byte[]>> _session;
|
||||||
|
private readonly ClientSession<string, byte[], byte[], byte[], Empty, TagSetFunctions> _tagsSession;
|
||||||
|
|
||||||
|
public FasterRelay(NostrStore store)
|
||||||
|
{
|
||||||
|
_session = store.MainStore.For(new SimpleFunctions<byte[], byte[]>()).NewSession<SimpleFunctions<byte[], byte[]>>();
|
||||||
|
_tagsSession = store.TagStore.For(new TagSetFunctions()).NewSession<TagSetFunctions>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<bool> AcceptConnection(NostrClientContext context)
|
||||||
|
{
|
||||||
|
return ValueTask.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<NostrEvent> 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<HandleEventResponse> 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<NostrEvent?> 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<string, byte[]>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
FASTERlay/FASTERlay.csproj
Normal file
24
FASTERlay/FASTERlay.csproj
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.FASTER.Core" Version="2.6.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
58
FASTERlay/NostrStore.cs
Normal file
58
FASTERlay/NostrStore.cs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
using FASTER.core;
|
||||||
|
|
||||||
|
namespace FASTERlay;
|
||||||
|
|
||||||
|
public class NostrStore : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<NostrStore> _logger;
|
||||||
|
private readonly CancellationTokenSource _cts = new();
|
||||||
|
private readonly FasterKVSettings<byte[], byte[]> _storeSettings;
|
||||||
|
private readonly FasterKV<byte[], byte[]> _store;
|
||||||
|
private readonly FasterKVSettings<string, byte[]> _tagStoreSettings;
|
||||||
|
private readonly FasterKV<string, byte[]> _tagStore;
|
||||||
|
|
||||||
|
private readonly Task _checkpointer;
|
||||||
|
|
||||||
|
public NostrStore(string dir, ILogger<NostrStore> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_storeSettings = new FasterKVSettings<byte[], byte[]>(Path.Combine(dir, "main"), false, _logger);
|
||||||
|
_store = new(_storeSettings);
|
||||||
|
var version = _store.GetLatestCheckpointVersion();
|
||||||
|
if (version != -1)
|
||||||
|
{
|
||||||
|
_store.Recover();
|
||||||
|
}
|
||||||
|
|
||||||
|
_tagStoreSettings = new FasterKVSettings<string, byte[]>(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<byte[], byte[]> MainStore => _store;
|
||||||
|
public FasterKV<string, byte[]> TagStore => _tagStore;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_checkpointer.ConfigureAwait(false).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
_store.Dispose();
|
||||||
|
_storeSettings.Dispose();
|
||||||
|
_tagStore.Dispose();
|
||||||
|
_tagStoreSettings.Dispose();
|
||||||
|
}
|
||||||
|
}
|
77
FASTERlay/Program.cs
Normal file
77
FASTERlay/Program.cs
Normal file
@ -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<NostrStore>(svc =>
|
||||||
|
{
|
||||||
|
var logger = svc.GetRequiredService<ILogger<NostrStore>>();
|
||||||
|
return new NostrStore("./data", logger);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddTransient<FasterRelay>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
||||||
|
app.UseWebSockets();
|
||||||
|
app.MapGet("/", async ctx =>
|
||||||
|
{
|
||||||
|
if (ctx.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
var logger = app.Services.GetRequiredService<ILogger<NostrRelay<FasterRelay>>>();
|
||||||
|
using var handler = app.Services.GetRequiredService<FasterRelay>();
|
||||||
|
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<FasterRelay>(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();
|
14
FASTERlay/Properties/launchSettings.json
Normal file
14
FASTERlay/Properties/launchSettings.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
FASTERlay/appsettings.Development.json
Normal file
10
FASTERlay/appsettings.Development.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"NostrRelay": "Debug",
|
||||||
|
"FASTER": "Debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
FASTERlay/appsettings.json
Normal file
9
FASTERlay/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
118
NostrRelay/NostrBuf.cs
Normal file
118
NostrRelay/NostrBuf.cs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Nostr.Client.Messages;
|
||||||
|
|
||||||
|
namespace NostrRelay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple fixed length nostr event storage
|
||||||
|
/// </summary>
|
||||||
|
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<byte> 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<NostrEventTag>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -27,7 +27,7 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||||||
private async Task WriteResponse<T>(T obj)
|
private async Task WriteResponse<T>(T obj)
|
||||||
{
|
{
|
||||||
var rspJson = JsonConvert.SerializeObject(obj, NostrSerializer.Settings);
|
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);
|
await _ctx.WebSocket.SendAsync(Encoding.UTF8.GetBytes(rspJson), WebSocketMessageType.Text, true, _cts.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,10 +36,11 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||||||
if (!await _handler.AcceptConnection(_ctx)) return;
|
if (!await _handler.AcceptConnection(_ctx)) return;
|
||||||
|
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
var mem = MemoryPool<byte>.Shared.Rent(1024 * 1024);
|
var mem = MemoryPool<byte>.Shared.Rent(1024 * 1024 * 32);
|
||||||
while (!_cts.IsCancellationRequested)
|
while (!_cts.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var msg = await _ctx.WebSocket.ReceiveAsync(mem.Memory[offset..], _cts.Token);
|
var msg = await _ctx.WebSocket.ReceiveAsync(mem.Memory[offset..], _cts.Token);
|
||||||
|
|
||||||
if (msg.MessageType is WebSocketMessageType.Text)
|
if (msg.MessageType is WebSocketMessageType.Text)
|
||||||
{
|
{
|
||||||
var buff = mem.Memory[..(offset + msg.Count)];
|
var buff = mem.Memory[..(offset + msg.Count)];
|
||||||
@ -47,49 +48,100 @@ public class NostrRelay<THandler> where THandler : INostrRelay
|
|||||||
if (!msg.EndOfMessage) continue;
|
if (!msg.EndOfMessage) continue;
|
||||||
|
|
||||||
var str = Encoding.UTF8.GetString(buff.Span);
|
var str = Encoding.UTF8.GetString(buff.Span);
|
||||||
_logger.LogDebug("Got msg {msg}", str);
|
_logger.LogTrace("Got msg {msg}", str);
|
||||||
if (str.StartsWith("[\"REQ\""))
|
try
|
||||||
{
|
{
|
||||||
var req = JsonConvert.DeserializeObject<NostrRequest>(str, NostrSerializer.Settings);
|
if (str.StartsWith("[\"REQ\""))
|
||||||
if (req != default)
|
|
||||||
{
|
{
|
||||||
await foreach (var ev in _handler.HandleRequest(_ctx, req))
|
var req = JsonConvert.DeserializeObject<NostrInboundRequest>(str, NostrSerializer.Settings);
|
||||||
|
if (req != default)
|
||||||
{
|
{
|
||||||
await WriteResponse(new NostrEventResponse()
|
await foreach (var ev in _handler.HandleRequest(_ctx, req))
|
||||||
{
|
{
|
||||||
MessageType = "EVENT",
|
await WriteResponse(new NostrEventResponse()
|
||||||
Subscription = req.Subscription,
|
{
|
||||||
Event = ev
|
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<NostrEventRequest>(str, NostrSerializer.Settings);
|
||||||
|
if (req != default)
|
||||||
{
|
{
|
||||||
MessageType = "EOSE",
|
if (!req.Event.IsSignatureValid())
|
||||||
Subscription = req.Subscription
|
{
|
||||||
};
|
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<NostrEventRequest>(str, NostrSerializer.Settings);
|
_logger.LogWarning("Failed to process msg {error}", ex.Message);
|
||||||
if (req != default)
|
offset = 0;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
103
NostrRelay/RelayDocument.cs
Normal file
103
NostrRelay/RelayDocument.cs
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace NostrRelay;
|
||||||
|
|
||||||
|
public class Fees
|
||||||
|
{
|
||||||
|
[JsonProperty("subscription")]
|
||||||
|
public List<Subscription> Subscription { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Limitation
|
||||||
|
{
|
||||||
|
[JsonProperty("payment_required")]
|
||||||
|
public bool? PaymentRequired { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonProperty("max_message_length")]
|
||||||
|
public int? MaxMessageLength { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of filter values in each subscription. Must be one or higher.
|
||||||
|
/// </summary>
|
||||||
|
[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<int>? SupportedNips { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("version")]
|
||||||
|
public string? Version { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A URL pointing to an image to be used as an icon for the relay
|
||||||
|
/// </summary>
|
||||||
|
[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<string>? RelayCountries { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ordered list of IETF language tags indicating the major languages spoken on the relay.
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("language_tags")]
|
||||||
|
public List<string>? LanguageTags { get; init; }
|
||||||
|
|
||||||
|
[JsonProperty("tags")]
|
||||||
|
public List<string>? Tags { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Link to a human-readable page which specifies the community policies for the relay
|
||||||
|
/// </summary>
|
||||||
|
[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; }
|
||||||
|
}
|
@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrRelay", "NostrRelay\NostrRelay.csproj", "{FBCF209E-9C58-45EB-BC59-99569B28D811}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrRelay", "NostrRelay\NostrRelay.csproj", "{FBCF209E-9C58-45EB-BC59-99569B28D811}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{FBCF209E-9C58-45EB-BC59-99569B28D811}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
Reference in New Issue
Block a user