Support onchain for prism

This commit is contained in:
Kukks
2023-10-31 14:35:47 +01:00
parent 64dc8501ae
commit 49cbc37e49
11 changed files with 382 additions and 105 deletions

View File

@@ -11,7 +11,7 @@
<PropertyGroup>
<Product>LN Prism</Product>
<Description>Automated value splits for lightning.</Description>
<Version>1.1.19</Version>
<Version>1.2.0</Version>
</PropertyGroup>
<!-- Plugin development properties -->
<PropertyGroup>

View File

@@ -29,24 +29,19 @@ else
@if (NoPayoutProcessors)
{
<div class="alert alert-warning mb-5" role="alert">
An automated payout processor for Lightning is required in order to automate prism payouts.
An automated payout processor is required in order to automate prism payouts.
<a class="alert-link p-0" href="@PayoutProcessorLink">Configure now</a>
</div>
}
@if (Users?.Any() is not true)
{
<div class="alert alert-warning mb-5" role="alert">
Prisms can currently mostly work on lightning addresses that are owned by the store. Please create a lightning address for the store.
<a class="alert-link p-0" href="@LNAddressLink">Configure now</a>. <br/><br/>Alternatively, you can use * as the source, which will match any settled invoice as long as it was paid through Lightning.
</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">A lightning address configured on your store</option>
<option value="@user.Username">@user.Username: (lightning address)</option>
}
</datalist>
<datalist id="destinations">
@@ -63,7 +58,7 @@ else
</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 href="@LNAddressLink">lightning address username</a>, or use * as a catch-all for all invoices settled through Lightning, excluding ones which Prism can handle explicitly) 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 href="@PayoutsLink">payout</a> will be created. Then, a <a href="@PayoutProcessorLink">payout processor</a> will run at intervals and process the payout.
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>
@@ -150,7 +145,7 @@ else
@StatusMessageModel.Message
</div>
}
<div class="row">
<div class="row pt-2">
<div class="d-flex">
@if (SelectedDestinationId is null or "null")
{
@@ -203,6 +198,7 @@ else
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; }
@@ -224,7 +220,7 @@ else
var fetchProcessors = PayoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {StoreId},
PaymentMethods = new[] {pmi.ToString()}
PaymentMethods = new[] {pmi.ToString(), pmichain.ToString()}
});
var tasks = new Task[]
@@ -241,7 +237,10 @@ else
EditContext.OnValidationRequested += Validate;
EditContext.OnFieldChanged += FieldChanged;
SatBreaker.PrismUpdated += SatBreakerOnPrismUpdated;
NoPayoutProcessors = PayoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(pmi)) && !(await fetchProcessors).Any();
//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);
}
@@ -300,7 +299,6 @@ else
MessageStore.Add(() => destination.Destination, "Destination is required");
continue;
}
if (!ValidateDestination(dest, true))
{
MessageStore.Add(() => destination.Destination, "Destination is not valid");
@@ -317,26 +315,8 @@ else
{
return true;
}
try
{
LNURL.LNURL.ExtractUriFromInternetIdentifier(dest);
return true;
}
catch (Exception e)
{
try
{
LNURL.LNURL.Parse(dest, out var tag);
return true;
}
catch (Exception exception)
{
var result = PluginHookService.ApplyFilter("prism-destination-validate", dest).Result;
if (result is true)
return true;
}
}
return false;
var result = PluginHookService.ApplyFilter("prism-destination-validate", dest).Result;
return result is true or PrismDestinationValidationResult {Success: true };
}
public ValidationMessageStore MessageStore { get; set; }

View File

