fixed send, full transaction list

This commit is contained in:
2025-12-12 08:14:15 +01:00
parent 2947555ef2
commit c8da1cf331
8 changed files with 149 additions and 130 deletions

View File

@@ -5,14 +5,14 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;CS0618</NoWarn>
<NoWarn>$(NoWarn);CS1591;CS0618;CS8073</NoWarn>
</PropertyGroup>
<!-- Plugin specific properties -->
<PropertyGroup>
<Product>BreezSpark Lightning Plugin</Product>
<Description>Nodeless Lightning payments powered by Breez Spark SDK</Description>
<Version>1.1.0</Version>
<Description>Nodeless Lightning payments powered by Breez Nodeless SDK (Spark)</Description>
<Version>0.0.4</Version>
<Author>Aljaz Ceru</Author>
<Company>Aljaz Ceru</Company>
<AssemblyName>BTCPayServer.Plugins.BreezSpark</AssemblyName>
@@ -40,6 +40,11 @@
<EmbeddedResource Include="Resources\**" />
<ProjectReference Include="../btcpayserver/BTCPayServer/BTCPayServer.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="csharp\**\*.cs" />
<EmbeddedResource Remove="csharp\**\*" />
<None Include="csharp\**\*" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Breez.Sdk.Spark" Version="0.4.1" />
<PackageReference Include="System.Numerics.Vectors" Version="4.5.0" />

View File

