make prism support opensats

This commit is contained in:
Kukks
2024-02-02 11:20:27 +01:00
parent 1efe97d4e1
commit abafab95f7
19 changed files with 1554 additions and 445 deletions

View File

@@ -1,4 +1,18 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=0D438B7D_002DF996_002D4BF3_002D8F54_002D02CB9DF120D8_002Ff_003ABTCPayCoinjoinCoinSelector_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=0D438B7D_002DF996_002D4BF3_002D8F54_002D02CB9DF120D8_002Ff_003ABTCPayWallet_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003ABlockchain_002Fd_003ATransactionOutputs_002Ff_003ACoinsView_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003ABlockchain_002Fd_003ATransactionOutputs_002Ff_003ASmartCoin_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003AWabiSabi_002Fd_003AClient_002Ff_003ACoinJoinClient_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003AWabiSabi_002Fd_003AClient_002Ff_003ACoinJoinManager_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003AWabiSabi_002Fd_003AClient_002Ff_003ACoinJoinTracker_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003AWabiSabi_002Fd_003AClient_002Ff_003ACoinJoinTrackerFactory_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=D1D1116C_002D38F9_002D4EA3_002DAC65_002DA75FEA82E5C8_002Fd_003AWallets_002Ff_003AWallet_002Ecs/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:String x:Key="/Default/Environment/UnitTesting/UnitTestSessionStore/Sessions/=cedf4985_002D652b_002D4803_002D9af5_002D7e2c856d885a/@EntryIndexedValue">&lt;SessionState ContinuousTestingMode="0" IsActive="True" Name="EnsureNewLightningInvoiceOnPartialPayment" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"&gt;&#xD;
&lt;TestAncestor&gt;&#xD;
&lt;TestId&gt;xUnit::4146B6DF-7BEE-4BD0-B6B1-77E7630A1B81::net8.0::BTCPayServer.Tests.UnitTest1.EnsureNewLightningInvoiceOnPartialPayment&lt;/TestId&gt;&#xD;
&lt;/TestAncestor&gt;&#xD;
&lt;/SessionState&gt;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Nostr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -36,6 +36,11 @@
<ItemGroup>
<Folder Include="Views\Bringin\" />
<AdditionalFiles Include="Components\_Imports.razor" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.3.4" />
</ItemGroup>
</Project>

View File

@@ -1,176 +1,181 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client.Models;
using BTCPayServer.Forms;
using BTCPayServer.Payments;
using NBitcoin;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Bringin;
public class BringinApiKeyFormComponentProvider:FormComponentProviderBase
{
private readonly IHttpClientFactory _httpClientFactory;
public override string View => "Bringin/ApiKeyElement";
public BringinApiKeyFormComponentProvider(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public override void Validate(Form form, Field field)
{
if (field.Required)
{
ValidateField<RequiredAttribute>(field);
}
if(field.ValidationErrors.Any())
return;
var httpClient = _httpClientFactory.CreateClient("bringin");
httpClient.BaseAddress = new Uri("https://dev.bringin.xyz");
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("api-key", GetValue(form, field));
try
{
var userId = new BringinClient(GetValue(form, field), httpClient).GetUserId().GetAwaiter().GetResult();
if(userId is null)
field.ValidationErrors.Add("Invalid API Key");
}
catch (Exception e)
{
field.ValidationErrors.Add("Invalid API Key");
}
}
public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
{
typeToComponentProvider.Add("bringin-apikey", this);
}
}
public class BringinCustodian : ICustodian, ICanDeposit
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly FormDataService _formDataService;
public string Code => "bringin";
public string Name => "Bringin";
public BringinCustodian(IHttpClientFactory httpClientFactory, FormDataService formDataService)
{
_httpClientFactory = httpClientFactory;
_formDataService = formDataService;
}
public async Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config,
CancellationToken cancellationToken)
{
var bringinClient = FromConfig(config);
if (bringinClient is null)
throw new BadConfigException(Array.Empty<string>());
var balance = await bringinClient.GetFiatBalance();
return new Dictionary<string, decimal>()
{
{"EUR", balance}
};
}
public async Task<Form> GetConfigForm(JObject config, CancellationToken cancellationToken = default)
{
var f = new Form();
var fieldset = Field.CreateFieldset();
fieldset.Label = "Connection details";
fieldset.Fields.Add(new Field()
{
Name = "apiKey",
Label = "API Key",
Type = "bringin-apikey",
Value = config["apiKey"]?.Value<string>(),
Required = true,
HelpText = "Enter your Bringin API Key which can be obtained from <a href=\"https://dev-app.bringin.xyz\" target=\"_blank\">here</a>"
});
// fieldset.Fields.Add(new Field()
// {
// Name = "server",
// Label = "Bringin Server (optional)",
// Type = "password",
// Value = config["server"]?.Value<string>(),
// Required = false,
// OriginalValue = "https://dev.bringin.xyz",
// HelpText = "Enter the Bringin server URL. This is optional and defaults to https://dev.bringin.xyz"
// });
//
f.Fields.Add(fieldset);
return f;
}
public async Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config,
CancellationToken cancellationToken)
{
var amount = config["depositAddressConfig"]?.Value<decimal>();
var bringinClient = FromConfig(config);
if (bringinClient is null)
throw new Exception("Invalid API Key");
if (amount is null or <= 0)
{
var rate = await bringinClient.GetRate();
//rate.bringinRate is the price for 1 BTC in EUR
//100eur = 1/rate.bringinRate BTC
amount = 100m / rate.BringinPrice;
}
// var rate = await bringinClient.GetRate();
var host = await Dns.GetHostEntryAsync(Dns.GetHostName(), cancellationToken);
var ipToUse = host.AddressList.FirstOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork)?.ToString();
var request = new BringinClient.CreateOrderRequest()
{
SourceAmount = Money.Coins(amount.Value).Satoshi,
IP = ipToUse,
PaymentMethod = "LIGHTNING"
};
var order = await bringinClient.PlaceOrder(request);
return new DepositAddressData()
{
Address = order.Invoice
};
}
public string[] GetDepositablePaymentMethods()
{
return new[] {new PaymentMethodId("BTC", LightningPaymentType.Instance).ToString()};
}
private BringinClient? FromConfig(JObject config)
{
Uri backend = new Uri("https://dev.bringin.xyz");
if (config.TryGetValue("apiKey", out var apiKey))
{
// if (config.TryGetValue("server", out var serverToken) && serverToken.Value<string>() is { } server &&
// !string.IsNullOrEmpty(server))
// {
// backend = new Uri(server);
// }
var httpClient = _httpClientFactory.CreateClient("bringin");
httpClient.BaseAddress = backend;
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("api-key", apiKey.Value<string>());
return new BringinClient(apiKey.Value<string>(), httpClient);
}
return null;
}
}
// #nullable enable
// using System;
// using System.Collections.Generic;
// using System.ComponentModel.DataAnnotations;
// using System.Linq;
// using System.Net;
// using System.Net.Http;
// using System.Net.Sockets;
// using System.Threading;
// using System.Threading.Tasks;
// using BTCPayServer.Abstractions.Custodians;
// using BTCPayServer.Abstractions.Form;
// using BTCPayServer.Client.Models;
// using BTCPayServer.Forms;
// using BTCPayServer.Payments;
// using NBitcoin;
// using Newtonsoft.Json.Linq;
//
// namespace BTCPayServer.Plugins.Bringin;
//
// public class BringinApiKeyFormComponentProvider:FormComponentProviderBase
// {
// private readonly IHttpClientFactory _httpClientFactory;
// public override string View => "Bringin/ApiKeyElement";
//
// public BringinApiKeyFormComponentProvider(IHttpClientFactory httpClientFactory)
// {
// _httpClientFactory = httpClientFactory;
// }
//
// public override void Validate(Form form, Field field)
// {
// if (field.Required)
// {
// ValidateField<RequiredAttribute>(field);
// }
// if(field.ValidationErrors.Any())
// return;
// var httpClient = _httpClientFactory.CreateClient("bringin");
// httpClient.BaseAddress = new Uri("https://dev.bringin.xyz");
// httpClient.DefaultRequestHeaders.TryAddWithoutValidation("api-key", GetValue(form, field));
// try
// {
// var userId = new BringinClient(GetValue(form, field), httpClient).GetUserId().GetAwaiter().GetResult();
// if(userId is null)
// field.ValidationErrors.Add("Invalid API Key");
// }
// catch (Exception e)
// {
// field.ValidationErrors.Add("Invalid API Key");
// }
// }
//
// public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
// {
// typeToComponentProvider.Add("bringin-apikey", this);
// }
// }
//
//
// public class BringinCustodian : ICustodian, ICanDeposit
// {
// private readonly IHttpClientFactory _httpClientFactory;
// private readonly FormDataService _formDataService;
// public string Code => "bringin";
//
// public string Name => "Bringin";
//
// public BringinCustodian(IHttpClientFactory httpClientFactory, FormDataService formDataService)
// {
// _httpClientFactory = httpClientFactory;
// _formDataService = formDataService;
// }
//
// public async Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config,
// CancellationToken cancellationToken)
// {
// var bringinClient = FromConfig(config);
// if (bringinClient is null)
// throw new BadConfigException(Array.Empty<string>());
//
// var balance = await bringinClient.GetFiatBalance();
// return new Dictionary<string, decimal>()
// {
// {"EUR", balance}
// };
// }
//
// public async Task<Form> GetConfigForm(JObject config, CancellationToken cancellationToken = default)
// {
//
// var f = new Form();
//
// var fieldset = Field.CreateFieldset();
// fieldset.Label = "Connection details";
// fieldset.Fields.Add(new Field()
// {
// Name = "apiKey",
// Label = "API Key",
// Type = "bringin-apikey",
// Value = config["apiKey"]?.Value<string>(),
// Required = true,
// HelpText = "Enter your Bringin API Key which can be obtained from <a href=\"https://dev-app.bringin.xyz\" target=\"_blank\">here</a>"
// });
//
// var fieldset2 = Field.CreateFieldset();
// fieldset.Label = "Conversion details";
//
// // fieldset.Fields.Add(new Field()
// // {
// // Name = "server",
// // Label = "Bringin Server (optional)",
// // Type = "password",
// // Value = config["server"]?.Value<string>(),
// // Required = false,
// // OriginalValue = "https://dev.bringin.xyz",
// // HelpText = "Enter the Bringin server URL. This is optional and defaults to https://dev.bringin.xyz"
// // });
// //
// f.Fields.Add(fieldset);
// return f;
// }
//
// public async Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config,
// CancellationToken cancellationToken)
// {
// var amount = config["depositAddressConfig"]?.Value<decimal>();
//
// var bringinClient = FromConfig(config);
// if (bringinClient is null)
// throw new Exception("Invalid API Key");
// if (amount is null or <= 0)
// {
// var rate = await bringinClient.GetRate();
// //rate.bringinRate is the price for 1 BTC in EUR
// //100eur = 1/rate.bringinRate BTC
// amount = 100m / rate.BringinPrice;
// }
// // var rate = await bringinClient.GetRate();
// var host = await Dns.GetHostEntryAsync(Dns.GetHostName(), cancellationToken);
// var ipToUse = host.AddressList.FirstOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork)?.ToString();
// var request = new BringinClient.CreateOrderRequest()
// {
// SourceAmount = Money.Coins(amount.Value).Satoshi,
// IP = ipToUse,
// PaymentMethod = "LIGHTNING"
// };
// var order = await bringinClient.PlaceOrder(request);
// return new DepositAddressData()
// {
// Address = order.Invoice
// };
// }
//
// public string[] GetDepositablePaymentMethods()
// {
// return new[] {new PaymentMethodId("BTC", LightningPaymentType.Instance).ToString()};
// }
//
// private BringinClient? FromConfig(JObject config)
// {
// Uri backend = new Uri("https://dev.bringin.xyz");
// if (config.TryGetValue("apiKey", out var apiKey))
// {
// // if (config.TryGetValue("server", out var serverToken) && serverToken.Value<string>() is { } server &&
// // !string.IsNullOrEmpty(server))
// // {
// // backend = new Uri(server);
// // }
//
// Uri backend = new Uri("https://dev.bringin.xyz");
// var httpClient = _httpClientFactory.CreateClient("bringin");
// httpClient.BaseAddress = backend;
//
// httpClient.DefaultRequestHeaders.TryAddWithoutValidation("api-key", apiKey.Value<string>());
// return new BringinClient(apiKey.Value<string>(), httpClient);
// }
//
// return null;
// }
// }

