diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 472a51136..2527384bb 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -20,182 +20,184 @@ using System.Diagnostics; using Microsoft.EntityFrameworkCore.Extensions; using BTCPayServer.Data; using Microsoft.EntityFrameworkCore; +using BTCPayServer.Services.Rates; +using Microsoft.Extensions.Caching.Memory; namespace BTCPayServer.Tests { - public class UnitTest1 - { - public UnitTest1(ITestOutputHelper helper) - { - Logs.Tester = new XUnitLog(helper) { Name = "Tests" }; - Logs.LogProvider = new XUnitLogProvider(helper); - } + public class UnitTest1 + { + public UnitTest1(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) { Name = "Tests" }; + Logs.LogProvider = new XUnitLogProvider(helper); + } - [Fact] - public void CanCalculateCryptoDue() - { - var entity = new InvoiceEntity(); - entity.TxFee = Money.Coins(0.1m); - entity.Rate = 5000; - entity.Payments = new System.Collections.Generic.List(); - entity.ProductInformation = new ProductInformation() { Price = 5000 }; + [Fact] + public void CanCalculateCryptoDue() + { + var entity = new InvoiceEntity(); + entity.TxFee = Money.Coins(0.1m); + entity.Rate = 5000; + entity.Payments = new System.Collections.Generic.List(); + entity.ProductInformation = new ProductInformation() { Price = 5000 }; - Assert.Equal(Money.Coins(1.1m), entity.GetCryptoDue()); - Assert.Equal(Money.Coins(1.1m), entity.GetTotalCryptoDue()); + Assert.Equal(Money.Coins(1.1m), entity.GetCryptoDue()); + Assert.Equal(Money.Coins(1.1m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.5m), new Key()) }); - //Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1 - Assert.Equal(Money.Coins(0.7m), entity.GetCryptoDue()); - Assert.Equal(Money.Coins(1.2m), entity.GetTotalCryptoDue()); + //Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1 + Assert.Equal(Money.Coins(0.7m), entity.GetCryptoDue()); + Assert.Equal(Money.Coins(1.2m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); - Assert.Equal(Money.Coins(0.6m), entity.GetCryptoDue()); - Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); + Assert.Equal(Money.Coins(0.6m), entity.GetCryptoDue()); + Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.6m), new Key()) }); - Assert.Equal(Money.Zero, entity.GetCryptoDue()); - Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); + Assert.Equal(Money.Zero, entity.GetCryptoDue()); + Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); - entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); + entity.Payments.Add(new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()) }); - Assert.Equal(Money.Zero, entity.GetCryptoDue()); - Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); - } + Assert.Equal(Money.Zero, entity.GetCryptoDue()); + Assert.Equal(Money.Coins(1.3m), entity.GetTotalCryptoDue()); + } - [Fact] - public void CanPayUsingBIP70() - { - using (var tester = ServerTester.Create()) - { - tester.Start(); - var user = tester.NewAccount(); - user.GrantAccess(); - var invoice = user.BitPay.CreateInvoice(new Invoice() - { - Buyer = new Buyer() { email = "test@fwf.com" }, - Price = 5000.0, - Currency = "USD", - PosData = "posData", - OrderId = "orderId", - //RedirectURL = redirect + "redirect", - //NotificationURL = CallbackUri + "/notification", - ItemDesc = "Some description", - FullNotifications = true - }, Facade.Merchant); + [Fact] + public void CanPayUsingBIP70() + { + using(var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var invoice = user.BitPay.CreateInvoice(new Invoice() + { + Buyer = new Buyer() { email = "test@fwf.com" }, + Price = 5000.0, + Currency = "USD", + PosData = "posData", + OrderId = "orderId", + //RedirectURL = redirect + "redirect", + //NotificationURL = CallbackUri + "/notification", + ItemDesc = "Some description", + FullNotifications = true + }, Facade.Merchant); - Assert.False(invoice.Refundable); + Assert.False(invoice.Refundable); - var url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP72); - var request = url.GetPaymentRequest(); - var payment = request.CreatePayment(); + var url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP72); + var request = url.GetPaymentRequest(); + var payment = request.CreatePayment(); - Transaction tx = new Transaction(); - tx.Outputs.AddRange(request.Details.Outputs.Select(o => new TxOut(o.Amount, o.Script))); - var cashCow = tester.ExplorerNode; - tx = cashCow.FundRawTransaction(tx).Transaction; - tx = cashCow.SignRawTransaction(tx); + Transaction tx = new Transaction(); + tx.Outputs.AddRange(request.Details.Outputs.Select(o => new TxOut(o.Amount, o.Script))); + var cashCow = tester.ExplorerNode; + tx = cashCow.FundRawTransaction(tx).Transaction; + tx = cashCow.SignRawTransaction(tx); - payment.Transactions.Add(tx); + payment.Transactions.Add(tx); - payment.RefundTo.Add(new PaymentOutput(Money.Coins(1.0m), new Key().ScriptPubKey)); - var ack = payment.SubmitPayment(); - Assert.NotNull(ack); + payment.RefundTo.Add(new PaymentOutput(Money.Coins(1.0m), new Key().ScriptPubKey)); + var ack = payment.SubmitPayment(); + Assert.NotNull(ack); - Eventually(() => - { - tester.SimulateCallback(url.Address); - var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant); - Assert.Equal("paid", localInvoice.Status); - Assert.True(localInvoice.Refundable); - }); - } - } + Eventually(() => + { + tester.SimulateCallback(url.Address); + var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant); + Assert.Equal("paid", localInvoice.Status); + Assert.True(localInvoice.Refundable); + }); + } + } - [Fact] - public void CanUseServerInitiatedPairingCode() - { - using (var tester = ServerTester.Create()) - { - tester.Start(); - var acc = tester.NewAccount(); - acc.Register(); - acc.CreateStore(); + [Fact] + public void CanUseServerInitiatedPairingCode() + { + using(var tester = ServerTester.Create()) + { + tester.Start(); + var acc = tester.NewAccount(); + acc.Register(); + acc.CreateStore(); - var controller = tester.PayTester.GetController(acc.UserId); - var token = (RedirectToActionResult)controller.CreateToken(acc.StoreId, new Models.StoreViewModels.CreateTokenViewModel() - { - Facade = Facade.Merchant.ToString(), - Label = "bla", - PublicKey = null - }).GetAwaiter().GetResult(); + var controller = tester.PayTester.GetController(acc.UserId); + var token = (RedirectToActionResult)controller.CreateToken(acc.StoreId, new Models.StoreViewModels.CreateTokenViewModel() + { + Facade = Facade.Merchant.ToString(), + Label = "bla", + PublicKey = null + }).GetAwaiter().GetResult(); - var pairingCode = (string)token.RouteValues["pairingCode"]; + var pairingCode = (string)token.RouteValues["pairingCode"]; - acc.BitPay.AuthorizeClient(new PairingCode(pairingCode)).GetAwaiter().GetResult(); - Assert.True(acc.BitPay.TestAccess(Facade.Merchant)); - } - } + acc.BitPay.AuthorizeClient(new PairingCode(pairingCode)).GetAwaiter().GetResult(); + Assert.True(acc.BitPay.TestAccess(Facade.Merchant)); + } + } - [Fact] - public void CanSendIPN() - { - using (var callbackServer = new CustomServer()) - { - using (var tester = ServerTester.Create()) - { - tester.Start(); - var acc = tester.NewAccount(); - acc.GrantAccess(); - var invoice = acc.BitPay.CreateInvoice(new Invoice() - { - Price = 5.0, - Currency = "USD", - PosData = "posData", - OrderId = "orderId", - NotificationURL = callbackServer.GetUri().AbsoluteUri, - ItemDesc = "Some description", - FullNotifications = true - }); - BitcoinUrlBuilder url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP21); - tester.ExplorerNode.SendToAddress(url.Address, url.Amount); - Thread.Sleep(5000); - tester.SimulateCallback(url.Address); - callbackServer.ProcessNextRequest((ctx) => - { - var ipn = new StreamReader(ctx.Request.Body).ReadToEnd(); - JsonConvert.DeserializeObject(ipn); //can deserialize - }); - var invoice2 = acc.BitPay.GetInvoice(invoice.Id); - Assert.NotNull(invoice2); - } - } - } + [Fact] + public void CanSendIPN() + { + using(var callbackServer = new CustomServer()) + { + using(var tester = ServerTester.Create()) + { + tester.Start(); + var acc = tester.NewAccount(); + acc.GrantAccess(); + var invoice = acc.BitPay.CreateInvoice(new Invoice() + { + Price = 5.0, + Currency = "USD", + PosData = "posData", + OrderId = "orderId", + NotificationURL = callbackServer.GetUri().AbsoluteUri, + ItemDesc = "Some description", + FullNotifications = true + }); + BitcoinUrlBuilder url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP21); + tester.ExplorerNode.SendToAddress(url.Address, url.Amount); + Thread.Sleep(5000); + tester.SimulateCallback(url.Address); + callbackServer.ProcessNextRequest((ctx) => + { + var ipn = new StreamReader(ctx.Request.Body).ReadToEnd(); + JsonConvert.DeserializeObject(ipn); //can deserialize + }); + var invoice2 = acc.BitPay.GetInvoice(invoice.Id); + Assert.NotNull(invoice2); + } + } + } - [Fact] - public void CantPairTwiceWithSamePubkey() - { - using (var tester = ServerTester.Create()) - { - tester.Start(); - var acc = tester.NewAccount(); - acc.Register(); - var store = acc.CreateStore(); - var pairingCode = acc.BitPay.RequestClientAuthorization("test", Facade.Merchant); - Assert.IsType(store.Pair(pairingCode.ToString(), acc.StoreId).GetAwaiter().GetResult()); + [Fact] + public void CantPairTwiceWithSamePubkey() + { + using(var tester = ServerTester.Create()) + { + tester.Start(); + var acc = tester.NewAccount(); + acc.Register(); + var store = acc.CreateStore(); + var pairingCode = acc.BitPay.RequestClientAuthorization("test", Facade.Merchant); + Assert.IsType(store.Pair(pairingCode.ToString(), acc.StoreId).GetAwaiter().GetResult()); - pairingCode = acc.BitPay.RequestClientAuthorization("test1", Facade.Merchant); - var store2 = acc.CreateStore(); - store2.Pair(pairingCode.ToString(), store2.CreatedStoreId).GetAwaiter().GetResult(); - Assert.Contains(nameof(PairingResult.ReusedKey), store2.StatusMessage); - } - } + pairingCode = acc.BitPay.RequestClientAuthorization("test1", Facade.Merchant); + var store2 = acc.CreateStore(); + store2.Pair(pairingCode.ToString(), store2.CreatedStoreId).GetAwaiter().GetResult(); + Assert.Contains(nameof(PairingResult.ReusedKey), store2.StatusMessage); + } + } - [Fact] - public void InvoiceFlowThroughDifferentStatesCorrectly() - { - using (var tester = ServerTester.Create()) + [Fact] + public void InvoiceFlowThroughDifferentStatesCorrectly() + { + using(var tester = ServerTester.Create()) { tester.Start(); var user = tester.NewAccount(); @@ -220,9 +222,7 @@ namespace BTCPayServer.Tests StoreId = user.StoreId, TextSearch = invoice.OrderId }).GetAwaiter().GetResult(); - Assert.Equal(1, textSearchResult.Length); - textSearchResult = tester.PayTester.Runtime.InvoiceRepository.GetInvoices(new InvoiceQuery() { StoreId = user.StoreId, @@ -352,6 +352,21 @@ namespace BTCPayServer.Tests } } + [Fact] + public void CheckRatesProvider() + { + var coinAverage = new CoinAverageRateProvider(); + var jpy = coinAverage.GetRateAsync("JPY").GetAwaiter().GetResult(); + var jpy2 = new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))).GetRateAsync("JPY").GetAwaiter().GetResult(); + + var cached = new CachedRateProvider(coinAverage, new MemoryCache(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) })); + cached.CacheSpan = TimeSpan.FromSeconds(10); + var a = cached.GetRateAsync("JPY").GetAwaiter().GetResult(); + var b = cached.GetRateAsync("JPY").GetAwaiter().GetResult(); + //Manually check that cache get hit after 10 sec + var c = cached.GetRateAsync("JPY").GetAwaiter().GetResult(); + } + private static bool IsMapped(Invoice invoice, ApplicationDbContext ctx) { var h = BitcoinAddress.Create(invoice.BitcoinAddress).ScriptPubKey.Hash.ToString(); @@ -359,20 +374,20 @@ namespace BTCPayServer.Tests } private void Eventually(Action act) - { - CancellationTokenSource cts = new CancellationTokenSource(10000); - while (true) - { - try - { - act(); - break; - } - catch (XunitException) when (!cts.Token.IsCancellationRequested) - { - cts.Token.WaitHandle.WaitOne(500); - } - } - } - } + { + CancellationTokenSource cts = new CancellationTokenSource(10000); + while(true) + { + try + { + act(); + break; + } + catch(XunitException) when(!cts.Token.IsCancellationRequested) + { + cts.Token.WaitHandle.WaitOne(500); + } + } + } + } } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index d9dc7073e..1c749224b 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -33,6 +33,7 @@ using System.Threading.Tasks; using System.Threading; using BTCPayServer.Services.Wallets; using BTCPayServer.Authentication; +using Microsoft.Extensions.Caching.Memory; namespace BTCPayServer.Hosting { @@ -137,7 +138,10 @@ namespace BTCPayServer.Hosting else return new Bitpay(new Key(), new Uri("https://test.bitpay.com/")); }); - services.TryAddSingleton(); + services.TryAddSingleton(o => + { + return new CachedRateProvider(new CoinAverageRateProvider(), o.GetRequiredService()) { CacheSpan = TimeSpan.FromMinutes(1.0) }; + }); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => o.GetRequiredService()); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 47b7b8f85..ac2887823 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -67,7 +67,7 @@ namespace BTCPayServer.Hosting public void ConfigureServices(IServiceCollection services) { services.ConfigureBTCPayServer(Configuration); - + services.AddMemoryCache(); services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); diff --git a/BTCPayServer/Services/Rates/CachedRateProvider.cs b/BTCPayServer/Services/Rates/CachedRateProvider.cs new file mode 100644 index 000000000..df5c8c4c3 --- /dev/null +++ b/BTCPayServer/Services/Rates/CachedRateProvider.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BTCPayServer.Services.Rates; +using Microsoft.Extensions.Caching.Memory; + +namespace BTCPayServer.Services.Rates +{ + public class CachedRateProvider : IRateProvider + { + private IRateProvider _Inner; + private IMemoryCache _MemoryCache; + + public CachedRateProvider(IRateProvider inner, IMemoryCache memoryCache) + { + if(inner == null) + throw new ArgumentNullException(nameof(inner)); + if(memoryCache == null) + throw new ArgumentNullException(nameof(memoryCache)); + this._Inner = inner; + this._MemoryCache = memoryCache; + } + + public TimeSpan CacheSpan + { + get; + set; + } = TimeSpan.FromMinutes(1.0); + + public Task GetRateAsync(string currency) + { + return _MemoryCache.GetOrCreateAsync("CURR_" + currency, (ICacheEntry entry) => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; + return _Inner.GetRateAsync(currency); + }); + } + + private bool TryGetFromCache(string key, out object obj) + { + obj = _MemoryCache.Get(key); + return obj != null; + } + + public Task> GetRatesAsync() + { + return _MemoryCache.GetOrCreateAsync("GLOBAL_RATES", (ICacheEntry entry) => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; + return _Inner.GetRatesAsync(); + }); + } + } +} \ No newline at end of file diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs new file mode 100644 index 000000000..9f7b3e0ba --- /dev/null +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -0,0 +1,116 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +namespace BTCPayServer.Services.Rates +{ + public class CoinAverageException : Exception + { + public CoinAverageException(string message) : base(message) + { + + } + } + public class CoinAverageRateProvider : IRateProvider + { + public class RatesJson + { + public class RateJson + { + public string Code + { + get; set; + } + public decimal Rate + { + get; set; + } + } + + [JsonProperty("rates")] + public JObject RatesInternal + { + get; set; + } + [JsonIgnore] + public List Rates + { + get; set; + } + + [JsonIgnore] + public Dictionary RatesByCurrency + { + get; set; + } + + public decimal GetRate(string currency) + { + if(!RatesByCurrency.TryGetValue(currency.ToUpperInvariant(), out decimal currUSD)) + throw new RateUnavailableException(currency); + + if(!RatesByCurrency.TryGetValue("BTC", out decimal btcUSD)) + throw new RateUnavailableException(currency); + + return currUSD / btcUSD; + } + public void CalculateDictionary() + { + RatesByCurrency = new Dictionary(); + Rates = new List(); + foreach(var rate in RatesInternal.OfType()) + { + var rateJson = new RateJson(); + rateJson.Code = rate.Name; + rateJson.Rate = rate.Value["rate"].Value(); + RatesByCurrency.Add(rate.Name, rateJson.Rate); + Rates.Add(rateJson); + } + } + } + static HttpClient _Client = new HttpClient(); + + public string Market + { + get; set; + } = "global"; + public async Task GetRateAsync(string currency) + { + RatesJson rates = await GetRatesCore(); + return rates.GetRate(currency); + } + + private async Task GetRatesCore() + { + var resp = await _Client.GetAsync("https://apiv2.bitcoinaverage.com/constants/exchangerates/" + Market); + using(resp) + { + + if((int)resp.StatusCode == 401) + throw new CoinAverageException("Unauthorized access to the API"); + if((int)resp.StatusCode == 429) + throw new CoinAverageException("Exceed API limits"); + if((int)resp.StatusCode == 403) + throw new CoinAverageException("Unauthorized access to the API, premium plan needed"); + resp.EnsureSuccessStatusCode(); + var rates = JsonConvert.DeserializeObject(await resp.Content.ReadAsStringAsync()); + rates.CalculateDictionary(); + return rates; + } + } + + public async Task> GetRatesAsync() + { + RatesJson rates = await GetRatesCore(); + return rates.Rates.Select(o => new Rate() + { + Currency = o.Code, + Value = rates.GetRate(o.Code) + }).ToList(); + } + } +}