This commit is contained in:
Andrew Camilleri
2019-09-30 10:32:43 +02:00
committed by Nicolas Dorier
parent 3366c86b16
commit d66b111121
57 changed files with 2165 additions and 12 deletions

View File

@@ -52,6 +52,7 @@ namespace BTCPayServer
public string LightningImagePath { get; set; }
public BTCPayDefaultSettings DefaultSettings { get; set; }
public KeyPath CoinType { get; internal set; }
public Dictionary<uint, DerivationType> ElectrumMapping = new Dictionary<uint, DerivationType>();
public int MaxTrackedConfirmation { get; internal set; } = 6;
@@ -131,7 +132,7 @@ namespace BTCPayServer
public virtual string ToString<T>(T obj)
{
return JsonConvert.SerializeObject(obj);
return NBitcoin.JsonConverters.Serializer.ToString(obj, null);
}
}
}

View File

@@ -56,6 +56,8 @@ namespace BTCPayServer
InitFeathercoin();
InitGroestlcoin();
InitViacoin();
InitMonero();
// Assume that electrum mappings are same as BTC if not specified
foreach (var network in _Networks.Values.OfType<BTCPayNetwork>())
{

View File

@@ -0,0 +1,21 @@
using NBitcoin;
namespace BTCPayServer
{
public partial class BTCPayNetworkProvider
{
public void InitMonero()
{
Add(new MoneroLikeSpecificBtcPayNetwork()
{
CryptoCode = "XMR",
DisplayName = "Monero",
BlockExplorerLink =
NetworkType == NetworkType.Mainnet
? "https://www.exploremonero.com/transaction/{0}"
: "https://testnet.xmrchain.net/tx/{0}",
CryptoImagePath = "/imlegacy/monero.svg"
});
}
}
}

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer
{
public class MoneroLikeSpecificBtcPayNetwork : BTCPayNetworkBase
{
public int MaxTrackedConfirmation = 10;
}
}

View File

@@ -1,4 +1,4 @@
using BTCPayServer.Configuration;
using BTCPayServer.Configuration;
using System.Linq;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Text;

View File

@@ -0,0 +1,28 @@
version: "3"
services:
monerod:
image: kukks/docker-monero:test
restart: unless-stopped
container_name: xmr_monerod
entrypoint: monerod --fixed-difficulty 100 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --non-interactive --block-notify="/scripts/notifier.sh https://127.0.0.1:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --no-sync --offline
volumes:
- "monero_data:/home/monero/.bitmonero"
ports:
- "18081:18081"
monero_wallet:
image: kukks/docker-monero:test
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=127.0.0.1:18081 --wallet-file=/wallet/wallet.keys --tx-notify="/scripts/notifier.sh https://127.0.0.1:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
ports:
- "18082:18082"
volumes:
- "monero_wallet:/wallet"
depends_on:
- monerod
volumes:
monero_data:
monero_wallet:

View File

@@ -179,7 +179,7 @@ namespace BTCPayServer.Controllers
paymentMethodId = store.GetDefaultPaymentId(_NetworkProvider);
isDefaultPaymentId = true;
}
BTCPayNetworkBase network = _NetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
BTCPayNetworkBase network = _NetworkProvider.GetNetwork<BTCPayNetworkBase>(paymentMethodId.CryptoCode);
if (network == null && isDefaultPaymentId)
{
//TODO: need to look into a better way for this as it does not scale

View File

@@ -141,6 +141,7 @@ namespace BTCPayServer.Controllers
.Where(c => c != null))
{
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, invoice.Currency));
//TODO: abstract
if (storeBlob.LightningMaxValue != null)
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, storeBlob.LightningMaxValue.Currency));
if (storeBlob.OnChainMinValue != null)

View File

@@ -21,6 +21,7 @@ using OpenIddict.EntityFrameworkCore.Models;
using System.Net;
using BTCPayServer.Authentication;
using BTCPayServer.Authentication.OpenId;
using BTCPayServer.Monero;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Services.Apps;
using BTCPayServer.Storage;
@@ -48,6 +49,10 @@ namespace BTCPayServer.Hosting
{
Logs.Configure(LoggerFactory);
services.ConfigureBTCPayServer(Configuration);
if (Configuration.AnyMoneroLikeCoinsConfigured())
{
services.AddMoneroLike();
}
services.AddMemoryCache();
services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace BTCPayServer.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; }
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Linq;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using BTCPayServer.Monero.Configuration;
using BTCPayServer.Monero.Payments;
using BTCPayServer.Monero.Services;
using BTCPayServer.Payments;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Monero
{
public static class MoneroLikeExtensions
{
public static bool AnyMoneroLikeCoinsConfigured(this IConfiguration configuration)
{
return configuration.GetOrDefault<string>("chains", "")
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.ToUpperInvariant())
.Any(s => s == "XMR");
}
public static IServiceCollection AddMoneroLike(this IServiceCollection serviceCollection)
{
serviceCollection.AddSingleton(provider =>
provider.ConfigureMoneroLikeConfiguration());
serviceCollection.AddSingleton<MoneroRPCProvider>();
serviceCollection.AddHostedService<MoneroLikeSummaryUpdaterHostedService>();
serviceCollection.AddHostedService<MoneroListener>();
serviceCollection.AddSingleton<MoneroLikePaymentMethodHandler>();
serviceCollection.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<MoneroLikePaymentMethodHandler>());
serviceCollection.AddSingleton<IStoreNavExtension,MoneroStoreNavExtension>();
return serviceCollection;
}
private static MoneroLikeConfiguration ConfigureMoneroLikeConfiguration(this IServiceProvider serviceProvider)
{
var configuration = serviceProvider.GetService<IConfiguration>();
var btcPayNetworkProvider = serviceProvider.GetService<BTCPayNetworkProvider>();
var result = new MoneroLikeConfiguration();
var supportedChains = configuration.GetOrDefault<string>("chains", string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.ToUpperInvariant());
var supportedNetworks = btcPayNetworkProvider.Filter(supportedChains.ToArray()).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);
if (daemonUri == null || walletDaemonUri == null )
{
throw new ConfigException($"{moneroLikeSpecificBtcPayNetwork.CryptoCode} is misconfigured");
}
result.MoneroLikeConfigurationItems.Add(moneroLikeSpecificBtcPayNetwork.CryptoCode, new MoneroLikeConfigurationItem()
{
DaemonRpcUri = daemonUri,
InternalWalletRpcUri = walletDaemonUri,
WalletDirectory = walletDaemonWalletDirectory
});
}
return result;
}
}
}