View File

@@ -1,10 +1,14 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Forms;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace BTCPayServer.Plugins.Bringin;
@@ -17,10 +21,50 @@ public class BringinPlugin : BaseBTCPayServerPlugin
public override void Execute(IServiceCollection applicationBuilder)
{
applicationBuilder.AddStartupTask<CustodianEnablerTask>();
applicationBuilder.AddSingleton<ICustodian, BringinCustodian>();
applicationBuilder.AddSingleton<IFormComponentProvider, BringinApiKeyFormComponentProvider>();
applicationBuilder.AddSingleton<BringinService>();
applicationBuilder.AddSingleton<IHostedService, BringinService>(s => s.GetService<BringinService>());
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Bringin/BringinDashboardWidget", "dashboard"));
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Bringin/Nav",
"store-integrations-nav"));
}
}
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Route("plugins/{storeId}/Bringin")]
public class BringinController : Controller
{
private readonly BringinService _bringinService;
public BringinController(BringinService bringinService)
{
_bringinService = bringinService;
}
[HttpGet("")]
public async Task<IActionResult> Edit()
{
return View();
}
[HttpGet("callback")]
public async Task<IActionResult> Callback(string storeId, string apiKey)
{
//truncate with showing only first 3 letters on start ond end
var truncatedApikey = apiKey.Substring(0, 3) + "***" + apiKey.Substring(apiKey.Length - 3);
return View("Confirm",
new ConfirmModel("Confirm Bringin API Key",
$"You are about to set your Bringin API key to {truncatedApikey}", "Set", "btn-primary"));
}
[HttpPost("callback")]
public async Task<IActionResult> CallbackConfirm(string storeId, string apiKey)
{
var vm = await _bringinService.Update(storeId);
vm.ApiKey = apiKey;
await _bringinService.Update(storeId, vm);
return RedirectToAction("Edit", new {storeId});
}
}

View File

@@ -0,0 +1,495 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
using NBitcoin;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData;
namespace BTCPayServer.Plugins.Bringin;
public class BringinService : EventHostedServiceBase
{
private readonly ILogger<BringinService> _logger;
private readonly StoreRepository _storeRepository;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private ConcurrentDictionary<string, BringinStoreSettings> _settings;
private readonly AsyncKeyedLocker<string> _storeLocker = new();
public BringinService(EventAggregator eventAggregator, ILogger<BringinService> logger,
StoreRepository storeRepository,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
IHttpClientFactory httpClientFactory, BTCPayNetworkProvider btcPayNetworkProvider) : base(eventAggregator, logger)
{
_logger = logger;
_storeRepository = storeRepository;
_pullPaymentHostedService = pullPaymentHostedService;
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
_httpClientFactory = httpClientFactory;
_btcPayNetworkProvider = btcPayNetworkProvider;
}
protected override void SubscribeToEvents()
{
base.SubscribeToEvents();
Subscribe<StoreRemovedEvent>();
Subscribe<InvoiceEvent>();
Subscribe<PayoutEvent>();
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_settings = new ConcurrentDictionary<string, BringinStoreSettings>(
await _storeRepository.GetSettingsAsync<BringinStoreSettings>(BringinStoreSettings.BringinSettings));
await CheckPendingPayouts();
_ = PeriodicallyCheckEditModes();
await base.StartAsync(cancellationToken);
}
private async Task PeriodicallyCheckEditModes()
{
while (!CancellationToken.IsCancellationRequested)
{
foreach (var (storeId, (disposable, _, expiry)) in _editModes)
{
if (expiry < DateTimeOffset.Now)
{
await CancelEdit(storeId);
}
}
await Task.Delay(TimeSpan.FromSeconds(10));
}
}
private async Task HandleStoreAction(string storeId, Func<BringinStoreSettings, Task> action)
{
using (await _storeLocker.LockAsync(storeId))
{
if (_settings.TryGetValue(storeId, out var bringinStoreSettings))
{
await action(bringinStoreSettings);
}
}
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
var storeId = evt switch
{
StoreRemovedEvent storeRemovedEvent => storeRemovedEvent.StoreId,
InvoiceEvent invoiceEvent => invoiceEvent.Invoice.StoreId,
PayoutEvent payoutEvent => payoutEvent.Payout.StoreDataId,
_ => null
};
if (storeId is not null)
{
_ = HandleStoreAction(storeId, async bringinStoreSettings =>
{
switch (evt)
{
case StoreRemovedEvent storeRemovedEvent:
_settings.TryRemove(storeRemovedEvent.StoreId, out _);
break;
case InvoiceEvent
{
EventCode:
InvoiceEventCode.Completed or InvoiceEventCode.MarkedCompleted
}
invoiceEvent when bringinStoreSettings.Enabled:
var pmPayments = invoiceEvent.Invoice.GetPayments("BTC", true)
.GroupBy(payment => payment.GetPaymentMethodId());
var update = false;
foreach (var pmPayment in pmPayments)
{
var methodId = pmPayment.Key;
if (methodId.PaymentType == PaymentTypes.LNURLPay)
methodId = new PaymentMethodId(methodId.CryptoCode, PaymentTypes.LightningLike);
if (!bringinStoreSettings.MethodSettings.TryGetValue(methodId.ToString(),
out var methodSettings))
{
continue;
}
methodSettings.CurrentBalance +=
pmPayment.Sum(payment => payment.GetCryptoPaymentData().GetValue());
update = true;
}
if (update)
{
await _storeRepository.UpdateSetting(invoiceEvent.Invoice.StoreId,
BringinStoreSettings.BringinSettings, bringinStoreSettings);
await CheckIfNewPayoutsNeeded(invoiceEvent.Invoice.StoreId, bringinStoreSettings);
}
break;
case PayoutEvent payoutEvent:
if (HandlePayoutState(payoutEvent.Payout))
{
await CheckIfNewPayoutsNeeded(payoutEvent.Payout.StoreDataId, bringinStoreSettings);
}
break;
}
});
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task<bool> CheckIfNewPayoutsNeeded(string storeId, BringinStoreSettings bringinStoreSetting)
{
if (!bringinStoreSetting.Enabled)
return false;
var result = false;
// check if there are any payouts that need to be created by looking at the balance and threshold
// for onchain, we may also try and cancel a payout if there is a pending balance so that we dont needlessly create multiple transactions
foreach (var methodSetting in bringinStoreSetting.MethodSettings)
{
var pmi = PaymentMethodId.TryParse(methodSetting.Key);
if (pmi is null)
{
continue;
}
// if there is a pending payout, try and cancel it if this is onchain as we want to save needless tx fees
if (methodSetting.Value.PendingPayouts.Count > 0 && pmi.PaymentType == BitcoinPaymentType.Instance)
{
var cancelResult = await _pullPaymentHostedService.Cancel(
new PullPaymentHostedService.CancelRequest(methodSetting.Value.PendingPayouts.ToArray(),
new[] {storeId}));
if (cancelResult.Values.Any(value => value == MarkPayoutRequest.PayoutPaidResult.Ok))
{
continue;
}
}
if (SupportedMethods.All(supportedMethod => supportedMethod.PaymentMethod != pmi))
{
//only LN is supported for now
continue;
}
var supportedMethod = SupportedMethods.First(supportedMethod => supportedMethod.PaymentMethod == pmi);
var bringinClient = bringinStoreSetting.CreateClient(_httpClientFactory);
var host = await Dns.GetHostEntryAsync(Dns.GetHostName(), CancellationToken.None);
var ipToUse = host.AddressList
.FirstOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork)?.ToString();
var thresholdAmount = methodSetting.Value.Threshold;
if (methodSetting.Value.FiatThreshold)
{
var rate = await bringinClient.GetRate();
thresholdAmount = methodSetting.Value.Threshold / rate.BringinPrice;
}
if (methodSetting.Value.CurrentBalance >= thresholdAmount)
{
var request = new BringinClient.CreateOrderRequest()
{
SourceAmount = Money.Coins(methodSetting.Value.CurrentBalance).Satoshi,
IP = ipToUse,
PaymentMethod = supportedMethod.bringinMethod
};
var order = await bringinClient.PlaceOrder(request);
var orderMoney = Money.Satoshis(order.Amount);
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(pmi.CryptoCode);
var claim = await _pullPaymentHostedService.Claim(new ClaimRequest()
{
PaymentMethodId = pmi,
StoreId = storeId,
Destination = new BoltInvoiceClaimDestination(order.Invoice, BOLT11PaymentRequest.Parse(order.Invoice, network.NBitcoinNetwork)),
Value = orderMoney.ToUnit(MoneyUnit.BTC),
PreApprove = true,
Metadata = JObject.FromObject(new
{
Source = "Bringin"
})
});
if (claim.Result == ClaimRequest.ClaimResult.Ok)
{
methodSetting.Value.CurrentBalance -= orderMoney.ToUnit(MoneyUnit.BTC);
methodSetting.Value.PendingPayouts.Add(claim.PayoutData.Id);
result = true;
}
}
}
if (result)
{
await _storeRepository.UpdateSetting(storeId, BringinStoreSettings.BringinSettings,
bringinStoreSetting);
_settings.AddOrReplace(storeId, bringinStoreSetting);
}
return result;
}
public async Task<string> CreateOrder(string storeId, PaymentMethodId paymentMethodId, Money amountBtc, bool payout)
{
if (SupportedMethods.All(supportedMethod => supportedMethod.PaymentMethod != paymentMethodId))
{
throw new NotSupportedException("Only LN is supported for now");
}
var settings = _settings[storeId];
var bringinClient = settings.CreateClient(_httpClientFactory);
var host = await Dns.GetHostEntryAsync(Dns.GetHostName(), CancellationToken.None);
var ipToUse = host.AddressList
.FirstOrDefault(address => address.AddressFamily == AddressFamily.InterNetwork)?.ToString();
var supportedMethod = SupportedMethods.First(supportedMethod => supportedMethod.PaymentMethod == paymentMethodId);
//check if amount is enough
if (supportedMethod.FiatMinimumAmount > 0)
{
var rate = await bringinClient.GetRate();
var thresholdAmount = supportedMethod.FiatMinimumAmount / rate.BringinPrice;
if (amountBtc.ToDecimal(MoneyUnit.BTC) < thresholdAmount)
{
throw new Exception($"Amount is too low. Minimum amount is {Money.Coins(thresholdAmount)} BTC");
}
}
var request = new BringinClient.CreateOrderRequest()
{
SourceAmount = amountBtc.Satoshi,
IP = ipToUse,
PaymentMethod = supportedMethod.bringinMethod
};
var order = await bringinClient.PlaceOrder(request);
var orderMoney = Money.Satoshis(order.Amount);
if (!payout)
{
return order.Invoice;
}
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
var claim = await _pullPaymentHostedService.Claim(new ClaimRequest()
{
PaymentMethodId = paymentMethodId,
StoreId = storeId,
Destination = new BoltInvoiceClaimDestination(order.Invoice, BOLT11PaymentRequest.Parse(order.Invoice, network.NBitcoinNetwork)),
Value = orderMoney.ToUnit(MoneyUnit.BTC),
PreApprove = true,
Metadata = JObject.FromObject(new
{
Source = "Bringin"
})
});
if (claim.Result != ClaimRequest.ClaimResult.Ok)
{
throw new Exception($"Could not create payout because {ClaimRequest.GetErrorMessage(claim.Result)}");
}
return claim?.PayoutData?.Id;
}
public bool IsInEditMode(string storeId)
{
return _editModes.ContainsKey(storeId);
}
private async Task CheckPendingPayouts()
{
var payoutsIdsToCheck = _settings.SelectMany(pair =>
pair.Value.MethodSettings.Values.SelectMany(settings => settings.PendingPayouts));
var payouts = await _pullPaymentHostedService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
{
PayoutIds = payoutsIdsToCheck.ToArray()
});
var storesToUpdate = new HashSet<string>();
foreach (var payout in payouts.Where(HandlePayoutState))
{
storesToUpdate.Add(payout.StoreDataId);
}
foreach (var (storeId, bringinStoreSettings) in _settings)
{
if (await CheckIfNewPayoutsNeeded(storeId, bringinStoreSettings))
{
//the method updates so no need to do it again
storesToUpdate.Remove(storeId);
}
}
foreach (var storeId in storesToUpdate)
{
await _storeRepository.UpdateSetting(storeId, BringinStoreSettings.BringinSettings, _settings[storeId]);
}
}
private bool HandlePayoutState(PayoutData payout)
{
switch (payout.State)
{
case PayoutState.Completed:
// remove from pending payouts list in a setting
return _settings[payout.StoreDataId].MethodSettings[payout.GetPaymentMethodId().ToString()]
.PendingPayouts.Remove(payout.Id);
case PayoutState.Cancelled:
// remove from pending payouts list in a setting and add to a balance
var result = _settings[payout.StoreDataId].MethodSettings[payout.GetPaymentMethodId().ToString()]
.PendingPayouts.Remove(payout.Id);
var pmi = payout.GetPaymentMethodId();
if (_settings[payout.StoreDataId].MethodSettings
.TryGetValue(pmi.ToString(), out var methodSettings))
{
methodSettings.CurrentBalance += payout.GetBlob(_btcPayNetworkJsonSerializerSettings).Amount;
result = true;
}
return result;
}
return false;
}
public async Task<BringinStoreSettings?> Get(string storeId)
{
return _settings.TryGetValue(storeId, out var bringinStoreSettings) ? bringinStoreSettings : null;
}
public record SupportedMethodOptions(PaymentMethodId PaymentMethod, bool FiatMinimum, decimal FiatMinimumAmount, string bringinMethod);
public static readonly SupportedMethodOptions[] SupportedMethods = new[]
{
new SupportedMethodOptions(new PaymentMethodId("BTC", LightningPaymentType.Instance), true, 100m, "LIGHTNING")
};
private ConcurrentDictionary<string, (IDisposable, BringinStoreSettings, DateTimeOffset Expiry)> _editModes = new();
public async Task<BringinStoreSettings> Update(string storeId)
{
var isNew = false;
var result = _editModes.GetOrAdd(storeId, (s) =>
{
var storeLock = _storeLocker.Lock(s);
var result = (_settings.TryGetValue(s, out var bringinStoreSettings)
? JObject.FromObject(bringinStoreSettings).ToObject<BringinStoreSettings>()
: new BringinStoreSettings())!;
// add or remove any missing methods in result
foreach (var supportedMethod in SupportedMethods)
{
if (!result.MethodSettings.ContainsKey(supportedMethod.PaymentMethod.ToString()))
{
result.MethodSettings.Add(supportedMethod.PaymentMethod.ToString(),
new BringinStoreSettings.PaymentMethodSettings()
{
FiatThreshold = supportedMethod.FiatMinimum,
Threshold = supportedMethod.FiatMinimum ? supportedMethod.FiatMinimumAmount : 0.1m
});
}
}
result.MethodSettings = result.MethodSettings.Where(pair =>
SupportedMethods.Any(supportedMethod => supportedMethod.PaymentMethod.ToString() == pair.Key))
.ToDictionary(pair => pair.Key, pair => pair.Value);
isNew = true;
return (storeLock, result, DateTimeOffset.Now.AddMinutes(5));
});
result.Expiry = DateTimeOffset.Now.AddMinutes(5);
if (_storeLocker.IsInUse(storeId))
{
if (isNew)
EditModeChanged?.Invoke(this, (storeId, true));
return result.Item2;
}
_editModes.Remove(storeId, out _);
return await Update(storeId);
}
public EventHandler<(string storeId, bool editMode)> EditModeChanged;
public async Task Update(string storeId, BringinStoreSettings bringinStoreSettings)
{
if (!_editModes.Remove(storeId, out var editModeLock))
return;
editModeLock.Item1.Dispose();
await _storeRepository.UpdateSetting(storeId, BringinStoreSettings.BringinSettings, bringinStoreSettings);
_settings.AddOrReplace(storeId, bringinStoreSettings);
EditModeChanged?.Invoke(this, (storeId, false));
}
public async Task<bool> CancelEdit(string storeId)
{
if (!_editModes.Remove(storeId, out var editModeLock))
return false;
editModeLock.Item1.Dispose();
EditModeChanged?.Invoke(this, (storeId, false));
return true;
}
public class BringinStoreSettings
{
public const string BringinSettings = "BringinSettings";
public bool Enabled { get; set; } = true;
public string ApiKey { get; set; }
public Dictionary<string, PaymentMethodSettings> MethodSettings { get; set; } = new();
public class PaymentMethodSettings
{
public decimal Threshold { get; set; }
public bool FiatThreshold { get; set; }
public decimal PercentageToForward { get; set; } = 99;
public decimal CurrentBalance { get; set; } = 0m;
public List<string> PendingPayouts { get; set; } = new();
}
public BringinClient CreateClient(IHttpClientFactory httpClientFactory)
{
var backend = new Uri("https://dev.bringin.xyz");
var httpClient = httpClientFactory.CreateClient("bringin");
httpClient.BaseAddress = backend;
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("api-key", ApiKey);
return new BringinClient(ApiKey, httpClient);
}
}
}

