diff --git a/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj b/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj index c017fa9..de50433 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj +++ b/Plugins/BTCPayServer.Plugins.NIP05/BTCPayServer.Plugins.NIP05.csproj @@ -9,9 +9,9 @@ - Nostr NIP5 - Allows you to verify your nostr account - 1.0.3 + Nostr + Allows you to verify your nostr account with NIP5 and zap like the rest of the crazies + 1.0.4 diff --git a/Plugins/BTCPayServer.Plugins.NIP05/Nip05Plugin.cs b/Plugins/BTCPayServer.Plugins.NIP05/Nip05Plugin.cs index 6a7f578..3da5d2e 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/Nip05Plugin.cs +++ b/Plugins/BTCPayServer.Plugins.NIP05/Nip05Plugin.cs @@ -1,7 +1,21 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Services; +using BTCPayServer.Events; +using BTCPayServer.Payments; +using BTCPayServer.Services.Invoices; +using LNURL; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Newtonsoft.Json; +using NNostr.Client; +using JsonSerializer = System.Text.Json.JsonSerializer; namespace BTCPayServer.Plugins.NIP05 { @@ -9,14 +23,241 @@ namespace BTCPayServer.Plugins.NIP05 { public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = { - new() { Identifier = nameof(BTCPayServer), Condition = ">=1.7.7" } + new() {Identifier = nameof(BTCPayServer), Condition = ">=1.7.7"} }; + public override void Execute(IServiceCollection applicationBuilder) { - applicationBuilder.AddSingleton(new UIExtension("Nip05Nav", "store-integrations-nav")); + applicationBuilder.AddSingleton(); + applicationBuilder.AddSingleton(); + applicationBuilder.AddHostedService(); base.Execute(applicationBuilder); } } -} + + public class Zapper : IHostedService + { + private readonly EventAggregator _eventAggregator; + private readonly Nip5Controller _nip5Controller; + private IEventAggregatorSubscription _subscription; + + public Zapper(EventAggregator eventAggregator, Nip5Controller nip5Controller) + { + _eventAggregator = eventAggregator; + _nip5Controller = nip5Controller; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _subscription = _eventAggregator.SubscribeAsync(Subscription); + return Task.CompletedTask; + } + + private async Task Subscription(InvoiceEvent arg) + { + if (arg.EventCode != InvoiceEventCode.Completed) + return; + var pm = arg.Invoice.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.LNURLPay)); + if (pm is null) + { + return; + } + + var zapRequest = arg.Invoice.Metadata.GetAdditionalData("zapRequest"); + if (zapRequest is null) + { + return; + } + var pmd = (LNURLPayPaymentMethodDetails) pm.GetPaymentMethodDetails(); + var name = pmd.ConsumedLightningAddress.Split("@")[0]; + var settings = await _nip5Controller.Get(name); + if (settings.storeId != arg.Invoice.StoreId) + { + return; + } + + if (string.IsNullOrEmpty(settings.settings.PrivateKey)) + { + return; + } + + var key = NostrExtensions.ParseKey(settings.settings.PrivateKey); + + var zapRequestEvent = JsonSerializer.Deserialize(zapRequest); + var relays = zapRequestEvent.GetTaggedData("relays"); + + var tags = zapRequestEvent.Tags.Where(a => a.TagIdentifier.Length == 1).ToList(); + tags.Add(new() + { + TagIdentifier = "bolt11", + Data = new() {pmd.BOLT11} + }); + + tags.Add(new() + { + TagIdentifier = "description", + Data = new() {zapRequest} + }); + + var zapReceipt = new NostrEvent() + { + Kind = 9735, + CreatedAt = DateTimeOffset.UtcNow, + PublicKey = settings.settings.PubKey, + Content = zapRequestEvent.Content, + Tags = tags + }; + + + await zapReceipt.ComputeIdAndSignAsync(key); + + + await Task.WhenAll(relays.Concat(settings.settings.Relays?? Array.Empty()).Distinct().Select(async relay => + { + try + { + + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(30)); + var tcs = new TaskCompletionSource(); + using var c = new NostrClient(new Uri(relay)); + await c.ConnectAndWaitUntilConnected(cts.Token); + + c.OkReceived += (sender, okargs) => + { + if(okargs.eventId == zapReceipt.Id) + tcs.SetResult(); + }; + await c.PublishEvent(zapReceipt, cts.Token); + await tcs.Task.WaitAsync(cts.Token); + await c.Disconnect(); + } + catch (Exception e) + { + } + })); + + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _subscription?.Dispose(); + return Task.CompletedTask; + } + } + + public class LnurlFilter : PluginHookFilter + { + private readonly Nip5Controller _nip5Controller; + private readonly LightningAddressService _lightningAddressService; + public override string Hook => "lnurlp"; + + public LnurlFilter(Nip5Controller nip5Controller, LightningAddressService lightningAddressService) + { + _nip5Controller = nip5Controller; + _lightningAddressService = lightningAddressService; + } + + public override async Task Execute(LNURLPayRequest arg) + { + var name = arg.ParsedMetadata.FirstOrDefault(pair => pair.Key == "text/identifier").Value + ?.ToLowerInvariant().Split("@")[0]; + if (string.IsNullOrEmpty(name)) + { + return arg; + } + + var lnAddress = await _lightningAddressService.ResolveByAddress(name); + if (lnAddress is null) + { + return arg; + } + + var nip5 = await _nip5Controller.Get(name); + if (nip5.storeId != lnAddress.StoreDataId) + { + return arg; + } + + arg.NostrPubkey = nip5.settings.PubKey; + arg.AllowsNostr = true; + return arg; + } + } + + public class LnurlDescriptionFilter : PluginHookFilter + { + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly Nip5Controller _nip5Controller; + private readonly LightningAddressService _lightningAddressService; + private readonly InvoiceRepository _invoiceRepository; + + public LnurlDescriptionFilter(IHttpContextAccessor httpContextAccessor, + Nip5Controller nip5Controller, LightningAddressService lightningAddressService, + InvoiceRepository invoiceRepository) + { + _httpContextAccessor = httpContextAccessor; + _nip5Controller = nip5Controller; + _lightningAddressService = lightningAddressService; + _invoiceRepository = invoiceRepository; + } + + public override string Hook => "lnurlp-description"; + + public override async Task Execute(string arg) + { + try + { + if (_httpContextAccessor.HttpContext.Request.Query.TryGetValue("nostr", out var nostr) && + _httpContextAccessor.HttpContext.Request.RouteValues.TryGetValue("invoiceId", out var invoiceId)) + { + var metadata = JsonConvert.DeserializeObject(arg); + var username = metadata + .FirstOrDefault(strings => strings.FirstOrDefault()?.Equals("text/identifier") is true) + ?.FirstOrDefault()?.ToLowerInvariant().Split("@")[0]; + if (string.IsNullOrEmpty(username)) + { + return arg; + } + + var lnAddress = await _lightningAddressService.ResolveByAddress(username); + + + var user = await _nip5Controller.Get(username); + if (user.storeId is not null) + { + if (user.storeId != lnAddress.StoreDataId) + { + return arg; + } + + var parsedNote = System.Text.Json.JsonSerializer.Deserialize(nostr); + if (parsedNote?.Kind != 9734) + { + throw new InvalidOperationException("Invalid zap note, kind must be 9734"); + } + + if (!parsedNote.Verify()) + { + throw new InvalidOperationException("Zap note sig check failed"); + } + + var invoice = await _invoiceRepository.GetInvoice(invoiceId.ToString()); + + invoice.Metadata.SetAdditionalData("zapRequest", nostr); + await _invoiceRepository.UpdateInvoiceMetadata(invoiceId.ToString(), invoice.StoreId, + invoice.Metadata.ToJObject()); + return nostr; + } + } + } + catch (Exception e) + { + } + + return arg; + } + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.NIP05/Nip5Controller.cs b/Plugins/BTCPayServer.Plugins.NIP05/Nip5Controller.cs index ef95760..caabe4c 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/Nip5Controller.cs +++ b/Plugins/BTCPayServer.Plugins.NIP05/Nip5Controller.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; +using NBitcoin.Secp256k1; using NNostr.Client; using NNostr.Client.Protocols; @@ -85,6 +86,38 @@ public class Nip5Controller : Controller ModelState.AddModelError(nameof(settings.PubKey), "invalid public key"); } + if (!string.IsNullOrEmpty(settings.PrivateKey)) + { + try + { + ECPrivKey k; + try + { +k = settings.PrivateKey.FromNIP19Nsec(); + } + catch (Exception e) + { + + k = NostrExtensions.ParseKey(settings.PrivateKey); + } + + if (string.IsNullOrEmpty(settings.PubKey)) + { + + settings.PubKey = k.CreateXOnlyPubKey().ToHex(); + ModelState.Remove(nameof(settings.PubKey)); + } + else if(settings.PubKey != k.CreateXOnlyPubKey().ToHex()) + ModelState.AddModelError(nameof(settings.PrivateKey), "private key does not match public key provided. Clear the public key to generate it from the private key."); + } + catch (Exception e) + { + + ModelState.AddModelError(nameof(settings.PubKey), "invalid private key"); + } + } + + if (!ModelState.IsValid) { return View(settings); @@ -109,8 +142,8 @@ public class Nip5Controller : Controller await _storeRepository.UpdateSetting(storeId, "NIP05", settings); return RedirectToAction("Edit", new {storeId}); } - - private async Task<(string? storeId, Nip5StoreSettings? settings)> Get(string name) +[NonAction] + public async Task<(string? storeId, Nip5StoreSettings? settings)> Get(string name) { var rex = await _memoryCache.GetOrCreateAsync<(string? storeId, Nip5StoreSettings? settings)>( $"NIP05_{name.ToLowerInvariant()}", diff --git a/Plugins/BTCPayServer.Plugins.NIP05/Nip5StoreSettings.cs b/Plugins/BTCPayServer.Plugins.NIP05/Nip5StoreSettings.cs index d8d7c61..58130ce 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/Nip5StoreSettings.cs +++ b/Plugins/BTCPayServer.Plugins.NIP05/Nip5StoreSettings.cs @@ -8,6 +8,7 @@ namespace BTCPayServer.Plugins.NIP05 public class Nip5StoreSettings { [Required] public string PubKey { get; set; } + public string PrivateKey { get; set; } [Required] public string Name { get; set; } public string[]? Relays { get; set; } diff --git a/Plugins/BTCPayServer.Plugins.NIP05/Views/Nip5/Edit.cshtml b/Plugins/BTCPayServer.Plugins.NIP05/Views/Nip5/Edit.cshtml index 8cec5cf..dca6d5b 100644 --- a/Plugins/BTCPayServer.Plugins.NIP05/Views/Nip5/Edit.cshtml +++ b/Plugins/BTCPayServer.Plugins.NIP05/Views/Nip5/Edit.cshtml @@ -2,7 +2,7 @@ @using Microsoft.AspNetCore.Mvc.TagHelpers @model Nip5StoreSettings @{ - ViewData.SetActivePage("Nostr NIP05", "Nostr NIP05", "Nostr NIP05"); + ViewData.SetActivePage("Nostr", "Nostr", "Nostr"); } @@ -23,6 +23,12 @@ +
+ + + +

You'll also need to enable a lightning address with the same name.

+
diff --git a/submodules/btcpayserver b/submodules/btcpayserver index 9d72b97..6388057 160000 --- a/submodules/btcpayserver +++ b/submodules/btcpayserver @@ -1 +1 @@ -Subproject commit 9d72b9779e9c1addaf7e101a2809619716d38f75 +Subproject commit 6388057806d8a04e90230645e07a6120ed4e17ff