using System; using System.Collections.Concurrent; using System.Linq; using System.Threading; using System.Threading.Tasks; using Breez.Sdk; using BTCPayServer.Lightning; using NBitcoin; using Network = Breez.Sdk.Network; namespace BTCPayServer.Plugins.Breez; public class BreezLightningClient : ILightningClient, IDisposable, EventListener { public override string ToString() { return $"type=breez;key={PaymentKey}"; } private readonly NBitcoin.Network _network; public readonly string PaymentKey; public BreezLightningClient(string inviteCode, string apiKey, string workingDir, NBitcoin.Network network, string mnemonic, string paymentKey) { _network = network; PaymentKey = paymentKey; var nodeConfig = new NodeConfig.Greenlight( new GreenlightNodeConfig(null, inviteCode) ); var config = BreezSdkMethods.DefaultConfig( network == NBitcoin.Network.Main ? EnvironmentType.PRODUCTION : EnvironmentType.STAGING, 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.FromUnixTimeSeconds(p.paymentTime), ExpiresAt = bolt11.ExpiryDate }; } public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) { var p = Sdk.PaymentByHash(paymentHash.ToString()!); if(p is null) return new LightningInvoice() { Id = paymentHash.ToString(), PaymentHash = paymentHash.ToString(), Status = LightningInvoiceStatus.Expired, }; 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 FromPR(p); } public LightningInvoice FromPR(ReceivePaymentResponse response) { return new LightningInvoice() { Amount = LightMoney.MilliSatoshis(response.lnInvoice.amountMsat ?? 0), Id = response.lnInvoice.paymentHash, Preimage = ConvertHelper.ToHexString(response.lnInvoice.paymentSecret.ToArray()), PaymentHash = response.lnInvoice.paymentHash, BOLT11 = response.lnInvoice.bolt11, Status = LightningInvoiceStatus.Unpaid, ExpiresAt = DateTimeOffset.FromUnixTimeSeconds((long) response.lnInvoice.expiry) }; } 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 FromPR(p); } 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) { SendPaymentResponse result; try { if (bolt11 is null) { result = Sdk.SendSpontaneousPayment(new SendSpontaneousPaymentRequest(payParams.Destination.ToString(), (ulong) payParams.Amount.MilliSatoshi)); } else { result = Sdk.SendPayment(new SendPaymentRequest(bolt11, (ulong?) payParams.Amount?.MilliSatoshi)); } var details = result.payment.details as PaymentDetails.Ln; return new PayResponse() { Result = result.payment.status switch { PaymentStatus.FAILED => PayResult.Error, PaymentStatus.COMPLETE => PayResult.Ok, PaymentStatus.PENDING => PayResult.Unknown, _ => throw new ArgumentOutOfRangeException() }, Details = new PayDetails() { Status = result.payment.status switch { PaymentStatus.FAILED => LightningPaymentStatus.Failed, PaymentStatus.COMPLETE => LightningPaymentStatus.Complete, PaymentStatus.PENDING => LightningPaymentStatus.Pending, _ => LightningPaymentStatus.Unknown }, Preimage = details.data.paymentPreimage is null ? null : uint256.Parse(details.data.paymentPreimage), PaymentHash = details.data.paymentHash is null ? null : uint256.Parse(details.data.paymentHash), FeeAmount = result.payment.feeMsat, TotalAmount = LightMoney.MilliSatoshis(result.payment.amountMsat + result.payment.feeMsat), } }; } catch (Exception e) { return new PayResponse(PayResult.Error, e.Message); } } 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; } } }