(feat) monero settlement thresholds (#5807)

* (bug) treat xmr wallet directory as required

The wallet directory configuration setting is required
because the `UIMoneroLikeStoreController`'s
`GetMoneroLikePaymentMethodViewModel` method checks if the wallet file
exists, and to do that in needs the directory.

* (feat) xmr settlement thresholds

Adds the ability to select zero, 1, 10, or a custom number of
confirmations as the payment settlement threshold.

* (review) fix validation message not showing

---------

Co-authored-by: Henry Hollingworth <henry.hollingworth@alcoa.com>
This commit is contained in:
Henry Hollingworth 2024-03-14 17:31:27 +08:00 committed by GitHub
parent 0e64df3bbf
commit c56c6401d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 112 additions and 7 deletions

View File

@ -73,7 +73,7 @@ namespace BTCPayServer.Services.Altcoins.Monero
var daemonPassword = var daemonPassword =
configuration.GetOrDefault<string>( configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_password", null); $"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_password", null);
if (daemonUri == null || walletDaemonUri == null) if (daemonUri == null || walletDaemonUri == null || walletDaemonWalletDirectory == null)
{ {
throw new ConfigException($"{moneroLikeSpecificBtcPayNetwork.CryptoCode} is misconfigured"); throw new ConfigException($"{moneroLikeSpecificBtcPayNetwork.CryptoCode} is misconfigured");
} }

View File

@ -29,6 +29,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
public long AddressIndex { get; set; } public long AddressIndex { get; set; }
public string DepositAddress { get; set; } public string DepositAddress { get; set; }
public decimal NextNetworkFee { get; set; } public decimal NextNetworkFee { get; set; }
public long? InvoiceSettledConfirmationThreshold { get; set; }
} }
} }
#endif #endif

View File

@ -16,6 +16,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
public long BlockHeight { get; set; } public long BlockHeight { get; set; }
public long ConfirmationCount { get; set; } public long ConfirmationCount { get; set; }
public string TransactionId { get; set; } public string TransactionId { get; set; }
public long? InvoiceSettledConfirmationThreshold { get; set; }
public BTCPayNetworkBase Network { get; set; } public BTCPayNetworkBase Network { get; set; }
public long LockTime { get; set; } = 0; public long LockTime { get; set; } = 0;
@ -48,6 +49,12 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
{ {
return false; return false;
} }
if (InvoiceSettledConfirmationThreshold.HasValue)
{
return ConfirmationCount >= InvoiceSettledConfirmationThreshold;
}
switch (speedPolicy) switch (speedPolicy)
{ {
case SpeedPolicy.HighSpeed: case SpeedPolicy.HighSpeed:

View File

@ -59,6 +59,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
AccountIndex = supportedPaymentMethod.AccountIndex, AccountIndex = supportedPaymentMethod.AccountIndex,
AddressIndex = address.AddressIndex, AddressIndex = address.AddressIndex,
DepositAddress = address.Address, DepositAddress = address.Address,
InvoiceSettledConfirmationThreshold = supportedPaymentMethod.InvoiceSettledConfirmationThreshold,
Activated = true Activated = true
}; };

View File

@ -9,6 +9,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
public string CryptoCode { get; set; } public string CryptoCode { get; set; }
public long AccountIndex { get; set; } public long AccountIndex { get; set; }
public long? InvoiceSettledConfirmationThreshold { get; set; }
[JsonIgnore] [JsonIgnore]
public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, MoneroPaymentType.Instance); public PaymentMethodId PaymentId => new PaymentMethodId(CryptoCode, MoneroPaymentType.Instance);
} }

View File

