Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.Breez/BreezLightningClient.cs
Claude 855cd20ba6 Replace Breez SDK (Greenlight) with Breez Spark SDK (nodeless)
Major changes:
- Built C# bindings for Breez Spark SDK from source using UniFFI
- Created local NuGet package infrastructure (Breez.Sdk.Spark v0.0.1)
- Replaced Breez.Sdk package reference with Breez.Sdk.Spark
- Updated BreezLightningClient to use async Spark SDK API
- Removed Greenlight-specific code (credentials, invite codes)
- Simplified BreezSettings (no more Greenlight fields)
- Updated BreezService for async client initialization
- Cleaned up BreezController (removed certificate upload logic)

Key differences in Spark SDK:
- Nodeless architecture (no Greenlight hosting required)
- Simplified configuration (only mnemonic + API key)
- All async methods (no BlockingBreezServices)
- Different payment flow (PrepareSendPayment + SendPayment)

The plugin now works with Breez's Spark protocol which provides
a self-custodial Lightning experience without infrastructure hosting.

Note: NuGet package must be built from spark-sdk source before use.
2025-11-13 15:01:05 +00:00

378 lines
13 KiB
C#

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