diff --git a/BTCPayServer.Common/Altcoins/Ethereum/BTCPayNetworkProvider.Ethereum.cs b/BTCPayServer.Common/Altcoins/Ethereum/BTCPayNetworkProvider.Ethereum.cs new file mode 100644 index 000000000..1f2ee6da1 --- /dev/null +++ b/BTCPayServer.Common/Altcoins/Ethereum/BTCPayNetworkProvider.Ethereum.cs @@ -0,0 +1,78 @@ +#if ALTCOINS +using NBitcoin; + +namespace BTCPayServer +{ + public partial class BTCPayNetworkProvider + { + public void InitEthereum() + { + Add(new EthereumBTCPayNetwork() + { + CryptoCode = "ETH", + DisplayName = "Ethereum", + DefaultRateRules = new[] {"ETH_X = ETH_BTC * BTC_X", "ETH_BTC = kraken(ETH_BTC)"}, + BlockExplorerLink = + NetworkType == NetworkType.Mainnet + ? "https://etherscan.io/address/{0}" + : "https://ropsten.etherscan.io/address/{0}", + CryptoImagePath = "/imlegacy/eth.png", + ShowSyncSummary = true, + CoinType = NetworkType == NetworkType.Mainnet? 60 : 1, + ChainId = NetworkType == NetworkType.Mainnet ? 1 : 3, + Divisibility = 18, + }); + } + + public void InitERC20() + { + if (NetworkType != NetworkType.Mainnet) + { + Add(new ERC20BTCPayNetwork() + { + CryptoCode = "FAU", + DisplayName = "Faucet Token", + DefaultRateRules = new[] + { + "FAU_X = FAU_BTC * BTC_X", + "FAU_BTC = 0.01", + }, + BlockExplorerLink = "https://ropsten.etherscan.io/address/{0}#tokentxns", + ShowSyncSummary = false, + CoinType = 1, + ChainId = 3, + //use https://erc20faucet.com for testnet + SmartContractAddress = "0xFab46E002BbF0b4509813474841E0716E6730136", + Divisibility = 18, + CryptoImagePath = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + }); + } + else + { + Add(new ERC20BTCPayNetwork() + { + CryptoCode = "USDT20", + DisplayName = "Tether USD (ERC20)", + DefaultRateRules = new[] + { + "USDT20_UST = 1", + "USDT20_X = USDT20_BTC * BTC_X", + "USDT20_BTC = bitfinex(UST_BTC)", + }, + BlockExplorerLink = + NetworkType == NetworkType.Mainnet + ? "https://etherscan.io/address/{0}#tokentxns" + : "https://ropsten.etherscan.io/address/{0}#tokentxns", + CryptoImagePath = "/imlegacy/liquid-tether.svg", + ShowSyncSummary = false, + CoinType = NetworkType == NetworkType.Mainnet? 60 : 1, + ChainId = NetworkType == NetworkType.Mainnet ? 1 : 3, + SmartContractAddress = "0xdAC17F958D2ee523a2206206994597C13D831ec7", + Divisibility = 6 + }); + } + + } + } +} +#endif diff --git a/BTCPayServer.Common/Altcoins/Ethereum/EthereumBTCPayNetwork.cs b/BTCPayServer.Common/Altcoins/Ethereum/EthereumBTCPayNetwork.cs new file mode 100644 index 000000000..d07bce92c --- /dev/null +++ b/BTCPayServer.Common/Altcoins/Ethereum/EthereumBTCPayNetwork.cs @@ -0,0 +1,20 @@ +#if ALTCOINS +namespace BTCPayServer +{ + public class EthereumBTCPayNetwork : BTCPayNetworkBase + { + public int ChainId { get; set; } + public int CoinType { get; set; } + + public string GetDefaultKeyPath() + { + return $"m/44'/{CoinType}'/0'/0/x"; + } + } + + public class ERC20BTCPayNetwork : EthereumBTCPayNetwork + { + public string SmartContractAddress { get; set; } + } +} +#endif diff --git a/BTCPayServer.Common/Altcoins/Ethereum/EthereumExtensions.cs b/BTCPayServer.Common/Altcoins/Ethereum/EthereumExtensions.cs new file mode 100644 index 000000000..a61baffd4 --- /dev/null +++ b/BTCPayServer.Common/Altcoins/Ethereum/EthereumExtensions.cs @@ -0,0 +1,20 @@ +#if ALTCOINS +using System.Collections.Generic; +using System.Linq; + +namespace BTCPayServer +{ + public static class EthereumExtensions + { + + public static IEnumerable GetAllEthereumSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfiltered) + { + var ethBased = networkProvider.GetAll().OfType(); + var chainId = ethBased.Select(network => network.ChainId).Distinct(); + return unfiltered.GetAll().OfType() + .Where(network => chainId.Contains(network.ChainId)) + .Select(network => network.CryptoCode.ToUpperInvariant()); + } + } +} +#endif diff --git a/BTCPayServer.Common/Altcoins/Liquid/LiquidExtensions.cs b/BTCPayServer.Common/Altcoins/Liquid/LiquidExtensions.cs index d0248ba7e..ccfd8e67e 100644 --- a/BTCPayServer.Common/Altcoins/Liquid/LiquidExtensions.cs +++ b/BTCPayServer.Common/Altcoins/Liquid/LiquidExtensions.cs @@ -6,11 +6,11 @@ namespace BTCPayServer { public static class LiquidExtensions { - public static IEnumerable GetAllElementsSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfilteredNetworkProvider) + public static IEnumerable GetAllElementsSubChains(this BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider unfiltered) { var elementsBased = networkProvider.GetAll().OfType(); var parentChains = elementsBased.Select(network => network.NetworkCryptoCode.ToUpperInvariant()).Distinct(); - return unfilteredNetworkProvider.GetAll().OfType() + return unfiltered.GetAll().OfType() .Where(network => parentChains.Contains(network.NetworkCryptoCode)).Select(network => network.CryptoCode.ToUpperInvariant()); } } diff --git a/BTCPayServer.Common/BTCPayNetworkProvider.cs b/BTCPayServer.Common/BTCPayNetworkProvider.cs index 852497a84..8466252a7 100644 --- a/BTCPayServer.Common/BTCPayNetworkProvider.cs +++ b/BTCPayServer.Common/BTCPayNetworkProvider.cs @@ -58,6 +58,8 @@ namespace BTCPayServer InitPolis(); InitChaincoin(); InitArgoneum(); + InitEthereum(); + InitERC20(); // Assume that electrum mappings are same as BTC if not specified foreach (var network in _Networks.Values.OfType()) diff --git a/BTCPayServer.Rating/Currencies.json b/BTCPayServer.Rating/Currencies.json index 738274c4e..f1c39a68b 100644 --- a/BTCPayServer.Rating/Currencies.json +++ b/BTCPayServer.Rating/Currencies.json @@ -1286,5 +1286,26 @@ "divisibility":0, "symbol":"Sats", "crypto":true + }, + { + "name": "Ethereum", + "code": "ETH", + "divisibility": 18, + "symbol": null, + "crypto": true + }, + { + "name":"USDt", + "code":"USDT20", + "divisibility":6, + "symbol":null, + "crypto":true + }, + { + "name":"FaucetToken", + "code":"FAU", + "divisibility":18, + "symbol":null, + "crypto":true } ] diff --git a/BTCPayServer.Tests/AltcoinTests/EthereumTests.cs b/BTCPayServer.Tests/AltcoinTests/EthereumTests.cs new file mode 100644 index 000000000..02c664e33 --- /dev/null +++ b/BTCPayServer.Tests/AltcoinTests/EthereumTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Tests.Logging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using NBitcoin; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests +{ + public class EthereumTests + { + public const int TestTimeout = 60_000; + + public EthereumTests(ITestOutputHelper helper) + { + Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.LogProvider = new XUnitLogProvider(helper); + } + + [Fact] + [Trait("Fast", "Fast")] + [Trait("Altcoins", "Altcoins")] + public void LoadSubChainsAlways() + { + var options = new BTCPayServerOptions(); + options.LoadArgs(new ConfigurationRoot(new List() + { + new MemoryConfigurationProvider(new MemoryConfigurationSource() + { + InitialData = new[] {new KeyValuePair("chains", "usdt20"),} + }) + })); + + Assert.NotNull(options.NetworkProvider.GetNetwork("ETH")); + Assert.NotNull(options.NetworkProvider.GetNetwork("USDT20")); + } + + [Fact] + [Trait("Altcoins", "Altcoins")] + public async Task CanUseEthereum() + { + using var s = SeleniumTester.Create("ETHEREUM", true); + s.Server.ActivateETH(); + await s.StartAsync(); + s.RegisterNewUser(true); + + IWebElement syncSummary = null; + TestUtils.Eventually(() => + { + syncSummary = s.Driver.FindElement(By.Id("modalDialog")); + Assert.True(syncSummary.Displayed); + }); + var web3Link = syncSummary.FindElement(By.LinkText("Configure Web3")); + web3Link.Click(); + s.Driver.FindElement(By.Id("Web3ProviderUrl")).SendKeys("https://ropsten-rpc.linkpool.io"); + s.Driver.FindElement(By.Id("saveButton")).Click(); + s.AssertHappyMessage(); + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + s.Driver.AssertElementNotFound(By.Id("modalDialog")); + }); + + var store = s.CreateNewStore(); + s.Driver.FindElement(By.LinkText("Ethereum")).Click(); + + var seed = new Mnemonic(Wordlist.English); + s.Driver.FindElement(By.Id("ModifyETH")).Click(); + s.Driver.FindElement(By.Id("Seed")).SendKeys(seed.ToString()); + s.SetCheckbox(s.Driver.FindElement(By.Id("StoreSeed")), true); + s.SetCheckbox(s.Driver.FindElement(By.Id("Enabled")), true); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + s.AssertHappyMessage(); + s.Driver.FindElement(By.Id("ModifyUSDT20")).Click(); + s.Driver.FindElement(By.Id("Seed")).SendKeys(seed.ToString()); + s.SetCheckbox(s.Driver.FindElement(By.Id("StoreSeed")), true); + s.SetCheckbox(s.Driver.FindElement(By.Id("Enabled")), true); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + s.AssertHappyMessage(); + + var invoiceId = s.CreateInvoice(store.storeName, 10); + s.GoToInvoiceCheckout(invoiceId); + var currencyDropdownButton = s.Driver.WaitForElement(By.ClassName("payment__currencies")); + Assert.Contains("ETH", currencyDropdownButton.Text); + s.Driver.FindElement(By.Id("copy-tab")).Click(); + + var ethAddress = s.Driver.FindElements(By.ClassName("copySectionBox")) + .Single(element => element.FindElement(By.TagName("label")).Text + .Contains("Address", StringComparison.InvariantCultureIgnoreCase)).FindElement(By.TagName("input")) + .GetAttribute("value"); + currencyDropdownButton.Click(); + var elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem")); + Assert.Equal(2, elements.Count); + + elements.Single(element => element.Text.Contains("USDT20")).Click(); + s.Driver.FindElement(By.Id("copy-tab")).Click(); + var usdtAddress = s.Driver.FindElements(By.ClassName("copySectionBox")) + .Single(element => element.FindElement(By.TagName("label")).Text + .Contains("Address", StringComparison.InvariantCultureIgnoreCase)).FindElement(By.TagName("input")) + .GetAttribute("value"); + Assert.Equal(usdtAddress, ethAddress); + } + } +} diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index 43e085570..d80447459 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -222,6 +222,9 @@ namespace BTCPayServer.Tests var bitpay = new MockRateProvider(); bitpay.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETB_BTC"), new BidAsk(0.1m))); rateProvider.Providers.Add("bitpay", bitpay); + var kraken = new MockRateProvider(); + kraken.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETH_BTC"), new BidAsk(0.1m))); + rateProvider.Providers.Add("kraken", kraken); } diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index b0694e3b8..7dea97cdf 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -78,6 +78,11 @@ namespace BTCPayServer.Tests PayTester.Chains.Add("LBTC"); PayTester.LBTCNBXplorerUri = LBTCExplorerClient.Address; } + public void ActivateETH() + { + PayTester.Chains.Add("ETH"); + } + #endif public void ActivateLightning() { diff --git a/BTCPayServer/Configuration/BTCPayServerOptions.cs b/BTCPayServer/Configuration/BTCPayServerOptions.cs index 9e94cce2b..5d546f292 100644 --- a/BTCPayServer/Configuration/BTCPayServerOptions.cs +++ b/BTCPayServer/Configuration/BTCPayServerOptions.cs @@ -93,6 +93,7 @@ namespace BTCPayServer.Configuration var filtered = networkProvider.Filter(supportedChains.ToArray()); #if ALTCOINS supportedChains.AddRange(filtered.GetAllElementsSubChains(networkProvider)); + supportedChains.AddRange(filtered.GetAllEthereumSubChains(networkProvider)); #endif #if !ALTCOINS var onlyBTC = supportedChains.Count == 1 && supportedChains.First() == "BTC"; diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index de9d829a6..ef5e32576 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -48,6 +48,7 @@ using NicolasDorier.RateLimits; using Serilog; #if ALTCOINS using BTCPayServer.Services.Altcoins.Monero; +using BTCPayServer.Services.Altcoins.Ethereum; #endif namespace BTCPayServer.Hosting { @@ -78,6 +79,7 @@ namespace BTCPayServer.Hosting services.AddPayJoinServices(); #if ALTCOINS services.AddMoneroLike(); + services.AddEthereumLike(); #endif services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/BTCPayServer/Payments/PaymentTypes.cs b/BTCPayServer/Payments/PaymentTypes.cs index 6ca9c0cf2..5b4e23cb9 100644 --- a/BTCPayServer/Payments/PaymentTypes.cs +++ b/BTCPayServer/Payments/PaymentTypes.cs @@ -1,5 +1,6 @@ using System; #if ALTCOINS +using BTCPayServer.Services.Altcoins.Ethereum.Payments; using BTCPayServer.Services.Altcoins.Monero.Payments; #endif using BTCPayServer.Services.Invoices; @@ -45,6 +46,9 @@ namespace BTCPayServer.Payments case "monerolike": type = PaymentTypes.MoneroLike; break; + case "ethereumlike": + type = EthereumPaymentType.Instance; + break; #endif default: type = null; diff --git a/BTCPayServer/Properties/launchSettings.json b/BTCPayServer/Properties/launchSettings.json index 15805eab9..e5c90e382 100644 --- a/BTCPayServer/Properties/launchSettings.json +++ b/BTCPayServer/Properties/launchSettings.json @@ -77,7 +77,7 @@ "BTCPAY_ALLOW-ADMIN-REGISTRATION": "true", "BTCPAY_DISABLE-REGISTRATION": "false", "ASPNETCORE_ENVIRONMENT": "Development", - "BTCPAY_CHAINS": "btc,ltc,lbtc", + "BTCPAY_CHAINS": "btc,ltc,lbtc,eth", "BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver", "BTCPAY_EXTERNALSERVICES": "totoservice:totolink;", "BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622", diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Configuration/EthereumLikeConfiguration.cs b/BTCPayServer/Services/Altcoins/Ethereum/Configuration/EthereumLikeConfiguration.cs new file mode 100644 index 000000000..aad53cc2b --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Configuration/EthereumLikeConfiguration.cs @@ -0,0 +1,31 @@ +#if ALTCOINS +using System.ComponentModel.DataAnnotations; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Configuration +{ + public class EthereumLikeConfiguration + { + public static string SettingsKey(int chainId) + { + return $"{nameof(EthereumLikeConfiguration)}_{chainId}"; + } + public int ChainId { get; set; } + [Display(Name = "Web3 provider url")] + public string Web3ProviderUrl { get; set; } + + [Display(Name = "Web3 provider username (can be left blank)")] + public string Web3ProviderUsername { get; set; } + + [Display(Name = "Web3 provider password (can be left blank)")] + public string Web3ProviderPassword { get; set; } + + public string InvoiceId { get; set; } + + public override string ToString() + { + return ""; + } + } +} +#endif + diff --git a/BTCPayServer/Services/Altcoins/Ethereum/EthereumLikeExtensions.cs b/BTCPayServer/Services/Altcoins/Ethereum/EthereumLikeExtensions.cs new file mode 100644 index 000000000..f0890da42 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/EthereumLikeExtensions.cs @@ -0,0 +1,41 @@ +#if ALTCOINS +using System.Net; +using System.Net.Http; +using BTCPayServer.Contracts; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.Services.Altcoins.Ethereum.Payments; +using BTCPayServer.Services.Altcoins.Ethereum.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.Services.Altcoins.Ethereum +{ + public static class EthereumLikeExtensions + { + public const string EthereumInvoiceCheckHttpClient = "EthereumCheck"; + public const string EthereumInvoiceCreateHttpClient = "EthereumCreate"; + public static IServiceCollection AddEthereumLike(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(provider => provider.GetService()); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(provider => provider.GetService()); + serviceCollection.AddSingleton(); + serviceCollection.AddTransient(); + serviceCollection.AddSingleton(); + serviceCollection.AddHttpClient(EthereumInvoiceCreateHttpClient) + .ConfigurePrimaryHttpMessageHandler(); + return serviceCollection; + } + } + + public class NoRedirectHttpClientHandler : HttpClientHandler + { + public NoRedirectHttpClientHandler() + { + this.AllowAutoRedirect = false; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/EthereumStoreNavExtension.cs b/BTCPayServer/Services/Altcoins/Ethereum/EthereumStoreNavExtension.cs new file mode 100644 index 000000000..8a2ec25be --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/EthereumStoreNavExtension.cs @@ -0,0 +1,11 @@ +#if ALTCOINS +using BTCPayServer.Contracts; + +namespace BTCPayServer.Services.Altcoins.Ethereum +{ + public class EthereumStoreNavExtension: IStoreNavExtension + { + public string Partial { get; } = "Ethereum/StoreNavEthereumExtension"; + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Filters/OnlyIfSupportEth.cs b/BTCPayServer/Services/Altcoins/Ethereum/Filters/OnlyIfSupportEth.cs new file mode 100644 index 000000000..a5920be22 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Filters/OnlyIfSupportEth.cs @@ -0,0 +1,26 @@ +#if ALTCOINS +using System; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Filters +{ + public class OnlyIfSupportEthAttribute : Attribute, IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var options = (BTCPayServerOptions) context.HttpContext.RequestServices.GetService(typeof(BTCPayServerOptions)); + if (!options.NetworkProvider.GetAll().OfType().Any()) + { + context.Result = new NotFoundResult(); + return; + } + await next(); + } + } + +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikeOnChainPaymentMethodDetails.cs b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikeOnChainPaymentMethodDetails.cs new file mode 100644 index 000000000..24b76e382 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikeOnChainPaymentMethodDetails.cs @@ -0,0 +1,37 @@ +#if ALTCOINS +using BTCPayServer.Payments; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Payments +{ + public class EthereumLikeOnChainPaymentMethodDetails : IPaymentMethodDetails + { + public PaymentType GetPaymentType() + { + return EthereumPaymentType.Instance; + } + + public string GetPaymentDestination() + { + return DepositAddress; + } + + public decimal GetNextNetworkFee() + { + return 0; + } + + public decimal GetFeeRate() + { + return 0; + } + + public void SetPaymentDestination(string newPaymentDestination) + { + DepositAddress = newPaymentDestination; + } + public long Index { get; set; } + public string XPub { get; set; } + public string DepositAddress { get; set; } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentData.cs b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentData.cs new file mode 100644 index 000000000..7bda8793e --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentData.cs @@ -0,0 +1,77 @@ +#if ALTCOINS +using System.Globalization; +using System.Numerics; +using BTCPayServer.Client.Models; +using BTCPayServer.Payments; +using BTCPayServer.Services.Invoices; +using Nethereum.Hex.HexTypes; +using Nethereum.Web3; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Payments +{ + public class EthereumLikePaymentData : CryptoPaymentData + { + public long Amount { get; set; } + public string CryptoCode { get; set; } + public string Address { get; set; } + public long AccountIndex { get; set; } + public string XPub { get; set; } + public long ConfirmationCount { get; set; } + public BTCPayNetworkBase Network { get; set; } + public long? BlockNumber { get; set; } + + public string GetPaymentId() + { + return GetPaymentId(CryptoCode,Address, Amount); + } + + public static string GetPaymentId(string cryptoCode, string address, long amount) + { + return $"{cryptoCode}#{address}#{amount}"; + } + + public string[] GetSearchTerms() + { + return new[] {Address}; + } + + public decimal GetValue() + { + return decimal.Parse(Web3.Convert.FromWeiToBigDecimal(Amount, Network.Divisibility).ToString(), + CultureInfo.InvariantCulture); + } + + public bool PaymentCompleted(PaymentEntity entity) + { + return ConfirmationCount >= 25; + } + + public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy) + { + switch (speedPolicy) + { + case SpeedPolicy.HighSpeed: + return ConfirmationCount >= 2; + case SpeedPolicy.MediumSpeed: + return ConfirmationCount >= 6; + case SpeedPolicy.LowMediumSpeed: + return ConfirmationCount >= 12; + case SpeedPolicy.LowSpeed: + return ConfirmationCount >= 20; + default: + return false; + } + } + + public PaymentType GetPaymentType() + { + return EthereumPaymentType.Instance; + } + + public string GetDestination() + { + return Address; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentMethodHandler.cs b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentMethodHandler.cs new file mode 100644 index 000000000..f157f2a67 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumLikePaymentMethodHandler.cs @@ -0,0 +1,134 @@ +#if ALTCOINS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Logging; +using BTCPayServer.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Payments; +using BTCPayServer.Rating; +using BTCPayServer.Services.Altcoins.Ethereum.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using NBitcoin; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Payments +{ + public class + EthereumLikePaymentMethodHandler : PaymentMethodHandlerBase + { + private readonly BTCPayNetworkProvider _networkProvider; + private readonly EthereumService _ethereumService; + + public EthereumLikePaymentMethodHandler(BTCPayNetworkProvider networkProvider, EthereumService ethereumService) + { + _networkProvider = networkProvider; + _ethereumService = ethereumService; + } + + public override PaymentType PaymentType => EthereumPaymentType.Instance; + + public override async Task CreatePaymentMethodDetails(InvoiceLogs logs, + EthereumSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod, + StoreData store, EthereumBTCPayNetwork network, object preparePaymentObject) + { + if (!_ethereumService.IsAvailable(network.CryptoCode, out var error)) + throw new PaymentMethodUnavailableException(error??$"Not configured yet"); + var invoice = paymentMethod.ParentEntity; + if (!(preparePaymentObject is Prepare ethPrepare)) throw new ArgumentException(); + var address = await ethPrepare.ReserveAddress(invoice.Id); + if (address is null || address.Failed) + { + throw new PaymentMethodUnavailableException($"could not generate address"); + } + + return new EthereumLikeOnChainPaymentMethodDetails() + { + DepositAddress = address.Address, Index = address.Index, XPub = address.XPub + }; + } + + public override object PreparePayment(EthereumSupportedPaymentMethod supportedPaymentMethod, StoreData store, + BTCPayNetworkBase network) + { + return new Prepare() + { + ReserveAddress = s => + _ethereumService.ReserveNextAddress( + new EthereumService.ReserveEthereumAddress() + { + StoreId = store.Id, CryptoCode = network.CryptoCode + }) + }; + } + + class Prepare + { + public Func> ReserveAddress; + } + + public override void PreparePaymentModel(PaymentModel model, InvoiceResponse invoiceResponse, + StoreBlob storeBlob) + { + var paymentMethodId = new PaymentMethodId(model.CryptoCode, PaymentType); + var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId); + var network = _networkProvider.GetNetwork(model.CryptoCode); + model.IsLightning = false; + model.PaymentMethodName = GetPaymentMethodName(network); + model.CryptoImage = GetCryptoImage(network); + model.InvoiceBitcoinUrl = ""; + model.InvoiceBitcoinUrlQR = cryptoInfo.Address; + } + + public override string GetCryptoImage(PaymentMethodId paymentMethodId) + { + var network = _networkProvider.GetNetwork(paymentMethodId.CryptoCode); + return GetCryptoImage(network); + } + + public override string GetPaymentMethodName(PaymentMethodId paymentMethodId) + { + var network = _networkProvider.GetNetwork(paymentMethodId.CryptoCode); + return GetPaymentMethodName(network); + } + + public override Task IsPaymentMethodAllowedBasedOnInvoiceAmount(StoreBlob storeBlob, + Dictionary> rate, Money amount, + PaymentMethodId paymentMethodId) + { + return Task.FromResult(null); + } + + public override IEnumerable GetSupportedPaymentMethods() + { + return _networkProvider.GetAll().OfType() + .Select(network => new PaymentMethodId(network.CryptoCode, PaymentType)); + } + + public override CheckoutUIPaymentMethodSettings GetCheckoutUISettings() + { + return new CheckoutUIPaymentMethodSettings() + { + ExtensionPartial = "Ethereum/EthereumLikeMethodCheckout", + CheckoutBodyVueComponentName = "EthereumLikeMethodCheckout", + CheckoutHeaderVueComponentName = "EthereumLikeMethodCheckoutHeader", + NoScriptPartialName = "Bitcoin_Lightning_LikeMethodCheckoutNoScript" + }; + } + + private string GetCryptoImage(EthereumBTCPayNetwork network) + { + return network.CryptoImagePath; + } + + + private string GetPaymentMethodName(EthereumBTCPayNetwork network) + { + return $"{network.DisplayName}"; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumPaymentType.cs b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumPaymentType.cs new file mode 100644 index 000000000..7edabf0b9 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumPaymentType.cs @@ -0,0 +1,59 @@ +#if ALTCOINS +using System.Globalization; +using BTCPayServer.Payments; +using BTCPayServer.Services.Altcoins.Monero.Payments; +using BTCPayServer.Services.Invoices; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Payments +{ + public class EthereumPaymentType: PaymentType + { + public static EthereumPaymentType Instance { get; } = new EthereumPaymentType(); + public override string ToPrettyString() => "On-Chain"; + + public override string GetId()=> "EthereumLike"; + + + public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str) + { + return JsonConvert.DeserializeObject(str); + } + + public override string SerializePaymentData(BTCPayNetworkBase network, CryptoPaymentData paymentData) + { + return JsonConvert.SerializeObject(paymentData); + } + + public override IPaymentMethodDetails DeserializePaymentMethodDetails(BTCPayNetworkBase network, string str) + { + return JsonConvert.DeserializeObject(str); + } + + public override string SerializePaymentMethodDetails(BTCPayNetworkBase network, IPaymentMethodDetails details) + { + return JsonConvert.SerializeObject(details); + } + + public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value) + { + return JsonConvert.DeserializeObject(value.ToString()); + } + + public override string GetTransactionLink(BTCPayNetworkBase network, string txId) + { + return string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId); + } + + public override string GetPaymentLink(BTCPayNetworkBase network, IPaymentMethodDetails paymentMethodDetails, Money cryptoInfoDue, + string serverUri) + { + return ""; + } + + public override string InvoiceViewPaymentPartialName { get; }= "Ethereum/ViewEthereumLikePaymentData"; + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumSupportedPaymentMethod.cs b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumSupportedPaymentMethod.cs new file mode 100644 index 000000000..93effb06a --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Payments/EthereumSupportedPaymentMethod.cs @@ -0,0 +1,41 @@ +#if ALTCOINS +using System; +using BTCPayServer.Payments; +using NBitcoin; +using Nethereum.HdWallet; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Payments +{ + public class EthereumSupportedPaymentMethod : ISupportedPaymentMethod + { + public string CryptoCode { get; set; } + public string Seed { get; set; } + public string Password { get; set; } + public string XPub { get; set; } + public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, EthereumPaymentType.Instance); + public long CurrentIndex { get; set; } + public string KeyPath { get; set; } + + public Func GetWalletDerivator() + { + if (!string.IsNullOrEmpty(XPub)) + { + try + { + return new PublicWallet(XPub).GetAddress; + } + catch (Exception) + { + return new PublicWallet(new BitcoinExtPubKey(XPub, Network.Main).ExtPubKey).GetAddress; + } + } + else if (!string.IsNullOrEmpty(XPub)) + { + return i => new Wallet(Seed, Password, KeyPath).GetAccount(i).Address; + } + + return null; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumService.cs b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumService.cs new file mode 100644 index 000000000..10b77ae15 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumService.cs @@ -0,0 +1,313 @@ +#if ALTCOINS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Logging; +using BTCPayServer.Services.Altcoins.Ethereum.Payments; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Altcoins.Ethereum.Configuration; +using BTCPayServer.Services.Altcoins.Ethereum.UI; +using BTCPayServer.Services.Invoices; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using NBitcoin; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Services +{ + public class EthereumService : EventHostedServiceBase + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly EventAggregator _eventAggregator; + private readonly StoreRepository _storeRepository; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly SettingsRepository _settingsRepository; + private readonly InvoiceRepository _invoiceRepository; + private readonly IConfiguration _configuration; + private readonly Dictionary _chainHostedServices = new Dictionary(); + + private readonly Dictionary _chainHostedServiceCancellationTokenSources = + new Dictionary(); + public EthereumService( + IHttpClientFactory httpClientFactory, + EventAggregator eventAggregator, + StoreRepository storeRepository, + BTCPayNetworkProvider btcPayNetworkProvider, + SettingsRepository settingsRepository, + InvoiceRepository invoiceRepository, + IConfiguration configuration) : base( + eventAggregator) + { + _httpClientFactory = httpClientFactory; + _eventAggregator = eventAggregator; + _storeRepository = storeRepository; + _btcPayNetworkProvider = btcPayNetworkProvider; + _settingsRepository = settingsRepository; + _invoiceRepository = invoiceRepository; + _configuration = configuration; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + var chainIds = _btcPayNetworkProvider.GetAll().OfType() + .Select(network => network.ChainId).Distinct().ToList(); + if (!chainIds.Any()) + { + return; + } + + await base.StartAsync(cancellationToken); + _ = Task.Run(async () => + { + while (!cancellationToken.IsCancellationRequested) + { + _eventAggregator.Publish(new CheckWatchers()); + await Task.Delay(IsAllAvailable()? TimeSpan.FromDays(1): TimeSpan.FromSeconds(5) , cancellationToken); + } + }, cancellationToken); + + } + + private static bool First = true; + private async Task LoopThroughChainWatchers(CancellationToken cancellationToken) + { + var chainIds = _btcPayNetworkProvider.GetAll().OfType() + .Select(network => network.ChainId).Distinct().ToList(); + foreach (var chainId in chainIds) + { + try + { + var settings = await _settingsRepository.GetSettingAsync( + EthereumLikeConfiguration.SettingsKey(chainId)); + if (settings is null || string.IsNullOrEmpty(settings.Web3ProviderUrl)) + { + var val = _configuration.GetValue($"chain{chainId}_web3", null); + var valUser = _configuration.GetValue($"chain{chainId}_web3_user", null); + var valPass = _configuration.GetValue($"chain{chainId}_web3_password", null); + if (val != null && First) + { + Logs.PayServer.LogInformation($"Setting eth chain {chainId} web3 to {val}"); + settings ??= new EthereumLikeConfiguration() + { + ChainId = chainId, + Web3ProviderUrl = val, + Web3ProviderPassword = valPass, + Web3ProviderUsername = valUser + }; + await _settingsRepository.UpdateSetting(settings, EthereumLikeConfiguration.SettingsKey(chainId)); + } + } + var currentlyRunning = _chainHostedServices.ContainsKey(chainId); + var valid = await EthereumConfigController.CheckValid(_httpClientFactory, _btcPayNetworkProvider.NetworkType, settings?.InvoiceId); + if (!currentlyRunning || (currentlyRunning && !valid)) + { + await HandleChainWatcher(settings, valid, cancellationToken); + } + } + catch (Exception) + { + // ignored + } + } + + First = false; + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + foreach (var chainHostedService in _chainHostedServices.Values) + { + _ = chainHostedService.StopAsync(cancellationToken); + } + + return base.StopAsync(cancellationToken); + } + + protected override void SubscribeToEvents() + { + base.SubscribeToEvents(); + + Subscribe(); + Subscribe>(); + Subscribe(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is ReserveEthereumAddress reserveEthereumAddress) + { + await HandleReserveNextAddress(reserveEthereumAddress); + } + + if (evt is SettingsChanged settingsChangedEthConfig) + { + var valid = await EthereumConfigController.CheckValid(_httpClientFactory, _btcPayNetworkProvider.NetworkType, settingsChangedEthConfig?.Settings?.InvoiceId); + await HandleChainWatcher(settingsChangedEthConfig.Settings, valid, cancellationToken); + } + + if (evt is CheckWatchers) + { + await LoopThroughChainWatchers(cancellationToken); + } + + await base.ProcessEvent(evt, cancellationToken); + } + + private async Task HandleChainWatcher(EthereumLikeConfiguration ethereumLikeConfiguration, bool valid, + CancellationToken cancellationToken) + { + if (ethereumLikeConfiguration is null) + { + return; + } + + if (_chainHostedServiceCancellationTokenSources.ContainsKey(ethereumLikeConfiguration.ChainId)) + { + _chainHostedServiceCancellationTokenSources[ethereumLikeConfiguration.ChainId].Cancel(); + _chainHostedServiceCancellationTokenSources.Remove(ethereumLikeConfiguration.ChainId); + } + + if (_chainHostedServices.ContainsKey(ethereumLikeConfiguration.ChainId)) + { + await _chainHostedServices[ethereumLikeConfiguration.ChainId].StopAsync(cancellationToken); + _chainHostedServices.Remove(ethereumLikeConfiguration.ChainId); + } + + if (!string.IsNullOrWhiteSpace(ethereumLikeConfiguration.Web3ProviderUrl) && valid) + { + + var cts = new CancellationTokenSource(); + _chainHostedServiceCancellationTokenSources.AddOrReplace(ethereumLikeConfiguration.ChainId, cts); + _chainHostedServices.AddOrReplace(ethereumLikeConfiguration.ChainId, + new EthereumWatcher(ethereumLikeConfiguration.ChainId, ethereumLikeConfiguration, + _btcPayNetworkProvider, _eventAggregator, _invoiceRepository)); + await _chainHostedServices[ethereumLikeConfiguration.ChainId].StartAsync(CancellationTokenSource + .CreateLinkedTokenSource(cancellationToken, cts.Token).Token); + } + } + + private async Task HandleReserveNextAddress(ReserveEthereumAddress reserveEthereumAddress) + { + var store = await _storeRepository.FindStore(reserveEthereumAddress.StoreId); + var ethereumSupportedPaymentMethod = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType() + .SingleOrDefault(method => method.PaymentId.CryptoCode == reserveEthereumAddress.CryptoCode); + if (ethereumSupportedPaymentMethod == null) + { + _eventAggregator.Publish(new ReserveEthereumAddressResponse() + { + OpId = reserveEthereumAddress.OpId, Failed = true + }); + return; + } + + ethereumSupportedPaymentMethod.CurrentIndex++; + var address = ethereumSupportedPaymentMethod.GetWalletDerivator()? + .Invoke((int)ethereumSupportedPaymentMethod.CurrentIndex); + + if (string.IsNullOrEmpty(address)) + { + _eventAggregator.Publish(new ReserveEthereumAddressResponse() + { + OpId = reserveEthereumAddress.OpId, Failed = true + }); + return; + } + store.SetSupportedPaymentMethod(ethereumSupportedPaymentMethod.PaymentId, + ethereumSupportedPaymentMethod); + await _storeRepository.UpdateStore(store); + _eventAggregator.Publish(new ReserveEthereumAddressResponse() + { + Address = address, + Index = ethereumSupportedPaymentMethod.CurrentIndex, + CryptoCode = ethereumSupportedPaymentMethod.CryptoCode, + OpId = reserveEthereumAddress.OpId, + StoreId = reserveEthereumAddress.StoreId, + XPub = ethereumSupportedPaymentMethod.XPub + }); + } + + public async Task ReserveNextAddress(ReserveEthereumAddress address) + { + address.OpId = string.IsNullOrEmpty(address.OpId) ? Guid.NewGuid().ToString() : address.OpId; + var tcs = new TaskCompletionSource(); + var subscription = _eventAggregator.Subscribe(response => + { + if (response.OpId == address.OpId) + { + tcs.SetResult(response); + } + }); + _eventAggregator.Publish(address); + + if (tcs.Task.Wait(TimeSpan.FromSeconds(60))) + { + subscription?.Dispose(); + return await tcs.Task; + } + + subscription?.Dispose(); + return null; + } + + public class CheckWatchers + { + public override string ToString() + { + return ""; + } + } + + public class ReserveEthereumAddressResponse + { + public string StoreId { get; set; } + public string CryptoCode { get; set; } + public string Address { get; set; } + public long Index { get; set; } + public string OpId { get; set; } + public string XPub { get; set; } + public bool Failed { get; set; } + + public override string ToString() + { + return $"Reserved {CryptoCode} address {Address} for store {StoreId}"; + } + } + + public class ReserveEthereumAddress + { + public string StoreId { get; set; } + public string CryptoCode { get; set; } + public string OpId { get; set; } + + public override string ToString() + { + return $"Reserving {CryptoCode} address for store {StoreId}"; + } + } + + public bool IsAllAvailable() + { + return _btcPayNetworkProvider.GetAll().OfType() + .All(network => IsAvailable(network.CryptoCode, out _)); + } + + public bool IsAvailable(string networkCryptoCode, out string error) + { + error = null; + var chainId = _btcPayNetworkProvider.GetNetwork(networkCryptoCode)?.ChainId; + if (chainId != null && _chainHostedServices.TryGetValue(chainId.Value, out var watcher)) + { + error = watcher.GlobalError; + return string.IsNullOrEmpty(watcher.GlobalError); + } + return false; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumSyncSummaryProvider.cs b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumSyncSummaryProvider.cs new file mode 100644 index 000000000..ea929ebe7 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumSyncSummaryProvider.cs @@ -0,0 +1,23 @@ +#if ALTCOINS +using BTCPayServer.Contracts; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Services +{ + public class EthereumSyncSummaryProvider : ISyncSummaryProvider + { + private readonly EthereumService _ethereumService; + + public EthereumSyncSummaryProvider(EthereumService ethereumService) + { + _ethereumService = ethereumService; + } + + public bool AllAvailable() + { + return _ethereumService.IsAllAvailable(); + } + + public string Partial { get; } = "Ethereum/ETHSyncSummary"; + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumWatcher.cs b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumWatcher.cs new file mode 100644 index 000000000..e82c7bce3 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/Services/EthereumWatcher.cs @@ -0,0 +1,378 @@ +#if ALTCOINS +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Headers; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.Services.Altcoins.Ethereum.Configuration; +using BTCPayServer.Services.Altcoins.Ethereum.Payments; +using BTCPayServer.Services.Invoices; +using Microsoft.Extensions.Logging; +using NBitcoin.Logging; +using Nethereum.RPC.Eth.DTOs; +using Nethereum.StandardTokenEIP20.ContractDefinition; +using Nethereum.Web3; + +namespace BTCPayServer.Services.Altcoins.Ethereum.Services +{ + public class EthereumWatcher : EventHostedServiceBase + { + private readonly EventAggregator _eventAggregator; + private readonly InvoiceRepository _invoiceRepository; + private int ChainId { get; } + private readonly HashSet PaymentMethods; + + private readonly Web3 Web3; + private readonly List Networks; + public string GlobalError { get; private set; } = "The chain watcher is still starting."; + + public override async Task StartAsync(CancellationToken cancellationToken) + { + Logs.NodeServer.LogInformation($"Starting EthereumWatcher for chain {ChainId}"); + var result = await Web3.Eth.ChainId.SendRequestAsync(); + if (result.Value != ChainId) + { + GlobalError = + $"The web3 client is connected to a different chain id. Expected {ChainId} but Web3 returned {result.Value}"; + return; + } + + await base.StartAsync(cancellationToken); + _eventAggregator.Publish(new CatchUp()); + GlobalError = null; + } + + protected override void SubscribeToEvents() + { + Subscribe(); + Subscribe(); + Subscribe(); + base.SubscribeToEvents(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is CatchUp) + { + DateTimeOffset start = DateTimeOffset.Now; + await UpdateAnyPendingEthLikePaymentAndAddressWatchList(cancellationToken); + + TimeSpan diff = start - DateTimeOffset.Now; + if (diff.TotalSeconds < 5) + { + _ = Task.Delay(TimeSpan.FromSeconds(5 - diff.TotalSeconds), cancellationToken).ContinueWith(task => + { + _eventAggregator.Publish(new CatchUp()); + return Task.CompletedTask; + }, cancellationToken, TaskContinuationOptions.None, TaskScheduler.Current); + } + } + + if (evt is EthereumAddressBalanceFetched response) + { + if (response.ChainId != ChainId) + { + return; + } + + var network = Networks.SingleOrDefault(payNetwork => + payNetwork.CryptoCode.Equals(response.CryptoCode, StringComparison.InvariantCultureIgnoreCase)); + + if (network is null) + { + return; + } + + var invoice = response.InvoiceEntity; + if (invoice is null) + { + return; + } + + var existingPayment = response.MatchedExistingPayment; + + if (existingPayment is null && response.Amount > 0) + { + //new payment + var paymentData = new EthereumLikePaymentData() + { + Address = response.Address, + CryptoCode = response.CryptoCode, + Amount = response.Amount, + Network = network, + BlockNumber = + response.BlockParameter.ParameterType == BlockParameter.BlockParameterType.blockNumber + ? (long?)response.BlockParameter.BlockNumber.Value + : (long?)null, + ConfirmationCount = 0, + AccountIndex = response.PaymentMethodDetails.Index, + XPub = response.PaymentMethodDetails.XPub + }; + var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, + paymentData, network, true); + if (payment != null) ReceivedPayment(invoice, payment); + } + else if (existingPayment != null) + { + var cd = (EthereumLikePaymentData)existingPayment.GetCryptoPaymentData(); + //existing payment amount was changed. Set to unaccounted and register as a new payment. + if (response.Amount == 0 || response.Amount != cd.Amount) + { + existingPayment.Accounted = false; + + await _invoiceRepository.UpdatePayments(new List() {existingPayment}); + if (response.Amount > 0) + { + var paymentData = new EthereumLikePaymentData() + { + Address = response.Address, + CryptoCode = response.CryptoCode, + Amount = response.Amount, + Network = network, + BlockNumber = + response.BlockParameter.ParameterType == + BlockParameter.BlockParameterType.blockNumber + ? (long?)response.BlockParameter.BlockNumber.Value + : null, + ConfirmationCount = + response.BlockParameter.ParameterType == + BlockParameter.BlockParameterType.blockNumber + ? 1 + : 0, + + AccountIndex = cd.AccountIndex, + XPub = cd.XPub + }; + var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow, + paymentData, network, true); + if (payment != null) ReceivedPayment(invoice, payment); + } + } + else if (response.Amount == cd.Amount) + { + //transition from pending to 1 confirmed + if (cd.BlockNumber is null && response.BlockParameter.ParameterType == + BlockParameter.BlockParameterType.blockNumber) + { + cd.ConfirmationCount = 1; + cd.BlockNumber = (long?)response.BlockParameter.BlockNumber.Value; + + existingPayment.SetCryptoPaymentData(cd); + await _invoiceRepository.UpdatePayments(new List() {existingPayment}); + + _eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id)); + } + //increment confirm count + else if (response.BlockParameter.ParameterType == + BlockParameter.BlockParameterType.blockNumber) + { + if (response.BlockParameter.BlockNumber.Value > cd.BlockNumber.Value) + { + cd.ConfirmationCount = + (long)(response.BlockParameter.BlockNumber.Value - cd.BlockNumber.Value); + } + else + { + cd.BlockNumber = (long?)response.BlockParameter.BlockNumber.Value; + cd.ConfirmationCount = 1; + } + + existingPayment.SetCryptoPaymentData(cd); + await _invoiceRepository.UpdatePayments(new List() {existingPayment}); + + _eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(invoice.Id)); + } + } + } + } + } + + class CatchUp + { + public override string ToString() + { + return ""; + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + Logs.NodeServer.LogInformation($"Stopping EthereumWatcher for chain {ChainId}"); + return base.StopAsync(cancellationToken); + } + + + private async Task UpdateAnyPendingEthLikePaymentAndAddressWatchList(CancellationToken cancellationToken) + { + var invoiceIds = await _invoiceRepository.GetPendingInvoices(); + if (!invoiceIds.Any()) + { + return; + } + + var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() {InvoiceId = invoiceIds}); + invoices = invoices + .Where(entity => PaymentMethods.Any(id => entity.GetPaymentMethod(id) != null)) + .ToArray(); + + await UpdatePaymentStates(invoices, cancellationToken); + } + + private long? LastBlock = null; + + private async Task UpdatePaymentStates(InvoiceEntity[] invoices, CancellationToken cancellationToken) + { + if (!invoices.Any()) + { + return; + } + + var currentBlock = await Web3.Eth.Blocks.GetBlockNumber.SendRequestAsync(); + + foreach (var network in Networks) + { + var erc20Network = network as ERC20BTCPayNetwork; + var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance); + var expandedInvoices = invoices + .Select(entity => ( + Invoice: entity, + PaymentMethodDetails: entity.GetPaymentMethods().TryGet(paymentMethodId), + ExistingPayments: entity.GetPayments(network).Select(paymentEntity => (Payment: paymentEntity, + PaymentData: (EthereumLikePaymentData)paymentEntity.GetCryptoPaymentData(), + Invoice: entity)) + )).Where(tuple => tuple.PaymentMethodDetails != null).ToList(); + + var existingPaymentData = expandedInvoices.SelectMany(tuple => + tuple.ExistingPayments.Where(valueTuple => valueTuple.Payment.Accounted)).ToList(); + + var noAccountedPaymentInvoices = expandedInvoices.Where(tuple => + tuple.ExistingPayments.All(valueTuple => !valueTuple.Payment.Accounted)).ToList(); + + var tasks = new List(); + if (existingPaymentData.Any() && currentBlock.Value != LastBlock) + { + Logs.NodeServer.LogInformation( + $"Checking {existingPaymentData.Count} existing payments on {expandedInvoices.Count} invoices on {network.CryptoCode}"); + var blockParameter = new BlockParameter(currentBlock); + + tasks.Add(Task.WhenAll(existingPaymentData.Select(async tuple => + { + var bal = await GetBalance(network, blockParameter, tuple.PaymentData.Address); + _eventAggregator.Publish(new EthereumAddressBalanceFetched() + { + Address = tuple.PaymentData.Address, + CryptoCode = network.CryptoCode, + Amount = bal, + MatchedExistingPayment = tuple.Payment, + BlockParameter = blockParameter, + ChainId = ChainId, + InvoiceEntity = tuple.Invoice, + }); + })).ContinueWith(task => + { + LastBlock = (long?)currentBlock.Value; + }, TaskScheduler.Current)); + } + + if (noAccountedPaymentInvoices.Any()) + { + Logs.NodeServer.LogInformation( + $"Checking {noAccountedPaymentInvoices.Count} addresses for new payments on {network.CryptoCode}"); + var blockParameter = BlockParameter.CreatePending(); + tasks.AddRange(noAccountedPaymentInvoices.Select(async tuple => + { + var bal = await GetBalance(network, blockParameter, + tuple.PaymentMethodDetails.GetPaymentMethodDetails().GetPaymentDestination()); + _eventAggregator.Publish(new EthereumAddressBalanceFetched() + { + Address = tuple.PaymentMethodDetails.GetPaymentMethodDetails().GetPaymentDestination(), + CryptoCode = network.CryptoCode, + Amount = bal, + MatchedExistingPayment = null, + BlockParameter = blockParameter, + ChainId = ChainId, + InvoiceEntity = tuple.Invoice, + PaymentMethodDetails = (EthereumLikeOnChainPaymentMethodDetails) tuple.PaymentMethodDetails.GetPaymentMethodDetails() + }); + })); + } + + await Task.WhenAll(tasks); + } + } + + public class EthereumAddressBalanceFetched + { + public BlockParameter BlockParameter { get; set; } + public int ChainId { get; set; } + public string Address { get; set; } + public string CryptoCode { get; set; } + public long Amount { get; set; } + public InvoiceEntity InvoiceEntity { get; set; } + public PaymentEntity MatchedExistingPayment { get; set; } + public EthereumLikeOnChainPaymentMethodDetails PaymentMethodDetails { get; set; } + + public override string ToString() + { + return ""; + } + } + + private void ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment) + { + _eventAggregator.Publish( + new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) {Payment = payment}); + } + + private async Task GetBalance(EthereumBTCPayNetwork network, BlockParameter blockParameter, + string address) + { + if (network is ERC20BTCPayNetwork erc20BTCPayNetwork) + { + return (long)(await Web3.Eth.GetContractHandler(erc20BTCPayNetwork.SmartContractAddress) + .QueryAsync(new BalanceOfFunction() {Owner = address})); + } + else + { + return (long)(await Web3.Eth.GetBalance.SendRequestAsync(address, blockParameter)).Value; + } + } + + public EthereumWatcher(int chainId, EthereumLikeConfiguration config, + BTCPayNetworkProvider btcPayNetworkProvider, + EventAggregator eventAggregator, InvoiceRepository invoiceRepository) : + base(eventAggregator) + { + _eventAggregator = eventAggregator; + _invoiceRepository = invoiceRepository; + ChainId = chainId; + AuthenticationHeaderValue headerValue = null; + if (!string.IsNullOrEmpty(config.Web3ProviderUsername)) + { + var val = config.Web3ProviderUsername; + if (!string.IsNullOrEmpty(config.Web3ProviderUsername)) + { + val += $":{config.Web3ProviderUsername}"; + } + + headerValue = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String( + System.Text.Encoding.ASCII.GetBytes(val))); + } + Web3 = new Web3(config.Web3ProviderUrl, null, headerValue); + Networks = btcPayNetworkProvider.GetAll() + .OfType() + .Where(network => network.ChainId == chainId) + .ToList(); + PaymentMethods = Networks + .Select(network => new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance)) + .ToHashSet(); + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumConfigController.cs b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumConfigController.cs new file mode 100644 index 000000000..5b5c5edfb --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumConfigController.cs @@ -0,0 +1,169 @@ +#if ALTCOINS +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Models; +using BTCPayServer.Security; +using BTCPayServer.Services.Altcoins.Ethereum.Configuration; +using BTCPayServer.Services.Altcoins.Ethereum.Filters; +using BTCPayServer.Services.Altcoins.Ethereum.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using NBitcoin; +using Nethereum.Hex.HexConvertors.Extensions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services.Altcoins.Ethereum.UI +{ + [Route("ethconfig")] + [OnlyIfSupportEth] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public class EthereumConfigController : Controller + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly SettingsRepository _settingsRepository; + private readonly UserManager _userManager; + private readonly EventAggregator _eventAggregator; + + public EthereumConfigController(IHttpClientFactory httpClientFactory, SettingsRepository settingsRepository, + UserManager userManager, + EventAggregator eventAggregator) + { + _httpClientFactory = httpClientFactory; + _settingsRepository = settingsRepository; + _userManager = userManager; + _eventAggregator = eventAggregator; + } + + [HttpGet("{chainId}")] + public async Task UpdateChainConfig(int chainId) + { + return View("Ethereum/UpdateChainConfig", + (await _settingsRepository.GetSettingAsync( + EthereumLikeConfiguration.SettingsKey(chainId))) ?? new EthereumLikeConfiguration() + { + ChainId = chainId, Web3ProviderUrl = "" + }); + } + + [HttpGet("{chainId}/cb")] + public IActionResult Callback(int chainId) + { + _eventAggregator.Publish(new EthereumService.CheckWatchers()); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "If the invoice was paid successfully and confirmed, the system will be enabled momentarily" + }); + return RedirectToAction("UpdateChainConfig", new {chainId}); + } + + [HttpPost("{chainId}")] + public async Task UpdateChainConfig(int chainId, EthereumLikeConfiguration vm) + { + var current = await _settingsRepository.GetSettingAsync( + EthereumLikeConfiguration.SettingsKey(chainId)); + if (current?.Web3ProviderUrl != vm.Web3ProviderUrl || current?.InvoiceId != vm.InvoiceId) + { + vm.ChainId = chainId; + await _settingsRepository.UpdateSetting(vm, EthereumLikeConfiguration.SettingsKey(chainId)); + } + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, Message = $"Chain {chainId} updated" + }); + return RedirectToAction(nameof(UpdateChainConfig)); + } + + [HttpGet("{chainId}/p")] + [HttpPost("{chainId}/p")] + public async Task CreateInvoice(int chainId) + { + var current = await _settingsRepository.GetSettingAsync( + EthereumLikeConfiguration.SettingsKey(chainId)); + current ??= new EthereumLikeConfiguration() {ChainId = chainId}; + if (!string.IsNullOrEmpty(current?.InvoiceId) && + Request.Method.Equals("get", StringComparison.InvariantCultureIgnoreCase)) + { + return View("Confirm", + new ConfirmModel() + { + Title = $"Generate new donation link?", + Description = + "This previously linked donation instructions will be erased. If you paid anything to it, you will lose access.", + Action = "Confirm and generate", + }); + } + + var user = await _userManager.GetUserAsync(User); + var httpClient = _httpClientFactory.CreateClient(EthereumLikeExtensions.EthereumInvoiceCreateHttpClient); + string invoiceUrl; + var response = await httpClient.PostAsync($"{Server.HexToUTF8String()}{invoiceEndpoint.HexToUTF8String()}", + new FormUrlEncodedContent(new List>() + { + new KeyValuePair("choiceKey", $"license_{chainId}"), + new KeyValuePair("posData", + JsonConvert.SerializeObject(new {Host = Request.Host, ChainId = chainId})), + new KeyValuePair("orderID", $"eth_{Request.Host}_{chainId}"), + new KeyValuePair("email", user.Email), + new KeyValuePair("redirectUrl", + Url.Action("Callback", "EthereumConfig", new {chainId}, Request.Scheme)), + })); + if (response.StatusCode == System.Net.HttpStatusCode.Found) + { + HttpResponseHeaders headers = response.Headers; + if (headers != null && headers.Location != null) + { + invoiceUrl = $"{Server.HexToUTF8String()}{headers.Location}"; + current.InvoiceId = headers.Location.ToString() + .Replace("/i/", string.Empty, StringComparison.InvariantCultureIgnoreCase); + await UpdateChainConfig(chainId, current); + return Redirect(invoiceUrl); + } + } + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, Message = $"Couldn't connect to donation server, try again later." + }); + return RedirectToAction("UpdateChainConfig", new { chainId}); + } + + private string invoiceEndpoint = "0x2f617070732f3262706f754e74576b4b3543636e426d374833456a3346505a756f412f706f73"; + private static string Server = "0x68747470733a2f2f787061797365727665722e636f6d"; + public static NetworkType InvoiceEnforced = NetworkType.Mainnet; + + public static async Task CheckValid(IHttpClientFactory httpClientFactory, NetworkType networkType, string invoiceId) + { + if (networkType != InvoiceEnforced) + { + return true; + } + if (string.IsNullOrEmpty(invoiceId)) + { + return false; + } + + var httpClient = httpClientFactory.CreateClient(EthereumLikeExtensions.EthereumInvoiceCheckHttpClient); + var url = $"{Server.HexToUTF8String()}/i/{invoiceId}/status"; + var response = await httpClient.GetAsync(url); + if (!response.IsSuccessStatusCode) + { + return false; + } + var raw = await response.Content.ReadAsStringAsync(); + var status = JObject.Parse(raw)["status"].ToString(); + return (status.Equals("complete", StringComparison.InvariantCultureIgnoreCase) || + status.Equals("confirmed", StringComparison.InvariantCultureIgnoreCase)); + ; + } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumLikeStoreController.cs b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumLikeStoreController.cs new file mode 100644 index 000000000..b462431b9 --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumLikeStoreController.cs @@ -0,0 +1,281 @@ +#if ALTCOINS +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Models; +using BTCPayServer.Payments; +using BTCPayServer.Security; +using BTCPayServer.Services.Altcoins.Ethereum.Filters; +using BTCPayServer.Services.Altcoins.Ethereum.Payments; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Internal; +using NBitcoin; +using Nethereum.HdWallet; +using Nethereum.Hex.HexConvertors.Extensions; + +namespace BTCPayServer.Services.Altcoins.Ethereum.UI +{ + [Route("stores/{storeId}/ethlike")] + [OnlyIfSupportEth] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public class EthereumLikeStoreController : Controller + { + private readonly StoreRepository _storeRepository; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + + public EthereumLikeStoreController(StoreRepository storeRepository, + BTCPayNetworkProvider btcPayNetworkProvider) + { + _storeRepository = storeRepository; + _btcPayNetworkProvider = btcPayNetworkProvider; + } + + private StoreData StoreData => HttpContext.GetStoreData(); + + [HttpGet()] + public IActionResult GetStoreEthereumLikePaymentMethods() + { + var eth = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType(); + + var excludeFilters = StoreData.GetStoreBlob().GetExcludedPaymentMethods(); + var ethNetworks = _btcPayNetworkProvider.GetAll().OfType(); + + var vm = new ViewEthereumStoreOptionsViewModel() { }; + + foreach (var network in ethNetworks) + { + var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance); + var matchedPaymentMethod = eth.SingleOrDefault(method => + method.PaymentId == paymentMethodId); + vm.Items.Add(new ViewEthereumStoreOptionItemViewModel() + { + CryptoCode = network.CryptoCode, + Enabled = matchedPaymentMethod != null && !excludeFilters.Match(paymentMethodId), + IsToken = network is ERC20BTCPayNetwork, + RootAddress = matchedPaymentMethod?.GetWalletDerivator()?.Invoke(0) ?? "not configured" + }); + } + + return View(vm); + } + + [HttpGet("{cryptoCode}")] + public IActionResult GetStoreEthereumLikePaymentMethod(string cryptoCode) + { + var eth = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType(); + + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null) + { + return NotFound(); + } + + var excludeFilters = StoreData.GetStoreBlob().GetExcludedPaymentMethods(); + var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance); + var matchedPaymentMethod = eth.SingleOrDefault(method => + method.PaymentId == paymentMethodId); + + return View(new EditEthereumPaymentMethodViewModel() + { + Enabled = !excludeFilters.Match(paymentMethodId), + XPub = matchedPaymentMethod?.XPub, + Index = matchedPaymentMethod?.CurrentIndex ?? 0, + Passphrase = matchedPaymentMethod?.Password, + Seed = matchedPaymentMethod?.Seed, + StoreSeed = !string.IsNullOrEmpty(matchedPaymentMethod?.Seed), + OriginalIndex = matchedPaymentMethod?.CurrentIndex ?? 0, + KeyPath = string.IsNullOrEmpty(matchedPaymentMethod?.KeyPath) + ? network.GetDefaultKeyPath() + : matchedPaymentMethod?.KeyPath + }); + } + + [HttpPost("{cryptoCode}")] + public async Task GetStoreEthereumLikePaymentMethod(string cryptoCode, + EditEthereumPaymentMethodViewModel viewModel) + { + var network = _btcPayNetworkProvider.GetNetwork(cryptoCode); + if (network is null) + { + return NotFound(); + } + + var store = StoreData; + var blob = StoreData.GetStoreBlob(); + var paymentMethodId = new PaymentMethodId(network.CryptoCode, EthereumPaymentType.Instance); + + var currentPaymentMethod = StoreData.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType().SingleOrDefault(method => + method.PaymentId == paymentMethodId); + + if (currentPaymentMethod != null && currentPaymentMethod.CurrentIndex != viewModel.Index && + viewModel.OriginalIndex == viewModel.Index) + { + viewModel.Index = currentPaymentMethod.CurrentIndex; + viewModel.OriginalIndex = currentPaymentMethod.CurrentIndex; + } + else if (currentPaymentMethod != null && currentPaymentMethod.CurrentIndex != viewModel.Index && + viewModel.OriginalIndex != currentPaymentMethod.CurrentIndex) + { + ModelState.AddModelError(nameof(viewModel.Index), + $"You tried to update the index (to {viewModel.Index}) but new derivations in the background updated the index (to {currentPaymentMethod.CurrentIndex}) "); + viewModel.Index = currentPaymentMethod.CurrentIndex; + viewModel.OriginalIndex = currentPaymentMethod.CurrentIndex; + } + + Wallet wallet = null; + try + { + if (!string.IsNullOrEmpty(viewModel.Seed)) + { + wallet = new Wallet(viewModel.Seed, viewModel.Passphrase, + string.IsNullOrEmpty(viewModel.KeyPath) ? network.GetDefaultKeyPath() : viewModel.KeyPath); + } + } + catch (Exception) + { + ModelState.AddModelError(nameof(viewModel.Seed), $"seed was incorrect"); + } + + if (wallet != null) + { + try + { + wallet.GetAccount(0); + } + catch (Exception) + { + ModelState.AddModelError(nameof(viewModel.KeyPath), $"keypath was incorrect"); + } + } + + PublicWallet publicWallet = null; + try + { + if (!string.IsNullOrEmpty(viewModel.XPub)) + { + try + { + publicWallet = new PublicWallet(viewModel.XPub); + } + catch (Exception) + { + publicWallet = new PublicWallet(new BitcoinExtPubKey(viewModel.XPub, Network.Main).ExtPubKey); + } + + if (wallet != null && !publicWallet.ExtPubKey.Equals(wallet.GetMasterPublicWallet().ExtPubKey)) + { + ModelState.AddModelError(nameof(viewModel.XPub), + $"The xpub does not match the seed/pass/key path provided"); + } + } + } + catch (Exception) + { + ModelState.AddModelError(nameof(viewModel.XPub), $"xpub was incorrect"); + } + + if (!string.IsNullOrEmpty(viewModel.AddressCheck)) + { + int index = -1; + if (wallet != null) + { + index = wallet.GetAddresses(1000) + .IndexOf(viewModel.AddressCheck); + } + else if (publicWallet != null) + { + index = publicWallet.GetAddresses(1000) + .IndexOf(viewModel.AddressCheck); + } + + if (viewModel.AddressCheckLastUsed && index > -1) + { + viewModel.Index = index; + } + + if (index == -1) + { + ModelState.AddModelError(nameof(viewModel.AddressCheck), + "Could not confirm address belongs to configured wallet"); + } + } + + if (!ModelState.IsValid) + { + return View(viewModel); + } + + currentPaymentMethod ??= new EthereumSupportedPaymentMethod(); + currentPaymentMethod.Password = viewModel.StoreSeed ? viewModel.Passphrase : ""; + currentPaymentMethod.Seed = viewModel.StoreSeed ? viewModel.Seed : ""; + currentPaymentMethod.XPub = string.IsNullOrEmpty(viewModel.XPub) && wallet != null + ? wallet.GetMasterPublicWallet().ExtPubKey.ToBytes().ToHex() + : viewModel.XPub; + currentPaymentMethod.CryptoCode = cryptoCode; + currentPaymentMethod.KeyPath = string.IsNullOrEmpty(viewModel.KeyPath) + ? network.GetDefaultKeyPath() + : viewModel.KeyPath; + currentPaymentMethod.CurrentIndex = viewModel.Index; + + blob.SetExcluded(paymentMethodId, !viewModel.Enabled); + store.SetSupportedPaymentMethod(currentPaymentMethod); + store.SetStoreBlob(blob); + await _storeRepository.UpdateStore(store); + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = $"updated {cryptoCode}", Severity = StatusMessageModel.StatusSeverity.Success + }); + + return RedirectToAction("GetStoreEthereumLikePaymentMethods", new {storeId = store.Id}); + } + } + + public class EditEthereumPaymentMethodViewModel + { + public string XPub { get; set; } + public string Seed { get; set; } + public string Passphrase { get; set; } + + public string KeyPath { get; set; } + public long OriginalIndex { get; set; } + + [Display(Name = "Current address index")] + + public long Index { get; set; } + + public bool Enabled { get; set; } + + [Display(Name = "Hot wallet")] public bool StoreSeed { get; set; } + + [Display(Name ="Address Check")] + public string AddressCheck { get; set; } + + public bool AddressCheckLastUsed { get; set; } + } + + public class ViewEthereumStoreOptionsViewModel + { + public List Items { get; set; } = + new List(); + } + + public class ViewEthereumStoreOptionItemViewModel + { + public string CryptoCode { get; set; } + public bool IsToken { get; set; } + public bool Enabled { get; set; } + public string RootAddress { get; set; } + } +} +#endif diff --git a/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumPaymentViewModel.cs b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumPaymentViewModel.cs new file mode 100644 index 000000000..3a3f3f1be --- /dev/null +++ b/BTCPayServer/Services/Altcoins/Ethereum/UI/EthereumPaymentViewModel.cs @@ -0,0 +1,19 @@ +#if ALTCOINS +using System; + +namespace BTCPayServer.Services.Altcoins.Ethereum.UI +{ + public class EthereumPaymentViewModel + { + public string Crypto { get; set; } + public string Confirmations { get; set; } + public string DepositAddress { get; set; } + public string Amount { get; set; } + public DateTimeOffset ReceivedTime { get; set; } + public long? BlockNumber { get; set; } + public string BalanceLink { get; set; } + public bool Replaced { get; set; } + public long Index { get; set; } + } +} +#endif diff --git a/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethod.cshtml b/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethod.cshtml new file mode 100644 index 000000000..e6742ff3b --- /dev/null +++ b/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethod.cshtml @@ -0,0 +1,100 @@ + +@using BTCPayServer.Views.Stores +@model BTCPayServer.Services.Altcoins.Ethereum.UI.EditEthereumPaymentMethodViewModel + +@{ + Layout = "../Shared/_NavLayout.cshtml"; + + ViewData["NavPartialName"] = "../Stores/_Nav"; + ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, $"{this.Context.GetRouteValue("cryptoCode")} Settings"); +} + + + + +
+
+
+
+
+
+
+
DO NOT USE THE WALLET TO ACCEPT PAYMENTS OUTSIDE OF THIS STORE.
If you spend funds received on invoices which have not been marked complete yet, the invoice will be marked as unpaid. +
+
+ + +
+ + + +
+
+ + + +
+ +
+ + + Please see this article. + +
+ +
+ + + Store the seed/password on server if provided. If not checked, will generate the xpub and erase the seed/pass from server + +
+ +
+ + + The public master key derived from a seed/pass/keypath. This allows you to generate addresses without private keys on the server. + +
+ +
+ + + The index to generate the next address from. If you are using a wallet that you have used before, be sure to set this to the last index +1 + +
+
+ +
+ +
+ +
+
+ Check wallet by providing an address it can generate within the first 1000 indexes + +
+
+ + + +
+
+ + + + Back to list + +
+
+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethods.cshtml b/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethods.cshtml new file mode 100644 index 000000000..84b92c891 --- /dev/null +++ b/BTCPayServer/Views/EthereumLikeStore/GetStoreEthereumLikePaymentMethods.cshtml @@ -0,0 +1,76 @@ + +@using BTCPayServer.Views.Stores +@model BTCPayServer.Services.Altcoins.Ethereum.UI.ViewEthereumStoreOptionsViewModel +@inject SignInManager SignInManager; +@inject BTCPayNetworkProvider BTCPayNetworkProvider; +@{ + Layout = "../Shared/_NavLayout.cshtml"; + + ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, "Ethereum Settings"); + + ViewData["NavPartialName"] = "../Stores/_Nav"; +} + + + + +
+
+
+
+
+
+
+
+ + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + + } + +
CryptoRoot addressEnabledActions
@item.CryptoCode@item.RootAddress + @if (item.Enabled) + { + + } + else + { + + } + + + Modify + + +
+
+
+
+@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin)) +{ + var chains = BTCPayNetworkProvider.GetAll().OfType().Select(network => network.ChainId).Distinct(); + foreach (var chain in chains) + { + + Configure Web3 for chain @chain + } +} +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/Server/_Nav.cshtml b/BTCPayServer/Views/Server/_Nav.cshtml index 843b184ba..f148f3157 100644 --- a/BTCPayServer/Views/Server/_Nav.cshtml +++ b/BTCPayServer/Views/Server/_Nav.cshtml @@ -1,10 +1,10 @@ - -
-
+
+
diff --git a/BTCPayServer/Views/Shared/Ethereum/ETHSyncSummary.cshtml b/BTCPayServer/Views/Shared/Ethereum/ETHSyncSummary.cshtml new file mode 100644 index 000000000..07f1d5d34 --- /dev/null +++ b/BTCPayServer/Views/Shared/Ethereum/ETHSyncSummary.cshtml @@ -0,0 +1,27 @@ +@using BTCPayServer.Services.Altcoins.Ethereum.Services +@inject BTCPayNetworkProvider BTCPayNetworkProvider +@inject EthereumService EthereumService; + +@inject SignInManager SignInManager; +@{ + var networks = BTCPayNetworkProvider.GetAll().OfType().OrderBy(network => network.ChainId).Where(network => network.ShowSyncSummary); +} + +@foreach (var network in networks) +{ +

@network.CryptoCode (Chain ID: @network.ChainId) @(network is ERC20BTCPayNetwork is true ? "(ERC20)" : "")

+
    + @if (!EthereumService.IsAvailable(network.CryptoCode, out var error)) + { +
  • + @(error??"Web3 has not yet been configured") + + + @if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin)) + { + Configure Web3 + } +
  • + } +