View File

@@ -0,0 +1,9 @@
using BTCPayServer.Contracts;
namespace BTCPayServer.Monero
{
public class MoneroStoreNavExtension: IStoreNavExtension
{
public string Partial { get; } = "Monero/StoreNavMoneroExtension";
}
}

View File

@@ -0,0 +1,30 @@
using BTCPayServer.Payments;
namespace BTCPayServer.Monero.Payments
{
public class MoneroLikeOnChainPaymentMethodDetails : IPaymentMethodDetails
{
public PaymentType GetPaymentType()
{
return MoneroPaymentType.Instance;
}
public string GetPaymentDestination()
{
return DepositAddress;
}
public decimal GetNextNetworkFee()
{
return NextNetworkFee;
}
public void SetPaymentDestination(string newPaymentDestination)
{
DepositAddress = newPaymentDestination;
}
public long AccountIndex { get; set; }
public long AddressIndex { get; set; }
public string DepositAddress { get; set; }
public decimal NextNetworkFee { get; set; }
}
}

View File

@@ -0,0 +1,67 @@
using BTCPayServer.Data;
using BTCPayServer.Monero.Utils;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Monero.Payments
{
public class MoneroLikePaymentData : CryptoPaymentData
{
public long Amount { get; set; }
public string Address { get; set; }
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 BTCPayNetworkBase Network { get; set; }
public string GetPaymentId()
{
return $"{TransactionId}#{SubaccountIndex}#{SubaddressIndex}";
}
public string[] GetSearchTerms()
{
return new[] {TransactionId};
}
public decimal GetValue()
{
return MoneroMoney.Convert(Amount);
}
public bool PaymentCompleted(PaymentEntity entity)
{
return ConfirmationCount >= (Network as MoneroLikeSpecificBtcPayNetwork).MaxTrackedConfirmation;
}
public bool PaymentConfirmed(PaymentEntity entity, SpeedPolicy speedPolicy)
{
switch (speedPolicy)
{
case SpeedPolicy.HighSpeed:
return ConfirmationCount >= 0;
case SpeedPolicy.MediumSpeed:
return ConfirmationCount >= 1;
case SpeedPolicy.LowMediumSpeed:
return ConfirmationCount >= 2;
case SpeedPolicy.LowSpeed:
return ConfirmationCount >= 6;
default:
return false;
}
}
public PaymentType GetPaymentType()
{
return MoneroPaymentType.Instance;
}
public string GetDestination()
{
return Address;
}
}
}

View File