@@ -0,0 +1,49 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.HostedServices;
namespace BTCPayServer.Plugins.Prism;
public class LNURLPrismClaimCreate : IPluginHookFilter
{
private readonly BTCPayNetworkProvider _networkProvider;
public string Hook => "prism-claim-create";
public LNURLPrismClaimCreate(BTCPayNetworkProvider networkProvider)
{
_networkProvider = networkProvider;
}
public async Task<object> Execute(object args)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>("BTC");
if (args is not ClaimRequest claimRequest || network is null)
{
return args;
}
if (claimRequest.Destination?.ToString() is not { } potentialLnurl) return args;
try
{
LNURL.LNURL.ExtractUriFromInternetIdentifier(potentialLnurl);
claimRequest.Destination = new LNURLPayClaimDestinaton(potentialLnurl);
return claimRequest;
}
catch (Exception e)
{
try
{
LNURL.LNURL.Parse(potentialLnurl, out _);
claimRequest.Destination = new LNURLPayClaimDestinaton(potentialLnurl);
return claimRequest;
}
catch (Exception)
{
}
}
return args;
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Payments;
namespace BTCPayServer.Plugins.Prism;
public class LNURLPrismDestinationValidator : IPluginHookFilter
{
public string Hook => "prism-destination-validate";
public Task<object> Execute(object args)
{
if (args is not string args1) return Task.FromResult(args);
try
{
LNURL.LNURL.ExtractUriFromInternetIdentifier(args1);
return Task.FromResult<object>(new PrismDestinationValidationResult()
{
Success = true,
PaymentMethod = new PaymentMethodId("BTC", PaymentTypes.LNURLPay)
});
}
catch (Exception e)
{
try
{
LNURL.LNURL.Parse(args1, out var tag);
return Task.FromResult<object>(new PrismDestinationValidationResult()
{
Success = true,
PaymentMethod = new PaymentMethodId("BTC", PaymentTypes.LNURLPay)
});
}
catch (Exception)
{
}
}
return Task.FromResult(args);
}
}
public class PrismDestinationValidationResult
{
public bool Success { get; set; }
public PaymentMethodId PaymentMethod { get; set; }
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using NBitcoin;
using NBXplorer.DerivationStrategy;
namespace BTCPayServer.Plugins.Prism;
public class OnChainPrismClaimCreate : IPluginHookFilter
{
private readonly BTCPayNetworkProvider _networkProvider;
private readonly ExplorerClientProvider _explorerClientProvider;
public string Hook => "prism-claim-create";
public OnChainPrismClaimCreate(BTCPayNetworkProvider networkProvider, ExplorerClientProvider explorerClientProvider)
{
_networkProvider = networkProvider;
_explorerClientProvider = explorerClientProvider;
}
public async Task<object> Execute(object args)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>("BTC");
if (args is not ClaimRequest claimRequest || network is null)
{
return args;
}
if (claimRequest.Destination?.Id is not { } destStr) return args;
try
{
claimRequest.Destination =
new AddressClaimDestination(BitcoinAddress.Create(destStr, network.NBitcoinNetwork));
claimRequest.PaymentMethodId = new PaymentMethodId("BTC", BitcoinPaymentType.Instance);
return args;
}
catch (Exception)
{
try
{
var ds = new DerivationSchemeParser(network).Parse(destStr);
var ec = _explorerClientProvider.GetExplorerClient(network);
var add = await ec.GetUnusedAsync(ds, DerivationFeature.Deposit, 0, true);
claimRequest.Destination =
new AddressClaimDestination(add.Address);
claimRequest.PaymentMethodId = new PaymentMethodId("BTC", BitcoinPaymentType.Instance);
}
catch (Exception exception)
{
Console.WriteLine(exception);
throw;
}
}
return args;
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using NBitcoin;
using NBXplorer;
namespace BTCPayServer.Plugins.Prism;
public class OnChainPrismDestinationValidator : IPluginHookFilter
{
private readonly BTCPayNetworkProvider _networkProvider;
public string Hook => "prism-destination-validate";
public OnChainPrismDestinationValidator(BTCPayNetworkProvider networkProvider)
{
_networkProvider = networkProvider;
}
public Task<object> Execute(object args)
{
if (args is not string args1) return Task.FromResult(args);
var network = _networkProvider.GetNetwork<BTCPayNetwork>("BTC");
if (network is null)
{
return Task.FromResult(args);
}
try
{
BitcoinAddress.Create(args1, network.NBitcoinNetwork);
return Task.FromResult<object>(new PrismDestinationValidationResult()
{
Success = true,
PaymentMethod = new PaymentMethodId("BTC", PaymentTypes.BTCLike)
});
}
catch (Exception e)
{
try
{
var parser = new DerivationSchemeParser(network);
var dsb = parser.Parse(args1);
return Task.FromResult<object>(new PrismDestinationValidationResult()
{
Success = true,
PaymentMethod = new PaymentMethodId("BTC", PaymentTypes.BTCLike)
});
}
catch (Exception)
{
}
}
return Task.FromResult(args);
}
}

View File

@@ -22,6 +22,10 @@ public class PrismPlugin : BaseBTCPayServerPlugin
"store-integrations-nav"));
applicationBuilder.AddSingleton<SatBreaker>();
applicationBuilder.AddHostedService(provider => provider.GetRequiredService<SatBreaker>());
applicationBuilder.AddSingleton<IPluginHookFilter, LNURLPrismDestinationValidator>();
applicationBuilder.AddSingleton<IPluginHookFilter, OnChainPrismDestinationValidator>();
applicationBuilder.AddSingleton<IPluginHookFilter, LNURLPrismClaimCreate>();
applicationBuilder.AddSingleton<IPluginHookFilter, OnChainPrismClaimCreate>();
base.Execute(applicationBuilder);
}

