Init commit

This commit is contained in:
nicolas.dorier
2025-01-08 18:27:19 +09:00
commit c24ef96fd4
61 changed files with 2791 additions and 0 deletions

25
.dockerignore Normal file
View File

@@ -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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
**/bin/**/*
**/obj
.idea
Plugins/packed
.vs/
monero_wallet/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "btcpayserver"]
path = btcpayserver
url = https://github.com/btcpayserver/btcpayserver

21
LICENSE.md Normal file
View File

@@ -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.

View File

@@ -0,0 +1,56 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<!-- Plugin specific properties -->
<PropertyGroup>
<Product>BTCPay Server Plugin Template</Product>
<Description>A template for your own BTCPay Server plugin.</Description>
<Version>1.0.0</Version>
</PropertyGroup>
<!-- Plugin development properties -->
<PropertyGroup>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PreserveCompilationContext>false</PreserveCompilationContext>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
<ItemDefinitionGroup>
<ProjectReference>
<Properties>StaticWebAssetsEnabled=false</Properties>
<Private>false</Private>
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
</ProjectReference>
</ItemDefinitionGroup>
<!-- If you need Entity Framework, you can uncomment this. This will make it usable in your project without publishing assemblies
already referenced by BTCPay Server Core project -->
<!--
<ItemGroup Condition="$(Configuration) != 'Release'">
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.9.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
</ItemGroup>
-->
<!-- If you reference another project, by default, the dlls won't be copied in the published plugin, you need <Private>true</Private> -->
<!--
<ItemGroup>
<ProjectReference Include="..\submodules\some-client\src\Some.Client\Some.Client.csproj">
<Private>true</Private>
</ProjectReference>
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer.Plugins.Monero.Configuration
{
public class MoneroLikeConfiguration
{
public Dictionary<string, MoneroLikeConfigurationItem> MoneroLikeConfigurationItems { get; set; } =
new Dictionary<string, MoneroLikeConfigurationItem>();
}
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; }
}
}

View File

@@ -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();
}
}
}

View File

@@ -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<IActionResult> GetStoreMoneroLikePaymentMethods()
{
return View("/Views/Monero/GetStoreMoneroLikePaymentMethods.cshtml", await GetVM(StoreData));
}
[NonAction]
public async Task<MoneroLikePaymentMethodListViewModel> 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<GetAccountsResponse> GetAccounts(string cryptoCode)
{
try
{
if (_MoneroRpcProvider.Summaries.TryGetValue(cryptoCode, out var summary) && summary.WalletAvailable)
{
return _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync<GetAccountsRequest, GetAccountsResponse>("get_accounts", new GetAccountsRequest());
}
}
catch { }
return Task.FromResult<GetAccountsResponse>(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<IActionResult> 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<IActionResult> 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<CreateAccountRequest, CreateAccountResponse>("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<OpenWalletRequest, OpenWalletResponse>("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<MoneroLikePaymentMethodViewModel> 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<SelectListItem> 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<ValidationResult> 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
}
}
}

View File

@@ -0,0 +1,8 @@
namespace BTCPayServer.Plugins.Altcoins;
public class MoneroLikeSpecificBtcPayNetwork : BTCPayNetworkBase
{
public int MaxTrackedConfirmation = 10;
public string UriScheme { get; set; }
}

View File