@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Monero.RPC.Models;
using BTCPayServer.Monero.Services;
using BTCPayServer.Monero.Utils;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using NBitcoin;
namespace BTCPayServer.Monero.Payments
{
public class MoneroLikePaymentMethodHandler : PaymentMethodHandlerBase<MoneroSupportedPaymentMethod, MoneroLikeSpecificBtcPayNetwork>
{
private readonly BTCPayNetworkProvider _networkProvider;
private readonly MoneroRPCProvider _moneroRpcProvider;
public MoneroLikePaymentMethodHandler(BTCPayNetworkProvider networkProvider, MoneroRPCProvider moneroRpcProvider)
{
_networkProvider = networkProvider;
_moneroRpcProvider = moneroRpcProvider;
}
public override PaymentType PaymentType => MoneroPaymentType.Instance;
public override async Task<IPaymentMethodDetails> CreatePaymentMethodDetails(MoneroSupportedPaymentMethod supportedPaymentMethod, PaymentMethod paymentMethod,
StoreData store, MoneroLikeSpecificBtcPayNetwork network, object preparePaymentObject)
{
if (!_moneroRpcProvider.IsAvailable(network.CryptoCode))
throw new PaymentMethodUnavailableException($"Node or wallet not available");
var invoice = paymentMethod.ParentEntity;
if (!(preparePaymentObject is Prepare moneroPrepare)) throw new ArgumentException();
var feeRatePerKb = await moneroPrepare.GetFeeRate;
var address = await moneroPrepare.ReserveAddress(invoice.Id);
var feeRatePerByte = feeRatePerKb.Fee / 1024;
return new MoneroLikeOnChainPaymentMethodDetails()
{
NextNetworkFee = MoneroMoney.Convert(feeRatePerByte * 100),
AccountIndex = supportedPaymentMethod.AccountIndex,
AddressIndex = address.AddressIndex,
DepositAddress = address.Address
};
}
public override object PreparePayment(MoneroSupportedPaymentMethod supportedPaymentMethod, StoreData store,
BTCPayNetworkBase network)
{
var walletClient = _moneroRpcProvider.WalletRpcClients [supportedPaymentMethod.CryptoCode];
var daemonClient = _moneroRpcProvider.DaemonRpcClients [supportedPaymentMethod.CryptoCode];
return 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 })
};
}
class Prepare
{
public Task<GetFeeEstimateResponse> GetFeeRate;
public Func<string, Task<CreateAddressResponse>> ReserveAddress;
}
public override void PreparePaymentModel(PaymentModel model, InvoiceResponse invoiceResponse, StoreBlob storeBlob)
{
var paymentMethodId = new PaymentMethodId(model.CryptoCode, PaymentType);
var client = _moneroRpcProvider.WalletRpcClients[model.CryptoCode];
var cryptoInfo = invoiceResponse.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var network = _networkProvider.GetNetwork<MoneroLikeSpecificBtcPayNetwork>(model.CryptoCode);
model.IsLightning = false;
model.PaymentMethodName = GetPaymentMethodName(network);
model.CryptoImage = GetCryptoImage(network);
model.InvoiceBitcoinUrl = client.SendCommandAsync<MakeUriRequest, MakeUriResponse>("make_uri", new MakeUriRequest()
{
Address = cryptoInfo.Address,
Amount = LightMoney.Parse(cryptoInfo.Due).MilliSatoshi
}).GetAwaiter()
.GetResult().Uri;
model.InvoiceBitcoinUrlQR = model.InvoiceBitcoinUrl;
}
public override string GetCryptoImage(PaymentMethodId paymentMethodId)
{
var network = _networkProvider.GetNetwork<MoneroLikeSpecificBtcPayNetwork>(paymentMethodId.CryptoCode);
return GetCryptoImage(network);
}
public override string GetPaymentMethodName(PaymentMethodId paymentMethodId)
{
var network = _networkProvider.GetNetwork<MoneroLikeSpecificBtcPayNetwork>(paymentMethodId.CryptoCode);
return GetPaymentMethodName(network);
}
public override Task<string> IsPaymentMethodAllowedBasedOnInvoiceAmount(StoreBlob storeBlob, Dictionary<CurrencyPair, Task<RateResult>> rate, Money amount,
PaymentMethodId paymentMethodId)
{
return Task.FromResult<string>(null);
}
public override IEnumerable<PaymentMethodId> GetSupportedPaymentMethods()
{
return _networkProvider.GetAll()
.Where(network => network is MoneroLikeSpecificBtcPayNetwork)
.Select(network => new PaymentMethodId(network.CryptoCode, PaymentType));
}
private string GetCryptoImage(MoneroLikeSpecificBtcPayNetwork network)
{
return network.CryptoImagePath;
}
private string GetPaymentMethodName(MoneroLikeSpecificBtcPayNetwork network)
{
return $"{network.DisplayName}";
}
}
}

View File

@@ -0,0 +1,42 @@
using System.Globalization;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Monero.Payments
{
public class MoneroPaymentType: PaymentType
{
public static MoneroPaymentType Instance { get; } = new MoneroPaymentType();
public override string ToPrettyString() => "On-Chain";
public override string GetId()=> "MoneroLike";
public override CryptoPaymentData DeserializePaymentData(string str)
{
#pragma warning disable CS0618
return JsonConvert.DeserializeObject<MoneroLikePaymentData>(str);
#pragma warning restore CS0618
}
public override IPaymentMethodDetails DeserializePaymentMethodDetails(string str)
{
return JsonConvert.DeserializeObject<MoneroLikeOnChainPaymentMethodDetails>(str);
}
public override ISupportedPaymentMethod DeserializeSupportedPaymentMethod(BTCPayNetworkBase network, JToken value)
{
return JsonConvert.DeserializeObject<MoneroSupportedPaymentMethod>(value.ToString());
}
public override string GetTransactionLink(BTCPayNetworkBase network, string txId)
{
return string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId);
}
public override string InvoiceViewPaymentPartialName { get; }= "Monero/ViewMoneroLikePaymentData";
}
}

View File

