Add LUNO exchange as default for ZAR currency
What changed, and why it matters
This commit mainly adds the LUNO exchange as the default source for South African Rand (ZAR) exchange rates in BTCPay Server. It also includes a small hardening fix in another rate provider so that missing bid/ask values don't crash the app, plus a batch of unrelated test reliability improvements. There is no clear security vulnerability being patched.
Treat as routine feature/maintenance commit. Review LunoRateProvider for standard HTTP client and parsing hygiene during normal code review, but no immediate security action is required.
Security signals we found
New third-party HTTP rate provider added (LUNO)
Defensive null-check added to CryptoMarketExchangeRateProvider bid/ask parsing
No evidence of vulnerability disclosure, CVE, or security advisory in commit or references
Evidence from the diff
The diff adds LunoRateProvider, registers it in DI, and sets it as the default for ZAR. CryptoMarketExchangeRateProvider is updated to tolerate null bid/ask fields by falling back to the last-traded price. HitBTCRateProvider is refactored to primary-constructor syntax. KrakenExchangeRateProvider removes an unused Microsoft.CodeAnalysis using. Test files update Playwright and make pull-payment/payjoin UI tests more deterministic. PullPaymentHostedService adds a ToString override for PayoutEvent. No authentication, authorization, cryptographic, or input-validation security fixes are evident.
Changed components
BTCPayServer.Rating/Providers/LunoRateProvider.csBTCPayServer.Rating/Providers/CryptoMarketExchangeRateProvider.csBTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer.Tests/PullPaymentsTests.csBTCPayServer.Tests/PayJoinTests.csBTCPayServer/HostedServices/PullPaymentHostedService.csInspect captured patch +104 / −47
diff --git a/BTCPayServer.Rating/Providers/CryptoMarketExchangeRateProvider.cs b/BTCPayServer.Rating/Providers/CryptoMarketExchangeRateProvider.cs
index 58c5bcd..92146df 100644
--- a/BTCPayServer.Rating/Providers/CryptoMarketExchangeRateProvider.cs
+++ b/BTCPayServer.Rating/Providers/CryptoMarketExchangeRateProvider.cs
@@ -35,12 +35,15 @@ namespace BTCPayServer.Services.Rates
.Properties()
.Where(p => SupportedPairs.Contains(p.Name))
.Select(p => new PairRate(CurrencyPair.Parse(p.Name), CreateBidAsk(p)))
+ .Where(p => p.BidAsk != null)
.ToArray();
}
private static BidAsk CreateBidAsk(JProperty p)
{
- var bid = decimal.Parse(p.Value["bid"].Value<string>(), System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture);
- var ask = decimal.Parse(p.Value["ask"].Value<string>(), System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture);
+ if (p.Value["bid"]?.Type is JTokenType.Null || p.Value["ask"]?.Type is JTokenType.Null)
+ return new BidAsk(decimal.Parse(p.Value["last"]!.Value<string>(), System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture));
+ var bid = decimal.Parse(p.Value["bid"]!.Value<string>(), System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture);
+ var ask = decimal.Parse(p.Value["ask"]!.Value<string>(), System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture);
if (bid > ask)
return null;
return new BidAsk(bid, ask);
diff --git a/BTCPayServer.Rating/Providers/HitBTCRateProvider.cs b/BTCPayServer.Rating/Providers/HitBTCRateProvider.cs
index 001b7f7..6d36edb 100644
--- a/BTCPayServer.Rating/Providers/HitBTCRateProvider.cs
+++ b/BTCPayServer.Rating/Providers/HitBTCRateProvider.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Services.Rates;
@@ -10,14 +7,10 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.Rating
{
- public class HitBTCRateProvider : IRateProvider
+ public class HitBTCRateProvider(HttpClient httpClient) : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("hitbtc", "HitBTC", "https://api.hitbtc.com/api/2/public/ticker");
- private readonly HttpClient _httpClient;
- public HitBTCRateProvider(HttpClient httpClient)
- {
- _httpClient = httpClient ?? new HttpClient();
- }
+ private readonly HttpClient _httpClient = httpClient ?? new HttpClient();
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
diff --git a/BTCPayServer.Rating/Providers/KrakenExchangeRateProvider.cs b/BTCPayServer.Rating/Providers/KrakenExchangeRateProvider.cs
index 997a453..32e872c 100644
--- a/BTCPayServer.Rating/Providers/KrakenExchangeRateProvider.cs
+++ b/BTCPayServer.Rating/Providers/KrakenExchangeRateProvider.cs
@@ -8,7 +8,6 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using ExchangeSharp;
-using Microsoft.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
diff --git a/BTCPayServer.Rating/Providers/LunoRateProvider.cs b/BTCPayServer.Rating/Providers/LunoRateProvider.cs
new file mode 100644
index 0000000..7a3bbbd
--- /dev/null
+++ b/BTCPayServer.Rating/Providers/LunoRateProvider.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Net.Http;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Services.Rates;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Rating.Providers;
+
+public class LunoRateProvider(HttpClient httpClient) : IRateProvider
+{
+ class GetTickerResponse
+ {
+ public class Ticker
+ {
+ public string Pair { get; set; }
+ public string Bid { get; set; }
+ public string Ask { get; set; }
+ }
+
+ public Ticker[] Tickers { get; set; }
+ }
+ public RateSourceInfo RateSourceInfo => new("luno", "Luno", "https://api.luno.com/api/1/tickers");
+ public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
+ {
+ using var response = await httpClient.GetAsync("https://api.luno.com/api/1/tickers", cancellationToken);
+ var resp = await response.Content.ReadAsAsync<GetTickerResponse>(cancellationToken);
+ List<PairRate> rates = new();
+ foreach (var ticker in resp.Tickers)
+ {
+ if (!CurrencyPair.TryParse(Normalize(ticker.Pair), out var pair) ||
+ !decimal.TryParse(ticker.Bid, NumberStyles.Any, CultureInfo.InvariantCulture, out var bid) ||
+ !decimal.TryParse(ticker.Ask, NumberStyles.Any, CultureInfo.InvariantCulture, out var ask))
+ continue;
+ if (bid > ask)
+ continue;
+ rates.Add(new PairRate(pair, new BidAsk(bid, ask)));
+ }
+ return rates.ToArray();
+ }
+
+ private string Normalize(string tickerPair) => tickerPair.Replace("XBT", "BTC", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj
index aa2e75f..2d08fb9 100644
--- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj
+++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj
@@ -41,7 +41,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
- <PackageReference Include="Microsoft.Playwright" Version="1.52.0" />
+ <PackageReference Include="Microsoft.Playwright" Version="1.57.0" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.16" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.11" />
<PackageReference Include="xunit" Version="2.9.2" />
diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs
index 34923e4..7681d88 100644
--- a/BTCPayServer.Tests/PayJoinTests.cs
+++ b/BTCPayServer.Tests/PayJoinTests.cs
@@ -280,7 +280,15 @@ namespace BTCPayServer.Tests
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
- await s.Page.ClickAsync("button[value=payjoin]");
+ try
+ {
+ await s.Page.ClickAsync("button[value=payjoin]");
+ }
+ catch
+ {
+ await s.TakeScreenshot("Flaky.png");
+ throw;
+ }
});
await s.FindAlertMessage();
diff --git a/BTCPayServer.Tests/PullPaymentsTests.cs b/BTCPayServer.Tests/PullPaymentsTests.cs
index c80cd3b..b969687 100644
--- a/BTCPayServer.Tests/PullPaymentsTests.cs
+++ b/BTCPayServer.Tests/PullPaymentsTests.cs
@@ -23,6 +23,7 @@ using NBitcoin.DataEncoders;
using Xunit;
using Xunit.Abstractions;
using static Microsoft.Playwright.Assertions;
+using PayoutData = BTCPayServer.Data.PayoutData;
namespace BTCPayServer.Tests;
@@ -35,6 +36,13 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
public async Task CanUsePullPaymentsViaUI()
{
await using var s = CreatePlaywrightTester();
+ async Task<PayoutData> ClickClaimAmount()
+ {
+ return (await s.Server.WaitForEvent<PayoutEvent>(async () =>
+ {
+ await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ }, e => e.Type == PayoutEvent.PayoutEventType.Created)).Payout;
+ }
s.Server.DeleteStore = false;
s.Server.ActivateLightning(LightningConnectionType.LndREST);
await s.StartAsync();
@@ -76,7 +84,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
await s.Page.FillAsync("#Destination", address.ToString());
await s.Page.FillAsync("#ClaimedAmount", "15");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await ClickClaimAmount();
await s.FindAlertMessage();
// We should not be able to use an address already used
@@ -88,7 +96,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
address = await s.Server.ExplorerNode.GetNewAddressAsync();
await s.Page.FillAsync("#Destination", address.ToString());
await s.Page.FillAsync("#ClaimedAmount", "20");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await ClickClaimAmount();
await s.FindAlertMessage();
await Expect(s.Page.Locator("body")).ToContainTextAsync("Awaiting Approval");
@@ -105,12 +113,8 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
payouts = s.Page.Locator(".pp-payout");
await payouts.First.ClickAsync();
+ await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(2);
- await TestUtils.EventuallyAsync(async () =>
- {
- var count = await s.Page.Locator(".payout").CountAsync();
- Assert.True(count > 0);
- });
await s.Page.CheckAsync(".mass-action-select-all");
await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
@@ -168,7 +172,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
await s.Page.FillAsync("#Destination", address.ToString());
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await ClickClaimAmount();
await s.FindAlertMessage();
await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
@@ -193,22 +197,12 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.FindAlertMessage();
await s.Page.ClickAsync($"#{PayoutState.InProgress}-view");
- await s.Page.WaitForLoadStateAsync(LoadState.DOMContentLoaded);
await Expect(s.Page.Locator(".payout")).ToHaveCountAsync(0);
await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
+ await Expect(s.Page.Locator(".payout").Filter(new() { HasText = "External Test" })).ToHaveCountAsync(1);
- await TestUtils.EventuallyAsync(async () =>
- {
- await s.Page.ReloadAsync();
- var rows = s.Page.Locator(".payout");
- var count = await rows.CountAsync();
- Assert.True(count > 0);
- // Basic sanity: source column shows the pull payment name
- var rowText = await rows.First.InnerTextAsync();
- Assert.Contains("External Test", rowText);
- });
- // lightning tests
+ // lightning tests,
// Since the merchant is sending on lightning, it needs some liquidity from the client
var payoutAmount = LightMoney.Satoshis(1000);
var minimumReserve = LightMoney.Satoshis(167773m);
@@ -234,6 +228,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.ClickPagePrimary();
await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
string bolt;
+ PayoutData payout;
await using (await s.SwitchPage(async () =>
{
await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
@@ -256,7 +251,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
$"LN payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromDays(31), CancellationToken.None)).BOLT11;
await s.Page.FillAsync("#Destination", bolt);
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ payout = await ClickClaimAmount();
await s.FindAlertMessage();
await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingApproval.GetStateString());
@@ -266,20 +261,29 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-view");
await s.Page.CheckAsync(".mass-action-select-all");
- await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
+ await s.Server.WaitForEvent<PayoutEvent>(async () =>
+ {
+ await s.Page.ClickAsync($"#{PayoutState.AwaitingApproval}-approve-pay");
+ }, e => e.Type == PayoutEvent.PayoutEventType.Approved && e.Payout.Id == payout.Id);
+
await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
await Expect(s.Page.Locator("body")).ToContainTextAsync($"{payoutAmount} BTC");
- await s.Page.Locator("#pay-invoices-form").EvaluateAsync("form => form.submit()");
+
+ await s.Server.WaitForEvent<PayoutEvent>(async () =>
+ {
+ await s.Page.ClickAsync("#Pay");
+ }, e => e.Type == PayoutEvent.PayoutEventType.Updated && e.Payout.Id == payout.Id);
await s.FindAlertMessage();
await s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
await s.Page.ClickAsync($"#{PaymentTypes.LN.GetPaymentMethodId("BTC")}-view");
await s.Page.ClickAsync($"#{PayoutState.Completed}-view");
+ await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
if (!(await s.Page.ContentAsync()).Contains(bolt))
{
await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-view");
- await Expect(s.Page.Locator("body")).ToContainTextAsync(bolt);
+
await s.Page.CheckAsync(".mass-action-select-all");
await s.Page.ClickAsync($"#{PayoutState.AwaitingPayment}-mark-paid");
@@ -307,7 +311,7 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
await s.Page.FillAsync("#Destination", address.ToString());
await s.Page.FillAsync("#ClaimedAmount", "20");
- await s.Page.PressAsync("#ClaimedAmount", "Enter");
+ await ClickClaimAmount();
await s.FindAlertMessage();
await Expect(s.Page.Locator("body")).ToContainTextAsync(PayoutState.AwaitingPayment.GetStateString());
@@ -425,9 +429,10 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.PressAsync("#Currency", "Enter");
await s.FindAlertMessage();
- var newPage7 = s.Page.Context.WaitForPageAsync();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- await using (await s.SwitchPage(await newPage7))
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
{
await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
await s.Page.ClickAsync("#lnurlwithdraw-button");
@@ -459,8 +464,6 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
});
}
- await s.Page.Context.Pages.First().BringToFrontAsync();
-
// LNURL Withdraw support check with SATS denomination
await s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
await s.ClickPagePrimary();
@@ -471,9 +474,10 @@ public class PullPaymentsTests(ITestOutputHelper helper) : UnitTestBase(helper)
await s.Page.PressAsync("#Currency", "Enter");
await s.FindAlertMessage();
- var newPage8 = s.Page.Context.WaitForPageAsync();
- await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
- await using (await s.SwitchPage(await newPage8))
+ await using (await s.SwitchPage(async () =>
+ {
+ await s.Page.Locator(".actions-col a:has-text('View')").First.ClickAsync();
+ }))
{
await Expect(s.Page.Locator("#lnurlwithdraw-button")).ToBeVisibleAsync();
await s.Page.ClickAsync("#lnurlwithdraw-button");
diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs
index 904deee..a013dbe 100644
--- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs
+++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs
@@ -1057,5 +1057,7 @@ namespace BTCPayServer.HostedServices
Approved,
Updated
}
+
+ public override string ToString() => $"Payout Event for {Payout.Id} ({Type})";
}
}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index c044de7..5b952b9 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -563,6 +563,7 @@ namespace BTCPayServer.Hosting
{ "NGN", "bitnob" },
{ "NOK", "barebitcoin" },
{ "CZK", "coinmate" },
+ { "ZAR", "luno" }
})
{
var r = new DefaultRules.Recommendation(rule.Key, rule.Value);
@@ -621,6 +622,7 @@ namespace BTCPayServer.Hosting
services.AddRateProvider<BitmyntRateProvider>();
services.AddRateProvider<BareBitcoinRateProvider>();
services.AddRateProvider<CoinmateRateProvider>();
+ services.AddRateProvider<LunoRateProvider>();
services.AddSingleton<InvoiceBlobMigratorHostedService>();
services.AddSingleton<IHostedService, InvoiceBlobMigratorHostedService>(o => o.GetRequiredService<InvoiceBlobMigratorHostedService>());
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.