@@ -18,6 +18,7 @@ using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
@@ -32,18 +33,22 @@ public class BreezSparkController : Controller
private readonly BreezSparkService _breezService;
private readonly BTCPayWalletProvider _btcWalletProvider;
private readonly StoreRepository _storeRepository;
private readonly ILogger<BreezSparkController> _logger;
public BreezSparkController(
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
BTCPayNetworkProvider btcPayNetworkProvider,
BreezSparkService breezService,
BTCPayWalletProvider btcWalletProvider, StoreRepository storeRepository)
BTCPayWalletProvider btcWalletProvider,
StoreRepository storeRepository,
ILogger<BreezSparkController> logger)
{
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
_btcPayNetworkProvider = btcPayNetworkProvider;
_breezService = breezService;
_btcWalletProvider = btcWalletProvider;
_storeRepository = storeRepository;
_logger = logger;
}
@@ -176,7 +181,7 @@ public class BreezSparkController : Controller
TempData["bolt11"] = response.paymentRequest;
TempData[WellKnownTempData.SuccessMessage] = "Invoice created successfully!";
return RedirectToAction(nameof(Payments), new {storeId});
return RedirectToAction(nameof(Transactions), new {storeId});
}
catch (Exception ex)
{
@@ -203,11 +208,7 @@ public class BreezSparkController : Controller
return RedirectToAction(nameof(Send), new {storeId});
}
BigInteger? amountSats = null;
if (amount > 0)
{
amountSats = new BigInteger(amount.Value);
}
var amountSats = ResolveAmountSats(address, amount);
var prepareRequest = new PrepareSendPaymentRequest(
paymentRequest: address,
@@ -219,38 +220,30 @@ public class BreezSparkController : Controller
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;
var amt = amountSats ?? BigInteger.Zero;
ViewData["PaymentDetails"] = new PaymentDetailsDto(
Destination: address,
Amount: (long)amt,
Fee: (long)totalFee
);
}
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;
ViewData["PaymentDetails"] = new PaymentDetailsDto(
Destination: address,
Amount: (long)BigInteger.Abs(amountSats ?? BigInteger.Zero),
Fee: (long)mediumFee
);
}
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;
ViewData["PaymentDetails"] = new PaymentDetailsDto(
Destination: address,
Amount: (long)BigInteger.Abs(amountSats ?? BigInteger.Zero),
Fee: (long)sparkMethod.fee
);
}
}
catch (Exception ex)
@@ -263,7 +256,7 @@ public class BreezSparkController : Controller
[HttpPost("confirm-send")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfirmSend(string storeId, string paymentRequest, long amount, string prepareResponseJson)
public async Task<IActionResult> ConfirmSend(string storeId, string paymentRequest, long amount)
{
var client = _breezService.GetClient(storeId);
if (client is null)
@@ -273,11 +266,12 @@ public class BreezSparkController : Controller
try
{
var prepareResponse = JsonSerializer.Deserialize<PrepareSendPaymentResponse>(prepareResponseJson);
if (prepareResponse == null)
{
throw new InvalidOperationException("Invalid payment preparation data");
}
// Re-run preparation to avoid polymorphic JSON deserialization issues
var amountSats = ResolveAmountSats(paymentRequest, amount);
var prepareResponse = await client.Sdk.PrepareSendPayment(new PrepareSendPaymentRequest(
paymentRequest: paymentRequest,
amount: amountSats
));
SendPaymentOptions? options = prepareResponse.paymentMethod switch
{
@@ -289,7 +283,8 @@ public class BreezSparkController : Controller
confirmationSpeed: OnchainConfirmationSpeed.Medium
),
SendPaymentMethod.SparkAddress => null,
_ => throw new NotSupportedException("Unsupported payment method")
SendPaymentMethod.SparkInvoice => null,
_ => null
};
var sendRequest = new SendPaymentRequest(
@@ -297,14 +292,18 @@ public class BreezSparkController : Controller
options: options
);
_logger.LogInformation("BreezSpark sending payment for store {StoreId} to {Destination}", storeId, paymentRequest);
var sendResponse = await client.Sdk.SendPayment(sendRequest);
_logger.LogInformation("BreezSpark send complete for store {StoreId}: payment id {PaymentId}, status {Status}",
storeId, sendResponse.payment?.id, sendResponse.payment?.status);
TempData[WellKnownTempData.SuccessMessage] = "Payment sent successfully!";
return RedirectToAction(nameof(Payments), new {storeId});
return RedirectToAction(nameof(Transactions), new {storeId});
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = $"Error sending payment: {ex.Message}";
_logger.LogError(ex, "BreezSpark send failed for store {StoreId}", storeId);
return RedirectToAction(nameof(Send), new {storeId});
}
}
@@ -484,9 +483,9 @@ public class BreezSparkController : Controller
return NotFound();
}
[Route("payments")]
[Route("transactions")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Payments(string storeId, PaymentsViewModel viewModel)
public async Task<IActionResult> Transactions(string storeId, PaymentsViewModel viewModel)
{
var client = _breezService.GetClient(storeId);
if (client is null)
@@ -495,6 +494,7 @@ public class BreezSparkController : Controller
}
viewModel ??= new PaymentsViewModel();
viewModel.Balance = await client.GetBalance();
var req = new ListPaymentsRequest(
typeFilter: null,
statusFilter: null,
@@ -506,18 +506,75 @@ public class BreezSparkController : Controller
sortAscending: false
);
var response = await client.Sdk.ListPayments(req);
viewModel.Payments = response.payments.Select(client.NormalizePayment).ToList();
var normalized = new List<NormalizedPayment>();
foreach (var p in response.payments.Where(p => p != null))
{
var norm = client.NormalizePayment(p);
if (norm is not null)
{
normalized.Add(norm);
continue;
}
return View(viewModel);
// Fallback: show raw SDK payment even if we lack invoice context
long amountSat = 0;
if (p.details is PaymentDetails.Lightning l && !string.IsNullOrEmpty(l.invoice))
{
var nbitcoinNetwork = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>("BTC")?.NBitcoinNetwork ?? NBitcoin.Network.Main;
if (BOLT11PaymentRequest.TryParse(l.invoice, out var pr, nbitcoinNetwork) && pr?.MinimumAmount is not null)
{
amountSat = (long)pr.MinimumAmount.ToUnit(LightMoneyUnit.Satoshi);
}
}
long feeSat = 0;
if (p.fees != null)
{
feeSat = (long)(p.fees / 1000);
}
normalized.Add(new NormalizedPayment
{
Id = p.id ?? Guid.NewGuid().ToString("N"),
PaymentType = p.paymentType,
Status = p.status,
Timestamp = p.timestamp,
Amount = LightMoney.Satoshis(amountSat),
Fee = LightMoney.Satoshis(feeSat),
Description = p.details?.ToString() ?? "BreezSpark payment"
});
}
viewModel.Payments = normalized;
return View("Transactions", viewModel);
}
private BigInteger? ResolveAmountSats(string paymentRequest, long? amount)
{
if (amount.HasValue && amount.Value > 0)
{
return new BigInteger(amount.Value);
}
// Try to derive amount from bolt11 invoice if present
var nbitcoinNetwork = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>("BTC")?.NBitcoinNetwork ?? NBitcoin.Network.Main;
if (BOLT11PaymentRequest.TryParse(paymentRequest, out var pr, nbitcoinNetwork) && pr?.MinimumAmount is not null)
{
return new BigInteger((long)pr.MinimumAmount.ToUnit(LightMoneyUnit.Satoshi));
}
return null;
}
}
public class PaymentsViewModel : BasePagingViewModel
{
public List<NormalizedPayment> Payments { get; set; } = new();
public LightningNodeBalance? Balance { get; set; }
public override int CurrentPageCount => Payments.Count;
}
public record PaymentDetailsDto(string Destination, long Amount, long Fee);
// Helper class for swap information display in views
public class SwapInfo
{

View File

@@ -215,10 +215,6 @@ public class BreezSparkLightningClient : ILightningClient, IDisposable
return new LightningNodeBalance()
{
OnchainBalance = new OnchainBalance()
{
Confirmed = Money.Satoshis((long)response.balanceSats)
},
OffchainBalance = new OffchainBalance()
{
Local = LightMoney.Satoshis((long)response.balanceSats),
@@ -230,10 +226,6 @@ public class BreezSparkLightningClient : ILightningClient, IDisposable
{
return new LightningNodeBalance()
{
OnchainBalance = new OnchainBalance()
{
Confirmed = Money.Zero
},
OffchainBalance = new OffchainBalance()
{
Local = LightMoney.Zero,

View File

@@ -9,6 +9,7 @@
@using BTCPayServer.Security
@using BTCPayServer.Services
@using BTCPayServer.Services.Invoices
@using BTCPayServer.Lightning
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using NBitcoin
@inject BreezSparkService BreezService
@@ -22,14 +23,17 @@
StoreDashboardViewModel dashboardModel => dashboardModel.StoreId,
_ => Context.GetImplicitStoreId()
};
var sdk = BreezService.GetClient(storeId)?.Sdk;
if (sdk is null)
var client = BreezService.GetClient(storeId);
var sdk = client?.Sdk;
if (client is null || sdk is null)
return;
LightningNodeBalance balance = await client.GetBalance();
}
<div class="row mb-4 mt-4">
<div class="col-12">
<h3>BreezSpark Lightning Node Information</h3>
<h3>BreezSpark Lightning Balance</h3>
@try
{
@@ -37,39 +41,13 @@
{
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Node Status</h5>
<h5 class="card-title mb-0">Balance</h5>
</div>
<div class="card-body">
<p class="text-muted">
BreezSpark Lightning Node is connected and operational.
This is a simplified view for SDK v0.4.1 compatibility.
</p>
<div class="row">
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-6">Status</dt>
<dd class="col-sm-6">
<span class="badge bg-success">Connected</span>
</dd>
<dt class="col-sm-6">Network</dt>
<dd class="col-sm-6">Bitcoin</dd>
<dt class="col-sm-6">Type</dt>
<dd class="col-sm-6">Breez Spark SDK v0.4.1</dd>
</dl>
</div>
<div class="col-md-6">
<dl class="row">
<dt class="col-sm-6">Service</dt>
<dd class="col-sm-6">Lightning Network</dd>
<dt class="col-sm-6">Integration</dt>
<dd class="col-sm-6">BTCPay Server</dd>
</dl>
</div>
</div>
<h3 class="mb-0">
@((balance?.OffchainBalance?.Local ?? LightMoney.Zero).ToDecimal(LightMoneyUnit.BTC)) BTC
</h3>
<p class="text-muted mb-0">Off-chain balance (Breez Spark)</p>
</div>
</div>
@@ -80,13 +58,13 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<a href="@Url.Action("SwapIn", "BreezSpark", new { storeId = storeId })" class="btn btn-primary w-100 mb-2">
<i class="bi bi-arrow-down-circle"></i> Swap In
<a href="@Url.Action("Receive", "BreezSpark", new { storeId = storeId })" class="btn btn-primary w-100 mb-2">
<i class="bi bi-arrow-down-circle"></i> Receive
</a>
</div>
<div class="col-md-6">
<a href="@Url.Action("SwapOut", "BreezSpark", new { storeId = storeId })" class="btn btn-success w-100 mb-2">
<i class="bi bi-arrow-up-circle"></i> Swap Out
<a href="@Url.Action("Send", "BreezSpark", new { storeId = storeId })" class="btn btn-success w-100 mb-2">
<i class="bi bi-arrow-up-circle"></i> Send
</a>
</div>
</div>
@@ -103,4 +81,4 @@
</div>
}
</div>
</div>
</div>

View File

@@ -37,7 +37,7 @@
}
@if (ViewData["PaymentDetails"] is PaymentDetailsViewModel paymentDetails)
@if (ViewData["PaymentDetails"] is BTCPayServer.Plugins.BreezSpark.PaymentDetailsDto paymentDetails)
{
<div class="payment-details mb-4">
<h4>Payment Details</h4>
@@ -51,13 +51,12 @@
<p><strong>Total:</strong> @(paymentDetails.Amount + paymentDetails.Fee) sats</p>
</div>
</div>
<form method="post" asp-action="ConfirmSend" asp-route-storeId="@storeId">
<input type="hidden" name="paymentRequest" value="@paymentDetails.Destination" />
<input type="hidden" name="amount" value="@paymentDetails.Amount" />
<input type="hidden" name="prepareResponse" value="@paymentDetails.PrepareResponseJson" />
<button type="submit" class="btn btn-success">Confirm and Send</button>
<a href="javascript:history.back()" class="btn btn-secondary">Cancel</a>
</form>
<form method="post" asp-action="ConfirmSend" asp-route-storeId="@storeId">
<input type="hidden" name="paymentRequest" value="@paymentDetails.Destination" />
<input type="hidden" name="amount" value="@paymentDetails.Amount" />
<button type="submit" class="btn btn-success">Confirm and Send</button>
<a href="javascript:history.back()" class="btn btn-secondary">Cancel</a>
</form>
</div>
}
else
@@ -70,7 +69,7 @@ else
<span>@ViewData["Title"]</span>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary">Prepare Payment</button>
<button type="submit" class="btn btn-primary">Pay</button>
</div>
</div>
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@@ -81,20 +80,12 @@ else
</div>
<div class="form-group">
<label for="amount" class="form-label">Amount (sats)</label>
<input type="number" id="amount" name="amount" min="1" max="@max" class="form-control" placeholder="Amount in satoshis"/>
<small class="form-text text-muted">Maximum payable: @max sats</small>
<input type="number" id="amount" name="amount" min="1" max="@max" class="form-control" placeholder="Leave blank if invoice has amount"/>
<small class="form-text text-muted">Maximum payable: @max sats. Bolt11 amounts are auto-read; fill only for zero-amount invoices.</small>
</div>
</div>
</div>
</form>
}
@functions {
public class PaymentDetailsViewModel
{
public string Destination { get; set; } = string.Empty;
public long Amount { get; set; }
public long Fee { get; set; }
public string PrepareResponseJson { get; set; } = string.Empty;
}
}
@* PaymentDetails model is provided via ViewData from the controller *@

View File

@@ -5,11 +5,12 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Components.TruncateCenter
@using BTCPayServer.Lightning
@model BTCPayServer.Plugins.BreezSpark.PaymentsViewModel
@{
var storeId = Context.GetCurrentStoreId();
ViewData.SetActivePage("BreezSpark", "Payments", "Payments");
ViewData.SetActivePage("BreezSpark", "Transactions", "Transactions");
TempData.TryGetValue("bolt11", out var bolt11);
}
@@ -17,7 +18,7 @@
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">
<span>@ViewData["Title"]</span>
<span>Transactions</span>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
@@ -43,7 +44,7 @@
</div>
}
<partial name="BreezSpark/BreezSparkPaymentsTable" model="Model.Payments"/>
<partial name="BreezSpark/BreezSparkTransactionsTable" model="Model.Payments"/>
<vc:pager view-model="Model"></vc:pager>
</div>
</div>
</div>

View File

@@ -45,7 +45,7 @@
<a permission="@Policies.CanViewStoreSettings" asp-action="Info" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("BreezSpark", null, "Info")">Info</a>
</li>
<li class="nav-item nav-item-sub">
<a permission="@Policies.CanViewStoreSettings" asp-action="Payments" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("BreezSpark", null, "Payments")">Payments</a>
<a permission="@Policies.CanViewStoreSettings" asp-action="Transactions" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("BreezSpark", null, "Transactions")">Transactions</a>
</li>
<li class="nav-item nav-item-sub">
<a permission="@Policies.CanModifyStoreSettings" asp-action="Configure" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("BreezSpark", null, "Configure")">Configuration</a>

View File

@@ -5,7 +5,7 @@
@using BTCPayServer.Security
@model List<NormalizedPayment>
@{
var data = Model ?? new List<NormalizedPayment>();
var data = (Model ?? new List<NormalizedPayment>()).Where(p => p != null).ToList();
var storeId = Context.GetImplicitStoreId() ?? string.Empty;
var isDashboard = false;
}
@@ -20,20 +20,11 @@
</style>
}
<div id="breezspark-payments" class="@(isDashboard ? "widget store-wallet-balance" : "")">
@if (isDashboard)
{
<header>
<h3>BreezSpark Payments</h3>
@if (data.Any())
{
<a asp-controller="BreezSpark" asp-action="Payments" asp-route-storeId="@storeId">View All</a>
}
</header>
}
@* Dashboard widget header removed for simplicity *@
@if (!data.Any())
{
<p class="text-secondary mt-3 mb-0">
There are no recent payments.
There are no recent transactions.
</p>
}
else
@@ -54,6 +45,10 @@
<tbody>
@foreach (var payment in data)
{
if (payment == null)
{
continue;
}
<tr>
<td class="smMaxWidth text-truncate">