mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
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.
403 lines
14 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|