From a6ef7387cfc1aade3336879ccc062d927909565d Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:12:11 +0200 Subject: [PATCH 001/119] Update LanguageService.cs --- BTCPayServer/Services/LanguageService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/BTCPayServer/Services/LanguageService.cs b/BTCPayServer/Services/LanguageService.cs index c738be363..bfaeb64ed 100644 --- a/BTCPayServer/Services/LanguageService.cs +++ b/BTCPayServer/Services/LanguageService.cs @@ -31,6 +31,7 @@ namespace BTCPayServer.Services new Language("nl-NL", "Dutch"), new Language("cs-CZ", "Česky"), new Language("is-IS", "Íslenska"), + new Language("hr-HR", "Croatian"), }; } } From 986c7b94f4d4d2b6b859f3fc088de0fdbd4e840f Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:13:44 +0200 Subject: [PATCH 002/119] Update Checkout.cshtml --- BTCPayServer/Views/Invoice/Checkout.cshtml | 1 + 1 file changed, 1 insertion(+) diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index 8a4484ba1..e4e9e4b49 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -115,6 +115,7 @@ 'nl': { translation: locales_nl }, 'cs-CZ': { translation: locales_cs }, 'is-IS': { translation: locales_is } + 'hr-HR': { translation: locales_hr } }, }); From 43975911349ab01493ecce94e6e17796ecb2a7ea Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:27:51 +0200 Subject: [PATCH 003/119] Create hr.js --- BTCPayServer/wwwroot/js/checkout/lang/hr.js | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 BTCPayServer/wwwroot/js/checkout/lang/hr.js diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/js/checkout/lang/hr.js new file mode 100644 index 000000000..6f7f5752d --- /dev/null +++ b/BTCPayServer/wwwroot/js/checkout/lang/hr.js @@ -0,0 +1,48 @@ +const locales_cs = { + nested: { + lang: 'Jazyk' + }, + "Awaiting Payment...": "Čeka se uplata...", + "Pay with": "Plati sa", + "Contact and Refund Email": "E-mail za kontakt i povrat sredstava", + "Contact_Body": "Molimo upišite Vašu e-mail adresu. Kontaktirati ćemo Vas ako nastane problem s uplatom", + "Your email": "Vaš e-mail", + "Continue": "Dalje", + "Please enter a valid email address": "Molimo unesite ispravnu e-mail adresu", + "Order Amount": "Količina", + "Network Cost": "Mrežni trošak", + "Already Paid": "Već plačeno", + "Due": "Rok", + // Tabs + "Scan": "Skeniraj", + "Copy": "Kopiraj", + "Conversion": "Pretvori", + // Scan tab + "Open in wallet": "Otvori u novčaniku", + // Copy tab + "CompletePay_Body": "Kako bi završili uplatu poašljite {{btcDue}} {{cryptoCode}} na navedenu adresu", + "Amount": "Iznos", + "Address": "Adresa", + "Copied": "Kopirano", + // Conversion tab + "ConversionTab_BodyTop": "Možete platiti {{btcDue}} {{cryptoCode}} pomoću altcoina koje prodavač ne podržava.", + "ConversionTab_BodyDesc": "Ovu usluga pruža treća strana. Vodite računa da nemamo kontroli nad načinom kako će Vam davatelji usluge prosljediti sredstva. Vodite računa da je račun plaćen tek kada su primljena sredstva na {{cryptoCode}} Blockchainu.", + "Shapeshift_Button_Text": "Plati s Altcoinovima", + "ConversionTab_Lightning": "Ne postoji treća strana koja bi konvertirala Lightning Network uplate.", + // Invoice expired + "Invoice expiring soon...": "Račun uskoro istiće...", + "Invoice expired": "Račun je istekao", + "What happened?": "Što se dogodilo", + "InvoiceExpired_Body_1": "Račun je istekao i nije više valjan. Račun vrijedi samo {{maxTimeMinutes}} minuta. \ +Možete se vratiti na {{storeName}}, gdje možete ponovo inicirati plačanje.", + "InvoiceExpired_Body_2": "Ukoliko ste pokušali poslati uplatu, ista nije registrirana na Blockchainu. Nismo još zaprimili Vašu uplatu.", + "InvoiceExpired_Body_3": "Ukoliko poslana sredstva na budu potvrđena na Blockchainu, sredstva će biti ponovo dostupna u Vašem novčaniku.", + "Invoice ID": "Broj računa", + "Order ID": "Broj narudžbe", + "Return to StoreName": "Vrati se na {{storeName}}", + // Invoice paid + "This invoice has been paid": "Račun je plačen", + // Invoice archived + "This invoice has been archived": "Račun je arhiviran.", + "Archived_Body": "Kontaktirajte dučan za detalje oko naruđbe ili pomoć." +}; From c5e833ee79ba3dac1b3f3cec7c1321e9e5cda440 Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:33:34 +0200 Subject: [PATCH 004/119] Update hr.js --- BTCPayServer/wwwroot/js/checkout/lang/hr.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/js/checkout/lang/hr.js index 6f7f5752d..3e219c676 100644 --- a/BTCPayServer/wwwroot/js/checkout/lang/hr.js +++ b/BTCPayServer/wwwroot/js/checkout/lang/hr.js @@ -20,7 +20,7 @@ const locales_cs = { // Scan tab "Open in wallet": "Otvori u novčaniku", // Copy tab - "CompletePay_Body": "Kako bi završili uplatu poašljite {{btcDue}} {{cryptoCode}} na navedenu adresu", + "CompletePay_Body": "Kako bi završili uplatu pošaljite {{btcDue}} {{cryptoCode}} na navedenu adresu", "Amount": "Iznos", "Address": "Adresa", "Copied": "Kopirano", From bb7dc1ed4ad065e43189ac3f606c855133d31490 Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:34:47 +0200 Subject: [PATCH 005/119] Update hr.js --- BTCPayServer/wwwroot/js/checkout/lang/hr.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/js/checkout/lang/hr.js index 3e219c676..a50748a60 100644 --- a/BTCPayServer/wwwroot/js/checkout/lang/hr.js +++ b/BTCPayServer/wwwroot/js/checkout/lang/hr.js @@ -1,6 +1,6 @@ -const locales_cs = { +const locales_hr = { nested: { - lang: 'Jazyk' + lang: 'Croatian' }, "Awaiting Payment...": "Čeka se uplata...", "Pay with": "Plati sa", From 94ff77f2b294af0ff6c7c3dc6f2db241970bc0b8 Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:44:55 +0200 Subject: [PATCH 006/119] Update hr.js --- BTCPayServer/wwwroot/js/checkout/lang/hr.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/js/checkout/lang/hr.js index a50748a60..d54d47c35 100644 --- a/BTCPayServer/wwwroot/js/checkout/lang/hr.js +++ b/BTCPayServer/wwwroot/js/checkout/lang/hr.js @@ -5,13 +5,13 @@ const locales_hr = { "Awaiting Payment...": "Čeka se uplata...", "Pay with": "Plati sa", "Contact and Refund Email": "E-mail za kontakt i povrat sredstava", - "Contact_Body": "Molimo upišite Vašu e-mail adresu. Kontaktirati ćemo Vas ako nastane problem s uplatom", + "Contact_Body": "Molimo upišite Vašu e-mail adresu. Kontaktirat ćemo Vas ukoliko bude potrebe.", "Your email": "Vaš e-mail", "Continue": "Dalje", "Please enter a valid email address": "Molimo unesite ispravnu e-mail adresu", "Order Amount": "Količina", "Network Cost": "Mrežni trošak", - "Already Paid": "Već plačeno", + "Already Paid": "Već plaćeno", "Due": "Rok", // Tabs "Scan": "Skeniraj", @@ -26,23 +26,23 @@ const locales_hr = { "Copied": "Kopirano", // Conversion tab "ConversionTab_BodyTop": "Možete platiti {{btcDue}} {{cryptoCode}} pomoću altcoina koje prodavač ne podržava.", - "ConversionTab_BodyDesc": "Ovu usluga pruža treća strana. Vodite računa da nemamo kontroli nad načinom kako će Vam davatelji usluge prosljediti sredstva. Vodite računa da je račun plaćen tek kada su primljena sredstva na {{cryptoCode}} Blockchainu.", - "Shapeshift_Button_Text": "Plati s Altcoinovima", + "ConversionTab_BodyDesc": "Ovu usluga pruža treća strana. Vodite računa da nemamo kontroli nad načinom kako će Vam davatelji usluge proslijediti sredstva. Vodite računa da je račun plaćen tek kada su primljena sredstva na {{cryptoCode}} Blockchainu.", + "Shapeshift_Button_Text": "Plati s Alt-coinovima", "ConversionTab_Lightning": "Ne postoji treća strana koja bi konvertirala Lightning Network uplate.", // Invoice expired - "Invoice expiring soon...": "Račun uskoro istiće...", + "Invoice expiring soon...": "Račun uskoro ističe...", "Invoice expired": "Račun je istekao", "What happened?": "Što se dogodilo", "InvoiceExpired_Body_1": "Račun je istekao i nije više valjan. Račun vrijedi samo {{maxTimeMinutes}} minuta. \ -Možete se vratiti na {{storeName}}, gdje možete ponovo inicirati plačanje.", - "InvoiceExpired_Body_2": "Ukoliko ste pokušali poslati uplatu, ista nije registrirana na Blockchainu. Nismo još zaprimili Vašu uplatu.", - "InvoiceExpired_Body_3": "Ukoliko poslana sredstva na budu potvrđena na Blockchainu, sredstva će biti ponovo dostupna u Vašem novčaniku.", +Možete se vratiti na {{storeName}}, gdje možete ponovo inicirati plaćanje.", + "InvoiceExpired_Body_2": "Ako ste pokušali poslati uplatu, ista nije registrirana na Blockchainu. Nismo još zaprimili Vašu uplatu.", + "InvoiceExpired_Body_3": "Ako poslana sredstva na budu potvrđena na Blockchainu, sredstva će biti ponovo dostupna u Vašem novčaniku.", "Invoice ID": "Broj računa", "Order ID": "Broj narudžbe", "Return to StoreName": "Vrati se na {{storeName}}", // Invoice paid - "This invoice has been paid": "Račun je plačen", + "This invoice has been paid": "Račun je plaćen", // Invoice archived "This invoice has been archived": "Račun je arhiviran.", - "Archived_Body": "Kontaktirajte dučan za detalje oko naruđbe ili pomoć." + "Archived_Body": "Kontaktirajte dućan za detalje oko narudžbe ili pomoć." }; From de39fa0aea67c16d509fa1eaa3b9f5e45a14b0f1 Mon Sep 17 00:00:00 2001 From: 2pac1 <35058480+2pac1@users.noreply.github.com> Date: Sat, 28 Apr 2018 18:46:55 +0200 Subject: [PATCH 007/119] Update hr.js --- BTCPayServer/wwwroot/js/checkout/lang/hr.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/js/checkout/lang/hr.js index d54d47c35..d2c6c4a24 100644 --- a/BTCPayServer/wwwroot/js/checkout/lang/hr.js +++ b/BTCPayServer/wwwroot/js/checkout/lang/hr.js @@ -2,7 +2,7 @@ const locales_hr = { nested: { lang: 'Croatian' }, - "Awaiting Payment...": "Čeka se uplata...", + "Awaiting Payment...": "Čekamo uplatu...", "Pay with": "Plati sa", "Contact and Refund Email": "E-mail za kontakt i povrat sredstava", "Contact_Body": "Molimo upišite Vašu e-mail adresu. Kontaktirat ćemo Vas ukoliko bude potrebe.", @@ -10,7 +10,7 @@ const locales_hr = { "Continue": "Dalje", "Please enter a valid email address": "Molimo unesite ispravnu e-mail adresu", "Order Amount": "Količina", - "Network Cost": "Mrežni trošak", + "Network Cost": "Trošak mreže", "Already Paid": "Već plaćeno", "Due": "Rok", // Tabs From 2848caff2ee9b4d87c8132efcb63cb431a7eac36 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 18:28:04 +0900 Subject: [PATCH 008/119] Support Legacy API Key authentication to Bitpay Invoice API --- BTCPayServer.Tests/UnitTest1.cs | 33 ++ BTCPayServer.Tests/UnitTestPeusa.cs | 52 -- .../Authentication/TokenRepository.cs | 40 ++ BTCPayServer/Controllers/StoresController.cs | 17 + BTCPayServer/Data/APIKeyData.cs | 23 + BTCPayServer/Data/ApplicationDbContext.cs | 7 + BTCPayServer/Data/StoreData.cs | 3 +- .../Filters/OnlyMediaTypeAttribute.cs | 6 +- BTCPayServer/Hosting/BTCpayMiddleware.cs | 43 +- .../20180429083930_legacyapikey.Designer.cs | 553 ++++++++++++++++++ .../Migrations/20180429083930_legacyapikey.cs | 35 ++ .../ApplicationDbContextModelSnapshot.cs | 18 +- .../Models/StoreViewModels/TokensViewModel.cs | 4 + BTCPayServer/Views/Stores/ListTokens.cshtml | 84 ++- 14 files changed, 831 insertions(+), 87 deletions(-) delete mode 100644 BTCPayServer.Tests/UnitTestPeusa.cs create mode 100644 BTCPayServer/Data/APIKeyData.cs create mode 100644 BTCPayServer/Migrations/20180429083930_legacyapikey.Designer.cs create mode 100644 BTCPayServer/Migrations/20180429083930_legacyapikey.cs diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 64afa1d28..1b7eccc60 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -32,6 +32,9 @@ using BTCPayServer.HostedServices; using BTCPayServer.Payments.Lightning; using BTCPayServer.Models.AppViewModels; using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Stores; +using System.Net.Http; +using System.Text; namespace BTCPayServer.Tests { @@ -623,6 +626,36 @@ namespace BTCPayServer.Tests user.GrantAccess(); user.RegisterDerivationScheme("BTC"); Assert.True(user.BitPay.TestAccess(Facade.Merchant)); + + // Can generate API Key + var repo = tester.PayTester.GetService(); + Assert.Empty(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); + Assert.IsType(user.GetController().GenerateAPIKey(user.StoreId).GetAwaiter().GetResult()); + + var apiKey = Assert.Single(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); + /////// + + // Generating a new one remove the previous + Assert.IsType(user.GetController().GenerateAPIKey(user.StoreId).GetAwaiter().GetResult()); + var apiKey2 = Assert.Single(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); + Assert.NotEqual(apiKey, apiKey2); + //////// + + apiKey = apiKey2; + + // Can create an invoice with this new API Key + HttpClient client = new HttpClient(); + HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post, tester.PayTester.ServerUri.AbsoluteUri + "invoices"); + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey))); + var invoice = new Invoice() + { + Price = 5000.0, + Currency = "USD" + }; + message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8, "application/json"); + var result = client.SendAsync(message).GetAwaiter().GetResult(); + result.EnsureSuccessStatusCode(); + ///////////////////// } } diff --git a/BTCPayServer.Tests/UnitTestPeusa.cs b/BTCPayServer.Tests/UnitTestPeusa.cs deleted file mode 100644 index cb9861d5f..000000000 --- a/BTCPayServer.Tests/UnitTestPeusa.cs +++ /dev/null @@ -1,52 +0,0 @@ -using NBitcoin; -using NBitcoin.DataEncoders; -using NBitpayClient; -using System; -using System.Collections.Generic; -using System.Text; -using Xunit; - -namespace BTCPayServer.Tests -{ - // Helper class for testing functionality and generating data needed during coding/debuging - public class UnitTestPeusa - { - // Unit test that generates temorary checkout Bitpay page - // https://forkbitpay.slack.com/archives/C7M093Z55/p1508293682000217 - - // Testnet of Bitpay down - //[Fact] - //public void BitpayCheckout() - //{ - // var key = new Key(Encoders.Hex.DecodeData("7b70a06f35562873e3dcb46005ed0fe78e1991ad906e56adaaafa40ba861e056")); - // var url = new Uri("https://test.bitpay.com/"); - // var btcpay = new Bitpay(key, url); - // var invoice = btcpay.CreateInvoice(new Invoice() - // { - - // Price = 5.0, - // Currency = "USD", - // PosData = "posData", - // OrderId = "cdfd8a5f-6928-4c3b-ba9b-ddf438029e73", - // ItemDesc = "Hello from the otherside" - // }, Facade.Merchant); - - // // go to invoice.Url - // Console.WriteLine(invoice.Url); - //} - - // Generating Extended public key to use on http://localhost:14142/stores/{storeId} - [Fact] - public void GeneratePubkey() - { - var network = Network.RegTest; - - ExtKey masterKey = new ExtKey(); - Console.WriteLine("Master key : " + masterKey.ToString(network)); - ExtPubKey masterPubKey = masterKey.Neuter(); - - ExtPubKey pubkey = masterPubKey.Derive(0); - Console.WriteLine("PubKey " + 0 + " : " + pubkey.ToString(network)); - } - } -} diff --git a/BTCPayServer/Authentication/TokenRepository.cs b/BTCPayServer/Authentication/TokenRepository.cs index 0473aadca..776533fe3 100644 --- a/BTCPayServer/Authentication/TokenRepository.cs +++ b/BTCPayServer/Authentication/TokenRepository.cs @@ -45,6 +45,46 @@ namespace BTCPayServer.Authentication } } + public async Task GetStoreIdFromAPIKey(string apiKey) + { + using (var ctx = _Factory.CreateContext()) + { + return await ctx.ApiKeys.Where(o => o.Id == apiKey).Select(o => o.StoreId).FirstOrDefaultAsync(); + } + } + + public async Task GenerateLegacyAPIKey(string storeId) + { + // It is legacy support and Bitpay generate string of unknown format, trying to replicate them + // as good as possible. The string below got generated for me. + var chars = "ERo0vkBMOYhyU0ZHvirCplbLDIGWPdi1ok77VnW7QdE"; + var rand = new Random(Math.Abs(RandomUtils.GetInt32())); + var generated = new char[chars.Length]; + for (int i = 0; i < generated.Length; i++) + { + generated[i] = chars[rand.Next(0, generated.Length)]; + } + + using (var ctx = _Factory.CreateContext()) + { + var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId).FirstOrDefaultAsync(); + if (existing != null) + { + ctx.ApiKeys.Remove(existing); + } + ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId }); + await ctx.SaveChangesAsync().ConfigureAwait(false); + } + } + + public async Task GetLegacyAPIKeys(string storeId) + { + using (var ctx = _Factory.CreateContext()) + { + return await ctx.ApiKeys.Where(o => o.StoreId == storeId).Select(c => c.Id).ToArrayAsync(); + } + } + private BitTokenEntity CreateTokenEntity(PairedSINData data) { return new BitTokenEntity() diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 5b33e4c63..69b911d5c 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -427,6 +427,12 @@ namespace BTCPayServer.Controllers SIN = t.SIN, Id = t.Value }).ToArray(); + + model.ApiKey = (await _TokenRepository.GetLegacyAPIKeys(storeId)).FirstOrDefault(); + if (model.ApiKey == null) + model.EncodedApiKey = "*API Key*"; + else + model.EncodedApiKey = Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey)); return View(model); } @@ -525,6 +531,17 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(ListTokens)); } + [HttpPost] + [Route("{storeId}/tokens/apikey")] + public async Task GenerateAPIKey(string storeId) + { + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + await _TokenRepository.GenerateLegacyAPIKey(storeId); + StatusMessage = "API Key re-generated"; + return RedirectToAction(nameof(ListTokens)); + } [HttpGet] [Route("/api-access-request")] diff --git a/BTCPayServer/Data/APIKeyData.cs b/BTCPayServer/Data/APIKeyData.cs new file mode 100644 index 000000000..e826c32f7 --- /dev/null +++ b/BTCPayServer/Data/APIKeyData.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Data +{ + public class APIKeyData + { + [MaxLength(50)] + public string Id + { + get; set; + } + + [MaxLength(50)] + public string StoreId + { + get; set; + } + } +} diff --git a/BTCPayServer/Data/ApplicationDbContext.cs b/BTCPayServer/Data/ApplicationDbContext.cs index 9ac63c03b..fb4507a99 100644 --- a/BTCPayServer/Data/ApplicationDbContext.cs +++ b/BTCPayServer/Data/ApplicationDbContext.cs @@ -86,6 +86,11 @@ namespace BTCPayServer.Data get; set; } + public DbSet ApiKeys + { + get; set; + } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var isConfigured = optionsBuilder.Options.Extensions.OfType().Any(); @@ -112,6 +117,8 @@ namespace BTCPayServer.Data t.StoreDataId }); + builder.Entity() + .HasIndex(o => o.StoreId); builder.Entity() .HasOne(a => a.StoreData); diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 58923bbf1..52c19e26a 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -14,6 +14,7 @@ using Newtonsoft.Json.Linq; using BTCPayServer.Services.Rates; using BTCPayServer.Payments; using BTCPayServer.JsonConverters; +using System.ComponentModel.DataAnnotations; namespace BTCPayServer.Data { @@ -120,7 +121,7 @@ namespace BTCPayServer.Data } } - if(!existing && supportedPaymentMethod == null && paymentMethodId.IsBTCOnChain) + if (!existing && supportedPaymentMethod == null && paymentMethodId.IsBTCOnChain) { DerivationStrategy = null; } diff --git a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs index abca0f908..61110d697 100644 --- a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs +++ b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs @@ -44,8 +44,10 @@ namespace BTCPayServer.Filters public bool Accept(ActionConstraintContext context) { var hasVersion = context.RouteContext.HttpContext.Request.Headers["x-accept-version"].Where(h => h == "2.0.0").Any(); - var hasIdentity = context.RouteContext.HttpContext.Request.Headers["x-identity"].Any(); - return (hasVersion || hasIdentity) == IsBitpayAPI; + var isBitpayAPI = + context.RouteContext.HttpContext.Items.TryGetValue("IsBitpayAPI", out object obj) && + obj is bool b && b; + return (hasVersion || isBitpayAPI) == IsBitpayAPI; } } diff --git a/BTCPayServer/Hosting/BTCpayMiddleware.cs b/BTCPayServer/Hosting/BTCpayMiddleware.cs index 5eab8d6c5..69b4951db 100644 --- a/BTCPayServer/Hosting/BTCpayMiddleware.cs +++ b/BTCPayServer/Hosting/BTCpayMiddleware.cs @@ -53,12 +53,23 @@ namespace BTCPayServer.Hosting var sig = values.FirstOrDefault(); httpContext.Request.Headers.TryGetValue("x-identity", out values); var id = values.FirstOrDefault(); + httpContext.Request.Headers.TryGetValue("Authorization", out values); + var auth = values.FirstOrDefault(); try { + bool isBitId = false; if (!string.IsNullOrEmpty(sig) && !string.IsNullOrEmpty(id)) { await HandleBitId(httpContext, sig, id); + isBitId = httpContext.User.HasClaim(c => c.Type == Claims.SIN); + if (!isBitId) + Logs.PayServer.LogDebug("BitId signature check failed"); } + if (!isBitId && !string.IsNullOrEmpty(auth)) + { + await HandleLegacyAPIKey(httpContext, auth); + } + await _Next(httpContext); } catch (WebSocketException) @@ -76,7 +87,7 @@ namespace BTCPayServer.Hosting Logs.PayServer.LogCritical(new EventId(), ex, "Unhandled exception in BTCPayMiddleware"); throw; } - } + } private void RewriteHostIfNeeded(HttpContext httpContext) { @@ -221,11 +232,37 @@ namespace BTCPayServer.Hosting identity.AddClaim(new Claim(Claims.OwnStore, bitToken.StoreId)); } Logs.PayServer.LogDebug($"BitId signature check success for SIN {sin}"); + NBitcoin.Extensions.TryAdd(httpContext.Items, "IsBitpayAPI", true); } } catch (FormatException) { } - if (!httpContext.User.HasClaim(c => c.Type == Claims.SIN)) - Logs.PayServer.LogDebug("BitId signature check failed"); + } + + private async Task HandleLegacyAPIKey(HttpContext httpContext, string auth) + { + var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase)) + { + throw new BitpayHttpException(401, $"Invalid Authorization header"); + } + + string apiKey = null; + try + { + apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1])); + } + catch + { + throw new BitpayHttpException(401, $"Invalid Authorization header"); + } + var storeId = await _TokenRepository.GetStoreIdFromAPIKey(apiKey); + if (storeId == null) + { + throw new BitpayHttpException(401, $"Invalid Authorization header"); + } + var identity = ((ClaimsIdentity)httpContext.User.Identity); + identity.AddClaim(new Claim(Claims.OwnStore, storeId)); + NBitcoin.Extensions.TryAdd(httpContext.Items, "IsBitpayAPI", true); } private async Task GetTokenPermissionAsync(string sin, string expectedToken) diff --git a/BTCPayServer/Migrations/20180429083930_legacyapikey.Designer.cs b/BTCPayServer/Migrations/20180429083930_legacyapikey.Designer.cs new file mode 100644 index 000000000..b478184d6 --- /dev/null +++ b/BTCPayServer/Migrations/20180429083930_legacyapikey.Designer.cs @@ -0,0 +1,553 @@ +// +using BTCPayServer.Data; +using BTCPayServer.Services.Invoices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.Internal; +using System; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20180429083930_legacyapikey")] + partial class legacyapikey + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "2.0.2-rtm-10011"); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.Property("Address") + .ValueGeneratedOnAdd(); + + b.Property("CreatedTime"); + + b.Property("InvoiceDataId"); + + b.HasKey("Address"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("AddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasMaxLength(50); + + b.Property("StoreId") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AppType"); + + b.Property("Created"); + + b.Property("Name"); + + b.Property("Settings"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.Property("InvoiceDataId"); + + b.Property("Address"); + + b.Property("Assigned"); + + b.Property("CryptoCode"); + + b.Property("UnAssigned"); + + b.HasKey("InvoiceDataId", "Address"); + + b.ToTable("HistoricalAddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("Created"); + + b.Property("CustomerEmail"); + + b.Property("ExceptionStatus"); + + b.Property("ItemCode"); + + b.Property("OrderId"); + + b.Property("Status"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.Property("InvoiceDataId"); + + b.Property("UniqueId"); + + b.Property("Message"); + + b.Property("Timestamp"); + + b.HasKey("InvoiceDataId", "UniqueId"); + + b.ToTable("InvoiceEvents"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("PairingTime"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.HasKey("Id"); + + b.HasIndex("SIN"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PairedSINData"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairingCodeData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DateCreated"); + + b.Property("Expiration"); + + b.Property("Facade"); + + b.Property("Label"); + + b.Property("SIN"); + + b.Property("StoreDataId"); + + b.Property("TokenValue"); + + b.HasKey("Id"); + + b.ToTable("PairingCodes"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Accounted"); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.HasKey("Id"); + + b.ToTable("PendingInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Blob"); + + b.Property("InvoiceDataId"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("RefundAddresses"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("Value"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("DefaultCrypto"); + + b.Property("DerivationStrategies"); + + b.Property("DerivationStrategy"); + + b.Property("SpeedPolicy"); + + b.Property("StoreBlob"); + + b.Property("StoreCertificate"); + + b.Property("StoreName"); + + b.Property("StoreWebsite"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId"); + + b.Property("StoreDataId"); + + b.Property("Role"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("AccessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Email") + .HasMaxLength(256); + + b.Property("EmailConfirmed"); + + b.Property("LockoutEnabled"); + + b.Property("LockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasMaxLength(256); + + b.Property("PasswordHash"); + + b.Property("PhoneNumber"); + + b.Property("PhoneNumberConfirmed"); + + b.Property("RequiresEmailConfirmation"); + + b.Property("SecurityStamp"); + + b.Property("TwoFactorEnabled"); + + b.Property("UserName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken(); + + b.Property("Name") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("RoleId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd(); + + b.Property("ClaimType"); + + b.Property("ClaimValue"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider"); + + b.Property("ProviderKey"); + + b.Property("ProviderDisplayName"); + + b.Property("UserId") + .IsRequired(); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId"); + + b.Property("RoleId"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId"); + + b.Property("LoginProvider"); + + b.Property("Name"); + + b.Property("Value"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("AddressInvoices") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Apps") + .HasForeignKey("StoreDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData") + .WithMany("HistoricalAddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany() + .HasForeignKey("StoreDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData") + .WithMany("Events") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("RefundAddresses") + .HasForeignKey("InvoiceDataId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Models.ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer/Migrations/20180429083930_legacyapikey.cs b/BTCPayServer/Migrations/20180429083930_legacyapikey.cs new file mode 100644 index 000000000..0a1d21bb5 --- /dev/null +++ b/BTCPayServer/Migrations/20180429083930_legacyapikey.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Migrations +{ + public partial class legacyapikey : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(maxLength: 50, nullable: false), + StoreId = table.Column(maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_StoreId", + table: "ApiKeys", + column: "StoreId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + } + } +} diff --git a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs index 196e557f9..c31f3ff53 100644 --- a/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace BTCPayServer.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "2.0.1-rtm-125"); + .HasAnnotation("ProductVersion", "2.0.2-rtm-10011"); modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => { @@ -36,6 +36,22 @@ namespace BTCPayServer.Migrations b.ToTable("AddressInvoices"); }); + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasMaxLength(50); + + b.Property("StoreId") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("ApiKeys"); + }); + modelBuilder.Entity("BTCPayServer.Data.AppData", b => { b.Property("Id") diff --git a/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs b/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs index 694b5d64d..84e9791b5 100644 --- a/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs @@ -68,5 +68,9 @@ namespace BTCPayServer.Models.StoreViewModels get; set; } + + [Display(Name = "API Key")] + public string ApiKey { get; set; } + public string EncodedApiKey { get; set; } } } diff --git a/BTCPayServer/Views/Stores/ListTokens.cshtml b/BTCPayServer/Views/Stores/ListTokens.cshtml index eccc1a77b..7aefec98d 100644 --- a/BTCPayServer/Views/Stores/ListTokens.cshtml +++ b/BTCPayServer/Views/Stores/ListTokens.cshtml @@ -5,32 +5,60 @@ ViewData.AddActivePage(StoreNavPages.Tokens); } -

@ViewData["Title"]

-

You can allow a public key to access the API of this store

@Html.Partial("_StatusMessage", Model.StatusMessage) - Create a new token - - - - - - - - - - - @foreach (var token in Model.Tokens) - { - - - - - - } - -
LabelSINFacadeActions
@token.Label@token.SIN@token.Facade -
- - -
-
+

Access token

+
+
+

Authorize a public key to access Bitpay compatible Invoice API (More information)

+
+
+
+
+ Create a new token + + + + + + + + + + + @foreach(var token in Model.Tokens) + { + + + + + + + } + +
LabelSINFacadeActions
@token.Label@token.SIN@token.Facade +
+ + +
+
+
+
+ +

Legacy API Keys

+
+
+

Alternatively, you can use the invoice API by including the following HTTP Header in your requests:
Authorization: Basic @Model.EncodedApiKey

+
+
+ +
+
+
+
+ + +
+ +
+
+
From f0145142a4fde790f8e055a7b379d482552c004e Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 20:32:43 +0900 Subject: [PATCH 009/119] Make sure that we don't authenticate call with bitpay auth methods on non bitpay calls --- .../Controllers/AccessTokenController.cs | 1 + .../Controllers/InvoiceController.API.cs | 21 +-- BTCPayServer/Extensions.cs | 21 +++ .../Filters/OnlyMediaTypeAttribute.cs | 6 +- BTCPayServer/Hosting/BTCpayMiddleware.cs | 133 +++++++++++++----- 5 files changed, 127 insertions(+), 55 deletions(-) diff --git a/BTCPayServer/Controllers/AccessTokenController.cs b/BTCPayServer/Controllers/AccessTokenController.cs index 25d42ca6b..ab6f15028 100644 --- a/BTCPayServer/Controllers/AccessTokenController.cs +++ b/BTCPayServer/Controllers/AccessTokenController.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; namespace BTCPayServer.Controllers { + [BitpayAPIConstraint] public class AccessTokenController : Controller { TokenRepository _TokenRepository; diff --git a/BTCPayServer/Controllers/InvoiceController.API.cs b/BTCPayServer/Controllers/InvoiceController.API.cs index 7dba3be1c..52b6f41f8 100644 --- a/BTCPayServer/Controllers/InvoiceController.API.cs +++ b/BTCPayServer/Controllers/InvoiceController.API.cs @@ -22,17 +22,14 @@ namespace BTCPayServer.Controllers { private InvoiceController _InvoiceController; private InvoiceRepository _InvoiceRepository; - private StoreRepository _StoreRepository; private BTCPayNetworkProvider _NetworkProvider; public InvoiceControllerAPI(InvoiceController invoiceController, InvoiceRepository invoceRepository, - StoreRepository storeRepository, BTCPayNetworkProvider networkProvider) { this._InvoiceController = invoiceController; this._InvoiceRepository = invoceRepository; - this._StoreRepository = storeRepository; this._NetworkProvider = networkProvider; } @@ -41,20 +38,14 @@ namespace BTCPayServer.Controllers [MediaTypeConstraint("application/json")] public async Task> CreateInvoice([FromBody] Invoice invoice) { - var store = await _StoreRepository.FindStore(this.User.GetStoreId()); - if (store == null) - throw new BitpayHttpException(401, "Can't access to store"); - return await _InvoiceController.CreateInvoiceCore(invoice, store, HttpContext.Request.GetAbsoluteRoot()); + return await _InvoiceController.CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot()); } [HttpGet] [Route("invoices/{id}")] public async Task> GetInvoice(string id, string token) { - var store = await _StoreRepository.FindStore(this.User.GetStoreId()); - if (store == null) - throw new BitpayHttpException(401, "Can't access to store"); - var invoice = await _InvoiceRepository.GetInvoice(store.Id, id); + var invoice = await _InvoiceRepository.GetInvoice(HttpContext.GetStoreData().Id, id); if (invoice == null) throw new BitpayHttpException(404, "Object not found"); var resp = invoice.EntityToDTO(_NetworkProvider); @@ -75,10 +66,7 @@ namespace BTCPayServer.Controllers { if (dateEnd != null) dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day - - var store = await _StoreRepository.FindStore(this.User.GetStoreId()); - if (store == null) - throw new BitpayHttpException(401, "Can't access to store"); + var query = new InvoiceQuery() { Count = limit, @@ -88,10 +76,9 @@ namespace BTCPayServer.Controllers OrderId = orderId, ItemCode = itemCode, Status = status == null ? null : new[] { status }, - StoreId = new[] { store.Id } + StoreId = new[] { this.HttpContext.GetStoreData().Id } }; - var entities = (await _InvoiceRepository.GetInvoices(query)) .Select((o) => o.EntityToDTO(_NetworkProvider)).ToArray(); diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index cef8a007e..7b6dc217e 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -30,6 +30,7 @@ using BTCPayServer.Models; using System.Security.Claims; using System.Globalization; using BTCPayServer.Services; +using BTCPayServer.Data; namespace BTCPayServer { @@ -153,6 +154,26 @@ namespace BTCPayServer return principal.Claims.Where(c => c.Type == Claims.OwnStore).Select(c => c.Value).FirstOrDefault(); } + public static void SetIsBitpayAPI(this HttpContext ctx, bool value) + { + NBitcoin.Extensions.TryAdd(ctx.Items, "IsBitpayAPI", value); + } + + public static bool GetIsBitpayAPI(this HttpContext ctx) + { + return ctx.Items.TryGetValue("IsBitpayAPI", out object obj) && + obj is bool b && b; + } + + public static StoreData GetStoreData(this HttpContext ctx) + { + return ctx.Items.TryGet("BTCPAY.STOREDATA") as StoreData; + } + public static void SetStoreData(this HttpContext ctx, StoreData storeData) + { + ctx.Items["BTCPAY.STOREDATA"] = storeData; + } + private static JsonSerializerSettings jsonSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }; public static string ToJson(this object o) { diff --git a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs index 61110d697..afb9e597a 100644 --- a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs +++ b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs @@ -43,11 +43,7 @@ namespace BTCPayServer.Filters public bool Accept(ActionConstraintContext context) { - var hasVersion = context.RouteContext.HttpContext.Request.Headers["x-accept-version"].Where(h => h == "2.0.0").Any(); - var isBitpayAPI = - context.RouteContext.HttpContext.Items.TryGetValue("IsBitpayAPI", out object obj) && - obj is bool b && b; - return (hasVersion || isBitpayAPI) == IsBitpayAPI; + return context.RouteContext.HttpContext.GetIsBitpayAPI() == IsBitpayAPI; } } diff --git a/BTCPayServer/Hosting/BTCpayMiddleware.cs b/BTCPayServer/Hosting/BTCpayMiddleware.cs index 69b4951db..091c4f90b 100644 --- a/BTCPayServer/Hosting/BTCpayMiddleware.cs +++ b/BTCPayServer/Hosting/BTCpayMiddleware.cs @@ -27,20 +27,24 @@ using System.Security.Claims; using BTCPayServer.Services; using NBitpayClient; using Newtonsoft.Json.Linq; +using BTCPayServer.Services.Stores; namespace BTCPayServer.Hosting { public class BTCPayMiddleware { TokenRepository _TokenRepository; + StoreRepository _StoreRepository; RequestDelegate _Next; BTCPayServerOptions _Options; public BTCPayMiddleware(RequestDelegate next, TokenRepository tokenRepo, + StoreRepository storeRepo, BTCPayServerOptions options) { _TokenRepository = tokenRepo ?? throw new ArgumentNullException(nameof(tokenRepo)); + _StoreRepository = storeRepo; _Next = next ?? throw new ArgumentNullException(nameof(next)); _Options = options ?? throw new ArgumentNullException(nameof(options)); } @@ -49,27 +53,48 @@ namespace BTCPayServer.Hosting public async Task Invoke(HttpContext httpContext) { RewriteHostIfNeeded(httpContext); - httpContext.Request.Headers.TryGetValue("x-signature", out StringValues values); - var sig = values.FirstOrDefault(); - httpContext.Request.Headers.TryGetValue("x-identity", out values); - var id = values.FirstOrDefault(); - httpContext.Request.Headers.TryGetValue("Authorization", out values); - var auth = values.FirstOrDefault(); + try { - bool isBitId = false; - if (!string.IsNullOrEmpty(sig) && !string.IsNullOrEmpty(id)) + var bitpayAuth = GetBitpayAuth(httpContext, out bool isBitpayAuth); + var isBitpayAPI = IsBitpayAPI(httpContext, isBitpayAuth); + httpContext.SetIsBitpayAPI(isBitpayAPI); + if (isBitpayAPI) { - await HandleBitId(httpContext, sig, id); - isBitId = httpContext.User.HasClaim(c => c.Type == Claims.SIN); - if (!isBitId) - Logs.PayServer.LogDebug("BitId signature check failed"); - } - if (!isBitId && !string.IsNullOrEmpty(auth)) - { - await HandleLegacyAPIKey(httpContext, auth); - } + string storeId = null; + var failedAuth = false; + if (!string.IsNullOrEmpty(bitpayAuth.Signature) && !string.IsNullOrEmpty(bitpayAuth.Id)) + { + storeId = await CheckBitId(httpContext, bitpayAuth.Signature, bitpayAuth.Id); + if (!httpContext.User.Claims.Any(c => c.Type == Claims.SIN)) + { + Logs.PayServer.LogDebug("BitId signature check failed"); + failedAuth = true; + } + } + else if (!string.IsNullOrEmpty(bitpayAuth.Authorization)) + { + storeId = await CheckLegacyAPIKey(httpContext, bitpayAuth.Authorization); + if (storeId == null) + { + Logs.PayServer.LogDebug("API key check failed"); + failedAuth = true; + } + } + + if (storeId != null) + { + var identity = ((ClaimsIdentity)httpContext.User.Identity); + identity.AddClaim(new Claim(Claims.OwnStore, storeId)); + var store = await _StoreRepository.FindStore(storeId); + httpContext.SetStoreData(store); + } + else if (failedAuth) + { + throw new BitpayHttpException(401, "Can't access to store"); + } + } await _Next(httpContext); } catch (WebSocketException) @@ -89,6 +114,55 @@ namespace BTCPayServer.Hosting } } + private static (string Signature, String Id, String Authorization) GetBitpayAuth(HttpContext httpContext, out bool hasBitpayAuth) + { + httpContext.Request.Headers.TryGetValue("x-signature", out StringValues values); + var sig = values.FirstOrDefault(); + httpContext.Request.Headers.TryGetValue("x-identity", out values); + var id = values.FirstOrDefault(); + httpContext.Request.Headers.TryGetValue("Authorization", out values); + var auth = values.FirstOrDefault(); + hasBitpayAuth = auth != null || (sig != null && id != null); + return (sig, id, auth); + } + + private bool IsBitpayAPI(HttpContext httpContext, bool bitpayAuth) + { + if (!httpContext.Request.Path.HasValue) + return false; + + var path = httpContext.Request.Path.Value; + if ( + bitpayAuth && + path == "/invoices" && + httpContext.Request.Method == "POST" && + (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + return true; + + if ( + bitpayAuth && + path == "/invoices" && + httpContext.Request.Method == "GET") + return true; + + if ( + bitpayAuth && + path.StartsWith("/invoices/", StringComparison.OrdinalIgnoreCase) && + httpContext.Request.Method == "GET") + return true; + + if (path.Equals("/rates", StringComparison.OrdinalIgnoreCase) && + httpContext.Request.Method == "GET") + return true; + + if ( + path.Equals("/tokens", StringComparison.Ordinal) && + ( httpContext.Request.Method == "GET" || httpContext.Request.Method == "POST")) + return true; + + return false; + } + private void RewriteHostIfNeeded(HttpContext httpContext) { string reverseProxyScheme = null; @@ -183,10 +257,11 @@ namespace BTCPayServer.Hosting } - private async Task HandleBitId(HttpContext httpContext, string sig, string id) + private async Task CheckBitId(HttpContext httpContext, string sig, string id) { httpContext.Request.EnableRewind(); + string storeId = null; string body = string.Empty; if (httpContext.Request.ContentLength != 0 && httpContext.Request.Body != null) { @@ -227,23 +302,22 @@ namespace BTCPayServer.Hosting var bitToken = await GetTokenPermissionAsync(sin, token); if (bitToken == null) { - throw new BitpayHttpException(401, $"This endpoint does not support this facade"); + return null; } - identity.AddClaim(new Claim(Claims.OwnStore, bitToken.StoreId)); + storeId = bitToken.StoreId; } - Logs.PayServer.LogDebug($"BitId signature check success for SIN {sin}"); - NBitcoin.Extensions.TryAdd(httpContext.Items, "IsBitpayAPI", true); } } catch (FormatException) { } + return storeId; } - private async Task HandleLegacyAPIKey(HttpContext httpContext, string auth) + private async Task CheckLegacyAPIKey(HttpContext httpContext, string auth) { var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries); if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase)) { - throw new BitpayHttpException(401, $"Invalid Authorization header"); + return null; } string apiKey = null; @@ -253,16 +327,9 @@ namespace BTCPayServer.Hosting } catch { - throw new BitpayHttpException(401, $"Invalid Authorization header"); + return null; } - var storeId = await _TokenRepository.GetStoreIdFromAPIKey(apiKey); - if (storeId == null) - { - throw new BitpayHttpException(401, $"Invalid Authorization header"); - } - var identity = ((ClaimsIdentity)httpContext.User.Identity); - identity.AddClaim(new Claim(Claims.OwnStore, storeId)); - NBitcoin.Extensions.TryAdd(httpContext.Items, "IsBitpayAPI", true); + return await _TokenRepository.GetStoreIdFromAPIKey(apiKey); } private async Task GetTokenPermissionAsync(string sin, string expectedToken) From 7c0b26174f160190f5e6e4e351a5c3d1ab82c427 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 20:48:17 +0900 Subject: [PATCH 010/119] Fix theme manager incorrectly applying default theme if rootPath is specified --- BTCPayServer/Extensions.cs | 11 +++++++++++ BTCPayServer/Views/Shared/_Layout.cshtml | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 7b6dc217e..c0753a953 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -135,6 +135,16 @@ namespace BTCPayServer request.PathBase.ToUriComponent()); } + public static string GetAbsoluteUri(this HttpRequest request, string redirectUrl) + { + bool isRelative = redirectUrl.Length > 0 && redirectUrl[0] == '/'; + if (!isRelative) + isRelative = new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; + if (!isRelative) + return redirectUrl; + return request.GetAbsoluteRoot() + redirectUrl; + } + public static IServiceCollection ConfigureBTCPayServer(this IServiceCollection services, IConfiguration conf) { services.Configure(o => @@ -165,6 +175,7 @@ namespace BTCPayServer obj is bool b && b; } + public static StoreData GetStoreData(this HttpContext ctx) { return ctx.Items.TryGet("BTCPAY.STOREDATA") as StoreData; diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml index 69ae060bf..3a75e4c8e 100644 --- a/BTCPayServer/Views/Shared/_Layout.cshtml +++ b/BTCPayServer/Views/Shared/_Layout.cshtml @@ -4,6 +4,7 @@ @inject BTCPayServer.Services.BTCPayServerEnvironment env @inject BTCPayServer.HostedServices.NBXplorerDashboard dashboard @inject BTCPayServer.HostedServices.CssThemeManager themeManager + @addTagHelper *, Meziantou.AspNetCore.BundleTagHelpers @@ -18,8 +19,8 @@ BTCPay Server @* CSS *@ - - + + From 48a95457b69b24ac8e9bc616d8a951ef40dbf9a3 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 20:49:38 +0900 Subject: [PATCH 011/119] fix boolean --- BTCPayServer/Extensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index c0753a953..bbd009f14 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -139,7 +139,7 @@ namespace BTCPayServer { bool isRelative = redirectUrl.Length > 0 && redirectUrl[0] == '/'; if (!isRelative) - isRelative = new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; + isRelative = !new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; if (!isRelative) return redirectUrl; return request.GetAbsoluteRoot() + redirectUrl; From 5b0b3e30f42fc0d18025343a91d860c91648f4cf Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 20:50:54 +0900 Subject: [PATCH 012/119] Small rewrite --- BTCPayServer/Extensions.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index bbd009f14..bd98aed3c 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -137,12 +137,10 @@ namespace BTCPayServer public static string GetAbsoluteUri(this HttpRequest request, string redirectUrl) { - bool isRelative = redirectUrl.Length > 0 && redirectUrl[0] == '/'; - if (!isRelative) - isRelative = !new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; - if (!isRelative) - return redirectUrl; - return request.GetAbsoluteRoot() + redirectUrl; + bool isRelative = + (redirectUrl.Length > 0 && redirectUrl[0] == '/') + || !new Uri(redirectUrl, UriKind.RelativeOrAbsolute).IsAbsoluteUri; + return isRelative ? request.GetAbsoluteRoot() + redirectUrl : redirectUrl; } public static IServiceCollection ConfigureBTCPayServer(this IServiceCollection services, IConfiguration conf) From d41474ebc82937b36a917e65d3feb42c4b7326d5 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 29 Apr 2018 20:52:51 +0900 Subject: [PATCH 013/119] Bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index f98b986e4..a8c604a51 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.93 + 1.0.1.94 NU1701,CA1816,CA1308,CA1810,CA2208 From 271de362cb3006dc5bccf135c735adebb413edcd Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 00:29:34 +0900 Subject: [PATCH 014/119] fix broken checkout --- BTCPayServer/BTCPayServer.csproj | 2 +- BTCPayServer/Views/Invoice/Checkout.cshtml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index a8c604a51..3942ec96b 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.94 + 1.0.1.95 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index e4e9e4b49..cbb884604 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -114,7 +114,7 @@ 'pt-BR': { translation: locales_pt_br }, 'nl': { translation: locales_nl }, 'cs-CZ': { translation: locales_cs }, - 'is-IS': { translation: locales_is } + 'is-IS': { translation: locales_is }, 'hr-HR': { translation: locales_hr } }, }); From 3954ce2137390c9b90a7a8312ea1b93bcbb68420 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 01:32:15 +0900 Subject: [PATCH 015/119] fix (again) the broken hr.js --- BTCPayServer/BTCPayServer.csproj | 2 +- .../wwwroot/{js/checkout/lang => checkout/js/langs}/hr.js | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename BTCPayServer/wwwroot/{js/checkout/lang => checkout/js/langs}/hr.js (100%) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 3942ec96b..8b3e700f3 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.95 + 1.0.1.96 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/wwwroot/js/checkout/lang/hr.js b/BTCPayServer/wwwroot/checkout/js/langs/hr.js similarity index 100% rename from BTCPayServer/wwwroot/js/checkout/lang/hr.js rename to BTCPayServer/wwwroot/checkout/js/langs/hr.js From 1fc9a1a54b3106b10176acb22d2308c226b3eb3d Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 02:33:42 +0900 Subject: [PATCH 016/119] Move to a Claim based security --- BTCPayServer.Tests/BTCPayServerTester.cs | 7 +- BTCPayServer.Tests/TestAccount.cs | 8 +- BTCPayServer.Tests/UnitTest1.cs | 20 ++-- BTCPayServer/Controllers/AccountController.cs | 2 +- BTCPayServer/Controllers/AppsController.cs | 2 + .../Controllers/InvoiceController.UI.cs | 19 ++-- BTCPayServer/Controllers/ManageController.cs | 2 +- BTCPayServer/Controllers/ServerController.cs | 2 +- .../Controllers/StoresController.BTCLike.cs | 8 +- .../StoresController.LightningLike.cs | 6 +- BTCPayServer/Controllers/StoresController.cs | 31 +++--- .../Controllers/UserStoresController.cs | 11 ++- BTCPayServer/Data/StoreData.cs | 28 ++++++ BTCPayServer/Hosting/BTCPayServerServices.cs | 64 +----------- .../Security/BTCPayClaimsPrincipalFactory.cs | 97 +++++++++++++++++++ BTCPayServer/Security/Policies.cs | 37 +++++++ .../Services/Stores/StoreRepository.cs | 4 + BTCPayServer/StorePolicies.cs | 5 - 18 files changed, 235 insertions(+), 118 deletions(-) create mode 100644 BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs create mode 100644 BTCPayServer/Security/Policies.cs diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 980b57cf6..faec08309 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -4,6 +4,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Stores; using BTCPayServer.Tests.Logging; using BTCPayServer.Tests.Mocks; using Microsoft.AspNetCore.Hosting; @@ -142,7 +143,7 @@ namespace BTCPayServer.Tests return _Host.Services.GetRequiredService(); } - public T GetController(string userId = null) where T : Controller + public T GetController(string userId = null, string storeId = null) where T : Controller { var context = new DefaultHttpContext(); context.Request.Host = new HostString("127.0.0.1"); @@ -152,6 +153,10 @@ namespace BTCPayServer.Tests { context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })); } + if(storeId != null) + { + context.SetStoreData(GetService().FindStore(storeId, userId).GetAwaiter().GetResult()); + } var scope = (IServiceScopeFactory)_Host.Services.GetService(typeof(IServiceScopeFactory)); var provider = scope.CreateScope().ServiceProvider; context.RequestServices = provider; diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index dd886b407..7c62bc233 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -56,7 +56,7 @@ namespace BTCPayServer.Tests public T GetController() where T : Controller { - return parent.PayTester.GetController(UserId); + return parent.PayTester.GetController(UserId, StoreId); } public async Task CreateStoreAsync() @@ -78,10 +78,10 @@ namespace BTCPayServer.Tests public async Task RegisterDerivationSchemeAsync(string cryptoCode) { SupportedNetwork = parent.NetworkProvider.GetNetwork(cryptoCode); - var store = parent.PayTester.GetController(UserId); + var store = parent.PayTester.GetController(UserId, StoreId); ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork); DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]"); - var vm = (StoreViewModel)((ViewResult)await store.UpdateStore(StoreId)).Model; + var vm = (StoreViewModel)((ViewResult)store.UpdateStore(StoreId)).Model; vm.SpeedPolicy = SpeedPolicy.MediumSpeed; await store.UpdateStore(StoreId, vm); @@ -127,7 +127,7 @@ namespace BTCPayServer.Tests public async Task RegisterLightningNodeAsync(string cryptoCode, LightningConnectionType connectionType) { - var storeController = parent.PayTester.GetController(UserId); + var storeController = this.GetController(); await storeController.AddLightningNode(StoreId, new LightningNodeViewModel() { Url = connectionType == LightningConnectionType.Charge ? parent.MerchantCharge.Client.Uri.AbsoluteUri : diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 1b7eccc60..f174becf1 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -306,9 +306,9 @@ namespace BTCPayServer.Tests tester.Start(); var user = tester.NewAccount(); user.GrantAccess(); - var storeController = tester.PayTester.GetController(user.UserId); - Assert.IsType(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult()); - Assert.IsType(storeController.AddLightningNode(user.StoreId, "BTC").GetAwaiter().GetResult()); + var storeController = user.GetController(); + Assert.IsType(storeController.UpdateStore(user.StoreId)); + Assert.IsType(storeController.AddLightningNode(user.StoreId, "BTC")); var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel() { @@ -322,7 +322,7 @@ namespace BTCPayServer.Tests Url = tester.MerchantCharge.Client.Uri.AbsoluteUri }, "save", "BTC").GetAwaiter().GetResult()); - var storeVm = Assert.IsType(Assert.IsType(storeController.UpdateStore(user.StoreId).GetAwaiter().GetResult()).Model); + var storeVm = Assert.IsType(Assert.IsType(storeController.UpdateStore(user.StoreId)).Model); Assert.Single(storeVm.LightningNodes.Where(l => !string.IsNullOrEmpty(l.Address))); } } @@ -468,7 +468,7 @@ namespace BTCPayServer.Tests acc.Register(); acc.CreateStore(); - var controller = tester.PayTester.GetController(acc.UserId); + var controller = acc.GetController(); var token = (RedirectToActionResult)controller.CreateToken(acc.StoreId, new Models.StoreViewModels.CreateTokenViewModel() { Facade = Facade.Merchant.ToString(), @@ -685,8 +685,8 @@ namespace BTCPayServer.Tests private static decimal CreateInvoice(ServerTester tester, TestAccount user, string exchange) { - var storeController = tester.PayTester.GetController(user.UserId); - var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId).Result).Model; + var storeController = user.GetController(); + var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; vm.PreferredExchange = exchange; storeController.UpdateStore(user.StoreId, vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() @@ -724,8 +724,8 @@ namespace BTCPayServer.Tests }, Facade.Merchant); - var storeController = tester.PayTester.GetController(user.UserId); - var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId).Result).Model; + var storeController = user.GetController(); + var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; Assert.Equal(1.0, vm.RateMultiplier); vm.RateMultiplier = 0.5; storeController.UpdateStore(user.StoreId, vm).Wait(); @@ -963,7 +963,7 @@ namespace BTCPayServer.Tests user.GrantAccess(); user.RegisterDerivationScheme("BTC"); user.RegisterLightningNode("BTC", LightningConnectionType.Charge); - var vm = Assert.IsType(Assert.IsType(user.GetController().CheckoutExperience(user.StoreId).Result).Model); + var vm = Assert.IsType(Assert.IsType(user.GetController().CheckoutExperience(user.StoreId)).Model); vm.LightningMaxValue = "2 USD"; vm.OnChainMinValue = "5 USD"; Assert.IsType(user.GetController().CheckoutExperience(user.StoreId, vm).Result); diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index 26c291ace..ddf40e28f 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -19,7 +19,7 @@ using BTCPayServer.Logging; namespace BTCPayServer.Controllers { - [Authorize] + [Authorize(AuthenticationSchemes = "Identity.Application")] [Route("[controller]/[action]")] public class AccountController : Controller { diff --git a/BTCPayServer/Controllers/AppsController.cs b/BTCPayServer/Controllers/AppsController.cs index 5e79741fc..92cce3217 100644 --- a/BTCPayServer/Controllers/AppsController.cs +++ b/BTCPayServer/Controllers/AppsController.cs @@ -140,6 +140,8 @@ namespace BTCPayServer.Controllers .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) .SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId)) .FirstOrDefaultAsync(); + if (app == null) + return null; if (type != null && type.Value.ToString() != app.AppType) return null; return app; diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 356ed2486..1daf39f1e 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -22,6 +22,7 @@ using BTCPayServer.Events; using NBXplorer; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Security; namespace BTCPayServer.Controllers { @@ -394,7 +395,7 @@ namespace BTCPayServer.Controllers [BitpayAPIConstraint(false)] public async Task CreateInvoice() { - var stores = await GetStores(GetUserId()); + var stores = new SelectList(await _StoreRepository.GetStoresByUserId(GetUserId()), nameof(StoreData.Id), nameof(StoreData.StoreName), null); if (stores.Count() == 0) { StatusMessage = "Error: You need to create at least one store before creating a transaction"; @@ -409,14 +410,19 @@ namespace BTCPayServer.Controllers [BitpayAPIConstraint(false)] public async Task CreateInvoice(CreateInvoiceModel model) { - model.Stores = await GetStores(GetUserId(), model.StoreId); + var stores = await _StoreRepository.GetStoresByUserId(GetUserId()); + model.Stores = new SelectList(stores, nameof(StoreData.Id), nameof(StoreData.StoreName), model.StoreId); + var store = stores.FirstOrDefault(s => s.Id == model.StoreId); + if(store == null) + { + ModelState.AddModelError(nameof(model.StoreId), "Store not found"); + } if (!ModelState.IsValid) { return View(model); } - var store = await _StoreRepository.FindStore(model.StoreId, GetUserId()); StatusMessage = null; - if (store.Role != StoreRoles.Owner) + if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { ModelState.AddModelError(nameof(model.StoreId), "You need to be owner of this store to create an invoice"); return View(model); @@ -461,11 +467,6 @@ namespace BTCPayServer.Controllers } } - private async Task GetStores(string userId, string storeId = null) - { - return new SelectList(await _StoreRepository.GetStoresByUserId(userId), nameof(StoreData.Id), nameof(StoreData.StoreName), storeId); - } - [HttpPost] [Authorize(AuthenticationSchemes = "Identity.Application")] [BitpayAPIConstraint(false)] diff --git a/BTCPayServer/Controllers/ManageController.cs b/BTCPayServer/Controllers/ManageController.cs index 800e1a3d3..44daccb46 100644 --- a/BTCPayServer/Controllers/ManageController.cs +++ b/BTCPayServer/Controllers/ManageController.cs @@ -24,7 +24,7 @@ using System.Globalization; namespace BTCPayServer.Controllers { - [Authorize] + [Authorize(AuthenticationSchemes = "Identity.Application")] [Route("[controller]/[action]")] public class ManageController : Controller { diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index 08c41e878..71bdb98d6 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -19,7 +19,7 @@ using System.Threading.Tasks; namespace BTCPayServer.Controllers { - [Authorize(Roles = Roles.ServerAdmin)] + [Authorize(Policy = BTCPayServer.Security.Policies.CanModifyServerSettings.Key)] public class ServerController : Controller { private UserManager _UserManager; diff --git a/BTCPayServer/Controllers/StoresController.BTCLike.cs b/BTCPayServer/Controllers/StoresController.BTCLike.cs index 0bb73d207..6a480a39a 100644 --- a/BTCPayServer/Controllers/StoresController.BTCLike.cs +++ b/BTCPayServer/Controllers/StoresController.BTCLike.cs @@ -21,9 +21,9 @@ namespace BTCPayServer.Controllers { [HttpGet] [Route("{storeId}/derivations/{cryptoCode}")] - public async Task AddDerivationScheme(string storeId, string cryptoCode) + public IActionResult AddDerivationScheme(string storeId, string cryptoCode) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var network = cryptoCode == null ? null : _ExplorerProvider.GetNetwork(cryptoCode); @@ -60,7 +60,7 @@ namespace BTCPayServer.Controllers { vm.ServerUrl = GetStoreUrl(storeId); vm.CryptoCode = cryptoCode; - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); @@ -188,7 +188,7 @@ namespace BTCPayServer.Controllers { if (!HttpContext.WebSockets.IsWebSocketRequest) return NotFound(); - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); diff --git a/BTCPayServer/Controllers/StoresController.LightningLike.cs b/BTCPayServer/Controllers/StoresController.LightningLike.cs index 546c2d757..1b51b51b4 100644 --- a/BTCPayServer/Controllers/StoresController.LightningLike.cs +++ b/BTCPayServer/Controllers/StoresController.LightningLike.cs @@ -19,9 +19,9 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/lightning/{cryptoCode}")] - public async Task AddLightningNode(string storeId, string cryptoCode) + public IActionResult AddLightningNode(string storeId, string cryptoCode) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); LightningNodeViewModel vm = new LightningNodeViewModel(); @@ -59,7 +59,7 @@ namespace BTCPayServer.Controllers public async Task AddLightningNode(string storeId, LightningNodeViewModel vm, string command, string cryptoCode) { vm.CryptoCode = cryptoCode; - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var network = vm.CryptoCode == null ? null : _ExplorerProvider.GetNetwork(vm.CryptoCode); diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 69b911d5c..f97defb01 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -4,6 +4,7 @@ using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; @@ -28,7 +29,7 @@ namespace BTCPayServer.Controllers { [Route("stores")] [Authorize(AuthenticationSchemes = "Identity.Application")] - [Authorize(Policy = StorePolicies.OwnStore)] + [Authorize(Policy = Policies.CanModifyStoreSettings.Key)] [AutoValidateAntiforgeryToken] public partial class StoresController : Controller { @@ -93,9 +94,9 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/wallet/{cryptoCode}")] - public async Task Wallet(string storeId, string cryptoCode) + public IActionResult Wallet(string storeId, string cryptoCode) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); WalletModel model = new WalletModel(); @@ -164,7 +165,7 @@ namespace BTCPayServer.Controllers public async Task DeleteStoreUser(string storeId, string userId) { StoreUsersViewModel vm = new StoreUsersViewModel(); - var store = await _Repo.FindStore(storeId, userId); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var user = await _UserManager.FindByIdAsync(userId); @@ -173,7 +174,7 @@ namespace BTCPayServer.Controllers return View("Confirm", new ConfirmModel() { Title = $"Remove store user", - Description = $"Are you sure to remove access to remove {store.Role} access to {user.Email}?", + Description = $"Are you sure to remove access to remove access to {user.Email}?", Action = "Delete" }); } @@ -189,9 +190,9 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/checkout")] - public async Task CheckoutExperience(string storeId) + public IActionResult CheckoutExperience(string storeId) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var storeBlob = store.GetStoreBlob(); @@ -229,7 +230,7 @@ namespace BTCPayServer.Controllers } } - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); bool needUpdate = false; @@ -271,9 +272,9 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}")] - public async Task UpdateStore(string storeId) + public IActionResult UpdateStore(string storeId) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); @@ -338,7 +339,7 @@ namespace BTCPayServer.Controllers } if (model.PreferredExchange != null) model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); AddPaymentMethods(store, model); @@ -450,10 +451,10 @@ namespace BTCPayServer.Controllers var userId = GetUserId(); if (userId == null) return Unauthorized(); - var store = await _Repo.FindStore(storeId, userId); + var store = HttpContext.GetStoreData(); if (store == null) return Unauthorized(); - if (store.Role != StoreRoles.Owner) + if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { StatusMessage = "Error: You need to be owner of this store to request pairing codes"; return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); @@ -535,7 +536,7 @@ namespace BTCPayServer.Controllers [Route("{storeId}/tokens/apikey")] public async Task GenerateAPIKey(string storeId) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); await _TokenRepository.GenerateLegacyAPIKey(storeId); @@ -585,7 +586,7 @@ namespace BTCPayServer.Controllers if (store == null || pairing == null) return NotFound(); - if (store.Role != StoreRoles.Owner) + if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { StatusMessage = "Error: You can't approve a pairing without being owner of the store"; return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); diff --git a/BTCPayServer/Controllers/UserStoresController.cs b/BTCPayServer/Controllers/UserStoresController.cs index b0000ee07..8f8d87e1c 100644 --- a/BTCPayServer/Controllers/UserStoresController.cs +++ b/BTCPayServer/Controllers/UserStoresController.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Security; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Authorization; @@ -37,9 +38,9 @@ namespace BTCPayServer.Controllers } [HttpGet] [Route("{storeId}/delete")] - public async Task DeleteStore(string storeId) + public IActionResult DeleteStore(string storeId) { - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); return View("Confirm", new ConfirmModel() @@ -67,7 +68,7 @@ namespace BTCPayServer.Controllers public async Task DeleteStorePost(string storeId) { var userId = GetUserId(); - var store = await _Repo.FindStore(storeId, GetUserId()); + var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); await _Repo.RemoveStore(storeId, userId); @@ -102,8 +103,8 @@ namespace BTCPayServer.Controllers Id = store.Id, Name = store.StoreName, WebSite = store.StoreWebsite, - IsOwner = store.Role == StoreRoles.Owner, - Balances = store.Role == StoreRoles.Owner ? balances[i].Select(t => t.Result).ToArray() : Array.Empty() + IsOwner = store.HasClaim(Policies.CanModifyStoreSettings.Key), + Balances = store.HasClaim(Policies.CanModifyStoreSettings.Key) ? balances[i].Select(t => t.Result).ToArray() : Array.Empty() }); } return View(result); diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 52c19e26a..1446e5ad6 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -15,6 +15,9 @@ using BTCPayServer.Services.Rates; using BTCPayServer.Payments; using BTCPayServer.JsonConverters; using System.ComponentModel.DataAnnotations; +using BTCPayServer.Services; +using System.Security.Claims; +using BTCPayServer.Security; namespace BTCPayServer.Data { @@ -152,10 +155,35 @@ namespace BTCPayServer.Data } [NotMapped] + [Obsolete] public string Role { get; set; } + + public Claim[] GetClaims() + { + List claims = new List(); +#pragma warning disable CS0612 // Type or member is obsolete + var role = Role; +#pragma warning restore CS0612 // Type or member is obsolete + if (role == StoreRoles.Owner) + { + claims.Add(new Claim(Policies.CanModifyStoreSettings.Key, Id)); + claims.Add(new Claim(Policies.CanUseStore.Key, Id)); + } + if (role == StoreRoles.Guest) + { + claims.Add(new Claim(Policies.CanUseStore.Key, Id)); + } + return claims.ToArray(); + } + + public bool HasClaim(string claim) + { + return GetClaims().Any(c => c.Type == claim); + } + public byte[] StoreBlob { get; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index f2ce5b78a..9f2052fbc 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -38,55 +38,13 @@ using Microsoft.Extensions.Caching.Memory; using BTCPayServer.Logging; using BTCPayServer.HostedServices; using Meziantou.AspNetCore.BundleTagHelpers; +using System.Security.Claims; +using BTCPayServer.Security; namespace BTCPayServer.Hosting { public static class BTCPayServerServices { - public class OwnStoreAuthorizationRequirement : IAuthorizationRequirement - { - public OwnStoreAuthorizationRequirement() - { - } - - public OwnStoreAuthorizationRequirement(string role) - { - Role = role; - } - - public string Role - { - get; set; - } - } - - public class OwnStoreHandler : AuthorizationHandler - { - StoreRepository _StoreRepository; - UserManager _UserManager; - public OwnStoreHandler(StoreRepository storeRepository, UserManager userManager) - { - _StoreRepository = storeRepository; - _UserManager = userManager; - } - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, OwnStoreAuthorizationRequirement requirement) - { - object storeId = null; - if (!((Microsoft.AspNetCore.Mvc.ActionContext)context.Resource).RouteData.Values.TryGetValue("storeId", out storeId)) - context.Succeed(requirement); - else if (storeId != null) - { - var user = _UserManager.GetUserId(((Microsoft.AspNetCore.Mvc.ActionContext)context.Resource).HttpContext.User); - if (user != null) - { - var store = await _StoreRepository.FindStore((string)storeId, user); - if (store != null) - if (requirement.Role == null || requirement.Role == store.Role) - context.Succeed(requirement); - } - } - } - } public static IServiceCollection AddBTCPayServer(this IServiceCollection services) { services.AddDbContext((provider, o) => @@ -160,6 +118,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient, BTCPayClaimsFilter>(); services.TryAddSingleton(); services.TryAddSingleton(o => @@ -172,27 +131,14 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddScoped(); - services.TryAddSingleton(); services.AddTransient(); services.AddTransient(); // Add application services. services.AddTransient(); - - services.AddAuthorization(o => - { - o.AddPolicy(StorePolicies.CanAccessStores, builder => - { - builder.AddRequirements(new OwnStoreAuthorizationRequirement()); - }); - - o.AddPolicy(StorePolicies.OwnStore, builder => - { - builder.AddRequirements(new OwnStoreAuthorizationRequirement(StoreRoles.Owner)); - }); - }); - // bundling + services.AddAuthorization(o => Policies.AddBTCPayPolicies(o)); + services.AddBundles(); services.AddTransient(provider => { diff --git a/BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs b/BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs new file mode 100644 index 000000000..1a0cdb47b --- /dev/null +++ b/BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using BTCPayServer.Models; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Security +{ + + public class BTCPayClaimsFilter : IAsyncAuthorizationFilter, IConfigureOptions + { + UserManager _UserManager; + StoreRepository _StoreRepository; + public BTCPayClaimsFilter( + UserManager userManager, + StoreRepository storeRepository) + { + _UserManager = userManager; + _StoreRepository = storeRepository; + } + + void IConfigureOptions.Configure(MvcOptions options) + { + options.Filters.Add(typeof(BTCPayClaimsFilter)); + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var principal = context.HttpContext.User; + if (!context.HttpContext.GetIsBitpayAPI()) + { + var identity = ((ClaimsIdentity)principal.Identity); + if (principal.IsInRole(Roles.ServerAdmin)) + { + identity.AddClaim(new Claim(Policies.CanModifyServerSettings.Key, "true")); + } + if (context.RouteData.Values.TryGetValue("storeId", out var storeId)) + { + var claim = identity.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier); + if (claim != null) + { + var store = await _StoreRepository.FindStore((string)storeId, claim.Value); + context.HttpContext.SetStoreData(store); + if (store != null) + { + identity.AddClaims(store.GetClaims()); + } + } + } + } + } + } + public class BTCPayClaimsPrincipalFactory : UserClaimsPrincipalFactory + { + IHttpContextAccessor httpContext; + StoreRepository _StoreRepository; + public BTCPayClaimsPrincipalFactory( + UserManager userManager, + IHttpContextAccessor httpContext, + StoreRepository storeRepository, + IOptions options) : base(userManager, options) + { + this.httpContext = httpContext; + _StoreRepository = storeRepository; + } + + public override async Task CreateAsync(ApplicationUser user) + { + var ctx = (IActionContextAccessor)httpContext.HttpContext.RequestServices.GetService(typeof(IActionContextAccessor)); + var principal = await base.CreateAsync(user); + if (ctx.ActionContext.HttpContext.GetIsBitpayAPI()) + return principal; + var identity = ((ClaimsIdentity)principal.Identity); + if (principal.IsInRole(Roles.ServerAdmin)) + { + identity.AddClaim(new Claim(Policies.CanModifyServerSettings.Key, "true")); + } + if (ctx.ActionContext.RouteData.Values.TryGetValue("storeId", out var storeId)) + { + var store = await _StoreRepository.FindStore((string)storeId, await UserManager.GetUserIdAsync(user)); + if (store != null) + { + identity.AddClaims(store.GetClaims()); + } + } + return principal; + } + } +} diff --git a/BTCPayServer/Security/Policies.cs b/BTCPayServer/Security/Policies.cs new file mode 100644 index 000000000..cc13921da --- /dev/null +++ b/BTCPayServer/Security/Policies.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace BTCPayServer.Security +{ + public static class Policies + { + public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options) + { + AddClaim(options, CanUseStore.Key); + AddClaim(options, CanModifyStoreSettings.Key); + AddClaim(options, CanModifyServerSettings.Key); + return options; + } + + private static void AddClaim(AuthorizationOptions options, string key) + { + options.AddPolicy(key, o => o.RequireClaim(key)); + } + + public class CanModifyServerSettings + { + public const string Key = "btcpay.store.canmodifyserversettings"; + } + public class CanUseStore + { + public const string Key = "btcpay.store.canusestore"; + } + public class CanModifyStoreSettings + { + public const string Key = "btcpay.store.canmodifystoresettings"; + } + } +} diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index f938f4bef..9f4e0fbee 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -44,7 +44,9 @@ namespace BTCPayServer.Services.Stores }).ToArrayAsync()) .Select(us => { +#pragma warning disable CS0612 // Type or member is obsolete us.Store.Role = us.Role; +#pragma warning restore CS0612 // Type or member is obsolete return us.Store; }).FirstOrDefault(); } @@ -84,7 +86,9 @@ namespace BTCPayServer.Services.Stores .ToArrayAsync()) .Select(u => { +#pragma warning disable CS0612 // Type or member is obsolete u.StoreData.Role = u.Role; +#pragma warning restore CS0612 // Type or member is obsolete return u.StoreData; }).ToArray(); } diff --git a/BTCPayServer/StorePolicies.cs b/BTCPayServer/StorePolicies.cs index 876e58700..76957920e 100644 --- a/BTCPayServer/StorePolicies.cs +++ b/BTCPayServer/StorePolicies.cs @@ -5,11 +5,6 @@ using System.Threading.Tasks; namespace BTCPayServer { - public class StorePolicies - { - public const string CanAccessStores = "CanAccessStore"; - public const string OwnStore = "OwnStore"; - } public class StoreRoles { public const string Owner = "Owner"; From af0eb831a25ea4b6f34fc35ea070839121ddc32d Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 02:37:32 +0900 Subject: [PATCH 017/119] Remove useless code and rename file --- ...ncipalFactory.cs => BTCPayClaimsFilter.cs} | 36 ------------------- 1 file changed, 36 deletions(-) rename BTCPayServer/Security/{BTCPayClaimsPrincipalFactory.cs => BTCPayClaimsFilter.cs} (57%) diff --git a/BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs b/BTCPayServer/Security/BTCPayClaimsFilter.cs similarity index 57% rename from BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs rename to BTCPayServer/Security/BTCPayClaimsFilter.cs index 1a0cdb47b..189485e3e 100644 --- a/BTCPayServer/Security/BTCPayClaimsPrincipalFactory.cs +++ b/BTCPayServer/Security/BTCPayClaimsFilter.cs @@ -58,40 +58,4 @@ namespace BTCPayServer.Security } } } - public class BTCPayClaimsPrincipalFactory : UserClaimsPrincipalFactory - { - IHttpContextAccessor httpContext; - StoreRepository _StoreRepository; - public BTCPayClaimsPrincipalFactory( - UserManager userManager, - IHttpContextAccessor httpContext, - StoreRepository storeRepository, - IOptions options) : base(userManager, options) - { - this.httpContext = httpContext; - _StoreRepository = storeRepository; - } - - public override async Task CreateAsync(ApplicationUser user) - { - var ctx = (IActionContextAccessor)httpContext.HttpContext.RequestServices.GetService(typeof(IActionContextAccessor)); - var principal = await base.CreateAsync(user); - if (ctx.ActionContext.HttpContext.GetIsBitpayAPI()) - return principal; - var identity = ((ClaimsIdentity)principal.Identity); - if (principal.IsInRole(Roles.ServerAdmin)) - { - identity.AddClaim(new Claim(Policies.CanModifyServerSettings.Key, "true")); - } - if (ctx.ActionContext.RouteData.Values.TryGetValue("storeId", out var storeId)) - { - var store = await _StoreRepository.FindStore((string)storeId, await UserManager.GetUserIdAsync(user)); - if (store != null) - { - identity.AddClaims(store.GetClaims()); - } - } - return principal; - } - } } From 9339c7dff287bc82762c6f132aaf6850c8f686a5 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 15:39:47 +0900 Subject: [PATCH 018/119] Make sure btcpay does not wait all the invoces to be cleaned to start --- BTCPayServer/HostedServices/InvoiceWatcher.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index 5aef0a738..b125019a5 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -261,6 +261,7 @@ namespace BTCPayServer.HostedServices private async Task WaitPendingInvoices() { + await new SynchronizationContextRemover(); await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) .Select(id => Wait(id)).ToArray()); _WaitingInvoices = null; @@ -268,8 +269,8 @@ namespace BTCPayServer.HostedServices async Task StartLoop(CancellationToken cancellation) { + await new SynchronizationContextRemover(); Logs.PayServer.LogInformation("Start watching invoices"); - await Task.Delay(1).ConfigureAwait(false); // Small hack so that the caller does not block on GetConsumingEnumerable try { foreach (var invoiceId in _WatchRequests.GetConsumingEnumerable(cancellation)) From 21bbf496407a5b590b57a9b3709954b000117d11 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 22:00:43 +0900 Subject: [PATCH 019/119] Rewrite authorization enforcement and simplify the code --- BTCPayServer.Tests/TestAccount.cs | 6 +- BTCPayServer.Tests/UnitTest1.cs | 14 +- BTCPayServer/Controllers/AccountController.cs | 3 +- .../Controllers/InvoiceController.UI.cs | 10 +- BTCPayServer/Controllers/ManageController.cs | 3 +- BTCPayServer/Controllers/StoresController.cs | 171 ++++++++++-------- .../Controllers/UserStoresController.cs | 2 +- BTCPayServer/HostedServices/InvoiceWatcher.cs | 3 +- BTCPayServer/Security/BTCPayClaimsFilter.cs | 11 +- BTCPayServer/Security/Policies.cs | 1 + 10 files changed, 127 insertions(+), 97 deletions(-) diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 7c62bc233..7acf3006f 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -54,9 +54,9 @@ namespace BTCPayServer.Tests return CreateStoreAsync().GetAwaiter().GetResult(); } - public T GetController() where T : Controller + public T GetController(bool setImplicitStore = true) where T : Controller { - return parent.PayTester.GetController(UserId, StoreId); + return parent.PayTester.GetController(UserId, setImplicitStore ? StoreId : null); } public async Task CreateStoreAsync() @@ -83,7 +83,7 @@ namespace BTCPayServer.Tests DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]"); var vm = (StoreViewModel)((ViewResult)store.UpdateStore(StoreId)).Model; vm.SpeedPolicy = SpeedPolicy.MediumSpeed; - await store.UpdateStore(StoreId, vm); + await store.UpdateStore(vm); await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel() { diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f174becf1..5d7705048 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -469,7 +469,7 @@ namespace BTCPayServer.Tests acc.CreateStore(); var controller = acc.GetController(); - var token = (RedirectToActionResult)controller.CreateToken(acc.StoreId, new Models.StoreViewModels.CreateTokenViewModel() + var token = (RedirectToActionResult)controller.CreateToken(new Models.StoreViewModels.CreateTokenViewModel() { Facade = Facade.Merchant.ToString(), Label = "bla", @@ -630,13 +630,13 @@ namespace BTCPayServer.Tests // Can generate API Key var repo = tester.PayTester.GetService(); Assert.Empty(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); - Assert.IsType(user.GetController().GenerateAPIKey(user.StoreId).GetAwaiter().GetResult()); + Assert.IsType(user.GetController().GenerateAPIKey().GetAwaiter().GetResult()); var apiKey = Assert.Single(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); /////// // Generating a new one remove the previous - Assert.IsType(user.GetController().GenerateAPIKey(user.StoreId).GetAwaiter().GetResult()); + Assert.IsType(user.GetController().GenerateAPIKey().GetAwaiter().GetResult()); var apiKey2 = Assert.Single(repo.GetLegacyAPIKeys(user.StoreId).GetAwaiter().GetResult()); Assert.NotEqual(apiKey, apiKey2); //////// @@ -688,7 +688,7 @@ namespace BTCPayServer.Tests var storeController = user.GetController(); var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; vm.PreferredExchange = exchange; - storeController.UpdateStore(user.StoreId, vm).Wait(); + storeController.UpdateStore(vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0, @@ -728,7 +728,7 @@ namespace BTCPayServer.Tests var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; Assert.Equal(1.0, vm.RateMultiplier); vm.RateMultiplier = 0.5; - storeController.UpdateStore(user.StoreId, vm).Wait(); + storeController.UpdateStore(vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() @@ -963,10 +963,10 @@ namespace BTCPayServer.Tests user.GrantAccess(); user.RegisterDerivationScheme("BTC"); user.RegisterLightningNode("BTC", LightningConnectionType.Charge); - var vm = Assert.IsType(Assert.IsType(user.GetController().CheckoutExperience(user.StoreId)).Model); + var vm = Assert.IsType(Assert.IsType(user.GetController().CheckoutExperience()).Model); vm.LightningMaxValue = "2 USD"; vm.OnChainMinValue = "5 USD"; - Assert.IsType(user.GetController().CheckoutExperience(user.StoreId, vm).Result); + Assert.IsType(user.GetController().CheckoutExperience(vm).Result); var invoice = user.BitPay.CreateInvoice(new Invoice() { diff --git a/BTCPayServer/Controllers/AccountController.cs b/BTCPayServer/Controllers/AccountController.cs index ddf40e28f..89ab09a8a 100644 --- a/BTCPayServer/Controllers/AccountController.cs +++ b/BTCPayServer/Controllers/AccountController.cs @@ -16,10 +16,11 @@ using BTCPayServer.Services; using BTCPayServer.Services.Mails; using BTCPayServer.Services.Stores; using BTCPayServer.Logging; +using BTCPayServer.Security; namespace BTCPayServer.Controllers { - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [Route("[controller]/[action]")] public class AccountController : Controller { diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 1daf39f1e..2b95e18c3 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -356,7 +356,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("invoices")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [BitpayAPIConstraint(false)] public async Task ListInvoices(string searchTerm = null, int skip = 0, int count = 50) { @@ -391,7 +391,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("invoices/create")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [BitpayAPIConstraint(false)] public async Task CreateInvoice() { @@ -406,7 +406,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("invoices/create")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [BitpayAPIConstraint(false)] public async Task CreateInvoice(CreateInvoiceModel model) { @@ -468,7 +468,7 @@ namespace BTCPayServer.Controllers } [HttpPost] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [BitpayAPIConstraint(false)] public IActionResult SearchInvoice(InvoicesModel invoices) { @@ -482,7 +482,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("invoices/invalidatepaid")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [BitpayAPIConstraint(false)] public async Task InvalidatePaidInvoice(string invoiceId) { diff --git a/BTCPayServer/Controllers/ManageController.cs b/BTCPayServer/Controllers/ManageController.cs index 44daccb46..84b0552a2 100644 --- a/BTCPayServer/Controllers/ManageController.cs +++ b/BTCPayServer/Controllers/ManageController.cs @@ -21,10 +21,11 @@ using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Mails; using System.Globalization; +using BTCPayServer.Security; namespace BTCPayServer.Controllers { - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [Route("[controller]/[action]")] public class ManageController : Controller { diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index f97defb01..b2758e5fa 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -28,7 +28,7 @@ using System.Threading.Tasks; namespace BTCPayServer.Controllers { [Route("stores")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [Authorize(Policy = Policies.CanModifyStoreSettings.Key)] [AutoValidateAntiforgeryToken] public partial class StoresController : Controller @@ -94,13 +94,10 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/wallet/{cryptoCode}")] - public IActionResult Wallet(string storeId, string cryptoCode) + public IActionResult Wallet(string cryptoCode) { - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); WalletModel model = new WalletModel(); - model.ServerUrl = GetStoreUrl(storeId); + model.ServerUrl = GetStoreUrl(StoreData.Id); model.CryptoCurrency = cryptoCode; return View(model); } @@ -112,17 +109,17 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/users")] - public async Task StoreUsers(string storeId) + public async Task StoreUsers() { StoreUsersViewModel vm = new StoreUsersViewModel(); - await FillUsers(storeId, vm); + await FillUsers(vm); return View(vm); } - private async Task FillUsers(string storeId, StoreUsersViewModel vm) + private async Task FillUsers(StoreUsersViewModel vm) { - var users = await _Repo.GetStoreUsers(storeId); - vm.StoreId = storeId; + var users = await _Repo.GetStoreUsers(StoreData.Id); + vm.StoreId = StoreData.Id; vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel() { Email = u.Email, @@ -131,11 +128,20 @@ namespace BTCPayServer.Controllers }).ToList(); } + public StoreData StoreData + { + get + { + return this.HttpContext.GetStoreData(); + } + } + + [HttpPost] [Route("{storeId}/users")] - public async Task StoreUsers(string storeId, StoreUsersViewModel vm) + public async Task StoreUsers(StoreUsersViewModel vm) { - await FillUsers(storeId, vm); + await FillUsers(vm); if (!ModelState.IsValid) { return View(vm); @@ -151,7 +157,7 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(nameof(vm.Role), "Invalid role"); return View(vm); } - if (!await _Repo.AddStoreUser(storeId, user.Id, vm.Role)) + if (!await _Repo.AddStoreUser(StoreData.Id, user.Id, vm.Role)) { ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store"); return View(vm); @@ -162,12 +168,9 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/users/{userId}/delete")] - public async Task DeleteStoreUser(string storeId, string userId) + public async Task DeleteStoreUser(string userId) { StoreUsersViewModel vm = new StoreUsersViewModel(); - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); var user = await _UserManager.FindByIdAsync(userId); if (user == null) return NotFound(); @@ -190,14 +193,11 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/checkout")] - public IActionResult CheckoutExperience(string storeId) + public IActionResult CheckoutExperience() { - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); - var storeBlob = store.GetStoreBlob(); + var storeBlob = StoreData.GetStoreBlob(); var vm = new CheckoutExperienceViewModel(); - vm.SetCryptoCurrencies(_ExplorerProvider, store.GetDefaultCrypto()); + vm.SetCryptoCurrencies(_ExplorerProvider, StoreData.GetDefaultCrypto()); vm.SetLanguages(_LangService, storeBlob.DefaultLang); vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.OnChainMinValue = storeBlob.OnChainMinValue?.ToString() ?? ""; @@ -210,7 +210,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{storeId}/checkout")] - public async Task CheckoutExperience(string storeId, CheckoutExperienceViewModel model) + public async Task CheckoutExperience(CheckoutExperienceViewModel model) { CurrencyValue lightningMaxValue = null; if (!string.IsNullOrWhiteSpace(model.LightningMaxValue)) @@ -229,16 +229,12 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(nameof(model.OnChainMinValue), "Invalid on chain min value"); } } - - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); bool needUpdate = false; - var blob = store.GetStoreBlob(); - if (store.GetDefaultCrypto() != model.DefaultCryptoCurrency) + var blob = StoreData.GetStoreBlob(); + if (StoreData.GetDefaultCrypto() != model.DefaultCryptoCurrency) { needUpdate = true; - store.SetDefaultCrypto(model.DefaultCryptoCurrency); + StoreData.SetDefaultCrypto(model.DefaultCryptoCurrency); } model.SetCryptoCurrencies(_ExplorerProvider, model.DefaultCryptoCurrency); model.SetLanguages(_LangService, model.DefaultLang); @@ -254,19 +250,19 @@ namespace BTCPayServer.Controllers blob.OnChainMinValue = onchainMinValue; blob.CustomLogo = string.IsNullOrWhiteSpace(model.CustomLogo) ? null : new Uri(model.CustomLogo, UriKind.Absolute); blob.CustomCSS = string.IsNullOrWhiteSpace(model.CustomCSS) ? null : new Uri(model.CustomCSS, UriKind.Absolute); - if (store.SetStoreBlob(blob)) + if (StoreData.SetStoreBlob(blob)) { needUpdate = true; } if (needUpdate) { - await _Repo.UpdateStore(store); + await _Repo.UpdateStore(StoreData); StatusMessage = "Store successfully updated"; } return RedirectToAction(nameof(CheckoutExperience), new { - storeId = storeId + storeId = StoreData.Id }); } @@ -330,7 +326,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{storeId}")] - public async Task UpdateStore(string storeId, StoreViewModel model) + public async Task UpdateStore(StoreViewModel model) { model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange); if (!ModelState.IsValid) @@ -339,29 +335,26 @@ namespace BTCPayServer.Controllers } if (model.PreferredExchange != null) model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); - var store = HttpContext.GetStoreData(); - if (store == null) - return NotFound(); - AddPaymentMethods(store, model); + AddPaymentMethods(StoreData, model); bool needUpdate = false; - if (store.SpeedPolicy != model.SpeedPolicy) + if (StoreData.SpeedPolicy != model.SpeedPolicy) { needUpdate = true; - store.SpeedPolicy = model.SpeedPolicy; + StoreData.SpeedPolicy = model.SpeedPolicy; } - if (store.StoreName != model.StoreName) + if (StoreData.StoreName != model.StoreName) { needUpdate = true; - store.StoreName = model.StoreName; + StoreData.StoreName = model.StoreName; } - if (store.StoreWebsite != model.StoreWebsite) + if (StoreData.StoreWebsite != model.StoreWebsite) { needUpdate = true; - store.StoreWebsite = model.StoreWebsite; + StoreData.StoreWebsite = model.StoreWebsite; } - var blob = store.GetStoreBlob(); + var blob = StoreData.GetStoreBlob(); blob.NetworkFeeDisabled = !model.NetworkFee; blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; @@ -372,7 +365,7 @@ namespace BTCPayServer.Controllers blob.SetRateMultiplier(model.RateMultiplier); - if (store.SetStoreBlob(blob)) + if (StoreData.SetStoreBlob(blob)) { needUpdate = true; } @@ -389,13 +382,13 @@ namespace BTCPayServer.Controllers if (needUpdate) { - await _Repo.UpdateStore(store); + await _Repo.UpdateStore(StoreData); StatusMessage = "Store successfully updated"; } return RedirectToAction(nameof(UpdateStore), new { - storeId = storeId + storeId = StoreData.Id }); } @@ -416,10 +409,10 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}/Tokens")] - public async Task ListTokens(string storeId) + public async Task ListTokens() { var model = new TokensViewModel(); - var tokens = await _TokenRepository.GetTokensByStoreIdAsync(storeId); + var tokens = await _TokenRepository.GetTokensByStoreIdAsync(StoreData.Id); model.StatusMessage = StatusMessage; model.Tokens = tokens.Select(t => new TokenViewModel() { @@ -429,7 +422,7 @@ namespace BTCPayServer.Controllers Id = t.Value }).ToArray(); - model.ApiKey = (await _TokenRepository.GetLegacyAPIKeys(storeId)).FirstOrDefault(); + model.ApiKey = (await _TokenRepository.GetLegacyAPIKeys(StoreData.Id)).FirstOrDefault(); if (model.ApiKey == null) model.EncodedApiKey = "*API Key*"; else @@ -440,24 +433,31 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("/api-tokens")] [Route("{storeId}/Tokens/Create")] - public async Task CreateToken(string storeId, CreateTokenViewModel model) + [AllowAnonymous] + public async Task CreateToken(CreateTokenViewModel model) { if (!ModelState.IsValid) { return View(model); } model.Label = model.Label ?? String.Empty; - storeId = model.StoreId ?? storeId; var userId = GetUserId(); if (userId == null) - return Unauthorized(); - var store = HttpContext.GetStoreData(); - if (store == null) - return Unauthorized(); + return Challenge(Policies.CookieAuthentication); + + var store = StoreData; + var storeId = StoreData?.Id; + if (storeId == null) + { + storeId = model.StoreId; + store = await _Repo.FindStore(storeId, userId); + if (store == null) + return Challenge(Policies.CookieAuthentication); + } + if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { - StatusMessage = "Error: You need to be owner of this store to request pairing codes"; - return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); + return Challenge(Policies.CookieAuthentication); } var tokenRequest = new TokenRequest() @@ -498,11 +498,20 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("/api-tokens")] [Route("{storeId}/Tokens/Create")] - public async Task CreateToken(string storeId) + [AllowAnonymous] + public async Task CreateToken() { var userId = GetUserId(); if (string.IsNullOrWhiteSpace(userId)) - return Unauthorized(); + return Challenge(Policies.CookieAuthentication); + var storeId = StoreData?.Id; + if (StoreData != null) + { + if (!StoreData.HasClaim(Policies.CanModifyStoreSettings.Key)) + { + return Challenge(Policies.CookieAuthentication); + } + } var model = new CreateTokenViewModel(); model.Facade = "merchant"; ViewBag.HidePublicKey = storeId == null; @@ -511,20 +520,25 @@ namespace BTCPayServer.Controllers model.StoreId = storeId; if (storeId == null) { - model.Stores = new SelectList(await _Repo.GetStoresByUserId(userId), nameof(StoreData.Id), nameof(StoreData.StoreName), storeId); + var stores = await _Repo.GetStoresByUserId(userId); + model.Stores = new SelectList(stores.Where(s => s.HasClaim(Policies.CanModifyStoreSettings.Key)), nameof(StoreData.Id), nameof(StoreData.StoreName), storeId); + } + if (model.Stores.Count() == 0) + { + StatusMessage = "Error: You need to be owner of at least one store before pairing"; + return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } - return View(model); } [HttpPost] [Route("{storeId}/Tokens/Delete")] - public async Task DeleteToken(string storeId, string tokenId) + public async Task DeleteToken(string tokenId) { var token = await _TokenRepository.GetToken(tokenId); if (token == null || - token.StoreId != storeId || + token.StoreId != StoreData.Id || !await _TokenRepository.DeleteToken(tokenId)) StatusMessage = "Failure to revoke this token"; else @@ -534,20 +548,24 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{storeId}/tokens/apikey")] - public async Task GenerateAPIKey(string storeId) + public async Task GenerateAPIKey() { var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); - await _TokenRepository.GenerateLegacyAPIKey(storeId); + await _TokenRepository.GenerateLegacyAPIKey(StoreData.Id); StatusMessage = "API Key re-generated"; return RedirectToAction(nameof(ListTokens)); } [HttpGet] [Route("/api-access-request")] + [AllowAnonymous] public async Task RequestPairing(string pairingCode, string selectedStore = null) { + var userId = GetUserId(); + if (userId == null) + return Challenge(Policies.CookieAuthentication); if (pairingCode == null) return NotFound(); var pairing = await _TokenRepository.GetPairingAsync(pairingCode); @@ -558,7 +576,7 @@ namespace BTCPayServer.Controllers } else { - var stores = await _Repo.GetStoresByUserId(GetUserId()); + var stores = await _Repo.GetStoresByUserId(userId); return View(new PairingModel() { Id = pairing.Id, @@ -566,7 +584,7 @@ namespace BTCPayServer.Controllers Label = pairing.Label, SIN = pairing.SIN ?? "Server-Initiated Pairing", SelectedStore = selectedStore ?? stores.FirstOrDefault()?.Id, - Stores = stores.Select(s => new PairingModel.StoreViewModel() + Stores = stores.Where(u => u.HasClaim(Policies.CanModifyStoreSettings.Key)).Select(s => new PairingModel.StoreViewModel() { Id = s.Id, Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName @@ -577,19 +595,22 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("/api-access-request")] + [AllowAnonymous] public async Task Pair(string pairingCode, string selectedStore) { if (pairingCode == null) return NotFound(); - var store = await _Repo.FindStore(selectedStore, GetUserId()); + var userId = GetUserId(); + if (userId == null) + return Challenge(Policies.CookieAuthentication); + var store = await _Repo.FindStore(selectedStore, userId); var pairing = await _TokenRepository.GetPairingAsync(pairingCode); if (store == null || pairing == null) return NotFound(); if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { - StatusMessage = "Error: You can't approve a pairing without being owner of the store"; - return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); + return Challenge(Policies.CookieAuthentication); } var pairingResult = await _TokenRepository.PairWithStoreAsync(pairingCode, store.Id); @@ -615,6 +636,8 @@ namespace BTCPayServer.Controllers private string GetUserId() { + if (User.Identity.AuthenticationType != Policies.CookieAuthentication) + return null; return _UserManager.GetUserId(User); } } diff --git a/BTCPayServer/Controllers/UserStoresController.cs b/BTCPayServer/Controllers/UserStoresController.cs index 8f8d87e1c..417277356 100644 --- a/BTCPayServer/Controllers/UserStoresController.cs +++ b/BTCPayServer/Controllers/UserStoresController.cs @@ -16,7 +16,7 @@ using NBXplorer.DerivationStrategy; namespace BTCPayServer.Controllers { [Route("stores")] - [Authorize(AuthenticationSchemes = "Identity.Application")] + [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [AutoValidateAntiforgeryToken] public partial class UserStoresController : Controller { diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index b125019a5..5aef0a738 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -261,7 +261,6 @@ namespace BTCPayServer.HostedServices private async Task WaitPendingInvoices() { - await new SynchronizationContextRemover(); await Task.WhenAll((await _InvoiceRepository.GetPendingInvoices()) .Select(id => Wait(id)).ToArray()); _WaitingInvoices = null; @@ -269,8 +268,8 @@ namespace BTCPayServer.HostedServices async Task StartLoop(CancellationToken cancellation) { - await new SynchronizationContextRemover(); Logs.PayServer.LogInformation("Start watching invoices"); + await Task.Delay(1).ConfigureAwait(false); // Small hack so that the caller does not block on GetConsumingEnumerable try { foreach (var invoiceId in _WatchRequests.GetConsumingEnumerable(cancellation)) diff --git a/BTCPayServer/Security/BTCPayClaimsFilter.cs b/BTCPayServer/Security/BTCPayClaimsFilter.cs index 189485e3e..5ab53ef12 100644 --- a/BTCPayServer/Security/BTCPayClaimsFilter.cs +++ b/BTCPayServer/Security/BTCPayClaimsFilter.cs @@ -48,10 +48,15 @@ namespace BTCPayServer.Security if (claim != null) { var store = await _StoreRepository.FindStore((string)storeId, claim.Value); - context.HttpContext.SetStoreData(store); - if (store != null) + if (store == null) + context.Result = new ChallengeResult(Policies.CookieAuthentication); + else { - identity.AddClaims(store.GetClaims()); + context.HttpContext.SetStoreData(store); + if (store != null) + { + identity.AddClaims(store.GetClaims()); + } } } } diff --git a/BTCPayServer/Security/Policies.cs b/BTCPayServer/Security/Policies.cs index cc13921da..470c7058b 100644 --- a/BTCPayServer/Security/Policies.cs +++ b/BTCPayServer/Security/Policies.cs @@ -8,6 +8,7 @@ namespace BTCPayServer.Security { public static class Policies { + public const string CookieAuthentication = "Identity.Application"; public static AuthorizationOptions AddBTCPayPolicies(this AuthorizationOptions options) { AddClaim(options, CanUseStore.Key); From eb975bf8fc0baab59e7c2fbe89c0de0b08f091cb Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 30 Apr 2018 22:28:00 +0900 Subject: [PATCH 020/119] Isolate Bitpay's code outside of middleware inside BitpayClaimsFilter --- BTCPayServer.Tests/BTCPayServerTester.cs | 3 +- BTCPayServer.Tests/TestAccount.cs | 14 +- BTCPayServer.Tests/UnitTest1.cs | 8 +- BTCPayServer/Extensions.cs | 10 + BTCPayServer/Hosting/BTCPayServerServices.cs | 1 + BTCPayServer/Hosting/BTCpayMiddleware.cs | 158 +-------------- BTCPayServer/Security/BitpayClaimsFilter.cs | 196 +++++++++++++++++++ 7 files changed, 221 insertions(+), 169 deletions(-) create mode 100644 BTCPayServer/Security/BitpayClaimsFilter.cs diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index faec08309..77c5a9be1 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -2,6 +2,7 @@ using BTCPayServer.Hosting; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Security; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; @@ -151,7 +152,7 @@ namespace BTCPayServer.Tests context.Request.Protocol = "http"; if (userId != null) { - context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })); + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, Policies.CookieAuthentication)); } if(storeId != null) { diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 7acf3006f..51d6840b3 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -44,14 +44,15 @@ namespace BTCPayServer.Tests public async Task GrantAccessAsync() { await RegisterAsync(); - var store = await CreateStoreAsync(); + await CreateStoreAsync(); + var store = this.GetController(); var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant); Assert.IsType(await store.RequestPairing(pairingCode.ToString())); await store.Pair(pairingCode.ToString(), StoreId); } - public StoresController CreateStore() + public void CreateStore() { - return CreateStoreAsync().GetAwaiter().GetResult(); + CreateStoreAsync().GetAwaiter().GetResult(); } public T GetController(bool setImplicitStore = true) where T : Controller @@ -59,14 +60,11 @@ namespace BTCPayServer.Tests return parent.PayTester.GetController(UserId, setImplicitStore ? StoreId : null); } - public async Task CreateStoreAsync() + public async Task CreateStoreAsync() { - var store = parent.PayTester.GetController(UserId); + var store = this.GetController(); await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" }); StoreId = store.CreatedStoreId; - var store2 = parent.PayTester.GetController(UserId); - store2.CreatedStoreId = store.CreatedStoreId; - return store2; } public BTCPayNetwork SupportedNetwork { get; set; } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 5d7705048..b068f0cb9 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -527,13 +527,15 @@ namespace BTCPayServer.Tests tester.Start(); var acc = tester.NewAccount(); acc.Register(); - var store = acc.CreateStore(); + acc.CreateStore(); + var store = acc.GetController(); 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(); + acc.CreateStore(); + var store2 = acc.GetController(); + store2.Pair(pairingCode.ToString(), store2.StoreData.Id).GetAwaiter().GetResult(); Assert.Contains(nameof(PairingResult.ReusedKey), store2.StatusMessage, StringComparison.CurrentCultureIgnoreCase); } } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index bd98aed3c..b29cf762f 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -173,6 +173,16 @@ namespace BTCPayServer obj is bool b && b; } + public static void SetBitpayAuth(this HttpContext ctx, (string Signature, String Id, String Authorization) value) + { + NBitcoin.Extensions.TryAdd(ctx.Items, "BitpayAuth", value); + } + + public static (string Signature, String Id, String Authorization) GetBitpayAuth(this HttpContext ctx) + { + ctx.Items.TryGetValue("BitpayAuth", out object obj); + return ((string Signature, String Id, String Authorization))obj; + } public static StoreData GetStoreData(this HttpContext ctx) { diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 9f2052fbc..2c295ab28 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -119,6 +119,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddTransient, BTCPayClaimsFilter>(); + services.AddTransient, BitpayClaimsFilter>(); services.TryAddSingleton(); services.TryAddSingleton(o => diff --git a/BTCPayServer/Hosting/BTCpayMiddleware.cs b/BTCPayServer/Hosting/BTCpayMiddleware.cs index 091c4f90b..2f229d12d 100644 --- a/BTCPayServer/Hosting/BTCpayMiddleware.cs +++ b/BTCPayServer/Hosting/BTCpayMiddleware.cs @@ -6,45 +6,25 @@ using System.Collections.Generic; using System.Text; using System.Linq; using System.Threading.Tasks; -using NBitcoin; -using NBitcoin.Crypto; -using NBitcoin.DataEncoders; -using Microsoft.AspNetCore.Http.Internal; using System.IO; using BTCPayServer.Authentication; -using System.Security.Principal; -using NBitpayClient.Extensions; using BTCPayServer.Logging; using Newtonsoft.Json; using BTCPayServer.Models; using BTCPayServer.Configuration; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Routing; -using Microsoft.AspNetCore.Http.Extensions; -using BTCPayServer.Controllers; using System.Net.WebSockets; -using System.Security.Claims; -using BTCPayServer.Services; -using NBitpayClient; -using Newtonsoft.Json.Linq; using BTCPayServer.Services.Stores; namespace BTCPayServer.Hosting { public class BTCPayMiddleware { - TokenRepository _TokenRepository; - StoreRepository _StoreRepository; RequestDelegate _Next; BTCPayServerOptions _Options; public BTCPayMiddleware(RequestDelegate next, - TokenRepository tokenRepo, - StoreRepository storeRepo, BTCPayServerOptions options) { - _TokenRepository = tokenRepo ?? throw new ArgumentNullException(nameof(tokenRepo)); - _StoreRepository = storeRepo; _Next = next ?? throw new ArgumentNullException(nameof(next)); _Options = options ?? throw new ArgumentNullException(nameof(options)); } @@ -61,39 +41,7 @@ namespace BTCPayServer.Hosting httpContext.SetIsBitpayAPI(isBitpayAPI); if (isBitpayAPI) { - - string storeId = null; - var failedAuth = false; - if (!string.IsNullOrEmpty(bitpayAuth.Signature) && !string.IsNullOrEmpty(bitpayAuth.Id)) - { - storeId = await CheckBitId(httpContext, bitpayAuth.Signature, bitpayAuth.Id); - if (!httpContext.User.Claims.Any(c => c.Type == Claims.SIN)) - { - Logs.PayServer.LogDebug("BitId signature check failed"); - failedAuth = true; - } - } - else if (!string.IsNullOrEmpty(bitpayAuth.Authorization)) - { - storeId = await CheckLegacyAPIKey(httpContext, bitpayAuth.Authorization); - if (storeId == null) - { - Logs.PayServer.LogDebug("API key check failed"); - failedAuth = true; - } - } - - if (storeId != null) - { - var identity = ((ClaimsIdentity)httpContext.User.Identity); - identity.AddClaim(new Claim(Claims.OwnStore, storeId)); - var store = await _StoreRepository.FindStore(storeId); - httpContext.SetStoreData(store); - } - else if (failedAuth) - { - throw new BitpayHttpException(401, "Can't access to store"); - } + httpContext.SetBitpayAuth(bitpayAuth); } await _Next(httpContext); } @@ -255,109 +203,5 @@ namespace BTCPayServer.Hosting await writer.FlushAsync(); } } - - - private async Task CheckBitId(HttpContext httpContext, string sig, string id) - { - httpContext.Request.EnableRewind(); - - string storeId = null; - string body = string.Empty; - if (httpContext.Request.ContentLength != 0 && httpContext.Request.Body != null) - { - using (StreamReader reader = new StreamReader(httpContext.Request.Body, Encoding.UTF8, true, 1024, true)) - { - body = reader.ReadToEnd(); - } - httpContext.Request.Body.Position = 0; - } - - var url = httpContext.Request.GetEncodedUrl(); - try - { - var key = new PubKey(id); - if (BitIdExtensions.CheckBitIDSignature(key, sig, url, body)) - { - var sin = key.GetBitIDSIN(); - var identity = ((ClaimsIdentity)httpContext.User.Identity); - identity.AddClaim(new Claim(Claims.SIN, sin)); - - string token = null; - if (httpContext.Request.Query.TryGetValue("token", out var tokenValues)) - { - token = tokenValues[0]; - } - - if (token == null && !String.IsNullOrEmpty(body) && httpContext.Request.Method == "POST") - { - try - { - token = JObject.Parse(body)?.Property("token")?.Value?.Value(); - } - catch { } - } - - if (token != null) - { - var bitToken = await GetTokenPermissionAsync(sin, token); - if (bitToken == null) - { - return null; - } - storeId = bitToken.StoreId; - } - } - } - catch (FormatException) { } - return storeId; - } - - private async Task CheckLegacyAPIKey(HttpContext httpContext, string auth) - { - var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - string apiKey = null; - try - { - apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1])); - } - catch - { - return null; - } - return await _TokenRepository.GetStoreIdFromAPIKey(apiKey); - } - - private async Task GetTokenPermissionAsync(string sin, string expectedToken) - { - var actualTokens = (await _TokenRepository.GetTokens(sin)).ToArray(); - actualTokens = actualTokens.SelectMany(t => GetCompatibleTokens(t)).ToArray(); - - var actualToken = actualTokens.FirstOrDefault(a => a.Value.Equals(expectedToken, StringComparison.Ordinal)); - if (expectedToken == null || actualToken == null) - { - Logs.PayServer.LogDebug($"No token found for facade {Facade.Merchant} for SIN {sin}"); - return null; - } - return actualToken; - } - - private IEnumerable GetCompatibleTokens(BitTokenEntity token) - { - if (token.Facade == Facade.Merchant.ToString()) - { - yield return token.Clone(Facade.User); - yield return token.Clone(Facade.PointOfSale); - } - if (token.Facade == Facade.PointOfSale.ToString()) - { - yield return token.Clone(Facade.User); - } - yield return token; - } } } diff --git a/BTCPayServer/Security/BitpayClaimsFilter.cs b/BTCPayServer/Security/BitpayClaimsFilter.cs new file mode 100644 index 000000000..c9c4ea489 --- /dev/null +++ b/BTCPayServer/Security/BitpayClaimsFilter.cs @@ -0,0 +1,196 @@ +using System; +using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Http.Extensions; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Authentication; +using BTCPayServer.Models; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Options; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBitpayClient; +using NBitpayClient.Extensions; +using Newtonsoft.Json.Linq; +using BTCPayServer.Logging; +using Microsoft.AspNetCore.Http.Internal; + +namespace BTCPayServer.Security +{ + public class BitpayClaimsFilter : IAsyncAuthorizationFilter, IConfigureOptions + { + UserManager _UserManager; + StoreRepository _StoreRepository; + TokenRepository _TokenRepository; + + public BitpayClaimsFilter( + UserManager userManager, + TokenRepository tokenRepository, + StoreRepository storeRepository) + { + _UserManager = userManager; + _StoreRepository = storeRepository; + _TokenRepository = tokenRepository; + } + + void IConfigureOptions.Configure(MvcOptions options) + { + options.Filters.Add(typeof(BitpayClaimsFilter)); + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var principal = context.HttpContext.User; + if (context.HttpContext.GetIsBitpayAPI()) + { + var bitpayAuth = context.HttpContext.GetBitpayAuth(); + string storeId = null; + var failedAuth = false; + if (!string.IsNullOrEmpty(bitpayAuth.Signature) && !string.IsNullOrEmpty(bitpayAuth.Id)) + { + storeId = await CheckBitId(context.HttpContext, bitpayAuth.Signature, bitpayAuth.Id); + if (!context.HttpContext.User.Claims.Any(c => c.Type == Claims.SIN)) + { + Logs.PayServer.LogDebug("BitId signature check failed"); + failedAuth = true; + } + } + else if (!string.IsNullOrEmpty(bitpayAuth.Authorization)) + { + storeId = await CheckLegacyAPIKey(context.HttpContext, bitpayAuth.Authorization); + if (storeId == null) + { + Logs.PayServer.LogDebug("API key check failed"); + failedAuth = true; + } + } + + if (storeId != null) + { + var identity = ((ClaimsIdentity)context.HttpContext.User.Identity); + identity.AddClaim(new Claim(Claims.OwnStore, storeId)); + var store = await _StoreRepository.FindStore(storeId); + context.HttpContext.SetStoreData(store); + } + else if (failedAuth) + { + throw new BitpayHttpException(401, "Can't access to store"); + } + } + } + + private async Task CheckBitId(HttpContext httpContext, string sig, string id) + { + httpContext.Request.EnableRewind(); + + string storeId = null; + string body = string.Empty; + if (httpContext.Request.ContentLength != 0 && httpContext.Request.Body != null) + { + using (StreamReader reader = new StreamReader(httpContext.Request.Body, Encoding.UTF8, true, 1024, true)) + { + body = reader.ReadToEnd(); + } + httpContext.Request.Body.Position = 0; + } + + var url = httpContext.Request.GetEncodedUrl(); + try + { + var key = new PubKey(id); + if (BitIdExtensions.CheckBitIDSignature(key, sig, url, body)) + { + var sin = key.GetBitIDSIN(); + var identity = ((ClaimsIdentity)httpContext.User.Identity); + identity.AddClaim(new Claim(Claims.SIN, sin)); + + string token = null; + if (httpContext.Request.Query.TryGetValue("token", out var tokenValues)) + { + token = tokenValues[0]; + } + + if (token == null && !String.IsNullOrEmpty(body) && httpContext.Request.Method == "POST") + { + try + { + token = JObject.Parse(body)?.Property("token")?.Value?.Value(); + } + catch { } + } + + if (token != null) + { + var bitToken = await GetTokenPermissionAsync(sin, token); + if (bitToken == null) + { + return null; + } + storeId = bitToken.StoreId; + } + } + } + catch (FormatException) { } + return storeId; + } + + private async Task CheckLegacyAPIKey(HttpContext httpContext, string auth) + { + var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string apiKey = null; + try + { + apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1])); + } + catch + { + return null; + } + return await _TokenRepository.GetStoreIdFromAPIKey(apiKey); + } + + private async Task GetTokenPermissionAsync(string sin, string expectedToken) + { + var actualTokens = (await _TokenRepository.GetTokens(sin)).ToArray(); + actualTokens = actualTokens.SelectMany(t => GetCompatibleTokens(t)).ToArray(); + + var actualToken = actualTokens.FirstOrDefault(a => a.Value.Equals(expectedToken, StringComparison.Ordinal)); + if (expectedToken == null || actualToken == null) + { + Logs.PayServer.LogDebug($"No token found for facade {Facade.Merchant} for SIN {sin}"); + return null; + } + return actualToken; + } + + private IEnumerable GetCompatibleTokens(BitTokenEntity token) + { + if (token.Facade == Facade.Merchant.ToString()) + { + yield return token.Clone(Facade.User); + yield return token.Clone(Facade.PointOfSale); + } + if (token.Facade == Facade.PointOfSale.ToString()) + { + yield return token.Clone(Facade.User); + } + yield return token; + } + } +} From 43be1e191f9a0172e1a4cb9f64455082497aeb6a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 2 May 2018 18:37:53 +0900 Subject: [PATCH 021/119] Create the RateRules class for parsing rate calculation rules --- BTCPayServer.Tests/RateRulesTest.cs | 119 +++++++ BTCPayServer/Extensions.cs | 7 + BTCPayServer/Rating/CurrencyPair.cs | 71 ++++ BTCPayServer/Rating/ExchangeRates.cs | 68 ++++ BTCPayServer/Rating/RateRules.cs | 484 +++++++++++++++++++++++++++ 5 files changed, 749 insertions(+) create mode 100644 BTCPayServer.Tests/RateRulesTest.cs create mode 100644 BTCPayServer/Rating/CurrencyPair.cs create mode 100644 BTCPayServer/Rating/ExchangeRates.cs create mode 100644 BTCPayServer/Rating/RateRules.cs diff --git a/BTCPayServer.Tests/RateRulesTest.cs b/BTCPayServer.Tests/RateRulesTest.cs new file mode 100644 index 000000000..4236571d6 --- /dev/null +++ b/BTCPayServer.Tests/RateRulesTest.cs @@ -0,0 +1,119 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Text; +using BTCPayServer.Rating; +using Xunit; + +namespace BTCPayServer.Tests +{ + public class RateRulesTest + { + [Fact] + public void CanParseRateRules() + { + // Check happy path + StringBuilder builder = new StringBuilder(); + builder.AppendLine("DOGE_X = DOGE_BTC * BTC_X * 1.1"); + builder.AppendLine("DOGE_BTC = Bittrex(DOGE_BTC)"); + builder.AppendLine("BTC_usd = GDax(BTC_USD)"); + builder.AppendLine("BTC_X = Coinbase(BTC_X)"); + builder.AppendLine("X_X = CoinAverage(X_X) * 1.02"); + + Assert.False(RateRules.TryParse("DPW*&W&#hdi&#&3JJD", out var rules)); + Assert.True(RateRules.TryParse(builder.ToString(), out rules)); + Assert.Equal( + "DOGE_X = DOGE_BTC * BTC_X * 1.1;\n" + + "DOGE_BTC = bittrex(DOGE_BTC);\n" + + "BTC_USD = gdax(BTC_USD);\n" + + "BTC_X = coinbase(BTC_X);\n" + + "X_X = coinaverage(X_X) * 1.02;", + rules.ToString()); + var tests = new[] + { + (Pair: "BTC_USD", Expected: "gdax(BTC_USD)"), + (Pair: "BTC_CAD", Expected: "coinbase(BTC_CAD)"), + (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1"), + (Pair: "DOGE_CAD", Expected: "bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1"), + (Pair: "LTC_CAD", Expected: "coinaverage(LTC_CAD) * 1.02"), + }; + foreach (var test in tests) + { + Assert.Equal(test.Expected, rules.GetRuleFor(CurrencyPair.Parse(test.Pair)).ToString()); + } + Assert.Equal("(bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1) * 2.32", rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD"), 2.32m).ToString()); + //////////////// + + // Check errors conditions + builder = new StringBuilder(); + builder.AppendLine("DOGE_X = LTC_CAD * BTC_X * 1.1"); + builder.AppendLine("DOGE_BTC = Bittrex(DOGE_BTC)"); + builder.AppendLine("BTC_usd = GDax(BTC_USD)"); + builder.AppendLine("LTC_CHF = LTC_CHF * 1.01"); + builder.AppendLine("BTC_X = Coinbase(BTC_X)"); + Assert.True(RateRules.TryParse(builder.ToString(), out rules)); + + tests = new[] + { + (Pair: "LTC_CAD", Expected: "ERR_NO_RULE_MATCH(LTC_CAD)"), + (Pair: "DOGE_USD", Expected: "ERR_NO_RULE_MATCH(LTC_CAD) * gdax(BTC_USD) * 1.1"), + (Pair: "LTC_CHF", Expected: "ERR_TOO_MUCH_NESTED_CALLS(LTC_CHF) * 1.01"), + }; + foreach (var test in tests) + { + Assert.Equal(test.Expected, rules.GetRuleFor(CurrencyPair.Parse(test.Pair)).ToString()); + } + ////////////////// + + // Check if we can resolve exchange rates + builder = new StringBuilder(); + builder.AppendLine("DOGE_X = DOGE_BTC * BTC_X * 1.1"); + builder.AppendLine("DOGE_BTC = Bittrex(DOGE_BTC)"); + builder.AppendLine("BTC_usd = GDax(BTC_USD)"); + builder.AppendLine("BTC_X = Coinbase(BTC_X)"); + builder.AppendLine("X_X = CoinAverage(X_X) * 1.02"); + Assert.True(RateRules.TryParse(builder.ToString(), out rules)); + + var tests2 = new[] + { + (Pair: "BTC_USD", Expected: "gdax(BTC_USD)", ExpectedExchangeRates: "gdax(BTC_USD)"), + (Pair: "BTC_CAD", Expected: "coinbase(BTC_CAD)", ExpectedExchangeRates: "coinbase(BTC_CAD)"), + (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1", ExpectedExchangeRates: "bittrex(DOGE_BTC),gdax(BTC_USD)"), + (Pair: "DOGE_CAD", Expected: "bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1", ExpectedExchangeRates: "bittrex(DOGE_BTC),coinbase(BTC_CAD)"), + (Pair: "LTC_CAD", Expected: "coinaverage(LTC_CAD) * 1.02", ExpectedExchangeRates: "coinaverage(LTC_CAD)"), + }; + foreach (var test in tests2) + { + var rule = rules.GetRuleFor(CurrencyPair.Parse(test.Pair)); + Assert.Equal(test.Expected, rule.ToString()); + Assert.Equal(test.ExpectedExchangeRates, string.Join(',', rule.ExchangeRates.OfType().ToArray())); + } + var rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_CAD")); + rule2.ExchangeRates.SetRate("bittrex", CurrencyPair.Parse("DOGE_BTC"), 5000); + rule2.Reevaluate(); + Assert.True(rule2.HasError); + Assert.Equal("5000 * ERR_RATE_UNAVAILABLE(coinbase, BTC_CAD) * 1.1", rule2.ToString(true)); + Assert.Equal("bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1", rule2.ToString(false)); + rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 2000.4m); + rule2.Reevaluate(); + Assert.False(rule2.HasError); + Assert.Equal("5000 * 2000.4 * 1.1", rule2.ToString(true)); + Assert.Equal(rule2.Value, 5000m * 2000.4m * 1.1m); + //////// + + // Make sure parenthesis are correctly calculated + builder = new StringBuilder(); + builder.AppendLine("DOGE_X = DOGE_BTC * BTC_X"); + builder.AppendLine("BTC_USD = -3 + coinbase(BTC_CAD) + 50 - 5"); + builder.AppendLine("DOGE_BTC = 2000"); + Assert.True(RateRules.TryParse(builder.ToString(), out rules)); + rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD"), 1.1m); + Assert.Equal("(2000 * (-3 + coinbase(BTC_CAD) + 50 - 5)) * 1.1", rule2.ToString()); + rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 1000m); + Assert.True(rule2.Reevaluate()); + Assert.Equal("(2000 * (-3 + 1000 + 50 - 5)) * 1.1", rule2.ToString(true)); + Assert.Equal((2000m * (-3m + 1000m + 50m - 5m)) * 1.1m, rule2.Value.Value); + //////// + } + } +} diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index b29cf762f..7c2bf6237 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -167,6 +167,13 @@ namespace BTCPayServer NBitcoin.Extensions.TryAdd(ctx.Items, "IsBitpayAPI", value); } + public static void AddRange(this HashSet hashSet, IEnumerable items) + { + foreach(var item in items) + { + hashSet.Add(item); + } + } public static bool GetIsBitpayAPI(this HttpContext ctx) { return ctx.Items.TryGetValue("IsBitpayAPI", out object obj) && diff --git a/BTCPayServer/Rating/CurrencyPair.cs b/BTCPayServer/Rating/CurrencyPair.cs new file mode 100644 index 000000000..21a15bd30 --- /dev/null +++ b/BTCPayServer/Rating/CurrencyPair.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Rating +{ + public class CurrencyPair + { + public CurrencyPair(string left, string right) + { + if (right == null) + throw new ArgumentNullException(nameof(right)); + if (left == null) + throw new ArgumentNullException(nameof(left)); + Right = right.ToUpperInvariant(); + Left = left.ToUpperInvariant(); + } + public string Left { get; private set; } + public string Right { get; private set; } + + public static CurrencyPair Parse(string str) + { + if (!TryParse(str, out var result)) + throw new FormatException("Invalid currency pair"); + return result; + } + public static bool TryParse(string str, out CurrencyPair value) + { + if (str == null) + throw new ArgumentNullException(nameof(str)); + value = null; + var splitted = str.Split('_'); + if (splitted.Length != 2) + return false; + value = new CurrencyPair(splitted[0], splitted[1]); + return true; + } + + + public override bool Equals(object obj) + { + CurrencyPair item = obj as CurrencyPair; + if (item == null) + return false; + return ToString().Equals(item.ToString(), StringComparison.OrdinalIgnoreCase); + } + public static bool operator ==(CurrencyPair a, CurrencyPair b) + { + if (System.Object.ReferenceEquals(a, b)) + return true; + if (((object)a == null) || ((object)b == null)) + return false; + return a.ToString() == b.ToString(); + } + + public static bool operator !=(CurrencyPair a, CurrencyPair b) + { + return !(a == b); + } + + public override int GetHashCode() + { + return ToString().GetHashCode(StringComparison.OrdinalIgnoreCase); + } + public override string ToString() + { + return $"{Left}_{Right}"; + } + } +} diff --git a/BTCPayServer/Rating/ExchangeRates.cs b/BTCPayServer/Rating/ExchangeRates.cs new file mode 100644 index 000000000..576201f0f --- /dev/null +++ b/BTCPayServer/Rating/ExchangeRates.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Rating +{ + public class ExchangeRates : IEnumerable + { + List _Rates = new List(); + public MultiValueDictionary ByExchange + { + get; + private set; + } = new MultiValueDictionary(); + + public void Add(ExchangeRate rate) + { + _Rates.Add(rate); + ByExchange.Add(rate.Exchange, rate); + } + + public IEnumerator GetEnumerator() + { + return _Rates.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public void SetRate(string exchangeName, CurrencyPair currencyPair, decimal value) + { + if(ByExchange.TryGetValue(exchangeName, out var rates)) + { + var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); + if (rate != null) + rate.Value = value; + } + } + public decimal? GetRate(string exchangeName, CurrencyPair currencyPair) + { + if (ByExchange.TryGetValue(exchangeName, out var rates)) + { + var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); + if (rate != null) + return rate.Value; + } + return null; + } + } + public class ExchangeRate + { + public string Exchange { get; set; } + public CurrencyPair CurrencyPair { get; set; } + public decimal? Value { get; set; } + + public override string ToString() + { + if (Value == null) + return $"{Exchange}({CurrencyPair})"; + return $"{Exchange}({CurrencyPair}) == {Value.Value.ToString(CultureInfo.InvariantCulture)}"; + } + } +} diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs new file mode 100644 index 000000000..a5964328c --- /dev/null +++ b/BTCPayServer/Rating/RateRules.cs @@ -0,0 +1,484 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace BTCPayServer.Rating +{ + public enum RateRulesErrors + { + Ok, + TooMuchNestedCalls, + InvalidCurrencyIdentifier, + NestedInvocation, + UnsupportedOperator, + MissingArgument, + DivideByZero, + PreprocessError, + RateUnavailable, + } + public class RateRules + { + class NormalizeCurrencyPairsRewritter : CSharpSyntaxRewriter + { + public List Errors = new List(); + + bool IsInvocation; + public override SyntaxNode VisitArgumentList(ArgumentListSyntax node) + { + IsInvocation = false; + var result = base.VisitArgumentList(node); + IsInvocation = true; + return result; + } + public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) + { + if (IsInvocation) + { + Errors.Add(RateRulesErrors.NestedInvocation); + return base.VisitInvocationExpression(node); + } + IsInvocation = true; + var result = base.VisitInvocationExpression(node); + IsInvocation = false; + return result; + } + public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) + { + if (IsInvocation) + { + return SyntaxFactory.IdentifierName(node.Identifier.ValueText.ToLowerInvariant()); + } + if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currencyPair)) + { + return SyntaxFactory.IdentifierName(currencyPair.ToString()); + } + else + { + Errors.Add(RateRulesErrors.InvalidCurrencyIdentifier); + return base.VisitIdentifierName(node); + } + } + } + class RuleList : CSharpSyntaxWalker + { + public Dictionary ExpressionsByPair = new Dictionary(); + public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) + { + if (node.Left is IdentifierNameSyntax id) + { + if (CurrencyPair.TryParse(id.Identifier.ValueText, out var currencyPair)) + { + ExpressionsByPair.TryAdd(currencyPair, node.Right); + } + } + } + public SyntaxNode GetSyntaxNode() + { + return SyntaxFactory.Block( + ExpressionsByPair.Select(e => + SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.IdentifierName(e.Key.ToString()), + e.Value) + )) + ); + } + } + + SyntaxNode root; + RuleList ruleList; + + RateRules(SyntaxNode root) + { + ruleList = new RuleList(); + // Remove every irrelevant statements + ruleList.Visit(root); + this.root = ruleList.GetSyntaxNode(); + } + public static bool TryParse(string str, out RateRules rules) + { + rules = null; + var expression = CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script)); + var rewriter = new NormalizeCurrencyPairsRewritter(); + // Rename BTC_usd to BTC_USD and verify structure + var root = rewriter.Visit(expression.GetRoot()); + if (rewriter.Errors.Count > 0) + return false; + rules = new RateRules(root); + return true; + } + + public RateRule GetRuleFor(CurrencyPair currencyPair, decimal globalMultiplier = 1.0m) + { + if (currencyPair.Left == "X" || currencyPair.Right == "X") + throw new ArgumentException(paramName: nameof(currencyPair), message: "Invalid X currency"); + var candidate = FindBestCandidate(currencyPair); + + if (globalMultiplier != decimal.One) + { + candidate = CreateExpression($"({candidate}) * {globalMultiplier.ToString(CultureInfo.InvariantCulture)}"); + } + return new RateRule(this, currencyPair, candidate); + } + + public ExpressionSyntax FindBestCandidate(CurrencyPair p) + { + var candidates = new List<(CurrencyPair Pair, int Prioriy, ExpressionSyntax Expression)>(); + foreach (var pair in new[] + { + (Pair: p, Priority: 0), + (Pair: new CurrencyPair(p.Left, "X"), Priority: 1), + (Pair: new CurrencyPair("X", p.Right), Priority: 1), + (Pair: new CurrencyPair("X", "X"), Priority: 2) + }) + { + if (ruleList.ExpressionsByPair.TryGetValue(pair.Pair, out ExpressionSyntax expression)) + { + candidates.Add((pair.Pair, pair.Priority, expression)); + } + } + if (candidates.Count == 0) + return CreateExpression($"ERR_NO_RULE_MATCH({p})"); + var best = candidates + .OrderBy(c => c.Prioriy) + .ThenBy(c => c.Expression.Span) + .First(); + + return best.Expression; + } + + internal static ExpressionSyntax CreateExpression(string str) + { + return (ExpressionSyntax)CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script)).GetRoot().ChildNodes().First().ChildNodes().First().ChildNodes().First(); + } + + public override string ToString() + { + return this.root.NormalizeWhitespace("", "\n") + .ToFullString() + .Replace("{\n", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("\n}", string.Empty, StringComparison.OrdinalIgnoreCase); + } + } + + public class RateRule + { + class ReplaceExchangeRateRewriter : CSharpSyntaxRewriter + { + public List Errors = new List(); + public ExchangeRates Rates; + public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) + { + var exchangeName = node.Expression.ToString(); + if (exchangeName.StartsWith("ERR_", StringComparison.OrdinalIgnoreCase)) + { + Errors.Add(RateRulesErrors.PreprocessError); + return base.VisitInvocationExpression(node); + } + + var currencyPair = node.ArgumentList.ChildNodes().FirstOrDefault()?.ToString(); + if (currencyPair == null || !CurrencyPair.TryParse(currencyPair, out var pair)) + { + Errors.Add(RateRulesErrors.InvalidCurrencyIdentifier); + return RateRules.CreateExpression($"ERR_INVALID_CURRENCY_PAIR({node.ToString()})"); + } + else + { + var rate = Rates.GetRate(exchangeName, pair); + if (rate == null) + { + Errors.Add(RateRulesErrors.RateUnavailable); + return RateRules.CreateExpression($"ERR_RATE_UNAVAILABLE({exchangeName}, {pair.ToString()})"); + } + else + { + var token = SyntaxFactory.ParseToken(rate.Value.ToString(CultureInfo.InvariantCulture)); + return SyntaxFactory.LiteralExpression(SyntaxKind.NumericLiteralExpression, token); + } + } + } + } + + class CalculateWalker : CSharpSyntaxWalker + { + public Stack Values = new Stack(); + public List Errors = new List(); + + public override void VisitPrefixUnaryExpression(PrefixUnaryExpressionSyntax node) + { + base.VisitPrefixUnaryExpression(node); + bool invalid = false; + switch (node.Kind()) + { + case SyntaxKind.UnaryMinusExpression: + case SyntaxKind.UnaryPlusExpression: + if (Values.Count < 1) + { + invalid = true; + Errors.Add(RateRulesErrors.MissingArgument); + } + break; + default: + invalid = true; + Errors.Add(RateRulesErrors.UnsupportedOperator); + break; + } + + if (invalid) + return; + + switch (node.Kind()) + { + case SyntaxKind.UnaryMinusExpression: + Values.Push(-Values.Pop()); + break; + case SyntaxKind.UnaryPlusExpression: + Values.Push(+Values.Pop()); + break; + default: + throw new NotSupportedException("Should never happen"); + } + } + + public override void VisitBinaryExpression(BinaryExpressionSyntax node) + { + base.VisitBinaryExpression(node); + + + bool invalid = false; + switch (node.Kind()) + { + case SyntaxKind.AddExpression: + case SyntaxKind.MultiplyExpression: + case SyntaxKind.DivideExpression: + case SyntaxKind.SubtractExpression: + if (Values.Count < 2) + { + invalid = true; + Errors.Add(RateRulesErrors.MissingArgument); + } + break; + } + + if (invalid) + return; + + var b = Values.Pop(); + var a = Values.Pop(); + + switch (node.Kind()) + { + case SyntaxKind.AddExpression: + Values.Push(a + b); + break; + case SyntaxKind.MultiplyExpression: + Values.Push(a * b); + break; + case SyntaxKind.DivideExpression: + if (b == decimal.Zero) + { + Errors.Add(RateRulesErrors.DivideByZero); + } + else + { + Values.Push(a / b); + } + break; + case SyntaxKind.SubtractExpression: + Values.Push(a - b); + break; + default: + throw new NotSupportedException("Should never happen"); + } + } + + public override void VisitLiteralExpression(LiteralExpressionSyntax node) + { + switch (node.Kind()) + { + case SyntaxKind.NumericLiteralExpression: + Values.Push(decimal.Parse(node.ToString(), CultureInfo.InvariantCulture)); + break; + } + } + } + + class HasBinaryOperations : CSharpSyntaxWalker + { + public bool Result = false; + public override void VisitBinaryExpression(BinaryExpressionSyntax node) + { + base.VisitBinaryExpression(node); + switch (node.Kind()) + { + case SyntaxKind.AddExpression: + case SyntaxKind.MultiplyExpression: + case SyntaxKind.DivideExpression: + case SyntaxKind.MinusToken: + Result = true; + break; + } + } + } + class FlattenExpressionRewriter : CSharpSyntaxRewriter + { + RateRules parent; + public FlattenExpressionRewriter(RateRules parent, CurrencyPair pair) + { + Context.Push(pair); + this.parent = parent; + } + + public ExchangeRates ExchangeRates = new ExchangeRates(); + public Stack Context { get; set; } = new Stack(); + bool IsInvocation; + public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) + { + IsInvocation = true; + _ExchangeName = node.Expression.ToString(); + var result = base.VisitInvocationExpression(node); + IsInvocation = false; + return result; + } + + string _ExchangeName = null; + + public List Errors = new List(); + const int MaxNestedCount = 6; + public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) + { + if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currentPair)) + { + var ctx = Context.Peek(); + + var replacedPair = new CurrencyPair(left: currentPair.Left == "X" ? ctx.Left : currentPair.Left, + right: currentPair.Right == "X" ? ctx.Right : currentPair.Right); + if (IsInvocation) // eg. replace bittrex(BTC_X) to bittrex(BTC_USD) + { + ExchangeRates.Add(new ExchangeRate() { CurrencyPair = replacedPair, Exchange = _ExchangeName }); + return SyntaxFactory.IdentifierName(replacedPair.ToString()); + } + else // eg. replace BTC_X to BTC_USD, then replace by the expression for BTC_USD + { + var bestCandidate = parent.FindBestCandidate(replacedPair); + if (Context.Count > MaxNestedCount) + { + Errors.Add(RateRulesErrors.TooMuchNestedCalls); + return RateRules.CreateExpression($"ERR_TOO_MUCH_NESTED_CALLS({replacedPair})"); + } + Context.Push(replacedPair); + var replaced = Visit(bestCandidate); + if (replaced is ExpressionSyntax expression) + { + var hasBinaryOps = new HasBinaryOperations(); + hasBinaryOps.Visit(expression); + if (hasBinaryOps.Result) + { + replaced = SyntaxFactory.ParenthesizedExpression(expression); + } + } + Context.Pop(); + if (Errors.Contains(RateRulesErrors.TooMuchNestedCalls)) + { + return RateRules.CreateExpression($"ERR_TOO_MUCH_NESTED_CALLS({replacedPair})"); + } + return replaced; + } + } + return base.VisitIdentifierName(node); + } + } + private SyntaxNode expression; + FlattenExpressionRewriter flatten; + + public RateRule(RateRules parent, CurrencyPair currencyPair, SyntaxNode candidate) + { + flatten = new FlattenExpressionRewriter(parent, currencyPair); + this.expression = flatten.Visit(candidate); + } + + public ExchangeRates ExchangeRates + { + get + { + return flatten.ExchangeRates; + } + } + + + public bool Reevaluate() + { + _Value = null; + _EvaluatedNode = null; + _Evaluated = null; + Errors.Clear(); + + var rewriter = new ReplaceExchangeRateRewriter(); + rewriter.Rates = ExchangeRates; + var result = rewriter.Visit(this.expression); + Errors.AddRange(rewriter.Errors); + _Evaluated = result.NormalizeWhitespace("", "\n").ToString(); + if (HasError) + return false; + + var calculate = new CalculateWalker(); + calculate.Visit(result); + if (calculate.Values.Count != 1 || calculate.Errors.Count != 0) + { + Errors.AddRange(calculate.Errors); + return false; + } + _Value = calculate.Values.Pop(); + _EvaluatedNode = result; + return true; + } + + + private readonly HashSet _Errors = new HashSet(); + public HashSet Errors + { + get + { + return _Errors; + } + } + + SyntaxNode _EvaluatedNode; + string _Evaluated; + public bool HasError + { + get + { + return _Errors.Count != 0; + } + } + + public string ToString(bool evaluated) + { + if (!evaluated) + return ToString(); + if (_Evaluated == null) + return "Call Evaluate() first"; + return _Evaluated; + } + + public override string ToString() + { + return expression.NormalizeWhitespace("", "\n").ToString(); + } + + decimal? _Value; + public decimal? Value + { + get + { + return _Value; + } + } + } +} From e57a488371c9311b091bacf2e67e2cabbca25ba0 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Thu, 3 May 2018 03:32:42 +0900 Subject: [PATCH 022/119] Refactor the RateProvider --- BTCPayServer.Tests/BTCPayServerTester.cs | 34 +++- BTCPayServer.Tests/Mocks/MockRateProvider.cs | 18 ++ BTCPayServer.Tests/RateRulesTest.cs | 6 +- BTCPayServer.Tests/UnitTest1.cs | 78 ++++----- BTCPayServer/BTCPayNetwork.cs | 2 +- BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs | 4 - .../BTCPayNetworkProvider.Dogecoin.cs | 2 +- .../BTCPayNetworkProvider.Litecoin.cs | 1 - .../Controllers/InvoiceController.UI.cs | 2 +- BTCPayServer/Controllers/InvoiceController.cs | 102 +++++++----- BTCPayServer/Controllers/RateController.cs | 101 ++++++++--- BTCPayServer/Controllers/ServerController.cs | 6 +- BTCPayServer/Controllers/StoresController.cs | 14 +- BTCPayServer/Data/StoreData.cs | 60 +++++-- BTCPayServer/Extensions.cs | 6 - .../HostedServices/RatesHostedService.cs | 20 ++- BTCPayServer/Hosting/BTCPayServerServices.cs | 3 +- .../Models/StoreViewModels/StoreViewModel.cs | 9 +- BTCPayServer/Rating/ExchangeRates.cs | 34 +++- BTCPayServer/Rating/RateRules.cs | 8 +- .../Services/Invoices/InvoiceEntity.cs | 37 ++--- .../Rates/BTCPayRateProviderFactory.cs | 157 ++++++++++++++---- .../Services/Rates/BitpayRateProvider.cs | 25 +-- .../Services/Rates/CachedRateProvider.cs | 23 +-- .../Services/Rates/CoinAverageRateProvider.cs | 119 ++++++------- .../Services/Rates/CoinAverageSettings.cs | 35 +++- .../Services/Rates/FallbackRateProvider.cs | 39 +---- BTCPayServer/Services/Rates/IRateProvider.cs | 24 +-- .../Services/Rates/IRateProviderFactory.cs | 40 ----- .../Services/Rates/MockRateProvider.cs | 63 ------- .../Services/Rates/QuadrigacxRateProvider.cs | 41 ++--- .../Services/Rates/RateProviderDescription.cs | 12 -- .../Rates/RateUnavailableException.cs | 21 --- .../Services/Rates/TweakRateProvider.cs | 53 ------ 34 files changed, 583 insertions(+), 616 deletions(-) create mode 100644 BTCPayServer.Tests/Mocks/MockRateProvider.cs delete mode 100644 BTCPayServer/Services/Rates/IRateProviderFactory.cs delete mode 100644 BTCPayServer/Services/Rates/MockRateProvider.cs delete mode 100644 BTCPayServer/Services/Rates/RateProviderDescription.cs delete mode 100644 BTCPayServer/Services/Rates/RateUnavailableException.cs delete mode 100644 BTCPayServer/Services/Rates/TweakRateProvider.cs diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 77c5a9be1..9feaab1f5 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -2,6 +2,7 @@ using BTCPayServer.Hosting; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Rating; using BTCPayServer.Security; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; @@ -106,15 +107,6 @@ namespace BTCPayServer.Tests .UseConfiguration(conf) .ConfigureServices(s => { - if (MockRates) - { - var mockRates = new MockRateProviderFactory(); - var btc = new MockRateProvider("BTC", new Rate("USD", 5000m), new Rate("CAD", 4500m)); - var ltc = new MockRateProvider("LTC", new Rate("USD", 500m)); - mockRates.AddMock(btc); - mockRates.AddMock(ltc); - s.AddSingleton(mockRates); - } s.AddLogging(l => { l.SetMinimumLevel(LogLevel.Information) @@ -128,6 +120,30 @@ namespace BTCPayServer.Tests .Build(); _Host.Start(); InvoiceRepository = (InvoiceRepository)_Host.Services.GetService(typeof(InvoiceRepository)); + + var rateProvider = (BTCPayRateProviderFactory)_Host.Services.GetService(typeof(BTCPayRateProviderFactory)); + rateProvider.DirectProviders.Clear(); + + var coinAverageMock = new MockRateProvider(); + coinAverageMock.ExchangeRates.Add(new Rating.ExchangeRate() + { + Exchange = "coinaverage", + CurrencyPair = CurrencyPair.Parse("BTC_USD"), + Value = 5000m + }); + coinAverageMock.ExchangeRates.Add(new Rating.ExchangeRate() + { + Exchange = "coinaverage", + CurrencyPair = CurrencyPair.Parse("BTC_CAD"), + Value = 4500m + }); + coinAverageMock.ExchangeRates.Add(new Rating.ExchangeRate() + { + Exchange = "coinaverage", + CurrencyPair = CurrencyPair.Parse("LTC_USD"), + Value = 500m + }); + rateProvider.DirectProviders.Add("coinaverage", coinAverageMock); } public string HostName diff --git a/BTCPayServer.Tests/Mocks/MockRateProvider.cs b/BTCPayServer.Tests/Mocks/MockRateProvider.cs new file mode 100644 index 000000000..bd151c8ad --- /dev/null +++ b/BTCPayServer.Tests/Mocks/MockRateProvider.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Rating; +using BTCPayServer.Services.Rates; + +namespace BTCPayServer.Tests.Mocks +{ + public class MockRateProvider : IRateProvider + { + public ExchangeRates ExchangeRates { get; set; } = new ExchangeRates(); + public Task GetRatesAsync() + { + return Task.FromResult(ExchangeRates); + } + } +} diff --git a/BTCPayServer.Tests/RateRulesTest.cs b/BTCPayServer.Tests/RateRulesTest.cs index 4236571d6..abd2c5201 100644 --- a/BTCPayServer.Tests/RateRulesTest.cs +++ b/BTCPayServer.Tests/RateRulesTest.cs @@ -41,7 +41,8 @@ namespace BTCPayServer.Tests { Assert.Equal(test.Expected, rules.GetRuleFor(CurrencyPair.Parse(test.Pair)).ToString()); } - Assert.Equal("(bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1) * 2.32", rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD"), 2.32m).ToString()); + rules.GlobalMultiplier = 2.32m; + Assert.Equal("(bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1) * 2.32", rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD")).ToString()); //////////////// // Check errors conditions @@ -107,7 +108,8 @@ namespace BTCPayServer.Tests builder.AppendLine("BTC_USD = -3 + coinbase(BTC_CAD) + 50 - 5"); builder.AppendLine("DOGE_BTC = 2000"); Assert.True(RateRules.TryParse(builder.ToString(), out rules)); - rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD"), 1.1m); + rules.GlobalMultiplier = 1.1m; + rule2 = rules.GetRuleFor(CurrencyPair.Parse("DOGE_USD")); Assert.Equal("(2000 * (-3 + coinbase(BTC_CAD) + 50 - 5)) * 1.1", rule2.ToString()); rule2.ExchangeRates.SetRate("coinbase", CurrencyPair.Parse("BTC_CAD"), 1000m); Assert.True(rule2.Reevaluate()); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index b068f0cb9..4c64aa2df 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -35,6 +35,7 @@ using BTCPayServer.Services.Apps; using BTCPayServer.Services.Stores; using System.Net.Http; using System.Text; +using BTCPayServer.Rating; namespace BTCPayServer.Tests { @@ -108,22 +109,11 @@ namespace BTCPayServer.Tests { var entity = new InvoiceEntity(); #pragma warning disable CS0618 - entity.TxFee = Money.Coins(0.1m); - entity.Rate = 5000; - entity.Payments = new System.Collections.Generic.List(); + entity.SetPaymentMethod(new PaymentMethod() { CryptoCode = "BTC", Rate = 5000, TxFee = Money.Coins(0.1m) }); entity.ProductInformation = new ProductInformation() { Price = 5000 }; - // Some check that handling legacy stuff does not break things - var paymentMethod = entity.GetPaymentMethods(null, true).TryGet("BTC", PaymentTypes.BTCLike); - paymentMethod.Calculate(); - Assert.NotNull(paymentMethod); - Assert.Null(entity.GetPaymentMethods(null, false).TryGet("BTC", PaymentTypes.BTCLike)); - entity.SetPaymentMethod(new PaymentMethod() { ParentEntity = entity, Rate = entity.Rate, CryptoCode = "BTC", TxFee = entity.TxFee }); - Assert.NotNull(entity.GetPaymentMethods(null, false).TryGet("BTC", PaymentTypes.BTCLike)); - Assert.NotNull(entity.GetPaymentMethods(null, true).TryGet("BTC", PaymentTypes.BTCLike)); - //////////////////// - + var paymentMethod = entity.GetPaymentMethods(null).TryGet("BTC", PaymentTypes.BTCLike); var accounting = paymentMethod.Calculate(); Assert.Equal(Money.Coins(1.1m), accounting.Due); Assert.Equal(Money.Coins(1.1m), accounting.TotalDue); @@ -1128,8 +1118,6 @@ namespace BTCPayServer.Tests var txFee = Money.Zero; - var rate = user.BitPay.GetRates(); - var cashCow = tester.ExplorerNode; var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network); @@ -1233,40 +1221,52 @@ namespace BTCPayServer.Tests [Fact] public void CheckQuadrigacxRateProvider() { - var quadri = new QuadrigacxRateProvider("BTC"); + var quadri = new QuadrigacxRateProvider(); var rates = quadri.GetRatesAsync().GetAwaiter().GetResult(); Assert.NotEmpty(rates); Assert.NotEqual(0.0m, rates.First().Value); - Assert.NotEqual(0.0m, quadri.GetRateAsync("CAD").GetAwaiter().GetResult()); - Assert.NotEqual(0.0m, quadri.GetRateAsync("USD").GetAwaiter().GetResult()); - Assert.Throws(() => quadri.GetRateAsync("IOEW").GetAwaiter().GetResult()); - - quadri = new QuadrigacxRateProvider("LTC"); - rates = quadri.GetRatesAsync().GetAwaiter().GetResult(); - Assert.NotEmpty(rates); - Assert.NotEqual(0.0m, rates.First().Value); - Assert.NotEqual(0.0m, quadri.GetRateAsync("CAD").GetAwaiter().GetResult()); - Assert.Throws(() => quadri.GetRateAsync("IOEW").GetAwaiter().GetResult()); - Assert.Throws(() => quadri.GetRateAsync("USD").GetAwaiter().GetResult()); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_CAD")).Value); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("BTC_USD")).Value); + Assert.NotEqual(0.0m, rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_CAD")).Value); + Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD"))); } [Fact] public void CheckRatesProvider() { - var coinAverage = new CoinAverageRateProvider("BTC"); - 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 provider = new BTCPayNetworkProvider(NetworkType.Mainnet); + var coinAverage = new CoinAverageRateProvider(provider); + var rates = coinAverage.GetRatesAsync().GetAwaiter().GetResult(); + Assert.NotNull(rates.GetRate("coinaverage", new CurrencyPair("BTC", "JPY"))); + var ratesBitpay = new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))).GetRatesAsync().GetAwaiter().GetResult(); + Assert.NotNull(ratesBitpay.GetRate("bitpay", new CurrencyPair("BTC", "JPY"))); - var cached = new CachedRateProvider("BTC", 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(); + RateRules.TryParse("X_X = coinaverage(X_X);", out var rateRules); + + var factory = new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + factory.DirectProviders.Clear(); + factory.CacheSpan = TimeSpan.FromSeconds(10); + + var fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.False(fetchedRate.Cached); + fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.True(fetchedRate.Cached); + + Thread.Sleep(11000); + fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.False(fetchedRate.Cached); + fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.True(fetchedRate.Cached); + // Should cache at exchange level so this should hit the cache + var fetchedRate2 = factory.FetchRate(CurrencyPair.Parse("LTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.True(fetchedRate.Cached); + Assert.NotEqual(fetchedRate.Value.Value, fetchedRate2.Value.Value); + + // Should cache at exchange level this should not hit the cache as it is different exchange + RateRules.TryParse("X_X = bittrex(X_X);", out rateRules); + fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); + Assert.False(fetchedRate.Cached); - 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/BTCPayNetwork.cs b/BTCPayServer/BTCPayNetwork.cs index 540a090c8..bc0be4841 100644 --- a/BTCPayServer/BTCPayNetwork.cs +++ b/BTCPayServer/BTCPayNetwork.cs @@ -44,7 +44,6 @@ namespace BTCPayServer public string CryptoCode { get; internal set; } public string BlockExplorerLink { get; internal set; } public string UriScheme { get; internal set; } - public RateProviderDescription DefaultRateProvider { get; set; } [Obsolete("Should not be needed")] public bool IsBTC @@ -62,6 +61,7 @@ namespace BTCPayServer public BTCPayDefaultSettings DefaultSettings { get; set; } public KeyPath CoinType { get; internal set; } public int MaxTrackedConfirmation { get; internal set; } = 6; + public string[] DefaultRateRules { get; internal set; } = Array.Empty(); public override string ToString() { diff --git a/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs b/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs index ca803f0a4..911edff2c 100644 --- a/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs +++ b/BTCPayServer/BTCPayNetworkProvider.Bitcoin.cs @@ -14,9 +14,6 @@ namespace BTCPayServer public void InitBitcoin() { var nbxplorerNetwork = NBXplorerNetworkProvider.GetFromCryptoCode("BTC"); - var coinaverage = new CoinAverageRateProviderDescription("BTC"); - var bitpay = new BitpayRateProviderDescription(); - var btcRate = new FallbackRateProviderDescription(new RateProviderDescription[] { coinaverage, bitpay }); Add(new BTCPayNetwork() { CryptoCode = nbxplorerNetwork.CryptoCode, @@ -24,7 +21,6 @@ namespace BTCPayServer NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork, NBXplorerNetwork = nbxplorerNetwork, UriScheme = "bitcoin", - DefaultRateProvider = btcRate, CryptoImagePath = "imlegacy/bitcoin-symbol.svg", LightningImagePath = "imlegacy/btc-lightning.svg", DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), diff --git a/BTCPayServer/BTCPayNetworkProvider.Dogecoin.cs b/BTCPayServer/BTCPayNetworkProvider.Dogecoin.cs index 18091ad91..9fbff33a9 100644 --- a/BTCPayServer/BTCPayNetworkProvider.Dogecoin.cs +++ b/BTCPayServer/BTCPayNetworkProvider.Dogecoin.cs @@ -20,7 +20,7 @@ namespace BTCPayServer NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork, NBXplorerNetwork = nbxplorerNetwork, UriScheme = "dogecoin", - DefaultRateProvider = new CoinAverageRateProviderDescription("DOGE"), + DefaultRateRules = new[] { "DOGE_X = bittrex(DOGE_BTC) * BTC_X" }, CryptoImagePath = "imlegacy/dogecoin.png", DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("3'") : new KeyPath("1'") diff --git a/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs b/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs index 98550f9e0..42b3de248 100644 --- a/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs +++ b/BTCPayServer/BTCPayNetworkProvider.Litecoin.cs @@ -20,7 +20,6 @@ namespace BTCPayServer NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork, NBXplorerNetwork = nbxplorerNetwork, UriScheme = "litecoin", - DefaultRateProvider = new CoinAverageRateProviderDescription("LTC"), CryptoImagePath = "imlegacy/litecoin-symbol.svg", LightningImagePath = "imlegacy/ltc-lightning.svg", DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 2b95e18c3..ab2c4430b 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -460,7 +460,7 @@ namespace BTCPayServer.Controllers StatusMessage = $"Invoice {result.Data.Id} just created!"; return RedirectToAction(nameof(ListInvoices)); } - catch (RateUnavailableException) + catch (BitpayHttpException) { ModelState.TryAddModelError(nameof(model.Currency), "Unsupported currency"); return View(model); diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index 03a89cc84..96299651b 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -40,13 +40,14 @@ using NBXplorer.DerivationStrategy; using NBXplorer; using BTCPayServer.HostedServices; using BTCPayServer.Payments; +using BTCPayServer.Rating; namespace BTCPayServer.Controllers { public partial class InvoiceController : Controller { InvoiceRepository _InvoiceRepository; - IRateProviderFactory _RateProviders; + BTCPayRateProviderFactory _RateProvider; StoreRepository _StoreRepository; UserManager _UserManager; private CurrencyNameTable _CurrencyNameTable; @@ -59,7 +60,7 @@ namespace BTCPayServer.Controllers InvoiceRepository invoiceRepository, CurrencyNameTable currencyNameTable, UserManager userManager, - IRateProviderFactory rateProviders, + BTCPayRateProviderFactory rateProvider, StoreRepository storeRepository, EventAggregator eventAggregator, BTCPayWalletProvider walletProvider, @@ -69,7 +70,7 @@ namespace BTCPayServer.Controllers _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); _StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository)); _InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository)); - _RateProviders = rateProviders ?? throw new ArgumentNullException(nameof(rateProviders)); + _RateProvider = rateProvider ?? throw new ArgumentNullException(nameof(rateProvider)); _UserManager = userManager; _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; @@ -111,6 +112,23 @@ namespace BTCPayServer.Controllers entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy); + HashSet currencyPairsToFetch = new HashSet(); + var rules = storeBlob.GetRateRules(_NetworkProvider); + + foreach (var network in store.GetSupportedPaymentMethods(_NetworkProvider) + .Select(c => _NetworkProvider.GetNetwork(c.PaymentId.CryptoCode)) + .Where(c => c != null)) + { + currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, invoice.Currency)); + if (storeBlob.LightningMaxValue != null) + currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, storeBlob.LightningMaxValue.Currency)); + if (storeBlob.OnChainMinValue != null) + currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, storeBlob.OnChainMinValue.Currency)); + } + + var rateRules = storeBlob.GetRateRules(_NetworkProvider); + var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules); + var supportedPaymentMethods = store.GetSupportedPaymentMethods(_NetworkProvider) .Select(c => (Handler: (IPaymentMethodHandler)_ServiceProvider.GetService(typeof(IPaymentMethodHandler<>).MakeGenericType(c.GetType())), @@ -119,19 +137,45 @@ namespace BTCPayServer.Controllers .Where(c => c.Network != null) .Select(o => (SupportedPaymentMethod: o.SupportedPaymentMethod, - PaymentMethod: CreatePaymentMethodAsync(o.Handler, o.SupportedPaymentMethod, o.Network, entity, store))) + PaymentMethod: CreatePaymentMethodAsync(fetchingByCurrencyPair, o.Handler, o.SupportedPaymentMethod, o.Network, entity, store))) .ToList(); List paymentMethodErrors = new List(); List supported = new List(); var paymentMethods = new PaymentMethodDictionary(); + + foreach(var pair in fetchingByCurrencyPair) + { + var rateResult = await pair.Value; + bool hasError = false; + if(rateResult.Errors.Count != 0) + { + var allRateRuleErrors = string.Join(", ", rateResult.Errors.ToArray()); + paymentMethodErrors.Add($"{pair.Key}: Rate rule error ({allRateRuleErrors})"); + hasError = true; + } + if(rateResult.ExchangeExceptions.Count != 0) + { + foreach(var ex in rateResult.ExchangeExceptions) + { + paymentMethodErrors.Add($"{pair.Key}: Exception reaching exchange {ex.ExchangeName} ({ex.Exception.Message})"); + } + hasError = true; + } + if(hasError) + { + paymentMethodErrors.Add($"{pair.Key}: The rule is {rateResult.Rule}"); + paymentMethodErrors.Add($"{pair.Key}: Evaluated rule is {rateResult.EvaluatedRule}"); + } + } + foreach (var o in supportedPaymentMethods) { try { var paymentMethod = await o.PaymentMethod; if (paymentMethod == null) - throw new PaymentMethodUnavailableException("Payment method unavailable (The handler returned null)"); + throw new PaymentMethodUnavailableException("Payment method unavailable"); supported.Add(o.SupportedPaymentMethod); paymentMethods.Add(paymentMethod); } @@ -158,23 +202,6 @@ namespace BTCPayServer.Controllers entity.SetSupportedPaymentMethods(supported); entity.SetPaymentMethods(paymentMethods); -#pragma warning disable CS0618 - // Legacy Bitpay clients expect information for BTC information, even if the store do not support it - var legacyBTCisSet = paymentMethods.Any(p => p.GetId().IsBTCOnChain); - if (!legacyBTCisSet && _NetworkProvider.BTC != null) - { - var btc = _NetworkProvider.BTC; - var feeProvider = ((IFeeProviderFactory)_ServiceProvider.GetService(typeof(IFeeProviderFactory))).CreateFeeProvider(btc); - var rateProvider = _RateProviders.GetRateProvider(btc, storeBlob.GetRateRules()); - if (feeProvider != null && rateProvider != null) - { - var gettingFee = feeProvider.GetFeeRateAsync(); - var gettingRate = rateProvider.GetRateAsync(invoice.Currency); - entity.TxFee = GetTxFee(storeBlob, await gettingFee); - entity.Rate = await gettingRate; - } -#pragma warning restore CS0618 - } entity.PosData = invoice.PosData; entity = await _InvoiceRepository.CreateInvoiceAsync(store.Id, entity, paymentMethodErrors, _NetworkProvider); @@ -183,15 +210,17 @@ namespace BTCPayServer.Controllers return new DataWrapper(resp) { Facade = "pos/invoice" }; } - private async Task CreatePaymentMethodAsync(IPaymentMethodHandler handler, ISupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network, InvoiceEntity entity, StoreData store) + private async Task CreatePaymentMethodAsync(Dictionary> fetchingByCurrencyPair, IPaymentMethodHandler handler, ISupportedPaymentMethod supportedPaymentMethod, BTCPayNetwork network, InvoiceEntity entity, StoreData store) { var storeBlob = store.GetStoreBlob(); - var rate = await _RateProviders.GetRateProvider(network, storeBlob.GetRateRules()).GetRateAsync(entity.ProductInformation.Currency); + var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.ProductInformation.Currency)]; + if (rate.Value == null) + return null; PaymentMethod paymentMethod = new PaymentMethod(); paymentMethod.ParentEntity = entity; paymentMethod.Network = network; paymentMethod.SetId(supportedPaymentMethod.PaymentId); - paymentMethod.Rate = rate; + paymentMethod.Rate = rate.Value.Value; var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network); if (storeBlob.NetworkFeeDisabled) paymentDetails.SetNoTxFee(); @@ -217,16 +246,14 @@ namespace BTCPayServer.Controllers if (compare != null) { - var limitValueRate = 0.0m; - if (limitValue.Currency == entity.ProductInformation.Currency) - limitValueRate = paymentMethod.Rate; - else - limitValueRate = await _RateProviders.GetRateProvider(network, storeBlob.GetRateRules()).GetRateAsync(limitValue.Currency); - - var limitValueCrypto = Money.Coins(limitValue.Value / limitValueRate); - if (compare(paymentMethod.Calculate().Due, limitValueCrypto)) + var limitValueRate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, limitValue.Currency)]; + if (limitValueRate.Value.HasValue) { - throw new PaymentMethodUnavailableException(errorMessage); + var limitValueCrypto = Money.Coins(limitValue.Value / limitValueRate.Value.Value); + if (compare(paymentMethod.Calculate().Due, limitValueCrypto)) + { + throw new PaymentMethodUnavailableException(errorMessage); + } } } /////////////// @@ -243,13 +270,6 @@ namespace BTCPayServer.Controllers return paymentMethod; } -#pragma warning disable CS0618 - private static Money GetTxFee(StoreBlob storeBlob, FeeRate feeRate) - { - return storeBlob.NetworkFeeDisabled ? Money.Zero : feeRate.GetFee(100); - } -#pragma warning restore CS0618 - private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy) { if (transactionSpeed == null) diff --git a/BTCPayServer/Controllers/RateController.cs b/BTCPayServer/Controllers/RateController.cs index 63336ed0b..35a000497 100644 --- a/BTCPayServer/Controllers/RateController.cs +++ b/BTCPayServer/Controllers/RateController.cs @@ -8,17 +8,19 @@ using System.Threading.Tasks; using BTCPayServer.Filters; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; +using BTCPayServer.Rating; +using Newtonsoft.Json; namespace BTCPayServer.Controllers { public class RateController : Controller { - IRateProviderFactory _RateProviderFactory; + BTCPayRateProviderFactory _RateProviderFactory; BTCPayNetworkProvider _NetworkProvider; CurrencyNameTable _CurrencyNameTable; StoreRepository _StoreRepo; public RateController( - IRateProviderFactory rateProviderFactory, + BTCPayRateProviderFactory rateProviderFactory, BTCPayNetworkProvider networkProvider, StoreRepository storeRepo, CurrencyNameTable currencyNameTable) @@ -32,45 +34,90 @@ namespace BTCPayServer.Controllers [Route("rates")] [HttpGet] [BitpayAPIConstraint] - public async Task GetRates(string cryptoCode = null, string storeId = null) + public async Task GetRates(string currencyPairs, string storeId) { - var result = await GetRates2(cryptoCode, storeId); + var result = await GetRates2(currencyPairs, storeId); var rates = (result as JsonResult)?.Value as NBitpayClient.Rate[]; - if(rates == null) + if (rates == null) return result; - return Json(new DataWrapper(rates)); + return Json(new DataWrapper(rates)); } [Route("api/rates")] [HttpGet] - public async Task GetRates2(string cryptoCode = null, string storeId = null) + public async Task GetRates2(string currencyPairs, string storeId) { - cryptoCode = cryptoCode ?? "BTC"; - var network = _NetworkProvider.GetNetwork(cryptoCode); - if (network == null) - return NotFound(); - - RateRules rules = null; - if (storeId != null) + if(storeId == null || currencyPairs == null) { - var store = await _StoreRepo.FindStore(storeId); - if (store == null) - return NotFound(); - rules = store.GetStoreBlob().GetRateRules(); + var result = Json(new BitpayErrorsModel() { Error = "You need to specify storeId (in your store settings) and currencyPairs (eg. BTC_USD,LTC_CAD)" }); + result.StatusCode = 400; + return result; } - var rateProvider = _RateProviderFactory.GetRateProvider(network, rules); - if (rateProvider == null) - return NotFound(); + + var store = await _StoreRepo.FindStore(storeId); + if (store == null) + { + var result = Json(new BitpayErrorsModel() { Error = "Store not found" }); + result.StatusCode = 404; + return result; + } + var rules = store.GetStoreBlob().GetRateRules(_NetworkProvider); - var allRates = (await rateProvider.GetRatesAsync()); - return Json(allRates.Select(r => - new NBitpayClient.Rate() + HashSet pairs = new HashSet(); + foreach(var currency in currencyPairs.Split(',')) + { + if(!CurrencyPair.TryParse(currency, out var pair)) + { + var result = Json(new BitpayErrorsModel() { Error = $"Currency pair {currency} uncorrectly formatted" }); + result.StatusCode = 400; + return result; + } + pairs.Add(pair); + } + + var fetching = _RateProviderFactory.FetchRates(pairs, rules); + await Task.WhenAll(fetching.Select(f => f.Value).ToArray()); + return Json(pairs + .Select(r => (Pair: r, Value: fetching[r].GetAwaiter().GetResult().Value)) + .Where(r => r.Value.HasValue) + .Select(r => + new Rate() { - Code = r.Currency, - Name = _CurrencyNameTable.GetCurrencyData(r.Currency)?.Name, - Value = r.Value + CryptoCode = r.Pair.Left, + Code = r.Pair.Right, + Name = _CurrencyNameTable.GetCurrencyData(r.Pair.Right)?.Name, + Value = r.Value.Value }).Where(n => n.Name != null).ToArray()); } + + public class Rate + { + + [JsonProperty(PropertyName = "name")] + public string Name + { + get; + set; + } + [JsonProperty(PropertyName = "cryptoCode")] + public string CryptoCode + { + get; + set; + } + [JsonProperty(PropertyName = "code")] + public string Code + { + get; + set; + } + [JsonProperty(PropertyName = "rate")] + public decimal Value + { + get; + set; + } + } } } diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index 71bdb98d6..8190ae220 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -24,10 +24,10 @@ namespace BTCPayServer.Controllers { private UserManager _UserManager; SettingsRepository _SettingsRepository; - private IRateProviderFactory _RateProviderFactory; + private BTCPayRateProviderFactory _RateProviderFactory; public ServerController(UserManager userManager, - IRateProviderFactory rateProviderFactory, + BTCPayRateProviderFactory rateProviderFactory, SettingsRepository settingsRepository) { _UserManager = userManager; @@ -99,7 +99,7 @@ namespace BTCPayServer.Controllers }; if (!withAuth || settings.GetCoinAverageSignature() != null) { - return new CoinAverageRateProvider("BTC") + return new CoinAverageRateProvider() { Authenticator = settings }; } return null; diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index b2758e5fa..a8f3ae79d 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -276,7 +276,7 @@ namespace BTCPayServer.Controllers var storeBlob = store.GetStoreBlob(); var vm = new StoreViewModel(); - vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange.IsCoinAverage() ? "coinaverage" : storeBlob.PreferredExchange); + vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName); vm.Id = store.Id; vm.StoreName = store.StoreName; vm.StoreWebsite = store.StoreWebsite; @@ -370,7 +370,7 @@ namespace BTCPayServer.Controllers needUpdate = true; } - if (!blob.PreferredExchange.IsCoinAverage() && newExchange) + if (newExchange) { if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase)) @@ -392,12 +392,12 @@ namespace BTCPayServer.Controllers }); } - private (String DisplayName, String Name)[] GetSupportedExchanges() + private CoinAverageExchange[] GetSupportedExchanges() { - return new[] { ("Coin Average", "coinaverage") } - .Concat(_CoinAverage.AvailableExchanges) - .OrderBy(s => s.Item1, StringComparer.OrdinalIgnoreCase) - .ToArray(); + return _CoinAverage.AvailableExchanges + .Select(c => c.Value) + .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) + .ToArray(); } private DerivationStrategy ParseDerivationStrategy(string derivationScheme, Script hint, BTCPayNetwork network) diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 1446e5ad6..2d3c79042 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -18,6 +18,7 @@ using System.ComponentModel.DataAnnotations; using BTCPayServer.Services; using System.Security.Claims; using BTCPayServer.Security; +using BTCPayServer.Rating; namespace BTCPayServer.Data { @@ -207,7 +208,10 @@ namespace BTCPayServer.Data public StoreBlob GetStoreBlob() { - return StoreBlob == null ? new StoreBlob() : new Serializer(Dummy).ToObject(Encoding.UTF8.GetString(StoreBlob)); + var result = StoreBlob == null ? new StoreBlob() : new Serializer(Dummy).ToObject(Encoding.UTF8.GetString(StoreBlob)); + if (result.PreferredExchange == null) + result.PreferredExchange = CoinAverageRateProvider.CoinAverageName; + return result; } public bool SetStoreBlob(StoreBlob storeBlob) @@ -221,9 +225,9 @@ namespace BTCPayServer.Data } } - public class RateRule + public class RateRule_Obsolete { - public RateRule() + public RateRule_Obsolete() { RuleName = "Multiplier"; } @@ -275,8 +279,8 @@ namespace BTCPayServer.Data public void SetRateMultiplier(double rate) { - RateRules = new List(); - RateRules.Add(new RateRule() { Multiplier = rate }); + RateRules = new List(); + RateRules.Add(new RateRule_Obsolete() { Multiplier = rate }); } public decimal GetRateMultiplier() { @@ -290,7 +294,7 @@ namespace BTCPayServer.Data return rate; } - public List RateRules { get; set; } = new List(); + public List RateRules { get; set; } = new List(); public string PreferredExchange { get; set; } [JsonConverter(typeof(CurrencyValueJsonConverter))] @@ -303,6 +307,10 @@ namespace BTCPayServer.Data [JsonConverter(typeof(UriJsonConverter))] public Uri CustomCSS { get; set; } + public bool RateScripting { get; set; } + + public string RateScript { get; set; } + string _LightningDescriptionTemplate; public string LightningDescriptionTemplate @@ -317,12 +325,44 @@ namespace BTCPayServer.Data } } - public RateRules GetRateRules() + public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider) { - return new RateRules(RateRules) + if (!RateScripting || + string.IsNullOrEmpty(RateScript) || + !BTCPayServer.Rating.RateRules.TryParse(RateScript, out var rules)) { - PreferredExchange = PreferredExchange - }; + return GetDefaultRateRules(networkProvider); + } + else + { + rules.GlobalMultiplier = GetRateMultiplier(); + return rules; + } + } + + public RateRules GetDefaultRateRules(BTCPayNetworkProvider networkProvider) + { + StringBuilder builder = new StringBuilder(); + foreach (var network in networkProvider.GetAll()) + { + if (network.DefaultRateRules.Length != 0) + { + builder.AppendLine($"// Default rate rules for {network.CryptoCode}"); + foreach (var line in network.DefaultRateRules) + { + builder.AppendLine(line); + } + builder.AppendLine($"////////"); + builder.AppendLine(); + } + } + + var preferredExchange = string.IsNullOrEmpty(PreferredExchange) ? "coinaverage" : PreferredExchange; + builder.AppendLine($"X_X = {preferredExchange}(X_X);"); + + BTCPayServer.Rating.RateRules.TryParse(builder.ToString(), out var rules); + rules.GlobalMultiplier = GetRateMultiplier(); + return rules; } } } diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 7c2bf6237..7bda961d9 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -104,12 +104,6 @@ namespace BTCPayServer return activeProvider != "Microsoft.EntityFrameworkCore.Sqlite"; } - public static bool IsCoinAverage(this string exchangeName) - { - string[] coinAverages = new[] { "coinaverage", "bitcoinaverage" }; - return String.IsNullOrWhiteSpace(exchangeName) ? true : coinAverages.Contains(exchangeName, StringComparer.OrdinalIgnoreCase) ? true : false; - } - public static async Task> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken)) { hashes = hashes.Distinct().ToArray(); diff --git a/BTCPayServer/HostedServices/RatesHostedService.cs b/BTCPayServer/HostedServices/RatesHostedService.cs index dcb0b404d..77d4dde18 100644 --- a/BTCPayServer/HostedServices/RatesHostedService.cs +++ b/BTCPayServer/HostedServices/RatesHostedService.cs @@ -17,15 +17,15 @@ namespace BTCPayServer.HostedServices public class RatesHostedService : BaseAsyncService { private SettingsRepository _SettingsRepository; - private IRateProviderFactory _RateProviderFactory; private CoinAverageSettings _coinAverageSettings; + BTCPayRateProviderFactory _RateProviderFactory; public RatesHostedService(SettingsRepository repo, - CoinAverageSettings coinAverageSettings, - IRateProviderFactory rateProviderFactory) + BTCPayRateProviderFactory rateProviderFactory, + CoinAverageSettings coinAverageSettings) { this._SettingsRepository = repo; - _RateProviderFactory = rateProviderFactory; _coinAverageSettings = coinAverageSettings; + _RateProviderFactory = rateProviderFactory; } internal override Task[] InitializeTasks() @@ -40,11 +40,15 @@ namespace BTCPayServer.HostedServices async Task RefreshCoinAverageSupportedExchanges() { await new SynchronizationContextRemover(); - var tickers = await new CoinAverageRateProvider("BTC") { Authenticator = _coinAverageSettings }.GetExchangeTickersAsync(); - _coinAverageSettings.AvailableExchanges = tickers + var tickers = await new CoinAverageRateProvider() { Authenticator = _coinAverageSettings }.GetExchangeTickersAsync(); + var exchanges = new CoinAverageExchanges(); + foreach(var item in tickers .Exchanges - .Select(c => (c.DisplayName, c.Name)) - .ToArray(); + .Select(c => new CoinAverageExchange(c.Name, c.DisplayName))) + { + exchanges.Add(item); + } + _coinAverageSettings.AvailableExchanges = exchanges; await Task.Delay(TimeSpan.FromHours(5), Cancellation); } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 2c295ab28..aa3189932 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -68,7 +68,6 @@ namespace BTCPayServer.Hosting services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(o => { var opts = o.GetRequiredService(); @@ -129,7 +128,7 @@ namespace BTCPayServer.Hosting else return new Bitpay(new Key(), new Uri("https://test.bitpay.com/")); }); - services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddScoped(); services.AddTransient(); diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index 1ed1dce96..b100f33aa 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -1,5 +1,6 @@ using BTCPayServer.Services; using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; using BTCPayServer.Validations; using Microsoft.AspNetCore.Mvc.Rendering; using System; @@ -49,10 +50,10 @@ namespace BTCPayServer.Models.StoreViewModels public List DerivationSchemes { get; set; } = new List(); - public void SetExchangeRates((String DisplayName, String Name)[] supportedList, string preferredExchange) + public void SetExchangeRates(CoinAverageExchange[] supportedList, string preferredExchange) { - var defaultStore = preferredExchange ?? "coinaverage"; - var choices = supportedList.Select(o => new Format() { Name = o.DisplayName, Value = o.Name }).ToArray(); + var defaultStore = preferredExchange ?? CoinAverageRateProvider.CoinAverageName; + var choices = supportedList.Select(o => new Format() { Name = o.Display, Value = o.Name }).ToArray(); var chosen = choices.FirstOrDefault(f => f.Value == defaultStore) ?? choices.FirstOrDefault(); Exchanges = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); PreferredExchange = chosen.Value; @@ -67,7 +68,7 @@ namespace BTCPayServer.Models.StoreViewModels { get { - return PreferredExchange.IsCoinAverage() ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}"; + return PreferredExchange == CoinAverageRateProvider.CoinAverageName ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}"; } } diff --git a/BTCPayServer/Rating/ExchangeRates.cs b/BTCPayServer/Rating/ExchangeRates.cs index 576201f0f..868c70a3b 100644 --- a/BTCPayServer/Rating/ExchangeRates.cs +++ b/BTCPayServer/Rating/ExchangeRates.cs @@ -9,6 +9,18 @@ namespace BTCPayServer.Rating { public class ExchangeRates : IEnumerable { + Dictionary _AllRates = new Dictionary(); + public ExchangeRates() + { + + } + public ExchangeRates(IEnumerable rates) + { + foreach (var rate in rates) + { + Add(rate); + } + } List _Rates = new List(); public MultiValueDictionary ByExchange { @@ -18,8 +30,22 @@ namespace BTCPayServer.Rating public void Add(ExchangeRate rate) { - _Rates.Add(rate); - ByExchange.Add(rate.Exchange, rate); + // 1 DOGE is always 1 DOGE + if (rate.CurrencyPair.Left == rate.CurrencyPair.Right) + return; + var key = $"({rate.Exchange}) {rate.CurrencyPair}"; + if (_AllRates.TryAdd(key, rate)) + { + _Rates.Add(rate); + ByExchange.Add(rate.Exchange, rate); + } + else + { + if (rate.Value.HasValue) + { + _AllRates[key].Value = rate.Value; + } + } } public IEnumerator GetEnumerator() @@ -34,7 +60,7 @@ namespace BTCPayServer.Rating public void SetRate(string exchangeName, CurrencyPair currencyPair, decimal value) { - if(ByExchange.TryGetValue(exchangeName, out var rates)) + if (ByExchange.TryGetValue(exchangeName, out var rates)) { var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); if (rate != null) @@ -43,6 +69,8 @@ namespace BTCPayServer.Rating } public decimal? GetRate(string exchangeName, CurrencyPair currencyPair) { + if (currencyPair.Left == currencyPair.Right) + return 1.0m; if (ByExchange.TryGetValue(exchangeName, out var rates)) { var rate = rates.FirstOrDefault(r => r.CurrencyPair == currencyPair); diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index a5964328c..539e4c77a 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -93,6 +93,8 @@ namespace BTCPayServer.Rating SyntaxNode root; RuleList ruleList; + public decimal GlobalMultiplier { get; set; } = 1.0m; + RateRules(SyntaxNode root) { ruleList = new RuleList(); @@ -113,15 +115,15 @@ namespace BTCPayServer.Rating return true; } - public RateRule GetRuleFor(CurrencyPair currencyPair, decimal globalMultiplier = 1.0m) + public RateRule GetRuleFor(CurrencyPair currencyPair) { if (currencyPair.Left == "X" || currencyPair.Right == "X") throw new ArgumentException(paramName: nameof(currencyPair), message: "Invalid X currency"); var candidate = FindBestCandidate(currencyPair); - if (globalMultiplier != decimal.One) + if (GlobalMultiplier != decimal.One) { - candidate = CreateExpression($"({candidate}) * {globalMultiplier.ToString(CultureInfo.InvariantCulture)}"); + candidate = CreateExpression($"({candidate}) * {GlobalMultiplier.ToString(CultureInfo.InvariantCulture)}"); } return new RateRule(this, currencyPair, candidate); } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 9330a218a..5fd74741b 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -338,14 +338,14 @@ namespace BTCPayServer.Services.Invoices }; dto.CryptoInfo = new List(); - foreach (var info in this.GetPaymentMethods(networkProvider, true)) + foreach (var info in this.GetPaymentMethods(networkProvider)) { var accounting = info.Calculate(); var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo(); cryptoInfo.CryptoCode = info.GetId().CryptoCode; cryptoInfo.PaymentType = info.GetId().PaymentType.ToString(); cryptoInfo.Rate = info.Rate; - cryptoInfo.Price = Money.Coins(ProductInformation.Price / cryptoInfo.Rate).ToString(); + cryptoInfo.Price = (accounting.TotalDue - accounting.NetworkFee).ToString(); cryptoInfo.Due = accounting.Due.ToString(); cryptoInfo.Paid = accounting.Paid.ToString(); @@ -396,8 +396,7 @@ namespace BTCPayServer.Services.Invoices dto.PaymentUrls = cryptoInfo.PaymentUrls; } #pragma warning restore CS0618 - if (!info.IsPhantomBTC) - dto.CryptoInfo.Add(cryptoInfo); + dto.CryptoInfo.Add(cryptoInfo); } Populate(ProductInformation, dto); @@ -432,26 +431,15 @@ namespace BTCPayServer.Services.Invoices return GetPaymentMethod(new PaymentMethodId(network.CryptoCode, paymentType), networkProvider); } - public PaymentMethodDictionary GetPaymentMethods(BTCPayNetworkProvider networkProvider, bool alwaysIncludeBTC = false) + public PaymentMethodDictionary GetPaymentMethods(BTCPayNetworkProvider networkProvider) { PaymentMethodDictionary rates = new PaymentMethodDictionary(networkProvider); var serializer = new Serializer(Dummy); - PaymentMethod phantom = null; #pragma warning disable CS0618 - // Legacy - if (alwaysIncludeBTC) - { - var btcNetwork = networkProvider?.GetNetwork("BTC"); - phantom = new PaymentMethod() { ParentEntity = this, IsPhantomBTC = true, Rate = Rate, CryptoCode = "BTC", TxFee = TxFee, FeeRate = new FeeRate(TxFee, 100), DepositAddress = DepositAddress, Network = btcNetwork }; - if (btcNetwork != null || networkProvider == null) - rates.Add(phantom); - } if (PaymentMethod != null) { foreach (var prop in PaymentMethod.Properties()) { - if (prop.Name == "BTC" && phantom != null) - rates.Remove(phantom); var r = serializer.ToObject(prop.Value.ToString()); var paymentMethodId = PaymentMethodId.Parse(prop.Name); r.CryptoCode = paymentMethodId.CryptoCode; @@ -635,20 +623,17 @@ namespace BTCPayServer.Services.Invoices [Obsolete("Use ((BitcoinLikeOnChainPaymentMethod)GetPaymentMethod()).DepositAddress")] public string DepositAddress { get; set; } - [JsonIgnore] - public bool IsPhantomBTC { get; set; } - public PaymentMethodAccounting Calculate(Func paymentPredicate = null) { paymentPredicate = paymentPredicate ?? new Func((p) => true); - var paymentMethods = ParentEntity.GetPaymentMethods(null, IsPhantomBTC); + var paymentMethods = ParentEntity.GetPaymentMethods(null); var totalDue = ParentEntity.ProductInformation.Price / Rate; var paid = 0m; var cryptoPaid = 0.0m; int precision = 8; - var paidTxFee = 0m; + var totalDueNoNetworkCost = Money.Coins(Extensions.RoundUp(totalDue, precision)); bool paidEnough = paid >= Extensions.RoundUp(totalDue, precision); int txRequired = 0; var payments = @@ -662,9 +647,8 @@ namespace BTCPayServer.Services.Invoices if (!paidEnough) { totalDue += txFee; - paidTxFee += txFee; } - paidEnough |= paid >= Extensions.RoundUp(totalDue, precision); + paidEnough |= Extensions.RoundUp(paid, precision) >= Extensions.RoundUp(totalDue, precision); if (GetId() == _.GetPaymentMethodId()) { cryptoPaid += _.GetCryptoPaymentData().GetValue(); @@ -680,16 +664,15 @@ namespace BTCPayServer.Services.Invoices { txRequired++; totalDue += GetTxFee(); - paidTxFee += GetTxFee(); } accounting.TotalDue = Money.Coins(Extensions.RoundUp(totalDue, precision)); - accounting.Paid = Money.Coins(paid); + accounting.Paid = Money.Coins(Extensions.RoundUp(paid, precision)); accounting.TxRequired = txRequired; - accounting.CryptoPaid = Money.Coins(cryptoPaid); + accounting.CryptoPaid = Money.Coins(Extensions.RoundUp(cryptoPaid, precision)); accounting.Due = Money.Max(accounting.TotalDue - accounting.Paid, Money.Zero); accounting.DueUncapped = accounting.TotalDue - accounting.Paid; - accounting.NetworkFee = Money.Coins(paidTxFee); + accounting.NetworkFee = accounting.TotalDue - totalDueNoNetworkCost; return accounting; } diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 1d71ec469..72ce27d20 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -3,13 +3,35 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Rating; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; namespace BTCPayServer.Services.Rates { - public class BTCPayRateProviderFactory : IRateProviderFactory + public class ExchangeException { + public Exception Exception { get; set; } + public string ExchangeName { get; set; } + } + public class RateResult + { + public List ExchangeExceptions { get; set; } = new List(); + public string Rule { get; set; } + public string EvaluatedRule { get; set; } + public HashSet Errors { get; set; } + public decimal? Value { get; set; } + public bool Cached { get; internal set; } + } + + public class BTCPayRateProviderFactory + { + class QueryRateResult + { + public bool CachedResult { get; set; } + public List Exceptions { get; set; } + public ExchangeRates ExchangeRates { get; set; } + } IMemoryCache _Cache; private IOptions _CacheOptions; @@ -20,18 +42,41 @@ namespace BTCPayServer.Services.Rates return _Cache; } } - public BTCPayRateProviderFactory(IOptions cacheOptions, IServiceProvider serviceProvider) + CoinAverageSettings _CoinAverageSettings; + public BTCPayRateProviderFactory(IOptions cacheOptions, + BTCPayNetworkProvider btcpayNetworkProvider, + CoinAverageSettings coinAverageSettings) { if (cacheOptions == null) throw new ArgumentNullException(nameof(cacheOptions)); + _CoinAverageSettings = coinAverageSettings; _Cache = new MemoryCache(cacheOptions); _CacheOptions = cacheOptions; // We use 15 min because of limits with free version of bitcoinaverage CacheSpan = TimeSpan.FromMinutes(15.0); - this.serviceProvider = serviceProvider; + this.btcpayNetworkProvider = btcpayNetworkProvider; + InitExchanges(); } - IServiceProvider serviceProvider; + public bool UseCoinAverageAsFallback { get; set; } = true; + + private void InitExchanges() + { + DirectProviders.Add(QuadrigacxRateProvider.QuadrigacxName, new QuadrigacxRateProvider()); + } + + + private readonly Dictionary _DirectProviders = new Dictionary(); + public Dictionary DirectProviders + { + get + { + return _DirectProviders; + } + } + + + BTCPayNetworkProvider btcpayNetworkProvider; TimeSpan _CacheSpan; public TimeSpan CacheSpan { @@ -51,45 +96,87 @@ namespace BTCPayServer.Services.Rates _Cache = new MemoryCache(_CacheOptions); } - public IRateProvider GetRateProvider(BTCPayNetwork network, RateRules rules) + public async Task FetchRate(CurrencyPair pair, RateRules rules) { - rules = rules ?? new RateRules(); - var rateProvider = GetDefaultRateProvider(network); - if (!rules.PreferredExchange.IsCoinAverage()) - { - rateProvider = CreateExchangeRateProvider(network, rules.PreferredExchange); - } - rateProvider = CreateCachedRateProvider(network, rateProvider, rules.PreferredExchange); - return new TweakRateProvider(network, rateProvider, rules); + return await FetchRates(new HashSet(new[] { pair }), rules).First().Value; } - private IRateProvider CreateExchangeRateProvider(BTCPayNetwork network, string exchange) + public Dictionary> FetchRates(HashSet pairs, RateRules rules) + { + if (rules == null) + throw new ArgumentNullException(nameof(rules)); + + var fetchingRates = new Dictionary>(); + var fetchingExchanges = new Dictionary>(); + var consolidatedRates = new ExchangeRates(); + + foreach (var i in pairs.Select(p => (Pair: p, RateRule: rules.GetRuleFor(p)))) + { + var dependentQueries = new List>(); + foreach (var requiredExchange in i.RateRule.ExchangeRates) + { + if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching)) + { + fetching = QueryRates(requiredExchange.Exchange); + fetchingExchanges.Add(requiredExchange.Exchange, fetching); + } + dependentQueries.Add(fetching); + } + fetchingRates.Add(i.Pair, GetRuleValue(dependentQueries, i.RateRule)); + } + return fetchingRates; + } + + private async Task GetRuleValue(List> dependentQueries, RateRule rateRule) + { + var result = new RateResult(); + result.Cached = true; + foreach (var queryAsync in dependentQueries) + { + var query = await queryAsync; + if (!query.CachedResult) + result.Cached = false; + result.ExchangeExceptions.AddRange(query.Exceptions); + foreach (var rule in query.ExchangeRates) + { + rateRule.ExchangeRates.Add(rule); + } + } + rateRule.Reevaluate(); + result.Value = rateRule.Value; + result.Errors = rateRule.Errors; + result.EvaluatedRule = rateRule.ToString(true); + result.Rule = rateRule.ToString(false); + return result; + } + + + private async Task QueryRates(string exchangeName) { List providers = new List(); - - if(exchange == "quadrigacx") + if (DirectProviders.TryGetValue(exchangeName, out var directProvider)) + providers.Add(directProvider); + if (_CoinAverageSettings.AvailableExchanges.ContainsKey(exchangeName)) { - providers.Add(new QuadrigacxRateProvider(network.CryptoCode)); + providers.Add(new CoinAverageRateProvider(btcpayNetworkProvider) + { + Exchange = exchangeName, + Authenticator = _CoinAverageSettings + }); } - - var coinAverage = new CoinAverageRateProviderDescription(network.CryptoCode).CreateRateProvider(serviceProvider); - coinAverage.Exchange = exchange; - providers.Add(coinAverage); - return new FallbackRateProvider(providers.ToArray()); - } - - private CachedRateProvider CreateCachedRateProvider(BTCPayNetwork network, IRateProvider rateProvider, string additionalScope) - { - return new CachedRateProvider(network.CryptoCode, rateProvider, _Cache) { CacheSpan = CacheSpan, AdditionalScope = additionalScope }; - } - - private IRateProvider GetDefaultRateProvider(BTCPayNetwork network) - { - if(network.DefaultRateProvider == null) + var fallback = new FallbackRateProvider(providers.ToArray()); + var cached = new CachedRateProvider(exchangeName, fallback, _Cache) { - throw new RateUnavailableException(network.CryptoCode); - } - return network.DefaultRateProvider.CreateRateProvider(serviceProvider); + CacheSpan = CacheSpan + }; + var value = await cached.GetRatesAsync(); + return new QueryRateResult() + { + CachedResult = !fallback.Used, + ExchangeRates = value, + Exceptions = fallback.Exceptions + .Select(c => new ExchangeException() { Exception = c, ExchangeName = exchangeName }).ToList() + }; } } } diff --git a/BTCPayServer/Services/Rates/BitpayRateProvider.cs b/BTCPayServer/Services/Rates/BitpayRateProvider.cs index edf44aab1..0898fd883 100644 --- a/BTCPayServer/Services/Rates/BitpayRateProvider.cs +++ b/BTCPayServer/Services/Rates/BitpayRateProvider.cs @@ -5,18 +5,13 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; using NBitcoin; +using BTCPayServer.Rating; namespace BTCPayServer.Services.Rates { - public class BitpayRateProviderDescription : RateProviderDescription - { - public IRateProvider CreateRateProvider(IServiceProvider serviceProvider) - { - return new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))); - } - } public class BitpayRateProvider : IRateProvider { + public const string BitpayName = "bitpay"; Bitpay _Bitpay; public BitpayRateProvider(Bitpay bitpay) { @@ -24,21 +19,13 @@ namespace BTCPayServer.Services.Rates throw new ArgumentNullException(nameof(bitpay)); _Bitpay = bitpay; } - public async Task GetRateAsync(string currency) - { - var rates = await _Bitpay.GetRatesAsync().ConfigureAwait(false); - var rate = rates.GetRate(currency); - if (rate == 0m) - throw new RateUnavailableException(currency); - return (decimal)rate; - } - public async Task> GetRatesAsync() + public async Task GetRatesAsync() { - return (await _Bitpay.GetRatesAsync().ConfigureAwait(false)) + return new ExchangeRates((await _Bitpay.GetRatesAsync().ConfigureAwait(false)) .AllRates - .Select(r => new Rate() { Currency = r.Code, Value = r.Value }) - .ToList(); + .Select(r => new ExchangeRate() { Exchange = BitpayName, CurrencyPair = new CurrencyPair("BTC", r.Code), Value = r.Value }) + .ToList()); } } } diff --git a/BTCPayServer/Services/Rates/CachedRateProvider.cs b/BTCPayServer/Services/Rates/CachedRateProvider.cs index 9f6016b6c..6f1e6bbe4 100644 --- a/BTCPayServer/Services/Rates/CachedRateProvider.cs +++ b/BTCPayServer/Services/Rates/CachedRateProvider.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using BTCPayServer.Rating; using BTCPayServer.Services.Rates; using Microsoft.Extensions.Caching.Memory; @@ -10,9 +11,8 @@ namespace BTCPayServer.Services.Rates { private IRateProvider _Inner; private IMemoryCache _MemoryCache; - private string _CryptoCode; - public CachedRateProvider(string cryptoCode, IRateProvider inner, IMemoryCache memoryCache) + public CachedRateProvider(string exchangeName, IRateProvider inner, IMemoryCache memoryCache) { if (inner == null) throw new ArgumentNullException(nameof(inner)); @@ -20,7 +20,7 @@ namespace BTCPayServer.Services.Rates throw new ArgumentNullException(nameof(memoryCache)); this._Inner = inner; this.MemoryCache = memoryCache; - this._CryptoCode = cryptoCode; + this.ExchangeName = exchangeName; } public IRateProvider Inner @@ -31,31 +31,22 @@ namespace BTCPayServer.Services.Rates } } + public string ExchangeName { get; set; } + 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 + "_" + AdditionalScope, (ICacheEntry entry) => - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; - return _Inner.GetRateAsync(currency); - }); - } - public Task> GetRatesAsync() + public Task GetRatesAsync() { - return MemoryCache.GetOrCreateAsync("GLOBAL_RATES_" + _CryptoCode + "_" + AdditionalScope, (ICacheEntry entry) => + return MemoryCache.GetOrCreateAsync("EXCHANGE_RATES_" + ExchangeName, (ICacheEntry entry) => { entry.AbsoluteExpiration = DateTimeOffset.UtcNow + CacheSpan; return _Inner.GetRatesAsync(); }); } - - public string AdditionalScope { get; set; } } } diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs index 5abe33b48..fda213130 100644 --- a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -10,6 +10,7 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using System.ComponentModel; +using BTCPayServer.Rating; namespace BTCPayServer.Services.Rates { @@ -21,29 +22,6 @@ namespace BTCPayServer.Services.Rates } } - public class CoinAverageRateProviderDescription : RateProviderDescription - { - public CoinAverageRateProviderDescription(string crypto) - { - CryptoCode = crypto; - } - - public string CryptoCode { get; set; } - - public CoinAverageRateProvider CreateRateProvider(IServiceProvider serviceProvider) - { - return new CoinAverageRateProvider(CryptoCode) - { - Authenticator = serviceProvider.GetService() - }; - } - - IRateProvider RateProviderDescription.CreateRateProvider(IServiceProvider serviceProvider) - { - return CreateRateProvider(serviceProvider); - } - } - public class GetExchangeTickersResponse { public class Exchange @@ -69,18 +47,25 @@ namespace BTCPayServer.Services.Rates public interface ICoinAverageAuthenticator { Task AddHeader(HttpRequestMessage message); - } + } public class CoinAverageRateProvider : IRateProvider { + public const string CoinAverageName = "coinaverage"; + BTCPayNetworkProvider _NetworkProvider; + public CoinAverageRateProvider() + { + + } + public CoinAverageRateProvider(BTCPayNetworkProvider networkProvider) + { + if (networkProvider == null) + throw new ArgumentNullException(nameof(networkProvider)); + _NetworkProvider = networkProvider; + } static HttpClient _Client = new HttpClient(); - public CoinAverageRateProvider(string cryptoCode) - { - CryptoCode = cryptoCode ?? "BTC"; - } - - public string Exchange { get; set; } + public string Exchange { get; set; } = CoinAverageName; public string CryptoCode { get; set; } @@ -88,27 +73,19 @@ namespace BTCPayServer.Services.Rates { get; set; } = "global"; - public async Task GetRateAsync(string currency) - { - var rates = await GetRatesCore(); - return GetRate(rates, currency); - } - - private decimal GetRate(Dictionary rates, string currency) - { - if (currency == "BTC") - return 1.0m; - if (rates.TryGetValue(currency, out decimal result)) - return result; - throw new RateUnavailableException(currency); - } public ICoinAverageAuthenticator Authenticator { get; set; } - private async Task> GetRatesCore() + private bool TryToDecimal(JProperty p, out decimal v) { - string url = Exchange == null ? $"https://apiv2.bitcoinaverage.com/indices/{Market}/ticker/short" - : $"https://apiv2.bitcoinaverage.com/exchanges/{Exchange}"; + JToken token = p.Value[Exchange == CoinAverageName ? "last" : "bid"]; + return decimal.TryParse(token.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out v); + } + + public async Task GetRatesAsync() + { + string url = Exchange == CoinAverageName ? $"https://apiv2.bitcoinaverage.com/indices/{Market}/ticker/short" + : $"https://apiv2.bitcoinaverage.com/exchanges/{Exchange}"; var request = new HttpRequestMessage(HttpMethod.Get, url); var auth = Authenticator; @@ -128,36 +105,34 @@ 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) + if (Exchange != CoinAverageName) { rates = (JObject)rates["symbols"]; } - return rates.Properties() - .Where(p => p.Name.StartsWith(CryptoCode, StringComparison.OrdinalIgnoreCase) && TryToDecimal(p, out decimal unused)) - .ToDictionary(p => p.Name.Substring(CryptoCode.Length, p.Name.Length - CryptoCode.Length), p => - { - TryToDecimal(p, out decimal v); - return v; - }); + + var exchangeRates = new ExchangeRates(); + foreach (var prop in rates.Properties()) + { + ExchangeRate exchangeRate = new ExchangeRate(); + exchangeRate.Exchange = Exchange; + if (!TryToDecimal(prop, out decimal value)) + continue; + exchangeRate.Value = value; + for (int i = 3; i < 5; i++) + { + var potentialCryptoName = prop.Name.Substring(0, i); + if (_NetworkProvider.GetNetwork(potentialCryptoName) != null) + { + exchangeRate.CurrencyPair = new CurrencyPair(potentialCryptoName, prop.Name.Substring(i)); + } + } + if (exchangeRate.CurrencyPair != null) + exchangeRates.Add(exchangeRate); + } + return exchangeRates; } } - private bool TryToDecimal(JProperty p, out decimal v) - { - JToken token = p.Value[Exchange == null ? "last" : "bid"]; - return decimal.TryParse(token.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out v); - } - - public async Task> GetRatesAsync() - { - var rates = await GetRatesCore(); - return rates.Select(o => new Rate() - { - Currency = o.Key, - Value = o.Value - }).ToList(); - } - public async Task TestAuthAsync() { var request = new HttpRequestMessage(HttpMethod.Get, "https://apiv2.bitcoinaverage.com/blockchain/tx_price/BTCUSD/8a3b4394ba811a9e2b0bbf3cc56888d053ea21909299b2703cdc35e156c860ff"); @@ -217,7 +192,7 @@ namespace BTCPayServer.Services.Rates var exchanges = (JObject)jobj["exchanges"]; response.Exchanges = exchanges .Properties() - .Select(p => + .Select(p => { var exchange = JsonConvert.DeserializeObject(p.Value.ToString()); exchange.Name = p.Name; diff --git a/BTCPayServer/Services/Rates/CoinAverageSettings.cs b/BTCPayServer/Services/Rates/CoinAverageSettings.cs index d76be9d64..aff18270b 100644 --- a/BTCPayServer/Services/Rates/CoinAverageSettings.cs +++ b/BTCPayServer/Services/Rates/CoinAverageSettings.cs @@ -20,12 +20,35 @@ namespace BTCPayServer.Services.Rates return _Settings.AddHeader(message); } } + + public class CoinAverageExchange + { + public CoinAverageExchange(string name, string display) + { + Name = name; + Display = display; + } + public string Name { get; set; } + public string Display { get; set; } + } + public class CoinAverageExchanges : Dictionary + { + public CoinAverageExchanges() + { + Add(new CoinAverageExchange(CoinAverageRateProvider.CoinAverageName, "Coin Average")); + } + + public void Add(CoinAverageExchange exchange) + { + Add(exchange.Name, exchange); + } + } public class CoinAverageSettings : ICoinAverageAuthenticator { private static readonly DateTime _epochUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); public (String PublicKey, String PrivateKey)? KeyPair { get; set; } - public (String DisplayName, String Name)[] AvailableExchanges { get; set; } = Array.Empty<(String DisplayName, String Name)>(); + public CoinAverageExchanges AvailableExchanges { get; set; } = new CoinAverageExchanges(); public CoinAverageSettings() { @@ -37,8 +60,9 @@ namespace BTCPayServer.Services.Rates // b.AppendLine($"(DisplayName: \"{availableExchange.DisplayName}\", Name: \"{availableExchange.Name}\"),"); //} //b.AppendLine("}.ToArray()"); - - AvailableExchanges = new[] { + AvailableExchanges = new CoinAverageExchanges(); + foreach(var item in + new[] { (DisplayName: "BitBargain", Name: "bitbargain"), (DisplayName: "Tidex", Name: "tidex"), (DisplayName: "LocalBitcoins", Name: "localbitcoins"), @@ -89,7 +113,10 @@ namespace BTCPayServer.Services.Rates (DisplayName: "Quoine", Name: "quoine"), (DisplayName: "BTC Markets", Name: "btcmarkets"), (DisplayName: "Bitso", Name: "bitso"), - }.ToArray(); + }) + { + AvailableExchanges.TryAdd(item.Name, new CoinAverageExchange(item.Name, item.DisplayName)); + } } public Task AddHeader(HttpRequestMessage message) diff --git a/BTCPayServer/Services/Rates/FallbackRateProvider.cs b/BTCPayServer/Services/Rates/FallbackRateProvider.cs index 18f31dfc0..2e618cb3a 100644 --- a/BTCPayServer/Services/Rates/FallbackRateProvider.cs +++ b/BTCPayServer/Services/Rates/FallbackRateProvider.cs @@ -2,58 +2,35 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Rating; namespace BTCPayServer.Services.Rates { - public class FallbackRateProviderDescription : RateProviderDescription - { - public FallbackRateProviderDescription(RateProviderDescription[] rateProviders) - { - RateProviders = rateProviders; - } - - public RateProviderDescription[] RateProviders { get; set; } - - public IRateProvider CreateRateProvider(IServiceProvider serviceProvider) - { - return new FallbackRateProvider(RateProviders.Select(r => r.CreateRateProvider(serviceProvider)).ToArray()); - } - } - public class FallbackRateProvider : IRateProvider { - IRateProvider[] _Providers; + public bool Used { get; set; } public FallbackRateProvider(IRateProvider[] providers) { if (providers == null) throw new ArgumentNullException(nameof(providers)); _Providers = providers; } - public async Task GetRateAsync(string currency) - { - foreach(var p in _Providers) - { - try - { - return await p.GetRateAsync(currency).ConfigureAwait(false); - } - catch { } - } - throw new RateUnavailableException(currency); - } - public async Task> GetRatesAsync() + public async Task GetRatesAsync() { + Used = true; foreach (var p in _Providers) { try { return await p.GetRatesAsync().ConfigureAwait(false); } - catch { } + catch(Exception ex) { Exceptions.Add(ex); } } - throw new RateUnavailableException("ALL"); + return new ExchangeRates(); } + + public List Exceptions { get; set; } = new List(); } } diff --git a/BTCPayServer/Services/Rates/IRateProvider.cs b/BTCPayServer/Services/Rates/IRateProvider.cs index 19a33ae45..00b26c5bd 100644 --- a/BTCPayServer/Services/Rates/IRateProvider.cs +++ b/BTCPayServer/Services/Rates/IRateProvider.cs @@ -2,32 +2,12 @@ using System.Collections.Generic; using System.Text; using System.Threading.Tasks; +using BTCPayServer.Rating; namespace BTCPayServer.Services.Rates { - public class Rate - { - public Rate() - { - - } - public Rate(string currency, decimal value) - { - Value = value; - Currency = currency; - } - public string Currency - { - get; set; - } - public decimal Value - { - get; set; - } - } public interface IRateProvider { - Task GetRateAsync(string currency); - Task> GetRatesAsync(); + Task GetRatesAsync(); } } diff --git a/BTCPayServer/Services/Rates/IRateProviderFactory.cs b/BTCPayServer/Services/Rates/IRateProviderFactory.cs deleted file mode 100644 index 5c3b76a77..000000000 --- a/BTCPayServer/Services/Rates/IRateProviderFactory.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using BTCPayServer.Data; - -namespace BTCPayServer.Services.Rates -{ - public class RateRules : IEnumerable - { - private List rateRules; - - public RateRules() - { - rateRules = new List(); - } - public RateRules(List rateRules) - { - this.rateRules = rateRules?.ToList() ?? new List(); - } - public string PreferredExchange { get; set; } - - public IEnumerator GetEnumerator() - { - return rateRules.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - } - public interface IRateProviderFactory - { - IRateProvider GetRateProvider(BTCPayNetwork network, RateRules rules); - TimeSpan CacheSpan { get; set; } - void InvalidateCache(); - } -} diff --git a/BTCPayServer/Services/Rates/MockRateProvider.cs b/BTCPayServer/Services/Rates/MockRateProvider.cs deleted file mode 100644 index 28d8298d1..000000000 --- a/BTCPayServer/Services/Rates/MockRateProvider.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using System.Linq; -using System.Threading.Tasks; - -namespace BTCPayServer.Services.Rates -{ - public class MockRateProviderFactory : IRateProviderFactory - { - List _Mocks = new List(); - public MockRateProviderFactory() - { - - } - - public TimeSpan CacheSpan { get; set; } - - public void AddMock(MockRateProvider mock) - { - _Mocks.Add(mock); - } - public IRateProvider GetRateProvider(BTCPayNetwork network, RateRules rules) - { - return _Mocks.FirstOrDefault(m => m.CryptoCode == network.CryptoCode); - } - - public void InvalidateCache() - { - - } - } - public class MockRateProvider : IRateProvider - { - List _Rates; - - public string CryptoCode { get; } - - public MockRateProvider(string cryptoCode, params Rate[] rates) - { - _Rates = new List(rates); - CryptoCode = cryptoCode; - } - public MockRateProvider(string cryptoCode, List rates) - { - _Rates = rates; - CryptoCode = cryptoCode; - } - public Task GetRateAsync(string currency) - { - var rate = _Rates.FirstOrDefault(r => r.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)); - if (rate == null) - throw new RateUnavailableException(currency); - return Task.FromResult(rate.Value); - } - - public Task> GetRatesAsync() - { - ICollection rates = _Rates; - return Task.FromResult(rates); - } - } -} diff --git a/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs b/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs index 34ae2b12d..10fb75189 100644 --- a/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs +++ b/BTCPayServer/Services/Rates/QuadrigacxRateProvider.cs @@ -4,32 +4,15 @@ using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading.Tasks; +using BTCPayServer.Rating; using Newtonsoft.Json.Linq; namespace BTCPayServer.Services.Rates { public class QuadrigacxRateProvider : IRateProvider { - public QuadrigacxRateProvider(string crypto) - { - CryptoCode = crypto; - } - public string CryptoCode { get; set; } + public const string QuadrigacxName = "quadrigacx"; static HttpClient _Client = new HttpClient(); - public async Task GetRateAsync(string currency) - { - return await GetRatesAsyncCore(CryptoCode, currency); - } - - private async Task GetRatesAsyncCore(string cryptoCode, string currency) - { - var response = await _Client.GetAsync($"https://api.quadrigacx.com/v2/ticker?book={cryptoCode.ToLowerInvariant()}_{currency.ToLowerInvariant()}"); - response.EnsureSuccessStatusCode(); - var rates = JObject.Parse(await response.Content.ReadAsStringAsync()); - if (!TryToDecimal(rates, out var result)) - throw new RateUnavailableException(currency); - return result; - } private bool TryToDecimal(JObject p, out decimal v) { @@ -40,26 +23,26 @@ namespace BTCPayServer.Services.Rates return decimal.TryParse(token.Value(), System.Globalization.NumberStyles.AllowExponent | System.Globalization.NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out v); } - public async Task> GetRatesAsync() + public async Task GetRatesAsync() { var response = await _Client.GetAsync($"https://api.quadrigacx.com/v2/ticker?book=all"); response.EnsureSuccessStatusCode(); var rates = JObject.Parse(await response.Content.ReadAsStringAsync()); - List result = new List(); + var exchangeRates = new ExchangeRates(); foreach (var prop in rates.Properties()) { - var rate = new Rate(); - var splitted = prop.Name.Split('_'); - var crypto = splitted[0].ToUpperInvariant(); - if (crypto != CryptoCode) + var rate = new ExchangeRate(); + if (!Rating.CurrencyPair.TryParse(prop.Name, out var pair)) + continue; + rate.CurrencyPair = pair; + rate.Exchange = QuadrigacxName; + if (!TryToDecimal((JObject)prop.Value, out var v)) continue; - rate.Currency = splitted[1].ToUpperInvariant(); - TryToDecimal((JObject)prop.Value, out var v); rate.Value = v; - result.Add(rate); + exchangeRates.Add(rate); } - return result; + return exchangeRates; } } } diff --git a/BTCPayServer/Services/Rates/RateProviderDescription.cs b/BTCPayServer/Services/Rates/RateProviderDescription.cs deleted file mode 100644 index bffac1b37..000000000 --- a/BTCPayServer/Services/Rates/RateProviderDescription.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace BTCPayServer.Services.Rates -{ - public interface RateProviderDescription - { - IRateProvider CreateRateProvider(IServiceProvider serviceProvider); - } -} diff --git a/BTCPayServer/Services/Rates/RateUnavailableException.cs b/BTCPayServer/Services/Rates/RateUnavailableException.cs deleted file mode 100644 index a21cbf71f..000000000 --- a/BTCPayServer/Services/Rates/RateUnavailableException.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; - -namespace BTCPayServer.Services.Rates -{ - public class RateUnavailableException : Exception - { - public RateUnavailableException(string currency) : base("Rate unavailable for currency " + currency) - { - if (currency == null) - throw new ArgumentNullException(nameof(currency)); - Currency = currency; - } - - public string Currency - { - get; set; - } - } -} diff --git a/BTCPayServer/Services/Rates/TweakRateProvider.cs b/BTCPayServer/Services/Rates/TweakRateProvider.cs deleted file mode 100644 index dcca887cd..000000000 --- a/BTCPayServer/Services/Rates/TweakRateProvider.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using BTCPayServer.Data; - -namespace BTCPayServer.Services.Rates -{ - public class TweakRateProvider : IRateProvider - { - private BTCPayNetwork network; - private IRateProvider rateProvider; - private RateRules rateRules; - - public TweakRateProvider(BTCPayNetwork network, IRateProvider rateProvider, RateRules rateRules) - { - if (network == null) - throw new ArgumentNullException(nameof(network)); - if (rateProvider == null) - throw new ArgumentNullException(nameof(rateProvider)); - if (rateRules == null) - throw new ArgumentNullException(nameof(rateRules)); - this.network = network; - this.rateProvider = rateProvider; - this.rateRules = rateRules; - } - - public async Task GetRateAsync(string currency) - { - var rate = await rateProvider.GetRateAsync(currency); - foreach(var rule in rateRules) - { - rate = rule.Apply(network, rate); - } - return rate; - } - - public async Task> GetRatesAsync() - { - List rates = new List(); - foreach (var rate in await rateProvider.GetRatesAsync()) - { - var localRate = rate.Value; - foreach (var rule in rateRules) - { - localRate = rule.Apply(network, localRate); - } - rates.Add(new Rate(rate.Currency, localRate)); - } - return rates; - } - } -} From 34d0d3e0118c4c3bb948577dba004c9b9cc074b3 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Thu, 3 May 2018 03:40:10 +0900 Subject: [PATCH 023/119] make sure we can calculate the rate of default currencies --- BTCPayServer.Tests/UnitTest1.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 4c64aa2df..c74738212 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1231,6 +1231,26 @@ namespace BTCPayServer.Tests Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD"))); } + [Fact] + public void CanGetRateCryptoCurrenciesByDefault() + { + var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); + var factory = new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + + var pairs = + provider.GetAll() + .Select(c => new CurrencyPair(c.CryptoCode, "USD")) + .ToHashSet(); + + var rules = new StoreBlob().GetDefaultRateRules(provider); + var result = factory.FetchRates(pairs, rules); + foreach(var value in result) + { + var rateResult = value.Value.GetAwaiter().GetResult(); + Assert.NotNull(rateResult.Value); + } + } + [Fact] public void CheckRatesProvider() { From f460837f961b1ab9abc5c0b73a0fe363a23e7a9a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Thu, 3 May 2018 04:33:21 +0900 Subject: [PATCH 024/119] Make sure RateRules do not remove comments --- BTCPayServer.Tests/RateRulesTest.cs | 6 +++++- BTCPayServer/Rating/RateRules.cs | 30 +++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/BTCPayServer.Tests/RateRulesTest.cs b/BTCPayServer.Tests/RateRulesTest.cs index abd2c5201..d82b287d1 100644 --- a/BTCPayServer.Tests/RateRulesTest.cs +++ b/BTCPayServer.Tests/RateRulesTest.cs @@ -14,17 +14,21 @@ namespace BTCPayServer.Tests { // Check happy path StringBuilder builder = new StringBuilder(); + builder.AppendLine("// Some cool comments"); builder.AppendLine("DOGE_X = DOGE_BTC * BTC_X * 1.1"); builder.AppendLine("DOGE_BTC = Bittrex(DOGE_BTC)"); + builder.AppendLine("// Some other cool comments"); builder.AppendLine("BTC_usd = GDax(BTC_USD)"); - builder.AppendLine("BTC_X = Coinbase(BTC_X)"); + builder.AppendLine("BTC_X = Coinbase(BTC_X);"); builder.AppendLine("X_X = CoinAverage(X_X) * 1.02"); Assert.False(RateRules.TryParse("DPW*&W&#hdi&#&3JJD", out var rules)); Assert.True(RateRules.TryParse(builder.ToString(), out rules)); Assert.Equal( + "// Some cool comments\n" + "DOGE_X = DOGE_BTC * BTC_X * 1.1;\n" + "DOGE_BTC = bittrex(DOGE_BTC);\n" + + "// Some other cool comments\n" + "BTC_USD = gdax(BTC_USD);\n" + "BTC_X = coinbase(BTC_X);\n" + "X_X = coinaverage(X_X) * 1.02;", diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index 539e4c77a..9c350bde9 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -55,7 +55,8 @@ namespace BTCPayServer.Rating } if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currencyPair)) { - return SyntaxFactory.IdentifierName(currencyPair.ToString()); + return SyntaxFactory.IdentifierName(currencyPair.ToString()) + .WithTriviaFrom(node); } else { @@ -66,25 +67,30 @@ namespace BTCPayServer.Rating } class RuleList : CSharpSyntaxWalker { - public Dictionary ExpressionsByPair = new Dictionary(); + public Dictionary ExpressionsByPair = new Dictionary(); public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) { - if (node.Left is IdentifierNameSyntax id) + if (node.Kind() == SyntaxKind.SimpleAssignmentExpression + && node.Left is IdentifierNameSyntax id + && node.Right is ExpressionSyntax expression) { if (CurrencyPair.TryParse(id.Identifier.ValueText, out var currencyPair)) { - ExpressionsByPair.TryAdd(currencyPair, node.Right); + expression = expression.WithTriviaFrom(expression); + ExpressionsByPair.Add(currencyPair, (expression, id)); } } + base.VisitAssignmentExpression(node); } + public SyntaxNode GetSyntaxNode() { return SyntaxFactory.Block( ExpressionsByPair.Select(e => SyntaxFactory.ExpressionStatement( SyntaxFactory.AssignmentExpression(SyntaxKind.SimpleAssignmentExpression, - SyntaxFactory.IdentifierName(e.Key.ToString()), - e.Value) + SyntaxFactory.IdentifierName(e.Key.ToString()).WithTriviaFrom(e.Value.Trivia), + e.Value.Expression) )) ); } @@ -98,8 +104,8 @@ namespace BTCPayServer.Rating RateRules(SyntaxNode root) { ruleList = new RuleList(); - // Remove every irrelevant statements ruleList.Visit(root); + // Remove every irrelevant statements this.root = ruleList.GetSyntaxNode(); } public static bool TryParse(string str, out RateRules rules) @@ -139,9 +145,9 @@ namespace BTCPayServer.Rating (Pair: new CurrencyPair("X", "X"), Priority: 2) }) { - if (ruleList.ExpressionsByPair.TryGetValue(pair.Pair, out ExpressionSyntax expression)) + if (ruleList.ExpressionsByPair.TryGetValue(pair.Pair, out var expression)) { - candidates.Add((pair.Pair, pair.Priority, expression)); + candidates.Add((pair.Pair, pair.Priority, expression.Expression)); } } if (candidates.Count == 0) @@ -161,9 +167,9 @@ namespace BTCPayServer.Rating public override string ToString() { - return this.root.NormalizeWhitespace("", "\n") - .ToFullString() - .Replace("{\n", string.Empty, StringComparison.OrdinalIgnoreCase) + return root.NormalizeWhitespace("", "\n") + .ToFullString() + .Replace("{\n", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("\n}", string.Empty, StringComparison.OrdinalIgnoreCase); } } From 6dc4bfaefe6cbd1fd092f8ac4deaf9dcf17645a9 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 01:46:52 +0900 Subject: [PATCH 025/119] Make rate calculation scriptable --- BTCPayServer.Tests/TestAccount.cs | 2 +- BTCPayServer.Tests/UnitTest1.cs | 74 +++++++- .../Controllers/InvoiceController.UI.cs | 4 +- BTCPayServer/Controllers/StoresController.cs | 168 +++++++++++++++--- BTCPayServer/Extensions.cs | 8 - BTCPayServer/Models/ConfirmModel.cs | 1 + .../Models/StoreViewModels/RatesViewModel.cs | 66 +++++++ .../Models/StoreViewModels/StoreViewModel.cs | 35 ---- BTCPayServer/Rating/CurrencyPair.cs | 1 + BTCPayServer/Rating/RateRules.cs | 41 +++-- .../Rates/BTCPayRateProviderFactory.cs | 2 +- .../Services/Rates/CoinAverageRateProvider.cs | 8 +- .../Services/Rates/CoinAverageSettings.cs | 8 + BTCPayServer/Views/Invoice/Checkout.cshtml | 2 +- .../Views/Invoice/ListInvoices.cshtml | 2 +- BTCPayServer/Views/Shared/Confirm.cshtml | 2 +- .../Views/Stores/AddDerivationScheme.cshtml | 2 +- BTCPayServer/Views/Stores/Rates.cshtml | 149 ++++++++++++++++ BTCPayServer/Views/Stores/StoreNavPages.cs | 2 + BTCPayServer/Views/Stores/UpdateStore.cshtml | 13 -- BTCPayServer/Views/Stores/Wallet.cshtml | 2 +- BTCPayServer/Views/Stores/_Nav.cshtml | 1 + 22 files changed, 472 insertions(+), 121 deletions(-) create mode 100644 BTCPayServer/Models/StoreViewModels/RatesViewModel.cs create mode 100644 BTCPayServer/Views/Stores/Rates.cshtml diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 51d6840b3..085d68349 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -79,7 +79,7 @@ namespace BTCPayServer.Tests var store = parent.PayTester.GetController(UserId, StoreId); ExtKey = new ExtKey().GetWif(SupportedNetwork.NBitcoinNetwork); DerivationScheme = new DerivationStrategyFactory(SupportedNetwork.NBitcoinNetwork).Parse(ExtKey.Neuter().ToString() + "-[legacy]"); - var vm = (StoreViewModel)((ViewResult)store.UpdateStore(StoreId)).Model; + var vm = (StoreViewModel)((ViewResult)store.UpdateStore()).Model; vm.SpeedPolicy = SpeedPolicy.MediumSpeed; await store.UpdateStore(vm); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index c74738212..3d12815c8 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -297,7 +297,7 @@ namespace BTCPayServer.Tests var user = tester.NewAccount(); user.GrantAccess(); var storeController = user.GetController(); - Assert.IsType(storeController.UpdateStore(user.StoreId)); + Assert.IsType(storeController.UpdateStore()); Assert.IsType(storeController.AddLightningNode(user.StoreId, "BTC")); var testResult = storeController.AddLightningNode(user.StoreId, new LightningNodeViewModel() @@ -312,7 +312,7 @@ namespace BTCPayServer.Tests Url = tester.MerchantCharge.Client.Uri.AbsoluteUri }, "save", "BTC").GetAwaiter().GetResult()); - var storeVm = Assert.IsType(Assert.IsType(storeController.UpdateStore(user.StoreId)).Model); + var storeVm = Assert.IsType(Assert.IsType(storeController.UpdateStore()).Model); Assert.Single(storeVm.LightningNodes.Where(l => !string.IsNullOrEmpty(l.Address))); } } @@ -678,9 +678,9 @@ namespace BTCPayServer.Tests private static decimal CreateInvoice(ServerTester tester, TestAccount user, string exchange) { var storeController = user.GetController(); - var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; + var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model; vm.PreferredExchange = exchange; - storeController.UpdateStore(vm).Wait(); + storeController.Rates(vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0, @@ -717,10 +717,10 @@ namespace BTCPayServer.Tests var storeController = user.GetController(); - var vm = (StoreViewModel)((ViewResult)storeController.UpdateStore(user.StoreId)).Model; + var vm = (RatesViewModel)((ViewResult)storeController.Rates()).Model; Assert.Equal(1.0, vm.RateMultiplier); vm.RateMultiplier = 0.5; - storeController.UpdateStore(vm).Wait(); + storeController.Rates(vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() @@ -796,6 +796,66 @@ namespace BTCPayServer.Tests } } + [Fact] + public void CanModifyRates() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterDerivationScheme("BTC"); + + var store = user.GetController(); + var rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); + Assert.False(rateVm.ShowScripting); + Assert.Equal("coinaverage", rateVm.PreferredExchange); + Assert.Equal(1.0, rateVm.RateMultiplier); + Assert.Null(rateVm.TestRateRules); + + rateVm.PreferredExchange = "bitflyer"; + Assert.IsType(store.Rates(rateVm, "Save").Result); + rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); + Assert.Equal("bitflyer", rateVm.PreferredExchange); + + rateVm.ScriptTest = "BTC_JPY,BTC_CAD"; + rateVm.RateMultiplier = 1.1; + store = user.GetController(); + rateVm = Assert.IsType(Assert.IsType(store.Rates(rateVm, "Test").Result).Model); + Assert.NotNull(rateVm.TestRateRules); + Assert.Equal(2, rateVm.TestRateRules.Count); + Assert.False(rateVm.TestRateRules[0].Error); + Assert.StartsWith("(bitflyer(BTC_JPY)) * 1.10 =", rateVm.TestRateRules[0].Rule, StringComparison.OrdinalIgnoreCase); + Assert.True(rateVm.TestRateRules[1].Error); + Assert.IsType(store.Rates(rateVm, "Save").Result); + + Assert.IsType(store.ShowRateRulesPost(true).Result); + Assert.IsType(store.Rates(rateVm, "Save").Result); + store = user.GetController(); + rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); + Assert.Equal(rateVm.DefaultScript, rateVm.Script); + Assert.True(rateVm.ShowScripting); + rateVm.ScriptTest = "BTC_JPY"; + rateVm = Assert.IsType(Assert.IsType(store.Rates(rateVm, "Test").Result).Model); + Assert.True(rateVm.ShowScripting); + Assert.Contains("(bitflyer(BTC_JPY)) * 1.10 = ", rateVm.TestRateRules[0].Rule, StringComparison.OrdinalIgnoreCase); + + rateVm.ScriptTest = "BTC_USD,BTC_CAD,DOGE_USD,DOGE_CAD"; + rateVm.Script = "DOGE_X = bittrex(DOGE_BTC) * BTC_X;\n" + + "X_CAD = quadrigacx(X_CAD);\n" + + "X_X = gdax(X_X);"; + rateVm.RateMultiplier = 0.5; + rateVm = Assert.IsType(Assert.IsType(store.Rates(rateVm, "Test").Result).Model); + Assert.True(rateVm.TestRateRules.All(t => !t.Error)); + Assert.IsType(store.Rates(rateVm, "Save").Result); + store = user.GetController(); + rateVm = Assert.IsType(Assert.IsType(store.Rates()).Model); + Assert.Equal(0.5, rateVm.RateMultiplier); + Assert.True(rateVm.ShowScripting); + Assert.Contains("DOGE_X", rateVm.Script, StringComparison.OrdinalIgnoreCase); + } + } + [Fact] public void CanPayWithTwoCurrencies() { @@ -1255,7 +1315,7 @@ namespace BTCPayServer.Tests public void CheckRatesProvider() { var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); - var coinAverage = new CoinAverageRateProvider(provider); + var coinAverage = new CoinAverageRateProvider(); var rates = coinAverage.GetRatesAsync().GetAwaiter().GetResult(); Assert.NotNull(rates.GetRate("coinaverage", new CurrencyPair("BTC", "JPY"))); var ratesBitpay = new BitpayRateProvider(new Bitpay(new Key(), new Uri("https://bitpay.com/"))).GetRatesAsync().GetAwaiter().GetResult(); diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index ab2c4430b..a34ee14e2 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -460,9 +460,9 @@ namespace BTCPayServer.Controllers StatusMessage = $"Invoice {result.Data.Id} just created!"; return RedirectToAction(nameof(ListInvoices)); } - catch (BitpayHttpException) + catch (BitpayHttpException ex) { - ModelState.TryAddModelError(nameof(model.Currency), "Unsupported currency"); + ModelState.TryAddModelError(nameof(model.Currency), $"Error: {ex.Message}"); return View(model); } } diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index a8f3ae79d..bef367f05 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -4,6 +4,7 @@ using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Rating; using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Rates; @@ -20,6 +21,7 @@ using NBitcoin.DataEncoders; using NBXplorer.DerivationStrategy; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -33,6 +35,7 @@ namespace BTCPayServer.Controllers [AutoValidateAntiforgeryToken] public partial class StoresController : Controller { + BTCPayRateProviderFactory _RateFactory; public string CreatedStoreId { get; set; } public StoresController( NBXplorerDashboard dashboard, @@ -46,12 +49,14 @@ namespace BTCPayServer.Controllers AccessTokenController tokenController, BTCPayWalletProvider walletProvider, BTCPayNetworkProvider networkProvider, + BTCPayRateProviderFactory rateFactory, ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, LanguageService langService, IHostingEnvironment env, CoinAverageSettings coinAverage) { + _RateFactory = rateFactory; _Dashboard = dashboard; _Repo = repo; _TokenRepository = tokenRepo; @@ -191,6 +196,143 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(StoreUsers), new { storeId = storeId, userId = userId }); } + [HttpGet] + [Route("{storeId}/rates")] + public IActionResult Rates() + { + var storeBlob = StoreData.GetStoreBlob(); + var vm = new RatesViewModel(); + vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName); + vm.RateMultiplier = (double)storeBlob.GetRateMultiplier(); + vm.Script = storeBlob.GetRateRules(_NetworkProvider).ToString(); + vm.DefaultScript = storeBlob.GetDefaultRateRules(_NetworkProvider).ToString(); + vm.AvailableExchanges = GetSupportedExchanges(); + vm.ShowScripting = storeBlob.RateScripting; + return View(vm); + } + + [HttpPost] + [Route("{storeId}/rates")] + public async Task Rates(RatesViewModel model, string command = null) + { + model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange); + if (!ModelState.IsValid) + { + return View(model); + } + if (model.PreferredExchange != null) + model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); + + var blob = StoreData.GetStoreBlob(); + model.DefaultScript = blob.GetDefaultRateRules(_NetworkProvider).ToString(); + model.AvailableExchanges = GetSupportedExchanges(); + + blob.PreferredExchange = model.PreferredExchange; + blob.SetRateMultiplier(model.RateMultiplier); + + if (!model.ShowScripting) + { + if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase)) + { + ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})"); + return View(model); + } + } + RateRules rules = null; + if (model.ShowScripting) + { + if (!RateRules.TryParse(model.Script, out rules, out var errors)) + { + errors = errors ?? new List(); + var errorString = String.Join(", ", errors.ToArray()); + ModelState.AddModelError(nameof(model.Script), $"Parsing error ({errorString})"); + return View(model); + } + else + { + blob.RateScript = rules.ToString(); + } + } + rules = blob.GetRateRules(_NetworkProvider); + + if (command == "Test") + { + if (string.IsNullOrWhiteSpace(model.ScriptTest)) + { + ModelState.AddModelError(nameof(model.ScriptTest), "Fill out currency pair to test for (like BTC_USD,BTC_CAD)"); + return View(model); + } + var splitted = model.ScriptTest.Split(',', StringSplitOptions.RemoveEmptyEntries); + + var pairs = new List(); + foreach (var pair in splitted) + { + if (!CurrencyPair.TryParse(pair, out var currencyPair)) + { + ModelState.AddModelError(nameof(model.ScriptTest), $"Invalid currency pair '{pair}' (it should be formatted like BTC_USD,BTC_CAD)"); + return View(model); + } + pairs.Add(currencyPair); + } + + var fetchs = _RateFactory.FetchRates(pairs.ToHashSet(), rules); + var testResults = new List(); + foreach (var fetch in fetchs) + { + var testResult = await (fetch.Value); + testResults.Add(new RatesViewModel.TestResultViewModel() + { + CurrencyPair = fetch.Key.ToString(), + Error = testResult.Errors.Count != 0, + Rule = testResult.Errors.Count == 0 ? testResult.Rule + " = " + testResult.Value.Value.ToString(CultureInfo.InvariantCulture) + : testResult.EvaluatedRule + }); + } + model.TestRateRules = testResults; + return View(model); + } + else // command == Save + { + if (StoreData.SetStoreBlob(blob)) + { + await _Repo.UpdateStore(StoreData); + StatusMessage = "Rate settings updated"; + } + return RedirectToAction(nameof(Rates), new + { + storeId = StoreData.Id + }); + } + } + + [HttpGet] + [Route("{storeId}/rates/confirm")] + public IActionResult ShowRateRules(bool scripting) + { + return View("Confirm", new ConfirmModel() + { + Action = nameof(ShowRateRulesPost), + Title = "Rate rule scripting", + Description = scripting ? + "This action will mofify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)" + : "This action will delete your rate script. Are you sure to turn off rate rules scripting?", + ButtonClass = "btn-primary" + }); + } + + [HttpPost] + [Route("{storeId}/rates/confirm")] + public async Task ShowRateRulesPost(bool scripting) + { + var blob = StoreData.GetStoreBlob(); + blob.RateScripting = scripting; + blob.RateScript = blob.GetDefaultRateRules(_NetworkProvider).ToString(); + StoreData.SetStoreBlob(blob); + await _Repo.UpdateStore(StoreData); + StatusMessage = "Rate rules scripting activated"; + return RedirectToAction(nameof(Rates), new { storeId = StoreData.Id }); + } + [HttpGet] [Route("{storeId}/checkout")] public IActionResult CheckoutExperience() @@ -268,7 +410,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{storeId}")] - public IActionResult UpdateStore(string storeId) + public IActionResult UpdateStore() { var store = HttpContext.GetStoreData(); if (store == null) @@ -276,7 +418,6 @@ namespace BTCPayServer.Controllers var storeBlob = store.GetStoreBlob(); var vm = new StoreViewModel(); - vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName); vm.Id = store.Id; vm.StoreName = store.StoreName; vm.StoreWebsite = store.StoreWebsite; @@ -285,7 +426,6 @@ namespace BTCPayServer.Controllers AddPaymentMethods(store, vm); vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; - vm.RateMultiplier = (double)storeBlob.GetRateMultiplier(); vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; return View(vm); } @@ -328,13 +468,6 @@ namespace BTCPayServer.Controllers [Route("{storeId}")] public async Task UpdateStore(StoreViewModel model) { - model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange); - if (!ModelState.IsValid) - { - return View(model); - } - if (model.PreferredExchange != null) - model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); AddPaymentMethods(StoreData, model); bool needUpdate = false; @@ -360,26 +493,11 @@ namespace BTCPayServer.Controllers blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; - bool newExchange = blob.PreferredExchange != model.PreferredExchange; - blob.PreferredExchange = model.PreferredExchange; - - blob.SetRateMultiplier(model.RateMultiplier); - if (StoreData.SetStoreBlob(blob)) { needUpdate = true; } - if (newExchange) - { - - if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase)) - { - ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})"); - return View(model); - } - } - if (needUpdate) { await _Repo.UpdateStore(StoreData); diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index 7bda961d9..e6e7e3520 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -200,13 +200,5 @@ namespace BTCPayServer var res = JsonConvert.SerializeObject(o, Formatting.None, jsonSettings); return res; } - - public static HtmlString ToJSVariableModel(this object o, string variableName) - { - var encodedJson = JavaScriptEncoder.Default.Encode(o.ToJson()); - return new HtmlString($"var {variableName} = JSON.parse('" + encodedJson + "');"); - } - - } } diff --git a/BTCPayServer/Models/ConfirmModel.cs b/BTCPayServer/Models/ConfirmModel.cs index 172db1eb5..a882e9ef8 100644 --- a/BTCPayServer/Models/ConfirmModel.cs +++ b/BTCPayServer/Models/ConfirmModel.cs @@ -19,5 +19,6 @@ namespace BTCPayServer.Models { get; set; } + public string ButtonClass { get; set; } = "btn-danger"; } } diff --git a/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs new file mode 100644 index 000000000..8fb756fd5 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Rating; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class RatesViewModel + { + public class TestResultViewModel + { + public string CurrencyPair { get; set; } + public string Rule { get; set; } + public bool Error { get; set; } + } + class Format + { + public string Name { get; set; } + public string Value { get; set; } + } + public void SetExchangeRates(CoinAverageExchange[] supportedList, string preferredExchange) + { + var defaultStore = preferredExchange ?? CoinAverageRateProvider.CoinAverageName; + var choices = supportedList.Select(o => new Format() { Name = o.Display, Value = o.Name }).ToArray(); + var chosen = choices.FirstOrDefault(f => f.Value == defaultStore) ?? choices.FirstOrDefault(); + Exchanges = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); + PreferredExchange = chosen.Value; + } + + public List TestRateRules { get; set; } + + public SelectList Exchanges { get; set; } + + public bool ShowScripting { get; set; } + + [Display(Name = "Rate rules")] + [MaxLength(2000)] + public string Script { get; set; } + public string DefaultScript { get; set; } + public string ScriptTest { get; set; } + public CoinAverageExchange[] AvailableExchanges { get; set; } + + [Display(Name = "Multiply the original rate by ...")] + [Range(0.01, 10.0)] + public double RateMultiplier + { + get; + set; + } + + [Display(Name = "Preferred price source (eg. bitfinex, bitstamp...)")] + public string PreferredExchange { get; set; } + + public string RateSource + { + get + { + return PreferredExchange == CoinAverageRateProvider.CoinAverageName ? "https://apiv2.bitcoinaverage.com/indices/global/ticker/short" : $"https://apiv2.bitcoinaverage.com/exchanges/{PreferredExchange}"; + } + } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index b100f33aa..ee4fbf318 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -13,11 +13,6 @@ namespace BTCPayServer.Models.StoreViewModels { public class StoreViewModel { - class Format - { - public string Name { get; set; } - public string Value { get; set; } - } public class DerivationScheme { public string Crypto { get; set; } @@ -50,36 +45,6 @@ namespace BTCPayServer.Models.StoreViewModels public List DerivationSchemes { get; set; } = new List(); - public void SetExchangeRates(CoinAverageExchange[] supportedList, string preferredExchange) - { - var defaultStore = preferredExchange ?? CoinAverageRateProvider.CoinAverageName; - var choices = supportedList.Select(o => new Format() { Name = o.Display, Value = o.Name }).ToArray(); - var chosen = choices.FirstOrDefault(f => f.Value == defaultStore) ?? choices.FirstOrDefault(); - Exchanges = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); - PreferredExchange = chosen.Value; - } - - public SelectList Exchanges { get; set; } - - [Display(Name = "Preferred price source (eg. bitfinex, bitstamp...)")] - public string PreferredExchange { get; set; } - - public string RateSource - { - get - { - return PreferredExchange == CoinAverageRateProvider.CoinAverageName ? "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 - { - get; - set; - } - [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/Rating/CurrencyPair.cs b/BTCPayServer/Rating/CurrencyPair.cs index 21a15bd30..f6341e7bd 100644 --- a/BTCPayServer/Rating/CurrencyPair.cs +++ b/BTCPayServer/Rating/CurrencyPair.cs @@ -30,6 +30,7 @@ namespace BTCPayServer.Rating if (str == null) throw new ArgumentNullException(nameof(str)); value = null; + str = str.Trim(); var splitted = str.Split('_'); if (splitted.Length != 2) return false; diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index 9c350bde9..61772a14f 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -20,6 +20,7 @@ namespace BTCPayServer.Rating DivideByZero, PreprocessError, RateUnavailable, + InvalidExchangeName, } public class RateRules { @@ -28,13 +29,6 @@ namespace BTCPayServer.Rating public List Errors = new List(); bool IsInvocation; - public override SyntaxNode VisitArgumentList(ArgumentListSyntax node) - { - IsInvocation = false; - var result = base.VisitArgumentList(node); - IsInvocation = true; - return result; - } public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) { if (IsInvocation) @@ -42,17 +36,22 @@ namespace BTCPayServer.Rating Errors.Add(RateRulesErrors.NestedInvocation); return base.VisitInvocationExpression(node); } - IsInvocation = true; - var result = base.VisitInvocationExpression(node); - IsInvocation = false; - return result; + if (node.Expression is IdentifierNameSyntax id) + { + IsInvocation = true; + var arglist = (ArgumentListSyntax)this.Visit(node.ArgumentList); + IsInvocation = false; + return SyntaxFactory.InvocationExpression(SyntaxFactory.IdentifierName(id.Identifier.ValueText.ToLowerInvariant()), arglist) + .WithTriviaFrom(id); + } + else + { + Errors.Add(RateRulesErrors.InvalidExchangeName); + return node; + } } public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) { - if (IsInvocation) - { - return SyntaxFactory.IdentifierName(node.Identifier.ValueText.ToLowerInvariant()); - } if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currencyPair)) { return SyntaxFactory.IdentifierName(currencyPair.ToString()) @@ -70,7 +69,7 @@ namespace BTCPayServer.Rating public Dictionary ExpressionsByPair = new Dictionary(); public override void VisitAssignmentExpression(AssignmentExpressionSyntax node) { - if (node.Kind() == SyntaxKind.SimpleAssignmentExpression + if (node.Kind() == SyntaxKind.SimpleAssignmentExpression && node.Left is IdentifierNameSyntax id && node.Right is ExpressionSyntax expression) { @@ -109,14 +108,22 @@ namespace BTCPayServer.Rating this.root = ruleList.GetSyntaxNode(); } public static bool TryParse(string str, out RateRules rules) + { + return TryParse(str, out rules, out var unused); + } + public static bool TryParse(string str, out RateRules rules, out List errors) { rules = null; + errors = null; var expression = CSharpSyntaxTree.ParseText(str, new CSharpParseOptions(LanguageVersion.Default).WithKind(SourceCodeKind.Script)); var rewriter = new NormalizeCurrencyPairsRewritter(); // Rename BTC_usd to BTC_USD and verify structure var root = rewriter.Visit(expression.GetRoot()); if (rewriter.Errors.Count > 0) + { + errors = rewriter.Errors; return false; + } rules = new RateRules(root); return true; } @@ -154,7 +161,7 @@ namespace BTCPayServer.Rating return CreateExpression($"ERR_NO_RULE_MATCH({p})"); var best = candidates .OrderBy(c => c.Prioriy) - .ThenBy(c => c.Expression.Span) + .ThenBy(c => c.Expression.Span.Start) .First(); return best.Expression; diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 72ce27d20..9900d76e2 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -158,7 +158,7 @@ namespace BTCPayServer.Services.Rates providers.Add(directProvider); if (_CoinAverageSettings.AvailableExchanges.ContainsKey(exchangeName)) { - providers.Add(new CoinAverageRateProvider(btcpayNetworkProvider) + providers.Add(new CoinAverageRateProvider() { Exchange = exchangeName, Authenticator = _CoinAverageSettings diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs index fda213130..229acc26c 100644 --- a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -55,13 +55,7 @@ namespace BTCPayServer.Services.Rates BTCPayNetworkProvider _NetworkProvider; public CoinAverageRateProvider() { - - } - public CoinAverageRateProvider(BTCPayNetworkProvider networkProvider) - { - if (networkProvider == null) - throw new ArgumentNullException(nameof(networkProvider)); - _NetworkProvider = networkProvider; + _NetworkProvider = new BTCPayNetworkProvider(NBitcoin.NetworkType.Mainnet); } static HttpClient _Client = new HttpClient(); diff --git a/BTCPayServer/Services/Rates/CoinAverageSettings.cs b/BTCPayServer/Services/Rates/CoinAverageSettings.cs index aff18270b..f3da666f6 100644 --- a/BTCPayServer/Services/Rates/CoinAverageSettings.cs +++ b/BTCPayServer/Services/Rates/CoinAverageSettings.cs @@ -30,6 +30,14 @@ namespace BTCPayServer.Services.Rates } public string Name { get; set; } public string Display { get; set; } + public string Url + { + get + { + return Name == CoinAverageRateProvider.CoinAverageName ? $"https://apiv2.bitcoinaverage.com/indices/global/ticker/short" + : $"https://apiv2.bitcoinaverage.com/exchanges/{Name}"; + } + } } public class CoinAverageExchanges : Dictionary { diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index cbb884604..3f3d4a4a8 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -16,7 +16,7 @@ diff --git a/BTCPayServer/Views/Invoice/ListInvoices.cshtml b/BTCPayServer/Views/Invoice/ListInvoices.cshtml index 250d0e475..e6699eda6 100644 --- a/BTCPayServer/Views/Invoice/ListInvoices.cshtml +++ b/BTCPayServer/Views/Invoice/ListInvoices.cshtml @@ -19,7 +19,7 @@

Create, search or pay an invoice. (Help)

- You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.
+ You can search for invoice Id, deposit address, price, order id, store id, any buyer information and any product information.
You can also apply filters to your search by searching for `filtername:value`, here is a list of supported filters

    diff --git a/BTCPayServer/Views/Shared/Confirm.cshtml b/BTCPayServer/Views/Shared/Confirm.cshtml index eb0c54855..4a44c7cce 100644 --- a/BTCPayServer/Views/Shared/Confirm.cshtml +++ b/BTCPayServer/Views/Shared/Confirm.cshtml @@ -15,7 +15,7 @@
    - +
    diff --git a/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml b/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml index 51c24364b..7974c8bb5 100644 --- a/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml +++ b/BTCPayServer/Views/Stores/AddDerivationScheme.cshtml @@ -125,7 +125,7 @@ @section Scripts { @await Html.PartialAsync("_ValidationScriptsPartial") diff --git a/BTCPayServer/Views/Stores/Rates.cshtml b/BTCPayServer/Views/Stores/Rates.cshtml new file mode 100644 index 000000000..10b2ee1e0 --- /dev/null +++ b/BTCPayServer/Views/Stores/Rates.cshtml @@ -0,0 +1,149 @@ +@model RatesViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Rates"; + ViewData.AddActivePage(BTCPayServer.Views.Stores.StoreNavPages.Rates); +} + +

    @ViewData["Title"]

    +@Html.Partial("_StatusMessage", TempData["TempDataProperty-StatusMessage"]) + +
    +
    +
    +
    +
    +
    +
    +
    + @if(Model.ShowScripting) + { +
    +
    Scripting
    + Rate script allows you to express precisely how you want to calculate rates for currency pairs. +

    + Supported exchanges are: + @for(int i = 0; i < Model.AvailableExchanges.Length; i++) + { + @Model.AvailableExchanges[i].Name@(i == Model.AvailableExchanges.Length - 1 ? "" : ",") + } +

    +

    Click here for more information

    +
    + } + @if(Model.TestRateRules != null) + { +
    +
    Test results:
    + + + @foreach(var result in Model.TestRateRules) + { + + @if(result.Error) + { + + } + else + { + + } + + + } + +
    @result.CurrencyPair @result.CurrencyPair@result.Rule
    +
    + } + @if(Model.ShowScripting) + { +
    +

    + The script language is composed of several rules composed of a currency pair and a mathematic expression. + The example below will use gdax for both LTC_USD and BTC_USD pairs. +

    +
    +                    
    +                            LTC_USD = gdax(LTC_USD);
    +                            BTC_USD = gdax(BTC_USD);
    +                        
    +                    
    +

    However, explicitely setting specific pairs like this can be a bit difficult. Instead, you can define a rule X_X which will match any currency pair. The following example will use gdax for getting the rate of any currency pair.

    +
    +                    
    +                            X_X = gdax(X_X);
    +                        
    +                    
    +

    However, gdax does not support the BTC_CAD pair. For this reason you can add a rule mapping all X_CAD to quadrigacx, a Canadian exchange.

    +
    +                    
    +                            X_CAD = quadrigacx(X_CAD);
    +                            X_X = gdax(X_X);
    +                        
    +                    
    +

    A given currency pair match the most specific rule. If two rules are matching and are as specific, the first rule will be chosen.

    +

    + But now, what if you want to support DOGE? The problem with DOGE is that most exchange do not have any pair for it. But bittrex has a DOGE_BTC pair.
    + Luckily, the rule engine allow you to reference rules: +

    +
    +                    
    +                            DOGE_X = bittrex(DOGE_BTC) * BTC_X
    +                            X_CAD = quadrigacx(X_CAD);
    +                            X_X = gdax(X_X);
    +                        
    +                    
    +

    With DOGE_USD will be expanded to bittrex(DOGE_BTC) * gdax(BTC_USD). And DOGE_CAD will be expanded to bittrex(DOGE_BTC) * quadrigacx(BTC_CAD)

    +
    + + + } + else + { +
    + + + +

    + Current price source is @Model.PreferredExchange. +

    +
    + + } +
    + + + +
    +
    +
    Testing
    + Enter currency pairs which you want to test against your rule (eg. DOGE_USD,DOGE_CAD,BTC_CAD,BTC_USD) +
    + + + + +
    + +
    + + +
    +
    +
    + +@section Scripts { + + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/Stores/StoreNavPages.cs b/BTCPayServer/Views/Stores/StoreNavPages.cs index 87c3cb516..14f54c253 100644 --- a/BTCPayServer/Views/Stores/StoreNavPages.cs +++ b/BTCPayServer/Views/Stores/StoreNavPages.cs @@ -11,6 +11,7 @@ namespace BTCPayServer.Views.Stores { public static string ActivePageKey => "ActivePage"; public static string Index => "Index"; + public static string Rates => "Rates"; public static string Checkout => "Checkout experience"; public static string Tokens => "Tokens"; @@ -20,6 +21,7 @@ namespace BTCPayServer.Views.Stores public static string CheckoutNavClass(ViewContext viewContext) => PageNavClass(viewContext, Checkout); public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); + public static string RatesNavClass(ViewContext viewContext) => PageNavClass(viewContext, Rates); public static string PageNavClass(ViewContext viewContext, string page) { diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index e35466c79..83e4b60d9 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -34,19 +34,6 @@
-
- - - -

- Current price source is @Model.PreferredExchange. -

-
-
- - - -
diff --git a/BTCPayServer/Views/Stores/Wallet.cshtml b/BTCPayServer/Views/Stores/Wallet.cshtml index 70fbeeee3..60265ca0f 100644 --- a/BTCPayServer/Views/Stores/Wallet.cshtml +++ b/BTCPayServer/Views/Stores/Wallet.cshtml @@ -65,7 +65,7 @@ @section Scripts { diff --git a/BTCPayServer/Views/Stores/_Nav.cshtml b/BTCPayServer/Views/Stores/_Nav.cshtml index 255dcb30d..369f388f3 100644 --- a/BTCPayServer/Views/Stores/_Nav.cshtml +++ b/BTCPayServer/Views/Stores/_Nav.cshtml @@ -3,6 +3,7 @@
- @if (Model.OnChainPayments.Count > 0) + @if(Model.OnChainPayments.Count > 0) {
@@ -198,7 +207,7 @@ - @foreach (var payment in Model.OnChainPayments) + @foreach(var payment in Model.OnChainPayments) { var replaced = payment.Replaced ? "class='linethrough'" : ""; @@ -217,7 +226,7 @@
} - @if (Model.OffChainPayments.Count > 0) + @if(Model.OffChainPayments.Count > 0) {
@@ -230,7 +239,7 @@ - @foreach (var payment in Model.OffChainPayments) + @foreach(var payment in Model.OffChainPayments) { @payment.Crypto @@ -253,7 +262,7 @@ - @foreach (var address in Model.Addresses) + @foreach(var address in Model.Addresses) { var current = address.Current ? "font-weight-bold" : ""; @@ -277,7 +286,7 @@ - @foreach (var evt in Model.Events) + @foreach(var evt in Model.Events) { @evt.Timestamp From 8f1324fdf372d493128d39d00806ee56acf24dec Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 02:16:12 +0900 Subject: [PATCH 027/119] Can clear email settings Fix #150 --- BTCPayServer/Controllers/ServerController.cs | 8 +++++++- BTCPayServer/Views/Server/Emails.cshtml | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index 8190ae220..a94d552d1 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -255,7 +255,7 @@ namespace BTCPayServer.Controllers } return View(model); } - else + else if(command == "Save") { ModelState.Remove(nameof(model.TestEmail)); if (!ModelState.IsValid) @@ -264,6 +264,12 @@ namespace BTCPayServer.Controllers model.StatusMessage = "Email settings saved"; return View(model); } + else + { + await _SettingsRepository.UpdateSetting(new EmailSettings()); + model.StatusMessage = "Email settings cleared"; + return View(model); + } } } } diff --git a/BTCPayServer/Views/Server/Emails.cshtml b/BTCPayServer/Views/Server/Emails.cshtml index f52bda25c..add24c1d0 100644 --- a/BTCPayServer/Views/Server/Emails.cshtml +++ b/BTCPayServer/Views/Server/Emails.cshtml @@ -57,6 +57,7 @@ +
From ce12e87b70bdbe1977b0084795037f29d6ec279b Mon Sep 17 00:00:00 2001 From: rockstardev Date: Thu, 3 May 2018 16:13:50 -0500 Subject: [PATCH 028/119] Restoring QR Code for 2Fact authentication, fix #147 --- BTCPayServer/Views/Manage/EnableAuthenticator.cshtml | 1 + BTCPayServer/wwwroot/js/qrcode.min.js | 1 + 2 files changed, 2 insertions(+) create mode 100644 BTCPayServer/wwwroot/js/qrcode.min.js diff --git a/BTCPayServer/Views/Manage/EnableAuthenticator.cshtml b/BTCPayServer/Views/Manage/EnableAuthenticator.cshtml index ab0187380..8eeb75748 100644 --- a/BTCPayServer/Views/Manage/EnableAuthenticator.cshtml +++ b/BTCPayServer/Views/Manage/EnableAuthenticator.cshtml @@ -22,6 +22,7 @@

Scan the QR Code or enter this key @Model.SharedKey into your two factor authenticator app. Spaces and casing do not matter.

+
  • diff --git a/BTCPayServer/wwwroot/js/qrcode.min.js b/BTCPayServer/wwwroot/js/qrcode.min.js new file mode 100644 index 000000000..d5f3ca88b --- /dev/null +++ b/BTCPayServer/wwwroot/js/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
    "),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); From 674cd1486df17af732234a54b65cbeb564163d1a Mon Sep 17 00:00:00 2001 From: rockstardev Date: Thu, 3 May 2018 16:38:40 -0500 Subject: [PATCH 029/119] Showing btcPaid once invoice is paid Fix #144 --- BTCPayServer/Views/Invoice/Checkout-Body.cshtml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index fff5ade47..05d2bf70b 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -66,9 +66,13 @@

    -
    +
    + {{ srvModel.btcPaid }} {{ srvModel.cryptoCode }} +
    +
    {{ srvModel.btcDue }} {{ srvModel.cryptoCode }}
    +
    1 {{ srvModel.cryptoCode }} = {{ srvModel.rate }}
    From 74ccc34c9c4e7cde6d9e3054494571b4672cfde4 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 11:48:03 +0900 Subject: [PATCH 030/119] Small enhancement on Rates page --- BTCPayServer/Controllers/StoresController.cs | 2 ++ BTCPayServer/Models/StoreViewModels/RatesViewModel.cs | 2 +- BTCPayServer/Views/Stores/Rates.cshtml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index bef367f05..2e1f78ca9 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -251,6 +251,8 @@ namespace BTCPayServer.Controllers else { blob.RateScript = rules.ToString(); + ModelState.Remove(nameof(model.Script)); + model.Script = blob.RateScript; } } rules = blob.GetRateRules(_NetworkProvider); diff --git a/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs index 8fb756fd5..a6e0c1346 100644 --- a/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/RatesViewModel.cs @@ -44,7 +44,7 @@ namespace BTCPayServer.Models.StoreViewModels public string ScriptTest { get; set; } public CoinAverageExchange[] AvailableExchanges { get; set; } - [Display(Name = "Multiply the original rate by ...")] + [Display(Name = "Multiply the rate by ...")] [Range(0.01, 10.0)] public double RateMultiplier { diff --git a/BTCPayServer/Views/Stores/Rates.cshtml b/BTCPayServer/Views/Stores/Rates.cshtml index 10b2ee1e0..d88ee35f7 100644 --- a/BTCPayServer/Views/Stores/Rates.cshtml +++ b/BTCPayServer/Views/Stores/Rates.cshtml @@ -48,7 +48,7 @@ { @result.CurrencyPair } - @result.Rule + @result.Rule } From 0a449e1e8e95e1ecd93460bc5d0937aecd998af4 Mon Sep 17 00:00:00 2001 From: rockstardev Date: Thu, 3 May 2018 16:51:04 -0500 Subject: [PATCH 031/119] Allowing custom HtmlTitle Fix #96 --- BTCPayServer/Controllers/InvoiceController.UI.cs | 1 + BTCPayServer/Controllers/StoresController.cs | 2 ++ BTCPayServer/Data/StoreData.cs | 1 + BTCPayServer/Models/InvoicingModels/PaymentModel.cs | 1 + .../Models/StoreViewModels/CheckoutExperienceViewModel.cs | 4 ++++ BTCPayServer/Views/Invoice/Checkout.cshtml | 2 +- BTCPayServer/Views/Stores/CheckoutExperience.cshtml | 5 +++++ 7 files changed, 15 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 93bd37004..7c14ebdd7 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -228,6 +228,7 @@ namespace BTCPayServer.Controllers OrderId = invoice.OrderId, InvoiceId = invoice.Id, DefaultLang = storeBlob.DefaultLang ?? "en-US", + HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", CustomCSSLink = storeBlob.CustomCSS?.AbsoluteUri, CustomLogoLink = storeBlob.CustomLogo?.AbsoluteUri, BtcAddress = paymentMethodDetails.GetPaymentDestination(), diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index bef367f05..9c6a227de 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -347,6 +347,7 @@ namespace BTCPayServer.Controllers vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.CustomCSS = storeBlob.CustomCSS?.AbsoluteUri; vm.CustomLogo = storeBlob.CustomLogo?.AbsoluteUri; + vm.HtmlTitle = storeBlob.HtmlTitle; return View(vm); } @@ -392,6 +393,7 @@ namespace BTCPayServer.Controllers blob.OnChainMinValue = onchainMinValue; blob.CustomLogo = string.IsNullOrWhiteSpace(model.CustomLogo) ? null : new Uri(model.CustomLogo, UriKind.Absolute); blob.CustomCSS = string.IsNullOrWhiteSpace(model.CustomCSS) ? null : new Uri(model.CustomCSS, UriKind.Absolute); + blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle; if (StoreData.SetStoreBlob(blob)) { needUpdate = true; diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 2d3c79042..30c86ad4e 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -306,6 +306,7 @@ namespace BTCPayServer.Data public Uri CustomLogo { get; set; } [JsonConverter(typeof(UriJsonConverter))] public Uri CustomCSS { get; set; } + public string HtmlTitle { get; set; } public bool RateScripting { get; set; } diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index 9cd26cbf1..e3dcaf149 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -13,6 +13,7 @@ namespace BTCPayServer.Models.InvoicingModels public string CryptoImage { get; set; } public string Link { get; set; } } + public string HtmlTitle { get; set; } public string CustomCSSLink { get; set; } public string CustomLogoLink { get; set; } public string DefaultLang { get; set; } diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index 758350d49..025315ad8 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -48,6 +48,10 @@ namespace BTCPayServer.Models.StoreViewModels [Url] public string CustomLogo { get; set; } + [Display(Name = "Custom HTML title to display on Checkout page")] + public string HtmlTitle { get; set; } + + public void SetCryptoCurrencies(ExplorerClientProvider explorerProvider, string defaultCrypto) { var choices = explorerProvider.GetAll().Select(o => new Format() { Name = o.Item1.CryptoCode, Value = o.Item1.CryptoCode }).ToArray(); diff --git a/BTCPayServer/Views/Invoice/Checkout.cshtml b/BTCPayServer/Views/Invoice/Checkout.cshtml index 3f3d4a4a8..6faf5668a 100644 --- a/BTCPayServer/Views/Invoice/Checkout.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout.cshtml @@ -11,7 +11,7 @@ - BTCPay Invoice + @Model.HtmlTitle diff --git a/BTCPayServer/Views/Stores/CheckoutExperience.cshtml b/BTCPayServer/Views/Stores/CheckoutExperience.cshtml index 8e2b29279..d1f05a234 100644 --- a/BTCPayServer/Views/Stores/CheckoutExperience.cshtml +++ b/BTCPayServer/Views/Stores/CheckoutExperience.cshtml @@ -26,6 +26,11 @@
    +
    + + + +
    From 14360bde7819c377e7211eb44883365c310db6b9 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 15:35:39 +0900 Subject: [PATCH 032/119] Use rate directly from some exchanges, fix bug in ServerSettings --- BTCPayServer.Tests/UnitTest1.cs | 38 +++++++++- BTCPayServer/BTCPayNetworkProvider.cs | 6 +- BTCPayServer/BTCPayServer.csproj | 4 +- BTCPayServer/Rating/CurrencyPair.cs | 30 +++++++- .../Rates/BTCPayRateProviderFactory.cs | 17 +++++ .../Services/Rates/CoinAverageRateProvider.cs | 14 +--- .../Rates/ExchangeSharpRateProvider.cs | 74 +++++++++++++++++++ BTCPayServer/Views/Server/Rates.cshtml | 2 +- BTCPayServer/Views/Stores/Rates.cshtml | 2 +- 9 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 3d12815c8..529851724 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -36,6 +36,7 @@ using BTCPayServer.Services.Stores; using System.Net.Http; using System.Text; using BTCPayServer.Rating; +using ExchangeSharp; namespace BTCPayServer.Tests { @@ -1291,11 +1292,37 @@ namespace BTCPayServer.Tests Assert.Null(rates.GetRate(QuadrigacxRateProvider.QuadrigacxName, CurrencyPair.Parse("LTC_USD"))); } + [Fact] + public void CanQueryDirectProviders() + { + var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); + var factory = CreateBTCPayRateFactory(provider); + + foreach (var result in factory + .DirectProviders + .Select(p => (ExpectedName: p.Key, ResultAsync: p.Value.GetRatesAsync())) + .ToList()) + { + var exchangeRates = result.ResultAsync.Result; + Assert.NotNull(exchangeRates); + Assert.NotEmpty(exchangeRates); + Assert.NotEmpty(exchangeRates.ByExchange[result.ExpectedName]); + + // This check if the currency pair is using right currency pair + Assert.Contains(exchangeRates.ByExchange[result.ExpectedName], + e => ( e.CurrencyPair == new CurrencyPair("BTC", "USD") || + e.CurrencyPair == new CurrencyPair("BTC", "EUR") || + e.CurrencyPair == new CurrencyPair("BTC", "USDT")) + && e.Value > 1.0m // 1BTC will always be more than 1USD + ); + } + } + [Fact] public void CanGetRateCryptoCurrenciesByDefault() { var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); - var factory = new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + var factory = CreateBTCPayRateFactory(provider); var pairs = provider.GetAll() @@ -1304,13 +1331,18 @@ namespace BTCPayServer.Tests var rules = new StoreBlob().GetDefaultRateRules(provider); var result = factory.FetchRates(pairs, rules); - foreach(var value in result) + foreach (var value in result) { var rateResult = value.Value.GetAwaiter().GetResult(); Assert.NotNull(rateResult.Value); } } + private static BTCPayRateProviderFactory CreateBTCPayRateFactory(BTCPayNetworkProvider provider) + { + return new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + } + [Fact] public void CheckRatesProvider() { @@ -1323,7 +1355,7 @@ namespace BTCPayServer.Tests RateRules.TryParse("X_X = coinaverage(X_X);", out var rateRules); - var factory = new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + var factory = CreateBTCPayRateFactory(provider); factory.DirectProviders.Clear(); factory.CacheSpan = TimeSpan.FromSeconds(10); diff --git a/BTCPayServer/BTCPayNetworkProvider.cs b/BTCPayServer/BTCPayNetworkProvider.cs index 1717adce2..ac441a39d 100644 --- a/BTCPayServer/BTCPayNetworkProvider.cs +++ b/BTCPayServer/BTCPayNetworkProvider.cs @@ -86,7 +86,11 @@ namespace BTCPayServer public BTCPayNetwork GetNetwork(string cryptoCode) { - _Networks.TryGetValue(cryptoCode.ToUpperInvariant(), out BTCPayNetwork network); + if(!_Networks.TryGetValue(cryptoCode.ToUpperInvariant(), out BTCPayNetwork network)) + { + if (cryptoCode == "XBT") + return GetNetwork("BTC"); + } return network; } } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 8b3e700f3..9b587a806 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -31,7 +31,7 @@ - + @@ -40,7 +40,7 @@ - + diff --git a/BTCPayServer/Rating/CurrencyPair.cs b/BTCPayServer/Rating/CurrencyPair.cs index f6341e7bd..7ba9cfe5a 100644 --- a/BTCPayServer/Rating/CurrencyPair.cs +++ b/BTCPayServer/Rating/CurrencyPair.cs @@ -7,6 +7,7 @@ namespace BTCPayServer.Rating { public class CurrencyPair { + static readonly BTCPayNetworkProvider _NetworkProvider = new BTCPayNetworkProvider(NBitcoin.NetworkType.Mainnet); public CurrencyPair(string left, string right) { if (right == null) @@ -31,11 +32,32 @@ namespace BTCPayServer.Rating throw new ArgumentNullException(nameof(str)); value = null; str = str.Trim(); - var splitted = str.Split('_'); - if (splitted.Length != 2) + if (str.Length > 12) return false; - value = new CurrencyPair(splitted[0], splitted[1]); - return true; + var splitted = str.Split(new[] { '_', '-' }, StringSplitOptions.RemoveEmptyEntries); + if (splitted.Length == 2) + { + value = new CurrencyPair(splitted[0], splitted[1]); + return true; + } + else if (splitted.Length == 1) + { + var currencyPair = splitted[0]; + if (currencyPair.Length < 6 || currencyPair.Length > 10) + return false; + for (int i = 3; i < 5; i++) + { + var potentialCryptoName = currencyPair.Substring(0, i); + var network = _NetworkProvider.GetNetwork(potentialCryptoName); + if (network != null) + { + value = new CurrencyPair(network.CryptoCode, currencyPair.Substring(i)); + return true; + } + } + } + + return false; } diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 9900d76e2..91104b3ba 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Rating; +using ExchangeSharp; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -62,7 +63,23 @@ namespace BTCPayServer.Services.Rates private void InitExchanges() { + // We need to be careful to only add exchanges which OnGetTickers implementation make only 1 request + DirectProviders.Add("binance", new ExchangeSharpRateProvider("binance", new ExchangeBinanceAPI(), true)); + DirectProviders.Add("bittrex", new ExchangeSharpRateProvider("bittrex", new ExchangeBittrexAPI(), true)); + DirectProviders.Add("poloniex", new ExchangeSharpRateProvider("poloniex", new ExchangePoloniexAPI(), false)); + DirectProviders.Add("hitbtc", new ExchangeSharpRateProvider("hitbtc", new ExchangeHitbtcAPI(), false)); + + // Handmade providers + DirectProviders.Add("bitpay", new BitpayRateProvider(new NBitpayClient.Bitpay(new NBitcoin.Key(), new Uri("https://bitpay.com/")))); DirectProviders.Add(QuadrigacxRateProvider.QuadrigacxName, new QuadrigacxRateProvider()); + + // Those exchanges make multiple requests when calling GetTickers so we remove them + //DirectProviders.Add("kraken", new ExchangeSharpRateProvider("kraken", new ExchangeKrakenAPI(), true)); + //DirectProviders.Add("gdax", new ExchangeSharpRateProvider("gdax", new ExchangeGdaxAPI())); + //DirectProviders.Add("gemini", new ExchangeSharpRateProvider("gemini", new ExchangeGeminiAPI())); + //DirectProviders.Add("bitfinex", new ExchangeSharpRateProvider("bitfinex", new ExchangeBitfinexAPI())); + //DirectProviders.Add("okex", new ExchangeSharpRateProvider("okex", new ExchangeOkexAPI())); + //DirectProviders.Add("bitstamp", new ExchangeSharpRateProvider("bitstamp", new ExchangeBitstampAPI())); } diff --git a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs index 229acc26c..0ee5c26b1 100644 --- a/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs +++ b/BTCPayServer/Services/Rates/CoinAverageRateProvider.cs @@ -52,10 +52,9 @@ namespace BTCPayServer.Services.Rates public class CoinAverageRateProvider : IRateProvider { public const string CoinAverageName = "coinaverage"; - BTCPayNetworkProvider _NetworkProvider; public CoinAverageRateProvider() { - _NetworkProvider = new BTCPayNetworkProvider(NBitcoin.NetworkType.Mainnet); + } static HttpClient _Client = new HttpClient(); @@ -112,16 +111,11 @@ namespace BTCPayServer.Services.Rates if (!TryToDecimal(prop, out decimal value)) continue; exchangeRate.Value = value; - for (int i = 3; i < 5; i++) + if(CurrencyPair.TryParse(prop.Name, out var pair)) { - var potentialCryptoName = prop.Name.Substring(0, i); - if (_NetworkProvider.GetNetwork(potentialCryptoName) != null) - { - exchangeRate.CurrencyPair = new CurrencyPair(potentialCryptoName, prop.Name.Substring(i)); - } - } - if (exchangeRate.CurrencyPair != null) + exchangeRate.CurrencyPair = pair; exchangeRates.Add(exchangeRate); + } } return exchangeRates; } diff --git a/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs b/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs new file mode 100644 index 000000000..9aeaf7fb1 --- /dev/null +++ b/BTCPayServer/Services/Rates/ExchangeSharpRateProvider.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BTCPayServer.Rating; +using ExchangeSharp; + +namespace BTCPayServer.Services.Rates +{ + public class ExchangeSharpRateProvider : IRateProvider + { + readonly ExchangeAPI _ExchangeAPI; + readonly string _ExchangeName; + public ExchangeSharpRateProvider(string exchangeName, ExchangeAPI exchangeAPI, bool reverseCurrencyPair = false) + { + if (exchangeAPI == null) + throw new ArgumentNullException(nameof(exchangeAPI)); + exchangeAPI.RequestTimeout = TimeSpan.FromSeconds(5.0); + _ExchangeAPI = exchangeAPI; + _ExchangeName = exchangeName; + ReverseCurrencyPair = reverseCurrencyPair; + } + + public bool ReverseCurrencyPair + { + get; set; + } + + public async Task GetRatesAsync() + { + await new SynchronizationContextRemover(); + var rates = await _ExchangeAPI.GetTickersAsync(); + lock (notFoundSymbols) + { + var exchangeRates = + rates.Select(t => CreateExchangeRate(t)) + .Where(t => t != null) + .ToArray(); + return new ExchangeRates(exchangeRates); + } + } + + // ExchangeSymbolToGlobalSymbol throws exception which would kill perf + HashSet notFoundSymbols = new HashSet(); + private ExchangeRate CreateExchangeRate(KeyValuePair ticker) + { + if (notFoundSymbols.Contains(ticker.Key)) + return null; + try + { + var tickerName = _ExchangeAPI.ExchangeSymbolToGlobalSymbol(ticker.Key); + if (!CurrencyPair.TryParse(tickerName, out var pair)) + { + notFoundSymbols.Add(ticker.Key); + return null; + } + if(ReverseCurrencyPair) + pair = new CurrencyPair(pair.Right, pair.Left); + var rate = new ExchangeRate(); + rate.CurrencyPair = pair; + rate.Exchange = _ExchangeName; + rate.Value = ticker.Value.Bid; + return rate; + } + catch (ArgumentException) + { + notFoundSymbols.Add(ticker.Key); + return null; + } + } + } +} diff --git a/BTCPayServer/Views/Server/Rates.cshtml b/BTCPayServer/Views/Server/Rates.cshtml index 61641a7c4..63d6e4394 100644 --- a/BTCPayServer/Views/Server/Rates.cshtml +++ b/BTCPayServer/Views/Server/Rates.cshtml @@ -1,4 +1,4 @@ -@model RatesViewModel +@model BTCPayServer.Models.ServerViewModels.RatesViewModel @{ ViewData.SetActivePageAndTitle(ServerNavPages.Rates); } diff --git a/BTCPayServer/Views/Stores/Rates.cshtml b/BTCPayServer/Views/Stores/Rates.cshtml index d88ee35f7..4aede496d 100644 --- a/BTCPayServer/Views/Stores/Rates.cshtml +++ b/BTCPayServer/Views/Stores/Rates.cshtml @@ -1,4 +1,4 @@ -@model RatesViewModel +@model BTCPayServer.Models.StoreViewModels.RatesViewModel.RatesViewModel @{ Layout = "../Shared/_NavLayout.cshtml"; ViewData["Title"] = "Rates"; From e2533a93e3b00cace70c0792ef33388208f91ba5 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 15:54:12 +0900 Subject: [PATCH 033/119] Fix set email screen --- BTCPayServer/Controllers/ServerController.cs | 21 ++++++++----------- .../ServerViewModels/EmailsViewModel.cs | 3 +-- BTCPayServer/Services/Mails/EmailSettings.cs | 5 ----- BTCPayServer/Views/Server/Emails.cshtml | 2 +- BTCPayServer/Views/Stores/Rates.cshtml | 2 +- 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/BTCPayServer/Controllers/ServerController.cs b/BTCPayServer/Controllers/ServerController.cs index a94d552d1..54ec88151 100644 --- a/BTCPayServer/Controllers/ServerController.cs +++ b/BTCPayServer/Controllers/ServerController.cs @@ -241,10 +241,16 @@ namespace BTCPayServer.Controllers { if (command == "Test") { - if (!ModelState.IsValid) - return View(model); try { + if(string.IsNullOrWhiteSpace(model.Settings.From) + || string.IsNullOrWhiteSpace(model.TestEmail) + || string.IsNullOrWhiteSpace(model.Settings.Login) + || string.IsNullOrWhiteSpace(model.Settings.Server)) + { + model.StatusMessage = "Error: Required fields missing"; + return View(model); + } var client = model.Settings.CreateSmtpClient(); await client.SendMailAsync(model.Settings.From, model.TestEmail, "BTCPay test", "BTCPay test"); model.StatusMessage = "Email sent to " + model.TestEmail + ", please, verify you received it"; @@ -255,21 +261,12 @@ namespace BTCPayServer.Controllers } return View(model); } - else if(command == "Save") + else // if(command == "Save") { - ModelState.Remove(nameof(model.TestEmail)); - if (!ModelState.IsValid) - return View(model); await _SettingsRepository.UpdateSetting(model.Settings); model.StatusMessage = "Email settings saved"; return View(model); } - else - { - await _SettingsRepository.UpdateSetting(new EmailSettings()); - model.StatusMessage = "Email settings cleared"; - return View(model); - } } } } diff --git a/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs b/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs index 47307dfc2..11c983beb 100644 --- a/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs +++ b/BTCPayServer/Models/ServerViewModels/EmailsViewModel.cs @@ -18,8 +18,7 @@ namespace BTCPayServer.Models.ServerViewModels { get; set; } - - [Required] + [EmailAddress] public string TestEmail { diff --git a/BTCPayServer/Services/Mails/EmailSettings.cs b/BTCPayServer/Services/Mails/EmailSettings.cs index 289b18c3a..22840cc36 100644 --- a/BTCPayServer/Services/Mails/EmailSettings.cs +++ b/BTCPayServer/Services/Mails/EmailSettings.cs @@ -10,30 +10,25 @@ namespace BTCPayServer.Services.Mails { public class EmailSettings { - [Required] public string Server { get; set; } - [Required] public int? Port { get; set; } - [Required] public String Login { get; set; } - [Required] public String Password { get; set; } - [EmailAddress] public string From { diff --git a/BTCPayServer/Views/Server/Emails.cshtml b/BTCPayServer/Views/Server/Emails.cshtml index add24c1d0..5cb41aae5 100644 --- a/BTCPayServer/Views/Server/Emails.cshtml +++ b/BTCPayServer/Views/Server/Emails.cshtml @@ -53,11 +53,11 @@
    +
    -
    diff --git a/BTCPayServer/Views/Stores/Rates.cshtml b/BTCPayServer/Views/Stores/Rates.cshtml index 4aede496d..4aca36b81 100644 --- a/BTCPayServer/Views/Stores/Rates.cshtml +++ b/BTCPayServer/Views/Stores/Rates.cshtml @@ -1,4 +1,4 @@ -@model BTCPayServer.Models.StoreViewModels.RatesViewModel.RatesViewModel +@model BTCPayServer.Models.StoreViewModels.RatesViewModel @{ Layout = "../Shared/_NavLayout.cshtml"; ViewData["Title"] = "Rates"; From 180341576b8b3557929cdd6e0804ab63987d0d6e Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 15:55:09 +0900 Subject: [PATCH 034/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 9b587a806..083286534 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.1.96 + 1.0.2.0 NU1701,CA1816,CA1308,CA1810,CA2208 From 93254416932febba3bc9675393a90f26d9ac0d3e Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 16:09:43 +0900 Subject: [PATCH 035/119] fix typo --- BTCPayServer/Controllers/StoresController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 99fa29bd7..6a22d7f6d 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -313,7 +313,7 @@ namespace BTCPayServer.Controllers { return View("Confirm", new ConfirmModel() { - Action = nameof(ShowRateRulesPost), + Action = "Continue", Title = "Rate rule scripting", Description = scripting ? "This action will mofify your current rate sources. Are you sure to turn on rate rules scripting? (Advanced users)" From 57effe318b877b92830d1c589588f2f7ce87c215 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 21:41:50 +0900 Subject: [PATCH 036/119] Fix missing URL for invoice --- BTCPayServer/BTCPayServer.csproj | 2 +- BTCPayServer/Services/Invoices/InvoiceEntity.cs | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 083286534..91f0d9936 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.2.0 + 1.0.2.1 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 5fd74741b..8185a394c 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -336,7 +336,6 @@ namespace BTCPayServer.Services.Invoices Currency = ProductInformation.Currency, Flags = new Flags() { Refundable = Refundable } }; - dto.CryptoInfo = new List(); foreach (var info in this.GetPaymentMethods(networkProvider)) { @@ -404,7 +403,7 @@ namespace BTCPayServer.Services.Invoices dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for dto.Guid = Guid.NewGuid().ToString(); - + dto.Url = dto.CryptoInfo[0].Url; dto.ExceptionStatus = ExceptionStatus == null ? new JValue(false) : new JValue(ExceptionStatus); return dto; } @@ -745,7 +744,7 @@ namespace BTCPayServer.Services.Invoices paymentData.Outpoint = Outpoint; return paymentData; } - if(GetPaymentMethodId().PaymentType== PaymentTypes.LightningLike) + if (GetPaymentMethodId().PaymentType == PaymentTypes.LightningLike) { return JsonConvert.DeserializeObject(CryptoPaymentData); } From 8a4da361fdca0ba07b517b38ac662749c939c71f Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 4 May 2018 22:05:40 +0900 Subject: [PATCH 037/119] Fix bug about invoice URL --- BTCPayServer/BTCPayServer.csproj | 2 +- .../Services/Invoices/InvoiceEntity.cs | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 91f0d9936..cd4e8d50f 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.0 - 1.0.2.1 + 1.0.2.2 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 8185a394c..0468de9f8 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -336,6 +336,8 @@ namespace BTCPayServer.Services.Invoices Currency = ProductInformation.Currency, Flags = new Flags() { Refundable = Refundable } }; + + dto.Url = ServerUrl.WithTrailingSlash() + $"invoice?id=" + Id; dto.CryptoInfo = new List(); foreach (var info in this.GetPaymentMethods(networkProvider)) { @@ -358,23 +360,22 @@ namespace BTCPayServer.Services.Invoices { { ProductInformation.Currency, (double)cryptoInfo.Rate } }; - + var paymentId = info.GetId(); var scheme = info.Network.UriScheme; - var cryptoSuffix = cryptoInfo.CryptoCode == "BTC" ? "" : "/" + cryptoInfo.CryptoCode; - cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"invoice{cryptoSuffix}?id=" + Id; + cryptoInfo.Url = ServerUrl.WithTrailingSlash() + $"i/{paymentId}/{Id}"; - if (info.GetId().PaymentType == PaymentTypes.BTCLike) + if (paymentId.PaymentType == PaymentTypes.BTCLike) { cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() { - BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", - BIP72b = $"{scheme}:?r={ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}")}", - BIP73 = ServerUrl.WithTrailingSlash() + ($"i/{Id}{cryptoSuffix}"), + BIP72 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}&r={cryptoInfo.Url}", + BIP72b = $"{scheme}:?r={cryptoInfo.Url}", + BIP73 = cryptoInfo.Url, BIP21 = $"{scheme}:{cryptoInfo.Address}?amount={cryptoInfo.Due}", }; } - var paymentId = info.GetId(); + if (paymentId.PaymentType == PaymentTypes.LightningLike) { cryptoInfo.PaymentUrls = new NBitpayClient.InvoicePaymentUrls() @@ -385,7 +386,6 @@ namespace BTCPayServer.Services.Invoices #pragma warning disable CS0618 if (info.CryptoCode == "BTC" && paymentId.PaymentType == PaymentTypes.BTCLike) { - dto.Url = cryptoInfo.Url; dto.BTCPrice = cryptoInfo.Price; dto.Rate = cryptoInfo.Rate; dto.ExRates = cryptoInfo.ExRates; @@ -403,7 +403,6 @@ namespace BTCPayServer.Services.Invoices dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for dto.Guid = Guid.NewGuid().ToString(); - dto.Url = dto.CryptoInfo[0].Url; dto.ExceptionStatus = ExceptionStatus == null ? new JValue(false) : new JValue(ExceptionStatus); return dto; } From c3d73236e07e7fc85c71df3e699aceb2660d1437 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Fri, 4 May 2018 16:15:34 +0200 Subject: [PATCH 038/119] start work on payment tolerance feature --- BTCPayServer/Controllers/InvoiceController.cs | 1 + BTCPayServer/Controllers/StoresController.cs | 2 ++ BTCPayServer/Data/StoreData.cs | 5 ++++ .../InvoiceNotificationManager.cs | 1 + BTCPayServer/HostedServices/InvoiceWatcher.cs | 23 ++++++++++++++++--- BTCPayServer/Models/InvoiceResponse.cs | 2 +- .../Models/StoreViewModels/StoreViewModel.cs | 8 +++++++ .../Services/Invoices/InvoiceEntity.cs | 1 + BTCPayServer/Views/Stores/UpdateStore.cshtml | 5 ++++ 9 files changed, 44 insertions(+), 4 deletions(-) diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index 96299651b..21f2fa83a 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -98,6 +98,7 @@ namespace BTCPayServer.Controllers entity.ExtendedNotifications = invoice.ExtendedNotifications; entity.NotificationURL = notificationUri?.AbsoluteUri; entity.BuyerInformation = Map(invoice); + entity.PaymentTolerance = storeBlob.PaymentTolerance; //Another way of passing buyer info to support FillBuyerInfo(invoice.Buyer, entity.BuyerInformation); if (entity?.BuyerInformation?.BuyerEmail != null) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 6a22d7f6d..2c4b42a60 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -431,6 +431,7 @@ namespace BTCPayServer.Controllers vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; + vm.PaymentTolerance = storeBlob.PaymentTolerance; return View(vm); } @@ -496,6 +497,7 @@ namespace BTCPayServer.Controllers blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; + blob.PaymentTolerance = model.PaymentTolerance; if (StoreData.SetStoreBlob(blob)) { diff --git a/BTCPayServer/Data/StoreData.cs b/BTCPayServer/Data/StoreData.cs index 30c86ad4e..9fa4224a2 100644 --- a/BTCPayServer/Data/StoreData.cs +++ b/BTCPayServer/Data/StoreData.cs @@ -247,6 +247,7 @@ namespace BTCPayServer.Data { InvoiceExpiration = 15; MonitoringExpiration = 60; + PaymentTolerance = 0; RequiresRefundEmail = true; } public bool NetworkFeeDisabled @@ -326,6 +327,10 @@ namespace BTCPayServer.Data } } + [DefaultValue(0)] + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)] + public double PaymentTolerance { get; set; } + public BTCPayServer.Rating.RateRules GetRateRules(BTCPayNetworkProvider networkProvider) { if (!RateScripting || diff --git a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs index 86f24d7d4..25da15e71 100644 --- a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs +++ b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs @@ -312,6 +312,7 @@ namespace BTCPayServer.HostedServices { if (e.Name == "invoice_expired" || e.Name == "invoice_paidInFull" || + e.Name == "invoice_paidWithinTolerance" || e.Name == "invoice_failedToConfirm" || e.Name == "invoice_markedInvalid" || e.Name == "invoice_failedToConfirm" || diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs index 5aef0a738..d745de69e 100644 --- a/BTCPayServer/HostedServices/InvoiceWatcher.cs +++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs @@ -96,11 +96,28 @@ namespace BTCPayServer.HostedServices } } - if (accounting.Paid < accounting.TotalDue && invoice.GetPayments().Count != 0 && invoice.ExceptionStatus != "paidPartial") + if (accounting.Paid < accounting.TotalDue && invoice.GetPayments().Count != 0) { - invoice.ExceptionStatus = "paidPartial"; - context.MarkDirty(); + if (invoice.PaymentTolerance > 0) + { + var tolerantAmount = (accounting.TotalDue.Satoshi * (invoice.PaymentTolerance / 100)); + var minimumTotalDue = accounting.TotalDue.Satoshi - tolerantAmount; + if (accounting.Paid.Satoshi >= minimumTotalDue) + { + context.Events.Add(new InvoiceEvent(invoice, 1003, "invoide_paidWithinTolerance")); + invoice.ExceptionStatus = "paidWithinTolerance"; + invoice.Status = "paid"; + } + } + + if (invoice.ExceptionStatus != "paidPartial") + { + invoice.ExceptionStatus = "paidPartial"; + context.MarkDirty(); + } } + + } // Just make sure RBF did not cancelled a payment diff --git a/BTCPayServer/Models/InvoiceResponse.cs b/BTCPayServer/Models/InvoiceResponse.cs index 4ff6f21ed..33fd3d951 100644 --- a/BTCPayServer/Models/InvoiceResponse.cs +++ b/BTCPayServer/Models/InvoiceResponse.cs @@ -178,7 +178,7 @@ namespace BTCPayServer.Models } //"exceptionStatus":false - //Can be `paidPartial`, `paidOver`, or false + //Can be `paidPartial`, `paidOver`, `paidWithinTolerance` or false [JsonProperty("exceptionStatus")] public JToken ExceptionStatus { diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index ee4fbf318..c2fadcdd3 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -85,5 +85,13 @@ namespace BTCPayServer.Models.StoreViewModels { get; set; } = new List(); + + [Display(Name = "Consider the invoice paid even if the paid amount is ... % less than expected")] + [Range(0, 100)] + public double PaymentTolerance + { + get; + set; + } } } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 0468de9f8..e30181706 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -314,6 +314,7 @@ namespace BTCPayServer.Services.Invoices } public bool ExtendedNotifications { get; set; } public List Events { get; internal set; } + public double PaymentTolerance { get; set; } public bool IsExpired() { diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index 83e4b60d9..cf5d2ef12 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -44,6 +44,11 @@ +
    + + + +
    + From 9afc143801e83e8c3cca8151e1a2b813755d7d7b Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 11 May 2018 22:38:31 +0900 Subject: [PATCH 085/119] Use decimals and fix invoices --- BTCPayServer.Tests/UnitTest1.cs | 40 +++++++++---------- .../Controllers/AppsController.PointOfSale.cs | 6 +-- .../InvoiceNotificationManager.cs | 2 +- BTCPayServer/Hosting/BTCpayMiddleware.cs | 5 ++- BTCPayServer/Models/InvoiceResponse.cs | 4 +- .../InvoicingModels/CreateInvoiceModel.cs | 2 +- .../Services/Invoices/InvoiceEntity.cs | 4 +- 7 files changed, 32 insertions(+), 31 deletions(-) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 630a7c3af..f82a4bc0a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -276,7 +276,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice(new Invoice() { Buyer = new Buyer() { email = "test@fwf.com" }, - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -308,7 +308,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice(new Invoice() { Buyer = new Buyer() { email = "test@fwf.com" }, - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -440,7 +440,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 0.01, + Price = 0.01m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -473,7 +473,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 0.01, + Price = 0.01m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -501,7 +501,7 @@ namespace BTCPayServer.Tests await Task.Delay(TimeSpan.FromSeconds(RandomUtils.GetUInt32() % 5)); var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice() { - Price = 0.01, + Price = 0.01m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -554,7 +554,7 @@ namespace BTCPayServer.Tests acc.RegisterDerivationScheme("BTC"); var invoice = acc.BitPay.CreateInvoice(new Invoice() { - Price = 5.0, + Price = 5.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -656,7 +656,7 @@ namespace BTCPayServer.Tests user.RegisterDerivationScheme("BTC"); var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD" }, Facade.Merchant); var payment1 = invoice.BtcDue + Money.Coins(0.0001m); @@ -756,7 +756,7 @@ namespace BTCPayServer.Tests message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey))); var invoice = new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD" }; message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8, "application/json"); @@ -798,7 +798,7 @@ namespace BTCPayServer.Tests storeController.Rates(vm).Wait(); var invoice2 = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -822,7 +822,7 @@ namespace BTCPayServer.Tests // First we try payment with a merchant having only BTC var invoice1 = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -840,7 +840,7 @@ namespace BTCPayServer.Tests var invoice2 = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -896,7 +896,7 @@ namespace BTCPayServer.Tests // Despite it is called BitcoinAddress it should be LTC because BTC is not available Assert.Null(invoice.BitcoinAddress); - Assert.NotEqual(1.0, invoice.Rate); + Assert.NotEqual(1.0m, invoice.Rate); Assert.NotEqual(invoice.BtcDue, invoice.CryptoInfo[0].Due); // Should be BTC rate cashCow.SendToAddress(invoiceAddress, invoice.CryptoInfo[0].Due); @@ -983,7 +983,7 @@ namespace BTCPayServer.Tests // First we try payment with a merchant having only BTC var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1015,7 +1015,7 @@ namespace BTCPayServer.Tests user.RegisterDerivationScheme("LTC"); invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1137,7 +1137,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 1.5, + Price = 1.5m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1150,7 +1150,7 @@ namespace BTCPayServer.Tests invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5.5, + Price = 5.5m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1199,7 +1199,7 @@ namespace BTCPayServer.Tests Assert.Equal("$5.00", vmview.Items[0].Price.Formatted); Assert.IsType(apps.ViewPointOfSale(appId, 0, "orange").Result); var invoice = user.BitPay.GetInvoices().First(); - Assert.Equal(10.00, invoice.Price); + Assert.Equal(10.00m, invoice.Price); Assert.Equal("CAD", invoice.Currency); Assert.Equal("orange", invoice.ItemDesc); } @@ -1250,7 +1250,7 @@ namespace BTCPayServer.Tests user.RegisterDerivationScheme("BTC"); var invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", @@ -1355,12 +1355,12 @@ namespace BTCPayServer.Tests { var localInvoice = user.BitPay.GetInvoice(invoice.Id, Facade.Merchant); Assert.Equal("complete", localInvoice.Status); - Assert.NotEqual(0.0, localInvoice.Rate); + Assert.NotEqual(0.0m, localInvoice.Rate); }); invoice = user.BitPay.CreateInvoice(new Invoice() { - Price = 5000.0, + Price = 5000.0m, Currency = "USD", PosData = "posData", OrderId = "orderId", diff --git a/BTCPayServer/Controllers/AppsController.PointOfSale.cs b/BTCPayServer/Controllers/AppsController.PointOfSale.cs index 76b0278d1..27290739b 100644 --- a/BTCPayServer/Controllers/AppsController.PointOfSale.cs +++ b/BTCPayServer/Controllers/AppsController.PointOfSale.cs @@ -163,7 +163,7 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{appId}/pos")] [IgnoreAntiforgeryToken] - public async Task ViewPointOfSale(string appId, double amount, string choiceKey) + public async Task ViewPointOfSale(string appId, decimal amount, string choiceKey) { var app = await GetApp(appId, AppType.PointOfSale); if (string.IsNullOrEmpty(choiceKey) && amount <= 0) @@ -178,7 +178,7 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId }); } string title = null; - double price = 0.0; + var price = 0.0m; if (!string.IsNullOrEmpty(choiceKey)) { var choices = Parse(settings.Template, settings.Currency); @@ -186,7 +186,7 @@ namespace BTCPayServer.Controllers if (choice == null) return NotFound(); title = choice.Title; - price = (double)choice.Price.Value; + price = choice.Price.Value; } else { diff --git a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs index 9845b2d53..0a2d9a057 100644 --- a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs +++ b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs @@ -207,7 +207,7 @@ namespace BTCPayServer.HostedServices if (btcCryptoInfo != null) { #pragma warning disable CS0618 - notification.Rate = (double)dto.Rate; + notification.Rate = dto.Rate; notification.Url = dto.Url; notification.BTCDue = dto.BTCDue; notification.BTCPaid = dto.BTCPaid; diff --git a/BTCPayServer/Hosting/BTCpayMiddleware.cs b/BTCPayServer/Hosting/BTCpayMiddleware.cs index bb80f20aa..a74c8d723 100644 --- a/BTCPayServer/Hosting/BTCpayMiddleware.cs +++ b/BTCPayServer/Hosting/BTCpayMiddleware.cs @@ -79,12 +79,13 @@ namespace BTCPayServer.Hosting if (!httpContext.Request.Path.HasValue) return false; + var isJson = (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase); var path = httpContext.Request.Path.Value; if ( bitpayAuth && path == "/invoices" && httpContext.Request.Method == "POST" && - (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + isJson) return true; if ( @@ -96,7 +97,7 @@ namespace BTCPayServer.Hosting if ( path.StartsWith("/invoices/", StringComparison.OrdinalIgnoreCase) && httpContext.Request.Method == "GET" && - (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + (isJson || httpContext.Request.Query.ContainsKey("token"))) return true; if (path.Equals("/rates", StringComparison.OrdinalIgnoreCase) && diff --git a/BTCPayServer/Models/InvoiceResponse.cs b/BTCPayServer/Models/InvoiceResponse.cs index 4ff6f21ed..fd3bab95c 100644 --- a/BTCPayServer/Models/InvoiceResponse.cs +++ b/BTCPayServer/Models/InvoiceResponse.cs @@ -79,7 +79,7 @@ namespace BTCPayServer.Models //"price":5 [JsonProperty("price")] - public double Price + public decimal Price { get; set; } @@ -94,7 +94,7 @@ namespace BTCPayServer.Models //"exRates":{"USD":4320.02} [JsonProperty("exRates")] [Obsolete("Use CryptoInfo.ExRates instead")] - public Dictionary ExRates + public Dictionary ExRates { get; set; } diff --git a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs index 39b77c8dc..9ac99d667 100644 --- a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs +++ b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs @@ -14,7 +14,7 @@ namespace BTCPayServer.Models.InvoicingModels Currency = "USD"; } [Required] - public double? Amount + public decimal? Amount { get; set; } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 6655971f9..1142e792d 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -358,9 +358,9 @@ namespace BTCPayServer.Services.Invoices cryptoInfo.CryptoPaid = accounting.CryptoPaid.ToString(); cryptoInfo.Address = info.GetPaymentMethodDetails()?.GetPaymentDestination(); - cryptoInfo.ExRates = new Dictionary + cryptoInfo.ExRates = new Dictionary { - { ProductInformation.Currency, (double)cryptoInfo.Rate } + { ProductInformation.Currency, cryptoInfo.Rate } }; var paymentId = info.GetId(); var scheme = info.Network.UriScheme; From 4afb0acc8429d99337297a239fbeb192420cac0c Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 11 May 2018 22:41:11 +0900 Subject: [PATCH 086/119] does not generate antiforgery --- BTCPayServer/Views/Apps/ViewPointOfSale.cshtml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BTCPayServer/Views/Apps/ViewPointOfSale.cshtml b/BTCPayServer/Views/Apps/ViewPointOfSale.cshtml index da90f297c..a4445ca83 100644 --- a/BTCPayServer/Views/Apps/ViewPointOfSale.cshtml +++ b/BTCPayServer/Views/Apps/ViewPointOfSale.cshtml @@ -19,7 +19,7 @@

    @Model.Title

    -
    +
    @for(int i = 0; i < Model.Items.Length; i++) { @@ -36,7 +36,7 @@ {
    - +
    From 70a6bd6a01b58e1472f40bae8d8e73da742c6fd0 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 11 May 2018 22:42:29 +0900 Subject: [PATCH 087/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index b651b7232..652595bf7 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.13 + 1.0.2.14 NU1701,CA1816,CA1308,CA1810,CA2208 From af3dee95dec9edcf23d97385089e29a57382641a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 11 May 2018 23:31:50 +0900 Subject: [PATCH 088/119] round up rates sent back by the RateProviderFactory --- BTCPayServer.Tests/UnitTest1.cs | 2 +- BTCPayServer/Rating/RateRules.cs | 11 +++++++++++ .../Services/Rates/BTCPayRateProviderFactory.cs | 11 ++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f82a4bc0a..2bbcaba46 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1454,7 +1454,7 @@ namespace BTCPayServer.Tests private static BTCPayRateProviderFactory CreateBTCPayRateFactory(BTCPayNetworkProvider provider) { - return new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); + return new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, null, new CoinAverageSettings()); } [Fact] diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index 617a24447..d4f72618f 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -417,10 +417,21 @@ namespace BTCPayServer.Rating public RateRule(RateRules parent, CurrencyPair currencyPair, SyntaxNode candidate) { + _CurrencyPair = currencyPair; flatten = new FlattenExpressionRewriter(parent, currencyPair); this.expression = flatten.Visit(candidate); } + + private readonly CurrencyPair _CurrencyPair; + public CurrencyPair CurrencyPair + { + get + { + return _CurrencyPair; + } + } + public ExchangeRates ExchangeRates { get diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 91104b3ba..b115d1d30 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -35,7 +35,7 @@ namespace BTCPayServer.Services.Rates } IMemoryCache _Cache; private IOptions _CacheOptions; - + CurrencyNameTable _CurrencyTable; public IMemoryCache Cache { get @@ -46,10 +46,12 @@ namespace BTCPayServer.Services.Rates CoinAverageSettings _CoinAverageSettings; public BTCPayRateProviderFactory(IOptions cacheOptions, BTCPayNetworkProvider btcpayNetworkProvider, + CurrencyNameTable currencyTable, CoinAverageSettings coinAverageSettings) { if (cacheOptions == null) throw new ArgumentNullException(nameof(cacheOptions)); + _CurrencyTable = currencyTable; _CoinAverageSettings = coinAverageSettings; _Cache = new MemoryCache(cacheOptions); _CacheOptions = cacheOptions; @@ -161,6 +163,13 @@ namespace BTCPayServer.Services.Rates } rateRule.Reevaluate(); result.Value = rateRule.Value; + + var currencyData = _CurrencyTable?.GetCurrencyData(rateRule.CurrencyPair.Right); + if(currencyData != null && result.Value.HasValue) + { + result.Value = decimal.Round(result.Value.Value, currencyData.Divisibility, MidpointRounding.AwayFromZero); + } + result.Errors = rateRule.Errors; result.EvaluatedRule = rateRule.ToString(true); result.Rule = rateRule.ToString(false); From 355989c278a7210c808cd4d41a904369daca5eb4 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 11 May 2018 23:34:42 +0900 Subject: [PATCH 089/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 652595bf7..3c9101e9f 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.14 + 1.0.2.15 NU1701,CA1816,CA1308,CA1810,CA2208 From 786d129452e756c39a1085a303842b780b8807a9 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 00:14:39 +0900 Subject: [PATCH 090/119] Make sure to not freeze if ligthning does not respond --- BTCPayServer/BTCPayServer.csproj | 2 +- .../Lightning/LightningLikePaymentHandler.cs | 78 +++++++++++-------- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 3c9101e9f..61a2a615b 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.15 + 1.0.2.16 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index e3e3a6361..76fc5cb18 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -36,17 +36,25 @@ namespace BTCPayServer.Payments.Lightning expiry = TimeSpan.FromSeconds(1); LightningInvoice lightningInvoice = null; - try + + string description = storeBlob.LightningDescriptionTemplate; + description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{ItemDescription}", invoice.ProductInformation.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) + .Replace("{OrderId}", invoice.OrderId ?? "", StringComparison.OrdinalIgnoreCase); + using (var cts = new CancellationTokenSource(5000)) { - string description = storeBlob.LightningDescriptionTemplate; - description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{ItemDescription}", invoice.ProductInformation.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase) - .Replace("{OrderId}", invoice.OrderId ?? "", StringComparison.OrdinalIgnoreCase); - lightningInvoice = await client.CreateInvoice(new LightMoney(due, LightMoneyUnit.BTC), description, expiry); - } - catch (Exception ex) - { - throw new PaymentMethodUnavailableException($"Impossible to create lightning invoice ({ex.Message})", ex); + try + { + lightningInvoice = await client.CreateInvoice(new LightMoney(due, LightMoneyUnit.BTC), description, expiry, cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + throw new PaymentMethodUnavailableException($"The lightning node did not replied in a timely maner"); + } + catch (Exception ex) + { + throw new PaymentMethodUnavailableException($"Impossible to create lightning invoice ({ex.Message})", ex); + } } var nodeInfo = await test; return new LightningLikePaymentMethodDetails() @@ -62,34 +70,36 @@ namespace BTCPayServer.Payments.Lightning if (!_Dashboard.IsFullySynched(network.CryptoCode, out var summary)) throw new PaymentMethodUnavailableException($"Full node not available"); - var cts = new CancellationTokenSource(5000); - var client = _LightningClientFactory.CreateClient(supportedPaymentMethod, network); - LightningNodeInformation info = null; - try + using (var cts = new CancellationTokenSource(5000)) { - info = await client.GetInfo(cts.Token); - } - catch (OperationCanceledException) when (cts.IsCancellationRequested) - { - throw new PaymentMethodUnavailableException($"The lightning node did not replied in a timely maner"); - } - catch (Exception ex) - { - throw new PaymentMethodUnavailableException($"Error while connecting to the API ({ex.Message})"); - } + var client = _LightningClientFactory.CreateClient(supportedPaymentMethod, network); + LightningNodeInformation info = null; + try + { + info = await client.GetInfo(cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + throw new PaymentMethodUnavailableException($"The lightning node did not replied in a timely maner"); + } + catch (Exception ex) + { + throw new PaymentMethodUnavailableException($"Error while connecting to the API ({ex.Message})"); + } - if (info.Address == null) - { - throw new PaymentMethodUnavailableException($"No lightning node public address has been configured"); - } + if (info.Address == null) + { + throw new PaymentMethodUnavailableException($"No lightning node public address has been configured"); + } - var blocksGap = Math.Abs(info.BlockHeight - summary.Status.ChainHeight); - if (blocksGap > 10) - { - throw new PaymentMethodUnavailableException($"The lightning is not synched ({blocksGap} blocks)"); - } + var blocksGap = Math.Abs(info.BlockHeight - summary.Status.ChainHeight); + if (blocksGap > 10) + { + throw new PaymentMethodUnavailableException($"The lightning is not synched ({blocksGap} blocks)"); + } - return new NodeInfo(info.NodeId, info.Address, info.P2PPort); + return new NodeInfo(info.NodeId, info.Address, info.P2PPort); + } } public async Task TestConnection(NodeInfo nodeInfo, CancellationToken cancellation) From b7f0ce18b3852379b03dac0ff42220dba7959b8f Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 00:23:25 +0900 Subject: [PATCH 091/119] Make sure Lightning charge can't hang out the payment --- BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs b/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs index 062f01bdc..121e6c001 100644 --- a/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs +++ b/BTCPayServer/Payments/Lightning/Charge/ChargeClient.cs @@ -156,7 +156,7 @@ namespace BTCPayServer.Payments.Lightning.Charge async Task ILightningInvoiceClient.CreateInvoice(LightMoney amount, string description, TimeSpan expiry, CancellationToken cancellation) { - var invoice = await CreateInvoiceAsync(new CreateInvoiceRequest() { Amount = amount, Expiry = expiry, Description = description ?? "" }); + var invoice = await CreateInvoiceAsync(new CreateInvoiceRequest() { Amount = amount, Expiry = expiry, Description = description ?? "" }, cancellation); return new LightningInvoice() { Id = invoice.Id, Amount = amount, BOLT11 = invoice.PayReq, Status = "unpaid" }; } From 26db946392927c3387cd9d263717f6fbf41bf054 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 00:54:17 +0900 Subject: [PATCH 092/119] BTCPayRateProviderFactory is responsible for getting the supported exchange list --- BTCPayServer/Controllers/StoresController.cs | 7 ++----- .../Services/Rates/BTCPayRateProviderFactory.cs | 15 +++++++++++++++ .../Services/Rates/CoinAverageSettings.cs | 3 +-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 410739b18..12b7cf005 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -53,8 +53,7 @@ namespace BTCPayServer.Controllers ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, LanguageService langService, - IHostingEnvironment env, - CoinAverageSettings coinAverage) + IHostingEnvironment env) { _RateFactory = rateFactory; _Dashboard = dashboard; @@ -72,9 +71,7 @@ namespace BTCPayServer.Controllers _ServiceProvider = serviceProvider; _BtcpayServerOptions = btcpayServerOptions; _BTCPayEnv = btcpayEnv; - _CoinAverage = coinAverage; } - CoinAverageSettings _CoinAverage; NBXplorerDashboard _Dashboard; BTCPayServerOptions _BtcpayServerOptions; BTCPayServerEnvironment _BTCPayEnv; @@ -518,7 +515,7 @@ namespace BTCPayServer.Controllers private CoinAverageExchange[] GetSupportedExchanges() { - return _CoinAverage.AvailableExchanges + return _RateFactory.GetSupportedExchanges() .Select(c => c.Value) .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index b115d1d30..4731195b7 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; using BTCPayServer.Rating; using ExchangeSharp; @@ -74,6 +75,7 @@ namespace BTCPayServer.Services.Rates // Handmade providers DirectProviders.Add("bitpay", new BitpayRateProvider(new NBitpayClient.Bitpay(new NBitcoin.Key(), new Uri("https://bitpay.com/")))); DirectProviders.Add(QuadrigacxRateProvider.QuadrigacxName, new QuadrigacxRateProvider()); + DirectProviders.Add(CoinAverageRateProvider.CoinAverageName, new CoinAverageRateProvider() { Exchange = CoinAverageRateProvider.CoinAverageName, Authenticator = _CoinAverageSettings }); // Those exchanges make multiple requests when calling GetTickers so we remove them //DirectProviders.Add("kraken", new ExchangeSharpRateProvider("kraken", new ExchangeKrakenAPI(), true)); @@ -84,6 +86,19 @@ namespace BTCPayServer.Services.Rates //DirectProviders.Add("bitstamp", new ExchangeSharpRateProvider("bitstamp", new ExchangeBitstampAPI())); } + public CoinAverageExchanges GetSupportedExchanges() + { + CoinAverageExchanges exchanges = new CoinAverageExchanges(); + foreach(var exchange in _CoinAverageSettings.AvailableExchanges) + { + exchanges.Add(exchange.Value); + } + + // Add other exchanges supported here + exchanges.Add(new CoinAverageExchange(CoinAverageRateProvider.CoinAverageName, "Coin Average")); + + return exchanges; + } private readonly Dictionary _DirectProviders = new Dictionary(); public Dictionary DirectProviders diff --git a/BTCPayServer/Services/Rates/CoinAverageSettings.cs b/BTCPayServer/Services/Rates/CoinAverageSettings.cs index f3da666f6..9d0c06df2 100644 --- a/BTCPayServer/Services/Rates/CoinAverageSettings.cs +++ b/BTCPayServer/Services/Rates/CoinAverageSettings.cs @@ -43,12 +43,11 @@ namespace BTCPayServer.Services.Rates { public CoinAverageExchanges() { - Add(new CoinAverageExchange(CoinAverageRateProvider.CoinAverageName, "Coin Average")); } public void Add(CoinAverageExchange exchange) { - Add(exchange.Name, exchange); + TryAdd(exchange.Name, exchange); } } public class CoinAverageSettings : ICoinAverageAuthenticator From 3770adb7d3c14efeee32953dc13908a635dee5a0 Mon Sep 17 00:00:00 2001 From: rockstardev Date: Fri, 11 May 2018 12:15:26 -0500 Subject: [PATCH 093/119] Displaying fiat value of invoice's order amount in details --- BTCPayServer/Controllers/InvoiceController.UI.cs | 9 ++++----- BTCPayServer/Models/InvoicingModels/PaymentModel.cs | 2 +- BTCPayServer/Views/Invoice/Checkout-Body.cshtml | 6 +++--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 47a9348c2..74be496df 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -239,9 +239,9 @@ namespace BTCPayServer.Controllers CustomCSSLink = storeBlob.CustomCSS?.AbsoluteUri, CustomLogoLink = storeBlob.CustomLogo?.AbsoluteUri, BtcAddress = paymentMethodDetails.GetPaymentDestination(), - OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ToString(), BtcDue = accounting.Due.ToString(), - FiatDue = FiatDue(accounting.Due, paymentMethod), + OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ToString(), + OrderAmountFiat = OrderAmountFiat(invoice.ProductInformation), CustomerEmail = invoice.RefundMail, RequiresRefundEmail = storeBlob.RequiresRefundEmail, ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds), @@ -295,10 +295,9 @@ namespace BTCPayServer.Controllers { return price.ToString("C", _CurrencyNameTable.GetCurrencyProvider(currency)) + $" ({currency})"; } - private string FiatDue(Money btcDue, PaymentMethod paymentMethod) + private string OrderAmountFiat(ProductInformation productInformation) { - var currency = paymentMethod.ParentEntity.ProductInformation.Currency; - return FormatCurrency(btcDue.ToUnit(MoneyUnit.BTC) * paymentMethod.Rate, currency); + return FormatCurrency(productInformation.Price, productInformation.Currency); } [HttpGet] diff --git a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs index e9c49d6f6..da28a8975 100644 --- a/BTCPayServer/Models/InvoicingModels/PaymentModel.cs +++ b/BTCPayServer/Models/InvoicingModels/PaymentModel.cs @@ -36,8 +36,8 @@ namespace BTCPayServer.Models.InvoicingModels public string ItemDesc { get; set; } public string TimeLeft { get; set; } public string Rate { get; set; } - public string FiatDue { get; set; } public string OrderAmount { get; set; } + public string OrderAmountFiat { get; set; } public string InvoiceBitcoinUrl { get; set; } public string InvoiceBitcoinUrlQR { get; set; } public int TxCount { get; set; } diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index a61f41176..f979f3b8f 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -74,7 +74,7 @@
    - {{srvModel.fiatDue}} + 1 {{ srvModel.cryptoCode }} = {{ srvModel.rate }}
    @@ -85,9 +85,9 @@
    - {{$t("Exchange Rate")}} + {{$t("Order Amount in Fiat")}}
    -
    1 {{ srvModel.cryptoCode }} = {{ srvModel.rate }}
    +
    {{srvModel.orderAmountFiat}}
    {{$t("Order Amount")}}
    From ca65c6bd8f0f99b98ad5fee089cb509e60d9a7ac Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 18:38:43 +0900 Subject: [PATCH 094/119] fix #171 --- BTCPayServer/Views/Account/ConfirmEmail.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Views/Account/ConfirmEmail.cshtml b/BTCPayServer/Views/Account/ConfirmEmail.cshtml index 2322bae22..0f7158654 100644 --- a/BTCPayServer/Views/Account/ConfirmEmail.cshtml +++ b/BTCPayServer/Views/Account/ConfirmEmail.cshtml @@ -7,7 +7,7 @@
    - +
    From eb882c2c46da8e6c7e52e0e9fa531b380aa063ce Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 19:15:54 +0900 Subject: [PATCH 095/119] Update package --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 61a2a615b..9445dfaad 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -41,7 +41,7 @@ - + From a34842585d43f2a832e54b764b3f7a73ad3ed5df Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 19:19:40 +0900 Subject: [PATCH 096/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 9445dfaad..2271c51cb 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.16 + 1.0.2.17 NU1701,CA1816,CA1308,CA1810,CA2208 From 449738414b1188691540c5fa0d7149dcac4cb1af Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sat, 12 May 2018 19:37:32 +0900 Subject: [PATCH 097/119] Add cryptopia --- BTCPayServer.Tests/UnitTest1.cs | 1 - BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 2bbcaba46..176ca3d36 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1470,7 +1470,6 @@ namespace BTCPayServer.Tests RateRules.TryParse("X_X = coinaverage(X_X);", out var rateRules); var factory = CreateBTCPayRateFactory(provider); - factory.DirectProviders.Clear(); factory.CacheSpan = TimeSpan.FromSeconds(10); var fetchedRate = factory.FetchRate(CurrencyPair.Parse("BTC_USD"), rateRules).GetAwaiter().GetResult(); diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 4731195b7..00c3e2b1a 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -71,6 +71,7 @@ namespace BTCPayServer.Services.Rates DirectProviders.Add("bittrex", new ExchangeSharpRateProvider("bittrex", new ExchangeBittrexAPI(), true)); DirectProviders.Add("poloniex", new ExchangeSharpRateProvider("poloniex", new ExchangePoloniexAPI(), false)); DirectProviders.Add("hitbtc", new ExchangeSharpRateProvider("hitbtc", new ExchangeHitbtcAPI(), false)); + DirectProviders.Add("cryptopia", new ExchangeSharpRateProvider("cryptopia", new ExchangeCryptopiaAPI(), false)); // Handmade providers DirectProviders.Add("bitpay", new BitpayRateProvider(new NBitpayClient.Bitpay(new NBitcoin.Key(), new Uri("https://bitpay.com/")))); @@ -96,6 +97,7 @@ namespace BTCPayServer.Services.Rates // Add other exchanges supported here exchanges.Add(new CoinAverageExchange(CoinAverageRateProvider.CoinAverageName, "Coin Average")); + exchanges.Add(new CoinAverageExchange("cryptopia", "Cryptopia")); return exchanges; } From f7fe855274ffeb869a80a90c0f04669a8a68f28a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 13 May 2018 15:09:17 +0900 Subject: [PATCH 098/119] Do not roundup rates --- BTCPayServer.Tests/UnitTest1.cs | 28 +++++++++++++---- .../Controllers/InvoiceController.UI.cs | 30 +++++++++++++++---- .../Rates/BTCPayRateProviderFactory.cs | 12 +------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 176ca3d36..7ae09e3fd 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -265,7 +265,7 @@ namespace BTCPayServer.Tests var user = tester.NewAccount(); user.GrantAccess(); user.RegisterDerivationScheme("BTC"); - + // Set tolerance to 50% var stores = user.GetController(); var vm = Assert.IsType(Assert.IsType(stores.UpdateStore()).Model); @@ -296,6 +296,22 @@ namespace BTCPayServer.Tests } } + [Fact] + public void RoundupCurrenciesCorrectly() + { + foreach(var test in new[] + { + (0.0005m, "$0.0005 (USD)"), + (0.001m, "$0.001 (USD)"), + (0.01m, "$0.01 (USD)"), + (0.1m, "$0.10 (USD)"), + }) + { + var actual = InvoiceController.FormatCurrency(test.Item1, "USD", new CurrencyNameTable()); + Assert.Equal(test.Item2, actual); + } + } + [Fact] public void CanPayUsingBIP70() { @@ -617,7 +633,7 @@ namespace BTCPayServer.Tests ItemDesc = "Some description", FullNotifications = true }, Facade.Merchant); - + var cashCow = tester.ExplorerNode; var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network); var firstPayment = invoice.CryptoInfo[0].TotalDue - Money.Satoshis(10); @@ -1411,7 +1427,7 @@ namespace BTCPayServer.Tests { var provider = new BTCPayNetworkProvider(NetworkType.Mainnet); var factory = CreateBTCPayRateFactory(provider); - + foreach (var result in factory .DirectProviders .Select(p => (ExpectedName: p.Key, ResultAsync: p.Value.GetRatesAsync())) @@ -1423,8 +1439,8 @@ namespace BTCPayServer.Tests Assert.NotEmpty(exchangeRates.ByExchange[result.ExpectedName]); // This check if the currency pair is using right currency pair - Assert.Contains(exchangeRates.ByExchange[result.ExpectedName], - e => ( e.CurrencyPair == new CurrencyPair("BTC", "USD") || + Assert.Contains(exchangeRates.ByExchange[result.ExpectedName], + e => (e.CurrencyPair == new CurrencyPair("BTC", "USD") || e.CurrencyPair == new CurrencyPair("BTC", "EUR") || e.CurrencyPair == new CurrencyPair("BTC", "USDT")) && e.Value > 1.0m // 1BTC will always be more than 1USD @@ -1454,7 +1470,7 @@ namespace BTCPayServer.Tests private static BTCPayRateProviderFactory CreateBTCPayRateFactory(BTCPayNetworkProvider provider) { - return new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, null, new CoinAverageSettings()); + return new BTCPayRateProviderFactory(new MemoryCacheOptions() { ExpirationScanFrequency = TimeSpan.FromSeconds(1.0) }, provider, new CoinAverageSettings()); } [Fact] diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index f2b8b423a..546bf9b68 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -51,7 +51,7 @@ namespace BTCPayServer.Controllers StoreLink = Url.Action(nameof(StoresController.UpdateStore), "Stores", new { storeId = store.Id }), Id = invoice.Id, Status = invoice.Status, - TransactionSpeed = invoice.SpeedPolicy == SpeedPolicy.HighSpeed ? "high" : + TransactionSpeed = invoice.SpeedPolicy == SpeedPolicy.HighSpeed ? "high" : invoice.SpeedPolicy == SpeedPolicy.MediumSpeed ? "medium" : invoice.SpeedPolicy == SpeedPolicy.LowMediumSpeed ? "low-medium" : "low", @@ -61,7 +61,7 @@ namespace BTCPayServer.Controllers MonitoringDate = invoice.MonitoringExpiration, OrderId = invoice.OrderId, BuyerInformation = invoice.BuyerInformation, - Fiat = FormatCurrency((decimal)dto.Price, dto.Currency), + Fiat = FormatCurrency((decimal)dto.Price, dto.Currency, _CurrencyNameTable), NotificationUrl = invoice.NotificationURL, RedirectUrl = invoice.RedirectURL, ProductInformation = invoice.ProductInformation, @@ -291,11 +291,29 @@ namespace BTCPayServer.Controllers private string FormatCurrency(PaymentMethod paymentMethod) { string currency = paymentMethod.ParentEntity.ProductInformation.Currency; - return FormatCurrency(paymentMethod.Rate, currency); + return FormatCurrency(paymentMethod.Rate, currency, _CurrencyNameTable); } - public string FormatCurrency(decimal price, string currency) + public static string FormatCurrency(decimal price, string currency, CurrencyNameTable currencies) { - return price.ToString("C", _CurrencyNameTable.GetCurrencyProvider(currency)) + $" ({currency})"; + var provider = ((CultureInfo)currencies.GetCurrencyProvider(currency)).NumberFormat; + var currencyData = currencies.GetCurrencyData(currency); + var divisibility = currencyData.Divisibility; + while (true) + { + var rounded = decimal.Round(price, divisibility, MidpointRounding.AwayFromZero); + if ((Math.Abs(rounded - price) / price) < 0.001m) + { + price = rounded; + break; + } + divisibility++; + } + if(divisibility != provider.CurrencyDecimalDigits) + { + provider = (NumberFormatInfo)provider.Clone(); + provider.CurrencyDecimalDigits = divisibility; + } + return price.ToString("C", provider) + $" ({currency})"; } [HttpGet] @@ -430,7 +448,7 @@ namespace BTCPayServer.Controllers var stores = await _StoreRepository.GetStoresByUserId(GetUserId()); model.Stores = new SelectList(stores, nameof(StoreData.Id), nameof(StoreData.StoreName), model.StoreId); var store = stores.FirstOrDefault(s => s.Id == model.StoreId); - if(store == null) + if (store == null) { ModelState.AddModelError(nameof(model.StoreId), "Store not found"); } diff --git a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs index 00c3e2b1a..381c148dd 100644 --- a/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs +++ b/BTCPayServer/Services/Rates/BTCPayRateProviderFactory.cs @@ -36,7 +36,6 @@ namespace BTCPayServer.Services.Rates } IMemoryCache _Cache; private IOptions _CacheOptions; - CurrencyNameTable _CurrencyTable; public IMemoryCache Cache { get @@ -47,12 +46,10 @@ namespace BTCPayServer.Services.Rates CoinAverageSettings _CoinAverageSettings; public BTCPayRateProviderFactory(IOptions cacheOptions, BTCPayNetworkProvider btcpayNetworkProvider, - CurrencyNameTable currencyTable, CoinAverageSettings coinAverageSettings) { if (cacheOptions == null) throw new ArgumentNullException(nameof(cacheOptions)); - _CurrencyTable = currencyTable; _CoinAverageSettings = coinAverageSettings; _Cache = new MemoryCache(cacheOptions); _CacheOptions = cacheOptions; @@ -90,7 +87,7 @@ namespace BTCPayServer.Services.Rates public CoinAverageExchanges GetSupportedExchanges() { CoinAverageExchanges exchanges = new CoinAverageExchanges(); - foreach(var exchange in _CoinAverageSettings.AvailableExchanges) + foreach (var exchange in _CoinAverageSettings.AvailableExchanges) { exchanges.Add(exchange.Value); } @@ -180,13 +177,6 @@ namespace BTCPayServer.Services.Rates } rateRule.Reevaluate(); result.Value = rateRule.Value; - - var currencyData = _CurrencyTable?.GetCurrencyData(rateRule.CurrencyPair.Right); - if(currencyData != null && result.Value.HasValue) - { - result.Value = decimal.Round(result.Value.Value, currencyData.Divisibility, MidpointRounding.AwayFromZero); - } - result.Errors = rateRule.Errors; result.EvaluatedRule = rateRule.ToString(true); result.Rule = rateRule.ToString(false); From 0ba1072d54e3f8f348d9dcc7aee692cca6b474f1 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Sun, 13 May 2018 15:09:38 +0900 Subject: [PATCH 099/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 2271c51cb..c76e706bb 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.17 + 1.0.2.18 NU1701,CA1816,CA1308,CA1810,CA2208 From ad67f4ef1823a09eb69128d82d61ff08e7ec27d3 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Sun, 13 May 2018 09:47:42 +0200 Subject: [PATCH 100/119] update to use longs --- BTCPayServer/Models/InvoiceResponse.cs | 11 ++++++++--- BTCPayServer/Services/Invoices/InvoiceEntity.cs | 12 ++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/BTCPayServer/Models/InvoiceResponse.cs b/BTCPayServer/Models/InvoiceResponse.cs index 69382c8bf..c7ad99dff 100644 --- a/BTCPayServer/Models/InvoiceResponse.cs +++ b/BTCPayServer/Models/InvoiceResponse.cs @@ -226,12 +226,17 @@ namespace BTCPayServer.Models } [JsonProperty("paymentSubtotals")] - public Dictionary PaymentSubtotals { get; set; } + public Dictionary PaymentSubtotals { get; set; } [JsonProperty("paymentTotals")] - public Dictionary PaymentTotals { get; set; } + public Dictionary PaymentTotals { get; set; } + [JsonProperty("amountPaid")] - public decimal AmountPaid { get; set; } + public long AmountPaid { get; set; } + + [JsonProperty("minerFees")] + public long MinerFees { get; set; } + [JsonProperty("exchangeRates")] public Dictionary> ExchangeRates{ get; set; } diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index ae50df206..7529f2e6a 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -339,8 +339,8 @@ namespace BTCPayServer.Services.Invoices Currency = ProductInformation.Currency, Flags = new Flags() { Refundable = Refundable }, - PaymentSubtotals = new Dictionary(), - PaymentTotals= new Dictionary(), + PaymentSubtotals = new Dictionary(), + PaymentTotals= new Dictionary(), SupportedTransactionCurrencies = new Dictionary(), Addresses = new Dictionary(), PaymentCodes = new Dictionary(), @@ -351,6 +351,7 @@ namespace BTCPayServer.Services.Invoices dto.CryptoInfo = new List(); foreach (var info in this.GetPaymentMethods(networkProvider)) { + var accounting = info.Calculate(); var cryptoInfo = new NBitpayClient.InvoiceCryptoInfo(); var subtotalPrice = accounting.TotalDue - accounting.NetworkFee; @@ -414,8 +415,8 @@ namespace BTCPayServer.Services.Invoices #pragma warning restore CS0618 dto.CryptoInfo.Add(cryptoInfo); - dto.PaymentSubtotals.Add(cryptoCode, subtotalPrice.ToDecimal(MoneyUnit.Satoshi)); - dto.PaymentTotals.Add(cryptoCode, accounting.TotalDue.ToDecimal(MoneyUnit.Satoshi)); + dto.PaymentSubtotals.Add(cryptoCode, subtotalPrice.Satoshi); + dto.PaymentTotals.Add(cryptoCode, accounting.TotalDue.Satoshi); dto.SupportedTransactionCurrencies.Add(cryptoCode, new InvoiceSupportedTransactionCurrency() { Enabled = true @@ -424,8 +425,11 @@ namespace BTCPayServer.Services.Invoices dto.ExchangeRates.Add(cryptoCode, exrates); } + //TODO: Populate dto.AmountPaid + //TODO: Populate dto.MinerFees //TODO: Populate dto.TransactionCurrency + Populate(ProductInformation, dto); Populate(BuyerInformation, dto); From b8c513aa2b3def06fe83e479cc705a98b0ee554b Mon Sep 17 00:00:00 2001 From: cryptcoin-junkey <16408160+cryptcoin-junkey@users.noreply.github.com> Date: Fri, 11 May 2018 14:24:29 +0900 Subject: [PATCH 101/119] Support Monacoin. --- .../BTCPayNetworkProvider.Monacoin.cs | 35 + BTCPayServer/BTCPayNetworkProvider.cs | 1 + .../wwwroot/imlegacy/mona-lightning.svg | 986 ++++++++++++++++++ BTCPayServer/wwwroot/imlegacy/monacoin.png | Bin 0 -> 135714 bytes 4 files changed, 1022 insertions(+) create mode 100644 BTCPayServer/BTCPayNetworkProvider.Monacoin.cs create mode 100644 BTCPayServer/wwwroot/imlegacy/mona-lightning.svg create mode 100644 BTCPayServer/wwwroot/imlegacy/monacoin.png diff --git a/BTCPayServer/BTCPayNetworkProvider.Monacoin.cs b/BTCPayServer/BTCPayNetworkProvider.Monacoin.cs new file mode 100644 index 000000000..bb086132b --- /dev/null +++ b/BTCPayServer/BTCPayNetworkProvider.Monacoin.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Services.Rates; +using NBitcoin; +using NBXplorer; + +namespace BTCPayServer +{ + public partial class BTCPayNetworkProvider + { + public void InitMonacoin() + { + var nbxplorerNetwork = NBXplorerNetworkProvider.GetFromCryptoCode("MONA"); + Add(new BTCPayNetwork() + { + CryptoCode = nbxplorerNetwork.CryptoCode, + BlockExplorerLink = NetworkType == NetworkType.Mainnet ? "https://mona.insight.monaco-ex.org/insight/tx/{0}" : "https://testnet-mona.insight.monaco-ex.org/insight/tx/{0}", + NBitcoinNetwork = nbxplorerNetwork.NBitcoinNetwork, + NBXplorerNetwork = nbxplorerNetwork, + UriScheme = "monacoin", + DefaultRateRules = new[] + { + "MONA_X = MONA_BTC * BTC_X", + "MONA_BTC = zaif(MONA_BTC)" + }, + CryptoImagePath = "imlegacy/monacoin.png", + LightningImagePath = "imlegacy/mona-lightning.svg", + DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType), + CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("22'") : new KeyPath("1'") + }); + } + } +} diff --git a/BTCPayServer/BTCPayNetworkProvider.cs b/BTCPayServer/BTCPayNetworkProvider.cs index 06f0f180b..1aadb7bc3 100644 --- a/BTCPayServer/BTCPayNetworkProvider.cs +++ b/BTCPayServer/BTCPayNetworkProvider.cs @@ -49,6 +49,7 @@ namespace BTCPayServer InitLitecoin(); InitDogecoin(); InitBitcoinGold(); + InitMonacoin(); } /// diff --git a/BTCPayServer/wwwroot/imlegacy/mona-lightning.svg b/BTCPayServer/wwwroot/imlegacy/mona-lightning.svg new file mode 100644 index 000000000..730f401d4 --- /dev/null +++ b/BTCPayServer/wwwroot/imlegacy/mona-lightning.svg @@ -0,0 +1,986 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BTCPayServer/wwwroot/imlegacy/monacoin.png b/BTCPayServer/wwwroot/imlegacy/monacoin.png new file mode 100644 index 0000000000000000000000000000000000000000..0b0dab9681dfca9694744f944e21e5e406027241 GIT binary patch literal 135714 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 z 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 literal 0 HcmV?d00001 From bcd79c5882d96811dcebd27c1360d1b6e891c042 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 14 May 2018 09:32:04 +0200 Subject: [PATCH 102/119] use alternative uri validation --- BTCPayServer.Tests/UnitTest1.cs | 20 ++++++++++++++++++ .../InvoicingModels/CreateInvoiceModel.cs | 4 ++-- .../CheckoutExperienceViewModel.cs | 5 +++-- .../Models/StoreViewModels/StoreViewModel.cs | 3 ++- BTCPayServer/Validation/UriAttribute.cs | 21 +++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 BTCPayServer/Validation/UriAttribute.cs diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 7ae09e3fd..02b420508 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -36,6 +36,7 @@ using BTCPayServer.Services.Stores; using System.Net.Http; using System.Text; using BTCPayServer.Rating; +using BTCPayServer.Validation; using ExchangeSharp; namespace BTCPayServer.Tests @@ -48,6 +49,25 @@ namespace BTCPayServer.Tests Logs.LogProvider = new XUnitLogProvider(helper); } + [Fact] + public void CanHandleUriValidation() + { + var attribute = new UriAttribute(); + Assert.True(attribute.IsValid("http://localhost")); + Assert.True(attribute.IsValid("http://localhost:1234")); + Assert.True(attribute.IsValid("https://localhost")); + Assert.True(attribute.IsValid("https://127.0.0.1")); + Assert.True(attribute.IsValid("http://127.0.0.1")); + Assert.True(attribute.IsValid("http://127.0.0.1:1234")); + Assert.True(attribute.IsValid("http://gozo.com")); + Assert.True(attribute.IsValid("https://gozo.com")); + Assert.True(attribute.IsValid("https://gozo.com:1234")); + Assert.False(attribute.IsValid("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud e")); + Assert.False(attribute.IsValid(2)); + Assert.False(attribute.IsValid("http://")); + Assert.False(attribute.IsValid("httpdsadsa.com")); + } + [Fact] public void CanCalculateCryptoDue2() { diff --git a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs index 9ac99d667..631303ae0 100644 --- a/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs +++ b/BTCPayServer/Models/InvoicingModels/CreateInvoiceModel.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; +using BTCPayServer.Validation; namespace BTCPayServer.Models.InvoicingModels { @@ -52,8 +53,7 @@ namespace BTCPayServer.Models.InvoicingModels get; set; } - - [Url] + [Uri] public string NotificationUrl { get; set; diff --git a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs index 025315ad8..ae5176ffd 100644 --- a/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/CheckoutExperienceViewModel.cs @@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Services; +using BTCPayServer.Validation; using Microsoft.AspNetCore.Mvc.Rendering; namespace BTCPayServer.Models.StoreViewModels @@ -42,10 +43,10 @@ namespace BTCPayServer.Models.StoreViewModels public string OnChainMinValue { get; set; } [Display(Name = "Link to a custom CSS stylesheet")] - [Url] + [Uri] public string CustomCSS { get; set; } [Display(Name = "Link to a custom logo")] - [Url] + [Uri] public string CustomLogo { get; set; } [Display(Name = "Custom HTML title to display on Checkout page")] diff --git a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs index c2fadcdd3..1b86a07c2 100644 --- a/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoreViewModel.cs @@ -1,6 +1,7 @@ using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; +using BTCPayServer.Validation; using BTCPayServer.Validations; using Microsoft.AspNetCore.Mvc.Rendering; using System; @@ -34,7 +35,7 @@ namespace BTCPayServer.Models.StoreViewModels get; set; } - [Url] + [Uri] [Display(Name = "Store Website")] [MaxLength(500)] public string StoreWebsite diff --git a/BTCPayServer/Validation/UriAttribute.cs b/BTCPayServer/Validation/UriAttribute.cs new file mode 100644 index 000000000..717d10746 --- /dev/null +++ b/BTCPayServer/Validation/UriAttribute.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace BTCPayServer.Validation +{ + //from https://stackoverflow.com/a/47196738/275504 + public class UriAttribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + Uri uri; + bool valid = Uri.TryCreate(Convert.ToString(value), UriKind.Absolute, out uri); + + if (!valid) + { + return new ValidationResult(ErrorMessage); + } + return ValidationResult.Success; + } + } +} From de48fb4077b593b7679cb4f691c13e5a344b8242 Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Mon, 14 May 2018 09:34:19 +0200 Subject: [PATCH 103/119] add direct file test cases --- BTCPayServer.Tests/UnitTest1.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 02b420508..5aee1d360 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -62,6 +62,8 @@ namespace BTCPayServer.Tests Assert.True(attribute.IsValid("http://gozo.com")); Assert.True(attribute.IsValid("https://gozo.com")); Assert.True(attribute.IsValid("https://gozo.com:1234")); + Assert.True(attribute.IsValid("https://gozo.com:1234/test.css")); + Assert.True(attribute.IsValid("https://gozo.com:1234/test.png")); Assert.False(attribute.IsValid("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud e")); Assert.False(attribute.IsValid(2)); Assert.False(attribute.IsValid("http://")); From 4184c6c208b8fb98dbfe7b516d4770c56e904f68 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 14 May 2018 21:28:33 +0900 Subject: [PATCH 104/119] Convert in UriAttribute use invariant culture --- BTCPayServer/Validation/UriAttribute.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/Validation/UriAttribute.cs b/BTCPayServer/Validation/UriAttribute.cs index 717d10746..e6bb01608 100644 --- a/BTCPayServer/Validation/UriAttribute.cs +++ b/BTCPayServer/Validation/UriAttribute.cs @@ -1,5 +1,6 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Globalization; namespace BTCPayServer.Validation { @@ -9,7 +10,7 @@ namespace BTCPayServer.Validation protected override ValidationResult IsValid(object value, ValidationContext validationContext) { Uri uri; - bool valid = Uri.TryCreate(Convert.ToString(value), UriKind.Absolute, out uri); + bool valid = Uri.TryCreate(Convert.ToString(value, CultureInfo.InvariantCulture), UriKind.Absolute, out uri); if (!valid) { From 23a3c145edfa7f1640aa1632a57624dccd7b46fd Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Mon, 14 May 2018 22:08:35 +0900 Subject: [PATCH 105/119] fix run.sh --- run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/run.sh b/run.sh index e1a9c7c9a..83ced22ec 100755 --- a/run.sh +++ b/run.sh @@ -1,3 +1,3 @@ #!/bin/bash -dotnet run --no-launch-profile --no-build -c Release -p "BTCPayServer/BTCPayServer.csproj" -- "$@" +dotnet run --no-launch-profile --no-build -c Release -p "BTCPayServer/BTCPayServer.csproj" -- $@ From 559f5352571a07c1c108f29ab70f557c3afbb53c Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Tue, 15 May 2018 16:18:26 +0200 Subject: [PATCH 106/119] add some coverage for bitpay fields --- BTCPayServer.Tests/UnitTest1.cs | 5 +++++ BTCPayServer/Services/Invoices/InvoiceEntity.cs | 5 +---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 5aee1d360..8f1f76599 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -913,6 +913,11 @@ namespace BTCPayServer.Tests Assert.Single(invoice.CryptoInfo); Assert.Equal("LTC", invoice.CryptoInfo[0].CryptoCode); + Assert.True(invoice.PaymentCodes.ContainsKey("LTC")); + Assert.True(invoice.SupportedTransactionCurrencies.ContainsKey("LTC")); + Assert.True(invoice.SupportedTransactionCurrencies["LTC"].Enabled); + Assert.True(invoice.PaymentSubtotals.ContainsKey("LTC")); + Assert.True(invoice.PaymentTotals.ContainsKey("LTC")); var cashCow = tester.LTCExplorerNode; var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network); var firstPayment = Money.Coins(0.1m); diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 7529f2e6a..c2264e275 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -425,10 +425,7 @@ namespace BTCPayServer.Services.Invoices dto.ExchangeRates.Add(cryptoCode, exrates); } - - //TODO: Populate dto.AmountPaid - //TODO: Populate dto.MinerFees - //TODO: Populate dto.TransactionCurrency + //dto.AmountPaid dto.MinerFees & dto.TransactionCurrency are not supported by btcpayserver as we have multi currency payment support per invoice Populate(ProductInformation, dto); Populate(BuyerInformation, dto); From a6ee337ed0c0d61b2d3bb3ee3d592bc0ea8c399e Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Tue, 15 May 2018 16:25:43 +0200 Subject: [PATCH 107/119] more coverage --- BTCPayServer.Tests/UnitTest1.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 8f1f76599..b5e3df28a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1052,6 +1052,16 @@ namespace BTCPayServer.Tests Assert.Single(checkout.AvailableCryptos); Assert.Equal("BTC", checkout.CryptoCode); + Assert.Single(invoice.PaymentCodes); + Assert.Single(invoice.SupportedTransactionCurrencies); + Assert.Single(invoice.SupportedTransactionCurrencies); + Assert.Single(invoice.PaymentSubtotals); + Assert.Single(invoice.PaymentTotals); + Assert.True(invoice.PaymentCodes.ContainsKey("BTC")); + Assert.True(invoice.SupportedTransactionCurrencies.ContainsKey("BTC")); + Assert.True(invoice.SupportedTransactionCurrencies["BTC"].Enabled); + Assert.True(invoice.PaymentSubtotals.ContainsKey("BTC")); + Assert.True(invoice.PaymentTotals.ContainsKey("BTC")); ////////////////////// // Retry now with LTC enabled @@ -1100,6 +1110,18 @@ namespace BTCPayServer.Tests checkout = (Models.InvoicingModels.PaymentModel)((JsonResult)controller.GetStatus(invoice.Id, "LTC").GetAwaiter().GetResult()).Value; Assert.Equal(2, checkout.AvailableCryptos.Count); Assert.Equal("LTC", checkout.CryptoCode); + + + Assert.Equal(2, invoice.PaymentCodes.Count()); + Assert.Equal(2, invoice.SupportedTransactionCurrencies.Count()); + Assert.Equal(2, invoice.SupportedTransactionCurrencies.Count()); + Assert.Equal(2, invoice.PaymentSubtotals.Count()); + Assert.Equal(2, invoice.PaymentTotals.Count()); + Assert.True(invoice.PaymentCodes.ContainsKey("LTC")); + Assert.True(invoice.SupportedTransactionCurrencies.ContainsKey("LTC")); + Assert.True(invoice.SupportedTransactionCurrencies["LTC"].Enabled); + Assert.True(invoice.PaymentSubtotals.ContainsKey("LTC")); + Assert.True(invoice.PaymentTotals.ContainsKey("LTC")); } } From 3a02f16c6e8bfcae7c7de3345f80198794cac50a Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 01:27:15 +0900 Subject: [PATCH 108/119] Fix bug where exchange name in rate rules were uncorrectly considered a currency --- BTCPayServer.Tests/RateRulesTest.cs | 12 ++++++-- BTCPayServer/BTCPayServer.csproj | 2 +- BTCPayServer/Rating/RateRules.cs | 46 ++++++++++++++++++++++------- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/BTCPayServer.Tests/RateRulesTest.cs b/BTCPayServer.Tests/RateRulesTest.cs index f12736855..09c26fc73 100644 --- a/BTCPayServer.Tests/RateRulesTest.cs +++ b/BTCPayServer.Tests/RateRulesTest.cs @@ -35,9 +35,9 @@ namespace BTCPayServer.Tests rules.ToString()); var tests = new[] { + (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1"), (Pair: "BTC_USD", Expected: "gdax(BTC_USD)"), (Pair: "BTC_CAD", Expected: "coinbase(BTC_CAD)"), - (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1"), (Pair: "DOGE_CAD", Expected: "bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1"), (Pair: "LTC_CAD", Expected: "coinaverage(LTC_CAD) * 1.02"), }; @@ -81,9 +81,9 @@ namespace BTCPayServer.Tests var tests2 = new[] { + (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1", ExpectedExchangeRates: "bittrex(DOGE_BTC),gdax(BTC_USD)"), (Pair: "BTC_USD", Expected: "gdax(BTC_USD)", ExpectedExchangeRates: "gdax(BTC_USD)"), (Pair: "BTC_CAD", Expected: "coinbase(BTC_CAD)", ExpectedExchangeRates: "coinbase(BTC_CAD)"), - (Pair: "DOGE_USD", Expected: "bittrex(DOGE_BTC) * gdax(BTC_USD) * 1.1", ExpectedExchangeRates: "bittrex(DOGE_BTC),gdax(BTC_USD)"), (Pair: "DOGE_CAD", Expected: "bittrex(DOGE_BTC) * coinbase(BTC_CAD) * 1.1", ExpectedExchangeRates: "bittrex(DOGE_BTC),coinbase(BTC_CAD)"), (Pair: "LTC_CAD", Expected: "coinaverage(LTC_CAD) * 1.02", ExpectedExchangeRates: "coinaverage(LTC_CAD)"), }; @@ -129,6 +129,14 @@ namespace BTCPayServer.Tests Assert.Equal("(1 / (2000 * (-3 + 1000 + 50 - 5))) * 1.1", rule2.ToString(true)); Assert.Equal(( 1.0m / (2000m * (-3m + 1000m + 50m - 5m))) * 1.1m, rule2.Value.Value); //////// + + // Make sure kraken is not converted to CurrencyPair + builder = new StringBuilder(); + builder.AppendLine("BTC_USD = kraken(BTC_USD)"); + Assert.True(RateRules.TryParse(builder.ToString(), out rules)); + rule2 = rules.GetRuleFor(CurrencyPair.Parse("BTC_USD")); + rule2.ExchangeRates.SetRate("kraken", CurrencyPair.Parse("BTC_USD"), 1000m); + Assert.True(rule2.Reevaluate()); } } } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index c76e706bb..215743aef 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.18 + 1.0.2.19 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Rating/RateRules.cs b/BTCPayServer/Rating/RateRules.cs index d4f72618f..e7fe02e0d 100644 --- a/BTCPayServer/Rating/RateRules.cs +++ b/BTCPayServer/Rating/RateRules.cs @@ -347,17 +347,23 @@ namespace BTCPayServer.Rating class FlattenExpressionRewriter : CSharpSyntaxRewriter { RateRules parent; + CurrencyPair pair; + int nested = 0; public FlattenExpressionRewriter(RateRules parent, CurrencyPair pair) { - Context.Push(pair); + this.pair = pair; this.parent = parent; } public ExchangeRates ExchangeRates = new ExchangeRates(); - public Stack Context { get; set; } = new Stack(); bool IsInvocation; public override SyntaxNode VisitInvocationExpression(InvocationExpressionSyntax node) { + if (IsInvocation) + { + Errors.Add(RateRulesErrors.InvalidCurrencyIdentifier); + return RateRules.CreateExpression($"ERR_INVALID_CURRENCY_PAIR({node.ToString()})"); + } IsInvocation = true; _ExchangeName = node.Expression.ToString(); var result = base.VisitInvocationExpression(node); @@ -365,18 +371,27 @@ namespace BTCPayServer.Rating return result; } + bool IsArgumentList; + public override SyntaxNode VisitArgumentList(ArgumentListSyntax node) + { + IsArgumentList = true; + var result = base.VisitArgumentList(node); + IsArgumentList = false; + return result; + } + string _ExchangeName = null; public List Errors = new List(); const int MaxNestedCount = 8; public override SyntaxNode VisitIdentifierName(IdentifierNameSyntax node) { - if (CurrencyPair.TryParse(node.Identifier.ValueText, out var currentPair)) + if ( + (!IsInvocation || IsArgumentList) && + CurrencyPair.TryParse(node.Identifier.ValueText, out var currentPair)) { - var ctx = Context.Peek(); - - var replacedPair = new CurrencyPair(left: currentPair.Left == "X" ? ctx.Left : currentPair.Left, - right: currentPair.Right == "X" ? ctx.Right : currentPair.Right); + var replacedPair = new CurrencyPair(left: currentPair.Left == "X" ? pair.Left : currentPair.Left, + right: currentPair.Right == "X" ? pair.Right : currentPair.Right); if (IsInvocation) // eg. replace bittrex(BTC_X) to bittrex(BTC_USD) { ExchangeRates.Add(new ExchangeRate() { CurrencyPair = replacedPair, Exchange = _ExchangeName }); @@ -385,13 +400,13 @@ namespace BTCPayServer.Rating else // eg. replace BTC_X to BTC_USD, then replace by the expression for BTC_USD { var bestCandidate = parent.FindBestCandidate(replacedPair); - if (Context.Count > MaxNestedCount) + if (nested > MaxNestedCount) { Errors.Add(RateRulesErrors.TooMuchNestedCalls); return RateRules.CreateExpression($"ERR_TOO_MUCH_NESTED_CALLS({replacedPair})"); } - Context.Push(replacedPair); - var replaced = Visit(bestCandidate); + var innerFlatten = CreateNewContext(replacedPair); + var replaced = innerFlatten.Visit(bestCandidate); if (replaced is ExpressionSyntax expression) { var hasBinaryOps = new HasBinaryOperations(); @@ -401,7 +416,6 @@ namespace BTCPayServer.Rating replaced = SyntaxFactory.ParenthesizedExpression(expression); } } - Context.Pop(); if (Errors.Contains(RateRulesErrors.TooMuchNestedCalls)) { return RateRules.CreateExpression($"ERR_TOO_MUCH_NESTED_CALLS({replacedPair})"); @@ -411,6 +425,16 @@ namespace BTCPayServer.Rating } return base.VisitIdentifierName(node); } + + private FlattenExpressionRewriter CreateNewContext(CurrencyPair pair) + { + return new FlattenExpressionRewriter(parent, pair) + { + Errors = Errors, + nested = nested + 1, + ExchangeRates = ExchangeRates, + }; + } } private SyntaxNode expression; FlattenExpressionRewriter flatten; From 1747414a57dd2705f314e332a458871b1677d188 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 01:37:20 +0900 Subject: [PATCH 109/119] update clightning of docker compose --- BTCPayServer.Tests/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index d2321ba1e..9f97dc2cf 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -89,7 +89,7 @@ services: - "bitcoin_datadir:/data" customer_lightningd: - image: nicolasdorier/clightning:0.0.0.14-dev + image: nicolasdorier/clightning:0.0.0.16-dev environment: EXPOSE_TCP: "true" LIGHTNINGD_OPT: | From ecf03f90aa94ddaef4e66864a9c16340bc89dc05 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 02:24:59 +0900 Subject: [PATCH 110/119] Fix UriAttribute bug, and currency formatting crash --- BTCPayServer/BTCPayServer.csproj | 2 +- .../Controllers/InvoiceController.UI.cs | 2 +- .../Services/Rates/CurrencyNameTable.cs | 25 ++++++++++++++++++- BTCPayServer/Validation/UriAttribute.cs | 3 ++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 215743aef..ad2987d1f 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.19 + 1.0.2.20 NU1701,CA1816,CA1308,CA1810,CA2208 diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 546bf9b68..dfd71768e 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -295,7 +295,7 @@ namespace BTCPayServer.Controllers } public static string FormatCurrency(decimal price, string currency, CurrencyNameTable currencies) { - var provider = ((CultureInfo)currencies.GetCurrencyProvider(currency)).NumberFormat; + var provider = currencies.GetNumberFormatInfo(currency); var currencyData = currencies.GetCurrencyData(currency); var divisibility = currencyData.Divisibility; while (true) diff --git a/BTCPayServer/Services/Rates/CurrencyNameTable.cs b/BTCPayServer/Services/Rates/CurrencyNameTable.cs index 01c6b57fa..97e748722 100644 --- a/BTCPayServer/Services/Rates/CurrencyNameTable.cs +++ b/BTCPayServer/Services/Rates/CurrencyNameTable.cs @@ -40,6 +40,14 @@ namespace BTCPayServer.Services.Rates } static Dictionary _CurrencyProviders = new Dictionary(); + + public NumberFormatInfo GetNumberFormatInfo(string currency) + { + var data = GetCurrencyProvider(currency); + if (data is NumberFormatInfo nfi) + return nfi; + return ((CultureInfo)data).NumberFormat; + } public IFormatProvider GetCurrencyProvider(string currency) { lock (_CurrencyProviders) @@ -54,7 +62,11 @@ namespace BTCPayServer.Services.Rates } catch { } } - AddCurrency(_CurrencyProviders, "BTC", 8, "BTC"); + + foreach (var network in new BTCPayNetworkProvider(NetworkType.Mainnet).GetAll()) + { + AddCurrency(_CurrencyProviders, network.CryptoCode, 8, network.CryptoCode); + } } return _CurrencyProviders.TryGet(currency); } @@ -106,6 +118,17 @@ namespace BTCPayServer.Services.Rates info.Symbol = splitted[3]; } } + + foreach (var network in new BTCPayNetworkProvider(NetworkType.Mainnet).GetAll()) + { + dico.TryAdd(network.CryptoCode, new CurrencyData() + { + Code = network.CryptoCode, + Divisibility = 8, + Name = network.CryptoCode + }); + } + return dico.Values.ToArray(); } diff --git a/BTCPayServer/Validation/UriAttribute.cs b/BTCPayServer/Validation/UriAttribute.cs index e6bb01608..0e9c75e08 100644 --- a/BTCPayServer/Validation/UriAttribute.cs +++ b/BTCPayServer/Validation/UriAttribute.cs @@ -9,8 +9,9 @@ namespace BTCPayServer.Validation { protected override ValidationResult IsValid(object value, ValidationContext validationContext) { + var str = value == null ? null : Convert.ToString(value, CultureInfo.InvariantCulture); Uri uri; - bool valid = Uri.TryCreate(Convert.ToString(value, CultureInfo.InvariantCulture), UriKind.Absolute, out uri); + bool valid = string.IsNullOrWhiteSpace(str) || Uri.TryCreate(str, UriKind.Absolute, out uri); if (!valid) { From 12ceb9e0bc4267301805826b4518a4621f4fab71 Mon Sep 17 00:00:00 2001 From: rockstardev Date: Tue, 15 May 2018 14:38:14 -0500 Subject: [PATCH 111/119] Simplifying CSS styles for line-items box Now that we'll have different box sizes it's not possible to rely on exact height specification --- .../Views/Invoice/Checkout-Body.cshtml | 12 ++++---- .../wwwroot/checkout/css/normalizer.css | 28 +++++-------------- BTCPayServer/wwwroot/checkout/js/core.js | 4 +++ 3 files changed, 17 insertions(+), 27 deletions(-) diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index f979f3b8f..913bbc411 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -83,16 +83,16 @@
    -
    -
    - {{$t("Order Amount in Fiat")}} -
    -
    {{srvModel.orderAmountFiat}}
    -
    {{$t("Order Amount")}}
    {{srvModel.orderAmount}} {{ srvModel.cryptoCode }}
    +
    +
     
    +
    + {{srvModel.orderAmountFiat}} +
    +
    {{$t("Network Cost")}} diff --git a/BTCPayServer/wwwroot/checkout/css/normalizer.css b/BTCPayServer/wwwroot/checkout/css/normalizer.css index bb566cb11..49016045a 100644 --- a/BTCPayServer/wwwroot/checkout/css/normalizer.css +++ b/BTCPayServer/wwwroot/checkout/css/normalizer.css @@ -11146,31 +11146,13 @@ language-selector { line-items { background: #FBFBFB; - height: 25px; - border-top: 0; + border-top: 1px solid rgba(238, 238, 238, 0.5); z-index: 2; - position: relative; - display: block; - overflow: hidden; - height: 0; - transition: height 250ms ease; + display: none; } - line-items.expanded { - height: 120px; - border-top: 1px solid rgba(238, 238, 238, 0.5); - } - - line-items.expanded.paid-over { - height: 295px; - } - - line-items.expanded.paid-partial-expired, line-items.expanded.paid-full { - height: 272px; - } - line-items .line-items { - padding: 1rem; + padding: 10px 1rem; color: #565D6E; } @@ -11198,6 +11180,10 @@ line-items { padding: 2px 0; } + line-items .line-items_fiatvalue { + margin-top: -5px; + } + line-items .line-items__item__label { flex-grow: 1; display: flex; diff --git a/BTCPayServer/wwwroot/checkout/js/core.js b/BTCPayServer/wwwroot/checkout/js/core.js index 89120ef3f..9feefed82 100644 --- a/BTCPayServer/wwwroot/checkout/js/core.js +++ b/BTCPayServer/wwwroot/checkout/js/core.js @@ -237,8 +237,12 @@ $(document).ready(function () { }); // Expand Line-Items + var lineItemsExpanded = false; $(".buyerTotalLine").click(function () { $("line-items").toggleClass("expanded"); + lineItemsExpanded ? $("line-items").slideUp() : $("line-items").slideDown(); + lineItemsExpanded = !lineItemsExpanded; + $(".buyerTotalLine").toggleClass("expanded"); $(".single-item-order__right__btc-price__chevron").toggleClass("expanded"); }); From eb01e91e136d3e7a361471edb52dfb9d9af241c6 Mon Sep 17 00:00:00 2001 From: rockstardev Date: Tue, 15 May 2018 14:48:23 -0500 Subject: [PATCH 112/119] Only show Order Amount in Fiat if Invoice is created with fiat value --- BTCPayServer/Controllers/InvoiceController.UI.cs | 7 +++++++ BTCPayServer/Views/Invoice/Checkout-Body.cshtml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 74be496df..8fc9ecf2b 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -297,6 +297,13 @@ namespace BTCPayServer.Controllers } private string OrderAmountFiat(ProductInformation productInformation) { + // check if invoice source currency is crypto... if it is there is no "order amount in fiat" + foreach (var net in _NetworkProvider.GetAll()) + { + if (net.CryptoCode == productInformation.Currency) + return null; + } + return FormatCurrency(productInformation.Price, productInformation.Currency); } diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index 913bbc411..20e689344 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -87,7 +87,7 @@
    {{$t("Order Amount")}}
    {{srvModel.orderAmount}} {{ srvModel.cryptoCode }}
    -
    +
     
    {{srvModel.orderAmountFiat}} From 67abbed66a9e25a22896073c9928d2c862d2fbaa Mon Sep 17 00:00:00 2001 From: rockstardev Date: Tue, 15 May 2018 14:56:29 -0500 Subject: [PATCH 113/119] Removing display of exchange if invoice source amount is in crypto --- BTCPayServer/Views/Invoice/Checkout-Body.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index 20e689344..e3f2a655b 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -73,7 +73,7 @@ {{ srvModel.btcDue }} {{ srvModel.cryptoCode }}
    -
    +
    1 {{ srvModel.cryptoCode }} = {{ srvModel.rate }}
    From bcf97b14742b4526ce6e1eda8f84f9c0541282b6 Mon Sep 17 00:00:00 2001 From: rockstardev Date: Tue, 15 May 2018 15:06:24 -0500 Subject: [PATCH 114/119] Hiding display of payment-tabs now that we have flex line-items --- BTCPayServer/Views/Invoice/Checkout-Body.cshtml | 2 +- BTCPayServer/wwwroot/checkout/css/normalizer.css | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml index e3f2a655b..b4a9e227c 100644 --- a/BTCPayServer/Views/Invoice/Checkout-Body.cshtml +++ b/BTCPayServer/Views/Invoice/Checkout-Body.cshtml @@ -139,7 +139,7 @@
    -
    +
    diff --git a/BTCPayServer/wwwroot/checkout/css/normalizer.css b/BTCPayServer/wwwroot/checkout/css/normalizer.css index 49016045a..e8a539b3e 100644 --- a/BTCPayServer/wwwroot/checkout/css/normalizer.css +++ b/BTCPayServer/wwwroot/checkout/css/normalizer.css @@ -10328,6 +10328,7 @@ All mobile class names should be prefixed by m- */ .wrong-email .payment-tabs { pointer-events: none; margin-top: -2.95rem; + z-index: -1; margin-bottom: 1rem; } @@ -10412,10 +10413,6 @@ All mobile class names should be prefixed by m- */ transform: translateY(20px); } -.payment-tabs { - z-index: 1; -} - .single-item-order { z-index: 2; } From 1c50210e6110f19828b572c6f46bf83173bcb803 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 10:19:48 +0900 Subject: [PATCH 115/119] fix build --- BTCPayServer/Controllers/InvoiceController.UI.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 856cebe7f..7515c18da 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -309,7 +309,7 @@ namespace BTCPayServer.Controllers } divisibility++; } - if(divisibility != provider.CurrencyDecimalDigits) + if (divisibility != provider.CurrencyDecimalDigits) { provider = (NumberFormatInfo)provider.Clone(); provider.CurrencyDecimalDigits = divisibility; @@ -319,13 +319,12 @@ namespace BTCPayServer.Controllers private string OrderAmountFiat(ProductInformation productInformation) { // check if invoice source currency is crypto... if it is there is no "order amount in fiat" - foreach (var net in _NetworkProvider.GetAll()) + if (_NetworkProvider.GetNetwork(productInformation.Currency) != null) { - if (net.CryptoCode == productInformation.Currency) - return null; + return null; } - return FormatCurrency(productInformation.Price, productInformation.Currency); + return FormatCurrency(productInformation.Price, productInformation.Currency, _CurrencyNameTable); } [HttpGet] From 39ec5242d7a08e5ca40d09d6fb42868c8956daa4 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 10:22:29 +0900 Subject: [PATCH 116/119] Bump NBitpayClient --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index ad2987d1f..98ddbf5c5 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -41,7 +41,7 @@ - + From 640ff36fa251bed3c713a0ac53b0205a18856e29 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 10:26:45 +0900 Subject: [PATCH 117/119] fix build --- .../Services/Invoices/InvoiceRepository.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 33253d0ec..61c79259e 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -112,7 +112,7 @@ namespace BTCPayServer.Services.Invoices invoice.StoreId = storeId; using (var context = _ContextFactory.CreateContext()) { - context.Invoices.Add(new InvoiceData() + context.Invoices.Add(new Data.InvoiceData() { StoreDataId = storeId, Id = invoice.Id, @@ -267,7 +267,7 @@ namespace BTCPayServer.Services.Invoices { using (var context = _ContextFactory.CreateContext()) { - var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); + var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); if (invoiceData == null) return; var invoiceEntity = ToObject(invoiceData.Blob, null); @@ -307,7 +307,7 @@ namespace BTCPayServer.Services.Invoices { using (var context = _ContextFactory.CreateContext()) { - var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); + var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); if (invoiceData == null) return; invoiceData.Status = status; @@ -320,7 +320,7 @@ namespace BTCPayServer.Services.Invoices { using (var context = _ContextFactory.CreateContext()) { - var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); + var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); if (invoiceData?.Status != "paid") return; invoiceData.Status = "invalid"; @@ -331,7 +331,7 @@ namespace BTCPayServer.Services.Invoices { using (var context = _ContextFactory.CreateContext()) { - IQueryable query = + IQueryable query = context .Invoices .Include(o => o.Payments) @@ -351,7 +351,7 @@ namespace BTCPayServer.Services.Invoices } } - private InvoiceEntity ToEntity(InvoiceData invoice) + private InvoiceEntity ToEntity(Data.InvoiceData invoice) { var entity = ToObject(invoice.Blob, null); #pragma warning disable CS0618 @@ -386,7 +386,7 @@ namespace BTCPayServer.Services.Invoices { using (var context = _ContextFactory.CreateContext()) { - IQueryable query = context + IQueryable query = context .Invoices .Include(o => o.Payments) .Include(o => o.RefundAddresses); From 58b994e043584cdb0deb6e8d2a89310e8a0aead4 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 10:40:25 +0900 Subject: [PATCH 118/119] fix tests --- BTCPayServer/Services/Invoices/InvoiceEntity.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index c2264e275..27204ed0f 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -415,14 +415,15 @@ namespace BTCPayServer.Services.Invoices #pragma warning restore CS0618 dto.CryptoInfo.Add(cryptoInfo); - dto.PaymentSubtotals.Add(cryptoCode, subtotalPrice.Satoshi); - dto.PaymentTotals.Add(cryptoCode, accounting.TotalDue.Satoshi); - dto.SupportedTransactionCurrencies.Add(cryptoCode, new InvoiceSupportedTransactionCurrency() + dto.PaymentCodes.Add(paymentId.ToString(), cryptoInfo.PaymentUrls); + dto.PaymentSubtotals.Add(paymentId.ToString(), subtotalPrice.Satoshi); + dto.PaymentTotals.Add(paymentId.ToString(), accounting.TotalDue.Satoshi); + dto.SupportedTransactionCurrencies.TryAdd(cryptoCode, new InvoiceSupportedTransactionCurrency() { Enabled = true }); - dto.Addresses.Add(cryptoCode, address); - dto.ExchangeRates.Add(cryptoCode, exrates); + dto.Addresses.Add(paymentId.ToString(), address); + dto.ExchangeRates.TryAdd(cryptoCode, exrates); } //dto.AmountPaid dto.MinerFees & dto.TransactionCurrency are not supported by btcpayserver as we have multi currency payment support per invoice From 7062705d6f3012594587be8bc36e0a2382f963cd Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 16 May 2018 10:40:48 +0900 Subject: [PATCH 119/119] bump --- BTCPayServer/BTCPayServer.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 98ddbf5c5..ca3aa0595 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -2,7 +2,7 @@ Exe netcoreapp2.1 - 1.0.2.20 + 1.0.2.21 NU1701,CA1816,CA1308,CA1810,CA2208