Merge pull request #97 from NicolasDorier/improv-blink

Blink Lightning Client improvements
This commit is contained in:
Andrew Camilleri
2025-06-30 08:29:31 +02:00
committed by GitHub
3 changed files with 210 additions and 156 deletions

View File

@@ -1,19 +1,19 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Reactive.Disposables;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Payments.Lightning;
using GraphQL; using GraphQL;
using GraphQL.Client.Abstractions.Websocket; using GraphQL.Client.Abstractions.Websocket;
using GraphQL.Client.Http; using GraphQL.Client.Http;
using GraphQL.Client.Http.Websocket; using GraphQL.Client.Http.Websocket;
using GraphQL.Client.Serializer.Newtonsoft; using GraphQL.Client.Serializer.Newtonsoft;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -23,13 +23,88 @@ using Network = NBitcoin.Network;
namespace BTCPayServer.Plugins.Blink; namespace BTCPayServer.Plugins.Blink;
public class BlinkLightningClient : ILightningClient 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 string _apiKey;
private readonly Uri _apiEndpoint; private readonly Uri _apiEndpoint;
public string? WalletId { get; set; }
public string? WalletCurrency { get; set; } async Task<string> GetWalletId() => ExplicitWalletId ?? (await GetWalletInfo()).WalletId;
public string? ExplicitWalletId { get; set; }
private readonly Network _network; private readonly Network _network;
public ILogger Logger; public ILogger Logger;
@@ -39,11 +114,12 @@ public class BlinkLightningClient : ILightningClient
{ {
[JsonProperty("X-API-KEY")] public string ApiKey { get; set; } [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; _apiKey = apiKey;
_apiEndpoint = apiEndpoint; _apiEndpoint = apiEndpoint;
WalletId = walletId; ExplicitWalletId = walletId;
ExplicitCurrency = currency;
_network = network; _network = network;
Logger = logger; Logger = logger;
_client = new GraphQLHttpClient(new GraphQLHttpClientOptions() {EndPoint = _apiEndpoint, _client = new GraphQLHttpClient(new GraphQLHttpClientOptions() {EndPoint = _apiEndpoint,
@@ -66,13 +142,15 @@ public class BlinkLightningClient : ILightningClient
} }
public BlinkCurrency? ExplicitCurrency { get; set; }
public override string ToString() 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<LightningInvoice?> GetInvoice(string invoiceId, public async Task<LightningInvoice?> GetInvoice(string invoiceId,
CancellationToken cancellation = new CancellationToken()) CancellationToken cancellation = default)
{ {
var reques = new GraphQLRequest var reques = new GraphQLRequest
@@ -96,7 +174,7 @@ query InvoiceByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!) {
OperationName = "InvoiceByPaymentHash", OperationName = "InvoiceByPaymentHash",
Variables = new Variables = new
{ {
walletId = WalletId, walletId = await GetWalletId(),
paymentHash = invoiceId paymentHash = invoiceId
} }
}; };
@@ -169,7 +247,7 @@ query Invoices($walletId: WalletId!) {
OperationName = "Invoices", OperationName = "Invoices",
Variables = new Variables = new
{ {
walletId = WalletId walletId = await GetWalletId()
} }
}; };
var response = await _client.SendQueryAsync<dynamic>(reques, cancellation); var response = await _client.SendQueryAsync<dynamic>(reques, cancellation);
@@ -218,7 +296,7 @@ query TransactionsByPaymentHash($paymentHash: PaymentHash!, $walletId: WalletId!
OperationName = "TransactionsByPaymentHash", OperationName = "TransactionsByPaymentHash",
Variables = new Variables = new
{ {
walletId = WalletId, walletId = await GetWalletId(),
paymentHash = paymentHash paymentHash = paymentHash
} }
}; };
@@ -308,7 +386,7 @@ query Transactions($walletId: WalletId!) {
OperationName = "Transactions", OperationName = "Transactions",
Variables = new Variables = new
{ {
walletId = WalletId walletId = await GetWalletId(),
} }
}; };
var response = await _client.SendQueryAsync<dynamic>(reques, cancellation); var response = await _client.SendQueryAsync<dynamic>(reques, cancellation);
@@ -328,9 +406,8 @@ query Transactions($walletId: WalletId!) {
public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams createInvoiceRequest, public async Task<LightningInvoice> CreateInvoice(CreateInvoiceParams createInvoiceRequest,
CancellationToken cancellation = new()) CancellationToken cancellation = new())
{ {
string query; var isUSD = (await GetWalletInfo()).WalletCurrency == BlinkCurrency.USD;
var query = isUSD ? @"
query = WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true ? @"
mutation lnInvoiceCreate($input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!) { mutation lnInvoiceCreate($input: LnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipientInput!) {
lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient(input: $input) { lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient(input: $input) {
invoice { invoice {
@@ -370,7 +447,7 @@ mutation lnInvoiceCreate($input: LnInvoiceCreateOnBehalfOfRecipientInput!) {
{ {
input = new input = new
{ {
recipientWalletId = WalletId, recipientWalletId = await GetWalletId(),
memo = createInvoiceRequest.Description, memo = createInvoiceRequest.Description,
descriptionHash = createInvoiceRequest.DescriptionHash?.ToString(), descriptionHash = createInvoiceRequest.DescriptionHash?.ToString(),
amount = (long)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi), amount = (long)createInvoiceRequest.Amount.ToUnit(LightMoneyUnit.Satoshi),
@@ -380,13 +457,13 @@ expiresIn = (int)createInvoiceRequest.Expiry.TotalMinutes
} }
}; };
var response = await _client.SendQueryAsync<dynamic>(reques, cancellation); var response = await _client.SendQueryAsync<dynamic>(reques, cancellation);
var inv = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true var inv = (isUSD
? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.invoice ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.invoice
: response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice)as JObject; : response.Data.lnInvoiceCreateOnBehalfOfRecipient.invoice) as JObject;
if (inv is null) if (inv is null)
{ {
var errors = (WalletCurrency?.Equals("btc", StringComparison.InvariantCultureIgnoreCase) is not true var errors = (isUSD
? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.errors ? response.Data.lnUsdInvoiceBtcDenominatedCreateOnBehalfOfRecipient.errors
: response.Data.lnInvoiceCreateOnBehalfOfRecipient.errors) as JArray; : response.Data.lnInvoiceCreateOnBehalfOfRecipient.errors) as JArray;
@@ -405,120 +482,88 @@ expiresIn = (int)createInvoiceRequest.Expiry.TotalMinutes
public class BlinkListener : ILightningInvoiceListener public class BlinkListener : ILightningInvoiceListener
{ {
private volatile int _WebSocketErrorCount = 0;
private readonly BlinkLightningClient _lightningClient; private readonly BlinkLightningClient _lightningClient;
private readonly Channel<LightningInvoice> _invoices = Channel.CreateUnbounded<LightningInvoice>(); private readonly Channel<string> _invoices = Channel.CreateUnbounded<string>();
private readonly IDisposable _subscription; private readonly IDisposable _subscription;
private readonly ILogger _logger; private readonly ILogger _logger;
public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient, ILogger logger) public BlinkListener(GraphQLHttpClient httpClient, BlinkLightningClient lightningClient, ILogger logger)
{ {
try _logger = logger;
_lightningClient = lightningClient;
var stream = httpClient.CreateSubscriptionStream<JObject>(new GraphQLRequest()
{ {
_logger = logger; Query = @"subscription myUpdates {
_lightningClient = lightningClient; myUpdates {
var stream = httpClient.CreateSubscriptionStream<JObject>(new GraphQLRequest() update {
{ ... on LnUpdate {
Query = @"subscription myUpdates { transaction {
myUpdates { initiationVia {
update { ... on InitiationViaLn {
... on LnUpdate { paymentHash
transaction {
initiationVia {
... on InitiationViaLn {
paymentHash
}
}
direction
} }
} }
direction
} }
} }
} }
}
}
", OperationName = "myUpdates" ", OperationName = "myUpdates"
}); }, webSocketExceptionHandler: (wse) =>
_subscription = stream.Subscribe(async response =>
{
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;
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"); _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() public void Dispose()
{ {
_subscription.Dispose(); _subscription.Dispose();
_invoices.Writer.TryComplete(); _invoices.Writer.TryComplete(new ObjectDisposedException(nameof(BlinkListener)));
_wsSubscriptionDisposable.Dispose();
streamEnded.TrySetResult();
} }
private TaskCompletionSource streamEnded = new();
private readonly IDisposable _wsSubscriptionDisposable;
public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation) public async Task<LightningInvoice> WaitInvoice(CancellationToken cancellation)
{ {
var resultz = await Task.WhenAny(streamEnded.Task, _invoices.Reader.ReadAsync(cancellation).AsTask()); await foreach (var id in _invoices.Reader.ReadAllAsync(cancellation))
if (resultz is Task<LightningInvoice> res)
{ {
return await res; var invoice = await _lightningClient.GetInvoice(id, cancellation);
if (invoice is not null)
return invoice;
} }
throw new ChannelClosedException();
// Enhanced logging to identify which task completed and its details
var taskType = resultz.GetType();
var typeName = taskType.Name.Contains('`') ? taskType.Name.Split('`')[0] : taskType.Name;
var genericArgs = taskType.GenericTypeArguments;
var genericTypeInfo = genericArgs.Length > 0
? $"<{string.Join(", ", genericArgs.Select(t => t.Name))}>"
: "";
_logger.LogInformation("WaitInvoice completed - Task Type: {TaskType}{GenericArgs}, " +
"Status: {Status}, IsCompletedSuccessfully: {IsCompletedSuccessfully}, " +
"IsFaulted: {IsFaulted}",
typeName,
genericTypeInfo,
resultz.Status,
resultz.IsCompletedSuccessfully,
resultz.IsFaulted);
if (resultz.IsFaulted && resultz.Exception != null)
{
_logger.LogError("Task completed with fault: {Exception}", resultz.Exception);
}
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 var reques = new GraphQLRequest
@@ -552,7 +597,7 @@ query GetNetworkAndDefaultWallet {
"regtest" => Network.RegTest, "regtest" => Network.RegTest,
_ => throw new ArgumentOutOfRangeException() _ => throw new ArgumentOutOfRangeException()
}; };
return (network, defaultWalletId, defaultWalletCurrency); return (network, defaultWalletId, ParseBlinkCurrency(defaultWalletCurrency));
} }
public Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = new CancellationToken()) public Task<LightningNodeInformation> GetInfo(CancellationToken cancellation = new CancellationToken())
@@ -563,6 +608,9 @@ query GetNetworkAndDefaultWallet {
public async Task<LightningNodeBalance> GetBalance(CancellationToken cancellation = new()) 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 var request = new GraphQLRequest
{ {
Query = @" Query = @"
@@ -579,25 +627,25 @@ query GetWallet($walletId: WalletId!) {
}", }",
OperationName = "GetWallet", OperationName = "GetWallet",
Variables = new { Variables = new {
walletId = WalletId walletId = await GetWalletId()
} }
}; };
var response = await _client.SendQueryAsync<dynamic>(request, cancellation); var response = await _client.SendQueryAsync<JObject>(request, cancellation);
var walletById = response.Data["me"]?["defaultAccount"]?["walletById"];
WalletCurrency = response.Data.me.defaultAccount.walletById.walletCurrency; if (walletById?["walletCurrency"]?.Value<string>() is string currency &&
if (response.Data.me.defaultAccount.walletById.walletCurrency == "BTC") walletById?["balance"]?.Value<long?>() is long balance &&
ParseBlinkCurrency(currency) is BlinkCurrency.BTC)
{ {
return new LightningNodeBalance() return new LightningNodeBalance()
{ {
OffchainBalance = new OffchainBalance() OffchainBalance = new OffchainBalance()
{ {
Local = LightMoney.Satoshis((long)response.Data.me.defaultAccount.walletById.balance) Local = LightMoney.Satoshis(balance)
} }
}; };
} }
throw new NotSupportedException();
return new LightningNodeBalance();
} }
public async Task<PayResponse> Pay(PayInvoiceParams payParams, public async Task<PayResponse> Pay(PayInvoiceParams payParams,
@@ -607,7 +655,7 @@ query GetWallet($walletId: WalletId!) {
} }
public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams, public async Task<PayResponse> Pay(string bolt11, PayInvoiceParams payParams,
CancellationToken cancellation = new CancellationToken()) CancellationToken cancellation = default)
{ {
var request = new GraphQLRequest var request = new GraphQLRequest
@@ -647,7 +695,7 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
OperationName = "LnInvoicePaymentSend", OperationName = "LnInvoicePaymentSend",
Variables = new { Variables = new {
input = new { input = new {
walletId = WalletId, walletId = await GetWalletId(),
paymentRequest = bolt11, paymentRequest = bolt11,
} }
} }
@@ -689,8 +737,8 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
"FAILURE" => LightningPaymentStatus.Failed, "FAILURE" => LightningPaymentStatus.Failed,
"PENDING" => LightningPaymentStatus.Pending, "PENDING" => LightningPaymentStatus.Pending,
"SUCCESS" => LightningPaymentStatus.Complete, "SUCCESS" => LightningPaymentStatus.Complete,
null => LightningPaymentStatus.Unknown, string status => throw new ArgumentOutOfRangeException($"Unknown status received by blink ({status})"),
_ => throw new ArgumentOutOfRangeException() _ => LightningPaymentStatus.Unknown,
}, },
Preimage = response["transaction"]["settlementVia"]?["preImage"].Value<string>() is null? null: new uint256(response["transaction"]["settlementVia"]["preImage"].Value<string>()), Preimage = response["transaction"]["settlementVia"]?["preImage"].Value<string>() is null? null: new uint256(response["transaction"]["settlementVia"]["preImage"].Value<string>()),
}; };
@@ -731,4 +779,36 @@ mutation LnInvoicePaymentSend($input: LnInvoicePaymentInput!) {
{ {
throw new NotImplementedException(); 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;
} }

View File

@@ -3,6 +3,7 @@ using System;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Payments.Lightning;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Network = NBitcoin.Network; using Network = NBitcoin.Network;
@@ -82,48 +83,21 @@ public class BlinkLightningConnectionStringHandler : ILightningConnectionStringH
client.BaseAddress = uri; client.BaseAddress = uri;
kv.TryGetValue("wallet-id", out var walletId); 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)) BlinkCurrency? currency = null;
{ if (kv.TryGetValue("currency", out var v))
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
{ {
try 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 null;
} }
} }
return bclient; return new BlinkLightningClient(apiKey, uri, walletId, currency, network, client, _loggerFactory.CreateLogger(nameof(BlinkLightningClient)));
} }
} }

View File

@@ -29,7 +29,7 @@
<div class="accordion-body"> <div class="accordion-body">
<ul class="pb-2"> <ul class="pb-2">
<li> <li>
<code><b>type=</b>blink;<b>server=</b>https://api.blink.sv/graphql;<b>api-key</b>=blink_...;<b>wallet-id=</b>xyz</code> <code><b>type=</b>blink;<b>server=</b>https://api.blink.sv/graphql;<b>api-key</b>=blink_...;<b>wallet-id=</b>xyz;<b>currency=</b>BTC;</code>
</li> </li>
</ul> </ul>
<p class="my-2">Head over to the <a href="https://dashboard.blink.sv" target="_blank" rel="noreferrer noopener" >Blink dashboard</a> 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.</p> <p class="my-2">Head over to the <a href="https://dashboard.blink.sv" target="_blank" rel="noreferrer noopener" >Blink dashboard</a> 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.</p>