View File

@@ -0,0 +1,563 @@
@using System.Threading
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Data
@using BTCPayServer.Payments
@using BTCPayServer.PayoutProcessors
@using BTCPayServer.Services
@using BTCPayServer.Services.Stores
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Routing
@using NBitcoin
@implements IAsyncDisposable;
@code {
private BringinService.BringinStoreSettings? _settings;
private bool _isLoaded = false;
private CancellationTokenSource _cts = new CancellationTokenSource();
[Inject] private IHttpContextAccessor HttpContextAccessor { get; set; }
[Inject] private DisplayFormatter DisplayFormatter { get; set; }
[Inject] private BringinService BringinService { get; set; }
[Inject] private LinkGenerator LinkGenerator { get; set; }
[Inject] private StoreRepository StoreRepository { get; set; }
[Inject] private BTCPayNetworkProvider BTCPayNetworkProvider { get; set; }
[Inject] private IHttpClientFactory HttpClientFactory { get; set; }
[Inject] private PayoutProcessorService PayoutProcessorService { get; set; }
[Parameter] public string StoreId { get; set; }
private decimal? LastFiatBalance { get; set; }
private DateTimeOffset? LastDataFetch { get; set; }
private decimal? LastFiatRate { get; set; }
private string SaveError { get; set; }
private bool ApiKeyError
{
get => _apiKeyError;
set
{
if (_apiKeyError == value)
return;
_apiKeyError = value;
InvokeAsync(StateHasChanged);
}
}
private bool IsLoaded
{
get => _isLoaded;
set
{
if (_isLoaded == value)
return;
_isLoaded = value;
InvokeAsync(StateHasChanged);
}
}
private bool EditMode
{
get => _editMode;
set
{
if (_editMode == value)
return;
_editMode = value;
InvokeAsync(StateHasChanged);
}
}
private bool _editMode;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
PmiLink = $"A payout processor has not been configured for this payment method. Payouts generated by Bringin will not be automatically handled. <a href=\"{LinkGenerator.GetUriByAction(HttpContextAccessor.HttpContext, "ConfigureStorePayoutProcessors", "UIPayoutProcessors", new {StoreId})}\">Configure now</a>";
_callbackLink = LinkGenerator.GetUriByAction(HttpContextAccessor.HttpContext, "Callback", "Bringin", new {StoreId});
_settings = BringinService.IsInEditMode(StoreId)? await BringinService.Update(StoreId): await BringinService.Get(StoreId);
_pms = (await StoreRepository.FindStore(StoreId)).GetSupportedPaymentMethods(BTCPayNetworkProvider).Select(method => method.PaymentId).Where(id => id.CryptoCode == "BTC").ToArray();
_pps = (await PayoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] {StoreId},
PaymentMethods = _pms.Select(p => p.ToString()).ToArray()
})).Select(data => PaymentMethodId.TryParse(data.PaymentMethod)).Where(id => id is not null).ToArray();
EditMode = BringinService.IsInEditMode(StoreId);
IsLoaded = true;
_ = FetchBalanceAndRate();
BringinService.EditModeChanged += EditModeChanged;
}
await base.OnAfterRenderAsync(firstRender);
}
private string _callbackLink;
private void EditModeChanged(object sender, (string storeId, bool editMode) e)
{
if (e.storeId != StoreId)
{
return;
}
if (EditMode == e.editMode)
return;
_ = e.editMode ? Edit() : CancelEdit();
InvokeAsync(StateHasChanged);
}
private async Task Edit()
{
if (_saving)
return;
SaveError = null;
ApiKeyError = false;
_settings = await BringinService.Update(StoreId);
await TestApiKey();
EditMode = true;
}
private async Task CancelEdit()
{
if (_saving)
return;
SaveError = null;
await BringinService.CancelEdit(StoreId);
EditMode = false;
_settings = await BringinService.Get(StoreId);
}
private async Task TestApiKey()
{
ApiKeyError = false;
if (string.IsNullOrEmpty(_settings?.ApiKey))
return;
try
{
var userId = await _settings.CreateClient(HttpClientFactory).GetUserId();
ApiKeyError = string.IsNullOrEmpty(userId);
}
catch (Exception e)
{
ApiKeyError = true;
}
}
private bool _saving;
private PaymentMethodId[] _pms;
private PaymentMethodId[] _pps;
private bool _apiKeyError;
private async Task Save()
{
if (_saving)
return;
_saving = true;
await TestApiKey();
if (ApiKeyError)
return;
SaveError = null;
if (!EditMode)
return;
await BringinService.Update(StoreId, _settings);
EditMode = false;
_saving = false;
fetcherCTS?.Cancel();
}
CancellationTokenSource fetcherCTS;
private async Task FetchBalanceAndRate()
{
if (_cts.IsCancellationRequested)
return;
fetcherCTS = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token);
try
{
if (_settings?.ApiKey is null || EditMode)
return;
var client = _settings.CreateClient(HttpClientFactory);
LastFiatBalance = await client.GetFiatBalance();
LastFiatRate = (await client.GetRate()).BringinPrice;
LastDataFetch = DateTimeOffset.UtcNow;
_ = InvokeAsync(StateHasChanged);
}
finally
{
try
{
await Task.Delay(TimeSpan.FromMinutes(1), fetcherCTS.Token);
}
catch
{
}
await FetchBalanceAndRate();
}
}
private void UpdateDestinationValue(BringinService.BringinStoreSettings.PaymentMethodSettings settings, object eValue)
{
var newValue = Math.Min(100, Math.Round(Convert.ToDecimal(eValue), 2));
settings.PercentageToForward = newValue;
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
BringinService.EditModeChanged -= EditModeChanged;
}
public string ApiKey
{
get => _settings.ApiKey;
set
{
if (_settings.ApiKey == value)
return;
_settings.ApiKey = value;
InvokeAsync(StateHasChanged);
}
}
public string TimeAgo
{
get
{
var res = LastDataFetch is null ? "" : LastDataFetch.Value.ToTimeAgo();
Task.Delay(TimeSpan.FromSeconds(1)).ContinueWith(_ => InvokeAsync(StateHasChanged));
return res;
}
}
private Task CreateManual()
{
SaveError = null;
_manualOrder = true;
return InvokeAsync(StateHasChanged);
}
private Task CancelManual()
{
SaveError = null;
_manualOrder = false;
ManualOrderResult = null;
return InvokeAsync(StateHasChanged);
}
public async Task SubmitOrder()
{
_saving = true;
await InvokeAsync(StateHasChanged);
var pm = PaymentMethodId.TryParse(ManualOrderPaymentMethod);
if (pm is null)
{
SaveError = "Invalid payment method";
return;
}
try
{
SaveError = null;
ManualOrderResult = await BringinService.CreateOrder(StoreId, pm, Money.Coins(ManualOrderAmount.Value), true);
}
catch (Exception e)
{
SaveError = e.Message;
_saving = false;
}
await InvokeAsync(StateHasChanged);
}
private string ManualOrderResult = null;
private bool _manualOrder = false;
private string ManualOrderPaymentMethod = null;
private decimal? ManualOrderAmount = null;
// private bool ManualOrderPayout = true;
public string PmiLink;
}
<div class="widget store-numbers" id="Bringin-Info" style="grid-column-start: 1; grid-column-end: 9;">
@if (!IsLoaded)
{
<h2 class="text-muted">Loading Bringin offramp</h2>
}
else
{
<header>
<h4 class="text-muted">
Bringin Off-ramp
@if (EditMode)
{
<span class="btcpay-status btcpay-status--pending"></span>
}
else if (_settings?.Enabled is true)
{
<span class="btcpay-status btcpay-status--enabled"></span>
}
else if (_settings is not null)
{
<span class="btcpay-status btcpay-status--disabled"></span>
}
</h4>
<div class="d-flex gap-2">
@if (_manualOrder)
{
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelManual" disabled="@_saving">Cancel order</button>
}
else if (_settings is not null && !EditMode)
{
<button class="btn btn-link" @onclick="Edit">Edit</button>
<button class="btn btn-link" @onclick="CreateManual">Manual order</button>
}
else if (_settings is not null && EditMode)
{
if (ApiKeyError)
{
<button class="btn btn-sm btn-outline-secondary" @onclick="TestApiKey" disabled="@_saving">Test API Key</button>
}
if (!string.IsNullOrEmpty(_settings.ApiKey) && !ApiKeyError)
{
<button class="btn btn-sm btn-primary" @onclick="Save" disabled="@_saving">Save</button>
}
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit" disabled="@_saving">Cancel edit</button>
}
</div>
</header>
@if (_settings is null)
{
<p class="text-secondary my-3">
Bringin is a service that allows you to automatically convert your BTC to EUR and send it to your bank account. Start configuring it by clicking on the button below.
</p>
<div class="d-flex">
<button class="btn btn-lg btn-outline-primary" @onclick="Edit">Configure</button>
</div>
}
else if (_manualOrder)
{
var items = new List<PaymentMethodId>();
items.AddRange(BringinService.SupportedMethods.Where(s => _pms.Contains(s.PaymentMethod)).Select(s => s.PaymentMethod));
<div class="row">
<div class="col-xxl-constrain">
@if (!string.IsNullOrEmpty(SaveError))
{
<div class="alert alert-danger">@SaveError</div>
}
@if (!string.IsNullOrEmpty(ManualOrderResult))
{
<div class="alert alert-success">Payout created: @ManualOrderResult</div>
<div class="form-group">
<button class="btn btn-primary" @onclick="CancelManual">Go back</button>
</div>
}
else
{
<div class="form-group">
@* <label class="form-label">Payment method</label> *@
<select @bind="ManualOrderPaymentMethod" class="form-select">
@foreach (var opt in items)
{
<option value="">Select a payment method</option>
<option value="@opt.ToString()">@opt.ToPrettyString()</option>
}
</select>
</div>
@if (!string.IsNullOrEmpty(ManualOrderPaymentMethod))
{
// var fiat = BringinService.SupportedMethods.First(s => s.PaymentMethod.ToString() == ManualOrderPaymentMethod)?.FiatMinimum is true;
<div class="form-group">
<label class="form-label">Amount</label>
<div class="input-group input-group-sm">
<input type="number" @bind="ManualOrderAmount" min="" class="form-control form-control-sm"/>
<span class="input-group-text">BTC</span>
</div>
</div>
@* <div class="form-group"> *@
@* <div class="d-flex align-items-center"> *@
@* <input type="checkbox" class="btcpay-toggle me-2" @bind="ManualOrderPayout"/> *@
@* <label class="form-label mb-0 me-1">Create as Payout</label> *@
@* </div> *@
@* </div> *@
<div class="form-group">
<button class="btn btn-primary" disabled="@(ManualOrderAmount is null or <= 0 || string.IsNullOrEmpty(ManualOrderPaymentMethod) || _saving)" @onclick="SubmitOrder">Create order</button>
</div>
}
}
</div>
</div>
}
else if (!EditMode)
{
@if (LastFiatBalance is not null)
{
<div class="store-number">
<header>
<h6>Balance</h6>
</header>
<div class="balance d-flex align-items-baseline gap-1">
<h3 class="d-inline-block me-1" data-balance="LastFiatBalance" data-sensitive>@LastFiatBalance</h3>
<span class="text-secondary fw-semibold currency">EUR</span>
<span class="text-secondary">@TimeAgo</span>
</div>
</div>
}
@foreach (var method in _settings.MethodSettings)
{
var pmi = PaymentMethodId.TryParse(method.Key);
if (pmi is null)
continue;
if (!_pms.Contains(pmi))
continue;
<hr class=""/>
<div class="store-number">
<header>
<h6>@pmi.ToPrettyString()</h6>
</header>
@if (LastFiatRate is null || !method.Value.FiatThreshold)
{
<div class="balance d-flex align-items-baseline gap-1">
<h3 class="d-inline-block me-1" data-balance="@method.Value.CurrentBalance" data-sensitive>@DisplayFormatter.Currency(method.Value.CurrentBalance, "BTC", DisplayFormatter.CurrencyFormat.None)</h3>
<span class="text-secondary fw-semibold currency">BTC</span>
<span class="text-secondary">pending to forward once @DisplayFormatter.Currency(method.Value.Threshold, method.Value.FiatThreshold ? "EUR" : "BTC") is reached.</span>
</div>
}
else if (LastFiatRate is not null && method.Value.FiatThreshold)
{
var balanceInFiat = method.Value.CurrentBalance * LastFiatRate;
var thresholdinBtc = method.Value.Threshold / LastFiatRate;
var percentage = (balanceInFiat / method.Value.Threshold) * 100m;
<div class="balance d-flex align-items-baseline gap-1">
<h3 class="d-inline-block me-1" data-balance="@method.Value.CurrentBalance" data-sensitive>@DisplayFormatter.Currency(method.Value.CurrentBalance, "BTC", DisplayFormatter.CurrencyFormat.None)</h3>
<span class="text-secondary fw-semibold currency">BTC</span>
<span class="text-secondary"> (@DisplayFormatter.Currency(balanceInFiat.Value, "EUR", DisplayFormatter.CurrencyFormat.Code)) pending to forward once @DisplayFormatter.Currency(thresholdinBtc.Value, "BTC", DisplayFormatter.CurrencyFormat.Code) (@DisplayFormatter.Currency(method.Value.Threshold, "EUR")) is reached.</span>
</div>
}
@if (method.Value.PendingPayouts.Any())
{
<div class="balance d-flex align-items-baseline gap-1">
<h3 class="d-inline-block me-1" data-balance="@method.Value.PendingPayouts.Count">@method.Value.PendingPayouts.Count</h3>
<span class="text-secondary fw-semibold">pending payouts</span>
</div>
}
@if (!_pps.Contains(pmi) && PmiLink is not null)
{
<p class="text-warning">@((MarkupString)PmiLink)</p>
}
</div>
}
}
else
{
@if (!string.IsNullOrEmpty(SaveError))
{
<div class="alert alert-danger">@SaveError</div>
}
@if (string.IsNullOrEmpty(_settings.ApiKey) || ApiKeyError)
{
<div class="row">
<div class="col-xxl-constrain">
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-control" @oninput="() => InvokeAsync(StateHasChanged)" @bind="ApiKey"/>
<p class="my-2">You can get one <a href="https://dev-app.bringin.xyz/" target="_blank">here</a></p>
</div>
</div>
</div>
}
else
{
<div class="row">
<div class="col-xxl-constrain">
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-control" @bind="_settings.ApiKey"/>
<p class="my-2">You can get one <a href="https://dev-app.bringin.xyz/" target="_blank">here</a></p>
@if (ApiKeyError)
{
<div class="text-danger">Invalid API Key</div>
}
</div>
<div class="form-group">
<div class="d-flex align-items-center">
<input type="checkbox" class="btcpay-toggle me-2" @bind="_settings.Enabled"/>
<label class="form-label mb-0 me-1">Enabled</label>
</div>
</div>
</div>
</div>
<div class="row">
@foreach (var method in _settings.MethodSettings)
{
var pmId = PaymentMethodId.TryParse(method.Key);
if (pmId is null)
continue;
var supportedMethod = BringinService.SupportedMethods.FirstOrDefault(s => s.PaymentMethod.ToString() == method.Key);
<div class="card col-xxl-constrain col-12 @(_settings.MethodSettings.Count > 1 ? "col-xl-6" : "")">
<h5 class="card-header border-bottom-0 text-muted">@pmId.ToPrettyString()</h5>
<div class="card-body">
<div class="form-group">
<label class="form-label">Percentage</label>
<input type="range" value="@method.Value.PercentageToForward" @oninput="@((e) => UpdateDestinationValue(method.Value, e.Value))" min="0" step='0.01' class="form-range" max="100"/>
<div class="input-group input-group-sm">
<input type="number" step='0.01' value="@method.Value.PercentageToForward" @onchange="@((e) => UpdateDestinationValue(method.Value, e.Value))" class="form-control form-control-sm"/>
<span class="input-group-text">%</span>
</div>
<p class="text-muted my-2">Every time an invoice becomes Settled, we take the sum of all settled payments of this payment method, get the specified percentage of it and add it to the current balance.</p>
</div>
<div class="form-group">
<label class="form-label">Threshold</label>
<div class="input-group input-group-sm">
<input type="number" @bind="method.Value.Threshold" min="@supportedMethod?.FiatMinimumAmount" class="form-control form-control-sm"/>
<span class="input-group-text">@(method.Value.FiatThreshold ? "EUR" : "BTC")</span>
</div>
<p class="text-muted my-2">Once the threshold is reached, we create a payout sending the balance to Bringin to be converted.</p>
</div>
@if (supportedMethod?.FiatMinimum is not true)
{
<div class="form-group">
<div class="d-flex align-items-center">
<input type="checkbox" class="btcpay-toggle me-2" @bind="method.Value.FiatThreshold"/>
<label class="form-label mb-0 me-1">Threshold in EUR</label>
</div>
</div>
}
</div>
</div>
}
</div>
}
}
}
</div>

