From c24ef96fd48d7ec54c5fdc1f4599222ebc9f493b Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Wed, 8 Jan 2025 18:27:19 +0900 Subject: [PATCH] Init commit --- .dockerignore | 25 ++ .gitignore | 6 + .gitmodules | 3 + LICENSE.md | 21 + .../Monero/BTCPayServer.Plugins.Monero.csproj | 56 +++ .../Configuration/MoneroLikeConfiguration.cs | 20 + .../MoneroDaemonCallbackController.cs | 38 ++ .../Controllers/MoneroLikeStoreController.cs | 392 ++++++++++++++++++ .../Monero/MoneroLikeSpecificBtcPayNetwork.cs | 8 + Plugins/Monero/MoneroPlugin.cs | 163 ++++++++ .../Payments/MoneroCheckoutModelExtension.cs | 52 +++ .../MoneroLikeOnChainPaymentMethodDetails.cs | 11 + .../Monero/Payments/MoneroLikePaymentData.cs | 20 + .../MoneroLikePaymentMethodHandler.cs | 119 ++++++ .../Payments/MoneroPaymentLinkExtension.cs | 27 ++ .../Payments/MoneroPaymentPromptDetails.cs | 11 + Plugins/Monero/RPC/JsonRpcClient.cs | 121 ++++++ .../Monero/RPC/Models/CreateAccountRequest.cs | 9 + .../RPC/Models/CreateAccountResponse.cs | 10 + .../Monero/RPC/Models/CreateAddressRequest.cs | 10 + .../RPC/Models/CreateAddressResponse.cs | 10 + .../Monero/RPC/Models/CreateWalletRequest.cs | 11 + .../Monero/RPC/Models/GetAccountsRequest.cs | 9 + .../Monero/RPC/Models/GetAccountsResponse.cs | 14 + .../RPC/Models/GetFeeEstimateRequest.cs | 9 + .../RPC/Models/GetFeeEstimateResponse.cs | 11 + .../Monero/RPC/Models/GetHeightResponse.cs | 9 + Plugins/Monero/RPC/Models/GetInfoResponse.cs | 13 + .../GetTransferByTransactionIdRequest.cs | 11 + .../GetTransferByTransactionIdResponse.cs | 31 ++ .../Monero/RPC/Models/GetTransfersRequest.cs | 19 + .../Monero/RPC/Models/GetTransfersResponse.cs | 35 ++ Plugins/Monero/RPC/Models/Info.cs | 33 ++ Plugins/Monero/RPC/Models/MakeUriRequest.cs | 13 + Plugins/Monero/RPC/Models/MakeUriResponse.cs | 9 + .../RPC/Models/OpenWallerErrorResponse.cs | 10 + .../Monero/RPC/Models/OpenWalletRequest.cs | 10 + .../Monero/RPC/Models/OpenWalletResponse.cs | 12 + .../Monero/RPC/Models/ParseStringConverter.cs | 40 ++ Plugins/Monero/RPC/Models/Peer.cs | 9 + Plugins/Monero/RPC/Models/SubaddrIndex.cs | 10 + .../Monero/RPC/Models/SubaddressAccount.cs | 14 + Plugins/Monero/RPC/MoneroEvent.cs | 15 + .../MoneroLikeSummaryUpdaterHostedService.cs | 70 ++++ Plugins/Monero/Services/MoneroListener.cs | 361 ++++++++++++++++ Plugins/Monero/Services/MoneroRPCProvider.cs | 135 ++++++ .../Services/MoneroSyncSummaryProvider.cs | 50 +++ Plugins/Monero/Utils/MoneroMoney.cs | 20 + .../ViewModels/MoneroPaymentViewModel.cs | 17 + .../GetStoreMoneroLikePaymentMethod.cshtml | 149 +++++++ .../GetStoreMoneroLikePaymentMethods.cshtml | 58 +++ .../Views/Monero/MoneroSyncSummary.cshtml | 29 ++ .../Monero/StoreNavMoneroExtension.cshtml | 19 + .../StoreWalletsNavMoneroExtension.cshtml | 34 ++ .../Monero/ViewMoneroLikePaymentData.cshtml | 67 +++ .../Monero/Views/Monero/_ViewImports.cshtml | 18 + README.md | 57 +++ btcpay-monero-plugin.sln | 85 ++++ btcpayserver | 1 + docker-compose.yml | 142 +++++++ img/Checkout.png | Bin 0 -> 85446 bytes 61 files changed, 2791 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 LICENSE.md create mode 100644 Plugins/Monero/BTCPayServer.Plugins.Monero.csproj create mode 100644 Plugins/Monero/Configuration/MoneroLikeConfiguration.cs create mode 100644 Plugins/Monero/Controllers/MoneroDaemonCallbackController.cs create mode 100644 Plugins/Monero/Controllers/MoneroLikeStoreController.cs create mode 100644 Plugins/Monero/MoneroLikeSpecificBtcPayNetwork.cs create mode 100644 Plugins/Monero/MoneroPlugin.cs create mode 100644 Plugins/Monero/Payments/MoneroCheckoutModelExtension.cs create mode 100644 Plugins/Monero/Payments/MoneroLikeOnChainPaymentMethodDetails.cs create mode 100644 Plugins/Monero/Payments/MoneroLikePaymentData.cs create mode 100644 Plugins/Monero/Payments/MoneroLikePaymentMethodHandler.cs create mode 100644 Plugins/Monero/Payments/MoneroPaymentLinkExtension.cs create mode 100644 Plugins/Monero/Payments/MoneroPaymentPromptDetails.cs create mode 100644 Plugins/Monero/RPC/JsonRpcClient.cs create mode 100644 Plugins/Monero/RPC/Models/CreateAccountRequest.cs create mode 100644 Plugins/Monero/RPC/Models/CreateAccountResponse.cs create mode 100644 Plugins/Monero/RPC/Models/CreateAddressRequest.cs create mode 100644 Plugins/Monero/RPC/Models/CreateAddressResponse.cs create mode 100644 Plugins/Monero/RPC/Models/CreateWalletRequest.cs create mode 100644 Plugins/Monero/RPC/Models/GetAccountsRequest.cs create mode 100644 Plugins/Monero/RPC/Models/GetAccountsResponse.cs create mode 100644 Plugins/Monero/RPC/Models/GetFeeEstimateRequest.cs create mode 100644 Plugins/Monero/RPC/Models/GetFeeEstimateResponse.cs create mode 100644 Plugins/Monero/RPC/Models/GetHeightResponse.cs create mode 100644 Plugins/Monero/RPC/Models/GetInfoResponse.cs create mode 100644 Plugins/Monero/RPC/Models/GetTransferByTransactionIdRequest.cs create mode 100644 Plugins/Monero/RPC/Models/GetTransferByTransactionIdResponse.cs create mode 100644 Plugins/Monero/RPC/Models/GetTransfersRequest.cs create mode 100644 Plugins/Monero/RPC/Models/GetTransfersResponse.cs create mode 100644 Plugins/Monero/RPC/Models/Info.cs create mode 100644 Plugins/Monero/RPC/Models/MakeUriRequest.cs create mode 100644 Plugins/Monero/RPC/Models/MakeUriResponse.cs create mode 100644 Plugins/Monero/RPC/Models/OpenWallerErrorResponse.cs create mode 100644 Plugins/Monero/RPC/Models/OpenWalletRequest.cs create mode 100644 Plugins/Monero/RPC/Models/OpenWalletResponse.cs create mode 100644 Plugins/Monero/RPC/Models/ParseStringConverter.cs create mode 100644 Plugins/Monero/RPC/Models/Peer.cs create mode 100644 Plugins/Monero/RPC/Models/SubaddrIndex.cs create mode 100644 Plugins/Monero/RPC/Models/SubaddressAccount.cs create mode 100644 Plugins/Monero/RPC/MoneroEvent.cs create mode 100644 Plugins/Monero/Services/MoneroLikeSummaryUpdaterHostedService.cs create mode 100644 Plugins/Monero/Services/MoneroListener.cs create mode 100644 Plugins/Monero/Services/MoneroRPCProvider.cs create mode 100644 Plugins/Monero/Services/MoneroSyncSummaryProvider.cs create mode 100644 Plugins/Monero/Utils/MoneroMoney.cs create mode 100644 Plugins/Monero/ViewModels/MoneroPaymentViewModel.cs create mode 100644 Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethod.cshtml create mode 100644 Plugins/Monero/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml create mode 100644 Plugins/Monero/Views/Monero/MoneroSyncSummary.cshtml create mode 100644 Plugins/Monero/Views/Monero/StoreNavMoneroExtension.cshtml create mode 100644 Plugins/Monero/Views/Monero/StoreWalletsNavMoneroExtension.cshtml create mode 100644 Plugins/Monero/Views/Monero/ViewMoneroLikePaymentData.cshtml create mode 100644 Plugins/Monero/Views/Monero/_ViewImports.cshtml create mode 100644 README.md create mode 100644 btcpay-monero-plugin.sln create mode 160000 btcpayserver create mode 100644 docker-compose.yml create mode 100644 img/Checkout.png 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 0000000000000000000000000000000000000000..d74ec38552d9dc9a24cf794308c7ffadf1b17068 GIT binary patch literal 85446 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

i>svFC+a|G7LgPVWcty3$rG0jtF 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 WyZDVGIKmIGFLh;|H?@lIKL1~&_;b7f literal 0 HcmV?d00001