Compare commits

...

9 Commits

14 changed files with 287 additions and 132 deletions

View File

@ -2138,19 +2138,20 @@ donation:
}
}
[Fact]
[Trait("Integration", "Integration")]
public void CheckQuadrigacxRateProvider()
{
var quadri = new QuadrigacxRateProvider();
var rates = quadri.GetRatesAsync().GetAwaiter().GetResult();
Assert.NotEmpty(rates);
Assert.NotEqual(0.0m, rates.First().BidAsk.Bid);
Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_CAD")).Bid);
Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_USD")).Bid);
Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_CAD")).Bid);
Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD")));
}
//[Fact]
//[Trait("Integration", "Integration")]
// 29 january, the exchange is down
//public void CheckQuadrigacxRateProvider()
//{
// var quadri = new QuadrigacxRateProvider();
// var rates = quadri.GetRatesAsync().GetAwaiter().GetResult();
// Assert.NotEmpty(rates);
// Assert.NotEqual(0.0m, rates.First().BidAsk.Bid);
// Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_CAD")).Bid);
// Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_USD")).Bid);
// Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_CAD")).Bid);
// Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD")));
//}
[Fact]
[Trait("Integration", "Integration")]
@ -2164,6 +2165,9 @@ donation:
.Select(p => (ExpectedName: p.Key, ResultAsync: p.Value.GetRatesAsync(), Fetcher: (BackgroundFetcherRateProvider)p.Value))
.ToList())
{
Logs.Tester.LogInformation($"Testing {result.ExpectedName}");
if (result.ExpectedName == "quadrigacx")
continue; // 29 january, the exchange is down
result.Fetcher.InvalidateCache();
var exchangeRates = result.ResultAsync.Result;
result.Fetcher.InvalidateCache();
@ -2312,6 +2316,42 @@ donation:
Assert.Throws<InvalidOperationException>(() => fetch.GetRatesAsync().GetAwaiter().GetResult());
}
[Fact]
[Trait("Fast", "Fast")]
public void CheckParseStatusMessageModel()
{
var legacyStatus = "Error: some bad shit happened";
var parsed = new StatusMessageModel(legacyStatus);
Assert.Equal(legacyStatus, parsed.Message);
Assert.Equal(StatusMessageModel.StatusSeverity.Error, parsed.Severity);
var legacyStatus2 = "Some normal shit happened";
parsed = new StatusMessageModel(legacyStatus2);
Assert.Equal(legacyStatus2, parsed.Message);
Assert.Equal(StatusMessageModel.StatusSeverity.Success, parsed.Severity);
var newStatus = new StatusMessageModel()
{
Html = "<a href='xxx'>something new</a>",
Severity = StatusMessageModel.StatusSeverity.Info
};
parsed = new StatusMessageModel(newStatus.ToString());
Assert.Null(parsed.Message);
Assert.Equal(newStatus.Html, parsed.Html);
Assert.Equal(StatusMessageModel.StatusSeverity.Info, parsed.Severity);
var newStatus2 = new StatusMessageModel()
{
Message = "something new",
Severity = StatusMessageModel.StatusSeverity.Success
};
parsed = new StatusMessageModel(newStatus2.ToString());
Assert.Null(parsed.Html);
Assert.Equal(newStatus2.Message, parsed.Message);
Assert.Equal(StatusMessageModel.StatusSeverity.Success, parsed.Severity);
}
private static bool IsMapped(Invoice invoice, ApplicationDbContext ctx)
{
var h = BitcoinAddress.Create(invoice.BitcoinAddress, Network.RegTest).ScriptPubKey.Hash.ToString();

View File

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
<Version>1.0.3.49</Version>
<Version>1.0.3.51</Version>
<NoWarn>NU1701,CA1816,CA1308,CA1810,CA2208</NoWarn>
</PropertyGroup>
<PropertyGroup>

View File

@ -2,6 +2,7 @@
using BTCPayServer.Filters;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using NBitcoin.DataEncoders;
using NBitpayClient;
@ -14,6 +15,7 @@ namespace BTCPayServer.Controllers
{
[Authorize(AuthenticationSchemes = Security.Policies.BitpayAuthentication)]
[BitpayAPIConstraint(true)]
[EnableCors(CorsPolicies.All)]
public class AccessTokenController : Controller
{
TokenRepository _TokenRepository;

View File

@ -73,7 +73,12 @@ namespace BTCPayServer.Controllers
var stores = await _AppsHelper.GetOwnedStores(GetUserId());
if (stores.Length == 0)
{
StatusMessage = "Error: You must have created at least one store";
StatusMessage = new StatusMessageModel()
{
Html =
$"Error: You must have created at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}'>Create store</a>",
Severity = StatusMessageModel.StatusSeverity.Error
}.ToString();
return RedirectToAction(nameof(ListApps));
}
var vm = new CreateAppViewModel();
@ -88,7 +93,12 @@ namespace BTCPayServer.Controllers
var stores = await _AppsHelper.GetOwnedStores(GetUserId());
if (stores.Length == 0)
{
StatusMessage = "Error: You must own at least one store";
StatusMessage = new StatusMessageModel()
{
Html =
$"Error: You must have created at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}'>Create store</a>",
Severity = StatusMessageModel.StatusSeverity.Error
}.ToString();
return RedirectToAction(nameof(ListApps));
}
var selectedStore = vm.SelectedStore;

View File

@ -176,30 +176,38 @@ namespace BTCPayServer.Controllers
}
store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id));
var invoice = await _InvoiceController.CreateInvoiceCore(new Invoice()
try
{
OrderId = $"{CrowdfundHubStreamer.CrowdfundInvoiceOrderIdPrefix}{appId}",
Currency = settings.TargetCurrency,
ItemCode = request.ChoiceKey ?? string.Empty,
ItemDesc = title,
BuyerEmail = request.Email,
Price = price,
NotificationURL = settings.NotificationUrl,
FullNotifications = true,
ExtendedNotifications = true,
RedirectURL = request.RedirectUrl ?? Request.GetDisplayUrl(),
var invoice = await _InvoiceController.CreateInvoiceCore(new Invoice()
{
OrderId = $"{CrowdfundHubStreamer.CrowdfundInvoiceOrderIdPrefix}{appId}",
Currency = settings.TargetCurrency,
ItemCode = request.ChoiceKey ?? string.Empty,
ItemDesc = title,
BuyerEmail = request.Email,
Price = price,
NotificationURL = settings.NotificationUrl,
FullNotifications = true,
ExtendedNotifications = true,
RedirectURL = request.RedirectUrl ?? Request.GetDisplayUrl(),
}, store, HttpContext.Request.GetAbsoluteRoot());
if (request.RedirectToCheckout)
{
return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice",
new {invoiceId = invoice.Data.Id});
}, store, HttpContext.Request.GetAbsoluteRoot());
if (request.RedirectToCheckout)
{
return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice",
new {invoiceId = invoice.Data.Id});
}
else
{
return Ok(invoice.Data.Id);
}
}
else
catch (BitpayHttpException e)
{
return Ok(invoice.Data.Id);
return BadRequest(e.Message);
}
}

