mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
376 lines
17 KiB
C#
376 lines
17 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Security.Claims;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AngleSharp.Dom.Events;
|
|
using BTCPayServer.Abstractions.Constants;
|
|
using BTCPayServer.Abstractions.Contracts;
|
|
using BTCPayServer.Client;
|
|
using BTCPayServer.Client.Models;
|
|
using BTCPayServer.Common;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
|
using NBitcoin;
|
|
using NBitcoin.Payment;
|
|
using NBXplorer;
|
|
using Newtonsoft.Json.Linq;
|
|
using NNostr.Client;
|
|
using WalletWasabi.Backend.Controllers;
|
|
using WalletWasabi.Blockchain.TransactionBuilding;
|
|
using WalletWasabi.Blockchain.TransactionOutputs;
|
|
|
|
namespace BTCPayServer.Plugins.Wabisabi
|
|
{
|
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
[Route("plugins/{storeId}/Wabisabi")]
|
|
public partial class WabisabiStoreController : Controller
|
|
{
|
|
private readonly WabisabiService _WabisabiService;
|
|
private readonly WalletProvider _walletProvider;
|
|
private readonly IBTCPayServerClientFactory _btcPayServerClientFactory;
|
|
private readonly IExplorerClientProvider _explorerClientProvider;
|
|
private readonly WabisabiCoordinatorService _wabisabiCoordinatorService;
|
|
private readonly WabisabiCoordinatorClientInstanceManager _instanceManager;
|
|
|
|
public WabisabiStoreController(WabisabiService WabisabiService, WalletProvider walletProvider,
|
|
IBTCPayServerClientFactory btcPayServerClientFactory,
|
|
IExplorerClientProvider explorerClientProvider,
|
|
WabisabiCoordinatorService wabisabiCoordinatorService,
|
|
WabisabiCoordinatorClientInstanceManager instanceManager)
|
|
{
|
|
_WabisabiService = WabisabiService;
|
|
_walletProvider = walletProvider;
|
|
_btcPayServerClientFactory = btcPayServerClientFactory;
|
|
_explorerClientProvider = explorerClientProvider;
|
|
_wabisabiCoordinatorService = wabisabiCoordinatorService;
|
|
_instanceManager = instanceManager;
|
|
}
|
|
|
|
[HttpGet("")]
|
|
public async Task<IActionResult> UpdateWabisabiStoreSettings(string storeId)
|
|
{
|
|
WabisabiStoreSettings Wabisabi = null;
|
|
try
|
|
{
|
|
Wabisabi = await _WabisabiService.GetWabisabiForStore(storeId);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
return View(Wabisabi);
|
|
}
|
|
|
|
public const int coordinatorEventKind = 15750;
|
|
|
|
[HttpPost("")]
|
|
public async Task<IActionResult> UpdateWabisabiStoreSettings(string storeId, WabisabiStoreSettings vm,
|
|
string command)
|
|
{
|
|
var pieces = command.Split(":");
|
|
var actualCommand = pieces[0];
|
|
var commandIndex = pieces.Length > 1 ? pieces[1] : null;
|
|
var coordinator = pieces.Length > 2 ? pieces[2] : null;
|
|
var coord = vm.Settings.SingleOrDefault(settings => settings.Coordinator == coordinator);
|
|
ModelState.Clear();
|
|
|
|
WabisabiCoordinatorSettings coordSettings;
|
|
switch (actualCommand)
|
|
{
|
|
case "discover":
|
|
coordSettings = await _wabisabiCoordinatorService.GetSettings();
|
|
var relay = commandIndex ??
|
|
(await _wabisabiCoordinatorService.GetSettings())?.NostrRelay.ToString();
|
|
|
|
if (Uri.TryCreate(relay, UriKind.Absolute, out var relayUri))
|
|
{
|
|
using var nostrClient = new NostrClient(relayUri);
|
|
await nostrClient.CreateSubscription("nostr-wabisabi-coordinators",
|
|
new[]
|
|
{
|
|
new NostrSubscriptionFilter()
|
|
{
|
|
Kinds = new[] {coordinatorEventKind},
|
|
Since = DateTimeOffset.UtcNow.Subtract(TimeSpan.FromHours(1)),
|
|
}
|
|
});
|
|
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1));
|
|
await nostrClient.ConnectAndWaitUntilConnected(cts.Token);
|
|
_ = nostrClient.ListenForMessages();
|
|
var result = new List<NostrEvent>();
|
|
var tcs = new TaskCompletionSource();
|
|
Stopwatch stopwatch = new();
|
|
stopwatch.Start();
|
|
nostrClient.MessageReceived += (sender, s) =>
|
|
{
|
|
if (JArray.Parse(s).FirstOrDefault()?.Value<string>() == "EOSE")
|
|
{
|
|
tcs.SetResult();
|
|
}
|
|
};
|
|
nostrClient.EventsReceived += (sender, tuple) =>
|
|
{
|
|
stopwatch.Restart();
|
|
result.AddRange(tuple.events);
|
|
};
|
|
while (!tcs.Task.IsCompleted && !cts.IsCancellationRequested &&
|
|
stopwatch.ElapsedMilliseconds < 10000)
|
|
{
|
|
await Task.Delay(1000, cts.Token);
|
|
}
|
|
|
|
nostrClient.Dispose();
|
|
|
|
var network = _explorerClientProvider.GetExplorerClient("BTC").Network.NBitcoinNetwork.Name
|
|
.ToLower();
|
|
ViewBag.DiscoveredCoordinators = result.Where(@event =>
|
|
@event.CreatedAt < DateTimeOffset.UtcNow.AddMinutes(15) &&
|
|
@event.Verify() &&
|
|
@event.Tags.Any(tag =>
|
|
tag.TagIdentifier == "uri" &&
|
|
tag.Data.Any(s => Uri.IsWellFormedUriString(s, UriKind.Absolute))) &&
|
|
@event.Tags.Any(tag =>
|
|
tag.TagIdentifier == "network" && tag.Data.FirstOrDefault() == network)
|
|
).Select(@event => new DiscoveredCoordinator()
|
|
{
|
|
Name = @event.PublicKey,
|
|
Uri = new Uri(@event.GetTaggedData("uri")
|
|
.First(s => Uri.IsWellFormedUriString(s, UriKind.Absolute)))
|
|
}).Where(discoveredCoordinator => string.IsNullOrEmpty(coordSettings.NostrIdentity) || discoveredCoordinator.Name != coordSettings.PubKey?.ToHex()).ToList();
|
|
}
|
|
else
|
|
{
|
|
TempData["ErrorMessage"] = $"No relay uri was provided";
|
|
}
|
|
|
|
return View(vm);
|
|
case "add-coordinator":
|
|
var name = commandIndex;
|
|
var uri = coordinator;
|
|
|
|
coordSettings = await _wabisabiCoordinatorService.GetSettings();
|
|
if (coordSettings.DiscoveredCoordinators.All(discoveredCoordinator =>
|
|
discoveredCoordinator.Name != name))
|
|
{
|
|
coordSettings.DiscoveredCoordinators.Add(new DiscoveredCoordinator() {Name = name,});
|
|
await _wabisabiCoordinatorService.UpdateSettings(coordSettings);
|
|
_instanceManager.AddCoordinator($"nostr[{name}]", name, provider => new Uri(uri));
|
|
|
|
TempData["SuccessMessage"] = $"Coordinator {commandIndex} added and started";
|
|
return RedirectToAction(nameof(UpdateWabisabiStoreSettings), new {storeId});
|
|
}
|
|
|
|
else
|
|
{
|
|
TempData["ErrorMessage"] =
|
|
$"Coordinator {commandIndex} could not be added because the name was not unique";
|
|
return View(vm);
|
|
}
|
|
|
|
break;
|
|
case "remove-coordinator":
|
|
coordSettings = await _wabisabiCoordinatorService.GetSettings();
|
|
if (coordSettings.DiscoveredCoordinators.RemoveAll(discoveredCoordinator =>
|
|
discoveredCoordinator.Name == commandIndex) > 0)
|
|
{
|
|
TempData["SuccessMessage"] = $"Coordinator {commandIndex} stopped and removed";
|
|
await _wabisabiCoordinatorService.UpdateSettings(coordSettings);
|
|
await _instanceManager.RemoveCoordinator(commandIndex);
|
|
return RedirectToAction(nameof(UpdateWabisabiStoreSettings), new {storeId});
|
|
}
|
|
else
|
|
{
|
|
TempData["ErrorMessage"] =
|
|
$"Coordinator {commandIndex} could not be removed because it was not found";
|
|
}
|
|
|
|
return View(vm);
|
|
break;
|
|
case "check":
|
|
await _walletProvider.Check(storeId, CancellationToken.None);
|
|
TempData["SuccessMessage"] = "Store wallet re-checked";
|
|
return RedirectToAction(nameof(UpdateWabisabiStoreSettings), new {storeId});
|
|
case "exclude-label-add":
|
|
vm.InputLabelsExcluded.Add("");
|
|
return View(vm);
|
|
|
|
case "exclude-label-remove":
|
|
vm.InputLabelsExcluded.Remove(commandIndex);
|
|
return View(vm);
|
|
case "include-label-add":
|
|
vm.InputLabelsAllowed.Add("");
|
|
return View(vm);
|
|
case "include-label-remove":
|
|
vm.InputLabelsAllowed.Remove(commandIndex);
|
|
return View(vm);
|
|
|
|
case "save":
|
|
foreach (WabisabiStoreCoordinatorSettings settings in vm.Settings)
|
|
{
|
|
vm.InputLabelsAllowed = vm.InputLabelsAllowed.Where(s => !string.IsNullOrEmpty(s)).Distinct()
|
|
.ToList();
|
|
vm.InputLabelsExcluded = vm.InputLabelsExcluded.Where(s => !string.IsNullOrEmpty(s)).Distinct()
|
|
.ToList();
|
|
}
|
|
|
|
await _WabisabiService.SetWabisabiForStore(storeId, vm);
|
|
TempData["SuccessMessage"] = "Wabisabi settings modified";
|
|
return RedirectToAction(nameof(UpdateWabisabiStoreSettings), new {storeId});
|
|
|
|
default:
|
|
return View(vm);
|
|
}
|
|
}
|
|
|
|
[HttpGet("spend")]
|
|
public async Task<IActionResult> Spend(string storeId)
|
|
{
|
|
if ((await _walletProvider.GetWalletAsync(storeId)) is not BTCPayWallet wallet)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return View(new SpendViewModel() { });
|
|
}
|
|
|
|
[HttpPost("spend")]
|
|
public async Task<IActionResult> Spend(string storeId, SpendViewModel spendViewModel, string command)
|
|
{
|
|
if ((await _walletProvider.GetWalletAsync(storeId)) is not BTCPayWallet wallet)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var n = _explorerClientProvider.GetExplorerClient("BTC").Network.NBitcoinNetwork;
|
|
if (string.IsNullOrEmpty(spendViewModel.Destination))
|
|
{
|
|
ModelState.AddModelError(nameof(spendViewModel.Destination),
|
|
"A destination is required");
|
|
}
|
|
else
|
|
{
|
|
try
|
|
{
|
|
BitcoinAddress.Create(spendViewModel.Destination, n);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
try
|
|
{
|
|
new BitcoinUrlBuilder(spendViewModel.Destination, n);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
ModelState.AddModelError(nameof(spendViewModel.Destination),
|
|
"A destination must be a bitcoin address or a bip21 payment link");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (spendViewModel.Amount is null)
|
|
{
|
|
try
|
|
{
|
|
spendViewModel.Amount =
|
|
new BitcoinUrlBuilder(spendViewModel.Destination, n).Amount.ToDecimal(MoneyUnit.BTC);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
ModelState.AddModelError(nameof(spendViewModel.Amount),
|
|
"An amount was not specified and the destination did not have an amount specified");
|
|
}
|
|
}
|
|
|
|
if (!ModelState.IsValid)
|
|
{
|
|
return View();
|
|
}
|
|
|
|
if (command == "payout")
|
|
{
|
|
var client = await _btcPayServerClientFactory.Create(null, storeId);
|
|
await client.CreatePayout(storeId,
|
|
new CreatePayoutThroughStoreRequest()
|
|
{
|
|
Approved = true, Amount = spendViewModel.Amount, Destination = spendViewModel.Destination
|
|
});
|
|
|
|
TempData["SuccessMessage"] =
|
|
"The payment has been scheduled. If payment batching is enabled in the coinjoin settings, and the coordinator supports sending that amount and that address type, it will be batched.";
|
|
|
|
return RedirectToAction("UpdateWabisabiStoreSettings", new {storeId});
|
|
}
|
|
|
|
var coins = await wallet.GetAllCoins();
|
|
if (command == "compute-with-selection")
|
|
{
|
|
if (spendViewModel.SelectedCoins?.Any() is true)
|
|
{
|
|
coins = (CoinsView)coins.FilterBy(coin =>
|
|
spendViewModel.SelectedCoins.Contains(coin.Outpoint.ToString()));
|
|
}
|
|
}
|
|
|
|
if (command == "compute-with-selection" || command == "compute")
|
|
{
|
|
if (spendViewModel.Amount is null)
|
|
{
|
|
spendViewModel.Amount =
|
|
new BitcoinUrlBuilder(spendViewModel.Destination, n).Amount.ToDecimal(MoneyUnit.BTC);
|
|
}
|
|
|
|
var defaultCoinSelector = new DefaultCoinSelector();
|
|
var defaultSelection =
|
|
(defaultCoinSelector.Select(coins.Select(coin => coin.Coin).ToArray(),
|
|
new Money((decimal)spendViewModel.Amount, MoneyUnit.BTC)) ?? Array.Empty<ICoin>())
|
|
.ToArray();
|
|
var selector = new SmartCoinSelector(coins.ToList());
|
|
var smartSelection = selector.Select(defaultSelection,
|
|
new Money((decimal)spendViewModel.Amount, MoneyUnit.BTC));
|
|
spendViewModel.SelectedCoins = smartSelection.Select(coin => coin.Outpoint.ToString()).ToArray();
|
|
return View(spendViewModel);
|
|
}
|
|
|
|
if (command == "send")
|
|
{
|
|
var userid = HttpContext.User.Claims.Single(claim => claim.Type == ClaimTypes.NameIdentifier).Value;
|
|
var client = await _btcPayServerClientFactory.Create(userid, storeId);
|
|
var tx = await client.CreateOnChainTransaction(storeId, "BTC",
|
|
new CreateOnChainTransactionRequest()
|
|
{
|
|
SelectedInputs = spendViewModel.SelectedCoins?.Select(OutPoint.Parse).ToList(),
|
|
Destinations =
|
|
new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
|
|
{
|
|
new CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination()
|
|
{
|
|
Destination = spendViewModel.Destination, Amount = spendViewModel.Amount
|
|
}
|
|
}
|
|
});
|
|
|
|
TempData["SuccessMessage"] =
|
|
$"The tx {tx.TransactionHash} has been broadcast.";
|
|
|
|
return RedirectToAction("UpdateWabisabiStoreSettings", new {storeId});
|
|
}
|
|
|
|
return View(spendViewModel);
|
|
}
|
|
|
|
|
|
public class SpendViewModel
|
|
{
|
|
public string Destination { get; set; }
|
|
public decimal? Amount { get; set; }
|
|
public string[] SelectedCoins { get; set; }
|
|
}
|
|
}
|
|
}
|