@@ -0,0 +1,12 @@
using BTCPayServer.Payments;
namespace BTCPayServer.Monero.Payments
{
public class MoneroSupportedPaymentMethod : ISupportedPaymentMethod
{
public string CryptoCode { get; set; }
public long AccountIndex { get; set; }
public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, MoneroPaymentType.Instance);
}
}

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.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}")));
var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
rawResult.EnsureSuccessStatusCode();
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;
}
internal 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.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.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.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.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,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.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.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.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.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.Monero.RPC.Models
{
public partial class GetHeightResponse
{
[JsonProperty("height")] public long Height { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using Newtonsoft.Json;
namespace BTCPayServer.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,32 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.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("fee")] public long Fee { 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.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,36 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.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("fee")] public long Fee { 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.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.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.Monero.RPC.Models
{
public partial class MakeUriResponse
{
[JsonProperty("uri")] public string Uri { get; set; }
}
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Globalization;
using Newtonsoft.Json;
namespace BTCPayServer.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.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.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.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,13 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Monero.RPC.Models
{
public partial class SyncInfoResponse
{
[JsonProperty("height")] public long Height { get; set; }
[JsonProperty("peers")] public List<Peer> Peers { get; set; }
[JsonProperty("status")] public string Status { get; set; }
[JsonProperty("target_height")] public long? TargetHeight { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Monero.RPC
{
[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,15 @@
namespace BTCPayServer.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,66 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Logging;
using BTCPayServer.Monero.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Monero.Services
{
public class MoneroLikeSummaryUpdaterHostedService: IHostedService
{
private readonly MoneroRPCProvider _MoneroRpcProvider;
private readonly MoneroLikeConfiguration _moneroLikeConfiguration;
private CancellationTokenSource _Cts;
public MoneroLikeSummaryUpdaterHostedService(MoneroRPCProvider moneroRpcProvider, MoneroLikeConfiguration moneroLikeConfiguration)
{
_MoneroRpcProvider = moneroRpcProvider;
_moneroLikeConfiguration = moneroLikeConfiguration;
}
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,360 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Monero.Configuration;
using BTCPayServer.Monero.Payments;
using BTCPayServer.Monero.RPC;
using BTCPayServer.Monero.RPC.Models;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBXplorer;
namespace BTCPayServer.Monero.Services
{
public class MoneroListener : IHostedService
{
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 CompositeDisposable leases = new CompositeDisposable();
private CancellationTokenSource _Cts;
public MoneroListener(InvoiceRepository invoiceRepository,
EventAggregator eventAggregator,
MoneroRPCProvider moneroRpcProvider,
MoneroLikeConfiguration moneroLikeConfiguration,
BTCPayNetworkProvider networkProvider,
ILogger<MoneroListener> logger)
{
_invoiceRepository = invoiceRepository;
_eventAggregator = eventAggregator;
_moneroRpcProvider = moneroRpcProvider;
_MoneroLikeConfiguration = moneroLikeConfiguration;
_networkProvider = networkProvider;
_logger = logger;
}
public Task StartAsync(CancellationToken cancellationToken)
{
if (!_MoneroLikeConfiguration.MoneroLikeConfigurationItems.Any())
{
return Task.CompletedTask;
}
_Cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
leases.Add(_eventAggregator.Subscribe<MoneroEvent>(OnMoneroEvent));
leases.Add(_eventAggregator.Subscribe<MoneroRPCProvider.MoneroDaemonStateChange>(e =>
{
if (_moneroRpcProvider.IsAvailable(e.CryptoCode))
{
_logger.LogInformation($"{e.CryptoCode} just became available");
_ = UpdateAnyPendingMoneroLikePayment(e.CryptoCode);
}
else
{
_logger.LogInformation($"{e.CryptoCode} just became unavailable");
}
}));
return Task.CompletedTask;
}
private void OnMoneroEvent(MoneroEvent obj)
{
if (!_moneroRpcProvider.IsAvailable(obj.CryptoCode))
{
return;
}
if (!string.IsNullOrEmpty(obj.BlockHash))
{
OnNewBlock(obj.CryptoCode);
}
if (!string.IsNullOrEmpty(obj.TransactionHash))
{
_ = OnTransactionUpdated(obj.CryptoCode, obj.TransactionHash);
}
}
private async Task ReceivedPayment(InvoiceEntity invoice, PaymentEntity payment)
{
_logger.LogInformation(
$"Invoice {invoice.Id} received payment {payment.GetCryptoPaymentData().GetValue()} {payment.GetCryptoCode()} {payment.GetCryptoPaymentData().GetPaymentId()}");
var paymentData = (MoneroLikePaymentData)payment.GetCryptoPaymentData();
var paymentMethod = invoice.GetPaymentMethod(payment.Network, MoneroPaymentType.Instance);
if (paymentMethod != null &&
paymentMethod.GetPaymentMethodDetails() is MoneroLikeOnChainPaymentMethodDetails monero &&
monero.GetPaymentDestination() == paymentData.GetDestination() &&
paymentMethod.Calculate().Due > Money.Zero)
{
var walletClient = _moneroRpcProvider.WalletRpcClients[payment.GetCryptoCode()];
var address = await walletClient.SendCommandAsync<CreateAddressRequest, CreateAddressResponse>(
"create_address",
new CreateAddressRequest()
{
Label = $"btcpay invoice #{invoice.Id}", AccountIndex = monero.AccountIndex
});
monero.DepositAddress = address.Address;
monero.AddressIndex = address.AddressIndex;
await _invoiceRepository.NewAddress(invoice.Id, monero, payment.Network);
_eventAggregator.Publish(
new InvoiceNewAddressEvent(invoice.Id, address.Address, payment.Network));
paymentMethod.SetPaymentMethodDetails(monero);
invoice.SetPaymentMethod(paymentMethod);
}
_eventAggregator.Publish(
new InvoiceEvent(invoice, 1002, 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);
//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),
PaymentMethodDetails: entity.GetPaymentMethod(network, MoneroPaymentType.Instance)
.GetPaymentMethodDetails() as MoneroLikeOnChainPaymentMethodDetails))
.Select(tuple => (
tuple.Invoice,
tuple.PaymentMethodDetails,
ExistingPayments: tuple.ExistingPayments.Select(entity =>
(Payment: entity, PaymentData: (MoneroLikePaymentData)entity.GetCryptoPaymentData(),
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 BlockingCollection<(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.PaymentData.Address == transfer.Address &&
tuple.PaymentData.TransactionId == transfer.Txid);
if (existingMatch.Invoice != null)
{
invoice = existingMatch.Invoice;
}
else
{
var newMatch = expandedInvoices.SingleOrDefault(tuple =>
tuple.PaymentMethodDetails.GetPaymentDestination() == transfer.Address);
if (newMatch.Invoice == null)
{
return Task.CompletedTask;
}
invoice = newMatch.Invoice;
}
return HandlePaymnetData(cryptoCode, transfer.Address, transfer.Amount, transfer.SubaddrIndex.Major,
transfer.SubaddrIndex.Minor, transfer.Txid, transfer.Confirmations, transfer.Height, invoice,
updatedPaymentEntities);
}));
}
transferProcessingTasks.Add(
_invoiceRepository.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));
}
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
leases.Dispose();
_Cts.Cancel();
return Task.CompletedTask;
}
private void OnNewBlock(string cryptoCode)
{
_ = UpdateAnyPendingMoneroLikePayment(cryptoCode);
_eventAggregator.Publish(new NewBlockEvent() {CryptoCode = cryptoCode});
}
private async Task OnTransactionUpdated(string cryptoCode, string transactionHash)
{
var paymentMethodId = new PaymentMethodId(cryptoCode, MoneroPaymentType.Instance);
var transfer = await _moneroRpcProvider.WalletRpcClients[cryptoCode]
.SendCommandAsync<GetTransferByTransactionIdRequest, GetTransferByTransactionIdResponse>(
"get_transfer_by_txid",
new GetTransferByTransactionIdRequest() {TransactionId = transactionHash});
var paymentsToUpdate = new BlockingCollection<(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 address = destination.Key + "#" + paymentMethodId;
var invoice = (await _invoiceRepository.GetInvoicesFromAddresses(new[] {address})).FirstOrDefault();
if (invoice == null)
{
continue;
}
var index = destination.First().SubaddrIndex;
await HandlePaymnetData(cryptoCode,
destination.Key,
destination.Sum(destination1 => destination1.Amount),
index.Major,
index.Minor,
transfer.Transfer.Txid,
transfer.Transfer.Confirmations,
transfer.Transfer.Height
, invoice, paymentsToUpdate);
}
if (paymentsToUpdate.Any())
{
await _invoiceRepository.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 HandlePaymnetData(string cryptoCode, string address, long totalAmount, long subaccountIndex,
long subaddressIndex,
string txId, long confirmations, long blockHeight, InvoiceEntity invoice,
BlockingCollection<(PaymentEntity Payment, InvoiceEntity invoice)> paymentsToUpdate)
{
//construct the payment data
var paymentData = new MoneroLikePaymentData()
{
Address = address,
SubaccountIndex = subaccountIndex,
SubaddressIndex = subaddressIndex,
TransactionId = txId,
ConfirmationCount = confirmations,
Amount = totalAmount,
BlockHeight = blockHeight,
Network = _networkProvider.GetNetwork(cryptoCode)
};
//check if this tx exists as a payment to this invoice already
var alreadyExistingPaymentThatMatches = GetAllMoneroLikePayments(invoice, cryptoCode)
.Select(entity => (Payment: entity, PaymentData: entity.GetCryptoPaymentData()))
.SingleOrDefault(c => c.PaymentData.GetPaymentId() == paymentData.GetPaymentId());
//if it doesnt, add it and assign a new monerolike address to the system if a balance is still due
if (alreadyExistingPaymentThatMatches.Payment == null)
{
var payment = await _invoiceRepository.AddPayment(invoice.Id, DateTimeOffset.UtcNow,
paymentData, _networkProvider.GetNetwork<MoneroLikeSpecificBtcPayNetwork>(cryptoCode), true);
if (payment != null)
await ReceivedPayment(invoice, payment);
}
else
{
//else update it with the new data
alreadyExistingPaymentThatMatches.PaymentData = paymentData;
alreadyExistingPaymentThatMatches.Payment.SetCryptoPaymentData(paymentData);
paymentsToUpdate.Add((alreadyExistingPaymentThatMatches.Payment, invoice));
}
}
private async Task UpdateAnyPendingMoneroLikePayment(string cryptoCode)
{
var invoiceIds =
await GetPendingInvoicesWithPaymentMethodOption(new PaymentMethodId(cryptoCode,
MoneroPaymentType.Instance));
if (!invoiceIds.Any())
{
return;
}
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery() {InvoiceId = invoiceIds});
_logger.LogInformation($"Updating pending payments for {cryptoCode} in {string.Join(',', invoiceIds)}");
await UpdatePaymentStates(cryptoCode, invoices);
}
private async Task<string[]> GetPendingInvoicesWithPaymentMethodOption(PaymentMethodId paymentMethodId)
{
return await _invoiceRepository.GetPendingInvoices(pendingInvoice =>
pendingInvoice.Where(data => data.InvoiceData.AddressInvoices.Any(invoiceData =>
invoiceData.GetpaymentMethodId() != null && invoiceData.GetpaymentMethodId() == paymentMethodId)));
}
private IEnumerable<PaymentEntity> GetAllMoneroLikePayments(InvoiceEntity invoice, string cryptoCode)
{
return invoice.GetPayments()
.Where(p => p.GetPaymentMethodId() == new PaymentMethodId(cryptoCode, MoneroPaymentType.Instance));
}
}
}

View File

@@ -0,0 +1,118 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Monero.Configuration;
using BTCPayServer.Monero.RPC;
using BTCPayServer.Monero.RPC.Models;
using NBitcoin;
namespace BTCPayServer.Monero.Services
{
public class MoneroRPCProvider
{
private readonly MoneroLikeConfiguration _moneroLikeConfiguration;
private readonly EventAggregator _eventAggregator;
public ImmutableDictionary<string, JsonRpcClient> DaemonRpcClients;
public ImmutableDictionary<string, JsonRpcClient> WalletRpcClients;
private ConcurrentDictionary<string, MoneroLikeSummary> _summaries =
new ConcurrentDictionary<string, MoneroLikeSummary>();
public ConcurrentDictionary<string, MoneroLikeSummary> Summaries => _summaries;
public MoneroRPCProvider(MoneroLikeConfiguration moneroLikeConfiguration, EventAggregator eventAggregator, IHttpClientFactory httpClientFactory)
{
_moneroLikeConfiguration = moneroLikeConfiguration;
_eventAggregator = eventAggregator;
DaemonRpcClients =
_moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key,
pair => new JsonRpcClient(pair.Value.DaemonRpcUri, "", "", httpClientFactory.CreateClient()));
WalletRpcClients =
_moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key,
pair => new JsonRpcClient(pair.Value.InternalWalletRpcUri, "", "", httpClientFactory.CreateClient()));
}
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, SyncInfoResponse>("sync_info",
JsonRpcClient.NoRequestModel.Instance);
summary.TargetHeight = daemonResult.TargetHeight ?? daemonResult.Height;
summary.Synced = !daemonResult.TargetHeight.HasValue ||
(daemonResult.Height >= daemonResult.TargetHeight && daemonResult.TargetHeight > 0);
summary.CurrentHeight = daemonResult.Height;
summary.UpdatedAt = DateTime.Now;
summary.DaemonAvailable = true;
}
catch
{
summary.DaemonAvailable = false;
}
try
{
var walletResult =
await walletRpcClient.SendCommandAsync<JsonRpcClient.NoRequestModel, GetHeightResponse>(
"get_height", JsonRpcClient.NoRequestModel.Instance);
summary.WalletHeight = walletResult.Height;
summary.WalletAvailable = true;
}
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,301 @@
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.Data;
using BTCPayServer.Models;
using BTCPayServer.Monero.Configuration;
using BTCPayServer.Monero.Payments;
using BTCPayServer.Monero.RPC.Models;
using BTCPayServer.Monero.Services;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Monero.UI
{
[Route("stores/{storeId}/monerolike")]
[Authorize(AuthenticationSchemes = Policies.CookieAuthentication)]
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = Policies.CookieAuthentication)]
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = Policies.CookieAuthentication)]
public class MoneroLikeStoreController : Controller
{
private readonly MoneroLikeConfiguration _MoneroLikeConfiguration;
private readonly StoreRepository _StoreRepository;
private readonly MoneroRPCProvider _MoneroRpcProvider;
private readonly BTCPayNetworkProvider _BtcPayNetworkProvider;
public MoneroLikeStoreController(MoneroLikeConfiguration moneroLikeConfiguration,
StoreRepository storeRepository, MoneroRPCProvider moneroRpcProvider,
BTCPayNetworkProvider btcPayNetworkProvider)
{
_MoneroLikeConfiguration = moneroLikeConfiguration;
_StoreRepository = storeRepository;
_MoneroRpcProvider = moneroRpcProvider;
_BtcPayNetworkProvider = btcPayNetworkProvider;
}
public StoreData StoreData => HttpContext.GetStoreData();
[HttpGet()]
public async Task<IActionResult> GetStoreMoneroLikePaymentMethods(string statusMessage)
{
var monero = StoreData.GetSupportedPaymentMethods(_BtcPayNetworkProvider)
.OfType<MoneroSupportedPaymentMethod>();
var excludeFilters = StoreData.GetStoreBlob().GetExcludedPaymentMethods();
var accountsList = _MoneroLikeConfiguration.MoneroLikeConfigurationItems.ToDictionary(pair => pair.Key,
pair => GetAccounts(pair.Key));
await Task.WhenAll(accountsList.Values);
return View(new MoneroLikePaymentMethodListViewModel()
{
StatusMessage = statusMessage,
Items = _MoneroLikeConfiguration.MoneroLikeConfigurationItems.Select(pair =>
GetMoneroLikePaymentMethodViewModel(monero, 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(
IEnumerable<MoneroSupportedPaymentMethod> monero, string cryptoCode,
IPaymentFilter excludeFilters, GetAccountsResponse accountsResponse)
{
var settings = monero.SingleOrDefault(method => method.CryptoCode == cryptoCode);
_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)));
return new MoneroLikePaymentMethodViewModel()
{
WalletFileFound = System.IO.File.Exists(fileAddress),
Enabled =
settings != null &&
!excludeFilters.Match(new PaymentMethodId(cryptoCode, MoneroPaymentType.Instance)),
Summary = summary,
CryptoCode = cryptoCode,
AccountIndex = settings?.AccountIndex ?? accountsResponse?.SubaddressAccounts?.FirstOrDefault()?.AccountIndex?? (long)0,
Accounts = accounts == null? null : new SelectList(accounts, nameof(SelectListItem.Value),
nameof(SelectListItem.Text))
};
}
[HttpGet("{cryptoCode}")]
public async Task<IActionResult> GetStoreMoneroLikePaymentMethod(string cryptoCode, string statusMessage = null)
{
cryptoCode = cryptoCode.ToUpperInvariant();
if (!_MoneroLikeConfiguration.MoneroLikeConfigurationItems.ContainsKey(cryptoCode))
{
return NotFound();
}
var vm = GetMoneroLikePaymentMethodViewModel(StoreData.GetSupportedPaymentMethods(_BtcPayNetworkProvider)
.OfType<MoneroSupportedPaymentMethod>(), cryptoCode,
StoreData.GetStoreBlob().GetExcludedPaymentMethods(), await GetAccounts(cryptoCode));
vm.StatusMessage = statusMessage;
return View(nameof(GetStoreMoneroLikePaymentMethod), vm);
}
[HttpPost("{cryptoCode}")]
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), "Could not create new account.");
}
}else if (command == "upload-wallet")
{
var valid = true;
if (viewModel.WalletFile == null)
{
ModelState.AddModelError(nameof(viewModel.WalletFile), "Please select the wallet file");
valid = false;
}
if (viewModel.WalletKeysFile == null)
{
ModelState.AddModelError(nameof(viewModel.WalletKeysFile), "Please select the wallet.keys file");
valid = false;
}
if(valid)
{
if(_MoneroRpcProvider.Summaries.TryGetValue(cryptoCode, out var summary))
{
if (summary.WalletAvailable)
{
return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod),
new {cryptoCode, StatusMessage = new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = $"There is already an active wallet configured for {cryptoCode}. Replacing it would break any existing invoices"
}.ToString()});
}
}
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
{
}
}
return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod), new
{
cryptoCode,
StatusMessage ="Wallet files uploaded. If it was valid, the wallet will become available soon"
});
}
}
if (!ModelState.IsValid)
{
var vm = GetMoneroLikePaymentMethodViewModel(StoreData
.GetSupportedPaymentMethods(_BtcPayNetworkProvider)
.OfType<MoneroSupportedPaymentMethod>(), cryptoCode,
StoreData.GetStoreBlob().GetExcludedPaymentMethods(), await GetAccounts(cryptoCode));
vm.Enabled = viewModel.Enabled;
vm.NewAccountLabel = viewModel.NewAccountLabel;
vm.AccountIndex = viewModel.AccountIndex;
return View(vm);
}
var storeData = StoreData;
var blob = storeData.GetStoreBlob();
storeData.SetSupportedPaymentMethod(new MoneroSupportedPaymentMethod()
{
AccountIndex = viewModel.AccountIndex,
CryptoCode = viewModel.CryptoCode
});
blob.SetExcluded(new PaymentMethodId(viewModel.CryptoCode, MoneroPaymentType.Instance), !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}\""
}
};
process.Start();
process.WaitForExit();
}
public class MoneroLikePaymentMethodListViewModel
{
public string StatusMessage { get; set; }
public IEnumerable<MoneroLikePaymentMethodViewModel> Items { get; set; }
}
public class MoneroLikePaymentMethodViewModel
{
public string StatusMessage { get; set; }
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; }
public IFormFile WalletKeysFile { get; set; }
public string WalletPassword { get; set; }
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace BTCPayServer.Monero.UI
{
public class MoneroPaymentViewModel
{
public string Crypto { 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; }
}
}

