#nullable enable using System; using System.Collections.Generic; 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 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; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Serialization; using Network = NBitcoin.Network; namespace BTCPayServer.Plugins.Blink; public class BlinkLightningClient : ILightningClient { private readonly string _apiKey; private readonly Uri _apiEndpoint; public string? WalletId { get; set; } public string? WalletCurrency { 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, Network network, HttpClient httpClient, ILogger logger) { _apiKey = apiKey; _apiEndpoint = apiEndpoint; WalletId = walletId; _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 override string ToString() { return $"type=blink;server={_apiEndpoint};api-key={_apiKey}{(WalletId is null? "":$";wallet-id={WalletId}")}"; } public async Task GetInvoice(string invoiceId, CancellationToken cancellation = new CancellationToken()) { 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 = WalletId, 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 = WalletId } }; 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 = WalletId, 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 = WalletId } }; 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()) { string query; query = WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true ? @" 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 = WalletId, 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 = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.invoice : response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice)as JObject; if (inv is null) { var errors = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true ? 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 readonly BlinkLightningClient _lightningClient; private readonly Channel _invoices = Channel.CreateUnbounded(); private readonly IDisposable _subscription; public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient, ILogger logger) { try { _lightningClient = lightningClient; var stream = httpClient.CreateSubscriptionStream(new GraphQLRequest() { Query = @"subscription myUpdates { myUpdates { update { ... on LnUpdate { transaction { initiationVia { ... on InitiationViaLn { paymentHash } } direction } } } } } ", OperationName = "myUpdates" }); _subscription = stream.Subscribe(async response => { 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; if (await _lightningClient.GetInvoice(invoiceId) is LightningInvoice inv) { _invoices.Writer.TryWrite(inv); } } catch (Exception e) { logger.LogError(e, "Error while processing detecting lightning invoice payment"); } }); _wsSubscriptionDisposable = httpClient.WebsocketConnectionState.Subscribe(state => { if (state == GraphQLWebsocketConnectionState.Disconnected) { streamEnded.TrySetResult(); } }); } catch (Exception e) { logger.LogError(e, "Error while creating lightning invoice listener"); } } public void Dispose() { _subscription.Dispose(); _invoices.Writer.TryComplete(); _wsSubscriptionDisposable.Dispose(); streamEnded.TrySetResult(); } private TaskCompletionSource streamEnded = new(); private readonly IDisposable _wsSubscriptionDisposable; public async Task WaitInvoice(CancellationToken cancellation) { var resultz = await Task.WhenAny(streamEnded.Task, _invoices.Reader.ReadAsync(cancellation).AsTask()); if (resultz is Task res) { return await res; } throw new Exception("Stream disconnected, cannot await invoice"); } } public async Task<(Network Network, string DefaultWalletId, string DefaultWalletCurrency)> GetNetworkAndDefaultWallet(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, defaultWalletCurrency); } public Task GetInfo(CancellationToken cancellation = new CancellationToken()) { throw new NotSupportedException(); } public async Task GetBalance(CancellationToken cancellation = new()) { var request = new GraphQLRequest { Query = @" query GetWallet($walletId: WalletId!) { me { defaultAccount { walletById(walletId: $walletId) { id balance walletCurrency } } } }", OperationName = "GetWallet", Variables = new { walletId = WalletId } }; var response = await _client.SendQueryAsync(request, cancellation); WalletCurrency = response.Data.me.defaultAccount.walletById.walletCurrency; if (response.Data.me.defaultAccount.walletById.walletCurrency == "BTC") { return new LightningNodeBalance() { OffchainBalance = new OffchainBalance() { Local = LightMoney.Satoshis((long)response.Data.me.defaultAccount.walletById.balance) } }; } return new LightningNodeBalance(); } 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 = new CancellationToken()) { 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 = WalletId, 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, null => LightningPaymentStatus.Unknown, _ => throw new ArgumentOutOfRangeException() }, 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(); } }