using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using Breez.Sdk.Spark; using BTCPayServer.Lightning; using NBitcoin; using Network = Breez.Sdk.Spark.Network; namespace BTCPayServer.Plugins.Breez; public class BreezLightningClient : ILightningClient, IDisposable { public override string ToString() { return $"type=breez;key={PaymentKey}"; } private readonly NBitcoin.Network _network; public readonly string PaymentKey; public ConcurrentQueue<(DateTimeOffset timestamp, string log)> Events { get; set} = new(); private BreezSdk _sdk; public static async Task Create(string apiKey, string workingDir, NBitcoin.Network network, Mnemonic mnemonic, string paymentKey) { apiKey ??= "99010c6f84541bf582899db6728f6098ba98ca95ea569f4c63f2c2c9205ace57"; var config = BreezSdkSparkMethods.DefaultConfig( network == NBitcoin.Network.Main ? Network.Mainnet : network == NBitcoin.Network.TestNet ? Network.Testnet : network == NBitcoin.Network.RegTest ? Network.Regtest : Network.Signet ) with { apiKey = apiKey }; var seed = mnemonic.DeriveSeed(); var sdk = await BreezSdkSparkMethods.Connect(new ConnectRequest(config, seed.ToList(), workingDir)); return new BreezLightningClient(sdk, network, paymentKey); } private BreezLightningClient(BreezSdk sdk, NBitcoin.Network network, string paymentKey) { _sdk = sdk; _network = network; PaymentKey = paymentKey; } public BreezSdk Sdk => _sdk; public async Task GetInvoice(string invoiceId, CancellationToken cancellation = default) { return await GetInvoice(uint256.Parse(invoiceId), cancellation); } public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) { try { var response = await _sdk.GetPayment(new GetPaymentRequest(paymentHash.ToString())); if (response?.payment != null) { return FromPayment(response.payment); } } catch { // Payment not found } return new LightningInvoice() { Id = paymentHash.ToString(), PaymentHash = paymentHash.ToString(), Status = LightningInvoiceStatus.Unpaid }; } public async Task ListInvoices(CancellationToken cancellation = default) { return await ListInvoices(null, cancellation); } public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = default) { var req = new ListPaymentsRequest( typeFilter: new List { PaymentType.Receive }, statusFilter: request?.PendingOnly == true ? new List { PaymentStatus.Pending } : null, assetFilter: new AssetFilter.Bitcoin(), fromTimestamp: null, toTimestamp: null, offset: (ulong?)request?.OffsetIndex, limit: null, sortAscending: false ); var response = await _sdk.ListPayments(req); return response.payments.Select(FromPayment).Where(p => p != null).ToArray(); } public async Task GetPayment(string paymentHash, CancellationToken cancellation = default) { try { var response = await _sdk.GetPayment(new GetPaymentRequest(paymentHash)); return ToLightningPayment(response?.payment); } catch { return null; } } public async Task ListPayments(CancellationToken cancellation = default) { return await ListPayments(null, cancellation); } public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = default) { var req = new ListPaymentsRequest( typeFilter: new List { PaymentType.Send }, statusFilter: null, assetFilter: new AssetFilter.Bitcoin(), fromTimestamp: null, toTimestamp: null, offset: (ulong?)request?.OffsetIndex, limit: null, sortAscending: false ); var response = await _sdk.ListPayments(req); return response.payments.Select(ToLightningPayment).Where(p => p != null).ToArray(); } public async Task CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = default) { var expiryS = expiry == TimeSpan.Zero ? (ulong?)null : Math.Max(0, (ulong)expiry.TotalSeconds); description ??= "Invoice"; var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice(description, (ulong)amount.ToUnit(LightMoneyUnit.Satoshi)); var response = await _sdk.ReceivePayment(new ReceivePaymentRequest(paymentMethod)); return FromReceivePaymentResponse(response); } public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = default) { var expiryS = createInvoiceRequest.Expiry == TimeSpan.Zero ? (ulong?)null : Math.Max(0, (ulong)createInvoiceRequest.Expiry.TotalSeconds); var description = createInvoiceRequest.Description ?? createInvoiceRequest.DescriptionHash?.ToString() ?? "Invoice"; var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice(description, (ulong)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi)); var response = await _sdk.ReceivePayment(new ReceivePaymentRequest(paymentMethod)); return FromReceivePaymentResponse(response); } public async Task Listen(CancellationToken cancellation = default) { return new BreezInvoiceListener(this, cancellation); } public async Task GetInfo(CancellationToken cancellation = default) { var response = await _sdk.GetInfo(new GetInfoRequest(ensureSynced: false)); return new LightningNodeInformation() { Alias = "Breez Spark (nodeless)", BlockHeight = 0 }; } public async Task GetBalance(CancellationToken cancellation = default) { var response = await _sdk.GetInfo(new GetInfoRequest(ensureSynced: false)); return new LightningNodeBalance() { OnchainBalance = new OnchainBalance() { Confirmed = Money.Zero }, OffchainBalance = new OffchainBalance() { Local = LightMoney.Satoshis((long)response.balanceSats), Remote = LightMoney.Zero } }; } 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) { try { if (string.IsNullOrEmpty(bolt11)) { return new PayResponse(PayResult.Error, "BOLT11 invoice required"); } var prepareRequest = new PrepareSendPaymentRequest( new SendPaymentDestination.Bolt11Invoice(bolt11, null, false) ); var prepareResponse = await _sdk.PrepareSendPayment(prepareRequest); var sendRequest = new SendPaymentRequest( prepareResponse, new SendPaymentOptions.Bolt11Invoice(preferSpark: false, completionTimeoutSecs: 30) ); var sendResponse = await _sdk.SendPayment(sendRequest); return new PayResponse() { Result = sendResponse.payment.status switch { PaymentStatus.Failed => PayResult.Error, PaymentStatus.Completed => PayResult.Ok, PaymentStatus.Pending => PayResult.Unknown, _ => PayResult.Error }, Details = new PayDetails() { Status = sendResponse.payment.status switch { PaymentStatus.Failed => LightningPaymentStatus.Failed, PaymentStatus.Completed => LightningPaymentStatus.Complete, PaymentStatus.Pending => LightningPaymentStatus.Pending, _ => LightningPaymentStatus.Unknown }, TotalAmount = LightMoney.Satoshis((long)sendResponse.payment.amount), FeeAmount = (long)sendResponse.payment.fees } }; } 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(); } private LightningInvoice FromReceivePaymentResponse(ReceivePaymentResponse response) { return new LightningInvoice() { BOLT11 = response.paymentRequest, Status = LightningInvoiceStatus.Unpaid, Amount = LightMoney.Satoshis((long)response.fee) }; } private LightningInvoice FromPayment(Payment payment) { if (payment == null) return null; string paymentHash = null; string bolt11 = null; if (payment.details is PaymentDetails.Lightning lightningDetails) { paymentHash = lightningDetails.paymentHash; bolt11 = lightningDetails.invoice; } return new LightningInvoice() { Id = payment.id, PaymentHash = paymentHash ?? payment.id, BOLT11 = bolt11, Amount = LightMoney.Satoshis((long)payment.amount), Status = payment.status switch { PaymentStatus.Pending => LightningInvoiceStatus.Unpaid, PaymentStatus.Failed => LightningInvoiceStatus.Expired, PaymentStatus.Completed => LightningInvoiceStatus.Paid, _ => LightningInvoiceStatus.Unpaid }, PaidAt = DateTimeOffset.FromUnixTimeSeconds((long)payment.timestamp) }; } private LightningPayment ToLightningPayment(Payment payment) { if (payment == null) return null; string paymentHash = null; string preimage = null; string bolt11 = null; if (payment.details is PaymentDetails.Lightning lightningDetails) { paymentHash = lightningDetails.paymentHash; preimage = lightningDetails.preimage; bolt11 = lightningDetails.invoice; } return new LightningPayment() { Id = payment.id, PaymentHash = paymentHash ?? payment.id, Preimage = preimage, BOLT11 = bolt11, Amount = LightMoney.Satoshis((long)payment.amount), Status = payment.status switch { PaymentStatus.Failed => LightningPaymentStatus.Failed, PaymentStatus.Completed => LightningPaymentStatus.Complete, PaymentStatus.Pending => LightningPaymentStatus.Pending, _ => LightningPaymentStatus.Unknown }, CreatedAt = DateTimeOffset.FromUnixTimeSeconds((long)payment.timestamp), Fee = LightMoney.Satoshis((long)payment.fees), AmountSent = LightMoney.Satoshis((long)payment.amount) }; } public void Dispose() { _sdk?.Dispose(); } public class BreezInvoiceListener : ILightningInvoiceListener { private readonly BreezLightningClient _breezLightningClient; private readonly CancellationToken _cancellationToken; private readonly ConcurrentQueue _invoices = new(); public BreezInvoiceListener(BreezLightningClient breezLightningClient, CancellationToken cancellationToken) { _breezLightningClient = breezLightningClient; _cancellationToken = cancellationToken; } public void Dispose() { } public async Task WaitInvoice(CancellationToken cancellation) { while (!cancellation.IsCancellationRequested) { if (_invoices.TryDequeue(out var payment)) { return _breezLightningClient.FromPayment(payment); } await Task.Delay(100, cancellation); } cancellation.ThrowIfCancellationRequested(); return null; } } }