commit e1135a15a73da0f34e1b05e4cebdda19ad9787c9 Author: Aljaz Ceru Date: Mon Dec 8 11:45:58 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8aa954 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +**/bin/**/* +**/obj +**/.idea +*//Plugins/packed +.vs/ +/BTCPayServerPlugins.sln.DotSettings.user +*/documentation/ +**/build.sh +*/build-and-test.sh diff --git a/BTCPayServer.Plugins.Breez/BTCPayServer.Plugins.Breez.csproj b/BTCPayServer.Plugins.Breez/BTCPayServer.Plugins.Breez.csproj new file mode 100644 index 0000000..26b460b --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BTCPayServer.Plugins.Breez.csproj @@ -0,0 +1,43 @@ + + + + net8.0 + latest + enable + true + $(NoWarn);CS1591 + + + + + Breez Spark Lightning Plugin + Nodeless Lightning payments powered by Breez Spark SDK + 1.1.0 +true + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + + + + + diff --git a/BTCPayServer.Plugins.Breez/BreezController.cs b/BTCPayServer.Plugins.Breez/BreezController.cs new file mode 100644 index 0000000..dd658c0 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezController.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Text.Json; +using System.Threading.Tasks; +using Breez.Sdk.Spark; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Lightning; +using BTCPayServer.Models; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using NBitcoin.DataEncoders; + +namespace BTCPayServer.Plugins.Breez; + +[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] +[Route("plugins/{storeId}/Breez")] +public class BreezController : Controller +{ + private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly BreezService _breezService; + private readonly BTCPayWalletProvider _btcWalletProvider; + private readonly StoreRepository _storeRepository; + + public BreezController( + PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, + BTCPayNetworkProvider btcPayNetworkProvider, + BreezService breezService, + BTCPayWalletProvider btcWalletProvider, StoreRepository storeRepository) + { + _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; + _btcPayNetworkProvider = btcPayNetworkProvider; + _breezService = breezService; + _btcWalletProvider = btcWalletProvider; + _storeRepository = storeRepository; + } + + + [HttpGet("")] + public async Task Index(string storeId) + { + var client = _breezService.GetClient(storeId); + return RedirectToAction(client is null ? nameof(Configure) : nameof(Info), new {storeId}); + } + + [HttpGet("swapin")] + [Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task SwapIn(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + + [HttpGet("info")] + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Info(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + [HttpGet("logs")] + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Logs(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View( client.Events); + } + + [HttpPost("sweep")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Sweep(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + // In Spark SDK v0.4.1, check for any unclaimed deposits + var request = new ListUnclaimedDepositsRequest(); + var response = await client.Sdk.ListUnclaimedDeposits(request); + + if (response.deposits.Any()) + { + TempData[WellKnownTempData.SuccessMessage] = $"Found {response.deposits.Count} unclaimed deposits"; + } + else + { + TempData[WellKnownTempData.SuccessMessage] = "No pending deposits to claim"; + } + } + catch (Exception e) + { + TempData[WellKnownTempData.ErrorMessage] = $"error claiming deposits: {e.Message}"; + } + + return View((object) storeId); + } + + [HttpGet("send")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Send(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + [Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [HttpGet("receive")] + [Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Receive(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + + [HttpPost("receive")] + [Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Receive(string storeId, long? amount, string description) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + description ??= "BTCPay Server Invoice"; + + var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice( + description: description, + amountSats: amount != null ? (ulong)amount.Value : null + ); + + var request = new ReceivePaymentRequest(paymentMethod: paymentMethod); + var response = await client.Sdk.ReceivePayment(request: request); + + TempData["bolt11"] = response.paymentRequest; + TempData[WellKnownTempData.SuccessMessage] = "Invoice created successfully!"; + + return RedirectToAction(nameof(Payments), new {storeId}); + } + catch (Exception ex) + { + TempData[WellKnownTempData.ErrorMessage] = $"Error creating invoice: {ex.Message}"; + return View((object) storeId); + } + } + + [HttpPost("prepare-send")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task PrepareSend(string storeId, string address, long? amount) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + if (string.IsNullOrWhiteSpace(address)) + { + TempData[WellKnownTempData.ErrorMessage] = "Payment destination is required"; + return RedirectToAction(nameof(Send), new {storeId}); + } + + BigInteger? amountSats = null; + if (amount > 0) + { + amountSats = new BigInteger(amount.Value); + } + + var prepareRequest = new PrepareSendPaymentRequest( + paymentRequest: address, + amount: amountSats + ); + + var prepareResponse = await client.Sdk.PrepareSendPayment(prepareRequest); + + if (prepareResponse.paymentMethod is SendPaymentMethod.Bolt11Invoice bolt11Method) + { + var totalFee = bolt11Method.lightningFeeSats + (bolt11Method.sparkTransferFeeSats ?? 0); + var viewModel = new + { + Destination = address, + Amount = amountSats ?? 0, + Fee = totalFee, + PrepareResponseJson = JsonSerializer.Serialize(prepareResponse) + }; + ViewData["PaymentDetails"] = viewModel; + } + else if (prepareResponse.paymentMethod is SendPaymentMethod.BitcoinAddress bitcoinMethod) + { + var fees = bitcoinMethod.feeQuote; + var mediumFee = fees.speedMedium.userFeeSat + fees.speedMedium.l1BroadcastFeeSat; + var viewModel = new + { + Destination = address, + Amount = amountSats ?? 0, + Fee = mediumFee, + PrepareResponseJson = JsonSerializer.Serialize(prepareResponse) + }; + ViewData["PaymentDetails"] = viewModel; + } + else if (prepareResponse.paymentMethod is SendPaymentMethod.SparkAddress sparkMethod) + { + var viewModel = new + { + Destination = address, + Amount = amountSats ?? 0, + Fee = sparkMethod.fee, + PrepareResponseJson = JsonSerializer.Serialize(prepareResponse) + }; + ViewData["PaymentDetails"] = viewModel; + } + } + catch (Exception ex) + { + TempData[WellKnownTempData.ErrorMessage] = $"Error preparing payment: {ex.Message}"; + } + + return View(nameof(Send), storeId); + } + + [HttpPost("confirm-send")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task ConfirmSend(string storeId, string paymentRequest, long amount, string prepareResponseJson) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + var prepareResponse = JsonSerializer.Deserialize(prepareResponseJson); + if (prepareResponse == null) + { + throw new InvalidOperationException("Invalid payment preparation data"); + } + + SendPaymentOptions? options = prepareResponse.paymentMethod switch + { + SendPaymentMethod.Bolt11Invoice => new SendPaymentOptions.Bolt11Invoice( + preferSpark: false, + completionTimeoutSecs: 60 + ), + SendPaymentMethod.BitcoinAddress => new SendPaymentOptions.BitcoinAddress( + confirmationSpeed: OnchainConfirmationSpeed.Medium + ), + SendPaymentMethod.SparkAddress => null, + _ => throw new NotSupportedException("Unsupported payment method") + }; + + var sendRequest = new SendPaymentRequest( + prepareResponse: prepareResponse, + options: options + ); + + var sendResponse = await client.Sdk.SendPayment(sendRequest); + + TempData[WellKnownTempData.SuccessMessage] = "Payment sent successfully!"; + return RedirectToAction(nameof(Payments), new {storeId}); + } + catch (Exception ex) + { + TempData[WellKnownTempData.ErrorMessage] = $"Error sending payment: {ex.Message}"; + return RedirectToAction(nameof(Send), new {storeId}); + } + } + + + [HttpGet("swapout")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task SwapOut(string storeId) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + + [HttpPost("swapout")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task SwapOut(string storeId, string address, ulong amount, uint satPerByte, + string feesHash) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + // Use current SDK pattern for onchain payments + var prepareRequest = new PrepareSendPaymentRequest( + paymentRequest: address, + amount: new BigInteger(amount) + ); + + var prepareResponse = await client.Sdk.PrepareSendPayment(prepareRequest); + + if (prepareResponse.paymentMethod is SendPaymentMethod.BitcoinAddress bitcoinMethod) + { + var options = new SendPaymentOptions.BitcoinAddress( + confirmationSpeed: OnchainConfirmationSpeed.Medium + ); + + var sendRequest = new SendPaymentRequest( + prepareResponse: prepareResponse, + options: options + ); + + var sendResponse = await client.Sdk.SendPayment(sendRequest); + + TempData[WellKnownTempData.SuccessMessage] = "Onchain payment initiated successfully!"; + } + else + { + TempData[WellKnownTempData.ErrorMessage] = "Invalid payment method for onchain swap"; + } + } + catch (Exception ex) + { + TempData[WellKnownTempData.ErrorMessage] = $"Error processing swap-out: {ex.Message}"; + } + + return RedirectToAction(nameof(SwapOut), new {storeId}); + } + + [HttpGet("swapin/{address}/refund")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task SwapInRefund(string storeId, string address) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + return View((object) storeId); + } + + [HttpPost("swapin/{address}/refund")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task SwapInRefund(string storeId, string txid, uint vout, string refundAddress, uint? satPerByte = null) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + try + { + // Parse the txid:vout format from depositId if needed + var fee = new Fee.Rate((ulong)(satPerByte ?? 5m)); + var request = new RefundDepositRequest( + txid: txid, + vout: vout, + destinationAddress: refundAddress, + fee: fee + ); + + var resp = await client.Sdk.RefundDeposit(request); + TempData[WellKnownTempData.SuccessMessage] = $"Refund successful: {resp.txId}"; + } + catch (Exception e) + { + TempData[WellKnownTempData.ErrorMessage] = $"Couldnt refund: {e.Message}"; + } + + return RedirectToAction(nameof(SwapIn), new {storeId}); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [HttpGet("configure")] + public async Task Configure(string storeId) + { + return View(await _breezService.Get(storeId)); + } + [HttpPost("configure")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Configure(string storeId, string command, BreezSettings settings) + { + var store = HttpContext.GetStoreData(); + if (store == null) + { + return NotFound(); + } + var pmi = new PaymentMethodId("BTC-LN"); + // In v2.2.1, payment methods are handled differently + // TODO: Implement proper v2.2.1 payment method handling + object? existing = null; + + if (command == "clear") + { + await _breezService.Set(storeId, null); + TempData[WellKnownTempData.SuccessMessage] = "Settings cleared successfully"; + var client = _breezService.GetClient(storeId); + // In v2.2.1, payment methods are handled differently + // TODO: Implement proper v2.2.1 payment method handling + return RedirectToAction(nameof(Configure), new {storeId}); + } + + if (command == "save") + { + try + { + if (string.IsNullOrEmpty(settings.Mnemonic)) + { + ModelState.AddModelError(nameof(settings.Mnemonic), "Mnemonic is required"); + return View(settings); + } + + try + { + new Mnemonic(settings.Mnemonic); + } + catch (Exception e) + { + ModelState.AddModelError(nameof(settings.Mnemonic), "Invalid mnemonic"); + return View(settings); + } + + await _breezService.Set(storeId, settings); + } + catch (Exception e) + { + TempData[WellKnownTempData.ErrorMessage] = $"Couldnt use provided settings: {e.Message}"; + return View(settings); + } + + // In v2.2.1, payment methods are handled differently + // TODO: Implement proper v2.2.1 payment method handling + // This will require a complete rewrite of the payment method system + + TempData[WellKnownTempData.SuccessMessage] = "Settings saved successfully"; + return RedirectToAction(nameof(Info), new {storeId}); + } + + return NotFound(); + } + + [Route("payments")] + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Payments(string storeId, PaymentsViewModel viewModel) + { + var client = _breezService.GetClient(storeId); + if (client is null) + { + return RedirectToAction(nameof(Configure), new {storeId}); + } + + viewModel ??= new PaymentsViewModel(); + var req = new ListPaymentsRequest( + typeFilter: null, + statusFilter: null, + assetFilter: new AssetFilter.Bitcoin(), + fromTimestamp: null, + toTimestamp: null, + offset: viewModel.Skip != null ? (uint?)viewModel.Skip : null, + limit: viewModel.Count != null ? (uint?)viewModel.Count : null, + sortAscending: false + ); + var response = await client.Sdk.ListPayments(req); + viewModel.Payments = response.payments.Select(client.NormalizePayment).ToList(); + + return View(viewModel); + } +} + +public class PaymentsViewModel : BasePagingViewModel +{ + public List Payments { get; set; } = new(); + public override int CurrentPageCount => Payments.Count; +} + +// Helper class for swap information display in views +public class SwapInfo +{ + public string? bitcoinAddress { get; set; } + public ulong minAllowedDeposit { get; set; } + public ulong maxAllowedDeposit { get; set; } + public string? status { get; set; } +} + +// Helper class for swap limits display in views +public class SwapLimits +{ + public ulong min { get; set; } + public ulong max { get; set; } +} diff --git a/BTCPayServer.Plugins.Breez/BreezLightningClient.cs b/BTCPayServer.Plugins.Breez/BreezLightningClient.cs new file mode 100644 index 0000000..3404609 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezLightningClient.cs @@ -0,0 +1,952 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using System.Text.Json; +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 EventLogEntry +{ + public DateTimeOffset timestamp { get; set; } + public string log { get; set; } = string.Empty; +} + +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 Events { get; set; } = new ConcurrentQueue(); + private readonly ConcurrentQueue _paymentNotifications = new(); + private readonly ConcurrentDictionary _seenCompletedPayments = new(); + private readonly ConcurrentDictionary _seenPaymentHashes = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _invoicesByHash = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _invoicesByBolt11 = new(StringComparer.OrdinalIgnoreCase); + + private void DebugLog(string message) + { + // Debug logging disabled for release build + } + + private void DebugLogObject(string label, object obj) + { + // Debug logging disabled for release build + } + + 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.RegTest ? Network.Regtest : Network.Mainnet + ) with + { + apiKey = apiKey + }; + + var seed = new Seed.Mnemonic(mnemonic: mnemonic.ToString(), passphrase: null); + var sdk = await BreezSdkSparkMethods.Connect(new ConnectRequest(config, seed, workingDir)); + + return new BreezLightningClient(sdk, network, paymentKey); + } + + private BreezLightningClient(BreezSdk sdk, NBitcoin.Network network, string paymentKey) + { + _sdk = sdk; + _network = network; + PaymentKey = paymentKey; + + // Start monitoring payment events + _ = Task.Run(MonitorPaymentEvents); + } + + public BreezSdk Sdk => _sdk; + + public async Task GetInvoice(string invoiceId, CancellationToken cancellation = default) + { + var invoice = await GetInvoiceInternal(invoiceId, cancellation); + if (invoice is not null) + { + return invoice; + } + + return new LightningInvoice() + { + Id = invoiceId, + PaymentHash = invoiceId, + Status = LightningInvoiceStatus.Unpaid + }; + } + + public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = default) + { + return await GetInvoice(paymentHash.ToString(), cancellation); + } + + public async Task ListInvoices(CancellationToken cancellation = default) + { + return await ListInvoices((ListInvoicesParams?)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: request?.OffsetIndex != null ? (uint?)request.OffsetIndex : null, + 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) + { + var payment = await FindPayment(paymentHash, cancellation); + return payment is not null ? ToLightningPayment(payment) : null; + } + + public async Task ListPayments(CancellationToken cancellation = default) + { + return await ListPayments((ListPaymentsParams?)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: request?.OffsetIndex != null ? (uint?)request.OffsetIndex : null, + 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 descriptionToUse = description ?? "Invoice"; + var amountSats = (ulong)amount.ToUnit(LightMoneyUnit.Satoshi); + var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice(descriptionToUse, amountSats); + var response = await _sdk.ReceivePayment(new ReceivePaymentRequest(paymentMethod)); + DebugLogObject("ReceivePaymentResponse(CreateInvoice)", response); + return FromReceivePaymentResponse(response, amount); + } + + public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, + CancellationToken cancellation = default) + { + var description = createInvoiceRequest.Description ?? createInvoiceRequest.DescriptionHash?.ToString() ?? "Invoice"; + var amountSats = (ulong)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi); + var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice(description, amountSats); + var response = await _sdk.ReceivePayment(new ReceivePaymentRequest(paymentMethod)); + DebugLogObject("ReceivePaymentResponse(CreateInvoiceParams)", response); + return FromReceivePaymentResponse(response, createInvoiceRequest.Amount); + } + + public async Task Listen(CancellationToken cancellation = default) + { + return new BreezInvoiceListener(this, cancellation); + } + + public async Task GetInfo(CancellationToken cancellation = default) + { + try + { + var response = await _sdk.GetInfo(new GetInfoRequest(ensureSynced: false)); + + return new LightningNodeInformation() + { + Alias = "Breez Spark (nodeless)", + BlockHeight = 0, // Spark SDK doesn't expose block height + Version = "0.4.1" // SDK version hardcoded since property not found + }; + } + catch + { + return new LightningNodeInformation() + { + Alias = "Breez Spark (nodeless)", + BlockHeight = 0 + }; + } + } + + public async Task GetBalance(CancellationToken cancellation = default) + { + try + { + var response = await _sdk.GetInfo(new GetInfoRequest(ensureSynced: false)); + + return new LightningNodeBalance() + { + OnchainBalance = new OnchainBalance() + { + Confirmed = Money.Satoshis((long)response.balanceSats) + }, + OffchainBalance = new OffchainBalance() + { + Local = LightMoney.Satoshis((long)response.balanceSats), + Remote = LightMoney.Zero + } + }; + } + catch + { + return new LightningNodeBalance() + { + OnchainBalance = new OnchainBalance() + { + Confirmed = Money.Zero + }, + OffchainBalance = new OffchainBalance() + { + Local = LightMoney.Zero, + 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"); + } + + BigInteger? amountSats = null; + if (payParams.Amount > 0) + { + amountSats = new BigInteger(payParams.Amount); + } + + var prepareRequest = new PrepareSendPaymentRequest( + paymentRequest: bolt11, + amount: amountSats + ); + var prepareResponse = await _sdk.PrepareSendPayment(prepareRequest); + + if (prepareResponse.paymentMethod is SendPaymentMethod.Bolt11Invoice bolt11Method) + { + var options = new SendPaymentOptions.Bolt11Invoice( + preferSpark: false, + completionTimeoutSecs: 60 + ); + + var sendRequest = new SendPaymentRequest( + prepareResponse: prepareResponse, + options: options + ); + 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 / 1000)), + FeeAmount = (long)(bolt11Method.lightningFeeSats + (bolt11Method.sparkTransferFeeSats ?? 0)) + } + }; + } + else + { + return new PayResponse(PayResult.Error, "Invalid payment method"); + } + } + 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, LightMoney requestedAmount) + { + string? paymentHash = null; + try + { + if (BOLT11PaymentRequest.TryParse(response.paymentRequest, out var pr, _network)) + { + paymentHash = pr.PaymentHash?.ToString(); + } + } + catch + { + // Ignore parse errors and fall back to raw request + } + + DebugLogObject("FromReceivePaymentResponse", response); + RecordInvoiceAmount(response.paymentRequest, paymentHash, requestedAmount); + + return new LightningInvoice() + { + Id = paymentHash ?? response.paymentRequest, + PaymentHash = paymentHash, + BOLT11 = response.paymentRequest, + Status = LightningInvoiceStatus.Unpaid, + Amount = requestedAmount + }; + } + + private LightningInvoice FromPayment(Payment payment) + { + if (payment == null) return null; + + string paymentHash = ExtractPaymentHash(payment); + string bolt11 = null; + LightMoney? boltAmount = null; + LightMoney? recordedAmount = null; + + if (payment.details is PaymentDetails.Lightning lightningDetails) + { + bolt11 = lightningDetails.invoice; + if (!string.IsNullOrEmpty(lightningDetails.invoice) && + BOLT11PaymentRequest.TryParse(lightningDetails.invoice, out var pr, _network)) + { + boltAmount = pr.MinimumAmount; + } + + var rec = LookupInvoice(lightningDetails.invoice, paymentHash); + recordedAmount = rec?.Amount; + } + + // Reject if hash is missing or not one we issued + if (string.IsNullOrEmpty(paymentHash)) + { + DebugLog($"FromPayment: missing payment hash for payment.id={payment.id}"); + return null; + } + + var record = LookupInvoice(null, paymentHash); + if (record is null || record.PaymentHash != paymentHash) + { + DebugLog($"FromPayment: unknown payment hash={paymentHash} payment.id={payment.id}"); + return null; + } + + recordedAmount ??= record.Amount; + + // Always use the invoice amount (BOLT11 truth). Never fall back to what Breez reports. + var resolvedAmount = recordedAmount ?? boltAmount; + if (boltAmount is not null && recordedAmount is not null && boltAmount != recordedAmount) + { + DebugLog($"FromPayment: bolt amount {boltAmount.ToUnit(LightMoneyUnit.Satoshi)} != recorded {recordedAmount.ToUnit(LightMoneyUnit.Satoshi)} for hash={paymentHash}"); + } + + var invoiceId = paymentHash; + if (resolvedAmount is null) + { + DebugLog($"FromPayment: missing amount for hash={paymentHash} bolt11={Shorten(bolt11)}"); + return null; + } + + DebugLog($"FromPayment: returning invoice id={invoiceId} hash={paymentHash} bolt11={Shorten(bolt11)} boltSat={boltAmount?.ToUnit(LightMoneyUnit.Satoshi)} recSat={recordedAmount?.ToUnit(LightMoneyUnit.Satoshi)} raw_msat={payment.amount} fee_msat={payment.fees} chosenSat={resolvedAmount.ToUnit(LightMoneyUnit.Satoshi)}"); + + return new LightningInvoice() + { + Id = invoiceId, + PaymentHash = paymentHash ?? invoiceId, + BOLT11 = bolt11 ?? payment.id, + Amount = resolvedAmount, + AmountReceived = resolvedAmount, + 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 = ExtractPaymentHash(payment); + string preimage = null; + string bolt11 = null; + LightMoney? boltAmount = null; + LightMoney? recordedAmount = null; + var feeAmount = GetFeeFromPayment(payment); + + if (payment.details is PaymentDetails.Lightning lightningDetails) + { + preimage = lightningDetails.preimage; + bolt11 = lightningDetails.invoice; + if (!string.IsNullOrEmpty(lightningDetails.invoice) && + BOLT11PaymentRequest.TryParse(lightningDetails.invoice, out var pr, _network)) + { + boltAmount = pr.MinimumAmount; + } + + var rec = LookupInvoice(lightningDetails.invoice, paymentHash); + recordedAmount = rec?.Amount; + } + + if (string.IsNullOrEmpty(paymentHash)) + { + DebugLog($"ToLightningPayment: missing payment hash for payment.id={payment.id}"); + return null; + } + + var record = LookupInvoice(null, paymentHash); + if (record is null || record.PaymentHash != paymentHash) + { + DebugLog($"ToLightningPayment: unknown payment hash={paymentHash} payment.id={payment.id}"); + return null; + } + + recordedAmount ??= record.Amount; + + var resolvedAmount = recordedAmount ?? boltAmount; + if (boltAmount is not null && recordedAmount is not null && boltAmount != recordedAmount) + { + DebugLog($"ToLightningPayment: bolt amount {boltAmount.ToUnit(LightMoneyUnit.Satoshi)} != recorded {recordedAmount.ToUnit(LightMoneyUnit.Satoshi)} for hash={paymentHash}"); + } + + var paymentId = paymentHash; + if (resolvedAmount is null) + { + DebugLog($"ToLightningPayment: missing amount for hash={paymentHash} bolt11={Shorten(bolt11)}"); + return null; + } + + DebugLog($"ToLightningPayment: returning payment id={paymentId} hash={paymentHash} bolt11={Shorten(bolt11)} boltSat={boltAmount?.ToUnit(LightMoneyUnit.Satoshi)} recSat={recordedAmount?.ToUnit(LightMoneyUnit.Satoshi)} raw_msat={payment.amount} fee_msat={payment.fees} chosenSat={resolvedAmount.ToUnit(LightMoneyUnit.Satoshi)}"); + + return new LightningPayment() + { + Id = paymentId, + PaymentHash = paymentHash ?? paymentId, + Preimage = preimage, + BOLT11 = bolt11, + Amount = resolvedAmount, + 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 = feeAmount, + AmountSent = resolvedAmount + }; + } + + private void RecordInvoiceAmount(string bolt11, string? paymentHash, LightMoney requestedAmount) + { + // Prefer the amount encoded in the BOLT11 (ground truth), fall back to the requested amount. + LightMoney amount = requestedAmount; + try + { + if (BOLT11PaymentRequest.TryParse(bolt11, out var pr, _network)) + { + if (pr.MinimumAmount is not null) + amount = pr.MinimumAmount; + if (string.IsNullOrEmpty(paymentHash) && pr.PaymentHash is not null) + paymentHash = pr.PaymentHash.ToString(); + } + } + catch { } + + if (string.IsNullOrEmpty(paymentHash)) + return; + + var record = new InvoiceRecord + { + PaymentHash = paymentHash, + Bolt11 = bolt11, + Amount = amount + }; + + _invoicesByHash[paymentHash] = record; + _invoicesByBolt11[bolt11] = record; + } + + private InvoiceRecord? LookupInvoice(string? bolt11, string? paymentHash) + { + if (!string.IsNullOrEmpty(paymentHash) && _invoicesByHash.TryGetValue(paymentHash, out var recByHash)) + { + DebugLog($"LookupInvoice: hit by hash={paymentHash} amount_sat={recByHash.Amount.ToUnit(LightMoneyUnit.Satoshi)} bolt11={Shorten(recByHash.Bolt11)}"); + return recByHash; + } + + if (!string.IsNullOrEmpty(bolt11) && _invoicesByBolt11.TryGetValue(bolt11, out var recByBolt)) + { + DebugLog($"LookupInvoice: hit by bolt11={Shorten(bolt11)} amount_sat={recByBolt.Amount.ToUnit(LightMoneyUnit.Satoshi)}"); + return recByBolt; + } + + DebugLog($"LookupInvoice: miss for hash={paymentHash} bolt11={Shorten(bolt11)}"); + return null; + } + + private bool IsKnownPayment(Payment payment) + { + var paymentHash = ExtractPaymentHash(payment); + if (string.IsNullOrEmpty(paymentHash)) + return false; + + return LookupInvoice(null, paymentHash) is not null; + } + + private LightMoney InferAmountFromPayment(Payment payment) + { + var rawAmount = payment.amount; + + if (rawAmount == 0) + { + return LightMoney.Zero; + } + + // Breez SDK surfaces amounts in millisats for lightning payments; fall back to sats otherwise. + if (rawAmount % 1000 == 0) + { + return LightMoney.Satoshis((long)(rawAmount / 1000)); + } + + return LightMoney.Satoshis((long)rawAmount); + } + + private string? ExtractPaymentHash(Payment payment) + { + if (payment?.details is not PaymentDetails.Lightning ln) + return null; + + if (!string.IsNullOrEmpty(ln.paymentHash)) + return ln.paymentHash; + + if (!string.IsNullOrEmpty(ln.invoice) && + BOLT11PaymentRequest.TryParse(ln.invoice, out var pr, _network) && + pr.PaymentHash is not null) + { + return pr.PaymentHash.ToString(); + } + + return null; + } + + private LightMoney GetFeeFromPayment(Payment payment) + { + return payment.fees % 1000 == 0 + ? LightMoney.Satoshis((long)(payment.fees / 1000)) + : LightMoney.Satoshis((long)payment.fees); + } + + private bool TryMarkPaymentSeen(Payment payment) + { + var paymentHash = ExtractPaymentHash(payment); + var seenByHash = !string.IsNullOrEmpty(paymentHash) && _seenPaymentHashes.ContainsKey(paymentHash); + var seenById = _seenCompletedPayments.ContainsKey(payment.id); + if (seenByHash || seenById) + { + DebugLog($"TryMarkPaymentSeen: already seen payment.id={payment.id} hash={paymentHash}"); + return false; + } + + _seenCompletedPayments.TryAdd(payment.id, true); + if (!string.IsNullOrEmpty(paymentHash)) + { + _seenPaymentHashes.TryAdd(paymentHash, true); + } + + return true; + } + + public NormalizedPayment NormalizePayment(Payment payment) + { + if (payment == null) throw new ArgumentNullException(nameof(payment)); + + string paymentHash = null; + string bolt11 = null; + string description = null; + LightMoney? boltAmount = null; + LightMoney? recordedAmount = null; + var feeAmount = GetFeeFromPayment(payment); + + if (payment.details is PaymentDetails.Lightning lightningDetails) + { + paymentHash = ExtractPaymentHash(payment); + bolt11 = lightningDetails.invoice; + description = lightningDetails.description; + if (!string.IsNullOrEmpty(lightningDetails.invoice) && + BOLT11PaymentRequest.TryParse(lightningDetails.invoice, out var pr, _network)) + { + boltAmount = pr.MinimumAmount; + } + + var rec = LookupInvoice(lightningDetails.invoice, lightningDetails.paymentHash); + recordedAmount = rec?.Amount; + } + + if (string.IsNullOrEmpty(paymentHash)) + { + DebugLog($"NormalizePayment: missing payment hash for payment.id={payment.id}"); + return null; + } + + var record = LookupInvoice(null, paymentHash); + if (record is null || record.PaymentHash != paymentHash) + { + DebugLog($"NormalizePayment: unknown payment hash={paymentHash} payment.id={payment.id}"); + return null; + } + + recordedAmount ??= record.Amount; + var amount = recordedAmount ?? boltAmount; + if (boltAmount is not null && recordedAmount is not null && boltAmount != recordedAmount) + { + DebugLog($"NormalizePayment: bolt amount {boltAmount.ToUnit(LightMoneyUnit.Satoshi)} != recorded {recordedAmount.ToUnit(LightMoneyUnit.Satoshi)} for hash={paymentHash}"); + } + + if (amount is null) + { + // If we can't prove the amount from the BOLT11 or stored record, reject the payment. + DebugLog($"NormalizePayment: missing amount for hash={paymentHash} bolt11={Shorten(bolt11)}"); + return null; + } + var fee = feeAmount; + + return new NormalizedPayment + { + Id = paymentHash ?? bolt11 ?? payment.id, + PaymentType = payment.paymentType, + Status = payment.status, + Timestamp = payment.timestamp, + Amount = amount, + Fee = fee, + Description = description ?? bolt11 + }; + } + + 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) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken, cancellation); + + while (!linkedCts.Token.IsCancellationRequested) + { + // Check the client's payment notification queue + if (_breezLightningClient._paymentNotifications.TryDequeue(out var payment)) + { + var invoice = _breezLightningClient.FromPayment(payment); + if (invoice is not null) + { + _breezLightningClient.DebugLog($"WaitInvoice: dequeued payment.id={payment.id} hash={invoice.PaymentHash} bolt11={_breezLightningClient.Shorten(invoice.BOLT11)} status={payment.status} raw_msat={payment.amount} fee_msat={payment.fees}"); + // Force amount to the recorded invoice amount (BOLT11 truth) before handing to BTCPay + var rec = _breezLightningClient.LookupInvoice(invoice.BOLT11, invoice.PaymentHash); + if (rec is not null) + { + invoice.Amount = rec.Amount; + invoice.AmountReceived = rec.Amount; + _breezLightningClient.DebugLog($"WaitInvoice: normalized invoice amount to recorded {rec.Amount.ToUnit(LightMoneyUnit.Satoshi)} sats for hash={invoice.PaymentHash}"); + } + return invoice; + } + } + + // Also check the local queue for backwards compatibility + if (_invoices.TryDequeue(out var payment2)) + { + var invoice = _breezLightningClient.FromPayment(payment2); + if (invoice is not null) + { + _breezLightningClient.DebugLog($"WaitInvoice(local): dequeued payment.id={payment2.id} hash={invoice.PaymentHash} bolt11={_breezLightningClient.Shorten(invoice.BOLT11)} status={payment2.status} raw_msat={payment2.amount} fee_msat={payment2.fees}"); + var rec = _breezLightningClient.LookupInvoice(invoice.BOLT11, invoice.PaymentHash); + if (rec is not null) + { + invoice.Amount = rec.Amount; + invoice.AmountReceived = rec.Amount; + _breezLightningClient.DebugLog($"WaitInvoice: normalized (local queue) invoice amount to recorded {rec.Amount.ToUnit(LightMoneyUnit.Satoshi)} sats for hash={invoice.PaymentHash}"); + } + return invoice; + } + } + + await Task.Delay(1000, linkedCts.Token); // Check every second + } + + linkedCts.Token.ThrowIfCancellationRequested(); + return null; + } + } + + private async Task MonitorPaymentEvents() + { + try + { + while (true) + { + try + { + // Get all payments and check for new paid ones + var payments = await _sdk.ListPayments(new ListPaymentsRequest( + typeFilter: new List { PaymentType.Receive } + )); + + foreach (var payment in payments.payments) + { + // If payment is complete, add it to the notification queue + if (payment.status == PaymentStatus.Completed && + TryMarkPaymentSeen(payment) && + IsKnownPayment(payment)) + { + DebugLogObject("MonitorPaymentEvents:payment", payment); + LogCompletedPayment(payment); + _paymentNotifications.Enqueue(payment); + } + } + + await Task.Delay(5000); // Poll every 5 seconds + } + catch (Exception ex) + { + // Log error but continue monitoring + Console.WriteLine($"Error monitoring Breez payments: {ex.Message}"); + await Task.Delay(10000); // Wait longer on error + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Breez payment monitoring stopped: {ex.Message}"); + } + } + + public void AddPaymentNotification(Payment payment) + { + if (TryMarkPaymentSeen(payment) && + IsKnownPayment(payment)) + { + DebugLogObject("AddPaymentNotification:payment", payment); + LogCompletedPayment(payment); + _paymentNotifications.Enqueue(payment); + } + } + + public async Task<(LightningInvoice Invoice, long FeeSats)> CreateInvoiceWithFee(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = default) + { + var description = createInvoiceRequest.Description ?? createInvoiceRequest.DescriptionHash?.ToString() ?? "Invoice"; + var amountSats = (ulong)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi); + var paymentMethod = new ReceivePaymentMethod.Bolt11Invoice(description, amountSats); + var response = await _sdk.ReceivePayment(new ReceivePaymentRequest(paymentMethod)); + var feeSats = (long)response.fee; + var invoice = FromReceivePaymentResponse(response, createInvoiceRequest.Amount); + return (invoice, feeSats); + } + + private async Task GetInvoiceInternal(string identifier, CancellationToken cancellation) + { + var payment = await FindPayment(identifier, cancellation); + if (payment is null) + return null; + + // Deduplicate completed payments so LightningListener doesn't try to add the same payment twice. + if (payment.status == PaymentStatus.Completed && !TryMarkPaymentSeen(payment)) + { + return null; + } + + return FromPayment(payment); + } + + private async Task FindPayment(string identifier, CancellationToken cancellation) + { + try + { + var byId = await _sdk.GetPayment(new GetPaymentRequest(identifier)); + DebugLogObject("FindPayment:GetPayment", byId); + if (byId?.payment != null && IsKnownPayment(byId.payment)) + { + return byId.payment; + } + } + catch + { + // Ignore and fallback to listing payments + } + + try + { + var list = await _sdk.ListPayments(new ListPaymentsRequest( + typeFilter: new List { PaymentType.Receive }, + assetFilter: new AssetFilter.Bitcoin() + )); + DebugLogObject("FindPayment:ListPayments", list); + + return list.payments.FirstOrDefault(p => + { + if (p.details is PaymentDetails.Lightning lightning) + { + if (!IsKnownPayment(p)) + return false; + + return lightning.paymentHash == identifier || + lightning.invoice == identifier; + } + + return p.id == identifier; + }); + } + catch + { + return null; + } + } + + private void LogCompletedPayment(Payment payment) + { + try + { + string paymentHash = ExtractPaymentHash(payment); + string bolt11 = null; + LightMoney? boltAmount = null; + if (payment.details is PaymentDetails.Lightning ln) + { + bolt11 = ln.invoice; + if (!string.IsNullOrEmpty(bolt11) && + BOLT11PaymentRequest.TryParse(bolt11, out var pr, _network)) + { + boltAmount = pr.MinimumAmount; + } + } + + var record = LookupInvoice(bolt11, paymentHash); + var recAmount = record?.Amount.ToUnit(LightMoneyUnit.Satoshi); + var rawAmount = payment.amount; + var fee = payment.fees; + var grossSat = InferAmountFromPayment(payment).ToUnit(LightMoneyUnit.Satoshi) + + GetFeeFromPayment(payment).ToUnit(LightMoneyUnit.Satoshi); + var boltSat = boltAmount?.ToUnit(LightMoneyUnit.Satoshi); + } + catch + { + // best-effort logging + } + } + + private string Shorten(string? s, int head = 6, int tail = 6) + { + if (string.IsNullOrEmpty(s)) + return string.Empty; + if (s.Length <= head + tail + 3) + return s; + return $"{s.Substring(0, head)}...{s.Substring(s.Length - tail)}"; + } +} + +public class NormalizedPayment +{ + public string Id { get; set; } = string.Empty; + public PaymentType PaymentType { get; set; } + public PaymentStatus Status { get; set; } + public ulong Timestamp { get; set; } + public LightMoney Amount { get; set; } = LightMoney.Zero; + public LightMoney Fee { get; set; } = LightMoney.Zero; + public string? Description { get; set; } +} diff --git a/BTCPayServer.Plugins.Breez/BreezLightningConnectionStringHandler.cs b/BTCPayServer.Plugins.Breez/BreezLightningConnectionStringHandler.cs new file mode 100644 index 0000000..1596394 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezLightningConnectionStringHandler.cs @@ -0,0 +1,33 @@ +using BTCPayServer.Lightning; +using NBitcoin; + +namespace BTCPayServer.Plugins.Breez; + +public class BreezLightningConnectionStringHandler : ILightningConnectionStringHandler +{ + private readonly BreezService _breezService; + + public BreezLightningConnectionStringHandler(BreezService breezService) + { + _breezService = breezService; + } + public ILightningClient? Create(string connectionString, Network network, out string? error) + { + var kv = LightningConnectionStringHelper.ExtractValues(connectionString, out var type); + if (type != "breez") + { + error = null; + return null; + } + + + if (!kv.TryGetValue("key", out var key)) + { + error = $"The key 'key' is mandatory for breez connection strings"; + return null; + } + + error = null; + return _breezService.GetClientByPaymentKey(key); + } +} diff --git a/BTCPayServer.Plugins.Breez/BreezPaymentMethodHandler.cs b/BTCPayServer.Plugins.Breez/BreezPaymentMethodHandler.cs new file mode 100644 index 0000000..30cb3e3 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezPaymentMethodHandler.cs @@ -0,0 +1,162 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.Breez +{ + public class BreezPaymentMethodConfig : LightningPaymentMethodConfig + { + public string PaymentKey { get; set; } = string.Empty; + public string StoreId { get; set; } = string.Empty; + } + + public class BreezPaymentMethodHandler : IPaymentMethodHandler, ILightningPaymentHandler + { + private readonly BreezService _breezService; + private readonly PaymentMethodId _paymentMethodId; + private readonly BTCPayNetwork _network; + private readonly LightningClientFactoryService _lightningClientFactory; + private readonly IOptions _lightningNetworkOptions; + public JsonSerializer Serializer { get; } + + public BreezPaymentMethodHandler( + BreezService breezService, + BTCPayNetwork network, + LightningClientFactoryService lightningClientFactory, + IOptions lightningNetworkOptions) + { + _breezService = breezService; + _network = network; + _lightningClientFactory = lightningClientFactory; + _lightningNetworkOptions = lightningNetworkOptions; + _paymentMethodId = PaymentMethodId.Parse("BTC-Breez"); + Serializer = BlobSerializer.CreateSerializer(network.NBitcoinNetwork).Serializer; + } + + public PaymentMethodId PaymentMethodId => _paymentMethodId; + + public BTCPayNetwork Network => _network; + + public Task BeforeFetchingRates(PaymentMethodContext context) + { + context.Prompt.Currency = _network.CryptoCode; + context.Prompt.PaymentMethodFee = 0m; + context.Prompt.Divisibility = 11; + context.Prompt.RateDivisibility = 8; + return Task.CompletedTask; + } + + public async Task ConfigurePrompt(PaymentMethodContext context) + { + if (context.InvoiceEntity.Type == InvoiceType.TopUp) + { + throw new PaymentMethodUnavailableException("Breez Lightning Network payment method is not available for top-up invoices"); + } + + var paymentPrompt = context.Prompt; + var storeBlob = context.StoreBlob; + var store = context.Store; + + // Parse Breez-specific config + var breezConfig = ParsePaymentMethodConfig(context.PaymentMethodConfig); + if (breezConfig == null || string.IsNullOrEmpty(breezConfig.PaymentKey)) + { + throw new PaymentMethodUnavailableException("Breez payment key is not configured"); + } + + // Get Breez client + var breezClient = _breezService.GetClient(breezConfig.StoreId); + if (breezClient == null) + { + throw new PaymentMethodUnavailableException("Breez client is not available for this store"); + } + + var invoice = context.InvoiceEntity; + decimal due = paymentPrompt.Calculate().Due; + var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; + if (expiry < TimeSpan.Zero) + expiry = TimeSpan.FromSeconds(1); + + LightningInvoice lightningInvoice; + string description = storeBlob.LightningDescriptionTemplate; + description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{ItemDescription}", invoice.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", invoice.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + + try + { + var request = new CreateInvoiceParams( + new LightMoney(due, LightMoneyUnit.BTC), + description, + expiry); + request.PrivateRouteHints = storeBlob.LightningPrivateRouteHints; + + lightningInvoice = await breezClient.CreateInvoice(request, CancellationToken.None); + } + catch (Exception ex) + { + throw new PaymentMethodUnavailableException($"Impossible to create Breez lightning invoice ({ex.Message})", ex); + } + + paymentPrompt.Destination = lightningInvoice.BOLT11; + var details = new LigthningPaymentPromptDetails + { + PaymentHash = lightningInvoice.GetPaymentHash(_network.NBitcoinNetwork), + Preimage = string.IsNullOrEmpty(lightningInvoice.Preimage) ? null : uint256.Parse(lightningInvoice.Preimage), + InvoiceId = lightningInvoice.Id, + NodeInfo = "Breez Lightning Wallet" + }; + paymentPrompt.Details = JObject.FromObject(details, Serializer); + } + + public BreezPaymentMethodConfig ParsePaymentMethodConfig(JToken config) + { + return config.ToObject(Serializer) ?? new BreezPaymentMethodConfig(); + } + + object IPaymentMethodHandler.ParsePaymentMethodConfig(JToken config) + { + return ParsePaymentMethodConfig(config); + } + + public Task CreateLightningClient(LightningPaymentMethodConfig config) + { + var breezConfig = config as BreezPaymentMethodConfig; + if (breezConfig == null || string.IsNullOrEmpty(breezConfig.StoreId)) + return Task.FromResult(null); + + return Task.FromResult(_breezService.GetClient(breezConfig.StoreId)); + } + + public object ParsePaymentPromptDetails(JToken details) + { + return details.ToObject(Serializer); + } + + public LightningPaymentData ParsePaymentDetails(JToken details) + { + return details.ToObject(Serializer); + } + + object IPaymentMethodHandler.ParsePaymentDetails(JToken details) + { + return ParsePaymentDetails(details); + } + } +} diff --git a/BTCPayServer.Plugins.Breez/BreezPlugin.cs b/BTCPayServer.Plugins.Breez/BreezPlugin.cs new file mode 100644 index 0000000..719c69c --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezPlugin.cs @@ -0,0 +1,58 @@ +#nullable enable +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Abstractions.Services; +using BTCPayServer.Configuration; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NBitcoin; +using System; + +namespace BTCPayServer.Plugins.Breez +{ + public class BreezPlugin : BaseBTCPayServerPlugin + { + public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = + { + new() { Identifier = nameof(BTCPayServer), Condition = ">=2.2.0" } + }; + + public override void Execute(IServiceCollection applicationBuilder) + { + applicationBuilder.AddSingleton(); + applicationBuilder.AddSingleton(provider => provider.GetRequiredService()); + applicationBuilder.AddSingleton(); + applicationBuilder.AddSingleton(provider => provider.GetRequiredService()); + + // Register the Breez payment method handler + applicationBuilder.AddSingleton(provider => + { + var breezService = provider.GetRequiredService(); + var networkProvider = provider.GetRequiredService(); + var lightningClientFactory = provider.GetRequiredService(); + var lightningNetworkOptions = provider.GetRequiredService>(); + + return new BreezPaymentMethodHandler( + breezService, + networkProvider.GetNetwork("BTC"), + lightningClientFactory, + lightningNetworkOptions); + }); + + // Add UI extensions for lightning setup tab (like Boltz does) + applicationBuilder.AddSingleton(new UIExtension("Breez/LNPaymentMethodSetupTab", + "ln-payment-method-setup-tab")); + applicationBuilder.AddSingleton(new UIExtension("Breez/LNPaymentMethodSetupTabhead", + "ln-payment-method-setup-tabhead")); + + // Surface Breez navigation inside the store integrations nav, matching the plugin template pattern. + applicationBuilder.AddUIExtension("store-integrations-nav", "Breez/BreezNav"); + + base.Execute(applicationBuilder); + } + } +} diff --git a/BTCPayServer.Plugins.Breez/BreezService.cs b/BTCPayServer.Plugins.Breez/BreezService.cs new file mode 100644 index 0000000..4ff8584 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezService.cs @@ -0,0 +1,181 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NBitcoin; + +namespace BTCPayServer.Plugins.Breez; + +public class BreezService:EventHostedServiceBase +{ + private readonly StoreRepository _storeRepository; + private readonly IOptions _dataDirectories; + private readonly IServiceProvider _serviceProvider; + private PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary => _serviceProvider.GetRequiredService(); + private readonly ILogger _logger; + private Dictionary _settings = new(); + private Dictionary _clients = new(); + + public BreezService( + EventAggregator eventAggregator, + StoreRepository storeRepository, + IOptions dataDirectories, + IServiceProvider serviceProvider, + ILogger logger) : base(eventAggregator, logger) + { + _storeRepository = storeRepository; + _dataDirectories = dataDirectories; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override void SubscribeToEvents() + { + base.SubscribeToEvents(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + await base.ProcessEvent(evt, cancellationToken); + } + + public string GetWorkDir(string storeId) + { + var dir = _dataDirectories.Value.DataDir; + return Path.Combine(dir, "Plugins", "Breez",storeId); + } + + TaskCompletionSource tcs = new(); + public override async Task StartAsync(CancellationToken cancellationToken) + { + _settings = (await _storeRepository.GetSettingsAsync("Breez")).Where(pair => pair.Value is not null).ToDictionary(pair => pair.Key, pair => pair.Value!); + foreach (var keyValuePair in _settings) + { + try + { + + await Handle(keyValuePair.Key, keyValuePair.Value); + } + catch + { + } + } + tcs.TrySetResult(); + await base.StartAsync(cancellationToken); + } + + public async Task Get(string storeId) + { + await tcs.Task; + _settings.TryGetValue(storeId, out var settings); + + return settings; + } + + public async Task Handle(string? storeId, BreezSettings? settings) + { + if (settings is null) + { + if (storeId is not null && _clients.Remove(storeId, out var client)) + { + client.Dispose(); + } + } + else + { + try + { + var network = Network.Main; + var dir = GetWorkDir(storeId); + Directory.CreateDirectory(dir); + settings.PaymentKey ??= Guid.NewGuid().ToString(); + + var client = await BreezLightningClient.Create( + settings.ApiKey, + dir, + network, + new Mnemonic(settings.Mnemonic), + settings.PaymentKey + ); + + if (storeId is not null) + { + _clients.AddOrReplace(storeId, client); + } + + return client; + } + catch (Exception e) + { + _logger.LogError(e, "Could not create Breez client"); + throw; + } + } + + return null; + } + + public async Task Set(string storeId, BreezSettings? settings) + { + + var result = await Handle(storeId, settings); + await _storeRepository.UpdateSetting(storeId, "Breez", settings!); + if (settings is null) + { + _settings.Remove(storeId, out var oldSettings ); + var data = await _storeRepository.FindStore(storeId); + if (data != null) + { + var pmi = new PaymentMethodId("BTC-LN"); + // In v2.2.1, the payment methods are handled differently + // We'll skip this for now as it needs to be refactored completely + // TODO: Implement proper v2.2.1 payment method handling + } + Directory.Delete(GetWorkDir(storeId), true); + + } + else if(result is not null ) + { + _settings.AddOrReplace(storeId, settings); + } + + + } + + public new async Task StopAsync(CancellationToken cancellationToken) + { + _clients.Values.ToList().ForEach(c => c.Dispose()); + await base.StopAsync(cancellationToken); + } + + public BreezLightningClient? GetClient(string? storeId) + { + + tcs.Task.GetAwaiter().GetResult(); + if(storeId is null) + return null; + _clients.TryGetValue(storeId, out var client); + return client; + } + public BreezLightningClient? GetClientByPaymentKey(string? paymentKey) + { + tcs.Task.GetAwaiter().GetResult(); + if(paymentKey is null) + return null; + var match = _settings.FirstOrDefault(pair => pair.Value.PaymentKey == paymentKey).Key; + return GetClient(match); + } +} \ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/BreezSettings.cs b/BTCPayServer.Plugins.Breez/BreezSettings.cs new file mode 100644 index 0000000..41d8322 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/BreezSettings.cs @@ -0,0 +1,14 @@ +#nullable enable +using System; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Breez; + +public class BreezSettings +{ + public string? Mnemonic { get; set; } + public string? ApiKey { get; set; } + + public string PaymentKey { get; set; } = Guid.NewGuid().ToString(); +} diff --git a/BTCPayServer.Plugins.Breez/InvoiceRecord.cs b/BTCPayServer.Plugins.Breez/InvoiceRecord.cs new file mode 100644 index 0000000..32a2cba --- /dev/null +++ b/BTCPayServer.Plugins.Breez/InvoiceRecord.cs @@ -0,0 +1,10 @@ +using BTCPayServer.Lightning; + +namespace BTCPayServer.Plugins.Breez; + +public class InvoiceRecord +{ + public string PaymentHash { get; set; } = string.Empty; + public string Bolt11 { get; set; } = string.Empty; + public LightMoney Amount { get; set; } = LightMoney.Zero; +} diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Configure.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Configure.cshtml new file mode 100644 index 0000000..45bce18 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Configure.cshtml @@ -0,0 +1,44 @@ +@using BTCPayServer +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Plugins.Breez +@model BTCPayServer.Plugins.Breez.BreezSettings? +@inject BreezService BreezService +@{ + ViewData.SetActivePage("Breez", "Configure", "Configure"); + var storeId = Context.GetCurrentStoreId(); + var active = (await BreezService.Get(storeId)) is not null; +} +
+
+
+
+