View File

@ -12,8 +12,8 @@ using NBitpayClient;
namespace BTCPayServer.Controllers
{
[EnableCors("BitpayAPI")]
[BitpayAPIConstraint]
[EnableCors(CorsPolicies.All)]
[Authorize(Policies.CanCreateInvoice.Key, AuthenticationSchemes = Policies.BitpayAuthentication)]
public class InvoiceControllerAPI : Controller
{

View File

@ -12,11 +12,13 @@ using BTCPayServer.Rating;
using Newtonsoft.Json;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Authentication;
using Microsoft.AspNetCore.Cors;
namespace BTCPayServer.Controllers
{
[Authorize(AuthenticationSchemes = Security.Policies.BitpayAuthentication)]
[AllowAnonymous]
[EnableCors(CorsPolicies.All)]
public class RateController : Controller
{
RateFetcher _RateProviderFactory;

View File

@ -208,8 +208,8 @@ namespace BTCPayServer.Controllers
{
builder.Scheme = this.Request.Scheme;
builder.Host = vm.DNSDomain;
var addresses1 = Dns.GetHostAddressesAsync(this.Request.Host.Host);
var addresses2 = Dns.GetHostAddressesAsync(vm.DNSDomain);
var addresses1 = GetAddressAsync(this.Request.Host.Host);
var addresses2 = GetAddressAsync(vm.DNSDomain);
await Task.WhenAll(addresses1, addresses2);
var addressesSet = addresses1.GetAwaiter().GetResult().Select(c => c.ToString()).ToHashSet();
@ -253,6 +253,13 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(Maintenance));
}
private Task<IPAddress[]> GetAddressAsync(string domainOrIP)
{
if (IPAddress.TryParse(domainOrIP, out var ip))
return Task.FromResult(new[] { ip });
return Dns.GetHostAddressesAsync(domainOrIP);
}
public static string RunId = Encoders.Hex.EncodeData(NBitcoin.RandomUtils.GetBytes(32));
[HttpGet]
[Route("runid")]

View File

@ -33,13 +33,10 @@ namespace BTCPayServer.HostedServices
get; set;
}
public InvoiceEntity Invoice
public InvoicePaymentNotificationEventWrapper Notification
{
get; set;
}
public int? EventCode { get; set; }
public string Message { get; set; }
}
IBackgroundJobClient _JobClient;
@ -63,22 +60,70 @@ namespace BTCPayServer.HostedServices
_EmailSenderFactory = emailSenderFactory;
}
void Notify(InvoiceEntity invoice, int? eventCode = null, string name = null)
void Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification)
{
var dto = invoice.EntityToDTO(_NetworkProvider);
var notification = new InvoicePaymentNotificationEventWrapper()
{
Data = new InvoicePaymentNotification()
{
Id = dto.Id,
Currency = dto.Currency,
CurrentTime = dto.CurrentTime,
ExceptionStatus = dto.ExceptionStatus,
ExpirationTime = dto.ExpirationTime,
InvoiceTime = dto.InvoiceTime,
PosData = dto.PosData,
Price = dto.Price,
Status = dto.Status,
BuyerFields = invoice.RefundMail == null ? null : new Newtonsoft.Json.Linq.JObject() { new JProperty("buyerEmail", invoice.RefundMail) },
PaymentSubtotals = dto.PaymentSubtotals,
PaymentTotals = dto.PaymentTotals,
AmountPaid = dto.AmountPaid,
ExchangeRates = dto.ExchangeRates,
},
Event = new InvoicePaymentNotificationEvent()
{
Code = invoiceEvent.EventCode,
Name = invoiceEvent.Name
},
ExtendedNotification = extendedNotification,
NotificationURL = invoice.NotificationURL
};
// For lightning network payments, paid, confirmed and completed come all at once.
// So despite the event is "paid" or "confirmed" the Status of the invoice is technically complete
// This confuse loggers who think their endpoint get duplicated events
// So here, we just override the status expressed by the notification
if (invoiceEvent.Name == InvoiceEvent.Confirmed)
{
notification.Data.Status = InvoiceState.ToString(InvoiceStatus.Confirmed);
}
if (invoiceEvent.Name == InvoiceEvent.PaidInFull)
{
notification.Data.Status = InvoiceState.ToString(InvoiceStatus.Paid);
}
//////////////////
// We keep backward compatibility with bitpay by passing BTC info to the notification
// we don't pass other info, as it is a bad idea to use IPN data for logic processing (can be faked)
var btcCryptoInfo = dto.CryptoInfo.FirstOrDefault(c => c.GetpaymentMethodId() == new PaymentMethodId("BTC", Payments.PaymentTypes.BTCLike));
if (btcCryptoInfo != null)
{
#pragma warning disable CS0618
notification.Data.Rate = dto.Rate;
notification.Data.Url = dto.Url;
notification.Data.BTCDue = dto.BTCDue;
notification.Data.BTCPaid = dto.BTCPaid;
notification.Data.BTCPrice = dto.BTCPrice;
#pragma warning restore CS0618
}
CancellationTokenSource cts = new CancellationTokenSource(10000);
if (!String.IsNullOrEmpty(invoice.NotificationEmail))
{
// just extracting most important data for email body, merchant should query API back for full invoice based on Invoice.Id
var ipn = new
{
invoice.Id,
invoice.Status,
invoice.StoreId
};
// TODO: Consider adding info on ItemDesc and payment info (amount)
var emailBody = NBitcoin.JsonConverters.Serializer.ToString(ipn);
var emailBody = NBitcoin.JsonConverters.Serializer.ToString(notification);
_EmailSenderFactory.GetEmailSender(invoice.StoreId).SendEmail(
invoice.NotificationEmail,
@ -86,9 +131,9 @@ namespace BTCPayServer.HostedServices
emailBody);
}
if (string.IsNullOrEmpty(invoice.NotificationURL))
if (string.IsNullOrEmpty(invoice.NotificationURL) || !Uri.IsWellFormedUriString(invoice.NotificationURL, UriKind.Absolute))
return;
var invoiceStr = NBitcoin.JsonConverters.Serializer.ToString(new ScheduledJob() { TryCount = 0, Invoice = invoice, EventCode = eventCode, Message = name });
var invoiceStr = NBitcoin.JsonConverters.Serializer.ToString(new ScheduledJob() { TryCount = 0, Notification = notification });
if (!string.IsNullOrEmpty(invoice.NotificationURL))
_JobClient.Schedule(() => NotifyHttp(invoiceStr), TimeSpan.Zero);
}
@ -97,30 +142,23 @@ namespace BTCPayServer.HostedServices
{
var job = NBitcoin.JsonConverters.Serializer.ToObject<ScheduledJob>(invoiceData);
bool reschedule = false;
var aggregatorEvent = new InvoiceIPNEvent(job.Notification.Data.Id, job.Notification.Event.Code, job.Notification.Event.Name);
CancellationTokenSource cts = new CancellationTokenSource(10000);
try
{
HttpResponseMessage response = await SendNotification(job.Invoice, job.EventCode, job.Message, cts.Token);
HttpResponseMessage response = await SendNotification(job.Notification, cts.Token);
reschedule = !response.IsSuccessStatusCode;
_EventAggregator.Publish<InvoiceIPNEvent>(new InvoiceIPNEvent(job.Invoice.Id, job.EventCode, job.Message)
{
Error = reschedule ? $"Unexpected return code: {(int)response.StatusCode}" : null
});
aggregatorEvent.Error = reschedule ? $"Unexpected return code: {(int)response.StatusCode}" : null;
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
_EventAggregator.Publish<InvoiceIPNEvent>(new InvoiceIPNEvent(job.Invoice.Id, job.EventCode, job.Message)
{
Error = "Timeout"
});
aggregatorEvent.Error = "Timeout";
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
reschedule = true;
}
catch (Exception ex)
{
_EventAggregator.Publish<InvoiceIPNEvent>(new InvoiceIPNEvent(job.Invoice.Id, job.EventCode, job.Message)
{
Error = ex.Message
});
reschedule = true;
List<string> messages = new List<string>();
@ -131,10 +169,8 @@ namespace BTCPayServer.HostedServices
}
string message = String.Join(',', messages.ToArray());
_EventAggregator.Publish<InvoiceIPNEvent>(new InvoiceIPNEvent(job.Invoice.Id, job.EventCode, job.Message)
{
Error = $"Unexpected error: {message}"
});
aggregatorEvent.Error = $"Unexpected error: {message}";
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
}
finally { cts?.Dispose(); }
@ -160,64 +196,35 @@ namespace BTCPayServer.HostedServices
public InvoicePaymentNotificationEvent Event { get; set; }
[JsonProperty("data")]
public InvoicePaymentNotification Data { get; set; }
[JsonProperty("extendedNotification")]
public bool ExtendedNotification { get; set; }
[JsonProperty(PropertyName = "notificationURL")]
public string NotificationURL { get; set; }
}
Encoding UTF8 = new UTF8Encoding(false);
private async Task<HttpResponseMessage> SendNotification(InvoiceEntity invoice, int? eventCode, string name, CancellationToken cancellation)
private async Task<HttpResponseMessage> SendNotification(InvoicePaymentNotificationEventWrapper notification, CancellationToken cancellation)
{
var request = new HttpRequestMessage();
request.Method = HttpMethod.Post;
var dto = invoice.EntityToDTO(_NetworkProvider);
InvoicePaymentNotification notification = new InvoicePaymentNotification()
{
Id = dto.Id,
Currency = dto.Currency,
CurrentTime = dto.CurrentTime,
ExceptionStatus = dto.ExceptionStatus,
ExpirationTime = dto.ExpirationTime,
InvoiceTime = dto.InvoiceTime,
PosData = dto.PosData,
Price = dto.Price,
Status = dto.Status,
BuyerFields = invoice.RefundMail == null ? null : new Newtonsoft.Json.Linq.JObject() { new JProperty("buyerEmail", invoice.RefundMail) },
PaymentSubtotals = dto.PaymentSubtotals,
PaymentTotals = dto.PaymentTotals,
AmountPaid = dto.AmountPaid,
ExchangeRates = dto.ExchangeRates,
var notificationString = NBitcoin.JsonConverters.Serializer.ToString(notification);
var jobj = JObject.Parse(notificationString);
};
// We keep backward compatibility with bitpay by passing BTC info to the notification
// we don't pass other info, as it is a bad idea to use IPN data for logic processing (can be faked)
var btcCryptoInfo = dto.CryptoInfo.FirstOrDefault(c => c.GetpaymentMethodId() == new PaymentMethodId("BTC", Payments.PaymentTypes.BTCLike));
if (btcCryptoInfo != null)
if (notification.ExtendedNotification)
{
#pragma warning disable CS0618
notification.Rate = dto.Rate;
notification.Url = dto.Url;
notification.BTCDue = dto.BTCDue;
notification.BTCPaid = dto.BTCPaid;
notification.BTCPrice = dto.BTCPrice;
#pragma warning restore CS0618
}
string notificationString = null;
if (eventCode.HasValue)
{
var wrapper = new InvoicePaymentNotificationEventWrapper();
wrapper.Data = notification;
wrapper.Event = new InvoicePaymentNotificationEvent() { Code = eventCode.Value, Name = name };
notificationString = JsonConvert.SerializeObject(wrapper);
jobj.Remove("extendedNotification");
jobj.Remove("notificationURL");
notificationString = jobj.ToString();
}
else
{
notificationString = JsonConvert.SerializeObject(notification);
notificationString = jobj["data"].ToString();
}
request.RequestUri = new Uri(invoice.NotificationURL, UriKind.Absolute);
request.RequestUri = new Uri(notification.NotificationURL, UriKind.Absolute);
request.Content = new StringContent(notificationString, UTF8, "application/json");
var response = await Enqueue(invoice.Id, async () => await _Client.SendAsync(request, cancellation));
var response = await Enqueue(notification.Data.Id, async () => await _Client.SendAsync(request, cancellation));
return response;
}
@ -306,17 +313,17 @@ namespace BTCPayServer.HostedServices
e.Name == InvoiceEvent.Completed ||
e.Name == InvoiceEvent.ExpiredPaidPartial
)
Notify(invoice);
Notify(invoice, e, false);
}
if (e.Name == "invoice_confirmed")
if (e.Name == InvoiceEvent.Confirmed)
{
Notify(invoice);
Notify(invoice, e, false);
}
if (invoice.ExtendedNotifications)
{
Notify(invoice, e.EventCode, e.Name);
Notify(invoice, e, true);
}
}));

