Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.NIP05/NostrWalletConnectLightningClient.cs
2024-05-08 08:35:27 +02:00

469 lines
18 KiB
C#

#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<LightningInvoice> 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<LightningInvoice> GetInvoice(uint256 paymentHash,
CancellationToken cancellation = new CancellationToken())
{
var (nostrClient, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var tx = await nostrClient.SendNIP47Request<NIP47.Nip47Transaction>(_connectParams.pubkey,
_connectParams.secret,
new NIP47.LookupInvoiceRequest()
{
PaymentHash = paymentHash.ToString()
}, cancellation);
return ToLightningInvoice(tx, _network)!;
}
}
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = new CancellationToken())
{
return await ListInvoices(new ListInvoicesParams(), cancellation);
}
public async Task<LightningInvoice[]> ListInvoices(ListInvoicesParams request,
CancellationToken cancellation = new CancellationToken())
{
var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var response = await client.SendNIP47Request<NIP47.ListTransactionsResponse>(_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<LightningPayment> GetPayment(string paymentHash,
CancellationToken cancellation = new CancellationToken())
{
var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var tx = await client.SendNIP47Request<NIP47.Nip47Transaction>(_connectParams.pubkey, _connectParams.secret,
new NIP47.LookupInvoiceRequest()
{
PaymentHash = paymentHash
}, cancellation);
return ToLightningPayment(tx)!;
}
}
public async Task<LightningPayment[]> ListPayments(CancellationToken cancellation = new CancellationToken())
{
return await ListPayments(new ListPaymentsParams(), cancellation);
}
public async Task<LightningPayment[]> ListPayments(ListPaymentsParams request,
CancellationToken cancellation = new CancellationToken())
{
var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var response = await client.SendNIP47Request<NIP47.ListTransactionsResponse>(_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<LightningInvoice> CreateInvoice(LightMoney amount, string description, TimeSpan expiry,
CancellationToken cancellation = new CancellationToken())
{
return await CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation);
}
public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams createInvoiceRequest,
CancellationToken cancellation = new CancellationToken())
{
var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var response = await client.SendNIP47Request<NIP47.Nip47Transaction>(_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<ILightningInvoiceListener> 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<NIP47.Nip47Notification> _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<LightningInvoice> WaitInvoice(CancellationToken cancellation)
{
var enumerator = _notifications.GetAsyncEnumerator(cancellation);
while (await enumerator.MoveNextAsync(cancellation))
{
if (enumerator.Current.NotificationType == "payment_received")
{
var tx = enumerator.Current.Deserialize<NIP47.Nip47Transaction>();
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<NIP47.Nip47Notification> _notifications;
private readonly IDisposable _disposable;
private NIP47.ListTransactionsResponse? _lastPaid;
private Channel<LightningInvoice> queue = Channel.CreateUnbounded<LightningInvoice>();
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<NIP47.ListTransactionsResponse>(_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<LightningInvoice> WaitInvoice(CancellationToken cancellation)
{
return await queue.Reader.ReadAsync(CancellationTokenSource
.CreateLinkedTokenSource(_cts.Token, cancellation).Token);
}
}
public async Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = new CancellationToken())
{
var (client, usage) = await NostrClientPool.GetClientAndConnect(_uri.ToString(), cancellation);
using (usage)
{
var response = await client.SendNIP47Request<NIP47.GetBalanceResponse>(_connectParams.pubkey,
_connectParams.secret,
new NIP47.NIP47Request("get_balance"), cancellation);
return new LightningNodeBalance()
{
OffchainBalance = new OffchainBalance()
{
Local = LightMoney.MilliSatoshis(response.BalanceMsats),
}
};
}
}
public async Task<PayResponse> Pay(PayInvoiceParams payParams,
CancellationToken cancellation = new CancellationToken())
{
return await Pay(null, new PayInvoiceParams(), cancellation);
}
public async Task<PayResponse> 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<NIP47.Nip47Transaction>(_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<PayResponse> Pay(string bolt11, CancellationToken cancellation = new CancellationToken())
{
return await Pay(bolt11, new PayInvoiceParams(), cancellation);
}
public async Task<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest,
CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
public async Task<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
public async Task<ConnectionResult> 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<LightningChannel[]> ListChannels(CancellationToken cancellation = new CancellationToken())
{
throw new NotSupportedException();
}
}