feat: zap2boost
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Kieran 2024-01-13 20:07:27 +00:00
parent fb3559eb84
commit d1cb619316
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 630 additions and 35 deletions

4
.gitignore vendored
View File

@ -2,4 +2,6 @@ bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
/_ReSharper.Caches/
appsettings.json
data/

View File

@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/projectSettingsUpdater.xml
/.idea.NostrServices.iml
/contentModel.xml
/modules.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,35 @@
using Newtonsoft.Json;
namespace NostrServices.Client;
public class NostrServicesClient
{
private readonly HttpClient _client;
public NostrServicesClient(HttpClient client)
{
_client = client;
_client.BaseAddress = new Uri("https://nostr.api.v0l.io");
}
public async Task<NostrProfile?> Profile(string id)
{
var rsp = await _client.GetAsync($"/api/v1/export/profile/{id}");
if (rsp.IsSuccessStatusCode)
{
var json = await rsp.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<NostrProfile>(json);
}
return default;
}
public class NostrProfile
{
[JsonProperty("name")]
public string? Name { get; init; }
[JsonProperty("lud16")]
public string? LightningAddress { get; init; }
}
}

View File

@ -15,6 +15,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FASTERlay", "FASTERlay\FAST
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicTests", "BasicTests\BasicTests.csproj", "{1F9E8BD2-38B6-4FA4-9FAA-7376759C2CBB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PayForReactions", "PayForReactions\PayForReactions.csproj", "{18CFBD86-576C-4185-82E4-A04866E2ED22}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrServices.Client", "NostrServices.Client\NostrServices.Client.csproj", "{17CA7036-95BC-4851-BAB1-419996EA2761}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -37,5 +41,13 @@ Global
{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
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Debug|Any CPU.Build.0 = Debug|Any CPU
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{18CFBD86-576C-4185-82E4-A04866E2ED22}.Release|Any CPU.Build.0 = Release|Any CPU
{17CA7036-95BC-4851-BAB1-419996EA2761}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17CA7036-95BC-4851-BAB1-419996EA2761}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17CA7036-95BC-4851-BAB1-419996EA2761}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17CA7036-95BC-4851-BAB1-419996EA2761}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -59,9 +59,8 @@ public class OpenGraphController : Controller
}
}
if ((NostrIdentifierParser.TryParse(id, out var nid) && nid != default) ||
(nid = NostrBareIdentifier.Parse(id)) != default ||
(nid = await TryParseNip05(id, cts)) != default)
var nid = await Extensions.TryParseIdentifier(id);
if (nid != default)
{
try
{
@ -221,37 +220,6 @@ public class OpenGraphController : Controller
}
record HeadElement(string Element, List<KeyValuePair<string, string>> Attributes);
private async Task<NostrIdentifier?> TryParseNip05(string id, CancellationToken cts)
{
try
{
if (id.Contains("@"))
{
var idSplit = id.Split("@");
var url = new Uri($"https://{idSplit[1]}/.well-known/nostr.json?name={Uri.EscapeDataString(idSplit[0])}");
var json = await _httpClient.GetStringAsync(url, cts);
var parsed = JsonConvert.DeserializeObject<NostrJson>(json);
var match = parsed?.Names?.FirstOrDefault(a => a.Key.Equals(idSplit[0], StringComparison.CurrentCultureIgnoreCase));
if (match.HasValue && !string.IsNullOrEmpty(match.Value.Value))
{
return new NostrProfileIdentifier(match.Value.Value.ToLower(), null);
}
}
}
catch (Exception ex)
{
_logger.LogWarning("Failed to parse nostr address {id} {msg}", id, ex.Message);
}
return default;
}
class NostrJson
{
[JsonProperty("names")]
public Dictionary<string, string>? Names { get; init; } = new();
}
}
[ProtoContract]

View File

@ -0,0 +1,45 @@
using System.Net.Http.Headers;
using Newtonsoft.Json;
namespace PayForReactions;
public class AlbyApi
{
private readonly HttpClient _client;
public AlbyApi(HttpClient client, Config config)
{
_client = client;
_client.BaseAddress = new("https://api.getalby.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(config.Alby.Type, config.Alby.Token);
}
public async Task<long> GetBalance()
{
var json = await _client.GetStringAsync("/balance");
var obj = JsonConvert.DeserializeObject<BalanceResponse>(json);
return (long?)obj?.Balance ?? 0L;
}
public async Task<bool> PayInvoice(string pr)
{
var rsp = await _client.PostAsync("/payments/bolt11", new StringContent(JsonConvert.SerializeObject(new
{
invoice = pr
}), new MediaTypeHeaderValue("application/json")));
return rsp.IsSuccessStatusCode;
}
class BalanceResponse
{
[JsonProperty("balance")]
public decimal Balance { get; init; }
[JsonProperty("currency")]
public string Currency { get; init; } = "BTC";
[JsonProperty("unit")]
public string Unit { get; init; } = "sat";
}
}

17
PayForReactions/Config.cs Normal file
View File

@ -0,0 +1,17 @@
namespace PayForReactions;
public class Config
{
public OAuthToken Alby { get; init; } = null!;
public string PrivateKey { get; init; } = null!;
public string Target { get; init; } = null!;
}
public class OAuthToken
{
public string Token { get; init; } = null!;
public string Type { get; init; } = "Bearer";
public string RefreshToken { get; init; } = null!;
}

View File

@ -0,0 +1,21 @@
FROM mcr.microsoft.com/dotnet/runtime:8.0 AS base
USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["PayForReactions/PayForReactions.csproj", "PayForReactions/"]
RUN dotnet restore "PayForReactions/PayForReactions.csproj"
COPY . .
WORKDIR "/src/PayForReactions"
RUN dotnet build "PayForReactions.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "PayForReactions.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PayForReactions.dll"]

187
PayForReactions/LnUrl.cs Normal file
View File

@ -0,0 +1,187 @@
using System.Text;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Nostr.Client.Json;
using Nostr.Client.Messages;
using Nostr.Client.Utils;
public enum LNURLErrorCode
{
ServiceUnavailable = 1,
InvalidLNURL = 2,
}
public class LNURLError : Exception
{
public LNURLErrorCode Code { get; private set; }
public LNURLError(LNURLErrorCode code, string message) : base(message)
{
Code = code;
}
}
public class Lnurl
{
private readonly HttpClient _client;
public Lnurl(HttpClient client)
{
_client = client;
}
public static Uri? ParseLnUrl(string lnurl)
{
var emailRegex =
new Regex(
"^(([^<>()\\[\\]\\\\.,;:\\s@\"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@\"]+)*)|(\".+\"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$");
lnurl = lnurl.ToLower().Trim();
if (lnurl.StartsWith("lnurlp:"))
{
return new Uri(lnurl.Replace("lnurlp://", "https://"));
}
if (lnurl.StartsWith("lnurl"))
{
Bech32.Decode(lnurl, out var hrp, out var decoded);
var decodedString = Encoding.UTF8.GetString(decoded!);
if (!decodedString.StartsWith("http"))
{
return default;
}
return new Uri(decodedString);
}
if (emailRegex.IsMatch(lnurl))
{
var parts = lnurl.Split('@');
return new Uri($"https://{parts[1]}/.well-known/lnurlp/{parts[0]}");
}
if (lnurl.StartsWith("https:"))
{
return new Uri(lnurl);
}
return default;
}
public async Task<LNURLService?> LoadAsync(string lnurl)
{
var url = ParseLnUrl(lnurl);
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<LNURLService>(json)!;
}
return default;
}
public async Task<LNURLInvoice> GetInvoiceAsync(LNURLService service, int amount, string? comment = null, NostrEvent? zap = null)
{
if (service == null || string.IsNullOrWhiteSpace(service.Callback))
{
throw new LNURLError(LNURLErrorCode.InvalidLNURL, "No callback url");
}
var query = new Dictionary<string, string>
{
["amount"] = (amount * 1000).ToString()
};
if (!string.IsNullOrWhiteSpace(comment) && service.CommentAllowed.HasValue)
{
query["comment"] = comment;
}
if (!string.IsNullOrWhiteSpace(service.NostrPubkey) && zap != null)
{
query["nostr"] = JsonConvert.SerializeObject(zap, NostrSerializer.Settings);
}
var builder = new UriBuilder(service.Callback)
{
Query = string.Join('&', query.Select(a => $"{a.Key}={Uri.EscapeDataString(a.Value)}"))
};
try
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(builder.Uri);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var data = JsonConvert.DeserializeObject<LNURLInvoice>(json);
if (data?.Status == "ERROR")
{
throw new Exception(data.Reason);
}
return data!;
}
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, $"Failed to fetch invoice ({response.ReasonPhrase})");
}
catch (Exception e)
{
throw new LNURLError(LNURLErrorCode.ServiceUnavailable, "Failed to load callback");
}
}
}
public class LNURLService
{
[JsonProperty("tag")]
public string? Tag { get; init; }
[JsonProperty("nostrPubkey")]
public string? NostrPubkey { get; init; }
[JsonProperty("minSendable")]
public int? MinSendable { get; init; }
[JsonProperty("maxSnedable")]
public int? MaxSendable { get; init; }
[JsonProperty("metadata")]
public string Metadata { get; init; } = null!;
[JsonProperty("callback")]
public string Callback { get; init; } = null!;
[JsonProperty("commentAllowed")]
public int? CommentAllowed { get; init; }
}
public class LNURLStatus
{
[JsonProperty("status")]
public string Status { get; set; } = null!;
[JsonProperty("reason")]
public string? Reason { get; set; }
}
public class LNURLInvoice : LNURLStatus
{
[JsonProperty("pr")]
public string Pr { get; set; } = null!;
[JsonProperty("successAction")]
public LNURLSuccessAction? SuccessAction { get; set; }
}
public class LNURLSuccessAction
{
[JsonProperty("description")]
public string? Description { get; set; }
[JsonProperty("url")]
public string? Url { get; set; }
}

View File

@ -0,0 +1,44 @@
using FASTER.core;
namespace PayForReactions;
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 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();
}
_checkPointer = Task.Factory.StartNew(async () =>
{
while (!_cts.IsCancellationRequested)
{
await Task.Delay(10_000);
await _store.TakeHybridLogCheckpointAsync(CheckpointType.Snapshot, true);
}
});
}
public FasterKV<byte[], byte[]> MainStore => _store;
public void Dispose()
{
_cts.Cancel();
_checkPointer.ConfigureAwait(false).GetAwaiter().GetResult();
_store.Dispose();
_storeSettings.Dispose();
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.FASTER.Core" Version="2.6.1" />
<PackageReference Include="Nostr.Client" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\NostrRelay\NostrRelay.csproj" />
<ProjectReference Include="..\NostrServices.Client\NostrServices.Client.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,38 @@
using NostrRelay;
using NostrServices.Client;
using PayForReactions;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration.GetSection("Config").Get<Config>()!;
builder.Services.AddSingleton(config);
builder.Services.AddHttpClient();
builder.Services.AddTransient<Lnurl>();
builder.Services.AddTransient<AlbyApi>();
builder.Services.AddTransient<ZapperRelay>();
builder.Services.AddTransient<NostrServicesClient>();
builder.Services.AddSingleton<NostrStore>(svc =>
{
var logger = svc.GetRequiredService<ILogger<NostrStore>>();
return new NostrStore("./data", logger);
});
var host = builder.Build();
host.UseWebSockets();
host.MapRelay<ZapperRelay>("/", new RelayDocument()
{
Name = "wss://reactions.v0l.io",
Description = "Zapping server for Kieran's paid reactions",
SupportedNips = [1],
Software = "PayForReactions (https://git.v0l.io/Kieran/NostrServices)",
Limitation = new()
{
RestrictedWrites = true,
AuthRequired = false,
PaymentRequired = false
}
});
host.Run();

View File

@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"PayForReactions": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,155 @@
using FASTER.core;
using Nostr.Client.Keys;
using Nostr.Client.Messages;
using Nostr.Client.Requests;
using NostrRelay;
using NostrServices.Client;
using EventSession =
FASTER.core.ClientSession<byte[], byte[], byte[], byte[], FASTER.core.Empty, FASTER.core.SimpleFunctions<byte[], byte[]>>;
namespace PayForReactions;
public class ZapperRelay : INostrRelay, IDisposable
{
private readonly Config _config;
private readonly Lnurl _lnurl;
private readonly AlbyApi _albyApi;
private readonly NostrServicesClient _nostrServices;
private readonly EventSession _session;
public ZapperRelay(Lnurl lnurl, AlbyApi albyApi, NostrServicesClient nostrServices, Config config, NostrStore store)
{
_lnurl = lnurl;
_albyApi = albyApi;
_nostrServices = nostrServices;
_config = config;
_session = store.MainStore.For(new SimpleFunctions<byte[], byte[]>()).NewSession<SimpleFunctions<byte[], byte[]>>();
}
public ValueTask<bool> AcceptConnection(NostrClientContext context)
{
return ValueTask.FromResult(true);
}
public async IAsyncEnumerable<NostrEvent> HandleRequest(NostrClientContext context, NostrRequest req)
{
yield break;
}
public async ValueTask<HandleEventResponse> HandleEvent(NostrClientContext context, NostrEvent ev)
{
var target = NostrPublicKey.FromBech32(_config.Target).Hex;
if (ev.Pubkey == target)
{
var id = Convert.FromHexString(ev.Id!);
var obj = NostrBuf.Encode(ev);
(await _session.UpsertAsync(ref id, ref obj)).Complete();
return new(true, "");
}
var amount = MapKindToAmount(ev.Kind);
if (amount != default)
{
var eTag = ev.Tags?.FirstOrDefault(a => a.TagIdentifier == "e");
var pTag = ev.Tags?.FirstOrDefault(a => a.TagIdentifier == "p" && a.AdditionalData[0] == target);
if (pTag == default || eTag == default)
{
return new(false, $"blocked: must be a reaction to {target}");
}
var idBytes = Convert.FromHexString(eTag.AdditionalData[0]);
var refEventResult = (await _session.ReadAsync(ref idBytes)).Complete();
if (refEventResult.status.NotFound)
{
return new(false, "blocked: parent event not found");
}
var seenId = Convert.FromHexString(ev.Id!);
var seenIdResult = (await _session.ReadAsync(ref seenId)).Complete();
if (seenIdResult.status.Found)
{
return new(false, "blocked: already zapped this one, how dare you!");
}
var refEvent = NostrBuf.Decode(refEventResult.output);
if (refEvent == default)
{
return new(false, "blocked: parent event not found");
}
if (refEvent.Pubkey != target)
{
return new(false, $"blocked: parent event not posted by {target}");
}
var sender = ev.Pubkey!;
var senderProfile = await _nostrServices.Profile(NostrPublicKey.FromHex(sender).Bech32);
if (senderProfile == default)
{
return new(false, "blocked: couldn't find your profile anon!");
}
var parsedTarget = Lnurl.ParseLnUrl(senderProfile.LightningAddress ?? "");
if (parsedTarget == default)
{
return new(false,
$"blocked: so sad... couldn't send a zap because you don't have a lightning address in your profile {senderProfile.Name}!");
}
var svc = await _lnurl.LoadAsync(parsedTarget.ToString());
if (svc == default)
{
return new(false, "blocked: wallet is down, no zap for you!");
}
var key = NostrPrivateKey.FromBech32(_config.PrivateKey);
var myPubkey = key.DerivePublicKey().Hex;
var zap = new NostrEvent
{
Kind = NostrKind.ZapRequest,
Content = "Thanks for your interaction!",
CreatedAt = DateTime.UtcNow,
Pubkey = myPubkey,
Tags = new NostrEventTags(
new NostrEventTag("e", ev.Id!),
new NostrEventTag("p", sender),
new NostrEventTag("amount", (amount * 1000).ToString()!)
)
};
var zapSigned = zap.Sign(key);
var invoice = await _lnurl.GetInvoiceAsync(svc, 5, "Thanks for your interaction!", zapSigned);
if (string.IsNullOrEmpty(invoice.Pr))
{
return new(false, $"blocked: failed to get invoice from {parsedTarget}");
}
if (!await _albyApi.PayInvoice(invoice.Pr))
{
return new(false, "blocked: failed to pay invoice!");
}
var seenEvent = NostrBuf.Encode(ev);
(await _session.UpsertAsync(ref seenId, ref seenEvent)).Complete();
return new(true, "Zapped!");
}
return new(false, "blocked: kind not accepted, no zap for you!");
}
public void Dispose()
{
}
private int? MapKindToAmount(NostrKind k)
{
switch (k)
{
case NostrKind.Reaction: return 5;
case NostrKind.GenericRepost: return 10;
}
return default;
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}