commit c24ef96fd48d7ec54c5fdc1f4599222ebc9f493b Author: nicolas.dorier Date: Wed Jan 8 18:27:19 2025 +0900 Init commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6db436f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +**/bin/**/* +**/obj +.idea +Plugins/packed +.vs/ +monero_wallet/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3dd49fd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "btcpayserver"] + path = btcpayserver + url = https://github.com/btcpayserver/btcpayserver diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..9fecbd2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017-2025 btcpayserver + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Plugins/Monero/BTCPayServer.Plugins.Monero.csproj b/Plugins/Monero/BTCPayServer.Plugins.Monero.csproj new file mode 100644 index 0000000..743d10f --- /dev/null +++ b/Plugins/Monero/BTCPayServer.Plugins.Monero.csproj @@ -0,0 +1,56 @@ + + + net8.0 + + + + + BTCPay Server Plugin Template + A template for your own BTCPay Server plugin. + 1.0.0 + + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + + + + + + diff --git a/Plugins/Monero/Configuration/MoneroLikeConfiguration.cs b/Plugins/Monero/Configuration/MoneroLikeConfiguration.cs new file mode 100644 index 0000000..ff4b9a7 --- /dev/null +++ b/Plugins/Monero/Configuration/MoneroLikeConfiguration.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace BTCPayServer.Plugins.Monero.Configuration +{ + public class MoneroLikeConfiguration + { + public Dictionary MoneroLikeConfigurationItems { get; set; } = + new Dictionary(); + } + + public class MoneroLikeConfigurationItem + { + public Uri DaemonRpcUri { get; set; } + public Uri InternalWalletRpcUri { get; set; } + public string WalletDirectory { get; set; } + public string Username { get; set; } + public string Password { get; set; } + } +} diff --git a/Plugins/Monero/Controllers/MoneroDaemonCallbackController.cs b/Plugins/Monero/Controllers/MoneroDaemonCallbackController.cs new file mode 100644 index 0000000..667e0bb --- /dev/null +++ b/Plugins/Monero/Controllers/MoneroDaemonCallbackController.cs @@ -0,0 +1,38 @@ +using BTCPayServer.Filters; +using BTCPayServer.Plugins.Monero.RPC; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Plugins.Monero.Controllers +{ + [Route("[controller]")] + public class MoneroLikeDaemonCallbackController : Controller + { + private readonly EventAggregator _eventAggregator; + + public MoneroLikeDaemonCallbackController(EventAggregator eventAggregator) + { + _eventAggregator = eventAggregator; + } + [HttpGet("block")] + public IActionResult OnBlockNotify(string hash, string cryptoCode) + { + _eventAggregator.Publish(new MoneroEvent() + { + BlockHash = hash, + CryptoCode = cryptoCode.ToUpperInvariant() + }); + return Ok(); + } + [HttpGet("tx")] + public IActionResult OnTransactionNotify(string hash, string cryptoCode) + { + _eventAggregator.Publish(new MoneroEvent() + { + TransactionHash = hash, + CryptoCode = cryptoCode.ToUpperInvariant() + }); + return Ok(); + } + + } +} diff --git a/Plugins/Monero/Controllers/MoneroLikeStoreController.cs b/Plugins/Monero/Controllers/MoneroLikeStoreController.cs new file mode 100644 index 0000000..97df722 --- /dev/null +++ b/Plugins/Monero/Controllers/MoneroLikeStoreController.cs @@ -0,0 +1,392 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Filters; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.Monero.Configuration; +using BTCPayServer.Plugins.Monero.Payments; +using BTCPayServer.Plugins.Monero.RPC.Models; +using BTCPayServer.Plugins.Monero.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; + +namespace BTCPayServer.Plugins.Monero.Controllers +{ + [Route("stores/{storeId}/monerolike")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public class UIMoneroLikeStoreController : Controller + { + private readonly MoneroLikeConfiguration _MoneroLikeConfiguration; + private readonly StoreRepository _StoreRepository; + private readonly MoneroRPCProvider _MoneroRpcProvider; + private readonly PaymentMethodHandlerDictionary _handlers; + private IStringLocalizer StringLocalizer { get; } + + public UIMoneroLikeStoreController(MoneroLikeConfiguration moneroLikeConfiguration, + StoreRepository storeRepository, MoneroRPCProvider moneroRpcProvider, + PaymentMethodHandlerDictionary handlers, + IStringLocalizer stringLocalizer) + { + _MoneroLikeConfiguration = moneroLikeConfiguration; + _StoreRepository = storeRepository; + _MoneroRpcProvider = moneroRpcProvider; + _handlers = handlers; + StringLocalizer = stringLocalizer; + } + + public StoreData StoreData => HttpContext.GetStoreData(); + + [HttpGet()] + public async Task GetStoreMoneroLikePaymentMethods() + { + return View("/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml", await GetVM(StoreData)); + } +[NonAction] + public async Task GetVM(StoreData storeData) + { + var excludeFilters = storeData.GetStoreBlob().GetExcludedPaymentMethods(); + + var accountsList = _MoneroLikeConfiguration.MoneroLikeConfigurationItems.ToDictionary(pair => pair.Key, + pair => GetAccounts(pair.Key)); + + await Task.WhenAll(accountsList.Values); + return new MoneroLikePaymentMethodListViewModel() + { + Items = _MoneroLikeConfiguration.MoneroLikeConfigurationItems.Select(pair => + GetMoneroLikePaymentMethodViewModel(storeData, pair.Key, excludeFilters, + accountsList[pair.Key].Result)) + }; + } + + private Task GetAccounts(string cryptoCode) + { + try + { + if (_MoneroRpcProvider.Summaries.TryGetValue(cryptoCode, out var summary) && summary.WalletAvailable) + { + + return _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync("get_accounts", new GetAccountsRequest()); + } + } + catch { } + return Task.FromResult(null); + } + + private MoneroLikePaymentMethodViewModel GetMoneroLikePaymentMethodViewModel( + StoreData storeData, string cryptoCode, + IPaymentFilter excludeFilters, GetAccountsResponse accountsResponse) + { + var monero = storeData.GetPaymentMethodConfigs(_handlers) + .Where(s => s.Value is MoneroPaymentPromptDetails) + .Select(s => (PaymentMethodId: s.Key, Details: (MoneroPaymentPromptDetails)s.Value)); + var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode); + var settings = monero.Where(method => method.PaymentMethodId == pmi).Select(m => m.Details).SingleOrDefault(); + _MoneroRpcProvider.Summaries.TryGetValue(cryptoCode, out var summary); + _MoneroLikeConfiguration.MoneroLikeConfigurationItems.TryGetValue(cryptoCode, + out var configurationItem); + var fileAddress = Path.Combine(configurationItem.WalletDirectory, "wallet"); + var accounts = accountsResponse?.SubaddressAccounts?.Select(account => + new SelectListItem( + $"{account.AccountIndex} - {(string.IsNullOrEmpty(account.Label) ? "No label" : account.Label)}", + account.AccountIndex.ToString(CultureInfo.InvariantCulture))); + + var settlementThresholdChoice = MoneroLikeSettlementThresholdChoice.StoreSpeedPolicy; + if (settings != null && settings.InvoiceSettledConfirmationThreshold is { } confirmations) + { + settlementThresholdChoice = confirmations switch + { + 0 => MoneroLikeSettlementThresholdChoice.ZeroConfirmation, + 1 => MoneroLikeSettlementThresholdChoice.AtLeastOne, + 10 => MoneroLikeSettlementThresholdChoice.AtLeastTen, + _ => MoneroLikeSettlementThresholdChoice.Custom + }; + } + + return new MoneroLikePaymentMethodViewModel() + { + WalletFileFound = System.IO.File.Exists(fileAddress), + Enabled = + settings != null && + !excludeFilters.Match(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode)), + Summary = summary, + CryptoCode = cryptoCode, + AccountIndex = settings?.AccountIndex ?? accountsResponse?.SubaddressAccounts?.FirstOrDefault()?.AccountIndex ?? 0, + Accounts = accounts == null ? null : new SelectList(accounts, nameof(SelectListItem.Value), + nameof(SelectListItem.Text)), + SettlementConfirmationThresholdChoice = settlementThresholdChoice, + CustomSettlementConfirmationThreshold = + settings != null && + settlementThresholdChoice is MoneroLikeSettlementThresholdChoice.Custom + ? settings.InvoiceSettledConfirmationThreshold + : null + }; + } + + [HttpGet("{cryptoCode}")] + public async Task GetStoreMoneroLikePaymentMethod(string cryptoCode) + { + cryptoCode = cryptoCode.ToUpperInvariant(); + if (!_MoneroLikeConfiguration.MoneroLikeConfigurationItems.ContainsKey(cryptoCode)) + { + return NotFound(); + } + + var vm = GetMoneroLikePaymentMethodViewModel(StoreData, cryptoCode, + StoreData.GetStoreBlob().GetExcludedPaymentMethods(), await GetAccounts(cryptoCode)); + return View("/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml", vm); + } + + [HttpPost("{cryptoCode}")] + [DisableRequestSizeLimit] + public async Task GetStoreMoneroLikePaymentMethod(MoneroLikePaymentMethodViewModel viewModel, string command, string cryptoCode) + { + cryptoCode = cryptoCode.ToUpperInvariant(); + if (!_MoneroLikeConfiguration.MoneroLikeConfigurationItems.TryGetValue(cryptoCode, + out var configurationItem)) + { + return NotFound(); + } + + if (command == "add-account") + { + try + { + var newAccount = await _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync("create_account", new CreateAccountRequest() + { + Label = viewModel.NewAccountLabel + }); + viewModel.AccountIndex = newAccount.AccountIndex; + } + catch (Exception) + { + ModelState.AddModelError(nameof(viewModel.AccountIndex), StringLocalizer["Could not create a new account."]); + } + + } + else if (command == "upload-wallet") + { + var valid = true; + if (viewModel.WalletFile == null) + { + ModelState.AddModelError(nameof(viewModel.WalletFile), StringLocalizer["Please select the view-only wallet file"]); + valid = false; + } + if (viewModel.WalletKeysFile == null) + { + ModelState.AddModelError(nameof(viewModel.WalletKeysFile), StringLocalizer["Please select the view-only wallet keys file"]); + valid = false; + } + + if (valid) + { + if (_MoneroRpcProvider.Summaries.TryGetValue(cryptoCode, out var summary)) + { + if (summary.WalletAvailable) + { + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = StringLocalizer["There is already an active wallet configured for {0}. Replacing it would break any existing invoices!", cryptoCode].Value + }); + return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod), + new { cryptoCode }); + } + } + + var fileAddress = Path.Combine(configurationItem.WalletDirectory, "wallet"); + using (var fileStream = new FileStream(fileAddress, FileMode.Create)) + { + await viewModel.WalletFile.CopyToAsync(fileStream); + try + { + Exec($"chmod 666 {fileAddress}"); + } + catch + { + } + } + + fileAddress = Path.Combine(configurationItem.WalletDirectory, "wallet.keys"); + using (var fileStream = new FileStream(fileAddress, FileMode.Create)) + { + await viewModel.WalletKeysFile.CopyToAsync(fileStream); + try + { + Exec($"chmod 666 {fileAddress}"); + } + catch + { + } + + } + + fileAddress = Path.Combine(configurationItem.WalletDirectory, "password"); + using (var fileStream = new StreamWriter(fileAddress, false)) + { + await fileStream.WriteAsync(viewModel.WalletPassword); + try + { + Exec($"chmod 666 {fileAddress}"); + } + catch + { + } + } + + try + { + var response = await _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync("open_wallet", new OpenWalletRequest + { + Filename = "wallet", + Password = viewModel.WalletPassword + }); + if (response?.Error != null) + { + throw new Exception(response.Error.Message); + } + } + catch (Exception ex) + { + ModelState.AddModelError(nameof(viewModel.AccountIndex), StringLocalizer["Could not open the wallet: {0}", ex.Message]); + return View("/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml", viewModel); + } + + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Info, + Message = StringLocalizer["View-only wallet files uploaded. The wallet will soon become available."].Value + }); + return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod), new { cryptoCode }); + } + } + + if (!ModelState.IsValid) + { + + var vm = GetMoneroLikePaymentMethodViewModel(StoreData, cryptoCode, + StoreData.GetStoreBlob().GetExcludedPaymentMethods(), await GetAccounts(cryptoCode)); + + vm.Enabled = viewModel.Enabled; + vm.NewAccountLabel = viewModel.NewAccountLabel; + vm.AccountIndex = viewModel.AccountIndex; + vm.SettlementConfirmationThresholdChoice = viewModel.SettlementConfirmationThresholdChoice; + vm.CustomSettlementConfirmationThreshold = viewModel.CustomSettlementConfirmationThreshold; + return View("/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml", vm); + } + + var storeData = StoreData; + var blob = storeData.GetStoreBlob(); + storeData.SetPaymentMethodConfig(_handlers[PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode)], new MoneroPaymentPromptDetails() + { + AccountIndex = viewModel.AccountIndex, + InvoiceSettledConfirmationThreshold = viewModel.SettlementConfirmationThresholdChoice switch + { + MoneroLikeSettlementThresholdChoice.ZeroConfirmation => 0, + MoneroLikeSettlementThresholdChoice.AtLeastOne => 1, + MoneroLikeSettlementThresholdChoice.AtLeastTen => 10, + MoneroLikeSettlementThresholdChoice.Custom when viewModel.CustomSettlementConfirmationThreshold is { } custom => custom, + _ => null + } + }); + + blob.SetExcluded(PaymentTypes.CHAIN.GetPaymentMethodId(viewModel.CryptoCode), !viewModel.Enabled); + storeData.SetStoreBlob(blob); + await _StoreRepository.UpdateStore(storeData); + return RedirectToAction("GetStoreMoneroLikePaymentMethods", + new { StatusMessage = $"{cryptoCode} settings updated successfully", storeId = StoreData.Id }); + } + + private void Exec(string cmd) + { + + var escapedArgs = cmd.Replace("\"", "\\\"", StringComparison.InvariantCulture); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + FileName = "/bin/sh", + Arguments = $"-c \"{escapedArgs}\"" + } + }; + +#pragma warning disable CA1416 // Validate platform compatibility + process.Start(); +#pragma warning restore CA1416 // Validate platform compatibility + process.WaitForExit(); + } + + public class MoneroLikePaymentMethodListViewModel + { + public IEnumerable Items { get; set; } + } + + public class MoneroLikePaymentMethodViewModel : IValidatableObject + { + public MoneroRPCProvider.MoneroLikeSummary Summary { get; set; } + public string CryptoCode { get; set; } + public string NewAccountLabel { get; set; } + public long AccountIndex { get; set; } + public bool Enabled { get; set; } + + public IEnumerable Accounts { get; set; } + public bool WalletFileFound { get; set; } + [Display(Name = "View-Only Wallet File")] + public IFormFile WalletFile { get; set; } + [Display(Name = "Wallet Keys File")] + public IFormFile WalletKeysFile { get; set; } + [Display(Name = "Wallet Password")] + public string WalletPassword { get; set; } + [Display(Name = "Consider the invoice settled when the payment transaction …")] + public MoneroLikeSettlementThresholdChoice SettlementConfirmationThresholdChoice { get; set; } + [Display(Name = "Required Confirmations"), Range(0, 100)] + public long? CustomSettlementConfirmationThreshold { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (SettlementConfirmationThresholdChoice is MoneroLikeSettlementThresholdChoice.Custom + && CustomSettlementConfirmationThreshold is null) + { + yield return new ValidationResult( + "You must specify the number of required confirmations when using a custom threshold.", + new[] { nameof(CustomSettlementConfirmationThreshold) }); + } + } + } + + public enum MoneroLikeSettlementThresholdChoice + { + [Display(Name = "Store Speed Policy", Description = "Use the store's speed policy")] + StoreSpeedPolicy, + [Display(Name = "Zero Confirmation", Description = "Is unconfirmed")] + ZeroConfirmation, + [Display(Name = "At Least One", Description = "Has at least 1 confirmation")] + AtLeastOne, + [Display(Name = "At Least Ten", Description = "Has at least 10 confirmations")] + AtLeastTen, + [Display(Name = "Custom", Description = "Custom")] + Custom + } + } +} diff --git a/Plugins/Monero/MoneroLikeSpecificBtcPayNetwork.cs b/Plugins/Monero/MoneroLikeSpecificBtcPayNetwork.cs new file mode 100644 index 0000000..9f4fd45 --- /dev/null +++ b/Plugins/Monero/MoneroLikeSpecificBtcPayNetwork.cs @@ -0,0 +1,8 @@ +namespace BTCPayServer.Plugins.Altcoins; + +public class MoneroLikeSpecificBtcPayNetwork : BTCPayNetworkBase +{ + public int MaxTrackedConfirmation = 10; + public string UriScheme { get; set; } +} + diff --git a/Plugins/Monero/MoneroPlugin.cs b/Plugins/Monero/MoneroPlugin.cs new file mode 100644 index 0000000..0acdf25 --- /dev/null +++ b/Plugins/Monero/MoneroPlugin.cs @@ -0,0 +1,163 @@ +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Services; +using System.Net.Http; +using System.Net; +using BTCPayServer.Hosting; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using NBitcoin; +using BTCPayServer.Configuration; +using System.Linq; +using System; +using System.Globalization; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Plugins.Altcoins; +using BTCPayServer.Plugins.Monero.Configuration; +using BTCPayServer.Plugins.Monero.Payments; +using BTCPayServer.Plugins.Monero.Services; +using BTCPayServer.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NBXplorer; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Plugins.Monero; + +public class MoneroPlugin : BaseBTCPayServerPlugin +{ + public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = + { + new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.0.5" } + }; + public ChainName ChainName { get; private set; } + public NBXplorerNetworkProvider NBXplorerNetworkProvider { get; private set; } + public override void Execute(IServiceCollection services) + { + var network = new MoneroLikeSpecificBtcPayNetwork() + { + CryptoCode = "XMR", + DisplayName = "Monero", + Divisibility = 12, + DefaultRateRules = new[] + { + "XMR_X = XMR_BTC * BTC_X", + "XMR_BTC = kraken(XMR_BTC)" + }, + CryptoImagePath = "/imlegacy/monero.svg", + UriScheme = "monero" + }; + var blockExplorerLink = ChainName == ChainName.Mainnet + ? "https://www.exploremonero.com/transaction/{0}" + : "https://testnet.xmrchain.net/tx/{0}"; + var pmi = PaymentTypes.CHAIN.GetPaymentMethodId("XMR"); + services.AddDefaultPrettyName(pmi, network.DisplayName); + services.AddBTCPayNetwork(network) + .AddTransactionLinkProvider(pmi, new SimpleTransactionLinkProvider(blockExplorerLink)); + + + services.AddSingleton(provider => + ConfigureMoneroLikeConfiguration(provider)); + services.AddHttpClient("XMRclient") + .ConfigurePrimaryHttpMessageHandler(provider => + { + var configuration = provider.GetRequiredService(); + if (!configuration.MoneroLikeConfigurationItems.TryGetValue("XMR", out var xmrConfig) || xmrConfig.Username is null || xmrConfig.Password is null) + { + return new HttpClientHandler(); + } + return new HttpClientHandler + { + Credentials = new NetworkCredential(xmrConfig.Username, xmrConfig.Password), + PreAuthenticate = true + }; + }); + services.AddSingleton(); + services.AddHostedService(); + services.AddHostedService(); + services.AddSingleton(provider => + (IPaymentMethodHandler)ActivatorUtilities.CreateInstance(provider, typeof(MoneroLikePaymentMethodHandler), new object[] { network })); + services.AddSingleton(provider => +(IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(MoneroPaymentLinkExtension), new object[] { network, pmi })); + services.AddSingleton(provider => +(ICheckoutModelExtension)ActivatorUtilities.CreateInstance(provider, typeof(MoneroCheckoutModelExtension), new object[] { network, pmi })); + + services.AddUIExtension("store-nav", "/Views/Monero/StoreNavMoneroExtension.cshtml"); + services.AddUIExtension("store-wallets-nav", "/Views/Monero/StoreWalletsNavMoneroExtension.cshtml"); + services.AddUIExtension("store-invoices-payments", "/Views/Monero/ViewMoneroLikePaymentData.cshtml"); + services.AddSingleton(); + } + class SimpleTransactionLinkProvider : DefaultTransactionLinkProvider + { + public SimpleTransactionLinkProvider(string blockExplorerLink) : base(blockExplorerLink) + { + } + + public override string GetTransactionLink(string paymentId) + { + if (string.IsNullOrEmpty(BlockExplorerLink)) + return null; + return string.Format(CultureInfo.InvariantCulture, BlockExplorerLink, paymentId); + } + } + + private static MoneroLikeConfiguration ConfigureMoneroLikeConfiguration(IServiceProvider serviceProvider) + { + var configuration = serviceProvider.GetService(); + var btcPayNetworkProvider = serviceProvider.GetService(); + var result = new MoneroLikeConfiguration(); + + var supportedNetworks = btcPayNetworkProvider.GetAll() + .OfType(); + + foreach (var moneroLikeSpecificBtcPayNetwork in supportedNetworks) + { + var daemonUri = + configuration.GetOrDefault($"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_uri", + null); + var walletDaemonUri = + configuration.GetOrDefault( + $"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_wallet_daemon_uri", null); + var walletDaemonWalletDirectory = + configuration.GetOrDefault( + $"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_wallet_daemon_walletdir", null); + var daemonUsername = + configuration.GetOrDefault( + $"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_username", null); + var daemonPassword = + configuration.GetOrDefault( + $"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_password", null); + if (daemonUri == null || walletDaemonUri == null || walletDaemonWalletDirectory == null) + { + var logger = serviceProvider.GetRequiredService>(); + var cryptoCode = moneroLikeSpecificBtcPayNetwork.CryptoCode.ToUpperInvariant(); + if (daemonUri is null) + { + logger.LogWarning($"BTCPAY_{cryptoCode}_DAEMON_URI is not configured"); + } + if (walletDaemonUri is null) + { + logger.LogWarning($"BTCPAY_{cryptoCode}_WALLET_DAEMON_URI is not configured"); + } + if (walletDaemonWalletDirectory is null) + { + logger.LogWarning($"BTCPAY_{cryptoCode}_WALLET_DAEMON_WALLETDIR is not configured"); + } + logger.LogWarning($"{cryptoCode} got disabled as it is not fully configured."); + } + else + { + result.MoneroLikeConfigurationItems.Add(moneroLikeSpecificBtcPayNetwork.CryptoCode, new MoneroLikeConfigurationItem() + { + DaemonRpcUri = daemonUri, + Username = daemonUsername, + Password = daemonPassword, + InternalWalletRpcUri = walletDaemonUri, + WalletDirectory = walletDaemonWalletDirectory + }); + } + } + return result; + } +} diff --git a/Plugins/Monero/Payments/MoneroCheckoutModelExtension.cs b/Plugins/Monero/Payments/MoneroCheckoutModelExtension.cs new file mode 100644 index 0000000..0ab35d2 --- /dev/null +++ b/Plugins/Monero/Payments/MoneroCheckoutModelExtension.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Bitcoin; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Plugins.Monero.Services; +using BTCPayServer.Services.Invoices; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroCheckoutModelExtension : ICheckoutModelExtension + { + private readonly BTCPayNetworkBase _network; + private readonly PaymentMethodHandlerDictionary _handlers; + private readonly IPaymentLinkExtension paymentLinkExtension; + + public MoneroCheckoutModelExtension( + PaymentMethodId paymentMethodId, + IEnumerable paymentLinkExtensions, + BTCPayNetworkBase network, + PaymentMethodHandlerDictionary handlers) + { + PaymentMethodId = paymentMethodId; + _network = network; + _handlers = handlers; + paymentLinkExtension = paymentLinkExtensions.Single(p => p.PaymentMethodId == PaymentMethodId); + } + public PaymentMethodId PaymentMethodId { get; } + + public string Image => _network.CryptoImagePath; + public string Badge => ""; + + public void ModifyCheckoutModel(CheckoutModelContext context) + { + if (context is not { Handler: MoneroLikePaymentMethodHandler handler }) + return; + context.Model.CheckoutBodyComponentName = BitcoinCheckoutModelExtension.CheckoutBodyComponentName; + var details = context.InvoiceEntity.GetPayments(true) + .Select(p => p.GetDetails(handler)) + .Where(p => p is not null) + .FirstOrDefault(); + if (details is not null) + { + context.Model.ReceivedConfirmations = details.ConfirmationCount; + context.Model.RequiredConfirmations = (int)MoneroListener.ConfirmationsRequired(details, context.InvoiceEntity.SpeedPolicy); + } + + context.Model.InvoiceBitcoinUrl = paymentLinkExtension.GetPaymentLink(context.Prompt, context.UrlHelper); + context.Model.InvoiceBitcoinUrlQR = context.Model.InvoiceBitcoinUrl; + } + } +} diff --git a/Plugins/Monero/Payments/MoneroLikeOnChainPaymentMethodDetails.cs b/Plugins/Monero/Payments/MoneroLikeOnChainPaymentMethodDetails.cs new file mode 100644 index 0000000..69f53f0 --- /dev/null +++ b/Plugins/Monero/Payments/MoneroLikeOnChainPaymentMethodDetails.cs @@ -0,0 +1,11 @@ +using BTCPayServer.Payments; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroLikeOnChainPaymentMethodDetails + { + public long AccountIndex { get; set; } + public long AddressIndex { get; set; } + public long? InvoiceSettledConfirmationThreshold { get; set; } + } +} diff --git a/Plugins/Monero/Payments/MoneroLikePaymentData.cs b/Plugins/Monero/Payments/MoneroLikePaymentData.cs new file mode 100644 index 0000000..5838552 --- /dev/null +++ b/Plugins/Monero/Payments/MoneroLikePaymentData.cs @@ -0,0 +1,20 @@ +using BTCPayServer.Client.Models; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.Altcoins; +using BTCPayServer.Plugins.Monero.Utils; +using BTCPayServer.Services.Invoices; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroLikePaymentData + { + public long SubaddressIndex { get; set; } + public long SubaccountIndex { get; set; } + public long BlockHeight { get; set; } + public long ConfirmationCount { get; set; } + public string TransactionId { get; set; } + public long? InvoiceSettledConfirmationThreshold { get; set; } + + public long LockTime { get; set; } = 0; + } +} diff --git a/Plugins/Monero/Payments/MoneroLikePaymentMethodHandler.cs b/Plugins/Monero/Payments/MoneroLikePaymentMethodHandler.cs new file mode 100644 index 0000000..eaaee0d --- /dev/null +++ b/Plugins/Monero/Payments/MoneroLikePaymentMethodHandler.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using AngleSharp.Dom; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.BIP78.Sender; +using BTCPayServer.Data; +using BTCPayServer.Logging; +using BTCPayServer.Models; +using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.Altcoins; +using BTCPayServer.Rating; +using BTCPayServer.Plugins.Monero.Services; +using BTCPayServer.Plugins.Monero.Utils; +using BTCPayServer.Plugins.Monero.RPC.Models; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using NBitcoin; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroLikePaymentMethodHandler : IPaymentMethodHandler + { + private readonly MoneroLikeSpecificBtcPayNetwork _network; + public MoneroLikeSpecificBtcPayNetwork Network => _network; + public JsonSerializer Serializer { get; } + private readonly MoneroRPCProvider _moneroRpcProvider; + + public PaymentMethodId PaymentMethodId { get; } + + public MoneroLikePaymentMethodHandler(MoneroLikeSpecificBtcPayNetwork network, MoneroRPCProvider moneroRpcProvider) + { + PaymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); + _network = network; + Serializer = BlobSerializer.CreateSerializer().Serializer; + _moneroRpcProvider = moneroRpcProvider; + } + + public Task BeforeFetchingRates(PaymentMethodContext context) + { + context.Prompt.Currency = _network.CryptoCode; + context.Prompt.Divisibility = _network.Divisibility; + if (context.Prompt.Activated) + { + var supportedPaymentMethod = ParsePaymentMethodConfig(context.PaymentMethodConfig); + var walletClient = _moneroRpcProvider.WalletRpcClients[_network.CryptoCode]; + var daemonClient = _moneroRpcProvider.DaemonRpcClients[_network.CryptoCode]; + context.State = new Prepare() + { + GetFeeRate = daemonClient.SendCommandAsync("get_fee_estimate", new GetFeeEstimateRequest()), + ReserveAddress = s => walletClient.SendCommandAsync("create_address", new CreateAddressRequest() { Label = $"btcpay invoice #{s}", AccountIndex = supportedPaymentMethod.AccountIndex }), + AccountIndex = supportedPaymentMethod.AccountIndex + }; + } + return Task.CompletedTask; + } + + public async Task ConfigurePrompt(PaymentMethodContext context) + { + if (!_moneroRpcProvider.IsAvailable(_network.CryptoCode)) + throw new PaymentMethodUnavailableException($"Node or wallet not available"); + var invoice = context.InvoiceEntity; + Prepare moneroPrepare = (Prepare)context.State; + var feeRatePerKb = await moneroPrepare.GetFeeRate; + var address = await moneroPrepare.ReserveAddress(invoice.Id); + + var feeRatePerByte = feeRatePerKb.Fee / 1024; + var details = new MoneroLikeOnChainPaymentMethodDetails() + { + AccountIndex = moneroPrepare.AccountIndex, + AddressIndex = address.AddressIndex, + InvoiceSettledConfirmationThreshold = ParsePaymentMethodConfig(context.PaymentMethodConfig).InvoiceSettledConfirmationThreshold + }; + context.Prompt.Destination = address.Address; + context.Prompt.PaymentMethodFee = MoneroMoney.Convert(feeRatePerByte * 100); + context.Prompt.Details = JObject.FromObject(details, Serializer); + context.TrackedDestinations.Add(address.Address); + } + private MoneroPaymentPromptDetails ParsePaymentMethodConfig(JToken config) + { + return config.ToObject(Serializer) ?? throw new FormatException($"Invalid {nameof(MoneroLikePaymentMethodHandler)}"); + } + object IPaymentMethodHandler.ParsePaymentMethodConfig(JToken config) + { + return ParsePaymentMethodConfig(config); + } + + class Prepare + { + public Task GetFeeRate; + public Func> ReserveAddress; + + public long AccountIndex { get; internal set; } + } + + public MoneroLikeOnChainPaymentMethodDetails ParsePaymentPromptDetails(Newtonsoft.Json.Linq.JToken details) + { + return details.ToObject(Serializer); + } + object IPaymentMethodHandler.ParsePaymentPromptDetails(Newtonsoft.Json.Linq.JToken details) + { + return ParsePaymentPromptDetails(details); + } + + public MoneroLikePaymentData ParsePaymentDetails(JToken details) + { + return details.ToObject(Serializer) ?? throw new FormatException($"Invalid {nameof(MoneroLikePaymentMethodHandler)}"); + } + object IPaymentMethodHandler.ParsePaymentDetails(JToken details) + { + return ParsePaymentDetails(details); + } + } +} diff --git a/Plugins/Monero/Payments/MoneroPaymentLinkExtension.cs b/Plugins/Monero/Payments/MoneroPaymentLinkExtension.cs new file mode 100644 index 0000000..13b50a7 --- /dev/null +++ b/Plugins/Monero/Payments/MoneroPaymentLinkExtension.cs @@ -0,0 +1,27 @@ +#nullable enable +using System.Globalization; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.Altcoins; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroPaymentLinkExtension : IPaymentLinkExtension + { + private readonly MoneroLikeSpecificBtcPayNetwork _network; + + public MoneroPaymentLinkExtension(PaymentMethodId paymentMethodId, MoneroLikeSpecificBtcPayNetwork network) + { + PaymentMethodId = paymentMethodId; + _network = network; + } + public PaymentMethodId PaymentMethodId { get; } + + public string? GetPaymentLink(PaymentPrompt prompt, IUrlHelper? urlHelper) + { + var due = prompt.Calculate().Due; + return $"{_network.UriScheme}:{prompt.Destination}?tx_amount={due.ToString(CultureInfo.InvariantCulture)}"; + } + } +} diff --git a/Plugins/Monero/Payments/MoneroPaymentPromptDetails.cs b/Plugins/Monero/Payments/MoneroPaymentPromptDetails.cs new file mode 100644 index 0000000..18d125b --- /dev/null +++ b/Plugins/Monero/Payments/MoneroPaymentPromptDetails.cs @@ -0,0 +1,11 @@ +using BTCPayServer.Payments; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.Payments +{ + public class MoneroPaymentPromptDetails + { + public long AccountIndex { get; set; } + public long? InvoiceSettledConfirmationThreshold { get; set; } + } +} diff --git a/Plugins/Monero/RPC/JsonRpcClient.cs b/Plugins/Monero/RPC/JsonRpcClient.cs new file mode 100644 index 0000000..53807c8 --- /dev/null +++ b/Plugins/Monero/RPC/JsonRpcClient.cs @@ -0,0 +1,121 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace BTCPayServer.Plugins.Monero.RPC +{ + public class JsonRpcClient + { + private readonly Uri _address; + private readonly string _username; + private readonly string _password; + private readonly HttpClient _httpClient; + + public JsonRpcClient(Uri address, string username, string password, HttpClient client = null) + { + _address = address; + _username = username; + _password = password; + _httpClient = client ?? new HttpClient(); + } + + + public async Task SendCommandAsync(string method, TRequest data, + CancellationToken cts = default(CancellationToken)) + { + var jsonSerializer = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + var httpRequest = new HttpRequestMessage() + { + Method = HttpMethod.Post, + RequestUri = new Uri(_address, "json_rpc"), + Content = new StringContent( + JsonConvert.SerializeObject(new JsonRpcCommand(method, data), jsonSerializer), + Encoding.UTF8, "application/json") + }; + httpRequest.Headers.Accept.Clear(); + httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.Default.GetBytes($"{_username}:{_password}"))); + + HttpResponseMessage rawResult = await _httpClient.SendAsync(httpRequest, cts); + rawResult.EnsureSuccessStatusCode(); + var rawJson = await rawResult.Content.ReadAsStringAsync(); + + JsonRpcResult response; + try + { + response = JsonConvert.DeserializeObject>(rawJson, jsonSerializer); + } + catch (Exception e) + { + Console.WriteLine(e.Message); + Console.WriteLine(rawJson); + throw; + } + + if (response.Error != null) + { + throw new JsonRpcApiException() + { + Error = response.Error + }; + } + + return response.Result; + } + + public class NoRequestModel + { + public static NoRequestModel Instance = new NoRequestModel(); + } + + internal class JsonRpcApiException : Exception + { + public JsonRpcResultError Error { get; set; } + + public override string Message => Error?.Message; + } + + public class JsonRpcResultError + { + [JsonProperty("code")] public int Code { get; set; } + [JsonProperty("message")] public string Message { get; set; } + [JsonProperty("data")] dynamic Data { get; set; } + } + internal class JsonRpcResult + { + + + [JsonProperty("result")] public T Result { get; set; } + [JsonProperty("error")] public JsonRpcResultError Error { get; set; } + [JsonProperty("id")] public string Id { get; set; } + } + + internal class JsonRpcCommand + { + [JsonProperty("jsonRpc")] public string JsonRpc { get; set; } = "2.0"; + [JsonProperty("id")] public string Id { get; set; } = Guid.NewGuid().ToString(); + [JsonProperty("method")] public string Method { get; set; } + + [JsonProperty("params")] public T Parameters { get; set; } + + public JsonRpcCommand() + { + } + + public JsonRpcCommand(string method, T parameters) + { + Method = method; + Parameters = parameters; + } + } + } +} diff --git a/Plugins/Monero/RPC/Models/CreateAccountRequest.cs b/Plugins/Monero/RPC/Models/CreateAccountRequest.cs new file mode 100644 index 0000000..a5cdb91 --- /dev/null +++ b/Plugins/Monero/RPC/Models/CreateAccountRequest.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class CreateAccountRequest + { + [JsonProperty("label")] public string Label { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/CreateAccountResponse.cs b/Plugins/Monero/RPC/Models/CreateAccountResponse.cs new file mode 100644 index 0000000..382d7a7 --- /dev/null +++ b/Plugins/Monero/RPC/Models/CreateAccountResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class CreateAccountResponse + { + [JsonProperty("account_index")] public long AccountIndex { get; set; } + [JsonProperty("address")] public string Address { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/CreateAddressRequest.cs b/Plugins/Monero/RPC/Models/CreateAddressRequest.cs new file mode 100644 index 0000000..f0b64a7 --- /dev/null +++ b/Plugins/Monero/RPC/Models/CreateAddressRequest.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class CreateAddressRequest + { + [JsonProperty("account_index")] public long AccountIndex { get; set; } + [JsonProperty("label")] public string Label { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/CreateAddressResponse.cs b/Plugins/Monero/RPC/Models/CreateAddressResponse.cs new file mode 100644 index 0000000..b37ca14 --- /dev/null +++ b/Plugins/Monero/RPC/Models/CreateAddressResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class CreateAddressResponse + { + [JsonProperty("address")] public string Address { get; set; } + [JsonProperty("address_index")] public long AddressIndex { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/CreateWalletRequest.cs b/Plugins/Monero/RPC/Models/CreateWalletRequest.cs new file mode 100644 index 0000000..9d91c3c --- /dev/null +++ b/Plugins/Monero/RPC/Models/CreateWalletRequest.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class CreateWalletRequest + { + [JsonProperty("filename")] public string Filename { get; set; } + [JsonProperty("password")] public string Password { get; set; } + [JsonProperty("language")] public string Language { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetAccountsRequest.cs b/Plugins/Monero/RPC/Models/GetAccountsRequest.cs new file mode 100644 index 0000000..09c0470 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetAccountsRequest.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetAccountsRequest + { + [JsonProperty("tag")] public string Tag { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetAccountsResponse.cs b/Plugins/Monero/RPC/Models/GetAccountsResponse.cs new file mode 100644 index 0000000..5292d7c --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetAccountsResponse.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetAccountsResponse + { + [JsonProperty("subaddress_accounts")] public List SubaddressAccounts { get; set; } + [JsonProperty("total_balance")] public decimal TotalBalance { get; set; } + + [JsonProperty("total_unlocked_balance")] + public decimal TotalUnlockedBalance { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetFeeEstimateRequest.cs b/Plugins/Monero/RPC/Models/GetFeeEstimateRequest.cs new file mode 100644 index 0000000..1fe05ff --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetFeeEstimateRequest.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public class GetFeeEstimateRequest + { + [JsonProperty("grace_blocks")] public int? GraceBlocks { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetFeeEstimateResponse.cs b/Plugins/Monero/RPC/Models/GetFeeEstimateResponse.cs new file mode 100644 index 0000000..d4d5e48 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetFeeEstimateResponse.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public class GetFeeEstimateResponse + { + [JsonProperty("fee")] public long Fee { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("untrusted")] public bool Untrusted { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetHeightResponse.cs b/Plugins/Monero/RPC/Models/GetHeightResponse.cs new file mode 100644 index 0000000..42f0f12 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetHeightResponse.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetHeightResponse + { + [JsonProperty("height")] public long Height { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetInfoResponse.cs b/Plugins/Monero/RPC/Models/GetInfoResponse.cs new file mode 100644 index 0000000..ce38ab3 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetInfoResponse.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetInfoResponse + { + [JsonProperty("height")] public long Height { get; set; } + [JsonProperty("busy_syncing")] public bool BusySyncing { get; set; } + [JsonProperty("status")] public string Status { get; set; } + [JsonProperty("target_height")] public long? TargetHeight { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetTransferByTransactionIdRequest.cs b/Plugins/Monero/RPC/Models/GetTransferByTransactionIdRequest.cs new file mode 100644 index 0000000..23bb39b --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetTransferByTransactionIdRequest.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public class GetTransferByTransactionIdRequest + { + [JsonProperty("txid")] public string TransactionId { get; set; } + + [JsonProperty("account_index")] public long AccountIndex { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetTransferByTransactionIdResponse.cs b/Plugins/Monero/RPC/Models/GetTransferByTransactionIdResponse.cs new file mode 100644 index 0000000..e0f7389 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetTransferByTransactionIdResponse.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetTransferByTransactionIdResponse + { + [JsonProperty("transfer")] public TransferItem Transfer { get; set; } + [JsonProperty("transfers")] public IEnumerable Transfers { get; set; } + + public partial class TransferItem + { + [JsonProperty("address")] public string Address { get; set; } + [JsonProperty("amount")] public long Amount { get; set; } + [JsonProperty("confirmations")] public long Confirmations { get; set; } + [JsonProperty("double_spend_seen")] public bool DoubleSpendSeen { get; set; } + [JsonProperty("height")] public long Height { get; set; } + [JsonProperty("note")] public string Note { get; set; } + [JsonProperty("payment_id")] public string PaymentId { get; set; } + [JsonProperty("subaddr_index")] public SubaddrIndex SubaddrIndex { get; set; } + + [JsonProperty("suggested_confirmations_threshold")] + public long SuggestedConfirmationsThreshold { get; set; } + + [JsonProperty("timestamp")] public long Timestamp { get; set; } + [JsonProperty("txid")] public string Txid { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("unlock_time")] public long UnlockTime { get; set; } + } + } +} diff --git a/Plugins/Monero/RPC/Models/GetTransfersRequest.cs b/Plugins/Monero/RPC/Models/GetTransfersRequest.cs new file mode 100644 index 0000000..5fdd123 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetTransfersRequest.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetTransfersRequest + { + [JsonProperty("in")] public bool In { get; set; } + [JsonProperty("out")] public bool Out { get; set; } + [JsonProperty("pending")] public bool Pending { get; set; } + [JsonProperty("failed")] public bool Failed { get; set; } + [JsonProperty("pool")] public bool Pool { get; set; } + [JsonProperty("filter_by_height ")] public bool FilterByHeight { get; set; } + [JsonProperty("min_height")] public long MinHeight { get; set; } + [JsonProperty("max_height")] public long MaxHeight { get; set; } + [JsonProperty("account_index")] public long AccountIndex { get; set; } + [JsonProperty("subaddr_indices")] public List SubaddrIndices { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/GetTransfersResponse.cs b/Plugins/Monero/RPC/Models/GetTransfersResponse.cs new file mode 100644 index 0000000..3d88ac2 --- /dev/null +++ b/Plugins/Monero/RPC/Models/GetTransfersResponse.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class GetTransfersResponse + { + [JsonProperty("in")] public List In { get; set; } + [JsonProperty("out")] public List Out { get; set; } + [JsonProperty("pending")] public List Pending { get; set; } + [JsonProperty("failed")] public List Failed { get; set; } + [JsonProperty("pool")] public List Pool { get; set; } + + public partial class GetTransfersResponseItem + + { + [JsonProperty("address")] public string Address { get; set; } + [JsonProperty("amount")] public long Amount { get; set; } + [JsonProperty("confirmations")] public long Confirmations { get; set; } + [JsonProperty("double_spend_seen")] public bool DoubleSpendSeen { get; set; } + [JsonProperty("height")] public long Height { get; set; } + [JsonProperty("note")] public string Note { get; set; } + [JsonProperty("payment_id")] public string PaymentId { get; set; } + [JsonProperty("subaddr_index")] public SubaddrIndex SubaddrIndex { get; set; } + + [JsonProperty("suggested_confirmations_threshold")] + public long SuggestedConfirmationsThreshold { get; set; } + + [JsonProperty("timestamp")] public long Timestamp { get; set; } + [JsonProperty("txid")] public string Txid { get; set; } + [JsonProperty("type")] public string Type { get; set; } + [JsonProperty("unlock_time")] public long UnlockTime { get; set; } + } + } +} diff --git a/Plugins/Monero/RPC/Models/Info.cs b/Plugins/Monero/RPC/Models/Info.cs new file mode 100644 index 0000000..a47b8ad --- /dev/null +++ b/Plugins/Monero/RPC/Models/Info.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class Info + { + [JsonProperty("address")] public string Address { get; set; } + [JsonProperty("avg_download")] public long AvgDownload { get; set; } + [JsonProperty("avg_upload")] public long AvgUpload { get; set; } + [JsonProperty("connection_id")] public string ConnectionId { get; set; } + [JsonProperty("current_download")] public long CurrentDownload { get; set; } + [JsonProperty("current_upload")] public long CurrentUpload { get; set; } + [JsonProperty("height")] public long Height { get; set; } + [JsonProperty("host")] public string Host { get; set; } + [JsonProperty("incoming")] public bool Incoming { get; set; } + [JsonProperty("ip")] public string Ip { get; set; } + [JsonProperty("live_time")] public long LiveTime { get; set; } + [JsonProperty("local_ip")] public bool LocalIp { get; set; } + [JsonProperty("localhost")] public bool Localhost { get; set; } + [JsonProperty("peer_id")] public string PeerId { get; set; } + + [JsonProperty("port")] + [JsonConverter(typeof(ParseStringConverter))] + public long Port { get; set; } + + [JsonProperty("recv_count")] public long RecvCount { get; set; } + [JsonProperty("recv_idle_time")] public long RecvIdleTime { get; set; } + [JsonProperty("send_count")] public long SendCount { get; set; } + [JsonProperty("send_idle_time")] public long SendIdleTime { get; set; } + [JsonProperty("state")] public string State { get; set; } + [JsonProperty("support_flags")] public long SupportFlags { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/MakeUriRequest.cs b/Plugins/Monero/RPC/Models/MakeUriRequest.cs new file mode 100644 index 0000000..65dc19c --- /dev/null +++ b/Plugins/Monero/RPC/Models/MakeUriRequest.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class MakeUriRequest + { + [JsonProperty("address")] public string Address { get; set; } + [JsonProperty("amount")] public long Amount { get; set; } + [JsonProperty("payment_id")] public string PaymentId { get; set; } + [JsonProperty("tx_description")] public string TxDescription { get; set; } + [JsonProperty("recipient_name")] public string RecipientName { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/MakeUriResponse.cs b/Plugins/Monero/RPC/Models/MakeUriResponse.cs new file mode 100644 index 0000000..6f17130 --- /dev/null +++ b/Plugins/Monero/RPC/Models/MakeUriResponse.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class MakeUriResponse + { + [JsonProperty("uri")] public string Uri { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/OpenWallerErrorResponse.cs b/Plugins/Monero/RPC/Models/OpenWallerErrorResponse.cs new file mode 100644 index 0000000..1f04c3f --- /dev/null +++ b/Plugins/Monero/RPC/Models/OpenWallerErrorResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class OpenWalletErrorResponse + { + [JsonProperty("code")] public int Code { get; set; } + [JsonProperty("message")] public string Message { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/OpenWalletRequest.cs b/Plugins/Monero/RPC/Models/OpenWalletRequest.cs new file mode 100644 index 0000000..be8a8e3 --- /dev/null +++ b/Plugins/Monero/RPC/Models/OpenWalletRequest.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class OpenWalletRequest + { + [JsonProperty("filename")] public string Filename { get; set; } + [JsonProperty("password")] public string Password { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/OpenWalletResponse.cs b/Plugins/Monero/RPC/Models/OpenWalletResponse.cs new file mode 100644 index 0000000..9ecde7e --- /dev/null +++ b/Plugins/Monero/RPC/Models/OpenWalletResponse.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class OpenWalletResponse + { + [JsonProperty("id")] public string Id { get; set; } + [JsonProperty("jsonrpc")] public string Jsonrpc { get; set; } + [JsonProperty("result")] public object Result { get; set; } + [JsonProperty("error")] public OpenWalletErrorResponse Error { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/ParseStringConverter.cs b/Plugins/Monero/RPC/Models/ParseStringConverter.cs new file mode 100644 index 0000000..6c67c04 --- /dev/null +++ b/Plugins/Monero/RPC/Models/ParseStringConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + internal class ParseStringConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(long) || t == typeof(long?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + var value = serializer.Deserialize(reader); + long l; + if (Int64.TryParse(value, out l)) + { + return l; + } + + throw new Exception("Cannot unmarshal type long"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + + var value = (long)untypedValue; + serializer.Serialize(writer, value.ToString(CultureInfo.InvariantCulture)); + return; + } + + public static readonly ParseStringConverter Singleton = new ParseStringConverter(); + } +} diff --git a/Plugins/Monero/RPC/Models/Peer.cs b/Plugins/Monero/RPC/Models/Peer.cs new file mode 100644 index 0000000..e72716b --- /dev/null +++ b/Plugins/Monero/RPC/Models/Peer.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class Peer + { + [JsonProperty("info")] public Info Info { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/SubaddrIndex.cs b/Plugins/Monero/RPC/Models/SubaddrIndex.cs new file mode 100644 index 0000000..210dd0f --- /dev/null +++ b/Plugins/Monero/RPC/Models/SubaddrIndex.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class SubaddrIndex + { + [JsonProperty("major")] public long Major { get; set; } + [JsonProperty("minor")] public long Minor { get; set; } + } +} diff --git a/Plugins/Monero/RPC/Models/SubaddressAccount.cs b/Plugins/Monero/RPC/Models/SubaddressAccount.cs new file mode 100644 index 0000000..17dd5d4 --- /dev/null +++ b/Plugins/Monero/RPC/Models/SubaddressAccount.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.Monero.RPC.Models +{ + public partial class SubaddressAccount + { + [JsonProperty("account_index")] public long AccountIndex { get; set; } + [JsonProperty("balance")] public decimal Balance { get; set; } + [JsonProperty("base_address")] public string BaseAddress { get; set; } + [JsonProperty("label")] public string Label { get; set; } + [JsonProperty("tag")] public string Tag { get; set; } + [JsonProperty("unlocked_balance")] public decimal UnlockedBalance { get; set; } + } +} diff --git a/Plugins/Monero/RPC/MoneroEvent.cs b/Plugins/Monero/RPC/MoneroEvent.cs new file mode 100644 index 0000000..9c5a906 --- /dev/null +++ b/Plugins/Monero/RPC/MoneroEvent.cs @@ -0,0 +1,15 @@ +namespace BTCPayServer.Plugins.Monero.RPC +{ + public class MoneroEvent + { + public string BlockHash { get; set; } + public string TransactionHash { get; set; } + public string CryptoCode { get; set; } + + public override string ToString() + { + return + $"{CryptoCode}: {(string.IsNullOrEmpty(TransactionHash) ? string.Empty : "Tx Update")}{(string.IsNullOrEmpty(BlockHash) ? string.Empty : "New Block")} ({TransactionHash ?? string.Empty}{BlockHash ?? string.Empty})"; + } + } +} diff --git a/Plugins/Monero/Services/MoneroLikeSummaryUpdaterHostedService.cs b/Plugins/Monero/Services/MoneroLikeSummaryUpdaterHostedService.cs new file mode 100644 index 0000000..d0bfff5 --- /dev/null +++ b/Plugins/Monero/Services/MoneroLikeSummaryUpdaterHostedService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Logging; +using BTCPayServer.Plugins.Monero.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Plugins.Monero.Services +{ + public class MoneroLikeSummaryUpdaterHostedService : IHostedService + { + private readonly MoneroRPCProvider _MoneroRpcProvider; + private readonly MoneroLikeConfiguration _moneroLikeConfiguration; + + public Logs Logs { get; } + + private CancellationTokenSource _Cts; + public MoneroLikeSummaryUpdaterHostedService(MoneroRPCProvider moneroRpcProvider, MoneroLikeConfiguration moneroLikeConfiguration, Logs logs) + { + _MoneroRpcProvider = moneroRpcProvider; + _moneroLikeConfiguration = moneroLikeConfiguration; + Logs = logs; + } + public Task StartAsync(CancellationToken cancellationToken) + { + _Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + foreach (var moneroLikeConfigurationItem in _moneroLikeConfiguration.MoneroLikeConfigurationItems) + { + _ = StartLoop(_Cts.Token, moneroLikeConfigurationItem.Key); + } + return Task.CompletedTask; + } + + private async Task StartLoop(CancellationToken cancellation, string cryptoCode) + { + Logs.PayServer.LogInformation($"Starting listening Monero-like daemons ({cryptoCode})"); + try + { + while (!cancellation.IsCancellationRequested) + { + try + { + await _MoneroRpcProvider.UpdateSummary(cryptoCode); + if (_MoneroRpcProvider.IsAvailable(cryptoCode)) + { + await Task.Delay(TimeSpan.FromMinutes(1), cancellation); + } + else + { + await Task.Delay(TimeSpan.FromSeconds(10), cancellation); + } + } + catch (Exception ex) when (!cancellation.IsCancellationRequested) + { + Logs.PayServer.LogError(ex, $"Unhandled exception in Summary updater ({cryptoCode})"); + await Task.Delay(TimeSpan.FromSeconds(10), cancellation); + } + } + } + catch when (cancellation.IsCancellationRequested) { } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _Cts?.Cancel(); + return Task.CompletedTask; + } + } +} diff --git a/Plugins/Monero/Services/MoneroListener.cs b/Plugins/Monero/Services/MoneroListener.cs new file mode 100644 index 0000000..aede9ac --- /dev/null +++ b/Plugins/Monero/Services/MoneroListener.cs @@ -0,0 +1,361 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.Plugins.Altcoins; +using BTCPayServer.Plugins.Monero.Configuration; +using BTCPayServer.Plugins.Monero.Payments; +using BTCPayServer.Plugins.Monero.RPC.Models; +using BTCPayServer.Plugins.Monero.Utils; +using BTCPayServer.Services; +using BTCPayServer.Plugins.Monero.RPC; +using BTCPayServer.Services.Invoices; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBXplorer; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Plugins.Monero.Services +{ + public class MoneroListener : EventHostedServiceBase + { + private readonly InvoiceRepository _invoiceRepository; + private readonly EventAggregator _eventAggregator; + private readonly MoneroRPCProvider _moneroRpcProvider; + private readonly MoneroLikeConfiguration _MoneroLikeConfiguration; + private readonly BTCPayNetworkProvider _networkProvider; + private readonly ILogger _logger; + private readonly PaymentMethodHandlerDictionary _handlers; + private readonly InvoiceActivator _invoiceActivator; + private readonly PaymentService _paymentService; + + public MoneroListener(InvoiceRepository invoiceRepository, + EventAggregator eventAggregator, + MoneroRPCProvider moneroRpcProvider, + MoneroLikeConfiguration moneroLikeConfiguration, + BTCPayNetworkProvider networkProvider, + ILogger logger, + PaymentMethodHandlerDictionary handlers, + InvoiceActivator invoiceActivator, + PaymentService paymentService) : base(eventAggregator, logger) + { + _invoiceRepository = invoiceRepository; + _eventAggregator = eventAggregator; + _moneroRpcProvider = moneroRpcProvider; + _MoneroLikeConfiguration = moneroLikeConfiguration; + _networkProvider = networkProvider; + _logger = logger; + _handlers = handlers; + _invoiceActivator = invoiceActivator; + _paymentService = paymentService; + } + + protected override void SubscribeToEvents() + { + base.SubscribeToEvents(); + Subscribe(); + Subscribe(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is MoneroRPCProvider.MoneroDaemonStateChange stateChange) + { + if (_moneroRpcProvider.IsAvailable(stateChange.CryptoCode)) + { + _logger.LogInformation($"{stateChange.CryptoCode} just became available"); + _ = UpdateAnyPendingMoneroLikePayment(stateChange.CryptoCode); + } + else + { + _logger.LogInformation($"{stateChange.CryptoCode} just became unavailable"); + } + } + else if (evt is MoneroEvent moneroEvent) + { + if (!_moneroRpcProvider.IsAvailable(moneroEvent.CryptoCode)) + return; + + if (!string.IsNullOrEmpty(moneroEvent.BlockHash)) + { + await OnNewBlock(moneroEvent.CryptoCode); + } + if (!string.IsNullOrEmpty(moneroEvent.TransactionHash)) + { + await OnTransactionUpdated(moneroEvent.CryptoCode, moneroEvent.TransactionHash); + } + } + } + + private async Task ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment) + { + _logger.LogInformation( + $"Invoice {invoice.Id} received payment {payment.Value} {payment.Currency} {payment.Id}"); + + var prompt = invoice.GetPaymentPrompt(payment.PaymentMethodId); + + if (prompt != null && + prompt.Activated && + prompt.Destination == payment.Destination && + prompt.Calculate().Due > 0.0m) + { + await _invoiceActivator.ActivateInvoicePaymentMethod(invoice.Id, payment.PaymentMethodId, true); + invoice = await _invoiceRepository.GetInvoice(invoice.Id); + } + + _eventAggregator.Publish( + new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment }); + } + + private async Task UpdatePaymentStates(string cryptoCode, InvoiceEntity[] invoices) + { + if (!invoices.Any()) + { + return; + } + + var moneroWalletRpcClient = _moneroRpcProvider.WalletRpcClients[cryptoCode]; + var network = _networkProvider.GetNetwork(cryptoCode); + var paymentId = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); + var handler = (MoneroLikePaymentMethodHandler)_handlers[paymentId]; + + //get all the required data in one list (invoice, its existing payments and the current payment method details) + var expandedInvoices = invoices.Select(entity => (Invoice: entity, + ExistingPayments: GetAllMoneroLikePayments(entity, cryptoCode), + Prompt: entity.GetPaymentPrompt(paymentId), + PaymentMethodDetails: handler.ParsePaymentPromptDetails(entity.GetPaymentPrompt(paymentId).Details))) + .Select(tuple => ( + tuple.Invoice, + tuple.PaymentMethodDetails, + tuple.Prompt, + ExistingPayments: tuple.ExistingPayments.Select(entity => + (Payment: entity, PaymentData: handler.ParsePaymentDetails(entity.Details), + tuple.Invoice)) + )); + + var existingPaymentData = expandedInvoices.SelectMany(tuple => tuple.ExistingPayments); + + var accountToAddressQuery = new Dictionary>(); + //create list of subaddresses to account to query the monero wallet + foreach (var expandedInvoice in expandedInvoices) + { + var addressIndexList = + accountToAddressQuery.GetValueOrDefault(expandedInvoice.PaymentMethodDetails.AccountIndex, + new List()); + + addressIndexList.AddRange( + expandedInvoice.ExistingPayments.Select(tuple => tuple.PaymentData.SubaddressIndex)); + addressIndexList.Add(expandedInvoice.PaymentMethodDetails.AddressIndex); + accountToAddressQuery.AddOrReplace(expandedInvoice.PaymentMethodDetails.AccountIndex, addressIndexList); + } + + var tasks = accountToAddressQuery.ToDictionary(datas => datas.Key, + datas => moneroWalletRpcClient.SendCommandAsync( + "get_transfers", + new GetTransfersRequest() + { + AccountIndex = datas.Key, + In = true, + SubaddrIndices = datas.Value.Distinct().ToList() + })); + + await Task.WhenAll(tasks.Values); + + + var transferProcessingTasks = new List(); + + var updatedPaymentEntities = new List<(PaymentEntity Payment, InvoiceEntity invoice)>(); + foreach (var keyValuePair in tasks) + { + var transfers = keyValuePair.Value.Result.In; + if (transfers == null) + { + continue; + } + + transferProcessingTasks.AddRange(transfers.Select(transfer => + { + InvoiceEntity invoice = null; + var existingMatch = existingPaymentData.SingleOrDefault(tuple => + tuple.Payment.Destination == transfer.Address && + tuple.PaymentData.TransactionId == transfer.Txid); + + if (existingMatch.Invoice != null) + { + invoice = existingMatch.Invoice; + } + else + { + var newMatch = expandedInvoices.SingleOrDefault(tuple => + tuple.Prompt.Destination == transfer.Address); + + if (newMatch.Invoice == null) + { + return Task.CompletedTask; + } + + invoice = newMatch.Invoice; + } + + + return HandlePaymentData(cryptoCode, transfer.Address, transfer.Amount, transfer.SubaddrIndex.Major, + transfer.SubaddrIndex.Minor, transfer.Txid, transfer.Confirmations, transfer.Height, transfer.UnlockTime,invoice, + updatedPaymentEntities); + })); + } + + transferProcessingTasks.Add( + _paymentService.UpdatePayments(updatedPaymentEntities.Select(tuple => tuple.Item1).ToList())); + await Task.WhenAll(transferProcessingTasks); + foreach (var valueTuples in updatedPaymentEntities.GroupBy(entity => entity.Item2)) + { + if (valueTuples.Any()) + { + _eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(valueTuples.Key.Id)); + } + } + } + + private async Task OnNewBlock(string cryptoCode) + { + await UpdateAnyPendingMoneroLikePayment(cryptoCode); + _eventAggregator.Publish(new NewBlockEvent() { PaymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode) }); + } + + private async Task OnTransactionUpdated(string cryptoCode, string transactionHash) + { + var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode); + var transfer = await _moneroRpcProvider.WalletRpcClients[cryptoCode] + .SendCommandAsync( + "get_transfer_by_txid", + new GetTransferByTransactionIdRequest() { TransactionId = transactionHash }); + + var paymentsToUpdate = new List<(PaymentEntity Payment, InvoiceEntity invoice)>(); + + //group all destinations of the tx together and loop through the sets + foreach (var destination in transfer.Transfers.GroupBy(destination => destination.Address)) + { + //find the invoice corresponding to this address, else skip + var invoice = await _invoiceRepository.GetInvoiceFromAddress(paymentMethodId, destination.Key); + if (invoice == null) + continue; + + var index = destination.First().SubaddrIndex; + + await HandlePaymentData(cryptoCode, + destination.Key, + destination.Sum(destination1 => destination1.Amount), + index.Major, + index.Minor, + transfer.Transfer.Txid, + transfer.Transfer.Confirmations, + transfer.Transfer.Height + , transfer.Transfer.UnlockTime,invoice, paymentsToUpdate); + } + + if (paymentsToUpdate.Any()) + { + await _paymentService.UpdatePayments(paymentsToUpdate.Select(tuple => tuple.Payment).ToList()); + foreach (var valueTuples in paymentsToUpdate.GroupBy(entity => entity.invoice)) + { + if (valueTuples.Any()) + { + _eventAggregator.Publish(new Events.InvoiceNeedUpdateEvent(valueTuples.Key.Id)); + } + } + } + } + + private async Task HandlePaymentData(string cryptoCode, string address, long totalAmount, long subaccountIndex, + long subaddressIndex, + string txId, long confirmations, long blockHeight, long locktime, InvoiceEntity invoice, + List<(PaymentEntity Payment, InvoiceEntity invoice)> paymentsToUpdate) + { + var network = _networkProvider.GetNetwork(cryptoCode); + var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode); + var handler = (MoneroLikePaymentMethodHandler)_handlers[pmi]; + var promptDetails = handler.ParsePaymentPromptDetails(invoice.GetPaymentPrompt(pmi).Details); + var details = new MoneroLikePaymentData() + { + SubaccountIndex = subaccountIndex, + SubaddressIndex = subaddressIndex, + TransactionId = txId, + ConfirmationCount = confirmations, + BlockHeight = blockHeight, + LockTime = locktime, + InvoiceSettledConfirmationThreshold = promptDetails.InvoiceSettledConfirmationThreshold + }; + var status = GetStatus(details, invoice.SpeedPolicy) ? PaymentStatus.Settled : PaymentStatus.Processing; + var paymentData = new Data.PaymentData() + { + Status = status, + Amount = MoneroMoney.Convert(totalAmount), + Created = DateTimeOffset.UtcNow, + Id = $"{txId}#{subaccountIndex}#{subaddressIndex}", + Currency = network.CryptoCode, + InvoiceDataId = invoice.Id, + }.Set(invoice, handler, details); + + + //check if this tx exists as a payment to this invoice already + var alreadyExistingPaymentThatMatches = GetAllMoneroLikePayments(invoice, cryptoCode) + .SingleOrDefault(c => c.Id == paymentData.Id && c.PaymentMethodId == pmi); + + //if it doesnt, add it and assign a new monerolike address to the system if a balance is still due + if (alreadyExistingPaymentThatMatches == null) + { + var payment = await _paymentService.AddPayment(paymentData, [txId]); + if (payment != null) + await ReceivedPayment(invoice, payment); + } + else + { + //else update it with the new data + alreadyExistingPaymentThatMatches.Status = status; + alreadyExistingPaymentThatMatches.Details = JToken.FromObject(details, handler.Serializer); + paymentsToUpdate.Add((alreadyExistingPaymentThatMatches, invoice)); + } + } + + private bool GetStatus(MoneroLikePaymentData details, SpeedPolicy speedPolicy) + => ConfirmationsRequired(details, speedPolicy) <= details.ConfirmationCount; + + public static long ConfirmationsRequired(MoneroLikePaymentData details, SpeedPolicy speedPolicy) + => (details, speedPolicy) switch + { + (_, _) when details.ConfirmationCount < details.LockTime => details.LockTime - details.ConfirmationCount, + ({ InvoiceSettledConfirmationThreshold: long v }, _) => v, + (_, SpeedPolicy.HighSpeed) => 0, + (_, SpeedPolicy.MediumSpeed) => 1, + (_, SpeedPolicy.LowMediumSpeed) => 2, + (_, SpeedPolicy.LowSpeed) => 6, + _ => 6, + }; + + + private async Task UpdateAnyPendingMoneroLikePayment(string cryptoCode) + { + var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode); + var invoices = await _invoiceRepository.GetMonitoredInvoices(paymentMethodId); + if (!invoices.Any()) + return; + invoices = invoices.Where(entity => entity.GetPaymentPrompt(paymentMethodId)?.Activated is true).ToArray(); + await UpdatePaymentStates(cryptoCode, invoices); + } + + private IEnumerable GetAllMoneroLikePayments(InvoiceEntity invoice, string cryptoCode) + { + return invoice.GetPayments(false) + .Where(p => p.PaymentMethodId == PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode)); + } + } +} diff --git a/Plugins/Monero/Services/MoneroRPCProvider.cs b/Plugins/Monero/Services/MoneroRPCProvider.cs new file mode 100644 index 0000000..55236f2 --- /dev/null +++ b/Plugins/Monero/Services/MoneroRPCProvider.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Net.Http; +using System.Threading.Tasks; +using Amazon.Runtime; +using BTCPayServer.Plugins.Monero.Configuration; +using BTCPayServer.Plugins.Monero.RPC; +using BTCPayServer.Plugins.Monero.RPC.Models; +using BTCPayServer.Services; +using NBitcoin; + +namespace BTCPayServer.Plugins.Monero.Services +{ + public class MoneroRPCProvider + { + private readonly MoneroLikeConfiguration _moneroLikeConfiguration; + private readonly EventAggregator _eventAggregator; + private readonly BTCPayServerEnvironment environment; + public ImmutableDictionary DaemonRpcClients; + public ImmutableDictionary WalletRpcClients; + + private readonly ConcurrentDictionary _summaries = + new ConcurrentDictionary(); + + public ConcurrentDictionary Summaries => _summaries; + + public MoneroRPCProvider(MoneroLikeConfiguration moneroLikeConfiguration, EventAggregator eventAggregator, IHttpClientFactory httpClientFactory, BTCPayServerEnvironment environment) + { + _moneroLikeConfiguration = moneroLikeConfiguration; + _eventAggregator = eventAggregator; + this.environment = environment; + DaemonRpcClients = + _moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key, + pair => new JsonRpcClient(pair.Value.DaemonRpcUri, pair.Value.Username, pair.Value.Password, httpClientFactory.CreateClient($"{pair.Key}client"))); + WalletRpcClients = + _moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key, + pair => new JsonRpcClient(pair.Value.InternalWalletRpcUri, "", "", httpClientFactory.CreateClient($"{pair.Key}client"))); + } + + public bool IsAvailable(string cryptoCode) + { + cryptoCode = cryptoCode.ToUpperInvariant(); + return _summaries.ContainsKey(cryptoCode) && IsAvailable(_summaries[cryptoCode]); + } + + private bool IsAvailable(MoneroLikeSummary summary) + { + return summary.Synced && + summary.WalletAvailable; + } + + public async Task UpdateSummary(string cryptoCode) + { + if (!DaemonRpcClients.TryGetValue(cryptoCode.ToUpperInvariant(), out var daemonRpcClient) || + !WalletRpcClients.TryGetValue(cryptoCode.ToUpperInvariant(), out var walletRpcClient)) + { + return null; + } + + var summary = new MoneroLikeSummary(); + try + { + var daemonResult = + await daemonRpcClient.SendCommandAsync("get_info", + JsonRpcClient.NoRequestModel.Instance); + summary.TargetHeight = daemonResult.TargetHeight.GetValueOrDefault(0); + summary.CurrentHeight = daemonResult.Height; + summary.TargetHeight = summary.TargetHeight == 0 ? summary.CurrentHeight : summary.TargetHeight; + summary.Synced = !daemonResult.BusySyncing; + summary.UpdatedAt = DateTime.UtcNow; + summary.DaemonAvailable = true; + } + catch + { + summary.DaemonAvailable = false; + } + + bool walletCreated = false; + retry: + try + { + var walletResult = + await walletRpcClient.SendCommandAsync( + "get_height", JsonRpcClient.NoRequestModel.Instance); + summary.WalletHeight = walletResult.Height; + summary.WalletAvailable = true; + } + catch when (environment.CheatMode && !walletCreated) + { + await walletRpcClient.SendCommandAsync("create_wallet", + new() + { + Filename = "wallet", + Password = "", + Language = "English" + }); + walletCreated = true; + goto retry; + } + catch + { + summary.WalletAvailable = false; + } + + var changed = !_summaries.ContainsKey(cryptoCode) || IsAvailable(cryptoCode) != IsAvailable(summary); + + _summaries.AddOrReplace(cryptoCode, summary); + if (changed) + { + _eventAggregator.Publish(new MoneroDaemonStateChange() { Summary = summary, CryptoCode = cryptoCode }); + } + + return summary; + } + + + public class MoneroDaemonStateChange + { + public string CryptoCode { get; set; } + public MoneroLikeSummary Summary { get; set; } + } + + public class MoneroLikeSummary + { + public bool Synced { get; set; } + public long CurrentHeight { get; set; } + public long WalletHeight { get; set; } + public long TargetHeight { get; set; } + public DateTime UpdatedAt { get; set; } + public bool DaemonAvailable { get; set; } + public bool WalletAvailable { get; set; } + } + } +} diff --git a/Plugins/Monero/Services/MoneroSyncSummaryProvider.cs b/Plugins/Monero/Services/MoneroSyncSummaryProvider.cs new file mode 100644 index 0000000..05f2f79 --- /dev/null +++ b/Plugins/Monero/Services/MoneroSyncSummaryProvider.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Linq; +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Client.Models; +using BTCPayServer.Payments; + +namespace BTCPayServer.Plugins.Monero.Services +{ + public class MoneroSyncSummaryProvider : ISyncSummaryProvider + { + private readonly MoneroRPCProvider _moneroRpcProvider; + + public MoneroSyncSummaryProvider(MoneroRPCProvider moneroRpcProvider) + { + _moneroRpcProvider = moneroRpcProvider; + } + + public bool AllAvailable() + { + return _moneroRpcProvider.Summaries.All(pair => pair.Value.WalletAvailable); + } + + public string Partial { get; } = "/Views/Monero/MoneroSyncSummary.cshtml"; + public IEnumerable GetStatuses() + { + return _moneroRpcProvider.Summaries.Select(pair => new MoneroSyncStatus() + { + Summary = pair.Value, PaymentMethodId = PaymentMethodId.Parse(pair.Key) + }); + } + } + + public class MoneroSyncStatus: SyncStatus, ISyncStatus + { + public new PaymentMethodId PaymentMethodId + { + get => PaymentMethodId.Parse(base.PaymentMethodId); + set => base.PaymentMethodId = value.ToString(); + } + public override bool Available + { + get + { + return Summary?.WalletAvailable ?? false; + } + } + + public MoneroRPCProvider.MoneroLikeSummary Summary { get; set; } + } +} diff --git a/Plugins/Monero/Utils/MoneroMoney.cs b/Plugins/Monero/Utils/MoneroMoney.cs new file mode 100644 index 0000000..8f4737c --- /dev/null +++ b/Plugins/Monero/Utils/MoneroMoney.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace BTCPayServer.Plugins.Monero.Utils +{ + public class MoneroMoney + { + public static decimal Convert(long piconero) + { + var amt = piconero.ToString(CultureInfo.InvariantCulture).PadLeft(12, '0'); + amt = amt.Length == 12 ? $"0.{amt}" : amt.Insert(amt.Length - 12, "."); + + return decimal.Parse(amt, CultureInfo.InvariantCulture); + } + + public static long Convert(decimal monero) + { + return System.Convert.ToInt64(monero * 1000000000000); + } + } +} diff --git a/Plugins/Monero/ViewModels/MoneroPaymentViewModel.cs b/Plugins/Monero/ViewModels/MoneroPaymentViewModel.cs new file mode 100644 index 0000000..d975f3d --- /dev/null +++ b/Plugins/Monero/ViewModels/MoneroPaymentViewModel.cs @@ -0,0 +1,17 @@ +using System; +using BTCPayServer.Payments; + +namespace BTCPayServer.Plugins.Monero.ViewModels +{ + public class MoneroPaymentViewModel + { + public PaymentMethodId PaymentMethodId { get; set; } + public string Confirmations { get; set; } + public string DepositAddress { get; set; } + public string Amount { get; set; } + public string TransactionId { get; set; } + public DateTimeOffset ReceivedTime { get; set; } + public string TransactionLink { get; set; } + public string Currency { get; set; } + } +} diff --git a/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml b/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml new file mode 100644 index 0000000..d33e9be --- /dev/null +++ b/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml @@ -0,0 +1,149 @@ +@using MoneroLikePaymentMethodViewModel = BTCPayServer.Plugins.Monero.Controllers.UIMoneroLikeStoreController.MoneroLikePaymentMethodViewModel +@using MoneroLikeSettlementThresholdChoice = BTCPayServer.Plugins.Monero.Controllers.UIMoneroLikeStoreController.MoneroLikeSettlementThresholdChoice; +@model MoneroLikePaymentMethodViewModel + +@{ + ViewData.SetActivePage(Model.CryptoCode, StringLocalizer["{0} Settings", Model.CryptoCode], Model.CryptoCode); + Layout = "_Layout"; +} + + + +
+
+ @if (!ViewContext.ModelState.IsValid) + { +
+ } + @if (Model.Summary != null) + { +
+
    +
  • Node available: @Model.Summary.DaemonAvailable
  • +
  • Wallet available: @Model.Summary.WalletAvailable (@(Model.WalletFileFound ? "Wallet file present" : "Wallet file not found"))
  • +
  • Last updated: @Model.Summary.UpdatedAt
  • +
  • Synced: @Model.Summary.Synced (@Model.Summary.CurrentHeight / @Model.Summary.TargetHeight)
  • +
+
+ } + + @if (!Model.WalletFileFound || Model.Summary.WalletHeight == default) + { +
+ +
+

Upload Wallet

+
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+ } +
+ + + @if (!Model.WalletFileFound || Model.Summary.WalletHeight == default) + { + + } + else + { +
+ + @if (@Model.Accounts != null && Model.Accounts.Any()) + { + + + } + else + { + No accounts available on the current wallet + + } +
+
+
+ + +
+
+ } + +
+ + + +
+ +
+ + + + + + + +
+ + + +
+ + + + Back to list + +
+
+
+
+ +@section PageFootContent { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml b/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml new file mode 100644 index 0000000..69f18f8 --- /dev/null +++ b/Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml @@ -0,0 +1,58 @@ +@model BTCPayServer.Plugins.Monero.Controllers.UIMoneroLikeStoreController.MoneroLikePaymentMethodListViewModel + +@{ + ViewData.SetActivePage("Monero Settings", StringLocalizer["{0} Settings", "Monero"], "Monero Settings"); + Layout = "_Layout"; +} + +
+
+ @if (!ViewContext.ModelState.IsValid) + { +
+ } +
+ + + + + + + + + + + @foreach (var item in Model.Items) + { + + + + + + + } + +
CryptoAccount IndexEnabledActions
@item.CryptoCode@item.AccountIndex + @if (item.Enabled) + { + + } + else + { + + } + + + Modify + +
+
+
+
+ +@section PageFootContent { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/Plugins/Monero/Views/Monero/MoneroSyncSummary.cshtml b/Plugins/Monero/Views/Monero/MoneroSyncSummary.cshtml new file mode 100644 index 0000000..37052ef --- /dev/null +++ b/Plugins/Monero/Views/Monero/MoneroSyncSummary.cshtml @@ -0,0 +1,29 @@ +@using BTCPayServer +@using BTCPayServer.Data +@using BTCPayServer.Plugins.Monero.Services +@using Microsoft.AspNetCore.Identity +@inject MoneroRPCProvider MoneroRpcProvider +@inject SignInManager SignInManager; + +@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin) && MoneroRpcProvider.Summaries.Any()) +{ + @foreach (var summary in MoneroRpcProvider.Summaries) + { + @if (summary.Value != null) + { + var status = summary.Value.DaemonAvailable + ? summary.Value.Synced ? "enabled" : "pending" + : "disabled"; +
+ + @summary.Key +
+
    +
  • Node available: @summary.Value.DaemonAvailable
  • +
  • Wallet available: @summary.Value.WalletAvailable
  • +
  • Last updated: @summary.Value.UpdatedAt
  • +
  • Synced: @summary.Value.Synced (@summary.Value.CurrentHeight / @summary.Value.TargetHeight)
  • +
+ } + } +} diff --git a/Plugins/Monero/Views/Monero/StoreNavMoneroExtension.cshtml b/Plugins/Monero/Views/Monero/StoreNavMoneroExtension.cshtml new file mode 100644 index 0000000..a362d18 --- /dev/null +++ b/Plugins/Monero/Views/Monero/StoreNavMoneroExtension.cshtml @@ -0,0 +1,19 @@ +@using BTCPayServer +@using BTCPayServer.Plugins.Monero.Configuration +@using BTCPayServer.Plugins.Monero.Controllers +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Data +@using Microsoft.AspNetCore.Identity +@inject SignInManager SignInManager; +@inject MoneroLikeConfiguration MoneroLikeConfiguration; +@inject IScopeProvider ScopeProvider +@{ + var storeId = ScopeProvider.GetCurrentStoreId(); + var isActive = !string.IsNullOrEmpty(storeId) && ViewContext.RouteData.Values.TryGetValue("Controller", out var controller) && controller is not null && + nameof(UIMoneroLikeStoreController).StartsWith(controller.ToString() ?? string.Empty, StringComparison.InvariantCultureIgnoreCase); +} +@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin) && MoneroLikeConfiguration.MoneroLikeConfigurationItems.Any()) +{ + Monero +} diff --git a/Plugins/Monero/Views/Monero/StoreWalletsNavMoneroExtension.cshtml b/Plugins/Monero/Views/Monero/StoreWalletsNavMoneroExtension.cshtml new file mode 100644 index 0000000..4fc4680 --- /dev/null +++ b/Plugins/Monero/Views/Monero/StoreWalletsNavMoneroExtension.cshtml @@ -0,0 +1,34 @@ +@using BTCPayServer.Plugins.Monero.Configuration +@using BTCPayServer.Plugins.Monero.Controllers +@using BTCPayServer.Abstractions.Contracts +@inject SignInManager SignInManager; +@inject MoneroLikeConfiguration MoneroLikeConfiguration; +@inject IScopeProvider ScopeProvider +@inject UIMoneroLikeStoreController UIMoneroLikeStore; +@{ + var storeId = ScopeProvider.GetCurrentStoreId(); + +} +@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin) && MoneroLikeConfiguration.MoneroLikeConfigurationItems.Any()) +{ + var store = Context.GetStoreData(); + var result = await UIMoneroLikeStore.GetVM(store); + + foreach (var item in result.Items) + { + + var isActive = !string.IsNullOrEmpty(storeId) && ViewContext.RouteData.Values.TryGetValue("Controller", out var controller) && controller is not null && + nameof(UIMoneroLikeStoreController).StartsWith(controller.ToString() ?? string.Empty, StringComparison.InvariantCultureIgnoreCase) && + ViewContext.RouteData.Values.TryGetValue("cryptoCode", out var cryptoCode) && cryptoCode is not null && cryptoCode.ToString() == item.CryptoCode; + + } +} diff --git a/Plugins/Monero/Views/Monero/ViewMoneroLikePaymentData.cshtml b/Plugins/Monero/Views/Monero/ViewMoneroLikePaymentData.cshtml new file mode 100644 index 0000000..631152b --- /dev/null +++ b/Plugins/Monero/Views/Monero/ViewMoneroLikePaymentData.cshtml @@ -0,0 +1,67 @@ +@using System.Globalization +@using BTCPayServer.Plugins.Monero.Payments +@using BTCPayServer.Plugins.Monero.Services +@using BTCPayServer.Plugins.Monero.ViewModels +@using BTCPayServer.Services +@using BTCPayServer.Services.Invoices +@inject DisplayFormatter DisplayFormatter +@model BTCPayServer.Models.InvoicingModels.InvoiceDetailsModel +@inject TransactionLinkProviders TransactionLinkProviders +@inject PaymentMethodHandlerDictionary handlers + +@{ + var payments = Model.Payments.Select(payment => + { + if (!handlers.TryGetValue(payment.PaymentMethodId, out var h) || h is not MoneroLikePaymentMethodHandler handler) + return null; + var m = new MoneroPaymentViewModel(); + var onChainPaymentData = handler.ParsePaymentDetails(payment.Details); + m.PaymentMethodId = handler.PaymentMethodId; + m.DepositAddress = payment.Destination; + m.Amount = payment.Value.ToString(CultureInfo.InvariantCulture); + + var confReq = MoneroListener.ConfirmationsRequired(onChainPaymentData, payment.InvoiceEntity.SpeedPolicy); + var confCount = onChainPaymentData.ConfirmationCount; + confCount = Math.Min(confReq, confCount); + m.Confirmations = $"{confCount} / {confReq}"; + + m.TransactionId = onChainPaymentData.TransactionId; + m.ReceivedTime = payment.ReceivedTime; + if (onChainPaymentData.TransactionId != null) + m.TransactionLink = TransactionLinkProviders.GetTransactionLink(m.PaymentMethodId, onChainPaymentData.TransactionId); + m.Currency = payment.Currency; + return m; + }).Where(c => c != null).ToList(); +} + +@if (payments.Any()) +{ +
+
Monero Payments
+ + + + + + + + + + + + @foreach (var payment in payments) + { + + + + + + + + } + +
Payment MethodDestinationPayment ProofConfirmationsPaid
@payment.PaymentMethodId@payment.Confirmations + @DisplayFormatter.Currency(payment.Amount, payment.Currency) +
+
+} diff --git a/Plugins/Monero/Views/Monero/_ViewImports.cshtml b/Plugins/Monero/Views/Monero/_ViewImports.cshtml new file mode 100644 index 0000000..26e5b93 --- /dev/null +++ b/Plugins/Monero/Views/Monero/_ViewImports.cshtml @@ -0,0 +1,18 @@ +@using Microsoft.AspNetCore.Identity +@using BTCPayServer +@using BTCPayServer.Abstractions.Services +@using BTCPayServer.Views +@using BTCPayServer.Models +@using BTCPayServer.Models.AccountViewModels +@using BTCPayServer.Models.InvoicingModels +@using BTCPayServer.Models.ManageViewModels +@using BTCPayServer.Models.StoreViewModels +@using BTCPayServer.Data +@using Microsoft.AspNetCore.Routing; +@using BTCPayServer.Abstractions.Extensions; +@inject Microsoft.AspNetCore.Mvc.Localization.ViewLocalizer ViewLocalizer +@inject Microsoft.Extensions.Localization.IStringLocalizer StringLocalizer +@inject Safe Safe +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..91dc823 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Monero support plugin + +This plugin extends BTCPay Server to enable users to receive payments via Monero. + +![Checkout](./img/Checkout.png) + +## Configuration + +Configure this plugin using the following environment variables: + +| Environment variable | Description | Example | +| --- | --- | --- | +**BTCPAY_XMR_DAEMON_URI** | **Required**. The URI of the [monerod](https://github.com/monero-project/monero) RPC interface. | http://127.0.0.1:18081 | +**BTCPAY_XMR_DAEMON_USERNAME** | **Optional**. The username for authenticating with the daemon. | john | +**BTCPAY_XMR_DAEMON_PASSWORD** | **Optional**. The password for authenticating with the daemon. | secret | +**BTCPAY_XMR_WALLET_DAEMON_URI** | **Required**. The URI of the [monero-wallet-rpc](https://getmonero.dev/interacting/monero-wallet-rpc.html) RPC interface. | http://127.0.0.1:18082 | +**BTCPAY_XMR_WALLET_DAEMON_WALLETDIR** | **Required**. The directory where BTCPay Server saves wallet files uploaded via the UI ([See this blog post for more details](https://sethforprivacy.com/guides/accepting-monero-via-btcpay-server/#configure-the-bitcoin-wallet-of-choice)). | + +BTCPay Server's Docker deployment simplifies the setup by automatically configuring these variables. For further details, refer to this [blog post](https://sethforprivacy.com/guides/accepting-monero-via-btcpay-server). + +# For maintainers + +If you are a developer maintaining this plugin, in order to maintain this plugin, you need to clone this repository with `--recurse-submodules`: +```bash +git clone --recurse-submodules +``` +Then run the tests dependencies +```bash +docker-compose up -d dev +``` + +Then create the `appsettings.dev.json` file in `btcpayserver/BTCPayServer`, with the following content: + +```json +{ + "DEBUG_PLUGINS": "C:\\Sources\\btcpayserver-monero-plugin\\Plugins\\Monero\\bin\\Debug\\net8.0\\BTCPayServer.Plugins.Monero.dll", + "XMR_DAEMON_URI": "http://127.0.0.1:18081", + "XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082", + "XMR_WALLET_DAEMON_WALLETDIR": "C:\\Sources\\btcpayserver-monero-plugin\\monero_wallet" +} +``` + +Please replace `C:\\Sources\\btcpayserver-monero-plugin` with the absolute path of your repository. + +This will ensure that BTCPay Server loads the plugin when it starts. + +Finally, set up BTCPay Server as the startup project in [Rider](https://www.jetbrains.com/rider/) or Visual Studio. + +Note: Running or compiling the BTCPay Server project will not automatically recompile the plugin project. Therefore, if you make any changes to the project, do not forget to build it before running BTCPay Server in debug mode. + +We recommend using [Rider](https://www.jetbrains.com/rider/) for plugin development, as it supports hot reload with plugins. You can edit `.cshtml` files, save, and refresh the page to see the changes. + +Visual Studio does not support this feature. + +# Licence + +[MIT](LICENSE.md) \ No newline at end of file diff --git a/btcpay-monero-plugin.sln b/btcpay-monero-plugin.sln new file mode 100644 index 0000000..8a184e5 --- /dev/null +++ b/btcpay-monero-plugin.sln @@ -0,0 +1,85 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "btcpayserver", "btcpayserver", "{891F21E0-262C-4430-90C5-7A540AD7C9AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer", "btcpayserver\BTCPayServer\BTCPayServer.csproj", "{049FC011-1952-4140-9652-12921C106B02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Abstractions", "btcpayserver\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj", "{3ACB5270-BA91-4326-A7CC-5EBEFB8FB511}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Client", "btcpayserver\BTCPayServer.Client\BTCPayServer.Client.csproj", "{157B3D22-F859-482C-B387-2C326A3ECB52}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Common", "btcpayserver\BTCPayServer.Common\BTCPayServer.Common.csproj", "{0A4AAC1F-513C-493C-B173-AA9D28FF5E60}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Data", "btcpayserver\BTCPayServer.Data\BTCPayServer.Data.csproj", "{CB161BDA-5350-4B54-AA94-9540189BAE81}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "btcpayserver\BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{E8FBC53B-768F-4454-B7AF-A4BD104B7F0D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Rating", "btcpayserver\BTCPayServer.Rating\BTCPayServer.Rating.csproj", "{67171233-EBD1-4086-9074-57D0F3A74ADC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Tests", "btcpayserver\BTCPayServer.Tests\BTCPayServer.Tests.csproj", "{B481573C-744D-433F-B4DA-442E3E19562E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{C9628212-0A00-4BF2-AF84-21797124579F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Monero", "Plugins\Monero\BTCPayServer.Plugins.Monero.csproj", "{319C8C91-952F-4CF6-A251-058DFC66D70F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {049FC011-1952-4140-9652-12921C106B02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {049FC011-1952-4140-9652-12921C106B02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {049FC011-1952-4140-9652-12921C106B02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {049FC011-1952-4140-9652-12921C106B02}.Release|Any CPU.Build.0 = Release|Any CPU + {3ACB5270-BA91-4326-A7CC-5EBEFB8FB511}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3ACB5270-BA91-4326-A7CC-5EBEFB8FB511}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ACB5270-BA91-4326-A7CC-5EBEFB8FB511}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3ACB5270-BA91-4326-A7CC-5EBEFB8FB511}.Release|Any CPU.Build.0 = Release|Any CPU + {157B3D22-F859-482C-B387-2C326A3ECB52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {157B3D22-F859-482C-B387-2C326A3ECB52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {157B3D22-F859-482C-B387-2C326A3ECB52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {157B3D22-F859-482C-B387-2C326A3ECB52}.Release|Any CPU.Build.0 = Release|Any CPU + {0A4AAC1F-513C-493C-B173-AA9D28FF5E60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A4AAC1F-513C-493C-B173-AA9D28FF5E60}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A4AAC1F-513C-493C-B173-AA9D28FF5E60}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A4AAC1F-513C-493C-B173-AA9D28FF5E60}.Release|Any CPU.Build.0 = Release|Any CPU + {CB161BDA-5350-4B54-AA94-9540189BAE81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB161BDA-5350-4B54-AA94-9540189BAE81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB161BDA-5350-4B54-AA94-9540189BAE81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB161BDA-5350-4B54-AA94-9540189BAE81}.Release|Any CPU.Build.0 = Release|Any CPU + {E8FBC53B-768F-4454-B7AF-A4BD104B7F0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8FBC53B-768F-4454-B7AF-A4BD104B7F0D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8FBC53B-768F-4454-B7AF-A4BD104B7F0D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8FBC53B-768F-4454-B7AF-A4BD104B7F0D}.Release|Any CPU.Build.0 = Release|Any CPU + {67171233-EBD1-4086-9074-57D0F3A74ADC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {67171233-EBD1-4086-9074-57D0F3A74ADC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {67171233-EBD1-4086-9074-57D0F3A74ADC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {67171233-EBD1-4086-9074-57D0F3A74ADC}.Release|Any CPU.Build.0 = Release|Any CPU + {B481573C-744D-433F-B4DA-442E3E19562E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B481573C-744D-433F-B4DA-442E3E19562E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B481573C-744D-433F-B4DA-442E3E19562E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B481573C-744D-433F-B4DA-442E3E19562E}.Release|Any CPU.Build.0 = Release|Any CPU + {319C8C91-952F-4CF6-A251-058DFC66D70F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {319C8C91-952F-4CF6-A251-058DFC66D70F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {319C8C91-952F-4CF6-A251-058DFC66D70F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {319C8C91-952F-4CF6-A251-058DFC66D70F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {049FC011-1952-4140-9652-12921C106B02} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {3ACB5270-BA91-4326-A7CC-5EBEFB8FB511} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {157B3D22-F859-482C-B387-2C326A3ECB52} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {0A4AAC1F-513C-493C-B173-AA9D28FF5E60} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {CB161BDA-5350-4B54-AA94-9540189BAE81} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {E8FBC53B-768F-4454-B7AF-A4BD104B7F0D} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {67171233-EBD1-4086-9074-57D0F3A74ADC} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {B481573C-744D-433F-B4DA-442E3E19562E} = {891F21E0-262C-4430-90C5-7A540AD7C9AD} + {319C8C91-952F-4CF6-A251-058DFC66D70F} = {C9628212-0A00-4BF2-AF84-21797124579F} + EndGlobalSection +EndGlobal diff --git a/btcpayserver b/btcpayserver new file mode 160000 index 0000000..29d602b --- /dev/null +++ b/btcpayserver @@ -0,0 +1 @@ +Subproject commit 29d602b937b9192c38c9a105d9019fa9befd5496 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0f2cf1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,142 @@ +version: "3" + +# Run `docker-compose up dev` for bootstrapping your development environment +# Doing so will expose NBXplorer, Bitcoind RPC and postgres port to the host so that tests can Run, +# The Visual Studio launch setting `Docker-regtest` is configured to use this environment. +services: + + tests: + build: + context: .. + dockerfile: BTCPayServer.Tests/Dockerfile + args: + CONFIGURATION_NAME: Release + environment: + TESTS_BTCRPCCONNECTION: server=http://bitcoind:43782;ceiwHEbqWI83:DwubwWsoo3 + TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/ + TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver + TESTS_EXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer + TESTS_HOSTNAME: tests + TESTS_RUN_EXTERNAL_INTEGRATION: ${TESTS_RUN_EXTERNAL_INTEGRATION:-"false"} + TESTS_AzureBlobStorageConnectionString: ${TESTS_AzureBlobStorageConnectionString:-none} + TESTS_INCONTAINER: "true" + TESTS_SSHCONNECTION: "root@sshd:22" + TESTS_SSHPASSWORD: "" + TESTS_SSHKEYFILE: "" + TESTS_SOCKSENDPOINT: "tor:9050" + expose: + - "80" + depends_on: + - dev + extra_hosts: + - "tests:127.0.0.1" + networks: + default: + custom: + ipv4_address: 172.23.0.18 + + # The dev container is not actually used, it is just handy to run `docker-compose up dev` to start all services + dev: + image: alpine:3.7 + command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ] + depends_on: + - nbxplorer + - postgres + - monero_wallet + + nbxplorer: + image: nicolasdorier/nbxplorer:2.5.16 + restart: unless-stopped + ports: + - "32838:32838" + expose: + - "32838" + environment: + NBXPLORER_NETWORK: regtest + NBXPLORER_CHAINS: "btc" + NBXPLORER_BTCRPCURL: http://bitcoind:43782/ + NBXPLORER_BTCNODEENDPOINT: bitcoind:39388 + NBXPLORER_BTCRPCUSER: ceiwHEbqWI83 + NBXPLORER_BTCRPCPASSWORD: DwubwWsoo3 + NBXPLORER_BIND: 0.0.0.0:32838 + NBXPLORER_MINGAPSIZE: 5 + NBXPLORER_MAXGAPSIZE: 10 + NBXPLORER_VERBOSE: 1 + NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer + NBXPLORER_EXPOSERPC: 1 + NBXPLORER_NOAUTH: 1 + depends_on: + - bitcoind + + bitcoind: + restart: unless-stopped + image: btcpayserver/bitcoin:26.0 + environment: + BITCOIN_NETWORK: regtest + BITCOIN_WALLETDIR: "/data/wallets" + BITCOIN_EXTRA_ARGS: |- + rpcuser=ceiwHEbqWI83 + rpcpassword=DwubwWsoo3 + rpcport=43782 + rpcbind=0.0.0.0:43782 + rpcallowip=0.0.0.0/0 + port=39388 + whitelist=0.0.0.0/0 + zmqpubrawblock=tcp://0.0.0.0:28332 + zmqpubrawtx=tcp://0.0.0.0:28333 + deprecatedrpc=signrawtransaction + fallbackfee=0.0002 + ports: + - "43782:43782" + - "39388:39388" + expose: + - "43782" # RPC + - "39388" # P2P + - "28332" # ZMQ + - "28333" # ZMQ + volumes: + - "bitcoin_datadir:/data" + + monerod: + image: btcpayserver/monero:0.18.3.3 + restart: unless-stopped + container_name: xmr_monerod + entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline --non-interactive + volumes: + - "monero_data:/home/monero/.bitmonero" + ports: + - "18081:18081" + + monero_wallet: + image: btcpayserver/monero:0.18.3.3 + restart: unless-stopped + container_name: xmr_wallet_rpc + entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-dir=/wallet --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s" + ports: + - "18082:18082" + volumes: + - "./monero_wallet:/wallet" + depends_on: + - monerod + + postgres: + image: postgres:13.13 + environment: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "39372:5432" + expose: + - "5432" + +volumes: + bitcoin_datadir: + monero_data: + +networks: + default: + driver: bridge + custom: + driver: bridge + ipam: + config: + - subnet: 172.23.0.0/16 diff --git a/img/Checkout.png b/img/Checkout.png new file mode 100644 index 0000000..d74ec38 Binary files /dev/null and b/img/Checkout.png differ