@ -324,6 +324,11 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services
string txId, long confirmations, long blockHeight, long locktime, InvoiceEntity invoice, string txId, long confirmations, long blockHeight, long locktime, InvoiceEntity invoice,
BlockingCollection<(PaymentEntity Payment, InvoiceEntity invoice)> paymentsToUpdate) BlockingCollection<(PaymentEntity Payment, InvoiceEntity invoice)> paymentsToUpdate)
{ {
var network = _networkProvider.GetNetwork(cryptoCode);
var moneroPaymentMethodDetails = invoice
.GetPaymentMethod(network, MoneroPaymentType.Instance)
.GetPaymentMethodDetails() as MoneroLikeOnChainPaymentMethodDetails;
//construct the payment data //construct the payment data
var paymentData = new MoneroLikePaymentData() var paymentData = new MoneroLikePaymentData()
{ {
@ -335,7 +340,8 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services
Amount = totalAmount, Amount = totalAmount,
BlockHeight = blockHeight, BlockHeight = blockHeight,
Network = _networkProvider.GetNetwork(cryptoCode), Network = _networkProvider.GetNetwork(cryptoCode),
LockTime = locktime LockTime = locktime,
InvoiceSettledConfirmationThreshold = moneroPaymentMethodDetails.InvoiceSettledConfirmationThreshold
}; };
//check if this tx exists as a payment to this invoice already //check if this tx exists as a payment to this invoice already

View File

@ -104,6 +104,14 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
new SelectListItem( new SelectListItem(
$"{account.AccountIndex} - {(string.IsNullOrEmpty(account.Label) ? "No label" : account.Label)}", $"{account.AccountIndex} - {(string.IsNullOrEmpty(account.Label) ? "No label" : account.Label)}",
account.AccountIndex.ToString(CultureInfo.InvariantCulture))); account.AccountIndex.ToString(CultureInfo.InvariantCulture)));
var settlementThresholdChoice = settings.InvoiceSettledConfirmationThreshold switch
{
null => MoneroLikeSettlementThresholdChoice.StoreSpeedPolicy,
0 => MoneroLikeSettlementThresholdChoice.ZeroConfirmation,
1 => MoneroLikeSettlementThresholdChoice.AtLeastOne,
10 => MoneroLikeSettlementThresholdChoice.AtLeastTen,
_ => MoneroLikeSettlementThresholdChoice.Custom
};
return new MoneroLikePaymentMethodViewModel() return new MoneroLikePaymentMethodViewModel()
{ {
WalletFileFound = System.IO.File.Exists(fileAddress), WalletFileFound = System.IO.File.Exists(fileAddress),
@ -114,7 +122,11 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
CryptoCode = cryptoCode, CryptoCode = cryptoCode,
AccountIndex = settings?.AccountIndex ?? accountsResponse?.SubaddressAccounts?.FirstOrDefault()?.AccountIndex ?? 0, AccountIndex = settings?.AccountIndex ?? accountsResponse?.SubaddressAccounts?.FirstOrDefault()?.AccountIndex ?? 0,
Accounts = accounts == null ? null : new SelectList(accounts, nameof(SelectListItem.Value), Accounts = accounts == null ? null : new SelectList(accounts, nameof(SelectListItem.Value),
nameof(SelectListItem.Text)) nameof(SelectListItem.Text)),
SettlementConfirmationThresholdChoice = settlementThresholdChoice,
CustomSettlementConfirmationThreshold = settlementThresholdChoice is MoneroLikeSettlementThresholdChoice.Custom
? settings.InvoiceSettledConfirmationThreshold
: null
}; };
} }
@ -250,6 +262,8 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
vm.Enabled = viewModel.Enabled; vm.Enabled = viewModel.Enabled;
vm.NewAccountLabel = viewModel.NewAccountLabel; vm.NewAccountLabel = viewModel.NewAccountLabel;
vm.AccountIndex = viewModel.AccountIndex; vm.AccountIndex = viewModel.AccountIndex;
vm.SettlementConfirmationThresholdChoice = viewModel.SettlementConfirmationThresholdChoice;
vm.CustomSettlementConfirmationThreshold = viewModel.CustomSettlementConfirmationThreshold;
return View(vm); return View(vm);
} }
@ -258,7 +272,15 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
storeData.SetSupportedPaymentMethod(new MoneroSupportedPaymentMethod() storeData.SetSupportedPaymentMethod(new MoneroSupportedPaymentMethod()
{ {
AccountIndex = viewModel.AccountIndex, AccountIndex = viewModel.AccountIndex,
CryptoCode = viewModel.CryptoCode CryptoCode = viewModel.CryptoCode,
InvoiceSettledConfirmationThreshold = viewModel.SettlementConfirmationThresholdChoice switch
{
MoneroLikeSettlementThresholdChoice.ZeroConfirmation => 0,
MoneroLikeSettlementThresholdChoice.AtLeastOne => 1,
MoneroLikeSettlementThresholdChoice.AtLeastTen => 10,
MoneroLikeSettlementThresholdChoice.Custom when viewModel.CustomSettlementConfirmationThreshold is { } custom => custom,
_ => null
}
}); });
blob.SetExcluded(new PaymentMethodId(viewModel.CryptoCode, MoneroPaymentType.Instance), !viewModel.Enabled); blob.SetExcluded(new PaymentMethodId(viewModel.CryptoCode, MoneroPaymentType.Instance), !viewModel.Enabled);
@ -297,7 +319,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
public IEnumerable<MoneroLikePaymentMethodViewModel> Items { get; set; } public IEnumerable<MoneroLikePaymentMethodViewModel> Items { get; set; }
} }
public class MoneroLikePaymentMethodViewModel public class MoneroLikePaymentMethodViewModel : IValidatableObject
{ {
public MoneroRPCProvider.MoneroLikeSummary Summary { get; set; } public MoneroRPCProvider.MoneroLikeSummary Summary { get; set; }
public string CryptoCode { get; set; } public string CryptoCode { get; set; }
@ -309,8 +331,39 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
public bool WalletFileFound { get; set; } public bool WalletFileFound { get; set; }
[Display(Name = "View-Only Wallet File")] [Display(Name = "View-Only Wallet File")]
public IFormFile WalletFile { get; set; } public IFormFile WalletFile { get; set; }
[Display(Name = "Wallet Keys File")]
public IFormFile WalletKeysFile { get; set; } public IFormFile WalletKeysFile { get; set; }
[Display(Name = "Wallet Password")]
public string WalletPassword { get; set; } 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

@ -1,6 +1,8 @@
@using BTCPayServer.Views.Stores @using BTCPayServer.Views.Stores
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Services.Altcoins.Monero.UI.UIMoneroLikeStoreController.MoneroLikePaymentMethodViewModel @using MoneroLikePaymentMethodViewModel = BTCPayServer.Services.Altcoins.Monero.UI.UIMoneroLikeStoreController.MoneroLikePaymentMethodViewModel
@using MoneroLikeSettlementThresholdChoice = BTCPayServer.Services.Altcoins.Monero.UI.UIMoneroLikeStoreController.MoneroLikeSettlementThresholdChoice;
@model MoneroLikePaymentMethodViewModel
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
@ -10,7 +12,7 @@
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div asp-validation-summary="All" class="text-danger"></div> <div asp-validation-summary="All"></div>
@if (Model.Summary != null) @if (Model.Summary != null)
{ {
<div class="card"> <div class="card">
@ -92,6 +94,40 @@
<span asp-validation-for="Enabled" class="text-danger"></span> <span asp-validation-for="Enabled" class="text-danger"></span>
</div> </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="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" />
Choosing to accept an unconfirmed invoice can lead to double-spending and is strongly discouraged.
</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"> <div class="form-group">
<button type="submit" class="btn btn-primary" id="SaveButton">Save</button> <button type="submit" class="btn btn-primary" id="SaveButton">Save</button>