diff --git a/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningClient.cs b/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningClient.cs index 0c8aab9..2d7f594 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningClient.cs +++ b/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningClient.cs @@ -1,19 +1,18 @@ #nullable enable using System; -using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; -using BTCPayServer.HostedServices; using BTCPayServer.Lightning; +using BTCPayServer.Payments.Lightning; using GraphQL; using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Http; using GraphQL.Client.Http.Websocket; using GraphQL.Client.Serializer.Newtonsoft; -using Microsoft.AspNetCore.OutputCaching; using Microsoft.Extensions.Logging; using NBitcoin; using Newtonsoft.Json; @@ -23,13 +22,88 @@ using Network = NBitcoin.Network; namespace BTCPayServer.Plugins.Blink; -public class BlinkLightningClient : ILightningClient +public enum BlinkCurrency { + BTC, + USD +} + +public class BlinkLightningClient : IExtendedLightningClient +{ + async Task FetchWalletCurrency() + { + var walletId = await GetWalletId(); + var reques = new GraphQLRequest + { + Query = @" +query InvoiceByPaymentHash($walletId: WalletId!) { + me { + defaultAccount { + walletById(walletId: $walletId) { + walletCurrency + } + } + } +}", + OperationName = "InvoiceByPaymentHash", + Variables = new + { + walletId = walletId + } + }; + var response = await _client.SendQueryAsync(reques); + return response.Data["me"]?["defaultAccount"]?["walletById"]?["walletCurrency"]?.ToString() switch + { + string str => ParseBlinkCurrency(str), + _ => null + }; + } + + internal static BlinkCurrency ParseBlinkCurrency(string str) + => str switch + { + "BTC" => BlinkCurrency.BTC, + "USD" => BlinkCurrency.USD, + _ => throw new FormatException("Invalid Blink Wallet Currency (Only BTC and USD are supported)") + }; + + record WalletInfo(BlinkCurrency WalletCurrency, string WalletId); + + private WalletInfo? _WalletInfo; + + /// + /// The connection string may or may not have the walletId and the currency + /// This method fetches the default wallet-id and the currency of the wallet if we + /// do not already have this information and cache it. + /// Now, the connection string generated by Blink should include both, so this is only to + /// accomodate legacy connection string format + /// + /// + /// + async Task GetWalletInfo() + { + if (_WalletInfo is not null) + return _WalletInfo; + if (ExplicitWalletId is null) + { + var defaultWallet = await FetchNetworkAndDefaultWallet(); + _WalletInfo = new(ExplicitCurrency ?? defaultWallet.DefaultWalletCurrency, defaultWallet.DefaultWalletId); + } + else + { + var currency = ExplicitCurrency ?? await FetchWalletCurrency() ?? + throw new InvalidOperationException("Invalid Blink Wallet Id"); + _WalletInfo = new(currency, ExplicitWalletId); + } + + return _WalletInfo; + } + private readonly string _apiKey; private readonly Uri _apiEndpoint; - public string? WalletId { get; set; } - public string? WalletCurrency { get; set; } + async Task GetWalletId() => ExplicitWalletId ?? (await GetWalletInfo()).WalletId; + public string? ExplicitWalletId { get; set; } private readonly Network _network; public ILogger Logger; @@ -39,11 +113,12 @@ public class BlinkLightningClient : ILightningClient { [JsonProperty("X-API-KEY")] public string ApiKey { get; set; } } - public BlinkLightningClient(string apiKey, Uri apiEndpoint, string walletId, Network network, HttpClient httpClient, ILogger logger) + public BlinkLightningClient(string apiKey, Uri apiEndpoint, string? walletId, BlinkCurrency? currency, Network network, HttpClient httpClient, ILogger logger) { _apiKey = apiKey; _apiEndpoint = apiEndpoint; - WalletId = walletId; + ExplicitWalletId = walletId; + ExplicitCurrency = currency; _network = network; Logger = logger; _client = new GraphQLHttpClient(new GraphQLHttpClientOptions() {EndPoint = _apiEndpoint, @@ -66,13 +141,15 @@ public class BlinkLightningClient : ILightningClient } + public BlinkCurrency? ExplicitCurrency { get; set; } + public override string ToString() { - return $"type=blink;server={_apiEndpoint};api-key={_apiKey}{(WalletId is null? "":$";wallet-id={WalletId}")}"; + return $"type=blink;server={_apiEndpoint};api-key={_apiKey}{(ExplicitWalletId is null? "":$";wallet-id={ExplicitWalletId}")}"; } public async Task GetInvoice(string invoiceId, - CancellationToken cancellation = new CancellationToken()) + CancellationToken cancellation = default) { var reques = new GraphQLRequest @@ -96,7 +173,7 @@ query InvoiceByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) { OperationName = "InvoiceByPaymentHash", Variables = new { - walletId = WalletId, + walletId = await GetWalletId(), paymentHash = invoiceId } }; @@ -169,7 +246,7 @@ query Invoices($walletId: WalletId!) { OperationName = "Invoices", Variables = new { - walletId = WalletId + walletId = await GetWalletId() } }; var response = await _client.SendQueryAsync(reques, cancellation); @@ -218,7 +295,7 @@ query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId! OperationName = "TransactionsByPaymentHash", Variables = new { - walletId = WalletId, + walletId = await GetWalletId(), paymentHash = paymentHash } }; @@ -308,7 +385,7 @@ query Transactions($walletId: WalletId!) { OperationName = "Transactions", Variables = new { - walletId = WalletId + walletId = await GetWalletId(), } }; var response = await _client.SendQueryAsync(reques, cancellation); @@ -328,9 +405,8 @@ query Transactions($walletId: WalletId!) { public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = new()) { - string query; - - query = WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true ? @" + var isUSD = (await GetWalletInfo()).WalletCurrency == BlinkCurrency.USD; + var query = isUSD ? @" mutation lnInvoiceCreate($input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!) { lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient(input: $input) { invoice { @@ -370,7 +446,7 @@ mutation lnInvoiceCreate($input: LnInvoiceCreateOnBehalfOfRecipientInput!) { { input = new { - recipientWalletId = WalletId, + recipientWalletId = await GetWalletId(), memo = createInvoiceRequest.Description, descriptionHash = createInvoiceRequest.DescriptionHash?.ToString(), amount = (long)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi), @@ -380,13 +456,13 @@ expiresIn = (int)createInvoiceRequest.Expiry.TotalMinutes } }; var response = await _client.SendQueryAsync(reques, cancellation); - var inv = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true + var inv = (isUSD ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.invoice - : response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice)as JObject; + : response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice) as JObject; if (inv is null) { - var errors = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true + var errors = (isUSD ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.errors : response.Data.lnInvoiceCreateOnBehalfOfRecipient.errors) as JArray; @@ -518,7 +594,7 @@ expiresIn = (int)createInvoiceRequest.Expiry.TotalMinutes return new LightningInvoice { Id = Guid.NewGuid().ToString() }; // Return a dummy invoice so calling listening logic exits } } - public async Task<(Network Network, string DefaultWalletId, string DefaultWalletCurrency)> GetNetworkAndDefaultWallet(CancellationToken cancellation =default) + public async Task<(Network Network, string DefaultWalletId, BlinkCurrency DefaultWalletCurrency)> FetchNetworkAndDefaultWallet(CancellationToken cancellation =default) { var reques = new GraphQLRequest @@ -552,7 +628,7 @@ query GetNetworkAndDefaultWallet { "regtest" => Network.RegTest, _ => throw new ArgumentOutOfRangeException() }; - return (network, defaultWalletId, defaultWalletCurrency); + return (network, defaultWalletId, ParseBlinkCurrency(defaultWalletCurrency)); } public Task GetInfo(CancellationToken cancellation = new CancellationToken()) @@ -563,6 +639,9 @@ query GetNetworkAndDefaultWallet { public async Task GetBalance(CancellationToken cancellation = new()) { + // Avoid call if we already know that fetching the balance is useless + if ((ExplicitCurrency ?? _WalletInfo?.WalletCurrency) is not (null or BlinkCurrency.BTC)) + throw new NotSupportedException(); var request = new GraphQLRequest { Query = @" @@ -579,25 +658,25 @@ query GetWallet($walletId: WalletId!) { }", OperationName = "GetWallet", Variables = new { - walletId = WalletId + walletId = await GetWalletId() } }; - var response = await _client.SendQueryAsync(request, cancellation); - - WalletCurrency = response.Data.me.defaultAccount.walletById.walletCurrency; - if (response.Data.me.defaultAccount.walletById.walletCurrency == "BTC") + var response = await _client.SendQueryAsync(request, cancellation); + var walletById = response.Data["me"]?["defaultAccount"]?["walletById"]; + if (walletById?["walletCurrency"]?.Value() is string currency && + walletById?["balance"]?.Value() is long balance && + ParseBlinkCurrency(currency) is BlinkCurrency.BTC) { return new LightningNodeBalance() { OffchainBalance = new OffchainBalance() { - Local = LightMoney.Satoshis((long)response.Data.me.defaultAccount.walletById.balance) + Local = LightMoney.Satoshis(balance) } }; } - - return new LightningNodeBalance(); + throw new NotSupportedException(); } public async Task Pay(PayInvoiceParams payParams, @@ -607,7 +686,7 @@ query GetWallet($walletId: WalletId!) { } public async Task Pay(string bolt11, PayInvoiceParams payParams, - CancellationToken cancellation = new CancellationToken()) + CancellationToken cancellation = default) { var request = new GraphQLRequest @@ -647,7 +726,7 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { OperationName = "LnInvoicePaymentSend", Variables = new { input = new { - walletId = WalletId, + walletId = await GetWalletId(), paymentRequest = bolt11, } } @@ -689,8 +768,8 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { "FAILURE" => LightningPaymentStatus.Failed, "PENDING" => LightningPaymentStatus.Pending, "SUCCESS" => LightningPaymentStatus.Complete, - null => LightningPaymentStatus.Unknown, - _ => throw new ArgumentOutOfRangeException() + string status => throw new ArgumentOutOfRangeException($"Unknown status received by blink ({status})"), + _ => LightningPaymentStatus.Unknown, }, Preimage = response["transaction"]["settlementVia"]?["preImage"].Value() is null? null: new uint256(response["transaction"]["settlementVia"]["preImage"].Value()), }; @@ -731,4 +810,36 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { { throw new NotImplementedException(); } + + public async Task Validate() + { + try + { + var res = await FetchNetworkAndDefaultWallet(); + if (res.Network != this._network) + { + return new ValidationResult( + $"The wallet is not on the right network ({res.Network.Name} instead of {_network.Name})"); + } + + if (ExplicitWalletId is null && string.IsNullOrEmpty(res.DefaultWalletId)) + { + return new ValidationResult($"The wallet-id is not set and no default wallet is set"); + } + } + catch (Exception) + { + return new ValidationResult($"Invalid server or api key"); + } + + var walletCurrency = await FetchWalletCurrency(); + if (walletCurrency is null) + return new ValidationResult($"Invalid wallet id"); + if (ExplicitCurrency != null && walletCurrency != ExplicitCurrency) + return new ValidationResult($"Invalid currency (it should be {walletCurrency})"); + return ValidationResult.Success; + } + + public string? DisplayName => "Blink"; + public Uri? ServerUri => _client.HttpClient.BaseAddress; } \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningConnectionStringHandler.cs b/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningConnectionStringHandler.cs index 07ad1f5..c64962e 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningConnectionStringHandler.cs +++ b/Plugins/BTCPayServer.Plugins.Blink/BlinkLightningConnectionStringHandler.cs @@ -3,6 +3,7 @@ using System; using System.Linq; using System.Net.Http; using BTCPayServer.Lightning; +using BTCPayServer.Payments.Lightning; using Microsoft.Extensions.Logging; using Network = NBitcoin.Network; @@ -82,48 +83,21 @@ public class BlinkLightningConnectionStringHandler : ILightningConnectionStringH client.BaseAddress = uri; kv.TryGetValue("wallet-id", out var walletId); - var bclient = new BlinkLightningClient(apiKey, uri, walletId, network, client, _loggerFactory.CreateLogger($"{nameof(BlinkLightningClient)}:{walletId}")); - (Network Network, string DefaultWalletId, string DefaultWalletCurrency) res; - try - { - res = bclient.GetNetworkAndDefaultWallet().GetAwaiter().GetResult(); - if (res.Network != network) - { - error = $"The wallet is not on the right network ({res.Network.Name} instead of {network.Name})"; - return null; - } - if (walletId is null && string.IsNullOrEmpty(res.DefaultWalletId)) - { - error = $"The wallet-id is not set and no default wallet is set"; - return null; - } - } - catch (Exception e) - { - error = $"Invalid server or api key"; - return null; - } - - if (walletId is null) - { - bclient.WalletId = res.DefaultWalletId; - bclient.WalletCurrency = res.DefaultWalletCurrency; - bclient.Logger = _loggerFactory.CreateLogger($"{nameof(BlinkLightningClient)}:{walletId}"); - } - else + BlinkCurrency? currency = null; + if (kv.TryGetValue("currency", out var v)) { try { - bclient.GetBalance().GetAwaiter().GetResult(); + currency = BlinkLightningClient.ParseBlinkCurrency(v); } - catch (Exception e) + catch (FormatException e) { - error = "Invalid wallet id"; + error = e.Message; return null; } } - return bclient; + return new BlinkLightningClient(apiKey, uri, walletId, currency, network, client, _loggerFactory.CreateLogger($"{nameof(BlinkLightningClient)}:{walletId}")); } } \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Blink/Views/Shared/Blink/LNPaymentMethodSetupTab.cshtml b/Plugins/BTCPayServer.Plugins.Blink/Views/Shared/Blink/LNPaymentMethodSetupTab.cshtml index fa54a3c..34af972 100644 --- a/Plugins/BTCPayServer.Plugins.Blink/Views/Shared/Blink/LNPaymentMethodSetupTab.cshtml +++ b/Plugins/BTCPayServer.Plugins.Blink/Views/Shared/Blink/LNPaymentMethodSetupTab.cshtml @@ -29,7 +29,7 @@
  • - type=blink;server=https://api.blink.sv/graphql;api-key=blink_...;wallet-id=xyz + type=blink;server=https://api.blink.sv/graphql;api-key=blink_...;wallet-id=xyz;currency=BTC;

Head over to the Blink dashboard and create an api key. The server is optional and will use the default Blink instance.The wallet-id is optional and will use the default wallet otherwise.