From 4bf0de27de2239ece44d79b9e755a8e26797b322 Mon Sep 17 00:00:00 2001 From: Kukks Date: Fri, 14 Apr 2023 11:53:28 +0200 Subject: [PATCH] prism --- BTCPayServerPlugins.sln | 10 + .../BTCPayServer.Plugins.Prism.csproj | 40 ++ .../PendingPayout.cs | 3 + .../PrismController.cs | 123 +++++++ .../BTCPayServer.Plugins.Prism/PrismPlugin.cs | 27 ++ .../PrismSettings.cs | 14 + .../BTCPayServer.Plugins.Prism/PrismSplit.cs | 3 + .../BTCPayServer.Plugins.Prism/SatBreaker.cs | 344 ++++++++++++++++++ Plugins/BTCPayServer.Plugins.Prism/Split.cs | 3 + .../Views/Prism/Edit.cshtml | 329 +++++++++++++++++ .../Views/Shared/PrismNav.cshtml | 72 ++++ .../_ViewImports.cshtml | 5 + submodules/btcpayserver | 2 +- 13 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj create mode 100644 Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/PrismController.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/Split.cs create mode 100644 Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.Prism/Views/Shared/PrismNav.cshtml create mode 100644 Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln index eba2f75..36c7223 100644 --- a/BTCPayServerPlugins.sln +++ b/BTCPayServerPlugins.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.DataEr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.DynamicRateLimits", "Plugins\BTCPayServer.Plugins.DynamicRateLimits\BTCPayServer.Plugins.DynamicRateLimits.csproj", "{C6033B0A-1070-4908-8A4E-F7B32C5007DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Prism", "Plugins\BTCPayServer.Plugins.Prism\BTCPayServer.Plugins.Prism.csproj", "{9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -253,6 +255,14 @@ Global {C6033B0A-1070-4908-8A4E-F7B32C5007DB}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU {C6033B0A-1070-4908-8A4E-F7B32C5007DB}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU {C6033B0A-1070-4908-8A4E-F7B32C5007DB}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Release|Any CPU.Build.0 = Release|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU + {9BADDA0B-A5AB-4D51-9EBE-67C08C459DC7}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962} diff --git a/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj b/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj new file mode 100644 index 0000000..dee5821 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/BTCPayServer.Plugins.Prism.csproj @@ -0,0 +1,40 @@ + + + + + + net6.0 + 10 + + + + + LN Prism + Automated value splits for lightning. + 1.0.1 + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + + + + diff --git a/Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs b/Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs new file mode 100644 index 0000000..f5a18fc --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs @@ -0,0 +1,3 @@ +namespace BTCPayServer.Plugins.Prism; + +public record PendingPayout(long BalanceAmount, long FeeCharged); \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs b/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs new file mode 100644 index 0000000..e029287 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/PrismController.cs @@ -0,0 +1,123 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Filters; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace BTCPayServer.Plugins.Prism; + +[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] +[Route("stores/{storeId}/plugins/prism")] +[ContentSecurityPolicy(CSPTemplate.AntiXSS, UnsafeInline = true)] +public class PrismController : Controller +{ + private readonly SatBreaker _satBreaker; + + public PrismController( SatBreaker satBreaker) + { + _satBreaker = satBreaker; + } + + [HttpGet] + public async Task Edit(string storeId) + { + var settings =await _satBreaker.Get(storeId); + return View(settings ); + } + + [HttpPost] + public async Task Edit(string storeId, PrismSettings settings, string command) + { + for (int i = 0; i < settings.Splits.Length; i++) + { + var prism = settings.Splits[i]; + if (string.IsNullOrEmpty(prism.Source)) + { + ModelState.AddModelError($"Splits[{i}].Source", "Source is required"); + } + else if(settings.Splits.Count(s => s.Source == prism.Source) > 1) + { + ModelState.AddModelError($"Splits[{i}].Source", "Sources must be unique"); + } + if (!(prism.Destinations?.Length > 0)) + { + + ModelState.AddModelError($"Splits[{i}].Destinations", "At least one destination is required"); + continue; + } + + var sum = prism.Destinations.Sum(d => d.Percentage); + if (sum > 100) + { + + ModelState.AddModelError($"Splits[{i}].Destinations", "Destinations must sum up to a 100 maximum"); + } + + for (int j = 0; j < prism.Destinations.Length; j++) + { + var dest = prism.Destinations[j].Destination; + //check that the source is a valid internet identifier, which is username@domain(and optional port) + if (string.IsNullOrEmpty(dest)) + { + ModelState.AddModelError($"Splits[{i}].Destinations[{j}].Destination", "Destination is required"); + continue; + } + + try + { + LNURL.LNURL.ExtractUriFromInternetIdentifier(dest); + } + catch (Exception e) + { + try + { + LNURL.LNURL.Parse(dest, out var tag); + } + catch (Exception exception) + { + + ModelState.AddModelError($"Splits[{i}].Destinations[{j}].Destination", "Destination is not a valid LN address or LNURL"); + } + } + } + + } + + + if (!ModelState.IsValid) + { + return View(settings); + } + + var settz = await _satBreaker.Get(storeId); + settz.Splits = settings.Splits; + settz.Enabled = settings.Enabled; + settz.SatThreshold = settings.SatThreshold; + var updateResult = await _satBreaker.UpdatePrismSettingsForStore(storeId, settz); + if (!updateResult) + { + ModelState.AddModelError("VersionConflict", "The settings have been updated by another process. Please refresh the page and try again."); + + return View(settings); + } + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "Successfully saved settings" + }); + return RedirectToAction("Edit", new {storeId}); + } + +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs b/Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs new file mode 100644 index 0000000..b8e2930 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs @@ -0,0 +1,27 @@ +using System; +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Abstractions.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.Plugins.Prism; + +public class PrismPlugin : BaseBTCPayServerPlugin +{ + public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } = + { + new() {Identifier = nameof(BTCPayServer), Condition = ">=1.9.0"} + }; + + public override void Execute(IServiceCollection applicationBuilder) + { + applicationBuilder.AddServerSideBlazor(o => o.DetailedErrors = true); + + applicationBuilder.AddSingleton(new UIExtension("PrismNav", + "store-integrations-nav")); + applicationBuilder.AddSingleton(); + applicationBuilder.AddHostedService(provider => provider.GetRequiredService()); + base.Execute(applicationBuilder); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs b/Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs new file mode 100644 index 0000000..3ae8e0d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace BTCPayServer.Plugins.Prism; + +public class PrismSettings +{ + public bool Enabled { get; set; } + + public Dictionary DestinationBalance { get; set; } = new(); + public Split[] Splits { get; set; } + public Dictionary PendingPayouts { get; set; } = new(); + public long SatThreshold { get; set; } = 100; + public ulong Version { get; set; } = 0; +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs b/Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs new file mode 100644 index 0000000..fd4f2cf --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs @@ -0,0 +1,3 @@ +namespace BTCPayServer.Plugins.Prism; + +public record PrismSplit(decimal Percentage, string Destination); \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs new file mode 100644 index 0000000..3c4412b --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Data.Payouts.LightningLike; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NBitcoin; + +namespace BTCPayServer.Plugins.Prism +{ + /// + /// monitors stores that have prism enabled and detects incoming payments based on the lightning address splits the funds to the destinations once the threshold is reached + /// + public class SatBreaker : EventHostedServiceBase + { + private readonly StoreRepository _storeRepository; + private readonly ILogger _logger; + private readonly LightningAddressService _lightningAddressService; + private readonly PullPaymentHostedService _pullPaymentHostedService; + private readonly LightningLikePayoutHandler _lightningLikePayoutHandler; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly LightningClientFactoryService _lightningClientFactoryService; + private readonly IOptions _lightningNetworkOptions; + private Dictionary _prismSettings; + + public SatBreaker(StoreRepository storeRepository, + EventAggregator eventAggregator, + ILogger logger, + LightningAddressService lightningAddressService, + PullPaymentHostedService pullPaymentHostedService, + LightningLikePayoutHandler lightningLikePayoutHandler, + BTCPayNetworkProvider btcPayNetworkProvider, + LightningClientFactoryService lightningClientFactoryService, + IOptions lightningNetworkOptions) : base(eventAggregator, logger) + { + _storeRepository = storeRepository; + _logger = logger; + _lightningAddressService = lightningAddressService; + _pullPaymentHostedService = pullPaymentHostedService; + _lightningLikePayoutHandler = lightningLikePayoutHandler; + _btcPayNetworkProvider = btcPayNetworkProvider; + _lightningClientFactoryService = lightningClientFactoryService; + _lightningNetworkOptions = lightningNetworkOptions; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + _prismSettings = await _storeRepository.GetSettingsAsync(nameof(PrismSettings)); + await base.StartAsync(cancellationToken); + _ = CheckPayouts(CancellationToken); + } + + protected override void SubscribeToEvents() + { + base.SubscribeToEvents(); + Subscribe(); + } + + /// + /// Go through generated payouts and check if they are completed or cancelled, and then remove them from the list. + /// If the fee can be fetched, we compute what the difference was from the original fee we computed (hardcoded 2% of the balance) + /// and we adjust the balance with the difference( credit if the fee was lower, debit if the fee was higher) + /// + /// + private async Task CheckPayouts(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var payoutsToCheck = _prismSettings.ToDictionary(pair => pair.Key, pair => pair.Value.PendingPayouts); + var payoutIds = payoutsToCheck.SelectMany(pair => pair.Value.Keys).ToArray(); + var payouts = (await _pullPaymentHostedService.GetPayouts(new PullPaymentHostedService.PayoutQuery() + { + PayoutIds = payoutIds, + States = new[] {PayoutState.Cancelled, PayoutState.Completed} + })); + var lnClients = new Dictionary(); + var res = new Dictionary(); + + foreach (var payout in payouts) + { + if (payoutsToCheck.TryGetValue(payout.StoreDataId, out var pendingPayouts) && + pendingPayouts.TryGetValue(payout.Id, out var pendingPayout)) + { + long toCredit = 0; + switch (payout.State) + { + case PayoutState.Completed: + + var proof = _lightningLikePayoutHandler.ParseProof(payout) as PayoutLightningBlob; + + long? feePaid = null; + if (!string.IsNullOrEmpty(proof?.PaymentHash)) + { + if (!lnClients.TryGetValue(payout.StoreDataId, out var lnClient)) + { + var store = await _storeRepository.FindStore(payout.StoreDataId); + + var network = _btcPayNetworkProvider.GetNetwork("BTC"); + var id = new PaymentMethodId("BTC", PaymentTypes.LightningLike); + var existing = store.GetSupportedPaymentMethods(_btcPayNetworkProvider) + .OfType() + .FirstOrDefault(d => d.PaymentId == id); + if (existing?.GetExternalLightningUrl() is { } connectionString) + { + lnClient = _lightningClientFactoryService.Create(connectionString, network); + } + else if (existing?.IsInternalNode is true && + _lightningNetworkOptions.Value.InternalLightningByCryptoCode + .TryGetValue(network.CryptoCode, + out var internalLightningNode)) + { + lnClient = _lightningClientFactoryService.Create(internalLightningNode, + network); + } + + + lnClients.Add(payout.StoreDataId, lnClient); + } + + if (lnClient is not null) + { + var p = await lnClient.GetPayment(proof.PaymentHash, CancellationToken); + feePaid = (long) p.Fee.ToUnit(LightMoneyUnit.Satoshi); + } + } + + if (feePaid is not null) + { + toCredit = pendingPayout.FeeCharged - feePaid.Value; + } + + break; + case PayoutState.Cancelled: + toCredit = pendingPayout.BalanceAmount + pendingPayout.FeeCharged; + break; + } + + res.TryAdd(payout.StoreDataId, + new CreditDestination(payout.StoreDataId, new Dictionary(), + new List())); + var credDest = res[payout.StoreDataId]; + credDest.PayoutsToRemove.Add(payout.Id); + credDest.Destcredits.Add(payout.Destination, toCredit); + } + } + + var tcs = new TaskCompletionSource(cancellationToken); + PushEvent(new PayoutCheckResult(res.Values.ToArray(), tcs)); + //we wait for ProcessEvent to handle this result so that we avoid race conditions. + await tcs.Task; + await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken); + } + } + + record PayoutCheckResult(CreditDestination[] CreditDestinations, TaskCompletionSource Tcs); + + record CreditDestination(string StoreId, Dictionary Destcredits, List PayoutsToRemove); + + private readonly SemaphoreSlim _updateLock = new(1, 1); + + public async Task Get(string storeId) + { + return _prismSettings.TryGetValue(storeId, out var settings) ? settings : new PrismSettings(); + } + + public async Task UpdatePrismSettingsForStore(string storeId, PrismSettings updatedSettings, bool skipLock = false) + { + try + { + if(!skipLock) + await _updateLock.WaitAsync(); + var currentSettings = await Get(storeId); + + if (currentSettings.Version != updatedSettings.Version) + { + return false; // Indicate that the update failed due to a version mismatch + } + + updatedSettings.Version++; // Increment the version number + + // Update the settings in the dictionary + _prismSettings.AddOrReplace(storeId, updatedSettings); + + // Update the settings in the StoreRepository + await _storeRepository.UpdateSetting(storeId, nameof(PrismSettings), updatedSettings); + } + + finally + { + if(!skipLock) + _updateLock.Release(); + } + + return true; // Indicate that the update succeeded + } + + /// + /// if an invoice is completed, check if it was created through a lightning address, and if the store has prism enabled and one of the splits' source is the same lightning address, grab the paid amount, split it based on the destination percentages, and credit it inside the prism destbalances. + /// When the threshold is reached (plus a 2% reserve fee to account for fees), create a payout and deduct the balance. + /// + /// + /// + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + try + { + await _updateLock.WaitAsync(cancellationToken); + + if (evt is PayoutCheckResult payoutCheckResult) + { + foreach (var creditDestination in payoutCheckResult.CreditDestinations) + { + if (_prismSettings.TryGetValue(creditDestination.StoreId, out var prismSettings)) + { + foreach (var creditDestinationDestcredit in creditDestination.Destcredits) + { + if (prismSettings.DestinationBalance.TryGetValue(creditDestinationDestcredit.Key, + out var currentBalance)) + { + prismSettings.DestinationBalance[creditDestinationDestcredit.Key] = + currentBalance + (creditDestinationDestcredit.Value * 1000); + } + else + { + prismSettings.DestinationBalance.Add(creditDestinationDestcredit.Key, + (creditDestinationDestcredit.Value * 1000)); + } + } + + foreach (var payout in creditDestination.PayoutsToRemove) + { + prismSettings.PendingPayouts.Remove(payout); + } + + await UpdatePrismSettingsForStore(creditDestination.StoreId, prismSettings); + } + } + + payoutCheckResult.Tcs.SetResult(); + return; + } + + if (evt is InvoiceEvent invoiceEvent && + new[] {InvoiceEventCode.Completed, InvoiceEventCode.MarkedCompleted}.Contains( + invoiceEvent.EventCode)) + { + var pm = invoiceEvent.Invoice.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.LNURLPay)); + var pmd = pm?.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails; + if (string.IsNullOrEmpty(pmd?.ConsumedLightningAddress)) + { + return; + } + + var address = + await _lightningAddressService.ResolveByAddress(pmd.ConsumedLightningAddress.Split("@")[0]); + if (address is null || !_prismSettings.TryGetValue(address.StoreDataId, out var prismSettings) || + !prismSettings.Enabled) + { + return; + } + + var splits = prismSettings.Splits.FirstOrDefault(s => s.Source == address.Username)?.Destinations; + if (splits?.Any() is not true) + { + return; + } + + + var msats = pm.Calculate().CryptoPaid.Satoshi * 1000; + //compute the sats for each destination based on splits percentage + var msatsPerDestination = + splits.ToDictionary(s => s.Destination, s => (long) (msats * (s.Percentage / 100))); + + prismSettings.DestinationBalance ??= new Dictionary(); + foreach (var (destination, splitMSats) in msatsPerDestination) + { + if (prismSettings.DestinationBalance.TryGetValue(destination, out var currentBalance)) + { + prismSettings.DestinationBalance[destination] = currentBalance + splitMSats; + } + else + { + prismSettings.DestinationBalance.Add(destination, splitMSats); + } + } + + await UpdatePrismSettingsForStore(address.StoreDataId, prismSettings); + await CreatePayouts(address.StoreDataId, prismSettings); + await UpdatePrismSettingsForStore(address.StoreDataId, prismSettings); + } + } + catch (Exception e) + { + Logs.PayServer.LogWarning(e, "Error while processing prism event"); + } + finally + { + _updateLock.Release(); + } + } + + private async Task CreatePayouts(string storeId, PrismSettings prismSettings) + { + var threshold = (long) Math.Max(1, + Math.Round(prismSettings.SatThreshold * 1.02, 0, MidpointRounding.AwayFromZero)); + foreach (var (destination, amtMsats) in prismSettings.DestinationBalance) + { + var amt = amtMsats / 1000; + if (amt >= threshold) + { + var reserveFee = (long) Math.Max(1, Math.Round(amt * 0.02, 0, MidpointRounding.AwayFromZero)); + var payoutAmount = amt - reserveFee; + var payout = await _pullPaymentHostedService.Claim(new ClaimRequest() + { + Destination = new LNURLPayClaimDestinaton(destination), + PreApprove = true, + StoreId = storeId, + PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike), + Value = Money.Satoshis(payoutAmount).ToDecimal(MoneyUnit.BTC), + }); + if (payout.Result == ClaimRequest.ClaimResult.Ok) + { + prismSettings.PendingPayouts??=new(); + prismSettings.PendingPayouts.Add(payout.PayoutData.Id, new PendingPayout(amt, reserveFee)); + prismSettings.DestinationBalance.AddOrReplace(destination, + amtMsats - (amt - reserveFee) * 1000); + } + } + } + } + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/Split.cs b/Plugins/BTCPayServer.Plugins.Prism/Split.cs new file mode 100644 index 0000000..936f638 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/Split.cs @@ -0,0 +1,3 @@ +namespace BTCPayServer.Plugins.Prism; + +public record Split(string Source, PrismSplit[] Destinations); \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml b/Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml new file mode 100644 index 0000000..64bdaff --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml @@ -0,0 +1,329 @@ +@using BTCPayServer.Abstractions.TagHelpers +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using System.Linq +@using BTCPayServer +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Payments +@using BTCPayServer.PayoutProcessors +@inject LightningAddressService LightningAddressService +@inject IScopeProvider ScopeProvider +@inject IEnumerable PayoutProcessorFactories +@inject PayoutProcessorService PayoutProcessorService +@model BTCPayServer.Plugins.Prism.PrismSettings +@{ + var users = await LightningAddressService.Get(new LightningAddressQuery() + { + StoreIds = new[] {ScopeProvider.GetCurrentStoreId()} + }); + ViewData.SetActivePage("Prism", "Prism", "Prism"); + var pmi = new PaymentMethodId("BTC", LightningPaymentType.Instance); +} + +@if (PayoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(pmi)) && !(await PayoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery() +{ + Stores = new[] {ScopeProvider.GetCurrentStoreId()}, + PaymentMethods = new[] {pmi.ToString()} +})).Any()) +{ + +} +@if (!users.Any()) +{ + +} + + + +@if (ViewData.ModelState.TryGetValue("VersionConflict", out var versionConflict)) +{ +
@versionConflict.Errors.First().ErrorMessage
+} + + +

@ViewData["Title"] + + + +

+

+ The prism plugin allows automated value splits for your lightning payments. You can set up multiple prisms, each with their own source (which is a lightning address username) and destinations (which are other lightning addresses or lnurls). 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. +

+ + + @foreach (var user in users) + { + + } + + +
+
+
+
+
+ +
+ + + +
+
+ + + + How many sats do you want to accumulate per destination before sending? +
+
+
+ +
+ @for (int i = 0; i < Model.Splits?.Length; i++) + { +
+
+ + + + + +
+ + + + + + + + + + @for (var x = 0; x < Model.Splits[i].Destinations?.Length; x++) + { + + + + + + } + + + + + + + + +
+ Destination + Percentage Actions
+
+ + +
+
+
+ + @Model.Splits[i].Destinations[x].Percentage% + +
+
+ +
+ + +
+
+ } +
+
+
+ + +
+
+
+
+ @if (Model.DestinationBalance?.Any() is true) + { +
+

Destination Balances

+ + + + + + @foreach (var (dest, balance) in Model.DestinationBalance) + { + + + + + } +
DestinationSats
@dest@(balance / 1000m)
+
+ } + + @if (Model.PendingPayouts?.Any() is true) + { +
+

Pending Payouts

+ + + + + + + @foreach (var (payoutId, pendingPayout) in Model.PendingPayouts) + { + + + + + + } +
Payout IdReserve feeAmount
@payoutId@pendingPayout.FeeCharged@pendingPayout.BalanceAmount
+
+ } +
+
+
+ + + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Prism/Views/Shared/PrismNav.cshtml b/Plugins/BTCPayServer.Plugins.Prism/Views/Shared/PrismNav.cshtml new file mode 100644 index 0000000..bdc71aa --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/Views/Shared/PrismNav.cshtml @@ -0,0 +1,72 @@ +@using BTCPayServer.Abstractions.Contracts +@using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Client +@using Microsoft.AspNetCore.Mvc.TagHelpers +@inject IScopeProvider ScopeProvider +@{ + var storeId = ScopeProvider.GetCurrentStoreId(); +} +@if (!string.IsNullOrEmpty(storeId)) +{ + +} diff --git a/Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml new file mode 100644 index 0000000..d897d63 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using BTCPayServer.Abstractions.Services +@inject Safe Safe +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions \ No newline at end of file diff --git a/submodules/btcpayserver b/submodules/btcpayserver index 72e66aa..717f161 160000 --- a/submodules/btcpayserver +++ b/submodules/btcpayserver @@ -1 +1 @@ -Subproject commit 72e66aa57640a682a2ca0296d503c728ae6760ee +Subproject commit 717f1610f5723ee40a11dec6cf43a615af0fb8e4