View File

@@ -0,0 +1,20 @@
using System.Globalization;
namespace BTCPayServer.Monero.Utils
{
public class MoneroMoney
{
public static decimal Convert(long atoms)
{
var amt = atoms.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);
}
}
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Monero.Payments;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json.Linq;
@@ -33,6 +34,9 @@ namespace BTCPayServer.Payments
case "offchain":
type = PaymentTypes.LightningLike;
break;
case "monerolike":
type = MoneroPaymentType.Instance;
break;
default:
type = null;
return false;

View File

@@ -46,6 +46,35 @@
"BTCPAY_SSHPASSWORD": "opD3i2282D"
},
"applicationUrl": "https://localhost:14142/"
},
"Docker-Regtest-https-monero": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",
"BTCPAY_HttpsUseDefaultCertificate": "true",
"BTCPAY_BUNDLEJSCSS": "false",
"BTCPAY_LTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_BTCLIGHTNING": "type=charge;server=http://127.0.0.1:54938/;api-token=foiewnccewuify",
"BTCPAY_BTCEXTERNALLNDGRPC": "type=lnd-grpc;server=https://lnd:lnd@127.0.0.1:53280/;allowinsecure=true",
"BTCPAY_BTCEXTERNALLNDREST": "type=lnd-rest;server=https://lnd:lnd@127.0.0.1:53280/lnd-rest/btc/;allowinsecure=true",
"BTCPAY_BTCEXTERNALSPARK": "server=/spark/btc/;cookiefile=fake",
"BTCPAY_BTCEXTERNALCHARGE": "server=https://127.0.0.1:53280/mycharge/btc/;cookiefilepath=fake",
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,xmr",
"BTCPAY_POSTGRES": "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_EXTERNALSERVICES": "totoservice:totolink;",
"BTCPAY_XMR_DAEMON_URI": "http://127.0.0.1:18081",
"BTCPAY_XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082",
"BTCPAY_XMR_WALLET_DAEMON_WALLETDIR": "C:/ProgramData/bitmonero/wallet"
},
"applicationUrl": "https://localhost:14142/"
}
}
}