@@ -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<MoneroLikeConfiguration>();
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<MoneroRPCProvider>();
services.AddHostedService<MoneroLikeSummaryUpdaterHostedService>();
services.AddHostedService<MoneroListener>();
services.AddSingleton<IPaymentMethodHandler>(provider =>
(IPaymentMethodHandler)ActivatorUtilities.CreateInstance(provider, typeof(MoneroLikePaymentMethodHandler), new object[] { network }));
services.AddSingleton<IPaymentLinkExtension>(provider =>
(IPaymentLinkExtension)ActivatorUtilities.CreateInstance(provider, typeof(MoneroPaymentLinkExtension), new object[] { network, pmi }));
services.AddSingleton<ICheckoutModelExtension>(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<ISyncSummaryProvider, MoneroSyncSummaryProvider>();
}
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<IConfiguration>();
var btcPayNetworkProvider = serviceProvider.GetService<BTCPayNetworkProvider>();
var result = new MoneroLikeConfiguration();
var supportedNetworks = btcPayNetworkProvider.GetAll()
.OfType<MoneroLikeSpecificBtcPayNetwork>();
foreach (var moneroLikeSpecificBtcPayNetwork in supportedNetworks)
{
var daemonUri =
configuration.GetOrDefault<Uri>($"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_uri",
null);
var walletDaemonUri =
configuration.GetOrDefault<Uri>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_wallet_daemon_uri", null);
var walletDaemonWalletDirectory =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_wallet_daemon_walletdir", null);
var daemonUsername =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_username", null);
var daemonPassword =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_password", null);
if (daemonUri == null || walletDaemonUri == null || walletDaemonWalletDirectory == null)
{
var logger = serviceProvider.GetRequiredService<ILogger<MoneroPlugin>>();
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;
}
}

View File

@@ -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<IPaymentLinkExtension> 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<MoneroLikePaymentData>(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;
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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;
}
}

View File

@@ -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<GetFeeEstimateRequest, GetFeeEstimateResponse>("get_fee_estimate", new GetFeeEstimateRequest()),
ReserveAddress = s => walletClient.SendCommandAsync<CreateAddressRequest, CreateAddressResponse>("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<MoneroPaymentPromptDetails>(Serializer) ?? throw new FormatException($"Invalid {nameof(MoneroLikePaymentMethodHandler)}");
}
object IPaymentMethodHandler.ParsePaymentMethodConfig(JToken config)
{
return ParsePaymentMethodConfig(config);
}
class Prepare
{
public Task<GetFeeEstimateResponse> GetFeeRate;
public Func<string, Task<CreateAddressResponse>> ReserveAddress;
public long AccountIndex { get; internal set; }
}
public MoneroLikeOnChainPaymentMethodDetails ParsePaymentPromptDetails(Newtonsoft.Json.Linq.JToken details)
{
return details.ToObject<MoneroLikeOnChainPaymentMethodDetails>(Serializer);
}
object IPaymentMethodHandler.ParsePaymentPromptDetails(Newtonsoft.Json.Linq.JToken details)
{
return ParsePaymentPromptDetails(details);
}
public MoneroLikePaymentData ParsePaymentDetails(JToken details)
{
return details.ToObject<MoneroLikePaymentData>(Serializer) ?? throw new FormatException($"Invalid {nameof(MoneroLikePaymentMethodHandler)}");
}
object IPaymentMethodHandler.ParsePaymentDetails(JToken details)
{
return ParsePaymentDetails(details);
}
}
}

View File

