#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using BTCPayServer.Lightning; using NBitcoin; using NBitcoin.Secp256k1; using NNostr.Client; using NNostr.Client.Protocols; namespace BTCPayServer.Plugins.NIP05; public class NostrWalletConnectLightningClient : ILightningClient { private readonly Uri _uri; private readonly Network _network; private readonly (string[] Commands, string[] Notifications) _commands; private readonly (ECXOnlyPubKey pubkey, ECPrivKey secret, Uri[] relays, string lud16) _connectParams; public NostrWalletConnectLightningClient(Uri uri, Network network, (string[] Commands, string[] Notifications) commands) { _uri = uri; _network = network; _commands = commands; _connectParams = NIP47.ParseUri(uri); } public override string ToString() { return $"type=nwc;key={_uri}"; } public async Task GetInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) { 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 CancellationToken()) { var (nostrClient, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var tx = await nostrClient.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.LookupInvoiceRequest() { PaymentHash = paymentHash.ToString() }, cancellation); return ToLightningInvoice(tx, _network)!; } } public async Task ListInvoices(CancellationToken cancellation = new CancellationToken()) { return await ListInvoices(new ListInvoicesParams(), cancellation); } public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = new CancellationToken()) { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); 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, }, cancellation); 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 CancellationToken()) { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var tx = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.LookupInvoiceRequest() { PaymentHash = paymentHash }, cancellation); return ToLightningPayment(tx)!; } } public async Task ListPayments(CancellationToken cancellation = new CancellationToken()) { return await ListPayments(new ListPaymentsParams(), cancellation); } public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = new CancellationToken()) { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.ListTransactionsRequest() { Type = "outgoing", Offset = (int) (request.OffsetIndex ?? 0), Unpaid = request.IncludePending ?? false, }, cancellation); 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 CancellationToken()) { return await CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation); } public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = new CancellationToken()) { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var response = await client.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, }, cancellation); return ToLightningInvoice(response, _network)!; } } public async Task Listen(CancellationToken cancellation = new CancellationToken()) { var x = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); if (_commands.Notifications?.Contains("payment_received") is true) { return new NotificationListener(_network, x, _connectParams); } return 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 CancellationToken()) { throw new NotSupportedException(); } public async Task GetBalance(CancellationToken cancellation = new CancellationToken()) { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, new NIP47.NIP47Request("get_balance"), cancellation); return new LightningNodeBalance() { OffchainBalance = new OffchainBalance() { Local = LightMoney.MilliSatoshis(response.BalanceMsats), } }; } } public async Task Pay(PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken()) { return await Pay(null, new PayInvoiceParams(), cancellation); } public async Task Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken()) { try { var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation); using (usage) { var response = await client.SendNIP47Request(_connectParams.pubkey, _connectParams.secret, bolt11 is null ? 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() } : new NIP47.PayInvoiceRequest() { Invoice = bolt11, Amount = payParams.Amount?.MilliSatoshi is not null ? Convert.ToDecimal(payParams.Amount.MilliSatoshi) : null, }, cancellation); var lp = ToLightningPayment(response); 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() { Result = PayResult.Error, ErrorDetail = e.Message }; } } public async Task Pay(string bolt11, CancellationToken cancellation = new CancellationToken()) { return await Pay(bolt11, new PayInvoiceParams(), cancellation); } public async Task OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } public async Task GetDepositAddress(CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } public async Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } public async Task ListChannels(CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } }