mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 15:44:26 +01:00
814 lines
27 KiB
C#
814 lines
27 KiB
C#
#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<BlinkCurrency?> 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<JObject>(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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
/// <exception cref="InvalidOperationException"></exception>
|
|
async Task<WalletInfo> 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<string> 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<LightningInvoice?> 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<dynamic>(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<string>(), _network);
|
|
return new LightningInvoice()
|
|
{
|
|
Id = invoice["paymentHash"].Value<string>(),
|
|
Amount = invoice["satoshis"] is null? bolt11.MinimumAmount: LightMoney.Satoshis(invoice["satoshis"].Value<long>()),
|
|
Preimage = invoice["paymentSecret"].Value<string>(),
|
|
PaidAt = (invoice["paymentStatus"].Value<string>()) == "PAID"? DateTimeOffset.UtcNow : null,
|
|
Status = (invoice["paymentStatus"].Value<string>()) switch
|
|
{
|
|
"EXPIRED" => LightningInvoiceStatus.Expired,
|
|
"PAID" => LightningInvoiceStatus.Paid,
|
|
"PENDING" => LightningInvoiceStatus.Unpaid
|
|
},
|
|
BOLT11 = invoice["paymentRequest"].Value<string>(),
|
|
PaymentHash = invoice["paymentHash"].Value<string>(),
|
|
ExpiresAt = bolt11.ExpiryDate
|
|
};
|
|
}
|
|
|
|
public async Task<LightningInvoice?> GetInvoice(uint256 paymentHash,
|
|
CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
return await GetInvoice(paymentHash.ToString(), cancellation);
|
|
}
|
|
|
|
public async Task<LightningInvoice[]> ListInvoices(CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
return await ListInvoices(new ListInvoicesParams(), cancellation);
|
|
}
|
|
|
|
public async Task<LightningInvoice[]> 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<dynamic>(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<LightningPayment?> 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<dynamic>(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<string>();
|
|
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<long>()),
|
|
AmountSent = bolt11.MinimumAmount,
|
|
Preimage = preimage
|
|
|
|
};
|
|
}
|
|
|
|
public async Task<LightningPayment[]> ListPayments(CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
return await ListPayments(new ListPaymentsParams(), cancellation);
|
|
}
|
|
|
|
public async Task<LightningPayment[]> 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<dynamic>(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<LightningInvoice> CreateInvoice(LightMoney amount, string description, TimeSpan expiry,
|
|
CancellationToken cancellation = new())
|
|
{
|
|
return await CreateInvoice(new CreateInvoiceParams(amount, description, expiry), cancellation);
|
|
}
|
|
|
|
public async Task<LightningInvoice> 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<dynamic>(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<ILightningInvoiceListener> 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<string> _invoices = Channel.CreateUnbounded<string>();
|
|
private readonly IDisposable _subscription;
|
|
private readonly ILogger _logger;
|
|
|
|
public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient, ILogger logger)
|
|
{
|
|
_logger = logger;
|
|
_lightningClient = lightningClient;
|
|
var stream = httpClient.CreateSubscriptionStream<JObject>(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<string>() != "RECEIVE")
|
|
return;
|
|
var invoiceId = response.Data
|
|
.SelectToken("myUpdates.update.transaction.initiationVia.paymentHash")?.Value<string>();
|
|
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<LightningInvoice> 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<dynamic>(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<LightningNodeInformation> GetInfo(CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public async Task<LightningNodeBalance> 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<JObject>(request, cancellation);
|
|
var walletById = response.Data["me"]?["defaultAccount"]?["walletById"];
|
|
if (walletById?["walletCurrency"]?.Value<string>() is string currency &&
|
|
walletById?["balance"]?.Value<long?>() is long balance &&
|
|
ParseBlinkCurrency(currency) is BlinkCurrency.BTC)
|
|
{
|
|
return new LightningNodeBalance()
|
|
{
|
|
OffchainBalance = new OffchainBalance()
|
|
{
|
|
Local = LightMoney.Satoshis(balance)
|
|
}
|
|
};
|
|
}
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
public async Task<PayResponse> Pay(PayInvoiceParams payParams,
|
|
CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
return await Pay(null, new PayInvoiceParams(), cancellation);
|
|
}
|
|
|
|
public async Task<PayResponse> 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<dynamic>(request, cts.Token)).Data.lnInvoicePaymentSend;
|
|
|
|
var result = new PayResponse();
|
|
result.Result = response["status"].Value<string>() 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<string>();
|
|
}
|
|
if (response["transaction"]?.Value<JObject>() is not null)
|
|
{
|
|
result.Details = new PayDetails()
|
|
{
|
|
PaymentHash = bolt11Parsed.PaymentHash ?? new uint256(response["transaction"]["initiationVia"]["paymentHash"].Value<string>()),
|
|
Status = response["status"].Value<string>() 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<string>() is null? null: new uint256(response["transaction"]["settlementVia"]["preImage"].Value<string>()),
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
public async Task<PayResponse> 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<BitcoinAddress> GetDepositAddress(CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
public async Task<OpenChannelResponse> OpenChannel(OpenChannelRequest openChannelRequest,
|
|
CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
public async Task<ConnectionResult> ConnectTo(NodeInfo nodeInfo,
|
|
CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
public async Task<LightningChannel[]> ListChannels(CancellationToken cancellation = new CancellationToken())
|
|
{
|
|
throw new NotImplementedException();
|
|
}
|
|
|
|
public async Task<ValidationResult?> 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;
|
|
} |