View File

@@ -0,0 +1,8 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using System.IO

View File

@@ -0,0 +1,6 @@
@using BTCPayServer.Abstractions.Extensions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@{
ViewData.SetActivePage("Bringin", "Bringin", "Bringin");
}
<partial name="Bringin/BringinDashboardWidget"/>

View File

@@ -1,28 +0,0 @@
@model BTCPayServer.Abstractions.Form.Field
@{
var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
}
<div class="form-group">
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Safe.Raw(Model.Label)
</label>
<input id="@Model.Name" type="password" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="@Model.Value" data-val="true" readonly="@Model.Constant"
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
}
/>
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
@if (!string.IsNullOrEmpty(Model.HelpText))
{
<div id="@($"HelpText-{Model.Name}")" class="form-text">
@Safe.Raw(Model.HelpText)
</div>
}
</div>

View File

@@ -0,0 +1,9 @@
@using BTCPayServer.Plugins.Bringin.Components
@using BTCPayServer.Abstractions.Contracts
@model BTCPayServer.Models.StoreViewModels.StoreDashboardViewModel
@inject IScopeProvider ScopeProvider
@(await Html.RenderComponentAsync<BringinWidget>(RenderMode.ServerPrerendered, new
{
StoreId = ScopeProvider.GetCurrentStoreId()
}))

View File

@@ -0,0 +1,23 @@
@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 permission="@Policies.CanModifyStoreSettings" asp-controller="Bringin" asp-action="Edit" asp-route-storeId="@storeId" class="nav-link @ViewData.IsActivePage("Bringin")" id="Nav-Bringin">
<svg xmlns="http://www.w3.org/2000/svg" style="margin-right: 7px;" width="14.000000pt" height="14.000000pt" viewBox="0 0 32.000000 32.000000" preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,32.000000) scale(0.100000,-0.100000)" fill="currentColor" stroke="none">
<path d="M92 304 c-18 -10 -45 -33 -60 -52 -22 -29 -27 -46 -27 -92 0 -47 5 -63 29 -94 87 -115 267 -67 283 76 7 57 -15 108 -61 143 -46 35 -116 43 -164 19z m101 -102 c33 -29 19 -78 -32 -107 -11 -6 -13 -4 -8 8 6 16 -11 41 -35 50 -9 3 -4 12 14 26 18 14 25 27 21 38 -7 18 8 12 40 -15z"/>
</g>
</svg>
<span>Bringin</span>
</a>
</li>
}

View File

@@ -11,7 +11,7 @@
<PropertyGroup>
<Product>Prism</Product>
<Description>Automated value splits for Bitcoin.</Description>
<Version>1.2.5</Version>
<Version>1.2.6</Version>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<!-- Plugin development properties -->