+ @ViewData["Title"] +

+
+ + @if (active) + { + + } +
+
+ +
+ + + + A Bitcoin 12-word mnemonic seed phrase.BACK THIS UP SAFELY! GENERATE IT RANDOMLY! SERVER ADMINS HAVE ACCESS TO THIS! + +
+
+ + + + Optional. Leave blank to use the default Breez API key. +
+ + +
+
+
diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Info.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Info.cshtml new file mode 100644 index 0000000..1d905e7 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Info.cshtml @@ -0,0 +1,106 @@ +@using Breez.Sdk.Spark +@using BTCPayServer +@using BTCPayServer.Client +@using BTCPayServer.Components.QRCode +@using BTCPayServer.Components.TruncateCenter +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Payments +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Services +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using NBitcoin +@inject BreezService BreezService +@inject TransactionLinkProviders TransactionLinkProviders +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@{ + ViewData.SetActivePage("Breez", "Info", "Info"); + string storeId = Model switch + { + string s => s, + StoreDashboardViewModel dashboardModel => dashboardModel.StoreId, + _ => Context.GetImplicitStoreId() + }; + var sdk = BreezService.GetClient(storeId)?.Sdk; + if (sdk is null) + return; +} + +
+
+

Breez Lightning Node Information

+ + @try + { + if (sdk != null) + { +
+
+
Node Status
+
+
+

+ Breez Lightning Node is connected and operational. + This is a simplified view for SDK v0.4.1 compatibility. +

+ +
+
+
+
Status
+
+ Connected +
+ +
Network
+
Bitcoin
+ +
Type
+
Breez SDK v0.4.1
+
+
+
+
+
Service
+
Lightning Network
+ +
Integration
+
BTCPay Server
+
+
+
+
+
+ +
+
+
Quick Actions
+
+ +
+ } + } + catch (Exception ex) + { +
+
Node Information Unavailable
+

Unable to fetch Breez node information: @ex.Message

+

This may be due to SDK v0.4.1 compatibility changes.

+
+ } +
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Logs.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Logs.cshtml new file mode 100644 index 0000000..3c637c1 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Logs.cshtml @@ -0,0 +1,46 @@ +@using BTCPayServer +@model System.Collections.Concurrent.ConcurrentQueue<(DateTimeOffset timestamp, string log)> +@{ + var storeId = Context.GetCurrentStoreId(); + + ViewData.SetActivePage("Breez", "Logs", "Logs"); +} + +
+
+ @if (!Model.Any()) + { +

+ There are no recent logs. +

+ } + else + { +
+ + + + + + + + + @foreach (var log in Model) + { + + + + + + + } + +
TimestampLog
+ @log.timestamp.ToTimeAgo() + + @log.log +
+
+ } +
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Payments.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Payments.cshtml new file mode 100644 index 0000000..4029a79 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Payments.cshtml @@ -0,0 +1,49 @@ +@using BTCPayServer.Components +@using BTCPayServer +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Client +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Components.QRCode +@using BTCPayServer.Components.TruncateCenter +@model BTCPayServer.Plugins.Breez.PaymentsViewModel +@{ + var storeId = Context.GetCurrentStoreId(); + + ViewData.SetActivePage("Breez", "Payments", "Payments"); + TempData.TryGetValue("bolt11", out var bolt11); +} + +
+
+
+