View File

@@ -0,0 +1,111 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Views.Stores
@model BTCPayServer.Monero.UI.MoneroLikeStoreController.MoneroLikePaymentMethodViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../Stores/_Nav";
ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, $"{Model.CryptoCode} Settings");
}
<partial name="_StatusMessage" for="StatusMessage"/>
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
@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(long))
{
<form method="post" asp-action="GetStoreMoneroLikePaymentMethod" 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"></label>
<input class="form-control-file" asp-for="WalletFile" required>
<span asp-validation-for="WalletFile" class="text-danger"></span>
</div>
<div class="form-group p-2">
<label asp-for="WalletKeysFile"></label>
<input class="form-control-file" asp-for="WalletKeysFile" required>
<span asp-validation-for="WalletKeysFile" class="text-danger"></span>
</div>
<div class="form-group p-2">
<label asp-for="WalletPassword"></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" class="mt-4" enctype="multipart/form-data">
<input type="hidden" asp-for="CryptoCode"/>
@if (!Model.WalletFileFound || Model.Summary.WalletHeight == default(long))
{
<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="New account label" asp-for="NewAccountLabel">
<div class="input-group-append">
<button name="command" value="add-account" class="btn btn-secondary"type="submit">Add account</button>
</div>
</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">
<button type="submit" class="btn btn-primary" id="SaveButton">Save</button>
<a class="btn btn-secondary" asp-action="GetStoreMoneroLikePaymentMethods" asp-controller="MoneroLikeStore">Back to list</a>
</div>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@@ -0,0 +1,61 @@
@using BTCPayServer.Views.Stores
@model BTCPayServer.Monero.UI.MoneroLikeStoreController.MoneroLikePaymentMethodListViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.ActivePage, "Monero Settings");
ViewData["NavPartialName"] = "../Stores/_Nav";
}
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="form-group">
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th>Crypto</th>
<th>Account Index</th>
<th class="text-center">Enabled</th>
<th class="text-right">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)
{
<span class="fa fa-check"></span>
}
else
{
<span class="fa fa-times"></span>
}
</td>
<td class="text-right">
<a id="Modify" asp-action="GetStoreMoneroLikePaymentMethod" asp-route-cryptoCode="@item.CryptoCode">Modify</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@@ -0,0 +1,13 @@
@using BTCPayServer.Controllers
@using BTCPayServer.Monero.Configuration
@using BTCPayServer.Monero.UI
@inject SignInManager<ApplicationUser> SignInManager;
@inject MoneroLikeConfiguration MoneroLikeConfiguration;
@{
var controller = ViewContext.RouteData.Values["Controller"].ToString();
var isMonero = controller.Equals(nameof(MoneroLikeStoreController), StringComparison.InvariantCultureIgnoreCase);
}
@if (SignInManager.IsSignedIn(User) && User.IsInRole(Roles.ServerAdmin) && MoneroLikeConfiguration.MoneroLikeConfigurationItems.Any())
{
<a class="nav-link @(isMonero ? "active" : string.Empty)" asp-action="GetStoreMoneroLikePaymentMethods" asp-controller="MoneroLikeStore">Monero</a>
}

