diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f219074aa..86c274be1 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -751,6 +751,10 @@ namespace BTCPayServer.Tests var b = cached.GetRateAsync("JPY").GetAwaiter().GetResult(); //Manually check that cache get hit after 10 sec var c = cached.GetRateAsync("JPY").GetAwaiter().GetResult(); + + var bitstamp = new CoinAverageRateProvider("BTC") { Exchange = "bitstamp" }; + var bitstampRate = bitstamp.GetRateAsync("USD").GetAwaiter().GetResult(); + Assert.Throws(() => bitstamp.GetRateAsync("XXXXX").GetAwaiter().GetResult()); } private static bool IsMapped(Invoice invoice, ApplicationDbContext ctx) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index e9558aa25..92486cf04 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.12 + 1.0.1.13 diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index f2ae40e31..a7f36f2ae 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -17,6 +17,7 @@ using NBXplorer.DerivationStrategy; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; namespace BTCPayServer.Controllers @@ -95,7 +96,7 @@ namespace BTCPayServer.Controllers var stores = await _Repo.GetStoresByUserId(GetUserId()); var balances = stores .Select(s => s.GetDerivationStrategies(_NetworkProvider) - .Select(d => (Wallet: _WalletProvider.GetWallet(d.Network), + .Select(d => (Wallet: _WalletProvider.GetWallet(d.Network), DerivationStrategy: d.DerivationStrategyBase)) .Where(_ => _.Wallet != null) .Select(async _ => (await _.Wallet.GetBalance(_.DerivationStrategy)).ToString() + " " + _.Wallet.Network.CryptoCode)) @@ -165,6 +166,7 @@ namespace BTCPayServer.Controllers vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.RateMultiplier = (double)storeBlob.GetRateMultiplier(); + vm.PreferredExchange = storeBlob.PreferredExchange; return View(vm); } @@ -309,6 +311,8 @@ namespace BTCPayServer.Controllers blob.NetworkFeeDisabled = !model.NetworkFee; blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; + blob.PreferredExchange = model.PreferredExchange; + blob.SetRateMultiplier(model.RateMultiplier); if (store.SetStoreBlob(blob)) @@ -316,6 +320,19 @@ namespace BTCPayServer.Controllers needUpdate = true; } + if (!string.IsNullOrEmpty(blob.PreferredExchange)) + { + using (HttpClient client = new HttpClient()) + { + var rate = await client.GetAsync(model.RateSource); + if (rate.StatusCode == System.Net.HttpStatusCode.NotFound) + { + ModelState.AddModelError(nameof(model.PreferredExchange), $"Invalid exchange ({model.RateSource})"); + return View(model); + } + } + } + if (needUpdate) { await _Repo.UpdateStore(store); diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index ff98ee20a..b948cea3a 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -235,9 +235,29 @@ namespace BTCPayServer.Data } public List RateRules { get; set; } = new List(); + public string PreferredExchange { get; set; } public IRateProvider ApplyRateRules(BTCPayNetwork network, IRateProvider rateProvider) { + if (!string.IsNullOrEmpty(PreferredExchange)) + { + // If the original rateProvider is a cache, use the same inner provider as fallback, and same memory cache to wrap it all + if (rateProvider is CachedRateProvider cachedRateProvider) + { + rateProvider = new FallbackRateProvider(new IRateProvider[] { + new CoinAverageRateProvider(network.CryptoCode) { Exchange = PreferredExchange }, + cachedRateProvider.Inner + }); + rateProvider = new CachedRateProvider(network.CryptoCode, rateProvider, cachedRateProvider.MemoryCache); + } + else + { + rateProvider = new FallbackRateProvider(new IRateProvider[] { + new CoinAverageRateProvider(network.CryptoCode) { Exchange = PreferredExchange }, + rateProvider + }); + } + } if (RateRules == null || RateRules.Count == 0) return rateProvider; return new TweakRateProvider(network, rateProvider, RateRules.ToList()); diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index a6fb4abb3..f5f76e1b4 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -47,6 +47,17 @@ namespace BTCPayServer.Models.StoreViewModels public List DerivationSchemes { get; set; } = new List(); + [Display(Name = "Preferred exchange rate...")] + public string PreferredExchange { get; set; } + + public string RateSource + { + get + { + return string.IsNullOrEmpty(PreferredExchange) ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}"; + } + } + [Display(Name = "Multiply the original rate by ...")] [Range(0.01, 10.0)] public double RateMultiplier diff --git a/BTCPayServer/Services/Rates/CachedDefaultRateProviderFactory.cs b/BTCPayServer/Services/Rates/CachedDefaultRateProviderFactory.cs index a96b99627..44b4dac65 100644 --- a/BTCPayServer/Services/Rates/CachedDefaultRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/CachedDefaultRateProviderFactory.cs @@ -11,6 +11,15 @@ namespace BTCPayServer.Services.Rates { IMemoryCache _Cache; ConcurrentDictionary _Providers = new ConcurrentDictionary(); + + public IMemoryCache Cache + { + get + { + return _Cache; + } + } + public CachedDefaultRateProviderFactory(IMemoryCache cache) { if (cache == null) @@ -18,10 +27,12 @@ namespace BTCPayServer.Services.Rates _Cache = cache; } + public IRateProvider RateProvider { get; set; } + public TimeSpan CacheSpan { get; set; } = TimeSpan.FromMinutes(1.0); public IRateProvider GetRateProvider(BTCPayNetwork network) { - return _Providers.GetOrAdd(network.CryptoCode, new CachedRateProvider(network.CryptoCode, network.DefaultRateProvider, _Cache) { CacheSpan = CacheSpan }); + return _Providers.GetOrAdd(network.CryptoCode, new CachedRateProvider(network.CryptoCode, RateProvider ?? network.DefaultRateProvider, _Cache) { CacheSpan = CacheSpan }); } } } diff --git a/BTCPayServer/Services/Rates/CachedRateProvider.cs b/BTCPayServer/Services/Rates/CachedRateProvider.cs index b64466b69..a1999b87e 100644 --- a/BTCPayServer/Services/Rates/CachedRateProvider.cs +++ b/BTCPayServer/Services/Rates/CachedRateProvider.cs @@ -19,19 +19,28 @@ namespace BTCPayServer.Services.Rates if (memoryCache == null) throw new ArgumentNullException(nameof(memoryCache)); this._Inner = inner; - this._MemoryCache = memoryCache; + this.MemoryCache = memoryCache; this._CryptoCode = cryptoCode; } + public IRateProvider Inner + { + get + { + return _Inner; + } + } + public TimeSpan CacheSpan { get; set; } = TimeSpan.FromMinutes(1.0); + public IMemoryCache MemoryCache { get => _MemoryCache; private set => _MemoryCache = value; } public Task GetRateAsync(string currency) { - return _MemoryCache.GetOrCreateAsync("CURR_" + currency + "_" + _CryptoCode, (ICacheEntry entry) => + return MemoryCache.GetOrCreateAsync("CURR_" + currency + "_" + _CryptoCode, (ICacheEntry entry) => { entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; return _Inner.GetRateAsync(currency); @@ -40,7 +49,7 @@ namespace BTCPayServer.Services.Rates public Task> GetRatesAsync() { - return _MemoryCache.GetOrCreateAsync("GLOBAL_RATES", (ICacheEntry entry) => + return MemoryCache.GetOrCreateAsync("GLOBAL_RATES", (ICacheEntry entry) => { entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; return _Inner.GetRatesAsync(); diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs index 824a2ff7e..8d063f42d 100644 --- a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -24,6 +24,8 @@ namespace BTCPayServer.Services.Rates CryptoCode = cryptoCode ?? "BTC"; } + public string Exchange { get; set; } + public string CryptoCode { get; set; } public string Market @@ -45,7 +47,15 @@ namespace BTCPayServer.Services.Rates private async Task> GetRatesCore() { - var resp = await _Client.GetAsync($"https://apiv2.bitcoinaverage.com/indices/{Market}/ticker/short"); + HttpResponseMessage resp = null; + if (Exchange == null) + { + resp = await _Client.GetAsync($"https://apiv2.bitcoinaverage.com/indices/{Market}/ticker/short"); + } + else + { + resp = await _Client.GetAsync($"https://apiv2.bitcoinaverage.com/exchanges/{Exchange}"); + } using (resp) { @@ -57,9 +67,23 @@ namespace BTCPayServer.Services.Rates throw new CoinAverageException("Unauthorized access to the API, premium plan needed"); resp.EnsureSuccessStatusCode(); var rates = JObject.Parse(await resp.Content.ReadAsStringAsync()); + if(Exchange != null) + { + rates = (JObject)rates["symbols"]; + } return rates.Properties() .Where(p => p.Name.StartsWith(CryptoCode, StringComparison.OrdinalIgnoreCase)) - .ToDictionary(p => p.Name.Substring(CryptoCode.Length, p.Name.Length - CryptoCode.Length), p => ToDecimal(p.Value["last"])); + .ToDictionary(p => p.Name.Substring(CryptoCode.Length, p.Name.Length - CryptoCode.Length), p => + { + if (Exchange == null) + { + return ToDecimal(p.Value["last"]); + } + else + { + return ToDecimal(p.Value["bid"]); + } + }); } } diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index 9c607b078..f968ac4fb 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -38,6 +38,14 @@ +
+ + + +

+ Current rate source is @Model.RateSource. (using 1 minute cache) +

+
diff --git a/BTCPayServer/wwwroot/css/creative.css b/BTCPayServer/wwwroot/css/creative.css index 4e655477d..0ac9802b9 100644 --- a/BTCPayServer/wwwroot/css/creative.css +++ b/BTCPayServer/wwwroot/css/creative.css @@ -43,7 +43,7 @@ h6 { } p { - font-size: 16px; + /*font-size: 16px;*/ line-height: 1.5; margin-bottom: 20px; }