View File

@@ -4,16 +4,12 @@
@using BTCPayServer.HostedServices
@using BTCPayServer.Payments
@using BTCPayServer.PayoutProcessors
@using BTCPayServer.Services.Custodian.Client
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Routing
@using Microsoft.Extensions.Logging
@using NBitcoin
@using LightningAddressData = BTCPayServer.Data.LightningAddressData
@using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest
@using System.Collections
@using BTCPayServer.Abstractions.Custodians
@using BTCPayServer.Abstractions.Extensions
@inject IPluginHookService PluginHookService
@inject LightningAddressService LightningAddressService
@inject PayoutProcessorService PayoutProcessorService
@@ -22,8 +18,6 @@
@inject LinkGenerator LinkGenerator
@inject PullPaymentHostedService PullPaymentHostedService
@inject IHttpContextAccessor HttpContextAccessor
@inject CustodianAccountRepository CustodianAccountRepository
@inject IEnumerable<ICustodian> Custodians
@inject ILogger<PrismEdit> Logger
@implements IDisposable
@@ -47,10 +41,6 @@ else
{
<option value="@destination">@destination</option>
}
@foreach (var destination in CustodianDestinations)
{
<option value="@destination.Key">@destination.Value</option>
}
</datalist>
@@ -215,7 +205,6 @@ else
public PaymentMethodId pmichain { get; set; } = new("BTC", PaymentTypes.BTCLike);
public bool NoPayoutProcessors { get; set; }
public Dictionary<string, string> CustodianDestinations { get; set; }
private string PrismEditButtonsFilter { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -247,34 +236,6 @@ else
await Task.WhenAll(tasks);
Settings = await fetchSettings;
Users = await fetchLnAddresses;
CustodianDestinations = await FetchCustodians();
async Task<Dictionary<string, string>> FetchCustodians()
{
var result = new Dictionary<string, string>();
var custodianConfigs = await CustodianAccountRepository.FindByStoreId(StoreId);
foreach (var custodianConfig in custodianConfigs)
{
var custodian = Custodians.GetCustodianByCode(custodianConfig.CustodianCode);
if(custodian is not ICanDeposit canDeposit)
continue;
foreach (var depositablePaymentMethod in canDeposit.GetDepositablePaymentMethods())
{
var pmi = PaymentMethodId.TryParse(depositablePaymentMethod);
if(pmi is null || pmi.CryptoCode != "BTC")
continue;
var custodianDestination = new CustodianDestinationValidator.CustodianDestination()
{
CustodianId = custodianConfig.Id,
PaymentMethod = pmi.ToString()
};
result.TryAdd(custodianDestination.ToString(), $"{custodianConfig.Name} {pmi.ToPrettyString()} (Custodian)");
}
}
return result;
}
EditContext = new EditContext(Settings);
MessageStore = new ValidationMessageStore(EditContext);

View File

@@ -1,85 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.Payments;
using BTCPayServer.Services.Custodian.Client;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Prism;
public class CustodianDestinationValidator : IPluginHookFilter
{
private readonly IServiceProvider _serviceProvider;
public string Hook => "prism-destination-validate";
public CustodianDestinationValidator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<object> Execute(object args)
{
var result = new PrismDestinationValidationResult();
if (args is not string args1 || !args1.StartsWith("custodian:")) return args;
try
{
var custodianDestination =
JObject.Parse(args1.Substring("custodian:".Length)).ToObject<CustodianDestination>();
var custodianPaymentMethod = custodianDestination.PaymentMethod is null
? new PaymentMethodId("BTC", PaymentTypes.LightningLike)
: PaymentMethodId.Parse(custodianDestination.PaymentMethod);
result.PaymentMethod = custodianPaymentMethod;
await using var ctx = _serviceProvider.GetService<ApplicationDbContextFactory>().CreateContext();
var custodianAccountData = ctx.CustodianAccount.SingleOrDefault(data => data.Id == custodianDestination.CustodianId);
if (custodianAccountData is null)
{
result.Success = false;
return result;
}
var custdodian = _serviceProvider.GetServices<ICustodian>().GetCustodianByCode(custodianAccountData.CustodianCode);
if (custdodian is null)
{
result.Success = false;
return result;
}
if (custdodian is ICanDeposit canDeposit &&
canDeposit.GetDepositablePaymentMethods() is { } paymentMethods &&
paymentMethods.Any(s => PaymentMethodId.TryParse(s) == custodianPaymentMethod))
{
result.Success = true;
return result;
}
result.Success = false;
return result;
}
catch (Exception e)
{
result.Success = false;
return result;
}
}
public class CustodianDestination
{
public string CustodianId { get; set; }
public string PaymentMethod { get; set; }
override public string ToString()
{
return $"custodian:{JObject.FromObject(this)}";
}
}
}

View File

@@ -1,104 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Prism;
public class CustodianPrismClaimCreate : IPluginHookFilter
{
private readonly IServiceProvider _serviceProvider;
public string Hook => "prism-claim-create";
public CustodianPrismClaimCreate(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<object> Execute(object args)
{
if (args is not ClaimRequest claimRequest)
{
return args;
}
if (claimRequest.Destination?.ToString() is not { } args1 || !args1.StartsWith("custodian:")) return args;
try
{
var custodianDestination = JObject.Parse(args1.Substring("custodian:".Length))
.ToObject<CustodianDestinationValidator.CustodianDestination>();
var custodianPaymentMethod = custodianDestination.PaymentMethod is null
? new PaymentMethodId("BTC", PaymentTypes.LightningLike)
: PaymentMethodId.Parse(custodianDestination.PaymentMethod);
await using var ctx = _serviceProvider.GetRequiredService<ApplicationDbContextFactory>().CreateContext();
var custodianAccountData = await ctx.CustodianAccount.SingleOrDefaultAsync(data => data.Id == custodianDestination.CustodianId);
if (custodianAccountData is null)
{
return null;
}
var custdodian = _serviceProvider.GetServices<ICustodian>().GetCustodianByCode(custodianAccountData.CustodianCode);
if (custdodian is null)
{
return null;
}
if (custdodian is not ICanDeposit canDeposit ||
canDeposit.GetDepositablePaymentMethods() is { } paymentMethods &&
paymentMethods.Any(s => PaymentMethodId.TryParse(s) == custodianPaymentMethod))
{
return null;
}
var handler = _serviceProvider.GetServices<IPayoutHandler>().FindPayoutHandler(custodianPaymentMethod);
if (handler is null)
{
return null;
}
var config = custodianAccountData.GetBlob();
config["depositAddressConfig"] = JToken.FromObject(new
{
amount = claimRequest.Value
});
var depositAddressAsync =
await canDeposit.GetDepositAddressAsync(custodianPaymentMethod.ToString(),config, CancellationToken.None);
if (depositAddressAsync.Address is null)
{
return null;
}
var claimDestination = await handler.ParseClaimDestination(custodianPaymentMethod,
depositAddressAsync.Address, CancellationToken.None);
if (claimDestination.destination is null)
{
return null;
}
claimRequest.Destination = claimDestination.destination;
claimRequest.PaymentMethodId = custodianPaymentMethod;
return claimRequest;
}
catch (Exception e)
{
return null;
}
}
}

View File

@@ -0,0 +1,96 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Payments;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Prism;
public class OpenSatsDestinationValidator : IPluginHookFilter
{
private readonly IServiceProvider _serviceProvider;
public string Hook => "prism-destination-validate";
public OpenSatsDestinationValidator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<object> Execute(object args)
{
var result = new PrismDestinationValidationResult();
if (args is not string args1 || !args1.StartsWith("opensats")) return args;
try
{
var parts = args1.Split(":", StringSplitOptions.RemoveEmptyEntries);
var project = "opensats";
var paymentMethod = new PaymentMethodId("BTC", PaymentTypes.LightningLike);
if (parts.Length > 1)
{
project = parts[1];
}
if (parts.Length > 2)
{
paymentMethod = PaymentMethodId.Parse(parts[2]);
}
var handler = _serviceProvider.GetServices<IPayoutHandler>().FindPayoutHandler(paymentMethod);
if (handler is null)
{
result.Success = false;
}
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("opensats");
var content = new StringContent(JObject.FromObject(new
{
btcpay = project,
name = "kukks <3 you"
}).ToString(), Encoding.UTF8, "application/json");
var xResult = await httpClient.PostAsync("https://opensats.org/api/btcpay",content).ConfigureAwait(false);
var rawInvoice = JObject.Parse(await xResult.Content.ReadAsStringAsync().ConfigureAwait(false));
var invoiceUrl = $"{rawInvoice.Value<string>("checkoutLink").TrimEnd('/')}/{paymentMethod}/status";
var invoiceBtcpayModel = JObject.Parse(await httpClient.GetStringAsync(invoiceUrl).ConfigureAwait(false));
var destination = invoiceBtcpayModel.Value<string>("btcAddress");
var claimDestination = await handler.ParseClaimDestination(paymentMethod,destination, CancellationToken.None);
if (claimDestination.destination is null)
{
result.Success = false;
}
result.Success = true;
result.PaymentMethod = paymentMethod;
return result;
}
catch (Exception e)
{
result.Success = false;
return result;
}
}
public class CustodianDestination
{
public string CustodianId { get; set; }
public string PaymentMethod { get; set; }
override public string ToString()
{
return $"custodian:{JObject.FromObject(this)}";
}
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Prism;
public class OpenSatsPrismClaimCreate : IPluginHookFilter
{
private readonly IServiceProvider _serviceProvider;
public string Hook => "prism-claim-create";
public OpenSatsPrismClaimCreate(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<object> Execute(object args)
{
if (args is not ClaimRequest claimRequest)
{
return args;
}
if (claimRequest.Destination?.ToString() is not { } args1 || !args1.StartsWith("opensats")) return args;
try
{
var parts = args1.Split(":", StringSplitOptions.RemoveEmptyEntries);
var project = "opensats";
var paymentMethod = new PaymentMethodId("BTC", PaymentTypes.LightningLike);
if (parts.Length > 1)
{
project = parts[1];
}
if (parts.Length > 2)
{
paymentMethod = PaymentMethodId.Parse(parts[2]);
}
var handler = _serviceProvider.GetServices<IPayoutHandler>().FindPayoutHandler(paymentMethod);
if (handler is null)
{
return null;
}
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient("opensats");
var content = new StringContent(JObject.FromObject(new
{
project_name = project,
project_slug = project,
name = "kukks <3 you"
}).ToString(), Encoding.UTF8, "application/json");
var result = await httpClient.PostAsync("https://opensats.org/api/btcpay",content).ConfigureAwait(false);
var rawInvoice = JObject.Parse(await result.Content.ReadAsStringAsync().ConfigureAwait(false));
var invoiceUrl = $"{rawInvoice.Value<string>("checkoutLink").TrimEnd('/')}/{paymentMethod}/status";
var invoiceBtcpayModel = JObject.Parse(await httpClient.GetStringAsync(invoiceUrl).ConfigureAwait(false));
var destination = invoiceBtcpayModel.Value<string>("btcAddress");
var claimDestination = await handler.ParseClaimDestination(paymentMethod,destination, CancellationToken.None);
if (claimDestination.destination is null)
{
return null;
}
claimRequest.Destination = claimDestination.destination;
claimRequest.PaymentMethodId = paymentMethod;
return claimRequest;
}
catch (Exception)
{
return null;
}
}
}

View File

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

View File

@@ -85,8 +85,7 @@ public class WabisabiScriptResolver: WabiSabiConfig.CoordinatorScriptResolver
}
var content = new StringContent(JObject.FromObject(new
{
project_name = value,
project_slug = value,
btcpay = value,
name = "kukks <3 you"
}).ToString(), Encoding.UTF8, "application/json");
var result = await httpClient.PostAsync("https://opensats.org/api/btcpay",content, cancellationToken).ConfigureAwait(false);