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 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 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(); var tcs = new TaskCompletionSource(); Stopwatch stopwatch = new(); stopwatch.Start(); nostrClient.MessageReceived += (sender, s) => { if (JArray.Parse(s).FirstOrDefault()?.Value() == "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 Spend(string storeId) { if ((await _walletProvider.GetWalletAsync(storeId)) is not BTCPayWallet wallet) { return NotFound(); } return View(new SpendViewModel() { }); } [HttpPost("spend")] public async Task 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()) .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() { 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; } } } }