#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using BTCPayServer.Lightning; using BTCPayServer.Payments.Lightning; using NBitcoin; using NBitcoin.Secp256k1; using NNostr.Client; using NNostr.Client.Protocols; using SHA256 = System.Security.Cryptography.SHA256; namespace BTCPayServer.Plugins.NIP05; public class NostrWalletConnectLightningClient : IExtendedLightningClient { [Display] public string DisplayLabel => $"Nostr Wallet Connect {_connectParams.lud16} {_connectParams.relays.First()} "; public string? DisplayName => "Nostr Wallet Connect (NwC)"; public Uri? ServerUri => _serverUri; private readonly NostrClientPool _nostrClientPool; private readonly Uri _uri; private readonly Network _network; private readonly (ECXOnlyPubKey pubkey, ECPrivKey secret, Uri[] relays, string lud16) _connectParams; private readonly Uri? _serverUri; public NostrWalletConnectLightningClient(NostrClientPool nostrClientPool, Uri uri, Network network) { _nostrClientPool = nostrClientPool; _uri = uri; _network = network; _connectParams = NIP47.ParseUri(uri); _serverUri = _connectParams.relays.FirstOrDefault(); } public override string ToString() { return $"type=nwc;key={_uri}"; } public async Task GetInvoice(string invoiceId, CancellationToken cancellation = new()) { return await GetInvoice(uint256.Parse(invoiceId), cancellation); } private static LightningInvoice? ToLightningInvoice(NIP47.Nip47Transaction tx, Network network) { if (tx.Type != "incoming") { return null; } var isPaid = tx.SettledAt.HasValue; var invoice = BOLT11PaymentRequest.Parse(tx.Invoice, network); var expiresAt = tx.ExpiresAt is not null ? DateTimeOffset.FromUnixTimeSeconds(tx.ExpiresAt.Value) : invoice.ExpiryDate; var expired = !isPaid && expiresAt < DateTimeOffset.UtcNow; var s = tx.SettledAt is not null ? DateTimeOffset.FromUnixTimeSeconds(tx.SettledAt.Value) : (DateTimeOffset?)null; return new LightningInvoice() { PaymentHash = tx.PaymentHash, Amount = LightMoney.MilliSatoshis(tx.AmountMsats), Preimage = tx.Preimage, Id = tx.PaymentHash, Status = isPaid ? LightningInvoiceStatus.Paid : expired ? LightningInvoiceStatus.Expired : LightningInvoiceStatus.Unpaid, PaidAt = s, ExpiresAt = expiresAt, AmountReceived = isPaid ? LightMoney.MilliSatoshis(tx.AmountMsats) : LightMoney.Zero, BOLT11 = tx.Invoice }; } public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (nostrClient, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { var tx = await nostrClient.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.LookupInvoiceRequest() { PaymentHash = paymentHash.ToString() }, cts.Token); return ToLightningInvoice(tx, _network)!; } } public async Task ListInvoices(CancellationToken cancellation = new()) { return await ListInvoices(new ListInvoicesParams(), cancellation); } public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = new CancellationToken()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (client, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.ListTransactionsRequest() { Type = "incoming", Offset = (int)(request.OffsetIndex ?? 0), Unpaid = request.PendingOnly ?? false, }, cts.Token); return response.Transactions.Select(transaction => ToLightningInvoice(transaction, _network)) .Where(i => i is not null).ToArray()!; } } private LightningPayment? ToLightningPayment(NIP47.Nip47Transaction tx) { // if (tx.Type != "outgoing") // { // return null; // } var isPaid = tx.SettledAt.HasValue || !string.IsNullOrEmpty(tx.Preimage); var invoice = BOLT11PaymentRequest.Parse(tx.Invoice, _network); var expiresAt = tx.ExpiresAt is not null ? DateTimeOffset.FromUnixTimeSeconds(tx.ExpiresAt.Value) : invoice.ExpiryDate; var created = DateTimeOffset.FromUnixTimeSeconds(tx.CreatedAt); var expired = !isPaid && expiresAt < DateTimeOffset.UtcNow; var s = tx.SettledAt is not null ? DateTimeOffset.FromUnixTimeSeconds(tx.SettledAt.Value) : (DateTimeOffset?)null; return new LightningPayment() { PaymentHash = tx.PaymentHash, Amount = LightMoney.MilliSatoshis(tx.AmountMsats), Preimage = tx.Preimage, Id = tx.PaymentHash, Status = isPaid ? LightningPaymentStatus.Complete : expired ? LightningPaymentStatus.Failed : LightningPaymentStatus.Unknown, BOLT11 = tx.Invoice, Fee = LightMoney.MilliSatoshis(tx.FeesPaidMsats), AmountSent = LightMoney.MilliSatoshis(tx.AmountMsats + tx.FeesPaidMsats), CreatedAt = created }; } public async Task GetPayment(string paymentHash, CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (nostrClient, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { NIP47.Nip47Transaction tx; try { tx = await nostrClient.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.LookupInvoiceRequest() { PaymentHash = paymentHash }, cts.Token); } // The standard says it returns NOT_FOUND error, but // Alby returns INTERNAL error... Probably safer to catch all catch (Exception) { return null; } return ToLightningPayment(tx)!; } } public async Task ListPayments(CancellationToken cancellation = new()) { return await ListPayments(new ListPaymentsParams(), cancellation); } public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (nostrClient, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { var response = await nostrClient.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.ListTransactionsRequest() { Type = "outgoing", Offset = (int)(request.OffsetIndex ?? 0), Unpaid = request.IncludePending ?? false, }, cts.Token); return response.Transactions.Select(ToLightningPayment).Where(i => i is not null).ToArray()!; } } public async Task CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = new()) { return await CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation); } public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (nostrClient, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { var response = await nostrClient.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.MakeInvoiceRequest() { AmountMsats = createInvoiceRequest.Amount.MilliSatoshi, Description = createInvoiceRequest.Description is null || createInvoiceRequest.DescriptionHashOnly ? null : createInvoiceRequest.Description, DescriptionHash = createInvoiceRequest.DescriptionHash?.ToString(), ExpirySeconds = (int)createInvoiceRequest.Expiry.TotalSeconds, }, cts.Token); return ToLightningInvoice(response, _network)!; } } public async Task Listen(CancellationToken cancellation = default) { var x = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cancellation); var commands = await x.Item1.FetchNIP47AvailableCommands(_connectParams.Item1, cancellationToken: cancellation); bool? hasNotification = commands?.Notifications?.Contains("payment_received"); if (hasNotification is false) { var response = await x.Item1.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.GetInfoRequest(), cancellationToken: cancellation); hasNotification = response?.Notifications?.Contains("payment_received"); } return hasNotification is true ? new NotificationListener(_network, x, _connectParams) : new PollListener(_network, x, _connectParams); } public class NotificationListener : ILightningInvoiceListener { private readonly Network _network; private readonly INostrClient _client; private readonly CancellationTokenSource _cts; private readonly IAsyncEnumerable _notifications; private readonly IDisposable _disposable; public NotificationListener(Network network, (INostrClient, IDisposable) client, (ECXOnlyPubKey pubkey, ECPrivKey secret, Uri[] relays, string lud16) x) { _network = network; _client = client.Item1; _disposable = client.Item2; _cts = new CancellationTokenSource(); _notifications = _client.SubscribeNip47Notifications(x.pubkey, x.secret, _cts.Token); } public void Dispose() { _cts.Cancel(); _disposable.Dispose(); } public async Task WaitInvoice(CancellationToken cancellation) { var enumerator = _notifications.GetAsyncEnumerator(cancellation); while (await enumerator.MoveNextAsync(cancellation)) { if (enumerator.Current.NotificationType == "payment_received") { var tx = enumerator.Current.Deserialize(); return ToLightningInvoice(tx, _network)!; } } throw new Exception("No notification received"); } } public class PollListener : ILightningInvoiceListener { private readonly Network _network; private readonly (ECXOnlyPubKey pubkey, ECPrivKey secret, Uri[] relays, string lud16) _connectparams; private readonly INostrClient _client; private readonly CancellationTokenSource _cts; private readonly IAsyncEnumerable _notifications; private readonly IDisposable _disposable; private NIP47.ListTransactionsResponse? _lastPaid; private Channel queue = Channel.CreateUnbounded(); public PollListener(Network network, (INostrClient, IDisposable) client, (ECXOnlyPubKey pubkey, ECPrivKey secret, Uri[] relays, string lud16) connectparams) { _network = network; _connectparams = connectparams; _client = client.Item1; _disposable = client.Item2; _cts = new CancellationTokenSource(); _ = Poll(); } private async Task Poll() { try { while (!_cts.IsCancellationRequested) { var cts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); cts.CancelAfter(TimeSpan.FromSeconds(10)); var paid = await _client.SendNIP47Request(_connectparams.pubkey, _connectparams.secret, new NIP47.ListTransactionsRequest() { Type = "incoming", // Unpaid = true, //seems like this is ignored... so we only get paid ones Limit = 300 }, cancellationToken: cts.Token); paid.Transactions = paid.Transactions.Where(i => i is { Type: "incoming", SettledAt: not null }).ToArray(); if (_lastPaid is not null) { var paidInvoicesSinceLastPoll = paid.Transactions.Where(i => _lastPaid.Transactions.All(j => j.PaymentHash != i.PaymentHash)).Select(i => ToLightningInvoice(i, _network)!); //all invoices which are no longer in the unpaid list are paid // var paidInvoicesSinceLastPoll = _lastPaid.Transactions // .Where(i => paid.Transactions.All(j => j.PaymentHash != i.PaymentHash)) // .Select(i => ToLightningInvoice(i, _network)!); foreach (var invoice in paidInvoicesSinceLastPoll) { await queue.Writer.WriteAsync(invoice, _cts.Token); } } _lastPaid = paid; await Task.Delay(1000, _cts.Token); } } catch (Exception e) { Dispose(); } } public void Dispose() { _cts.Cancel(); _disposable.Dispose(); queue.Writer.Complete(); } public async Task WaitInvoice(CancellationToken cancellation) { return await queue.Reader.ReadAsync(CancellationTokenSource .CreateLinkedTokenSource(_cts.Token, cancellation).Token); } } public async Task GetInfo(CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task GetBalance(CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(5)); var (client, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.NIP47Request("get_balance"), cts.Token); return new LightningNodeBalance() { OffchainBalance = new OffchainBalance() { Local = LightMoney.MilliSatoshis(response.BalanceMsats), } }; } } public async Task Pay(PayInvoiceParams payParams, CancellationToken cancellation = new()) { return await Pay(null, new PayInvoiceParams(), cancellation); } public async Task Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = new()) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (client, usage) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token); using (usage) { NIP47.INIP47Request request; if (bolt11 is null) { request = new NIP47.PayKeysendRequest() { Amount = Convert.ToDecimal(payParams.Amount.MilliSatoshi), Pubkey = payParams.Destination.ToHex(), TlvRecords = payParams.CustomRecords?.Select(kv => new NIP47.TlvRecord() { Type = kv.Key.ToString(), Value = kv.Value }).ToArray() }; } else { request = new NIP47.PayInvoiceRequest() { Invoice = bolt11, Amount = payParams.Amount?.MilliSatoshi is not null ? Convert.ToDecimal(payParams.Amount.MilliSatoshi) : null, }; } var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, request, cts.Token); var payHash = ConvertHelper.ToHexString(SHA256.HashData(Convert.FromHexString(response.Preimage))); try { var tx = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.LookupInvoiceRequest() { PaymentHash = payHash }, cts.Token); var lp = ToLightningPayment(tx)!; return new PayResponse(lp.Status == LightningPaymentStatus.Complete ? PayResult.Ok : PayResult.Error, new PayDetails() { Preimage = lp.Preimage is null ? null : new uint256(lp.Preimage), Status = lp.Status, TotalAmount = lp.AmountSent, PaymentHash = new uint256(lp.PaymentHash), FeeAmount = lp.Fee }); } catch (Exception e) { return new PayResponse(PayResult.Ok, new PayDetails() { Status = LightningPaymentStatus.Complete, PaymentHash = new uint256(payHash), Preimage = new uint256(response.Preimage), }) { ErrorDetail = e.Message }; } } } public async Task Pay(string bolt11, CancellationToken cancellation = new()) { return await Pay(bolt11, new PayInvoiceParams(), cancellation); } public async Task OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task GetDepositAddress(CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task ListChannels(CancellationToken cancellation = new()) { throw new NotSupportedException(); } public async Task Validate() { var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(10)); var (client, disposable) = await _nostrClientPool.GetClientAndConnect(_connectParams.relays, cts.Token).ConfigureAwait(false); using (disposable) { var commands = await client.FetchNIP47AvailableCommands(_connectParams.Item1, cancellationToken: cts.Token); var requiredCommands = new[] { "get_info", "make_invoice", "lookup_invoice", "list_transactions" }; if (commands?.Commands is null || requiredCommands.Any(c => !commands.Value.Commands.Contains(c))) { return new ValidationResult("No commands available or not all required commands are available (get_info, make_invoice, lookup_invoice, list_transactions)"); } var response = await client .SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.GetInfoRequest(), cancellationToken: cts.Token); var walletNetwork = response.Network; if (!_network.ChainName.ToString().Equals(walletNetwork, StringComparison.InvariantCultureIgnoreCase)) { return new ValidationResult($"The network of the wallet ({walletNetwork}) does not match the network of the server ({_network.ChainName})"); } if (response?.Methods is null || requiredCommands.Any(c => !response.Methods.Contains(c))) { return new ValidationResult("No commands available or not all required commands are available (get_info, make_invoice, lookup_invoice, list_transactions)"); } } return ValidationResult.Success; } }