@@ -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)}";
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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<TResponse> SendCommandAsync<TRequest, TResponse>(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<TRequest>(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<TResponse> response;
try
{
response = JsonConvert.DeserializeObject<JsonRpcResult<TResponse>>(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<T>
{
[JsonProperty("result")] public T Result { get; set; }
[JsonProperty("error")] public JsonRpcResultError Error { get; set; }
[JsonProperty("id")] public string Id { get; set; }
}
internal class JsonRpcCommand<T>
{
[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;
}
}
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public partial class CreateAccountRequest
{
[JsonProperty("label")] public string Label { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public partial class GetAccountsRequest
{
[JsonProperty("tag")] public string Tag { get; set; }
}
}

View File

@@ -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<SubaddressAccount> SubaddressAccounts { get; set; }
[JsonProperty("total_balance")] public decimal TotalBalance { get; set; }
[JsonProperty("total_unlocked_balance")]
public decimal TotalUnlockedBalance { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public class GetFeeEstimateRequest
{
[JsonProperty("grace_blocks")] public int? GraceBlocks { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public partial class GetHeightResponse
{
[JsonProperty("height")] public long Height { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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<TransferItem> 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; }
}
}
}

View File

@@ -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<long> SubaddrIndices { get; set; }
}
}

View File

@@ -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<GetTransfersResponseItem> In { get; set; }
[JsonProperty("out")] public List<GetTransfersResponseItem> Out { get; set; }
[JsonProperty("pending")] public List<GetTransfersResponseItem> Pending { get; set; }
[JsonProperty("failed")] public List<GetTransfersResponseItem> Failed { get; set; }
[JsonProperty("pool")] public List<GetTransfersResponseItem> 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; }
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public partial class MakeUriResponse
{
[JsonProperty("uri")] public string Uri { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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<string>(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();
}
}

View File

@@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Monero.RPC.Models
{
public partial class Peer
{
[JsonProperty("info")] public Info Info { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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})";
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<MoneroListener> _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<MoneroListener> 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<MoneroEvent>();
Subscribe<MoneroRPCProvider.MoneroDaemonStateChange>();
}
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<long, List<long>>();
//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<long>());
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<GetTransfersRequest, GetTransfersResponse>(
"get_transfers",
new GetTransfersRequest()
{
AccountIndex = datas.Key,
In = true,
SubaddrIndices = datas.Value.Distinct().ToList()
}));
await Task.WhenAll(tasks.Values);
var transferProcessingTasks = new List<Task>();
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<GetTransferByTransactionIdRequest, GetTransferByTransactionIdResponse>(
"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<PaymentEntity> GetAllMoneroLikePayments(InvoiceEntity invoice, string cryptoCode)
{
return invoice.GetPayments(false)
.Where(p => p.PaymentMethodId == PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode));
}
}
}

View File

@@ -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<string, JsonRpcClient> DaemonRpcClients;
public ImmutableDictionary<string, JsonRpcClient> WalletRpcClients;
private readonly ConcurrentDictionary<string, MoneroLikeSummary> _summaries =
new ConcurrentDictionary<string, MoneroLikeSummary>();
public ConcurrentDictionary<string, MoneroLikeSummary> 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<MoneroLikeSummary> 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<JsonRpcClient.NoRequestModel, GetInfoResponse>("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<JsonRpcClient.NoRequestModel, GetHeightResponse>(
"get_height", JsonRpcClient.NoRequestModel.Instance);
summary.WalletHeight = walletResult.Height;
summary.WalletAvailable = true;
}
catch when (environment.CheatMode && !walletCreated)
{
await walletRpcClient.SendCommandAsync<CreateWalletRequest, JsonRpcClient.NoRequestModel>("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; }
}
}
}

View File

@@ -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<ISyncStatus> 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; }
}
}

View File