+ @ViewData["Title"] +

+
+ + Send + Receive +
+
+ + @if (bolt11 is string bolt11s) + { +
+
+ +
+
+
+ + +
+ + +
+
+ } + + + +
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Receive.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Receive.cshtml new file mode 100644 index 0000000..5f3a198 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Receive.cshtml @@ -0,0 +1,64 @@ +@using BTCPayServer.Lightning +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@inject BreezService BreezService + +@{ + ViewData.SetActivePage("Breez", "Receive", "Receive"); + var storeId = Model switch + { + string s => s, + StoreDashboardViewModel dashboardModel => dashboardModel.StoreId, + _ => Context.GetImplicitStoreId() + }; + var sdk = BreezService.GetClient(storeId)?.Sdk; + if (sdk is null) + return; + + var nodeInfo = new Breez.Sdk.Spark.GetInfoRequest(ensureSynced: false); + Breez.Sdk.Spark.GetInfoResponse? infoResponse = null; + try + { + infoResponse = await sdk.GetInfo(nodeInfo); + } + catch (Exception ex) + { + + return; + } + + var max = LightMoney.Satoshis((long)(infoResponse?.balanceSats ?? 0)); + +} + + +
+
+
+
+

+ @ViewData["Title"] +

+
+ + +
+
+
+
+ + + Maximum receivable: @max.ToUnit(LightMoneyUnit.Satoshi) sats +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Send.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Send.cshtml new file mode 100644 index 0000000..304d9a5 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Send.cshtml @@ -0,0 +1,100 @@ +@using System.Numerics +@using BTCPayServer.Lightning +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@inject BreezService BreezService + +@{ + ViewData.SetActivePage("Breez", "Send", "Send"); + var storeId = Model switch + { + string s => s, + StoreDashboardViewModel dashboardModel => dashboardModel.StoreId, + _ => Context.GetImplicitStoreId() + }; + var sdk = BreezService.GetClient(storeId)?.Sdk; + if (sdk is null) + return; + + var nodeInfo = new Breez.Sdk.Spark.GetInfoRequest(ensureSynced: false); + Breez.Sdk.Spark.GetInfoResponse? infoResponse = null; + try + { + infoResponse = await sdk.GetInfo(nodeInfo); + } + catch (Exception ex) + { + + return; + } + + var max = LightMoney.Satoshis((long)(infoResponse?.balanceSats ?? 0)).ToUnit(LightMoneyUnit.Satoshi); + +} + + +@if (ViewData["PaymentDetails"] is PaymentDetailsViewModel paymentDetails) +{ +
+

Payment Details

+
+
+

Destination: @paymentDetails.Destination

+

Amount: @paymentDetails.Amount sats

+
+
+

Fee: @paymentDetails.Fee sats

+

Total: @(paymentDetails.Amount + paymentDetails.Fee) sats

+
+
+
+ + + + + Cancel +
+
+} +else +{ +
+
+
+
+

+ @ViewData["Title"] +

+
+ +
+
+
+
+ + + Enter a Lightning bolt11 invoice or a Bitcoin address +
+
+ + + Maximum payable: @max sats +
+
+
+
+} + +@functions { + public class PaymentDetailsViewModel + { + public string Destination { get; set; } + public long Amount { get; set; } + public long Fee { get; set; } + public string PrepareResponseJson { get; set; } + } +} \ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/SwapIn.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/SwapIn.cshtml new file mode 100644 index 0000000..7dc44e9 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/SwapIn.cshtml @@ -0,0 +1,212 @@ +@using Breez.Sdk.Spark +@using BTCPayServer +@using BTCPayServer.Client +@using BTCPayServer.Components.QRCode +@using BTCPayServer.Components.TruncateCenter +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Payments +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Services +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using NBitcoin +@inject BreezService BreezService +@inject TransactionLinkProviders TransactionLinkProviders +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@inject BTCPayNetworkProvider BTCPayNetworkProvider +@{ + ViewData.SetActivePage("Breez", "Swap In", "SwapIn"); + var pmi = PaymentMethodId.Parse("BTC-OnChain"); + string storeId = Model switch + { + string s => s, + StoreDashboardViewModel dashboardModel => dashboardModel.StoreId, + _ => Context.GetImplicitStoreId() + }; + var sdk = BreezService.GetClient(storeId)?.Sdk; + if (sdk is null) + return; + + // Get current receive payment swap info (if any) + SwapInfo? currentSwap = null; + try + { + // Simplified logic - skip complex payment lookup for now + Payment? pendingReceive = null; + + // Skip pending receive logic for SDK v0.4.1 compatibility + + // If no pending swap, create a new one + if (currentSwap == null) + { + var request = new ReceivePaymentRequest( + paymentMethod: new ReceivePaymentMethod.BitcoinAddress() + ); + var response = await sdk.ReceivePayment(request: request); + + // Create swap info from response + currentSwap = new SwapInfo + { + bitcoinAddress = response.paymentRequest, + minAllowedDeposit = 1000, // Default minimum + maxAllowedDeposit = 16777215, // Default maximum (~0.16 BTC) + status = "Created" + }; + } + } + catch (Exception ex) + { + + return; + } + + // Get refundable deposits using the new SDK pattern + // TODO: Fix for v2.2.1 - DepositInfo structure needs to be updated + // List? refundables = null; + // try + // { + // var request = new ListUnclaimedDepositsRequest(); + // var response = await sdk.ListUnclaimedDeposits(request); + // refundables = response.deposits.ToList(); + // } + // catch (Exception ex) + // { + // + // } + + @* TODO: Fix for v2.2.1 - derivation settings check needed *@ + + // NodeState API has changed in SDK v0.4.1 - using GetInfo instead + var nodeInfo = new Breez.Sdk.Spark.GetInfoRequest(ensureSynced: false); + Breez.Sdk.Spark.GetInfoResponse? ni = null; + try + { + ni = await sdk.GetInfo(nodeInfo); + } + catch (Exception ex) + { + + } + + // Use standard fee rates since RecommendedFees API may have changed + var hasFeeRates = true; // Always show fee options + + // Get explorer URL for transaction links + var network = BTCPayNetworkProvider.GetNetwork("BTC"); + var explorerUrl = network.BlockExplorerLink?.ToString() ?? "#"; +} + +@if (hasFeeRates) +{ + + + + + + + +} + +
+
+ + @if (currentSwap != null) + { +
+
+ +
+
+
+ + +
+
+ Please send an amount between
@Money.Satoshis(currentSwap.minAllowedDeposit).ToDecimal(MoneyUnit.BTC) and @Money.Satoshis(currentSwap.maxAllowedDeposit).ToDecimal(MoneyUnit.BTC)BTC
+ @* TODO: Fix for v2.2.1 - derivation settings check needed *@ + @* + @if (derivationSettings != null) + { + + } + *@ +
+
+
+ } + + @* + @if (refundables?.Any() is true) + { +
+ + + + + + + + + + + + @foreach (var deposit in refundables) + { + + + + + + + + } + +
Deposit TxAmountSwap TxRefundableActions
+ @{ + var txLink = TransactionLinkProviders.GetTransactionLink(deposit.txId, "BTC"); + } + + + + @deposit.amount + @if (!string.IsNullOrEmpty(deposit.swapTxId)) + { + var swapTxLink = TransactionLinkProviders.GetTransactionLink(deposit.swapTxId, "BTC"); + + + + } + else + { + N/A + } + + @if (deposit.refundable) + { + Yes + } + else + { + No + } + +
+ +
+
+
+ } + *@ +
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/SwapInRefund.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/SwapInRefund.cshtml new file mode 100644 index 0000000..6ad7a46 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/SwapInRefund.cshtml @@ -0,0 +1,73 @@ +@using BTCPayServer +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using Microsoft.AspNetCore.Routing +@model string +@inject BreezService BreezService +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@* @inject BTCPayNetworkProvider BTCPayNetworkProvider *@ +@{ + var storeId = Context.GetImplicitStoreId(); + var address = Context.GetRouteValue("address").ToString(); + ViewData.SetActivePage("Breez", "Create Swapin Refund", "SwapIn"); + + @* TODO: Fix for v2.2.1 - derivation settings check needed *@ + var sdk = BreezService.GetClient(storeId)?.Sdk; + // Use standard fee rates since RecommendedFees API may have changed + var standardFees = new { fastestFee = 10, halfHourFee = 5, hourFee = 3, economyFee = 2, minimumFee = 1 }; +} + + + + + + + + + +@* + @if (derivationSettings is not null) + { + + } +*@ + + +
+
+
+
+

+ @ViewData["Title"] + + + +

+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Choose from preset fee rates or enter custom value +
+ +
+
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/SwapOut.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/SwapOut.cshtml new file mode 100644 index 0000000..6ee9fa1 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/SwapOut.cshtml @@ -0,0 +1,83 @@ +@using Breez.Sdk.Spark +@using BTCPayServer +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@inject BreezService BreezService +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@* @inject BTCPayNetworkProvider BTCPayNetworkProvider *@ +@{ + Layout = "_Layout"; + ViewData.SetActivePage("Breez", "Swap Out", "SwapOut"); + string storeId = null; + if (Model is string s) + { + storeId = s; + } + else if (Model is StoreDashboardViewModel dashboardModel) + { + storeId = dashboardModel.StoreId; + } + else + { + storeId = Context.GetImplicitStoreId(); + } + + // Simplified fee structure for SDK v0.4.1 compatibility + var fastFee = 10; + var slowFee = 5; + var minFee = 1; +} + + + + + + + + + + + @{ + @* TODO: Fix derivation settings for v2.2.1 - GetDerivationSchemeSettings method signature changed *@ + var hasStoreWallet = false; + if (hasStoreWallet) + { + + } + } + + +
+
+
+
+

+ @ViewData["Title"] +

+
+ +
+
+
+
+ + +
+
+ + + Choose from preset fee rates or enter custom value +
+
+ + +

Minimum: 1000 sats, Maximum: 10000000 sats

+
+ + +
+
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/Sweep.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/Sweep.cshtml new file mode 100644 index 0000000..64a319e --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Breez/Sweep.cshtml @@ -0,0 +1,79 @@ +@using BTCPayServer +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Services.Invoices +@using Microsoft.AspNetCore.Mvc.TagHelpers +@inject BreezService BreezService +@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary +@* @inject BTCPayNetworkProvider BTCPayNetworkProvider *@ +@{ + Layout = "_Layout"; + ViewData.SetActivePage("Breez", "Sweep", "Sweep"); + string storeId = null; + if (Model is string s) + { + storeId = s; + } + else if (Model is StoreDashboardViewModel dashboardModel) + { + storeId = dashboardModel.StoreId; + } + else + { + storeId = Context.GetImplicitStoreId(); + } + var sdk = BreezService.GetClient(storeId)?.Sdk; + if (sdk is null) + return; + + @* TODO: Fix for v2.2.1 - derivation settings check needed *@ + // Use standard fee rates since RecommendedFees API may have changed + var standardFees = new { fastestFee = 10, halfHourFee = 5, hourFee = 3, economyFee = 2, minimumFee = 1 }; +} + + + + + + + + + +@* + @if (derivationSettings is not null) + { + + } +*@ + + +
+
+
+
+

+ @ViewData["Title"] + + + +

+
+ +
+
+
+
+ + + Address to sweep funds to +
+
+ + + Choose from preset fee rates or enter custom value +
+ +
+
+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Breez/_Nav.cshtml b/BTCPayServer.Plugins.Breez/Views/Breez/_Nav.cshtml new file mode 100644 index 0000000..e69de29 diff --git a/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNav.cshtml b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNav.cshtml new file mode 100644 index 0000000..efdfbe7 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNav.cshtml @@ -0,0 +1,60 @@ +@using Breez.Sdk +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Client +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using Microsoft.AspNetCore.Mvc.TagHelpers +@inject IScopeProvider ScopeProvider +@inject BreezService BreezService +@{ + var storeId = Model switch + { + string s => s, + StoreDashboardViewModel dashboardModel => dashboardModel.StoreId, + _ => Context.GetImplicitStoreId() + }; + var active = @ViewData.IsActivePage("Breez"); + var client = string.IsNullOrEmpty(active) ? null : BreezService.GetClient(storeId); + var sdk = client?.Sdk; +} +@if (!string.IsNullOrEmpty(storeId)) +{ + + + @if (sdk is not null) + { + + + + @if (client.Events.Any()) + { + + } + } +} \ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNodeInfo.cshtml b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNodeInfo.cshtml new file mode 100644 index 0000000..f2059ac --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezNodeInfo.cshtml @@ -0,0 +1,90 @@ +@using BTCPayServer.Lightning +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@using BTCPayServer.Client +@inject BreezService BreezService +@{ + string storeId = null; + if (Model is string s) + { + storeId = s; + } + else if (Model is StoreDashboardViewModel dashboardModel) + { + storeId = dashboardModel.StoreId; + } + else + { + storeId = Context.GetImplicitStoreId(); + } + + // In SDK v0.4.1, NodeState API has changed and async calls can't be made from partial views + // This widget needs to be refactored to receive data from controller + bool isConfigured = false; + try + { + var client = BreezService.GetClient(storeId); + isConfigured = client?.Sdk != null; + } + catch + { + // Handle any errors gracefully + } +} + + +
+ @if (Model is StoreDashboardViewModel) + { +
+

Breez Node

+ + Manage + + +
+ } + + @if (isConfigured) + { +
+ +
Breez Spark (nodeless)
+
+ +
+
+
Lightning Balance
+ @if (Model is StoreDashboardViewModel) + { +
+ Swap In + Send +
+ } +
+
+ Dashboard widget updated for SDK v0.4.1 +
+
+ } + else + { +
+

Breez node information not available

+ @if (string.IsNullOrEmpty(storeId)) + { + Configure Breez + } +
+ } +
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezPaymentsTable.cshtml b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezPaymentsTable.cshtml new file mode 100644 index 0000000..a6ebb80 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/BreezPaymentsTable.cshtml @@ -0,0 +1,94 @@ +@using Breez.Sdk.Spark +@using BTCPayServer.Lightning +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Plugins.Breez +@using BTCPayServer.Security +@model List +@{ + var data = Model ?? new List(); + var storeId = Context.GetImplicitStoreId(); + if (data is null) + { + if (string.IsNullOrEmpty(storeId)) + return; + } + + var isDashboard = false; +} +@if (isDashboard) +{ + +} +
+ @if (isDashboard) + { +
+

Breez Payments

+ @if (data.Any()) + { + View All + } +
+ } + @if (!data.Any()) + { +

+ There are no recent payments. +

+ } + else + { +
+ + + + + + + + + + + + + + @foreach (var payment in data) + { + + + + + + + + + + + } + +
IdTimestampTypeAmountFeeStatusDescription
+ + @payment.Id + + + @DateTimeOffset.FromUnixTimeSeconds((long)payment.Timestamp).ToTimeAgo() ?? "Unknown" + + @(payment.PaymentType == PaymentType.Receive ? "receive" : "send") + + @payment.Amount.ToDecimal(LightMoneyUnit.BTC) BTC + + @payment.Fee.ToDecimal(LightMoneyUnit.BTC) BTC + + @payment.Status.ToString().ToLowerInvariant() + + @payment.Description +
+
+ } +
diff --git a/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTab.cshtml b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTab.cshtml new file mode 100644 index 0000000..aad4056 --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTab.cshtml @@ -0,0 +1,90 @@ +@model BTCPayServer.Models.StoreViewModels.LightningNodeViewModel +@inject BreezService BreezService +@{ + var storeId = Model.StoreId; + if (Model.CryptoCode != "BTC") + { + return; + } + + // Try to get existing Breez client to extract the payment key + var breezClient = BreezService.GetClient(Model.StoreId); + string paymentKey = ""; + + if (breezClient != null) + { + // Extract payment key from the existing client connection string + var connStr = breezClient.ToString(); + if (connStr.Contains("key=")) + { + var keyStart = connStr.IndexOf("key=") + 4; + var keyEnd = connStr.IndexOf(";", keyStart); + if (keyEnd == -1) keyEnd = connStr.Length; + paymentKey = connStr.Substring(keyStart, keyEnd - keyStart); + } + } +} + + + + + +
+

You can use Breez to accept lightning payments without running a traditional Lightning node.

+

Breez is a mobile-first Lightning Network client that provides a non-custodial solution for Lightning payments.

+
\ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTabhead.cshtml b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTabhead.cshtml new file mode 100644 index 0000000..e7b8cdb --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/Shared/Breez/LNPaymentMethodSetupTabhead.cshtml @@ -0,0 +1,38 @@ +@inject BreezService BreezService; +@using BTCPayServer.Client +@using BTCPayServer.Plugins.Breez +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model BTCPayServer.Models.StoreViewModels.LightningNodeViewModel + +@if (Model.CryptoCode != "BTC") +{ + return; +} + +@{ + var breezClient = BreezService.GetClient(Model.StoreId); +} + +@if (breezClient == null) +{ + + + +} +else +{ + + + +} \ No newline at end of file diff --git a/BTCPayServer.Plugins.Breez/Views/_ViewImports.cshtml b/BTCPayServer.Plugins.Breez/Views/_ViewImports.cshtml new file mode 100644 index 0000000..b1fa7fb --- /dev/null +++ b/BTCPayServer.Plugins.Breez/Views/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Plugins.Breez +@using Breez.Sdk.Spark +@inject BTCPayServer.Abstractions.Services.Safe Safe +@addTagHelper *, BTCPayServer.Abstractions +@addTagHelper *, BTCPayServer.TagHelpers +@addTagHelper *, BTCPayServer.Views.TagHelpers +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer \ No newline at end of file