View File

@@ -14,6 +14,7 @@ using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -23,6 +24,17 @@ using LightningAddressData = BTCPayServer.Data.LightningAddressData;
namespace BTCPayServer.Plugins.Prism
{
internal class PrismPlaceholderClaimDestination:IClaimDestination
{
public PrismPlaceholderClaimDestination(string id)
{
Id = id;
}
public string Id { get; }
public decimal? Amount { get; } = null;
}
/// <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>
@@ -293,6 +305,102 @@ namespace BTCPayServer.Plugins.Prism
return true; // Indicate that the update succeeded
}
private (Split, LightMoney)[] DetermineMatches(PrismSettings prismSettings, InvoiceEntity entity)
{
//first check the primary thing - ln address
var explicitPMI = new PaymentMethodId("BTC", LNURLPayPaymentType.Instance);
var pm = entity.GetPaymentMethod(explicitPMI);
var pmd = pm?.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails;
List<(Split, LightMoney)> result = new();
var payments = entity.GetPayments(true).GroupBy(paymentEntity => paymentEntity.GetPaymentMethodId()).ToArray();
if (pmd?.ConsumedLightningAddress is not null)
{
var address = pmd.ConsumedLightningAddress.Split("@")[0];
var matchedExplicit = prismSettings.Splits.FirstOrDefault(s =>
s.Source.Equals(address, StringComparison.InvariantCultureIgnoreCase));
if (matchedExplicit is not null)
{
var explicitPayments = payments.FirstOrDefault(grouping =>
grouping.Key == explicitPMI)?.Sum(paymentEntity => paymentEntity.PaidAmount.Net);
payments = payments.Where(grouping => grouping.Key != explicitPMI).ToArray();
if (explicitPayments > 0)
{
result.Add((matchedExplicit, LightMoney.FromUnit(explicitPayments.Value, LightMoneyUnit.BTC)));
}
}
}
var catchAlls = prismSettings.Splits.Where(split => split.Source.StartsWith("*")).Select(split =>
{
PaymentMethodId pmi = null;
var valid = true;
switch (split.Source)
{
case "*":
pmi = new PaymentMethodId("BTC", PaymentTypes.LightningLike);
break;
case "*All":
break;
case var s when PaymentTypes.TryParse(s.Substring(1), out var pType):
pmi = new PaymentMethodId("BTC", pType);
break;
case var s when !PaymentMethodId.TryParse(s.Substring(1), out pmi):
valid = false;
break;
}
if (pmi is not null && pmi.CryptoCode != "BTC")
{
valid = false;
}
return (pmi, valid, split);
}).Where(tuple => tuple.valid).ToDictionary(split => split.pmi, split => split.split);
while(payments.Any() || catchAlls.Any())
{
decimal paymentSum;
Split catchAllSplit;
//check if all catachalls do not match to all payments.key and then check if there is a catch all with a null key, that will take all the payments
if(catchAlls.All(catchAll => payments.All(payment => payment.Key != catchAll.Key)) && catchAlls.TryGetValue(null, out catchAllSplit))
{
paymentSum = payments.Sum(paymentEntity =>
paymentEntity.Sum(paymentEntity => paymentEntity.PaidAmount.Net));
payments = Array.Empty<IGrouping<PaymentMethodId, PaymentEntity>>();
}
else
{
var paymentGroup = payments.First();
if (!catchAlls.Remove(paymentGroup.Key, out catchAllSplit))
{
//shift the paymentgroup to bottom of the list
payments = payments.Where(grouping => grouping.Key != paymentGroup.Key).Append(paymentGroup).ToArray();
continue;
}
paymentSum = paymentGroup.Sum(paymentEntity => paymentEntity.PaidAmount.Net);
payments = payments.Where(grouping => grouping.Key != paymentGroup.Key).ToArray();
}
if (paymentSum > 0)
{
result.Add((catchAllSplit, LightMoney.FromUnit(paymentSum, LightMoneyUnit.BTC)));
}
}
return result.ToArray();
}
/// <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.
@@ -320,71 +428,30 @@ namespace BTCPayServer.Plugins.Prism
return;
}
var onChainCatchAllIdentifier = "*" + PaymentTypes.BTCLike.ToStringNormalized();
var catchAllPrism = prismSettings.Splits.FirstOrDefault(split =>
split.Source == "*" || split.Source == onChainCatchAllIdentifier);
Split prism = null;
LightningAddressData address = null;
var prisms = DetermineMatches(prismSettings, invoiceEvent.Invoice);
foreach (var prism in prisms)
{
if (prism.Item2 is not { } msats || msats<= 0)
continue;
var splits = prism.Item1?.Destinations;
if (splits?.Any() is not true)
continue;
var pm = invoiceEvent.Invoice.GetPaymentMethod(new PaymentMethodId("BTC",
LNURLPayPaymentType.Instance));
var pmd = pm?.GetPaymentMethodDetails() as LNURLPayPaymentMethodDetails;
if (string.IsNullOrEmpty(pmd?.ConsumedLightningAddress) && catchAllPrism is null)
{
return;
}
else if (!string.IsNullOrEmpty(pmd?.ConsumedLightningAddress))
{
address = await _lightningAddressService.ResolveByAddress(
pmd.ConsumedLightningAddress.Split("@")[0]);
if (address is null)
//compute the sats for each destination based on splits percentage
var msatsPerDestination =
splits.ToDictionary(s => s.Destination, s => (long) (msats.MilliSatoshi * (s.Percentage / 100)));
prismSettings.DestinationBalance ??= new Dictionary<string, long>();
foreach (var (destination, splitMSats) in msatsPerDestination)
{
return;
}
prism = prismSettings.Splits.FirstOrDefault(s =>
s.Source.Equals(address.Username, StringComparison.InvariantCultureIgnoreCase));
}
else if (catchAllPrism?.Source == onChainCatchAllIdentifier)
{
pm = invoiceEvent.Invoice.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
prism = catchAllPrism;
}
else
{
pm = invoiceEvent.Invoice.GetPaymentMethod(invoiceEvent.Invoice.GetPayments(true)
.FirstOrDefault()?.GetPaymentMethodId());
prism = catchAllPrism;
}
var splits = prism?.Destinations;
if (splits?.Any() is not true || pm is null)
{
return;
}
var msats = LightMoney.FromUnit(pm.Calculate().CryptoPaid, LightMoneyUnit.BTC)
.ToUnit(LightMoneyUnit.MilliSatoshi);
if (msats <= 0)
{
return;
}
//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 if (splitMSats > 0)
{
prismSettings.DestinationBalance.Add(destination, splitMSats);
if (prismSettings.DestinationBalance.TryGetValue(destination, out var currentBalance))
{
prismSettings.DestinationBalance[destination] = currentBalance + splitMSats;
}
else if (splitMSats > 0)
{
prismSettings.DestinationBalance.Add(destination, splitMSats);
}
}
}
@@ -393,7 +460,6 @@ namespace BTCPayServer.Plugins.Prism
{
await UpdatePrismSettingsForStore(invoiceEvent.Invoice.StoreId, prismSettings, true);
}
break;
}
case CheckPayoutsEvt:
@@ -416,7 +482,6 @@ namespace BTCPayServer.Plugins.Prism
}
}
private async Task<bool> CreatePayouts(string storeId, PrismSettings prismSettings)
{
if (!prismSettings.Enabled)
@@ -454,7 +519,7 @@ namespace BTCPayServer.Plugins.Prism
}
var claimRequest = new ClaimRequest()
{
Destination = new LNURLPayClaimDestinaton(destinationSettings?.Destination ?? destination),
Destination = new PrismPlaceholderClaimDestination(destinationSettings?.Destination ?? destination),
PreApprove = true,
StoreId = storeId,
PaymentMethodId = pmi,

View File

@@ -43,7 +43,7 @@ public class PrismClaimCreate : IPluginHookFilter
affiliateId = "qg0OrfHJV",
settleMemo = request.ShiftMemo,
depositCoin = "BTC",
depositNetwork = "lightning",
depositNetwork = request.SourceNetwork?? "lightning",
settleCoin = request.ShiftCoin,
settleNetwork = request.ShiftNetwork,
}