@@ -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);
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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";
}
<partial name="_StatusMessage" />
<div class="row">
<div class="col-md-8">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All"></div>
}
@if (Model.Summary != null)
{
<div class="card">
<ul class="list-group list-group-flush">
<li class="list-group-item">Node available: @Model.Summary.DaemonAvailable</li>
<li class="list-group-item">Wallet available: @Model.Summary.WalletAvailable (@(Model.WalletFileFound ? "Wallet file present" : "Wallet file not found"))</li>
<li class="list-group-item">Last updated: @Model.Summary.UpdatedAt</li>
<li class="list-group-item">Synced: @Model.Summary.Synced (@Model.Summary.CurrentHeight / @Model.Summary.TargetHeight)</li>
</ul>
</div>
}
@if (!Model.WalletFileFound || Model.Summary.WalletHeight == default)
{
<form method="post" asp-action="GetStoreMoneroLikePaymentMethod"
asp-route-storeId="@Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@Context.GetRouteValue("cryptoCode")"
class="mt-4" enctype="multipart/form-data">
<div class="card my-2">
<h3 class="card-title p-2">Upload Wallet</h3>
<div class="form-group p-2">
<label asp-for="WalletFile" class="form-label"></label>
<input class="form-control" asp-for="WalletFile" required>
<span asp-validation-for="WalletFile" class="text-danger"></span>
</div>
<div class="form-group p-2">
<label asp-for="WalletKeysFile" class="form-label"></label>
<input class="form-control" asp-for="WalletKeysFile" required>
<span asp-validation-for="WalletKeysFile" class="text-danger"></span>
</div>
<div class="form-group p-2">
<label asp-for="WalletPassword" class="form-label"></label>
<input class="form-control" asp-for="WalletPassword">
<span asp-validation-for="WalletPassword" class="text-danger"></span>
</div>
<div class="card-footer text-right">
<button name="command" value="upload-wallet" class="btn btn-secondary" type="submit">Upload</button>
</div>
</div>
</form>
}
<form method="post" asp-action="GetStoreMoneroLikePaymentMethod"
asp-route-storeId="@Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@Context.GetRouteValue("cryptoCode")"
class="mt-4" enctype="multipart/form-data">
<input type="hidden" asp-for="CryptoCode"/>
@if (!Model.WalletFileFound || Model.Summary.WalletHeight == default)
{
<input type="hidden" asp-for="AccountIndex"/>
}
else
{
<div class="form-group">
<label asp-for="AccountIndex" class="control-label"></label>
@if (@Model.Accounts != null && Model.Accounts.Any())
{
<select asp-for="AccountIndex" asp-items="Model.Accounts" class="form-control"></select>
<span asp-validation-for="AccountIndex" class="text-danger"></span>
}
else
{
<span>No accounts available on the current wallet</span>
<input type="hidden" asp-for="AccountIndex"/>
}
</div>
<div class="form-group">
<div class="input-group my-3">
<input type="text" class="form-control" placeholder="@StringLocalizer["New account label"]" asp-for="NewAccountLabel">
<button name="command" value="add-account" class="input-group-text btn btn-secondary" type="submit">Add account</button>
</div>
</div>
}
<div class="form-group">
<label asp-for="Enabled"></label>
<input asp-for="Enabled" type="checkbox" class="form-check"/>
<span asp-validation-for="Enabled" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SettlementConfirmationThresholdChoice" class="form-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/Stores/#consider-the-invoice-confirmed-when-the-payment-transaction" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
<vc:icon symbol="info" />
</a>
<select
asp-for="SettlementConfirmationThresholdChoice"
asp-items="Html.GetEnumSelectList<MoneroLikeSettlementThresholdChoice>()"
class="form-select w-auto"
onchange="
document.getElementById('unconfirmed-warning').hidden = this.value !== '@((int)MoneroLikeSettlementThresholdChoice.ZeroConfirmation)';
document.getElementById('custom-confirmation-value').hidden = this.value !== '@((int)MoneroLikeSettlementThresholdChoice.Custom)';">
</select>
<span asp-validation-for="SettlementConfirmationThresholdChoice" class="text-danger"></span>
<p class="info-note my-3 text-warning" id="unconfirmed-warning" role="alert" hidden="@(Model.SettlementConfirmationThresholdChoice is not MoneroLikeSettlementThresholdChoice.ZeroConfirmation)">
<vc:icon symbol="warning" />
<span text-translate="true">Choosing to accept an unconfirmed invoice can lead to double-spending and is strongly discouraged.</span>
</p>
</div>
<div class="form-group" id="custom-confirmation-value" hidden="@(Model.SettlementConfirmationThresholdChoice is not MoneroLikeSettlementThresholdChoice.Custom)">
<label asp-for="CustomSettlementConfirmationThreshold" class="form-label"></label>
<input
asp-for="CustomSettlementConfirmationThreshold"
type="number"
value="@(Model.CustomSettlementConfirmationThreshold)"
class="form-control w-auto"
min="0"
max="100"
pattern="\d+"
/>
<span asp-validation-for="CustomSettlementConfirmationThreshold" class="text-danger"></span>
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary" id="SaveButton">Save</button>
<a class="btn btn-secondary" asp-action="GetStoreMoneroLikePaymentMethods"
asp-route-storeId="@Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@Context.GetRouteValue("cryptoCode")"
asp-controller="UIMoneroLikeStore">
Back to list
</a>
</div>
</form>
</div>
</div>
@section PageFootContent {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@@ -0,0 +1,58 @@
@model BTCPayServer.Plugins.Monero.Controllers.UIMoneroLikeStoreController.MoneroLikePaymentMethodListViewModel
@{
ViewData.SetActivePage("Monero Settings", StringLocalizer["{0} Settings", "Monero"], "Monero Settings");
Layout = "_Layout";
}
<div class="row">
<div class="col-md-8">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All"></div>
}
<div class="table-responsive-md">
<table class="table table-hover">
<thead>
<tr>
<th text-translate="true">Crypto</th>
<th text-translate="true">Account Index</th>
<th class="text-center" text-translate="true">Enabled</th>
<th class="text-right" text-translate="true">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Items)
{
<tr>
<td>@item.CryptoCode</td>
<td>@item.AccountIndex</td>
<td class="text-center">
@if (item.Enabled)
{
<vc:icon symbol="checkmark" css-class="text-success" />
}
else
{
<vc:icon symbol="cross" css-class="text-danger" />
}
</td>
<td class="text-right">
<a id="Modify" asp-action="GetStoreMoneroLikePaymentMethod"
asp-route-storeId="@Context.GetRouteValue("storeId")"
asp-route-cryptoCode="@item.CryptoCode"
text-translate="true">
Modify
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@section PageFootContent {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@@ -0,0 +1,29 @@
@using BTCPayServer
@using BTCPayServer.Data
@using BTCPayServer.Plugins.Monero.Services
@using Microsoft.AspNetCore.Identity
@inject MoneroRPCProvider MoneroRpcProvider
@inject SignInManager<ApplicationUser> 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";
<h5 class="d-flex align-items-center fw-semibold">
<span class="me-2 btcpay-status btcpay-status--@status"></span>
@summary.Key
</h5>
<ul>
<li>Node available: @summary.Value.DaemonAvailable</li>
<li>Wallet available: @summary.Value.WalletAvailable</li>
<li>Last updated: @summary.Value.UpdatedAt</li>
<li>Synced: @summary.Value.Synced (@summary.Value.CurrentHeight / @summary.Value.TargetHeight)</li>
</ul>
}
}
}

