From 4b976c13c1fe2e7f82d50a8b728a08ba2a5efb63 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Wed, 24 Oct 2018 07:52:19 +0200 Subject: [PATCH] Changelly v2 (#343) * Disable shapeshift and use changelly * UI to manage changelly payment method * wip on changelly api * Add in Vue component for changelly and remove target currency from payment method * add changelly merhcant id * Small fixes to get Conversion to load * wip fixing the component * fix merge conflict * fixes to UI * remove debug, fix fee calc and move changelly to own partials * Update ChangellyController.cs * move original vue setup back to checkout * Update core.js * Extracting Changelly component to js file * Proposal for loading spinner * remove zone * imrpove changelly ui * add in changelly config checks * try new method to calculate amount + remove to currency from list * abstract changelly lofgic to provider and reduce dependency on js component * Add UTs for Changelly * refactor changelly backend * fix failing UT * add shitcoin tax * pr changes * pr changes * WIP: getting rid of changelly dependency * client caching, compiling code, cleaner code * Cleaner changelly * fiat! * updat i18n, css and error styler * default keys * pr changes part 1 * part2 * fix tests * fix loader alignment and retry button responsiveness * final pr change --- BTCPayServer.Tests/ChangellyTests.cs | 316 ++++++++++++++++++ BTCPayServer.Tests/UnitTest1.cs | 2 +- .../Controllers/ChangellyController.cs | 125 +++++++ .../Controllers/InvoiceController.UI.cs | 19 +- .../Controllers/StoresController.Changelly.cs | 98 ++++++ BTCPayServer/Controllers/StoresController.cs | 20 +- BTCPayServer/Data/StoreData.cs | 8 +- BTCPayServer/Hosting/BTCPayServerServices.cs | 3 + .../Models/InvoicingModels/PaymentModel.cs | 5 +- .../CheckoutExperienceViewModel.cs | 5 - .../Models/StoreViewModels/StoreViewModel.cs | 11 +- .../UpdateChangellySettingsViewModel.cs | 33 ++ BTCPayServer/Payments/Changelly/Changelly.cs | 108 ++++++ .../Changelly/ChangellyClientProvider.cs | 75 +++++ .../Payments/Changelly/ChangellyException.cs | 11 + .../Payments/Changelly/ChangellySettings.cs | 21 ++ .../Changelly/Models/ChangellyResponse.cs | 17 + .../Payments/Changelly/Models/CurrencyFull.cs | 18 + .../Payments/Changelly/Models/Error.cs | 13 + .../Payments/PaymentMethodExtensions.cs | 1 + .../Views/Invoice/Checkout-Body.cshtml | 51 ++- BTCPayServer/Views/Invoice/Checkout.cshtml | 129 +++---- .../Views/Stores/CheckoutExperience.cshtml | 4 - .../Stores/UpdateChangellySettings.cshtml | 56 ++++ BTCPayServer/Views/Stores/UpdateStore.cshtml | 37 ++ .../wwwroot/checkout/css/normalizer.css | 53 ++- .../wwwroot/checkout/js/changellyComponent.js | 131 ++++++++ BTCPayServer/wwwroot/checkout/js/core.js | 3 - BTCPayServer/wwwroot/checkout/js/langs/en.js | 3 +- 29 files changed, 1274 insertions(+), 102 deletions(-) create mode 100644 BTCPayServer.Tests/ChangellyTests.cs create mode 100644 BTCPayServer/Controllers/ChangellyController.cs create mode 100644 BTCPayServer/Controllers/StoresController.Changelly.cs create mode 100644 BTCPayServer/Models/StoreViewModels/UpdateChangellySettingsViewModel.cs create mode 100644 BTCPayServer/Payments/Changelly/Changelly.cs create mode 100644 BTCPayServer/Payments/Changelly/ChangellyClientProvider.cs create mode 100644 BTCPayServer/Payments/Changelly/ChangellyException.cs create mode 100644 BTCPayServer/Payments/Changelly/ChangellySettings.cs create mode 100644 BTCPayServer/Payments/Changelly/Models/ChangellyResponse.cs create mode 100644 BTCPayServer/Payments/Changelly/Models/CurrencyFull.cs create mode 100644 BTCPayServer/Payments/Changelly/Models/Error.cs create mode 100644 BTCPayServer/Views/Stores/UpdateChangellySettings.cshtml create mode 100644 BTCPayServer/wwwroot/checkout/js/changellyComponent.js diff --git a/BTCPayServer.Tests/ChangellyTests.cs b/BTCPayServer.Tests/ChangellyTests.cs new file mode 100644 index 000000000..4e92ed3d5 --- /dev/null +++ b/BTCPayServer.Tests/ChangellyTests.cs @@ -0,0 +1,316 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Models; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments.Changelly; +using BTCPayServer.Payments.Changelly.Models; +using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; +using BTCPayServer.Tests.Logging; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class ChangellyTests + { + public ChangellyTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + public async void CanSetChangellyPaymentMethod() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var controller = tester.PayTester.GetController(user.UserId, user.StoreId); + + + var storeBlob = controller.StoreData.GetStoreBlob(); + Assert.Null(storeBlob.ChangellySettings); + + var updateModel = new UpdateChangellySettingsViewModel() + { + ApiSecret = "secret", + ApiKey = "key", + ApiUrl = "http://gozo.com", + ChangellyMerchantId = "aaa", + }; + + Assert.Equal("UpdateStore", Assert.IsType( + await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + var store = await tester.PayTester.StoreRepository.FindStore(user.StoreId); + storeBlob = controller.StoreData.GetStoreBlob(); + Assert.NotNull(storeBlob.ChangellySettings); + Assert.NotNull(storeBlob.ChangellySettings); + Assert.IsType(storeBlob.ChangellySettings); + Assert.Equal(storeBlob.ChangellySettings.ApiKey, updateModel.ApiKey); + Assert.Equal(storeBlob.ChangellySettings.ApiSecret, + updateModel.ApiSecret); + Assert.Equal(storeBlob.ChangellySettings.ApiUrl, updateModel.ApiUrl); + Assert.Equal(storeBlob.ChangellySettings.ChangellyMerchantId, + updateModel.ChangellyMerchantId); + } + } + + + [Fact] + public async void CanToggleChangellyPaymentMethod() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var controller = tester.PayTester.GetController(user.UserId, user.StoreId); + + var updateModel = new UpdateChangellySettingsViewModel() + { + ApiSecret = "secret", + ApiKey = "key", + ApiUrl = "http://gozo.com", + ChangellyMerchantId = "aaa", + }; + Assert.Equal("UpdateStore", Assert.IsType( + await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + + var store = await tester.PayTester.StoreRepository.FindStore(user.StoreId); + + Assert.True(store.GetStoreBlob().ChangellySettings.Enabled); + + updateModel.Enabled = false; + + Assert.Equal("UpdateStore", Assert.IsType( + await controller.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + store = await tester.PayTester.StoreRepository.FindStore(user.StoreId); + + Assert.False(store.GetStoreBlob().ChangellySettings.Enabled); + } + } + + [Fact] + public async void CannotUseChangellyApiWithoutChangellyPaymentMethodSet() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + var changellyController = + tester.PayTester.GetController(user.UserId, user.StoreId); + + //test non existing payment method + Assert.IsType(Assert + .IsType(await changellyController.GetCurrencyList(user.StoreId)) + .Value); + + var updateModel = new UpdateChangellySettingsViewModel + { + Enabled = false + }; + var storesController = tester.PayTester.GetController(user.UserId, user.StoreId); + //set payment method but disabled + + + Assert.Equal("UpdateStore", Assert.IsType( + await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + + Assert.IsType(Assert + .IsType(await changellyController.GetCurrencyList(user.StoreId)) + .Value); + + updateModel.Enabled = true; + //test with enabled method + + Assert.Equal("UpdateStore", Assert.IsType( + await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + + + Assert.IsNotType(Assert + .IsType(await changellyController.GetCurrencyList(user.StoreId)) + .Value); + } + } + + + [Fact] + public async void CanGetCurrencyListFromChangelly() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + + //save changelly settings + var updateModel = new UpdateChangellySettingsViewModel() + { + ApiSecret = "secret", + ApiKey = "key", + ApiUrl = "http://gozo.com", + ChangellyMerchantId = "aaa" + }; + var storesController = tester.PayTester.GetController(user.UserId, user.StoreId); + + //confirm saved + Assert.Equal("UpdateStore", Assert.IsType( + await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + + var mockChangelly = new MockChangelly(new MockHttpClientFactory(), updateModel.ApiKey, updateModel.ApiSecret, updateModel.ApiUrl); + var mock = new MockChangellyClientProvider(mockChangelly, tester.PayTester.StoreRepository); + + var factory = UnitTest1.CreateBTCPayRateFactory(); + var fetcher = new RateFetcher(factory); + + var changellyController = new ChangellyController(mock, tester.NetworkProvider, fetcher); + + + mockChangelly.GetCurrenciesFullResult = new List() + { + new CurrencyFull() + { + Name = "a", + Enable = true, + PayInConfirmations = 10, + FullName = "aa", + ImageLink = "" + } + }; + var result = Assert + .IsType(await changellyController.GetCurrencyList(user.StoreId)) + .Value as IEnumerable; + Assert.Equal(1, mockChangelly.GetCurrenciesFullCallCount); + + } + } + + + [Fact] + public async void CanCalculateToAmountForChangelly() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + + var updateModel = new UpdateChangellySettingsViewModel() + { + ApiSecret = "secret", + ApiKey = "key", + ApiUrl = "http://gozo.com", + ChangellyMerchantId = "aaa" + }; + var storesController = tester.PayTester.GetController(user.UserId, user.StoreId); + + Assert.Equal("UpdateStore", Assert.IsType( + await storesController.UpdateChangellySettings(user.StoreId, updateModel, "save")).ActionName); + + var mockChangelly = new MockChangelly(new MockHttpClientFactory(), updateModel.ApiKey, updateModel.ApiSecret, updateModel.ApiUrl); + var mock = new MockChangellyClientProvider(mockChangelly, tester.PayTester.StoreRepository); + + var factory = UnitTest1.CreateBTCPayRateFactory(); + var fetcher = new RateFetcher(factory); + + var changellyController = new ChangellyController(mock,tester.NetworkProvider,fetcher); + + mockChangelly.GetExchangeAmountResult = (from, to, amount) => + { + Assert.Equal("A", from); + Assert.Equal("B", to); + + switch (mockChangelly.GetExchangeAmountCallCount) + { + case 1: + return 0.5m; + break; + default: + return 1.01m; + break; + } + }; + + Assert.IsType(Assert + .IsType(await changellyController.CalculateAmount(user.StoreId, "A", "B", 1.0m)).Value); + Assert.True(mockChangelly.GetExchangeAmountCallCount > 1); + } + } + } + + public class MockHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) + { + return new HttpClient(); + } + } + + public class MockChangelly : Changelly + { + public IEnumerable GetCurrenciesFullResult { get; set; } + + public delegate decimal ParamsFunc(T1 arg1, T2 arg2, T3 arg3); + + public ParamsFunc GetExchangeAmountResult + { + get; + set; + } + + public int GetCurrenciesFullCallCount { get; set; } = 0; + public int GetExchangeAmountCallCount { get; set; } = 0; + + public MockChangelly(IHttpClientFactory httpClientFactory, string apiKey, string apiSecret, string apiUrl) : base(httpClientFactory, apiKey, apiSecret, apiUrl) + { + } + + public override async Task> GetCurrenciesFull() + { + GetCurrenciesFullCallCount++; + return GetCurrenciesFullResult; + } + + public override async Task GetExchangeAmount(string fromCurrency, + string toCurrency, decimal amount) + { + GetExchangeAmountCallCount++; + return GetExchangeAmountResult.Invoke(fromCurrency, toCurrency, amount); + } + } + + public class MockChangellyClientProvider : ChangellyClientProvider + { + public MockChangelly MockChangelly; + + public MockChangellyClientProvider( + MockChangelly mockChangelly, + StoreRepository storeRepository) : base(storeRepository, new MockHttpClientFactory()) + { + MockChangelly = mockChangelly; + } + + public override bool TryGetChangellyClient(string storeId, out string error, out Changelly changelly) + { + error = null; + changelly = MockChangelly; + return true; + } + } +} diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 6e3e97ee5..6b4b9e32a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1597,7 +1597,7 @@ namespace BTCPayServer.Tests } } - private static RateProviderFactory CreateBTCPayRateFactory() + public static RateProviderFactory CreateBTCPayRateFactory() { return new RateProviderFactory(CreateMemoryCache(), null, new CoinAverageSettings()); } diff --git a/BTCPayServer/Controllers/ChangellyController.cs b/BTCPayServer/Controllers/ChangellyController.cs new file mode 100644 index 000000000..724d558f1 --- /dev/null +++ b/BTCPayServer/Controllers/ChangellyController.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading.Tasks; +using BTCPayServer.Models; +using BTCPayServer.Payments.Changelly; +using BTCPayServer.Rating; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Controllers +{ + [Route("[controller]/{storeId}")] + public class ChangellyController : Controller + { + private readonly ChangellyClientProvider _changellyClientProvider; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly RateFetcher _RateProviderFactory; + + public ChangellyController(ChangellyClientProvider changellyClientProvider, + BTCPayNetworkProvider btcPayNetworkProvider, + RateFetcher rateProviderFactory) + { + _RateProviderFactory = rateProviderFactory ?? throw new ArgumentNullException(nameof(rateProviderFactory)); + + _changellyClientProvider = changellyClientProvider; + _btcPayNetworkProvider = btcPayNetworkProvider; + } + + [HttpGet] + [Route("currencies")] + public async Task GetCurrencyList(string storeId) + { + if (!TryGetChangellyClient(storeId, out var actionResult, out var client)) + { + return actionResult; + } + + try + { + return Ok(await client.GetCurrenciesFull()); + } + catch (Exception e) + { + return BadRequest(new BitpayErrorModel() + { + Error = e.Message + }); + } + } + + [HttpGet] + [Route("calculate")] + public async Task CalculateAmount(string storeId, string fromCurrency, string toCurrency, + decimal toCurrencyAmount) + { + if (!TryGetChangellyClient(storeId, out var actionResult, out var client)) + { + return actionResult; + } + + + if (fromCurrency.Equals("usd", StringComparison.InvariantCultureIgnoreCase) + || fromCurrency.Equals("eur", StringComparison.InvariantCultureIgnoreCase)) + { + var store = HttpContext.GetStoreData(); + var rules = store.GetStoreBlob().GetRateRules(_btcPayNetworkProvider); + var rate = await _RateProviderFactory.FetchRate(new CurrencyPair(toCurrency, fromCurrency), rules); + if (rate.BidAsk == null) return BadRequest(); + var flatRate = rate.BidAsk.Center; + return Ok(flatRate * toCurrencyAmount); + } + + + try + { + var callCounter = 0; + var response1 = await client.GetExchangeAmount(fromCurrency, toCurrency, 1); + var currentAmount = response1; + while (true) + { + if (callCounter > 10) + { + BadRequest(); + } + + var response2 = await client.GetExchangeAmount(fromCurrency, toCurrency, currentAmount); + callCounter++; + if (response2 < toCurrencyAmount) + { + var newCurrentAmount = ((toCurrencyAmount / response2) * 1m) * currentAmount; + + currentAmount = newCurrentAmount; + } + else + { + return Ok(currentAmount); + } + } + } + catch (Exception e) + { + return BadRequest(new BitpayErrorModel() + { + Error = e.Message + }); + } + } + + private bool TryGetChangellyClient(string storeId, out IActionResult actionResult, + out Changelly changelly) + { + changelly = null; + actionResult = null; + storeId = storeId ?? HttpContext.GetStoreData()?.Id; + + if (_changellyClientProvider.TryGetChangellyClient(storeId, out var error, out changelly)) + return true; + actionResult = BadRequest(new BitpayErrorModel() + { + Error = error + }); + return false; + + } + } +} diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 1987b5ea0..aa266603e 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -10,6 +10,7 @@ using BTCPayServer.Events; using BTCPayServer.Filters; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Payments; +using BTCPayServer.Payments.Changelly; using BTCPayServer.Payments.Lightning; using BTCPayServer.Security; using BTCPayServer.Services.Invoices; @@ -213,7 +214,6 @@ namespace BTCPayServer.Controllers paymentMethodIdStr = store.GetDefaultCrypto(_NetworkProvider); isDefaultCrypto = true; } - var paymentMethodId = PaymentMethodId.Parse(paymentMethodIdStr); var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode); if (network == null && isDefaultCrypto) @@ -245,6 +245,18 @@ namespace BTCPayServer.Controllers var storeBlob = store.GetStoreBlob(); var currency = invoice.ProductInformation.Currency; var accounting = paymentMethod.Calculate(); + + ChangellySettings changelly = (storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled && + storeBlob.ChangellySettings.IsConfigured()) + ? storeBlob.ChangellySettings + : null; + + + var changellyAmountDue = changelly != null + ? (accounting.Due.ToDecimal(MoneyUnit.BTC) * + (1m + (changelly.AmountMarkupPercentage / 100m))) + : (decimal?)null; + var model = new PaymentModel() { CryptoCode = network.CryptoCode, @@ -284,7 +296,10 @@ namespace BTCPayServer.Controllers Status = invoice.Status, NetworkFee = paymentMethodDetails.GetTxFee(), IsMultiCurrency = invoice.GetPayments().Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, - AllowCoinConversion = storeBlob.AllowCoinConversion, + ChangellyEnabled = changelly != null, + ChangellyMerchantId = changelly?.ChangellyMerchantId, + ChangellyAmountDue = changellyAmountDue, + StoreId = store.Id, AvailableCryptos = invoice.GetPaymentMethods(_NetworkProvider) .Where(i => i.Network != null) .Select(kv => new PaymentModel.AvailableCrypto() diff --git a/BTCPayServer/Controllers/StoresController.Changelly.cs b/BTCPayServer/Controllers/StoresController.Changelly.cs new file mode 100644 index 000000000..0a434f6f3 --- /dev/null +++ b/BTCPayServer/Controllers/StoresController.Changelly.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments.Changelly; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Controllers +{ + public partial class StoresController + { + [HttpGet] + [Route("{storeId}/changelly")] + public IActionResult UpdateChangellySettings(string storeId) + { + var store = HttpContext.GetStoreData(); + if (store == null) + return NotFound(); + UpdateChangellySettingsViewModel vm = new UpdateChangellySettingsViewModel(); + SetExistingValues(store, vm); + return View(vm); + } + + private void SetExistingValues(StoreData store, UpdateChangellySettingsViewModel vm) + { + + var existing = store.GetStoreBlob().ChangellySettings; + if (existing == null) return; + vm.ApiKey = existing.ApiKey; + vm.ApiSecret = existing.ApiSecret; + vm.ApiUrl = existing.ApiUrl; + vm.ChangellyMerchantId = existing.ChangellyMerchantId; + vm.Enabled = existing.Enabled; + vm.AmountMarkupPercentage = existing.AmountMarkupPercentage; + vm.ShowFiat = existing.ShowFiat; + + } + + [HttpPost] + [Route("{storeId}/changelly")] + public async Task UpdateChangellySettings(string storeId, UpdateChangellySettingsViewModel vm, + string command) + { + var store = HttpContext.GetStoreData(); + if (store == null) + return NotFound(); + if (vm.Enabled) + { + if (!ModelState.IsValid) + { + return View(vm); + } + } + + var changellySettings = new ChangellySettings() + { + ApiKey = vm.ApiKey, + ApiSecret = vm.ApiSecret, + ApiUrl = vm.ApiUrl, + ChangellyMerchantId = vm.ChangellyMerchantId, + Enabled = vm.Enabled, + AmountMarkupPercentage = vm.AmountMarkupPercentage, + ShowFiat = vm.ShowFiat + }; + + switch (command) + { + case "save": + var storeBlob = store.GetStoreBlob(); + storeBlob.ChangellySettings = changellySettings; + store.SetStoreBlob(storeBlob); + await _Repo.UpdateStore(store); + StatusMessage = "Changelly settings modified"; + _changellyClientProvider.InvalidateClient(storeId); + return RedirectToAction(nameof(UpdateStore), new { + storeId}); + case "test": + try + { + var client = new Changelly(_httpClientFactory, changellySettings.ApiKey, changellySettings.ApiSecret, + changellySettings.ApiUrl); + var result = await client.GetCurrenciesFull(); + vm.StatusMessage = "Test Successful"; + return View(vm); + } + catch (Exception ex) + { + vm.StatusMessage = $"Error: {ex.Message}"; + return View(vm); + } + + break; + default: + return View(vm); + } + } + } +} diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index a50ad4b21..0d1bd332a 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using BTCPayServer.Authentication; using BTCPayServer.Configuration; @@ -9,6 +10,7 @@ using BTCPayServer.Data; using BTCPayServer.Models; using BTCPayServer.Models.AppViewModels; using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Payments.Changelly; using BTCPayServer.Rating; using BTCPayServer.Security; using BTCPayServer.Services; @@ -48,16 +50,19 @@ namespace BTCPayServer.Controllers ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, LanguageService langService, - IHostingEnvironment env) + ChangellyClientProvider changellyClientProvider, + IHostingEnvironment env, IHttpClientFactory httpClientFactory) { _RateFactory = rateFactory; _Repo = repo; _TokenRepository = tokenRepo; _UserManager = userManager; _LangService = langService; + _changellyClientProvider = changellyClientProvider; _TokenController = tokenController; _WalletProvider = walletProvider; _Env = env; + _httpClientFactory = httpClientFactory; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; _FeeRateProvider = feeRateProvider; @@ -77,7 +82,9 @@ namespace BTCPayServer.Controllers TokenRepository _TokenRepository; UserManager _UserManager; private LanguageService _LangService; + private readonly ChangellyClientProvider _changellyClientProvider; IHostingEnvironment _Env; + private IHttpClientFactory _httpClientFactory; [TempData] public string StatusMessage @@ -318,7 +325,6 @@ namespace BTCPayServer.Controllers vm.SetLanguages(_LangService, storeBlob.DefaultLang); vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.OnChainMinValue = storeBlob.OnChainMinValue?.ToString() ?? ""; - vm.AllowCoinConversion = storeBlob.AllowCoinConversion; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.CustomCSS = storeBlob.CustomCSS?.AbsoluteUri; vm.CustomLogo = storeBlob.CustomLogo?.AbsoluteUri; @@ -362,7 +368,6 @@ namespace BTCPayServer.Controllers return View(model); } blob.DefaultLang = model.DefaultLang; - blob.AllowCoinConversion = model.AllowCoinConversion; blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.LightningMaxValue = lightningMaxValue; blob.OnChainMinValue = onchainMinValue; @@ -447,6 +452,15 @@ namespace BTCPayServer.Controllers Enabled = !excludeFilters.Match(paymentId) }); } + + + var changellyEnabled = storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled; + vm.ThirdPartyPaymentMethods.Add(new StoreViewModel.ThirdPartyPaymentMethod() + { + Enabled = changellyEnabled, + Action = nameof(UpdateChangellySettings), + Provider = "Changelly" + }); } [HttpPost] diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 19fcdfc25..ab2955047 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -17,6 +17,7 @@ using BTCPayServer.JsonConverters; using System.ComponentModel.DataAnnotations; using BTCPayServer.Services; using System.Security.Claims; +using BTCPayServer.Payments.Changelly; using BTCPayServer.Security; using BTCPayServer.Rating; @@ -261,11 +262,6 @@ namespace BTCPayServer.Data { get; set; } - public bool AllowCoinConversion - { - get; set; - } - public bool RequiresRefundEmail { get; set; } public string DefaultLang { get; set; } @@ -307,6 +303,8 @@ namespace BTCPayServer.Data public string RateScript { get; set; } public bool AnyoneCanInvoice { get; set; } + + public ChangellySettings ChangellySettings { get; set; } string _LightningDescriptionTemplate; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 80569bec4..ba0294710 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -38,6 +38,7 @@ using BTCPayServer.Logging; using BTCPayServer.HostedServices; using Meziantou.AspNetCore.BundleTagHelpers; using System.Security.Claims; +using BTCPayServer.Payments.Changelly; using BTCPayServer.Security; using Microsoft.AspNetCore.Mvc.ModelBinding; using NBXplorer.DerivationStrategy; @@ -125,6 +126,8 @@ namespace BTCPayServer.Hosting services.AddSingleton, Payments.Lightning.LightningLikePaymentHandler>(); services.AddSingleton(); + + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index 78a13d50b..8f1dffeaa 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -55,7 +55,10 @@ namespace BTCPayServer.Models.InvoicingModels public string PaymentMethodName { get; set; } public string CryptoImage { get; set; } - public bool AllowCoinConversion { get; set; } + public bool ChangellyEnabled { get; set; } + public string StoreId { get; set; } public string PeerInfo { get; set; } + public string ChangellyMerchantId { get; set; } + public decimal? ChangellyAmountDue { get; set; } } } diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index ae5176ffd..a77cccd0c 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -23,11 +23,6 @@ namespace BTCPayServer.Models.StoreViewModels public string DefaultCryptoCurrency { get; set; } [Display(Name = "Default language on checkout")] public string DefaultLang { get; set; } - [Display(Name = "Allow conversion through third party (Shapeshift, Changelly...)")] - public bool AllowCoinConversion - { - get; set; - } [Display(Name = "Do not propose lightning payment if value of the invoice is above...")] [MaxLength(20)] public string LightningMaxValue { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 9401bb957..6c899bf6d 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -21,7 +21,13 @@ namespace BTCPayServer.Models.StoreViewModels public WalletId WalletId { get; set; } public bool Enabled { get; set; } } - + + public class ThirdPartyPaymentMethod + { + public string Provider { get; set; } + public bool Enabled { get; set; } + public string Action { get; set; } + } public StoreViewModel() { @@ -52,6 +58,9 @@ namespace BTCPayServer.Models.StoreViewModels public List DerivationSchemes { get; set; } = new List(); + public List ThirdPartyPaymentMethods { get; set; } = + new List(); + [Display(Name = "Invoice expires if the full amount has not been paid after ... minutes")] [Range(1, 60 * 24 * 24)] public int InvoiceExpiration diff --git a/BTCPayServer/Models/StoreViewModels/UpdateChangellySettingsViewModel.cs b/BTCPayServer/Models/StoreViewModels/UpdateChangellySettingsViewModel.cs new file mode 100644 index 000000000..f3dc1895c --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/UpdateChangellySettingsViewModel.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Runtime.InteropServices; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class UpdateChangellySettingsViewModel + { + [Required] public string ApiKey { get; set; } = "6ed02cdf1b614d89a8c0ceb170eebb61"; + + [Required] public string ApiSecret { get; set; } = "8fbd66a2af5fd15a6b5f8ed0159c5842e32a18538521ffa145bd6c9e124d3483"; + + [Required] public string ApiUrl { get; set; } = "https://api.changelly.com"; + + [Display(Name = "Optional, Changelly Merchant Id")] + public string ChangellyMerchantId { get; set; } = "804298eb5753"; + + [Display(Name = "Show Fiat Currencies as option in conversion")] + public bool ShowFiat { get; set; } = true; + + [Required] + [Range(0, 100)] + [Display(Name = + "Percentage to multiply amount requested at Changelly to avoid underpaid situations due to Changelly not guaranteeing rates. ")] + public decimal AmountMarkupPercentage { get; set; } = new decimal(2); + + public bool Enabled { get; set; } = true; + + public string StatusMessage { get; set; } + } +} diff --git a/BTCPayServer/Payments/Changelly/Changelly.cs b/BTCPayServer/Payments/Changelly/Changelly.cs new file mode 100644 index 000000000..f9505baed --- /dev/null +++ b/BTCPayServer/Payments/Changelly/Changelly.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Payments.Changelly.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SshNet.Security.Cryptography; + +namespace BTCPayServer.Payments.Changelly +{ + public class Changelly + { + private readonly string _apisecret; + private readonly bool _showFiat; + private readonly HttpClient _httpClient; + + public Changelly(IHttpClientFactory httpClientFactory, string apiKey, string apiSecret, string apiUrl, bool showFiat = true) + { + _apisecret = apiSecret; + _showFiat = showFiat; + _httpClient = httpClientFactory.CreateClient(); + _httpClient.BaseAddress = new Uri(apiUrl); + _httpClient.DefaultRequestHeaders.Add("api-key", apiKey); + } + + + private static string ToHexString(byte[] array) + { + var hex = new StringBuilder(array.Length * 2); + foreach (var b in array) + { + hex.AppendFormat("{0:x2}", b); + } + + return hex.ToString(); + } + + private async Task> PostToApi(string message) + { + var hmac = new HMACSHA512(Encoding.UTF8.GetBytes(_apisecret)); + var hashMessage = hmac.ComputeHash(Encoding.UTF8.GetBytes(message)); + var sign = ToHexString(hashMessage); + + var request = new HttpRequestMessage(HttpMethod.Post, ""); + request.Headers.Add("sign", sign); + request.Content = new StringContent(message, Encoding.UTF8, "application/json"); + + var result = await _httpClient.SendAsync(request); + + if (!result.IsSuccessStatusCode) + throw new ChangellyException(result.ReasonPhrase); + var content = + await result.Content.ReadAsStringAsync(); + return JObject.Parse(content).ToObject>(); + + } + + public virtual async Task> GetCurrenciesFull() + { + const string message = @"{ + ""jsonrpc"": ""2.0"", + ""id"": 1, + ""method"": ""getCurrenciesFull"", + ""params"": [] + }"; + + var result = await PostToApi>(message); + var appendedResult = _showFiat + ? result.Result.Concat(new[] + { + new CurrencyFull() + { + Enable = true, + Name = "EUR", + FullName = "Euro", + PayInConfirmations = 0, + ImageLink = "https://changelly.com/api/coins/eur.png" + }, + new CurrencyFull() + { + Enable = true, + Name = "USD", + FullName = "US Dollar", + PayInConfirmations = 0, + ImageLink = "https://changelly.com/api/coins/usd.png" + } + }) + : result.Result; + return appendedResult; + } + + public virtual async Task GetExchangeAmount(string fromCurrency, + string toCurrency, + decimal amount) + { + var message = + $"{{\"id\": \"test\",\"jsonrpc\": \"2.0\",\"method\": \"getExchangeAmount\",\"params\":{{\"from\": \"{fromCurrency}\",\"to\": \"{toCurrency}\",\"amount\": \"{amount}\"}}}}"; + + var result = await PostToApi(message); + + return Convert.ToDecimal(result.Result); + } + } +} diff --git a/BTCPayServer/Payments/Changelly/ChangellyClientProvider.cs b/BTCPayServer/Payments/Changelly/ChangellyClientProvider.cs new file mode 100644 index 000000000..f42ca8c38 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/ChangellyClientProvider.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using BTCPayServer.Services.Stores; +using NBitcoin; + +namespace BTCPayServer.Payments.Changelly +{ + public class ChangellyClientProvider + { + private readonly StoreRepository _storeRepository; + private readonly IHttpClientFactory _httpClientFactory; + + private readonly ConcurrentDictionary _clientCache = + new ConcurrentDictionary(); + + public ChangellyClientProvider(StoreRepository storeRepository, IHttpClientFactory httpClientFactory) + { + _storeRepository = storeRepository; + _httpClientFactory = httpClientFactory; + } + + public void InvalidateClient(string storeId) + { + if (_clientCache.ContainsKey(storeId)) + { + _clientCache.Remove(storeId, out var value); + } + } + + + public virtual bool TryGetChangellyClient(string storeId, out string error, + out Changelly changelly) + { + if (_clientCache.ContainsKey(storeId)) + { + changelly = _clientCache[storeId]; + error = null; + return true; + } + + changelly = null; + + + var store = _storeRepository.FindStore(storeId).Result; + if (store == null) + { + error = "Store not found"; + return false; + } + + var blob = store.GetStoreBlob(); + var changellySettings = blob.ChangellySettings; + + + if (changellySettings == null || !changellySettings.IsConfigured()) + { + error = "Changelly not configured for this store"; + return false; + } + + if (!changellySettings.Enabled) + { + error = "Changelly not enabled for this store"; + return false; + } + + changelly = new Changelly(_httpClientFactory, changellySettings.ApiKey, changellySettings.ApiSecret, + changellySettings.ApiUrl, changellySettings.ShowFiat); + _clientCache.AddOrReplace(storeId, changelly); + error = null; + return true; + } + } +} diff --git a/BTCPayServer/Payments/Changelly/ChangellyException.cs b/BTCPayServer/Payments/Changelly/ChangellyException.cs new file mode 100644 index 000000000..f476fc233 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/ChangellyException.cs @@ -0,0 +1,11 @@ +using System; + +namespace BTCPayServer.Payments.Changelly +{ + public class ChangellyException : Exception + { + public ChangellyException(string message) : base(message) + { + } + } +} diff --git a/BTCPayServer/Payments/Changelly/ChangellySettings.cs b/BTCPayServer/Payments/Changelly/ChangellySettings.cs new file mode 100644 index 000000000..56d6b7567 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/ChangellySettings.cs @@ -0,0 +1,21 @@ +namespace BTCPayServer.Payments.Changelly +{ + public class ChangellySettings + { + public string ApiKey { get; set; } + public string ApiSecret { get; set; } + public string ApiUrl { get; set; } + public bool Enabled { get; set; } + public string ChangellyMerchantId { get; set; } + public decimal AmountMarkupPercentage { get; set; } + public bool ShowFiat { get; set; } + + public bool IsConfigured() + { + return + !string.IsNullOrEmpty(ApiKey) || + !string.IsNullOrEmpty(ApiSecret) || + !string.IsNullOrEmpty(ApiUrl); + } + } +} diff --git a/BTCPayServer/Payments/Changelly/Models/ChangellyResponse.cs b/BTCPayServer/Payments/Changelly/Models/ChangellyResponse.cs new file mode 100644 index 000000000..dffa43714 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/Models/ChangellyResponse.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Changelly.Models +{ + + public class ChangellyResponse + { + [JsonProperty("jsonrpc")] + public string JsonRPC { get; set; } + [JsonProperty("id")] + public object Id { get; set; } + [JsonProperty("result")] + public T Result { get; set; } + [JsonProperty("error")] + public Error Error { get; set; } + } +} diff --git a/BTCPayServer/Payments/Changelly/Models/CurrencyFull.cs b/BTCPayServer/Payments/Changelly/Models/CurrencyFull.cs new file mode 100644 index 000000000..f58777de6 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/Models/CurrencyFull.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Changelly.Models +{ + public class CurrencyFull + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("fullName")] + public string FullName { get; set; } + [JsonProperty("enabled")] + public bool Enable { get; set; } + [JsonProperty("payinConfirmations")] + public int PayInConfirmations { get; set; } + [JsonProperty("image")] + public string ImageLink { get; set; } + } +} \ No newline at end of file diff --git a/BTCPayServer/Payments/Changelly/Models/Error.cs b/BTCPayServer/Payments/Changelly/Models/Error.cs new file mode 100644 index 000000000..bc7aaa023 --- /dev/null +++ b/BTCPayServer/Payments/Changelly/Models/Error.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Payments.Changelly.Models +{ + public class Error + { + [JsonProperty("code")] + public int Code { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + } +} \ No newline at end of file diff --git a/BTCPayServer/Payments/PaymentMethodExtensions.cs b/BTCPayServer/Payments/PaymentMethodExtensions.cs index af1f45e81..b63fe4364 100644 --- a/BTCPayServer/Payments/PaymentMethodExtensions.cs +++ b/BTCPayServer/Payments/PaymentMethodExtensions.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Payments.Changelly; using Newtonsoft.Json; using Newtonsoft.Json.Linq; diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index 308b0e8fb..7f7502ffb 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -148,7 +148,7 @@
{{$t("Copy")}}
- @if (Model.AllowCoinConversion) + @if (Model.ChangellyEnabled) {
{{$t("Conversion")}} @@ -253,7 +253,7 @@
- @if (Model.AllowCoinConversion) + @if (Model.ChangellyEnabled) {
-
- - - - - - @*Changelly doesn't have TO_AMOUNT support so we can't include it - - - Changelly - *@ +
+ +
+
+ +
+ + Changelly + + + +
+ +
+
+
diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index 1a4bbd190..c5f86fdba 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -53,49 +53,53 @@
- -
-