mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
prism
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>10</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Plugin specific properties -->
|
||||
<PropertyGroup>
|
||||
<Product>LN Prism</Product>
|
||||
<Description>Automated value splits for lightning.</Description>
|
||||
<Version>1.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<!-- Plugin development properties -->
|
||||
<PropertyGroup>
|
||||
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||
<PreserveCompilationContext>false</PreserveCompilationContext>
|
||||
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
|
||||
<ItemDefinitionGroup>
|
||||
<ProjectReference>
|
||||
<Properties>StaticWebAssetsEnabled=false</Properties>
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\**" />
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Views\Prism\Edit.cshtml" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
3
Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs
Normal file
3
Plugins/BTCPayServer.Plugins.Prism/PendingPayout.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace BTCPayServer.Plugins.Prism;
|
||||
|
||||
public record PendingPayout(long BalanceAmount, long FeeCharged);
|
||||
123
Plugins/BTCPayServer.Plugins.Prism/PrismController.cs
Normal file
123
Plugins/BTCPayServer.Plugins.Prism/PrismController.cs
Normal file
@@ -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<IActionResult> Edit(string storeId)
|
||||
{
|
||||
var settings =await _satBreaker.Get(storeId);
|
||||
return View(settings );
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> 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});
|
||||
}
|
||||
|
||||
}
|
||||
27
Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs
Normal file
27
Plugins/BTCPayServer.Plugins.Prism/PrismPlugin.cs
Normal file
@@ -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<IUIExtension>(new UIExtension("PrismNav",
|
||||
"store-integrations-nav"));
|
||||
applicationBuilder.AddSingleton<SatBreaker>();
|
||||
applicationBuilder.AddHostedService(provider => provider.GetRequiredService<SatBreaker>());
|
||||
base.Execute(applicationBuilder);
|
||||
}
|
||||
}
|
||||
14
Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs
Normal file
14
Plugins/BTCPayServer.Plugins.Prism/PrismSettings.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Plugins.Prism;
|
||||
|
||||
public class PrismSettings
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public Dictionary<string, long> DestinationBalance { get; set; } = new();
|
||||
public Split[] Splits { get; set; }
|
||||
public Dictionary<string, PendingPayout> PendingPayouts { get; set; } = new();
|
||||
public long SatThreshold { get; set; } = 100;
|
||||
public ulong Version { get; set; } = 0;
|
||||
}
|
||||
3
Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs
Normal file
3
Plugins/BTCPayServer.Plugins.Prism/PrismSplit.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace BTCPayServer.Plugins.Prism;
|
||||
|
||||
public record PrismSplit(decimal Percentage, string Destination);
|
||||
344
Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs
Normal file
344
Plugins/BTCPayServer.Plugins.Prism/SatBreaker.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public class SatBreaker : EventHostedServiceBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly ILogger<SatBreaker> _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> _lightningNetworkOptions;
|
||||
private Dictionary<string, PrismSettings> _prismSettings;
|
||||
|
||||
public SatBreaker(StoreRepository storeRepository,
|
||||
EventAggregator eventAggregator,
|
||||
ILogger<SatBreaker> logger,
|
||||
LightningAddressService lightningAddressService,
|
||||
PullPaymentHostedService pullPaymentHostedService,
|
||||
LightningLikePayoutHandler lightningLikePayoutHandler,
|
||||
BTCPayNetworkProvider btcPayNetworkProvider,
|
||||
LightningClientFactoryService lightningClientFactoryService,
|
||||
IOptions<LightningNetworkOptions> 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<PrismSettings>(nameof(PrismSettings));
|
||||
await base.StartAsync(cancellationToken);
|
||||
_ = CheckPayouts(CancellationToken);
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
base.SubscribeToEvents();
|
||||
Subscribe<InvoiceEvent>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken"></param>
|
||||
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<string, ILightningClient>();
|
||||
var res = new Dictionary<string, CreditDestination>();
|
||||
|
||||
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<BTCPayNetwork>("BTC");
|
||||
var id = new PaymentMethodId("BTC", PaymentTypes.LightningLike);
|
||||
var existing = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
|
||||
.OfType<LightningSupportedPaymentMethod>()
|
||||
.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<string, long>(),
|
||||
new List<string>()));
|
||||
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<string, long> Destcredits, List<string> PayoutsToRemove);
|
||||
|
||||
private readonly SemaphoreSlim _updateLock = new(1, 1);
|
||||
|
||||
public async Task<PrismSettings> Get(string storeId)
|
||||
{
|
||||
return _prismSettings.TryGetValue(storeId, out var settings) ? settings : new PrismSettings();
|
||||
}
|
||||
|
||||
public async Task<bool> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="evt"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
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<string, long>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
Plugins/BTCPayServer.Plugins.Prism/Split.cs
Normal file
3
Plugins/BTCPayServer.Plugins.Prism/Split.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace BTCPayServer.Plugins.Prism;
|
||||
|
||||
public record Split(string Source, PrismSplit[] Destinations);
|
||||
329
Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml
Normal file
329
Plugins/BTCPayServer.Plugins.Prism/Views/Prism/Edit.cshtml
Normal file
@@ -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<IPayoutProcessorFactory> 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())
|
||||
{
|
||||
<div class="alert alert-warning mb-5" role="alert">
|
||||
An automated payout procesor for Lightning is required in order to automate prism payouts.
|
||||
<a class="alert-link p-0" asp-action="ConfigureStorePayoutProcessors" asp-controller="UIPayoutProcessors" asp-route-storeId="@ScopeProvider.GetCurrentStoreId()">Configure now</a>
|
||||
</div>
|
||||
}
|
||||
@if (!users.Any())
|
||||
{
|
||||
<div class="alert alert-warning mb-5" role="alert">
|
||||
Prisms can currently only work on lightning addresses that are owned by the store. Please create a lightning address for the store.
|
||||
<a class="alert-link p-0" asp-action="EditLightningAddress" asp-controller="UILNURL" asp-route-storeId="@ScopeProvider.GetCurrentStoreId()">Configure now</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
<partial name="_StatusMessage"/>
|
||||
@if (ViewData.ModelState.TryGetValue("VersionConflict", out var versionConflict))
|
||||
{
|
||||
<div class="alert alert-danger">@versionConflict.Errors.First().ErrorMessage</div>
|
||||
}
|
||||
|
||||
|
||||
<h2 class="mb-4">@ViewData["Title"]
|
||||
<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 payments. You can set up multiple prisms, each with their own source (which is a <a asp-action="EditLightningAddress" asp-controller="UILNURL" asp-route-storeId="@ScopeProvider.GetCurrentStoreId()">lightning address username</a>) 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 <a asp-action="Payouts" asp-controller="UIStorePullPayments" asp-route-storeId="@ScopeProvider.GetCurrentStoreId()" asp-route-payoutState="AwaitingPayment" asp-route-paymentMethodId="@pmi.ToString()">payout</a> will be created. Then, a <a asp-action="ConfigureStorePayoutProcessors" asp-controller="UIPayoutProcessors" asp-route-storeId="@ScopeProvider.GetCurrentStoreId()">payout processor</a> will run at intervals and process the payout.
|
||||
</p>
|
||||
|
||||
<datalist id="users">
|
||||
@foreach (var user in users)
|
||||
{
|
||||
<option value="@user.Username"></option>
|
||||
}
|
||||
</datalist>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<form method="post">
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input asp-for="Enabled" type="checkbox" class="form-check-input"/>
|
||||
<label asp-for="Enabled" class="form-check-label"></label>
|
||||
<span asp-validation-for="Enabled" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="SatThreshold" class="form-label">Sat Threshold</label>
|
||||
<input type="number" asp-for="SatThreshold" class="form-control"/>
|
||||
<span asp-validation-for="SatThreshold" class="text-danger"></span>
|
||||
<span class="text-muted">How many sats do you want to accumulate per destination before sending?</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" id="prism-holder">
|
||||
@for (int i = 0; i < Model.Splits?.Length; i++)
|
||||
{
|
||||
<div class="prism col-sm-12 col-md-5 border border-light p-2 m-1">
|
||||
<div class="form-group">
|
||||
<label asp-for="Splits[i].Source" class="form-label"></label>
|
||||
<input type="text" asp-for="Splits[i].Source" list="users" class="form-control src"/>
|
||||
<span asp-validation-for="Splits[i].Source" class="text-danger "></span>
|
||||
|
||||
<span asp-validation-for="Splits[i].Destinations" class="text-danger w-100"></span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Destination
|
||||
</th>
|
||||
<th> Percentage</th>
|
||||
<th> Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (var x = 0; x < Model.Splits[i].Destinations?.Length; x++)
|
||||
{
|
||||
<tr class="dest">
|
||||
<td>
|
||||
<div class="form-group">
|
||||
<input type="text" asp-for="Splits[i].Destinations[x].Destination" class="form-control"/>
|
||||
<span asp-validation-for="Splits[i].Destinations[x].Destination" class="text-danger"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-group">
|
||||
<input type="range" asp-for="Splits[i].Destinations[x].Percentage" class="form-range" min="0" max="100"/>
|
||||
<output>@Model.Splits[i].Destinations[x].Percentage%</output>
|
||||
<span asp-validation-for="Splits[i].Destinations[x].Percentage" class="text-danger"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="remove-dest btn btn-link">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<button class="btn btn-link add-dest" type="button">Add</button>
|
||||
<button class="btn btn-link remove-prism" type="button">Remove Prism</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="d-flex">
|
||||
<button name="command" type="submit" value="save" class="btn btn-primary mx-2">Submit</button>
|
||||
<button type="button" class="btn btn-primary mx-2" id="add-prism">Add Prism</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row mt-4">
|
||||
@if (Model.DestinationBalance?.Any() is true)
|
||||
{
|
||||
<div class="col-sm-12 col-md-5 col-xxl-constrain border border-light">
|
||||
<h4 class="text-center p-2">Destination Balances</h4>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Destination</th>
|
||||
<th>Sats</th>
|
||||
</tr>
|
||||
@foreach (var (dest, balance) in Model.DestinationBalance)
|
||||
{
|
||||
<tr>
|
||||
<td>@dest</td>
|
||||
<td>@(balance / 1000m)</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.PendingPayouts?.Any() is true)
|
||||
{
|
||||
<div class="col-sm-12 col-md-5 col-xxl-constrain border border-light">
|
||||
<h4 class="text-center p-2">Pending Payouts</h4>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Payout Id</th>
|
||||
<th>Reserve fee</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
@foreach (var (payoutId, pendingPayout) in Model.PendingPayouts)
|
||||
{
|
||||
<tr>
|
||||
<td>@payoutId</td>
|
||||
<td>@pendingPayout.FeeCharged</td>
|
||||
<td>@pendingPayout.BalanceAmount</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="prism">
|
||||
|
||||
<div class="prism col-sm-12 col-md-5 border border-light p-2 m-1">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Source</label>
|
||||
<input type="text" name="Splits[i].Source" list="users" class="form-control src"/>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Destination
|
||||
</th>
|
||||
<th> Percentage</th>
|
||||
<th> Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<button class="btn btn-link add-dest" type="button">Add</button>
|
||||
<button class="btn btn-link remove-prism" type="button">Remove Prism</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<template id="split">
|
||||
<tr class="dest">
|
||||
<td>
|
||||
<div class="form-group">
|
||||
<input type="text" name="Splits[i].Destinations[x].Destination" class="form-control"/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-group">
|
||||
<input type="range" name="Splits[i].Destinations[x].Percentage" class="form-range" min="0" max="100" value="0"/>
|
||||
<output>0%</output>
|
||||
<span class="text-danger"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="remove-dest btn btn-link">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script >
|
||||
document.addEventListener("DOMContentLoaded", ()=>{
|
||||
|
||||
setupDests();
|
||||
|
||||
document.getElementById("add-prism").addEventListener("click", ()=>{
|
||||
const template = document.querySelector('#prism');
|
||||
const clone = template.content.cloneNode(true);
|
||||
const prismholder = document.getElementById("prism-holder");
|
||||
prismholder.appendChild(clone);
|
||||
const el = prismholder.lastElementChild
|
||||
setIndex();
|
||||
el.querySelectorAll(".add-dest").forEach(value =>{
|
||||
value.addEventListener("click",onAddDest );
|
||||
})
|
||||
el.querySelectorAll(".remove-prism").forEach(value =>{
|
||||
value.addEventListener("click",onRemovePrism );
|
||||
})
|
||||
});
|
||||
|
||||
function onRemoveDest(evt){
|
||||
evt.target.parentElement.parentElement.remove();
|
||||
setIndex();
|
||||
}
|
||||
function onRemovePrism(evt){
|
||||
debugger;
|
||||
evt.target.parentElement.parentElement.parentElement.parentElement.parentElement.remove();
|
||||
setIndex();
|
||||
}
|
||||
function onUpdateValue(evt){
|
||||
evt.target.nextElementSibling.value = evt.target.value + "%";
|
||||
setIndex();
|
||||
}
|
||||
|
||||
function onAddDest(evt){
|
||||
const template = document.querySelector('#split');
|
||||
const clone = template.content.cloneNode(true);
|
||||
evt.target.parentElement.parentElement.parentElement.previousElementSibling.appendChild(clone)
|
||||
setIndex();
|
||||
const el = evt.target.parentElement.parentElement.parentElement.previousElementSibling.lastElementChild;
|
||||
el.querySelectorAll(".remove-dest").forEach(value => {
|
||||
value.addEventListener("click",onRemoveDest );
|
||||
});
|
||||
el.querySelectorAll("input[type=range]").forEach(value =>{
|
||||
|
||||
value.addEventListener("input",onUpdateValue );
|
||||
});
|
||||
}
|
||||
|
||||
function setupDests(){
|
||||
document.querySelectorAll(".remove-dest").forEach(value =>{
|
||||
value.removeEventListener("click",onRemoveDest )
|
||||
value.addEventListener("click",onRemoveDest );
|
||||
});
|
||||
|
||||
document.querySelectorAll(".add-dest").forEach(value =>{
|
||||
|
||||
value.removeEventListener("click",onAddDest )
|
||||
value.addEventListener("click",onAddDest );
|
||||
})
|
||||
document.querySelectorAll(".remove-prism").forEach(value =>{
|
||||
value.addEventListener("click",onRemovePrism );
|
||||
})
|
||||
document.querySelectorAll("input[type=range]").forEach(value =>{
|
||||
|
||||
value.addEventListener("input",onUpdateValue );
|
||||
});
|
||||
}
|
||||
|
||||
function setIndex(){
|
||||
document.querySelectorAll(".prism").forEach((prism, key) => {
|
||||
|
||||
prism.querySelector("input.src").name = `Splits[${key}].Source`;
|
||||
prism.querySelectorAll("tr.dest").forEach((value, key2) => {
|
||||
value.setAttribute("data-index", key);
|
||||
value.querySelectorAll("input").forEach(value1 => {
|
||||
value1.name = `Splits[${key}].Destinations[${key2}].${value1.name.split(".").pop()}`;
|
||||
});
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -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))
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a asp-controller="Prism" asp-action="Edit" asp-route-storeId="@storeId" class="nav-link @ViewData.IsActivePage("Prism")" id="Nav-Prism"
|
||||
permission="@Policies.CanModifyStoreSettings">
|
||||
<svg
|
||||
style=" width: 24px;
|
||||
margin-right: 3px; height: 22px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 37 30"
|
||||
version="1.1"
|
||||
id="svg3449">
|
||||
<defs
|
||||
id="defs3443" />
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(0,-267)">
|
||||
<g
|
||||
transform="translate(-301.50996,158.13669)"
|
||||
id="g3423">
|
||||
<g
|
||||
style="stroke-width:0.60000002;stroke-miterlimit:4;stroke-dasharray:none"
|
||||
transform="translate(0,2.1166667)"
|
||||
id="g3391">
|
||||
<path
|
||||
|
||||
style="fill:#89afd7;stroke:currentColor;stroke-width:0.60000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 319.99999,116.00001 304,116 v -2 h 15.99999 z"
|
||||
id="path10794"/>
|
||||
<path
|
||||
style="fill:#3465a4;stroke:currentColor;stroke-width:0.60000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 336,118 v 3 L 319.99999,116.00001 320,115.5 c 4.83688,0.38641 11.17142,1.98265 16,2.5 z"
|
||||
id="path10796"/>
|
||||
<path
|
||||
style="fill:#73d216;stroke:currentColor;stroke-width:0.60000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 320,115.5 16,2.5 v -3 h -16 z"
|
||||
id="path10798" />
|
||||
<path
|
||||
style="fill:#fc9d12;stroke:currentColor;stroke-width:0.60000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 319.99999,115 320,114.5 336,112 v 3 z"
|
||||
id="path10800" />
|
||||
<path
|
||||
style="fill:#a40000;stroke:currentColor;stroke-width:0.60000002;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 336,112 -16,2.5 -10e-6,-0.5 L 336,109 Z"
|
||||
id="path10802"/>
|
||||
</g>
|
||||
<g
|
||||
id="g3395">
|
||||
<path
|
||||
style="fill:#d3d7cf;stroke:currentColor;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="M 319.99999,117.00001 309.99998,132 320,137 v 0 l 10,-5 z"
|
||||
id="path10790" />
|
||||
<path
|
||||
style="fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 319.99999,117.00001 v 20 0"
|
||||
id="path10792"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<span>Prism</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
5
Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml
Normal file
5
Plugins/BTCPayServer.Plugins.Prism/_ViewImports.cshtml
Normal file
@@ -0,0 +1,5 @@
|
||||
@using BTCPayServer.Abstractions.Services
|
||||
@inject Safe Safe
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, BTCPayServer
|
||||
@addTagHelper *, BTCPayServer.Abstractions
|
||||
Submodule submodules/btcpayserver updated: 72e66aa576...717f1610f5
Reference in New Issue
Block a user