View File

@@ -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<ApplicationUser> 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())
{
<a class="nav-link @(isActive ? "active" : string.Empty)" asp-route-storeId="@storeId" asp-action="GetStoreMoneroLikePaymentMethods" asp-controller="UIMoneroLikeStore">Monero</a>
}

View File

@@ -0,0 +1,34 @@
@using BTCPayServer.Plugins.Monero.Configuration
@using BTCPayServer.Plugins.Monero.Controllers
@using BTCPayServer.Abstractions.Contracts
@inject SignInManager<ApplicationUser> 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;
<li class="nav-item">
<a class="nav-link @(isActive? "active" : "")"
asp-route-cryptoCode="@item.CryptoCode"
asp-route-storeId="@storeId"
asp-action="GetStoreMoneroLikePaymentMethod"
asp-controller="UIMoneroLikeStore">
<span class="me-2 btcpay-status btcpay-status--@(item.Enabled ? "enabled" : "pending")"></span>
<span>@item.CryptoCode Wallet</span>
</a>
</li>
}
}

View File

@@ -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())
{
<section>
<h5>Monero Payments</h5>
<table class="table table-hover">
<thead>
<tr>
<th class="w-75px">Payment Method</th>
<th class="w-175px">Destination</th>
<th class="text-nowrap">Payment Proof</th>
<th class="text-end">Confirmations</th>
<th class="w-150px text-end">Paid</th>
</tr>
</thead>
<tbody>
@foreach (var payment in payments)
{
<tr >
<td>@payment.PaymentMethodId</td>
<td><vc:truncate-center text="@payment.DepositAddress" classes="truncate-center-id" /></td>
<td><vc:truncate-center text="@payment.TransactionId" link="@payment.TransactionLink" classes="truncate-center-id" /></td>
<td class="text-end">@payment.Confirmations</td>
<td class="payment-value text-end text-nowrap">
<span data-sensitive class="text-success">@DisplayFormatter.Currency(payment.Amount, payment.Currency)</span>
</td>
</tr>
}
</tbody>
</table>
</section>
}

View File

@@ -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

57
README.md Normal file
View File

@@ -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)

85
btcpay-monero-plugin.sln Normal file
View File

@@ -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

1
btcpayserver Submodule

Submodule btcpayserver added at 29d602b937

142
docker-compose.yml Normal file
View File

@@ -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

BIN
img/Checkout.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB