#nullable enable using System; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Net.Http; using System.Reactive.Disposables; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; 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.Extensions.Logging; using NBitcoin; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using Network = NBitcoin.Network; namespace BTCPayServer.Plugins.Blink; 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; async Task GetWalletId() => ExplicitWalletId ?? (await GetWalletInfo()).WalletId; public string? ExplicitWalletId { get; set; } private readonly Network _network; public ILogger Logger; private readonly GraphQLHttpClient _client; public class BlinkConnectionInit { [JsonProperty("X-API-KEY")] public string ApiKey { get; set; } } public BlinkLightningClient(string apiKey, Uri apiEndpoint, string? walletId, BlinkCurrency? currency, Network network, HttpClient httpClient, ILogger logger) { _apiKey = apiKey; _apiEndpoint = apiEndpoint; ExplicitWalletId = walletId; ExplicitCurrency = currency; _network = network; Logger = logger; _client = new GraphQLHttpClient(new GraphQLHttpClientOptions() {EndPoint = _apiEndpoint, WebSocketEndPoint = new Uri("wss://" + _apiEndpoint.Host.Replace("api.", "ws.") + _apiEndpoint.PathAndQuery), WebSocketProtocol = WebSocketProtocols.GRAPHQL_TRANSPORT_WS, ConfigureWebSocketConnectionInitPayload = options => new BlinkConnectionInit() {ApiKey = apiKey}, ConfigureWebsocketOptions = _ => { } }, new NewtonsoftJsonSerializer(settings => { if (settings.ContractResolver is CamelCasePropertyNamesContractResolver camelCasePropertyNamesContractResolver) { camelCasePropertyNamesContractResolver.NamingStrategy.OverrideSpecifiedNames = false; camelCasePropertyNamesContractResolver.NamingStrategy.ProcessDictionaryKeys = false; } }), httpClient); } public BlinkCurrency? ExplicitCurrency { get; set; } public override string ToString() { return $"type=blink;server={_apiEndpoint};api-key={_apiKey}{(ExplicitWalletId is null? "":$";wallet-id={ExplicitWalletId}")}"; } public async Task GetInvoice(string invoiceId, CancellationToken cancellation = default) { var reques = new GraphQLRequest { Query = @" query InvoiceByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { invoiceByPaymentHash(paymentHash: $paymentHash) { createdAt paymentHash paymentRequest paymentSecret paymentStatus } } } } }", OperationName = "InvoiceByPaymentHash", Variables = new { walletId = await GetWalletId(), paymentHash = invoiceId } }; var response = await _client.SendQueryAsync(reques, cancellation); return response.Data is null ? null : ToInvoice(response.Data.me.defaultAccount.walletById.invoiceByPaymentHash); } public LightningInvoice? ToInvoice(JObject invoice) { var bolt11 = BOLT11PaymentRequest.Parse(invoice["paymentRequest"].Value(), _network); return new LightningInvoice() { Id = invoice["paymentHash"].Value(), Amount = invoice["satoshis"] is null? bolt11.MinimumAmount: LightMoney.Satoshis(invoice["satoshis"].Value()), Preimage = invoice["paymentSecret"].Value(), PaidAt = (invoice["paymentStatus"].Value()) == "PAID"? DateTimeOffset.UtcNow : null, Status = (invoice["paymentStatus"].Value()) switch { "EXPIRED" => LightningInvoiceStatus.Expired, "PAID" => LightningInvoiceStatus.Paid, "PENDING" => LightningInvoiceStatus.Unpaid }, BOLT11 = invoice["paymentRequest"].Value(), PaymentHash = invoice["paymentHash"].Value(), ExpiresAt = bolt11.ExpiryDate }; } public async Task GetInvoice(uint256 paymentHash, CancellationToken cancellation = new CancellationToken()) { return await GetInvoice(paymentHash.ToString(), cancellation); } public async Task ListInvoices(CancellationToken cancellation = new CancellationToken()) { return await ListInvoices(new ListInvoicesParams(), cancellation); } public async Task ListInvoices(ListInvoicesParams request, CancellationToken cancellation = new CancellationToken()) { var reques = new GraphQLRequest { Query = @" query Invoices($walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { invoices { edges { node { createdAt paymentHash paymentRequest paymentSecret paymentStatus ... on LnInvoice { satoshis } } } } } } } }", OperationName = "Invoices", Variables = new { walletId = await GetWalletId() } }; var response = await _client.SendQueryAsync(reques, cancellation); var result = ((JArray)response.Data.me.defaultAccount.walletById.invoices.edges).Select(o => ToInvoice((JObject) o["node"] )).Where(o => o is not null || request.PendingOnly is not true || o.Status == LightningInvoiceStatus.Unpaid).ToArray(); return (LightningInvoice[]) result; } public async Task GetPayment(string paymentHash, CancellationToken cancellation = new CancellationToken()) { var reques = new GraphQLRequest { Query = @" query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { transactionsByPaymentHash(paymentHash: $paymentHash) { createdAt direction id initiationVia { ... on InitiationViaLn { paymentHash paymentRequest } } memo settlementAmount settlementCurrency settlementVia { ... on SettlementViaLn { preImage } ... on SettlementViaIntraLedger { preImage } } status } } } } }", OperationName = "TransactionsByPaymentHash", Variables = new { walletId = await GetWalletId(), paymentHash = paymentHash } }; var response = await _client.SendQueryAsync(reques, cancellation); var item = (JArray) response.Data.me.defaultAccount.walletById.transactionsByPaymentHash; return item.Any()? ToLightningPayment((JObject)item.First()): null; } public LightningPayment? ToLightningPayment(JObject transaction) { if ((string)transaction["direction"] == "RECEIVE") return null; var initiationVia = transaction["initiationVia"]; if (initiationVia?["paymentHash"] == null) return null; var bolt11 = BOLT11PaymentRequest.Parse((string)initiationVia["paymentRequest"], _network); var preimage = transaction["settlementVia"]?["preImage"]?.Value(); return new LightningPayment() { Amount = bolt11.MinimumAmount, Status = transaction["status"].ToString() switch { "FAILURE" => LightningPaymentStatus.Failed, "PENDING" => LightningPaymentStatus.Pending, "SUCCESS" => LightningPaymentStatus.Complete, _ => LightningPaymentStatus.Unknown }, BOLT11 = (string)initiationVia["paymentRequest"], Id = (string)initiationVia["paymentHash"], PaymentHash = (string)initiationVia["paymentHash"], CreatedAt = DateTimeOffset.FromUnixTimeSeconds(transaction["createdAt"].Value()), AmountSent = bolt11.MinimumAmount, Preimage = preimage }; } public async Task ListPayments(CancellationToken cancellation = new CancellationToken()) { return await ListPayments(new ListPaymentsParams(), cancellation); } public async Task ListPayments(ListPaymentsParams request, CancellationToken cancellation = new CancellationToken()) { var reques = new GraphQLRequest { Query = @" query Transactions($walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { transactions { edges { node { createdAt direction id initiationVia { ... on InitiationViaLn { paymentHash paymentRequest } } memo settlementAmount settlementCurrency settlementVia { ... on SettlementViaLn { preImage } ... on SettlementViaIntraLedger { preImage } } status } } } } } } }", OperationName = "Transactions", Variables = new { walletId = await GetWalletId(), } }; var response = await _client.SendQueryAsync(reques, cancellation); var result = ((JArray)response.Data.me.defaultAccount.walletById.transactions.edges).Select(o => ToLightningPayment((JObject) o["node"])).Where(o => o is not null && (request.IncludePending is not true || o.Status!= LightningPaymentStatus.Pending)).ToArray(); return (LightningPayment[]) result; } public async Task CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation = new()) { return await CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation); } public async Task CreateInvoice(CreateInvoiceParams createInvoiceRequest, CancellationToken cancellation = new()) { var isUSD = (await GetWalletInfo()).WalletCurrency == BlinkCurrency.USD; var query = isUSD ? @" mutation lnInvoiceCreate($input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!) { lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient(input: $input) { invoice { createdAt paymentHash paymentRequest paymentSecret paymentStatus satoshis }, errors{ message } } }" : @" mutation lnInvoiceCreate($input: LnInvoiceCreateOnBehalfOfRecipientInput!) { lnInvoiceCreateOnBehalfOfRecipient(input: $input) { invoice { createdAt paymentHash paymentRequest paymentSecret paymentStatus satoshis }, errors{ message } } }"; var reques = new GraphQLRequest { Query = query, OperationName = "lnInvoiceCreate", Variables = new { input = new { recipientWalletId = await GetWalletId(), memo = createInvoiceRequest.Description, descriptionHash = createInvoiceRequest.DescriptionHash?.ToString(), amount = (long)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi), expiresIn = (int)createInvoiceRequest.Expiry.TotalMinutes } } }; var response = await _client.SendQueryAsync(reques, cancellation); var inv = (isUSD ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.invoice : response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice) as JObject; if (inv is null) { var errors = (isUSD ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.errors : response.Data.lnInvoiceCreateOnBehalfOfRecipient.errors) as JArray; if (errors.Any()) { throw new Exception(errors.First()["message"].ToString()); } } return ToInvoice(inv); } public async Task Listen(CancellationToken cancellation = new CancellationToken()) { return new BlinkListener(_client, this, Logger); } public class BlinkListener : ILightningInvoiceListener { private volatile int _WebSocketErrorCount = 0; private readonly BlinkLightningClient _lightningClient; private readonly Channel _invoices = Channel.CreateUnbounded(); private readonly IDisposable _subscription; private readonly ILogger _logger; public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient, ILogger logger) { _logger = logger; _lightningClient = lightningClient; var stream = httpClient.CreateSubscriptionStream(new GraphQLRequest() { Query = @"subscription myUpdates { myUpdates { update { ... on LnUpdate { transaction { initiationVia { ... on InitiationViaLn { paymentHash } } direction } } } } } ", OperationName = "myUpdates" }, webSocketExceptionHandler: (wse) => { _logger.LogWarning(wse, $"Websocket error to Blink... ({_WebSocketErrorCount})"); if (Interlocked.Increment(ref _WebSocketErrorCount) == 10) { _invoices.Writer.TryComplete(wse); _logger.LogError(wse, "Connection to Blink WebSocket closed."); } }); _subscription = stream.Subscribe(response => { _WebSocketErrorCount = 0; try { if(response?.Data is null) return; if (response.Data.SelectToken("myUpdates.update.transaction.direction")?.Value() != "RECEIVE") return; var invoiceId = response.Data .SelectToken("myUpdates.update.transaction.initiationVia.paymentHash")?.Value(); if (invoiceId is null) return; _invoices.Writer.TryWrite(invoiceId); } catch (Exception e) { _logger.LogError(e, "Error while processing detecting lightning invoice payment"); } }, onError: (e) => { _invoices.Writer.TryComplete(e); }); } public void Dispose() { _subscription.Dispose(); _invoices.Writer.TryComplete(new ObjectDisposedException(nameof(BlinkListener))); } public async Task WaitInvoice(CancellationToken cancellation) { await foreach (var id in _invoices.Reader.ReadAllAsync(cancellation)) { var invoice = await _lightningClient.GetInvoice(id, cancellation); if (invoice is not null) return invoice; } throw new ChannelClosedException(); } } public async Task<(Network Network, string DefaultWalletId, BlinkCurrency DefaultWalletCurrency)> FetchNetworkAndDefaultWallet(CancellationToken cancellation =default) { var reques = new GraphQLRequest { Query = @" query GetNetworkAndDefaultWallet { globals { network } me { defaultAccount { defaultWallet{ id currency } } } }", OperationName = "GetNetworkAndDefaultWallet" }; var response = await _client.SendQueryAsync(reques, cancellation); var defaultWalletId = (string) response.Data.me.defaultAccount.defaultWallet.id; var defaultWalletCurrency = (string) response.Data.me.defaultAccount.defaultWallet.currency; var network = response.Data.globals.network.ToString() switch { "mainnet" => Network.Main, "testnet" => Network.TestNet, "signet" => Network.TestNet, "regtest" => Network.RegTest, _ => throw new ArgumentOutOfRangeException() }; return (network, defaultWalletId, ParseBlinkCurrency(defaultWalletCurrency)); } public Task GetInfo(CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } 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 = @" query GetWallet($walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { id balance walletCurrency } } } }", OperationName = "GetWallet", Variables = new { walletId = await GetWalletId() } }; 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(balance) } }; } throw new NotSupportedException(); } public async Task Pay(PayInvoiceParams payParams, CancellationToken cancellation = new CancellationToken()) { return await Pay(null, new PayInvoiceParams(), cancellation); } public async Task Pay(string bolt11, PayInvoiceParams payParams, CancellationToken cancellation = default) { var request = new GraphQLRequest { Query = @" mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) { lnInvoicePaymentSend(input: $input) { transaction { createdAt direction id initiationVia { ... on InitiationViaLn { paymentHash paymentRequest } } memo settlementAmount settlementCurrency settlementVia { ... on SettlementViaLn { preImage } ... on SettlementViaIntraLedger { preImage } } status } errors { message } status } }", OperationName = "LnInvoicePaymentSend", Variables = new { input = new { walletId = await GetWalletId(), paymentRequest = bolt11, } } }; var bolt11Parsed = BOLT11PaymentRequest.Parse(bolt11, _network); CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation, new CancellationTokenSource(payParams?.SendTimeout ?? PayInvoiceParams.DefaultSendTimeout).Token); var response =(JObject) (await _client.SendQueryAsync(request, cts.Token)).Data.lnInvoicePaymentSend; var result = new PayResponse(); result.Result = response["status"].Value() switch { "ALREADY_PAID" => PayResult.Ok, "FAILURE" => PayResult.Error, "PENDING"=> PayResult.Unknown, "SUCCESS" => PayResult.Ok, null => PayResult.Unknown, _ => throw new ArgumentOutOfRangeException() }; if (result.Result == PayResult.Error && response.TryGetValue("errors", out var error)) { if (error.ToString().Contains("ResourceAttemptsRedlockServiceError", StringComparison.InvariantCultureIgnoreCase)) { await Task.Delay(Random.Shared.Next(200, 600), cts.Token); return await Pay(bolt11, payParams, cts.Token); } if (error is JArray { Count: > 0 } arr) result.ErrorDetail = arr[0]["message"]?.Value(); } if (response["transaction"]?.Value() is not null) { result.Details = new PayDetails() { PaymentHash = bolt11Parsed.PaymentHash ?? new uint256(response["transaction"]["initiationVia"]["paymentHash"].Value()), Status = response["status"].Value() switch { "ALREADY_PAID" => LightningPaymentStatus.Complete, "FAILURE" => LightningPaymentStatus.Failed, "PENDING" => LightningPaymentStatus.Pending, "SUCCESS" => LightningPaymentStatus.Complete, 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()), }; } return result; } public async Task Pay(string bolt11, CancellationToken cancellation = new CancellationToken()) { return await Pay(bolt11, new PayInvoiceParams(), cancellation); } public async Task CancelInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) { throw new NotImplementedException(); } public async Task GetDepositAddress(CancellationToken cancellation = new CancellationToken()) { throw new NotImplementedException(); } public async Task OpenChannel(OpenChannelRequest openChannelRequest, CancellationToken cancellation = new CancellationToken()) { throw new NotImplementedException(); } public async Task ConnectTo(NodeInfo nodeInfo, CancellationToken cancellation = new CancellationToken()) { throw new NotImplementedException(); } public async Task ListChannels(CancellationToken cancellation = new CancellationToken()) { 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; }