+} diff --git a/BTCPayServer/Views/Shared/Ethereum/EthereumLikeMethodCheckout.cshtml b/BTCPayServer/Views/Shared/Ethereum/EthereumLikeMethodCheckout.cshtml new file mode 100644 index 000000000..3623e52c7 --- /dev/null +++ b/BTCPayServer/Views/Shared/Ethereum/EthereumLikeMethodCheckout.cshtml @@ -0,0 +1,329 @@ +@using BTCPayServer.Services +@model BTCPayServer.Models.InvoicingModels.PaymentModel +@inject BTCPayNetworkProvider BTCPayNetworkProvider +@{ + var chains = BTCPayNetworkProvider.GetAll().OfType().ToDictionary(network => network.CryptoCode.ToLowerInvariant(), network => new + { + ChainId = network.ChainId, + SmartContractAddress = (network as ERC20BTCPayNetwork)?.SmartContractAddress, + Divisibility = network.Divisibility + }); +} + + + + + diff --git a/BTCPayServer/Views/Shared/Ethereum/StoreNavEthereumExtension.cshtml b/BTCPayServer/Views/Shared/Ethereum/StoreNavEthereumExtension.cshtml new file mode 100644 index 000000000..93e9e7473 --- /dev/null +++ b/BTCPayServer/Views/Shared/Ethereum/StoreNavEthereumExtension.cshtml @@ -0,0 +1,11 @@ +@using BTCPayServer.Services.Altcoins.Ethereum.UI +@inject SignInManager SignInManager; +@inject BTCPayNetworkProvider BTCPayNetworkProvider; +@{ + var controller = ViewContext.RouteData.Values["Controller"].ToString(); + var isEthereum = controller.Equals(nameof(EthereumLikeStoreController), StringComparison.InvariantCultureIgnoreCase); +} +@if (SignInManager.IsSignedIn(User) && BTCPayNetworkProvider.GetAll().OfType().Any()) +{ + Ethereum +} diff --git a/BTCPayServer/Views/Shared/Ethereum/UpdateChainConfig.cshtml b/BTCPayServer/Views/Shared/Ethereum/UpdateChainConfig.cshtml new file mode 100644 index 000000000..fdc0efd0c --- /dev/null +++ b/BTCPayServer/Views/Shared/Ethereum/UpdateChainConfig.cshtml @@ -0,0 +1,65 @@ +@using BTCPayServer.Services.Altcoins.Ethereum.UI +@using BTCPayServer.Views.Server +@using System.Net.Http +@model BTCPayServer.Services.Altcoins.Ethereum.Configuration.EthereumLikeConfiguration +@inject BTCPayNetworkProvider BTCPayNetworkProvider; +@inject IHttpClientFactory HttpClientFactory; +@{ + Layout = "../_NavLayout.cshtml"; + ViewData["NavPartialName"] = "../Server/_Nav"; + ViewBag.MainTitle = "Server settings"; + ViewData.SetActivePageAndTitle(ServerNavPages.Policies, $"ETH Chain {Model.ChainId} Configuration"); +} + + +@if (!this.ViewContext.ModelState.IsValid) +{ +
+} + +
+
+ + +
+ + + +
+ Possible free options are +
    +
  • linkpool.io - Free, just set the url to https://main-rpc.linkpool.io
  • +
  • chainstack.com - Free plan, choose shared public node
  • +
  • infura.io - Free tier but limited calls per day
  • +
  • Your own geth/openethereum node
  • +
