#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.Http; using GraphQL.Client.Http.Websocket; using GraphQL.Client.Serializer.Newtonsoft; using Microsoft.AspNetCore.OutputCaching; 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; 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) { _apiKey = apiKey; _apiEndpoint = apiEndpoint; WalletId = walletId; _network = network; _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); 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, }; } 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); } public class BlinkListener : ILightningInvoiceListener { private readonly BlinkLightningClient _lightningClient; private readonly Channel _invoices = Channel.CreateUnbounded(); private readonly IDisposable _subscription; public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient) { 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) { Console.WriteLine(e); } }); } catch (Exception e) { Console.WriteLine(e); } } public void Dispose() { _subscription.Dispose(); _invoices.Writer.TryComplete(); } public async Task WaitInvoice(CancellationToken cancellation) { return await _invoices.Reader.ReadAsync(cancellation); } } 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, "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, } } }; 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 (response["transaction"]?.Value() is not null) { result.Details = new PayDetails() { 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(); } }