View File

@ -92,14 +92,6 @@ namespace BTCPayServer.Hosting
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
});
services.AddCors(o =>
{
o.AddPolicy("BitpayAPI", b =>
{
b.AllowAnyMethod().AllowAnyHeader().AllowAnyOrigin();
});
});
// If the HTTPS certificate path is not set this logic will NOT be used and the default Kestrel binding logic will be.
string httpsCertificateFilePath = Configuration.GetOrDefault<string>("HttpsCertificateFilePath", null);
bool useDefaultCertificate = Configuration.GetOrDefault<bool>("HttpsUseDefaultCertificate", false);

View File

@ -0,0 +1,80 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Models
{
public class StatusMessageModel
{
public StatusMessageModel()
{
}
public StatusMessageModel(string s)
{
if (string.IsNullOrEmpty(s))
return;
try
{
if (s.StartsWith("{", StringComparison.InvariantCultureIgnoreCase) &&
s.EndsWith("}", StringComparison.InvariantCultureIgnoreCase))
{
var model = JObject.Parse(s).ToObject<StatusMessageModel>();
Html = model.Html;
Message = model.Message;
Severity = model.Severity;
}
else
{
ParseNonJsonStatus(s);
}
}
catch (Exception)
{
ParseNonJsonStatus(s);
}
}
public string Message { get; set; }
public string Html { get; set; }
public StatusSeverity Severity { get; set; }
public string SeverityCSS
{
get
{
switch (Severity)
{
case StatusSeverity.Info:
return "info";
case StatusSeverity.Error:
return "danger";
case StatusSeverity.Success:
return "success";
default:
throw new ArgumentOutOfRangeException();
}
}
}
public override string ToString()
{
return JObject.FromObject(this).ToString(Formatting.None);
}
private void ParseNonJsonStatus(string s)
{
Message = s;
Severity = s.StartsWith("Error", StringComparison.InvariantCultureIgnoreCase)
? StatusSeverity.Error
: StatusSeverity.Success;
}
public enum StatusSeverity
{
Info,
Error,
Success
}
}
}

