using System; using System.Collections.Concurrent; using System.Linq; using System.Threading; using System.Threading.Tasks; using Breez.Sdk; using NBitcoin; using Network = Breez.Sdk.Network; namespace BTCPayServer.Lightning.Breez; public class BreezLightningClient: ILightningClient, IDisposable, EventListener { private readonly NBitcoin.Network _network; public BreezLightningClient(string inviteCode, string apiKey, string workingDir, NBitcoin.Network network, string mnemonic) { _network = network; var nodeConfig = new NodeConfig.Greenlight( new GreenlightNodeConfig(null, inviteCode) ); var config = BreezSdkMethods.DefaultConfig( EnvironmentType.PRODUCTION, apiKey, nodeConfig ) with { workingDir= workingDir, network = network == NBitcoin.Network.Main ? Network.BITCOIN : network == NBitcoin.Network.TestNet ? Network.TESTNET: network == NBitcoin.Network.RegTest? Network.REGTEST: Network.SIGNET }; var seed = BreezSdkMethods.MnemonicToSeed(mnemonic); Sdk = BreezSdkMethods.Connect(config, seed, this); } public BlockingBreezServices Sdk { get; } public event EventHandler EventReceived; public void OnEvent(BreezEvent e) { EventReceived?.Invoke(this, e); } public Task GetInvoice(string invoiceId, CancellationToken cancellation = default) { return GetInvoice(uint256.Parse(invoiceId), cancellation); } private LightningPayment ToLightningPayment(Payment payment) { if (payment?.details is not PaymentDetails.Ln lnPaymentDetails) { return null; } return new LightningPayment() { Amount = LightMoney.MilliSatoshis(payment.amountMsat), Id = lnPaymentDetails.data.paymentHash, Preimage = lnPaymentDetails.data.paymentPreimage, PaymentHash = lnPaymentDetails.data.paymentHash, BOLT11 = lnPaymentDetails.data.bolt11, Status = payment.status switch { PaymentStatus.FAILED => LightningPaymentStatus.Failed, PaymentStatus.COMPLETE => LightningPaymentStatus.Complete, PaymentStatus.PENDING => LightningPaymentStatus.Pending, _ => throw new ArgumentOutOfRangeException() }, CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(payment.paymentTime), Fee = LightMoney.MilliSatoshis(payment.feeMsat), AmountSent = LightMoney.MilliSatoshis(payment.amountMsat) }; } private LightningInvoice FromPayment(Payment p) { if (p?.details is not PaymentDetails.Ln lnPaymentDetails) { return null; } var bolt11 = BOLT11PaymentRequest.Parse(lnPaymentDetails.data.bolt11, _network); return new LightningInvoice() { Amount = LightMoney.MilliSatoshis(p.amountMsat), Id = lnPaymentDetails.data.paymentHash, Preimage = lnPaymentDetails.data.paymentPreimage, PaymentHash = lnPaymentDetails.data.paymentHash, BOLT11 = lnPaymentDetails.data.bolt11, Status = p.status switch { PaymentStatus.FAILED => LightningInvoiceStatus.Expired, PaymentStatus.COMPLETE => LightningInvoiceStatus.Paid, _ => LightningInvoiceStatus.Unpaid }, PaidAt = DateTimeOffset.FromUnixTimeMilliseconds(p.paymentTime), ExpiresAt = bolt11.ExpiryDate }; } public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) { var p = Sdk.PaymentByHash(paymentHash.ToString()!); return FromPayment(p); } public async Task ListInvoices(CancellationToken cancellation = default) { return await ListInvoices(null, cancellation); } public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default) { return Sdk.ListPayments(new ListPaymentsRequest(PaymentTypeFilter.RECEIVED, null, null, request?.PendingOnly is not true, (uint?) request?.OffsetIndex, null)) .Select(FromPayment).ToArray(); } public async Task GetPayment(string paymentHash, CancellationToken cancellation = default) { return ToLightningPayment(Sdk.PaymentByHash(paymentHash)); } public async Task ListPayments(CancellationToken cancellation = default) { return await ListPayments(null, cancellation); } public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = default) { return Sdk.ListPayments(new ListPaymentsRequest(PaymentTypeFilter.RECEIVED, null, null, null, (uint?) request?.OffsetIndex, null)) .Select(ToLightningPayment).ToArray(); } public async Task CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = default) { var expiryS =expiry == TimeSpan.Zero? (uint?) null: Math.Max(0, (uint)expiry.TotalSeconds); var p = Sdk.ReceivePayment(new ReceivePaymentRequest((ulong)amount.MilliSatoshi, description, null, null, false,expiryS )); return await GetInvoice(p.lnInvoice.paymentHash, cancellation); } public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = default) { var expiryS =createInvoiceRequest.Expiry == TimeSpan.Zero? (uint?) null: Math.Max(0, (uint)createInvoiceRequest.Expiry.TotalSeconds); var p = Sdk.ReceivePayment(new ReceivePaymentRequest((ulong)createInvoiceRequest.Amount.MilliSatoshi, (createInvoiceRequest.Description??createInvoiceRequest.DescriptionHash.ToString())!, null, null, createInvoiceRequest.DescriptionHashOnly,expiryS )); return await GetInvoice(p.lnInvoice.paymentHash, cancellation); } public async Task Listen(CancellationToken cancellation = default) { return new BreezInvoiceListener(this, cancellation); } public async Task GetInfo(CancellationToken cancellation = default) { var ni = Sdk.NodeInfo(); return new LightningNodeInformation() { PeersCount = ni.connectedPeers.Count, Alias = $"greenlight {ni.id}", NodeInfoList = {new NodeInfo(new PubKey(ni.id), "blockstrean.com", 69)},//we have to fake this as btcpay currently requires this to enable the payment method BlockHeight = (int)ni.blockHeight }; } public async Task GetBalance(CancellationToken cancellation = default) { var ni = Sdk.NodeInfo(); return new LightningNodeBalance() { OnchainBalance = new OnchainBalance() { Confirmed = Money.Coins(LightMoney.MilliSatoshis(ni.onchainBalanceMsat) .ToUnit(LightMoneyUnit.BTC)) }, OffchainBalance = new OffchainBalance() { Local = LightMoney.MilliSatoshis(ni.channelsBalanceMsat), Remote = LightMoney.MilliSatoshis(ni.inboundLiquidityMsats), } }; } public async Task Pay(PayInvoiceParams payParams, CancellationToken cancellation = default) { return await Pay(null, payParams, cancellation); } public async Task Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default) { throw new NotImplementedException(); } public async Task Pay(string bolt11, CancellationToken cancellation = default) { return await Pay(bolt11, null, cancellation); } public async Task OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = default) { throw new NotImplementedException(); } public async Task GetDepositAddress(CancellationToken cancellation = default) { throw new NotImplementedException(); } public async Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = default) { throw new NotImplementedException(); } public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = default) { throw new NotImplementedException(); } public async Task ListChannels(CancellationToken cancellation = default) { throw new NotImplementedException(); } public void Dispose() { Sdk.Dispose(); Sdk.Dispose(); } public class BreezInvoiceListener: ILightningInvoiceListener { private readonly BreezLightningClient _breezLightningClient; private readonly CancellationToken _cancellationToken; public BreezInvoiceListener(BreezLightningClient breezLightningClient, CancellationToken cancellationToken) { _breezLightningClient = breezLightningClient; _cancellationToken = cancellationToken; breezLightningClient.EventReceived += BreezLightningClientOnEventReceived; } private readonly ConcurrentQueue> _invoices = new(); private void BreezLightningClientOnEventReceived(object sender, BreezEvent e) { if (e is BreezEvent.InvoicePaid pre) { _invoices.Enqueue(_breezLightningClient.GetInvoice(pre.details.paymentHash, _cancellationToken)); } } public void Dispose() { _breezLightningClient.EventReceived -= BreezLightningClientOnEventReceived; } public async Task WaitInvoice(CancellationToken cancellation) { while(cancellation.IsCancellationRequested is not true) { if (_invoices.TryDequeue(out var task)) { return await task.WithCancellation(cancellation); } await Task.Delay(100, cancellation); } cancellation.ThrowIfCancellationRequested(); return null; } } }