@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Models @using BTCPayServer.Client.Models @using BTCPayServer.HostedServices @using BTCPayServer.Payments @using BTCPayServer.PayoutProcessors @using BTCPayServer.Payouts @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 PayoutProcessorFactories @inject SatBreaker SatBreaker @inject LinkGenerator LinkGenerator @inject PullPaymentHostedService PullPaymentHostedService @inject IHttpContextAccessor HttpContextAccessor @inject ILogger Logger @implements IDisposable @if (Loading) { } else { @foreach (var user in Users) { } @foreach (var destination in Destinations) { }

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 lightning address username, 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 payout will be created. Then, a payout processor will run at intervals and process the payout.

@if (NoPayoutProcessors) { } @if (StatusMessageModel != null) {
@StatusMessageModel.Message
}
@if (SelectedDestinationId is null or "null") {

Global Prism Settings

How many sats do you want to accumulate per destination before sending?
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.
@if (Settings.Splits.Any()) {

Your Prisms

@foreach (var item in Settings.Splits) { } } } else { @if (SelectedDestinationId is not null && SelectedDestinationId != "null") { switch (SelectedDestinationId) { case "":

Create new Destination

break; default:

Editing @SelectedDestinationId Destination

break; } } }
} @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 Users { get; set; } = new(); public PayoutMethodId pmi { get; set; } = PayoutTypes.LN.GetPayoutMethodId("BTC"); public PayoutMethodId pmichain { get; set; } = PayoutTypes.CHAIN.GetPayoutMethodId("BTC"); 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}, PayoutMethods = new[] {pmi, pmichain} }); 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.GetSupportedPayoutMethods().Contains(pmi)) && (await fetchProcessors).All(data => !new[] {pmi, pmichain}.Contains(data.GetPayoutMethodId())); 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(); } } }