Files
BTCPayServerPlugins/Plugins/BTCPayServer.Plugins.Prism/Components/PrismEdit.razor
2023-10-31 14:35:47 +01:00

530 lines
21 KiB
Plaintext

@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client.Models
@using BTCPayServer.HostedServices
@using BTCPayServer.Payments
@using BTCPayServer.PayoutProcessors
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Routing
@using Microsoft.Extensions.Logging
@using NBitcoin
@using LightningAddressData = BTCPayServer.Data.LightningAddressData
@using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest
@inject IPluginHookService PluginHookService
@inject LightningAddressService LightningAddressService
@inject PayoutProcessorService PayoutProcessorService
@inject IEnumerable<IPayoutProcessorFactory> PayoutProcessorFactories
@inject SatBreaker SatBreaker
@inject LinkGenerator LinkGenerator
@inject PullPaymentHostedService PullPaymentHostedService
@inject IHttpContextAccessor HttpContextAccessor
@inject ILogger<PrismEdit> Logger
@implements IDisposable
@if (Loading)
{
}
else
{
@if (NoPayoutProcessors)
{
<div class="alert alert-warning mb-5" role="alert">
An automated payout processor is required in order to automate prism payouts.
<a class="alert-link p-0" href="@PayoutProcessorLink">Configure now</a>
</div>
}
<datalist id="users">
<option value="*">Catch-all lightning payments made against invoices in your store (excluding when other prisms are configured that capture those payments.)</option>
<option value="*@BitcoinPaymentType.Instance.ToStringNormalized()">Catch-all on-chain payments made against invoices in your store</option>
<option value="*All">Catch-all any payments made against invoices in your store</option>
@foreach (var user in Users)
{
<option value="@user.Username">@user.Username: (lightning address)</option>
}
</datalist>
<datalist id="destinations">
@foreach (var destination in Destinations)
{
<option value="@destination">@destination</option>
}
</datalist>
<h2 class="mb-4">
Prism
<a href="https://dergigi.com/2023/03/12/lightning-prisms/" class="ms-1" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</h2>
<p class="text-muted">
The prism plugin allows automated value splits for your lightning and onchain payments. You can set up multiple prisms, each with their own source (which is a <a href="@LNAddressLink">lightning address username</a>, or use *, *Onchain or *All as catch-all for all payments made against invoices, excluding ones which Prism can handle explicitly) and destinations (which are other lightning addresses,, lnurls, bitcoin addresses, an xpub, or a custom formaty provided by other plugins). The plugin will automatically credit the configured percentage of the payment to the destination (while also making sure there is 2% reserved to cater for fees, don't worry, once the lightning node tells us the exact fee amount, we credit/debit the balance after the payment), and once the configured threshold is reached, a <a href="@PayoutsLink">payout</a> will be created. Then, a <a href="@PayoutProcessorLink">payout processor</a> will run at intervals and process the payout.
</p>
<EditForm EditContext="EditContext" OnValidSubmit="Save">
<div class="row" id="prism-holder">
<div class="prism col-sm-12 border border-light p-2">
@if (SelectedDestinationId is null or "null")
{
<h2>Global Prism Settings</h2>
<div class="row mb-4">
<div class="col-xl-10 col-xxl-constrain">
<div class="form-group form-check">
<input @bind="Settings.Enabled" type="checkbox" class="form-check-input"/>
<label asp-for="Enabled" class="form-check-label">Enabled</label>
<ValidationMessage2 For="() => Settings.Enabled" class="text-danger"></ValidationMessage2>
</div>
<div class="form-group">
<label class="form-label">Sat Threshold</label>
<input type="number" @bind="Settings.SatThreshold" min="1" class="form-control"/>
<ValidationMessage2 For="() => Settings.SatThreshold" class="text-danger"></ValidationMessage2>
<span class="text-muted">How many sats do you want to accumulate per destination before sending?</span>
</div>
<div class="form-group">
<label class="form-label">Reserve fee</label>
<input type="number" @bind="Settings.Reserve" min="0" max="100" step="any" class="form-control"/>
<ValidationMessage2 For="() => Settings.Reserve" class="text-danger"></ValidationMessage2>
<span class="text-muted">When a payout is being generated, how many of its amount in percentage should be excluded to cover the fee? Once the payment is settled, if the lightning node provides the exact fee, the balance is adjusted accordingly.</span>
</div>
</div>
</div>
@if (Settings.Splits.Any())
{
<h2>Your Prisms</h2>
@foreach (var item in Settings.Splits)
{
<PrismSplit Split="@item" OnRequestRemove="@RemovePrism"/>
}
}
}
else
{
@if (SelectedDestinationId is not null && SelectedDestinationId != "null")
{
switch (SelectedDestinationId)
{
case "":
<h2>Create new Destination</h2>
break;
default:
<h2>Editing @SelectedDestinationId Destination</h2>
break;
}
<PrismDestinationEditor
ValidateId="ValidateId"
ValidateDestination="s => ValidateDestination(s, false)"
Id="@SelectedDestinationId"
Settings="@SelectedDestination"
OnUpdate="OnDestinationUpdated"
OnCancel="o => SelectedDestinationId = null">
</PrismDestinationEditor>
}
}
</div>
</div>
<PrismBalances
OnUpdate="OnUpdateBalance"
OnCancelPayout="CancelPayout"
DestinationBalance="Settings.DestinationBalance"
PendingPayouts="Settings.PendingPayouts">
</PrismBalances>
@if (StatusMessageModel != null)
{
<div class="alert alert-@StatusMessageModel.ToString(StatusMessageModel.Severity)">
@StatusMessageModel.Message
</div>
}
<div class="row pt-2">
<div class="d-flex">
@if (SelectedDestinationId is null or "null")
{
<button type="button" class="btn btn-primary mx-2" id="add-prism" @onclick="CreateNewPrism">Add Prism</button>
<button type="submit" class="btn btn-primary mx-2">Save</button>
<select class="form-select mx-2" style="max-width: fit-content" @bind="SelectedDestinationId" disabled="@(SelectedDestinationId is not null && SelectedDestinationId != "null")">
<option value="null">Select destination to configure</option>
@foreach (var destination in Destinations)
{
<option value="@destination">@destination</option>
}
<option value="">Create new destination</option>
</select>
}
@if (PrismEditButtonsFilter is not null)
{
@((MarkupString) PrismEditButtonsFilter)
}
</div>
</div>
</EditForm>
}
@code {
private void AddDestination()
{
SelectedDestinationId = "";
StateHasChanged();
}
public string? SelectedDestinationId { get; set; } = "null";
public PrismDestination? SelectedDestination
{
get
{
if (SelectedDestinationId is null or "null")
return null;
Settings.Destinations.TryGetValue(SelectedDestinationId, out var res);
return res;
}
}
public string[] Destinations => Settings.Destinations.Keys.ToArray();
public bool Loading { get; set; } = true;
public List<LightningAddressData> Users { get; set; } = new();
public PaymentMethodId pmi { get; set; } = new("BTC", LightningPaymentType.Instance);
public PaymentMethodId pmichain { get; set; } = new("BTC", PaymentTypes.BTCLike);
public bool NoPayoutProcessors { get; set; }
private string PrismEditButtonsFilter { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
PrismEditButtonsFilter = (await PluginHookService.ApplyFilter("prism-edit-buttons", "")) as string;
PayoutProcessorLink = LinkGenerator.GetUriByAction(HttpContextAccessor.HttpContext, "ConfigureStorePayoutProcessors", "UIPayoutProcessors", new {StoreId});
LNAddressLink = LinkGenerator.GetUriByAction(HttpContextAccessor.HttpContext, "EditLightningAddress", "UILNURL", new {StoreId});
PayoutsLink = LinkGenerator.GetUriByAction(HttpContextAccessor.HttpContext, "Payouts", "UIStorePullPayments", new {StoreId, payoutState = PayoutState.AwaitingPayment, paymentMethodId = pmi.ToString()});
var fetchSettings = SatBreaker.Get(StoreId);
var fetchLnAddresses = LightningAddressService.Get(new LightningAddressQuery()
{
StoreIds = new[] {StoreId}
});
var fetchProcessors = PayoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {StoreId},
PaymentMethods = new[] {pmi.ToString(), pmichain.ToString()}
});
var tasks = new Task[]
{
fetchSettings,
fetchLnAddresses,
fetchProcessors
};
await Task.WhenAll(tasks);
Settings = await fetchSettings;
Users = await fetchLnAddresses;
EditContext = new EditContext(Settings);
MessageStore = new ValidationMessageStore(EditContext);
EditContext.OnValidationRequested += Validate;
EditContext.OnFieldChanged += FieldChanged;
SatBreaker.PrismUpdated += SatBreakerOnPrismUpdated;
//set NoPayoutProcessors to true if there are no configured payout processores for pmi and pmichain
NoPayoutProcessors = PayoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(pmi)) && (await fetchProcessors).All(data =>
!new[] {pmi, pmichain}.Contains(data.GetPaymentMethodId()));
Loading = false;
await InvokeAsync(StateHasChanged);
}
await base.OnAfterRenderAsync(firstRender);
}
private void FieldChanged(object sender, FieldChangedEventArgs e)
{
StatusMessageModel = null;
}
private void SatBreakerOnPrismUpdated(object sender, PrismPaymentDetectedEventArgs e)
{
if (e.StoreId != StoreId) return;
if (e.Settings != Settings && e.Settings.Version != Settings.Version)
{
Settings.DestinationBalance = e.Settings.DestinationBalance;
Settings.PendingPayouts = e.Settings.PendingPayouts;
Settings.Version = e.Settings.Version;
}
InvokeAsync(StateHasChanged);
}
private void Validate(object sender, ValidationRequestedEventArgs validationRequestedEventArgs)
{
var previousState = EditContext.GetValidationMessages().Any();
MessageStore.Clear();
StatusMessageModel = null;
foreach (var prism in Settings.Splits)
{
if (string.IsNullOrEmpty(prism.Source))
{
MessageStore.Add(() => prism.Source, "Source is required");
}
else if (Settings.Splits.Count(s => s.Source == prism.Source) > 1)
{
MessageStore.Add(() => prism.Source, "Sources must be unique");
}
if (!(prism.Destinations?.Count > 0))
{
MessageStore.Add(() => prism.Destinations, "At least one destination is required");
continue;
}
var sum = prism.Destinations.Sum(d => d.Percentage);
if (sum > 100)
{
MessageStore.Add(() => prism.Destinations, "Destinations must sum up to a 100 maximum");
}
foreach (var destination in prism.Destinations)
{
var dest = destination.Destination;
if (string.IsNullOrEmpty(dest))
{
MessageStore.Add(() => destination.Destination, "Destination is required");
continue;
}
if (!ValidateDestination(dest, true))
{
MessageStore.Add(() => destination.Destination, "Destination is not valid");
}
}
}
if (previousState != EditContext.GetValidationMessages().Any())
EditContext.NotifyValidationStateChanged();
}
private bool ValidateDestination(string dest, bool allowAlias)
{
if (allowAlias && Destinations.Contains(dest))
{
return true;
}
var result = PluginHookService.ApplyFilter("prism-destination-validate", dest).Result;
return result is true or PrismDestinationValidationResult {Success: true };
}
public ValidationMessageStore MessageStore { get; set; }
public EditContext? EditContext { get; set; }
public StatusMessageModel StatusMessageModel { get; set; }
public PrismSettings Settings { get; set; }
public string PayoutProcessorLink { get; set; }
public string LNAddressLink { get; set; }
public string PayoutsLink { get; set; }
[Parameter]
public string StoreId { get; set; }
private async Task CreateNewPrism()
{
Settings.Splits.Add(new Split());
await InvokeAsync(StateHasChanged);
}
private async Task RemovePrism(Split item)
{
if (Settings.Splits.Remove(item))
{
await InvokeAsync(StateHasChanged);
}
}
private async Task SaveDestinations()
{
var settz = await SatBreaker.Get(StoreId);
settz.Destinations = Settings.Destinations;
await UpdateAndShowResult(settz);
}
private async Task Save()
{
var settz = await SatBreaker.Get(StoreId);
settz.Splits = Settings.Splits;
settz.Destinations = Settings.Destinations;
settz.Enabled = Settings.Enabled;
settz.SatThreshold = Settings.SatThreshold;
settz.Reserve = Settings.Reserve;
Settings = settz;
await UpdateAndShowResult(settz);
}
private async Task UpdateAndShowResult(PrismSettings settz)
{
var updateResult = await SatBreaker.UpdatePrismSettingsForStore(StoreId, settz);
if (!updateResult)
{
StatusMessageModel = new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "The settings have been updated by another process. Please refresh the page and try again."
};
}
else
{
StatusMessageModel = new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Successfully saved settings"
};
}
}
public void Dispose()
{
if (EditContext is not null)
{
EditContext.OnValidationRequested -= Validate;
EditContext.OnFieldChanged -= FieldChanged;
}
SatBreaker.PrismUpdated -= SatBreakerOnPrismUpdated;
}
private bool ValidateId(string arg)
{
if (string.IsNullOrEmpty(arg))
return false;
if (SelectedDestinationId == arg)
{
return true;
}
if (Destinations.Contains(arg))
{
return false;
}
return true;
}
private async Task OnUpdateBalance((string destination, long newBalance) obj)
{
try
{
await SatBreaker.WaitAndLock();
if (obj.newBalance == 0)
{
Settings.DestinationBalance.Remove(obj.destination);
}
else
{
Settings.DestinationBalance.AddOrReplace(obj.destination, obj.newBalance);
}
await SatBreaker.UpdatePrismSettingsForStore(StoreId, Settings, true);
SatBreaker.TriggerPayoutCheck();
}
finally
{
SatBreaker.Unlock();
}
}
private async Task OnDestinationUpdated((string Id, PrismDestination? Destination) obj)
{
if (obj.Destination is null && !string.IsNullOrEmpty(SelectedDestinationId))
{
Settings.Destinations.Remove(SelectedDestinationId);
foreach (var settingsSplit in Settings.Splits)
{
settingsSplit.Destinations.RemoveAll(split => split.Destination == SelectedDestinationId);
}
SelectedDestinationId = null;
}
else
{
if (string.IsNullOrEmpty(SelectedDestinationId))
{
SelectedDestinationId = obj.Id;
}
Settings.Destinations.AddOrReplace(SelectedDestinationId, obj.Destination);
await SaveDestinations();
var needSave = false;
if (!string.IsNullOrEmpty(SelectedDestinationId) && SelectedDestinationId != obj.Id)
{
try
{
await SatBreaker.WaitAndLock();
// find all prisms splits that use this id + all destination balances that use this id + all pending payouts that use this id and rename them
foreach (var destination in Settings.Splits.SelectMany(split => split.Destinations.Where(destination => destination.Destination == SelectedDestinationId)))
{
destination.Destination = obj.Id;
needSave = true;
}
if (Settings.DestinationBalance.Remove(SelectedDestinationId, out var db))
{
needSave = true;
Settings.DestinationBalance.Add(obj.Id, db);
}
if (Settings.Destinations.Remove(SelectedDestinationId, out var dest))
{
needSave = true;
Settings.Destinations.Add(obj.Id, dest);
}
if (needSave)
{
await SatBreaker.UpdatePrismSettingsForStore(StoreId, Settings, true);
}
}
finally
{
SatBreaker.Unlock();
}
}
SelectedDestinationId = "null";
}
}
private async Task CancelPayout((string payoutId,TaskCompletionSource tcs) payout)
{
try
{
var result = (await PullPaymentHostedService.Cancel(
new PullPaymentHostedService.CancelRequest(new[] {payout.payoutId}, new[] {StoreId}))).First().Value;
StatusMessageModel = new StatusMessageModel()
{
Severity = result == MarkPayoutRequest.PayoutPaidResult.Ok ?
StatusMessageModel.StatusSeverity.Success :
StatusMessageModel.StatusSeverity.Error,
Message = result switch
{
MarkPayoutRequest.PayoutPaidResult.Ok => "Payout cancelled (please note that if the threshold is still within reach, a new payout will be created in its place)",
MarkPayoutRequest.PayoutPaidResult.NotFound => "Payout not found",
MarkPayoutRequest.PayoutPaidResult.InvalidState => "Payout state was in a non-cancellable state.",
_ => "Unknown error"
}
};
if (result == MarkPayoutRequest.PayoutPaidResult.Ok)
{
SatBreaker.TriggerPayoutCheck();
}
}
finally
{
payout.tcs.TrySetResult();
}
}
}