View File

@@ -0,0 +1,67 @@
@using System.Globalization
@using BTCPayServer.Controllers
@using BTCPayServer.Monero.Payments
@using BTCPayServer.Monero.UI
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
@{
var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == MoneroPaymentType.Instance).Select(payment =>
{
var m = new MoneroPaymentViewModel();
var onChainPaymentData = payment.GetCryptoPaymentData() as MoneroLikePaymentData;
m.Crypto = payment.GetPaymentMethodId().CryptoCode;
m.DepositAddress = onChainPaymentData.GetDestination();
var confirmationCount = onChainPaymentData.ConfirmationCount;
var network = payment.Network as MoneroLikeSpecificBtcPayNetwork;
if (confirmationCount >= network.MaxTrackedConfirmation)
{
m.Confirmations = "At least " + (network.MaxTrackedConfirmation);
}
else
{
m.Confirmations = confirmationCount.ToString(CultureInfo.InvariantCulture);
}
m.TransactionId = onChainPaymentData.TransactionId;
m.ReceivedTime = payment.ReceivedTime;
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId);
return m;
});
}
@if (onchainPayments.Any())
{
<div class="row">
<div class="col-md-12 invoice-payments">
<h3>Monero payments</h3>
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th>Crypto</th>
<th>Deposit address</th>
<th>Transaction Id</th>
<th class="text-right">Confirmations</th>
</tr>
</thead>
<tbody>
@foreach (var payment in onchainPayments)
{
<tr >
<td>@payment.Crypto</td>
<td>@payment.DepositAddress</td>
<td>
<div class="wraptextAuto">
<a href="@payment.TransactionLink" target="_blank">
@payment.TransactionId
</a>
</div>
</td>
<td class="text-right">@payment.Confirmations</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

View File

@@ -1,10 +1,10 @@
<div class="nav flex-column nav-pills">
<a id="@(nameof(StoreNavPages.Index))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Index)" asp-action="UpdateStore">General settings</a>
<a id="@(nameof(StoreNavPages.Rates))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Rates)" asp-action="Rates">Rates</a>
<a id="@(nameof(StoreNavPages.Checkout))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Checkout)" asp-action="CheckoutExperience">Checkout experience</a>
<a id="@(nameof(StoreNavPages.Tokens))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-action="ListTokens">Access Tokens</a>
<a id="@(nameof(StoreNavPages.Users))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-action="StoreUsers">Users</a>
<a id="@(nameof(StoreNavPages.PayButton))"class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)" asp-action="PayButton">Pay Button</a>
<div class="nav flex-column nav-pills">
<a id="@(nameof(StoreNavPages.Index))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Index)" asp-controller="Stores" asp-action="UpdateStore">General settings</a>
<a id="@(nameof(StoreNavPages.Rates))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Rates)" asp-controller="Stores" asp-action="Rates">Rates</a>
<a id="@(nameof(StoreNavPages.Checkout))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Checkout)" asp-controller="Stores" asp-action="CheckoutExperience">Checkout experience</a>
<a id="@(nameof(StoreNavPages.Tokens))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-controller="Stores" asp-action="ListTokens">Access Tokens</a>
<a id="@(nameof(StoreNavPages.Users))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="Stores" asp-action="StoreUsers">Users</a>
<a id="@(nameof(StoreNavPages.PayButton))"class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)"asp-controller="Stores" asp-action="PayButton">Pay Button</a>
@inject IEnumerable<BTCPayServer.Contracts.IStoreNavExtension> Extensions;
@foreach (var extension in Extensions)
{

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<circle style="fill:#F0EFEB;" cx="256" cy="256" r="256"/>
<path style="fill:#4C4C4C;" d="M364.2,393.163h107.979c-45.411,71.439-125.262,118.836-216.178,118.836
S85.235,464.603,39.824,393.163h107.969V257.328l108.209,108.146L364.2,257.328V393.163z"/>
<path style="fill:#FF6600;" d="M512,256.001c0,28.599-4.692,56.1-13.343,81.784H421.21V122.537L256.002,286.062L90.794,122.537
v215.248H13.346C4.694,312.102,0.003,284.6,0.003,256.001c0-141.384,114.614-255.998,255.998-255.998S512,114.616,512,256.001z"/>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 995 B