+
+
+
+ + + +
+
+ + + +
+ @{ + var valid = await EthereumConfigController.CheckValid(HttpClientFactory, BTCPayNetworkProvider.NetworkType, Model.InvoiceId); + if (!valid) + { +
+ Support for this feature requires a one-time donation. + @if (!string.IsNullOrEmpty(Model.InvoiceId)) + { + The payment instructions associated has not been paid or confirmed yet. + } + Please click here to generate payment instructions. +
+ } + } +
+ + +
diff --git a/BTCPayServer/Views/Shared/Ethereum/ViewEthereumLikePaymentData.cshtml b/BTCPayServer/Views/Shared/Ethereum/ViewEthereumLikePaymentData.cshtml new file mode 100644 index 000000000..81044dff5 --- /dev/null +++ b/BTCPayServer/Views/Shared/Ethereum/ViewEthereumLikePaymentData.cshtml @@ -0,0 +1,64 @@ +@using System.Globalization +@using BTCPayServer.Services.Altcoins.Ethereum.Payments +@using BTCPayServer.Services.Altcoins.Ethereum.UI +@model IEnumerable + +@{ + var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == EthereumPaymentType.Instance).Select(payment => + { + var m = new EthereumPaymentViewModel(); + var onChainPaymentData = payment.GetCryptoPaymentData() as EthereumLikePaymentData; + m.Crypto = payment.GetPaymentMethodId().CryptoCode; + m.DepositAddress = onChainPaymentData.GetDestination(); + + m.Amount = onChainPaymentData.GetValue().ToString(CultureInfo.InvariantCulture); + m.Confirmations = onChainPaymentData.BlockNumber.HasValue ? $"{onChainPaymentData.ConfirmationCount} (block {onChainPaymentData.BlockNumber})" : "pending"; + m.Amount = onChainPaymentData.GetValue().ToString(CultureInfo.InvariantCulture); + m.BlockNumber = onChainPaymentData.BlockNumber; + m.ReceivedTime = payment.ReceivedTime; + m.BalanceLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.DepositAddress); + m.Replaced = !payment.Accounted; + m.Index = onChainPaymentData.AccountIndex; + return m; + }); +} + +@if (onchainPayments.Any()) +{ +
+
+

Ethereum/ERC-20 payments

+ + + + + + + + + + + + @foreach (var payment in onchainPayments) + { + + + + + + + + + } + +
CryptoAmountAddressIndexConfirmations
@payment.Crypto@payment.Amount + + @payment.Index@payment.Confirmations
+
+
+} diff --git a/BTCPayServer/wwwroot/imlegacy/eth.png b/BTCPayServer/wwwroot/imlegacy/eth.png new file mode 100644 index 000000000..b3b8fce4f Binary files /dev/null and b/BTCPayServer/wwwroot/imlegacy/eth.png differ