Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.Breez/BreezLightningClient.cs
Claude ac191bae17 Fix seed initialization to use Spark SDK Seed.Mnemonic type
The previous implementation incorrectly converted the mnemonic to bytes:
- OLD: var seed = mnemonic.DeriveSeed(); ... seed.ToList()
- NEW: var seed = new Seed.Mnemonic(mnemonic: mnemonic.ToString(), passphrase: null);

This matches the official Spark SDK C# snippets pattern where ConnectRequest
expects a Seed discriminated union type, not a List<byte>.

Reference: https://github.com/breez/spark-sdk/tree/main/docs/breez-sdk/snippets/csharp
2025-11-14 15:55:50 +00:00

403 lines
14 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 = 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;
}
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 = "Breez Spark (nodeless)",
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.Zero
},
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.amount),
FeeAmount = (long)sendResponse.payment.fees
}
};
}
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.paymentRequest,
Status = LightningInvoiceStatus.Unpaid,
Amount = LightMoney.Satoshis((long)response.fee)
};
}
private LightningInvoice FromPayment(Payment payment)
{
if (payment == null) return null;
string paymentHash = null;
string bolt11 = null;
if (payment.details is PaymentDetails.Lightning lightningDetails)
{
paymentHash = lightningDetails.paymentHash;
bolt11 = lightningDetails.invoice;
}
return new LightningInvoice()
{
Id = payment.id,
PaymentHash = paymentHash ?? payment.id,
BOLT11 = bolt11,
Amount = LightMoney.Satoshis((long)payment.amount),
Status = payment.status switch
{
PaymentStatus.Pending => LightningInvoiceStatus.Unpaid,
PaymentStatus.Failed => LightningInvoiceStatus.Expired,
PaymentStatus.Completed => LightningInvoiceStatus.Paid,
_ => LightningInvoiceStatus.Unpaid
},
PaidAt = DateTimeOffset.FromUnixTimeSeconds((long)payment.timestamp)
};
}
private LightningPayment ToLightningPayment(Payment payment)
{
if (payment == null) return null;
string paymentHash = null;
string preimage = null;
string bolt11 = null;
if (payment.details is PaymentDetails.Lightning lightningDetails)
{
paymentHash = lightningDetails.paymentHash;
preimage = lightningDetails.preimage;
bolt11 = lightningDetails.invoice;
}
return new LightningPayment()
{
Id = payment.id,
PaymentHash = paymentHash ?? payment.id,
Preimage = preimage,
BOLT11 = bolt11,
Amount = LightMoney.Satoshis((long)payment.amount),
Status = payment.status switch
{
PaymentStatus.Failed => LightningPaymentStatus.Failed,
PaymentStatus.Completed => LightningPaymentStatus.Complete,
PaymentStatus.Pending => LightningPaymentStatus.Pending,
_ => LightningPaymentStatus.Unknown
},
CreatedAt = DateTimeOffset.FromUnixTimeSeconds((long)payment.timestamp),
Fee = LightMoney.Satoshis((long)payment.fees),
AmountSent = LightMoney.Satoshis((long)payment.amount)
};
}
public void Dispose()
{
_sdk?.Dispose();
}
public class BreezInvoiceListener : ILightningInvoiceListener
{
private readonly BreezLightningClient _breezLightningClient;
private readonly CancellationToken _cancellationToken;
private readonly ConcurrentQueue<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;
}
}
}