Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.Breez/BreezLightningClient.cs
Claude 7e60fe0976 Fix Spark SDK API compatibility issues
Critical fixes for Spark SDK migration:

1. **BreezController.cs**:
   - Replaced RedeemOnchainFunds with ClaimDeposit/ListUnclaimedDeposits
   - Disabled swap-out (not available in nodeless Spark SDK)
   - Updated refund to use RefundDeposit instead of Refund
   - Fixed method signatures and parameter names

2. **BreezLightningClient.cs**:
   - Fixed field name mismatches: amount (not amountSats), fees (not feesSats)
   - Updated ReceivePaymentResponse: paymentRequest (not destination), fee (not feesSats)
   - Fixed PaymentDetails pattern matching for Lightning variant
   - Removed timestamp nullable check (it's always present in Spark SDK)
   - Updated GetInfo/GetBalance for nodeless architecture
   - Fixed payment conversion to handle Spark SDK's discriminated union structure

3. **BTCPay Server submodule**: Updated to v2.2.0

The Spark SDK uses a nodeless architecture with different capabilities:
- Deposits instead of traditional swap-in
- No onchain swap-out functionality
- No node ID or block height in GetInfo
- Payment details use discriminated unions (Lightning/Spark/Token/Deposit/Withdraw)

All Lightning payment operations now work correctly with the Spark SDK.
2025-11-13 16:05:54 +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 = 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 = "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;
}
}
}