View File

@ -192,7 +192,7 @@
</div>
<div class="col-md-6">
<h3>Point of Sale Data</h3>
<partial name="PosData" model="@Model.PosData"></partial>
<partial name="PosData" model="@Model.PosData" />
</div>
</div>
}

View File

@ -1,10 +1,17 @@
@model string
@if(!String.IsNullOrEmpty(Model))
@if(!string.IsNullOrEmpty(Model))
{
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
var parsedModel = new StatusMessageModel(Model);
<div class="alert alert-@parsedModel.SeverityCSS alert-dismissible" role="alert">
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
@Model
@if (!string.IsNullOrEmpty(parsedModel.Message))
{
@parsedModel.Message
}
@if (!string.IsNullOrEmpty(parsedModel.Html))
{
@Html.Raw(parsedModel.Html)
}
</div>
}

View File

@ -43,7 +43,7 @@
Select ledger wallet account
</button>
<div class="dropdown-menu" aria-labelledby="ledgerAccountsDropdownMenuButton">
@for (var i = 0; i < 20; i++)
@for (var i = 0; i < 5; i++)
{
<a class="dropdown-item ledger-info-recommended" data-ledgerkeypath="@Model.RootKeyPath.Derive(i, true)" href="#">Account @i (<span>@Model.RootKeyPath.Derive(i, true)</span>)</a>
}