View File

@@ -6,6 +6,7 @@ public class PrismSideshiftDestination
public string ShiftNetwork { get; set; }
public string ShiftDestination { get; set; }
public string ShiftMemo { get; set; }
public string SourceNetwork { get; set; }
public bool Valid()
{

View File

@@ -49,6 +49,7 @@ document.addEventListener('DOMContentLoaded', (event) => {
// sideshiftDestinationButton.addEventListener("click", ev => modal.show());
const selectedSideShiftCoin = document.getElementById("sscoin");
const specifiedSideShiftDestination = document.getElementById("ssdest");
const specifiedSideShiftDepositNetwork = document.getElementById("ssdepositNetwork");
const specifiedSideShiftMemo= document.getElementById("ssmemo");
const shiftButton = document.getElementById("ssshift");
let selectedCoin = null;
@@ -106,7 +107,7 @@ document.addEventListener('DOMContentLoaded', (event) => {
settleMemo: specifiedSideShiftMemo.value,
affiliateId: "qg0OrfHJV",
depositCoin : "BTC",
depositNetwork : "lightning",
depositNetwork : specifiedSideShiftDepositNetwork.value,
settleCoin: selectedCoin.code,
settleNetwork: selectedCoin.network,
permanent: true
@@ -135,7 +136,8 @@ document.addEventListener('DOMContentLoaded', (event) => {
shiftCoin:selectedCoin.code,
shiftNetwork: selectedCoin.network,
shiftDestination: specifiedSideShiftDestination.value,
shiftMemo: specifiedSideShiftMemo.value
shiftMemo: specifiedSideShiftMemo.value,
shiftDepositNetwork: specifiedSideShiftDepositNetwork.value
});
shiftButton.removeAttribute("disabled");
}
@@ -164,6 +166,12 @@ document.addEventListener('DOMContentLoaded', (event) => {
<div id="ss-server-errors" class="text-danger"></div>
<p>This will generate a piece of code based on Sideshift configuration that can work as a valid destination in prism. Prism will then generate a "shift" on Sideshift and send the funds through LN to it, and Sideshift will send you the conversion. </p>
<div class="form-group">
<label class="form-label">How do you want to send BTC to SideShift?</label>
<select id="ssdepositNetwork" class="form-select">
<option value="lightning">Lightning</option>
<option value="bitcoin">On-Chain</option>
</select>
</div><div class="form-group">
<label class="form-label">Which coin should Sideshift send you</label>
<select id="sscoin" class="form-select">
@foreach (var opt in coins)