using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Models; using BTCPayServer.BIP78.Sender; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments.PayJoin.Sender; using BTCPayServer.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using NBitcoin; using NBitcoin.Payment; using NBXplorer; using NBXplorer.Models; namespace BTCPayServer.Controllers { public partial class UIWalletsController { [NonAction] public async Task CreatePSBT(BTCPayNetwork network, DerivationSchemeSettings derivationSettings, WalletSendModel sendModel, CancellationToken cancellationToken) { var nbx = ExplorerClientProvider.GetExplorerClient(network); CreatePSBTRequest psbtRequest = new CreatePSBTRequest(); if (sendModel.InputSelection) { psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList() ?? new List(); } foreach (var transactionOutput in sendModel.Outputs) { var psbtDestination = new CreatePSBTDestination(); psbtRequest.Destinations.Add(psbtDestination); psbtDestination.Destination = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork); psbtDestination.Amount = Money.Coins(transactionOutput.Amount.Value); psbtDestination.SubstractFees = transactionOutput.SubtractFeesFromOutput; } if (network.SupportRBF) { if (sendModel.AllowFeeBump is WalletSendModel.ThreeStateBool.Yes) psbtRequest.RBF = true; if (sendModel.AllowFeeBump is WalletSendModel.ThreeStateBool.No) psbtRequest.RBF = false; } psbtRequest.AlwaysIncludeNonWitnessUTXO = sendModel.AlwaysIncludeNonWitnessUTXO; psbtRequest.FeePreference = new FeePreference(); if (sendModel.FeeSatoshiPerByte is decimal v && v > decimal.Zero) { psbtRequest.FeePreference.ExplicitFeeRate = new FeeRate(v); } if (sendModel.NoChange) { psbtRequest.ExplicitChangeAddress = psbtRequest.Destinations.First().Destination; } var psbt = (await nbx.CreatePSBTAsync(derivationSettings.AccountDerivation, psbtRequest, cancellationToken)); if (psbt == null) throw new NotSupportedException("You need to update your version of NBXplorer"); // Not supported by coldcard, remove when they do support it psbt.PSBT.GlobalXPubs.Clear(); return psbt; } [HttpPost("{walletId}/cpfp")] public async Task WalletCPFP([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string[] outpoints, string[] transactionHashes, string returnUrl) { outpoints ??= Array.Empty(); transactionHashes ??= Array.Empty(); var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var explorer = ExplorerClientProvider.GetExplorerClient(network); var fr = _feeRateProvider.CreateFeeProvider(network); var targetFeeRate = await fr.GetFeeRateAsync(1); // Since we don't know the actual fee rate paid by a tx from NBX // we just assume that it is 20 blocks var assumedFeeRate = await fr.GetFeeRateAsync(20); var derivationScheme = (this.GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, network.CryptoCode))?.AccountDerivation; if (derivationScheme is null) return NotFound(); var utxos = await explorer.GetUTXOsAsync(derivationScheme); var outpointsHashet = outpoints.ToHashSet(); var transactionHashesSet = transactionHashes.ToHashSet(); var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && (outpointsHashet.Contains(u.Outpoint.ToString()) || transactionHashesSet.Contains(u.Outpoint.Hash.ToString()))).ToArray(); if (bumpableUTXOs.Length == 0) { TempData[WellKnownTempData.ErrorMessage] = "There isn't any UTXO available to bump fee"; return LocalRedirect(returnUrl); } Money bumpFee = Money.Zero; foreach (var txid in bumpableUTXOs.Select(u => u.TransactionHash).ToHashSet()) { var tx = await explorer.GetTransactionAsync(txid); var vsize = tx.Transaction.GetVirtualSize(); var assumedFeePaid = assumedFeeRate.GetFee(vsize); var expectedFeePaid = targetFeeRate.GetFee(vsize); bumpFee += Money.Max(Money.Zero, expectedFeePaid - assumedFeePaid); } var returnAddress = (await explorer.GetUnusedAsync(derivationScheme, NBXplorer.DerivationStrategy.DerivationFeature.Deposit)).Address; TransactionBuilder builder = explorer.Network.NBitcoinNetwork.CreateTransactionBuilder(); builder.AddCoins(bumpableUTXOs.Select(utxo => utxo.AsCoin(derivationScheme))); // The fee of the bumped transaction should pay for both, the fee // of the bump transaction and those that are being bumped builder.SendEstimatedFees(targetFeeRate); builder.SendFees(bumpFee); builder.SendAll(returnAddress); try { var psbt = builder.BuildPSBT(false); psbt = (await explorer.UpdatePSBTAsync(new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = derivationScheme })).PSBT; return View("PostRedirect", new PostRedirectViewModel { AspController = "UIWallets", AspAction = nameof(WalletSign), RouteParameters = { { "walletId", walletId.ToString() } }, FormParameters = { { "walletId", walletId.ToString() }, { "psbt", psbt.ToHex() }, { "backUrl", returnUrl }, { "returnUrl", returnUrl } } }); } catch (Exception ex) { TempData[WellKnownTempData.ErrorMessage] = ex.Message; return LocalRedirect(returnUrl); } } [HttpPost("{walletId}/sign")] public async Task WalletSign([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTViewModel vm, string command = null) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var psbt = await vm.GetPSBT(network.NBitcoinNetwork); vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath; if (psbt is null || vm.InvalidPSBT) { ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT"); return View("WalletSigningOptions", new WalletSigningOptionsModel { SigningContext = vm.SigningContext, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); } switch (command) { case "vault": return ViewVault(walletId, vm); case "seed": return SignWithSeed(walletId, vm.SigningContext, vm.ReturnUrl, vm.BackUrl); case "decode": return await WalletPSBT(walletId, vm, "decode"); default: break; } if (await CanUseHotWallet()) { var derivationScheme = GetDerivationSchemeSettings(walletId); if (derivationScheme.IsHotWallet) { var extKey = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) .GetMetadataAsync(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey); if (extKey != null) { return await SignWithSeed(walletId, new SignWithSeedViewModel { SeedOrKey = extKey, SigningContext = vm.SigningContext, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); } } } return View("WalletSigningOptions", new WalletSigningOptionsModel { SigningContext = vm.SigningContext, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); } [HttpGet("{walletId}/psbt")] public async Task WalletPSBT([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string returnUrl) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var referer = HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath; var vm = new WalletPSBTViewModel { BackUrl = string.IsNullOrEmpty(returnUrl) ? null : referer, ReturnUrl = returnUrl ?? referer, CryptoCode = network.CryptoCode }; var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet; return View(vm); } [HttpPost("{walletId}/psbt")] public async Task WalletPSBT( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTViewModel vm, string command) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); vm.CryptoCode = network.CryptoCode; var derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet; vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath; var psbt = await vm.GetPSBT(network.NBitcoinNetwork); if (vm.InvalidPSBT) { ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT"); return View(vm); } if (psbt is null) { return View("WalletPSBT", vm); } switch (command) { case "sign": return await WalletSign(walletId, vm); case "decode": ModelState.Remove(nameof(vm.PSBT)); ModelState.Remove(nameof(vm.FileName)); ModelState.Remove(nameof(vm.UploadedPSBTFile)); await FetchTransactionDetails(derivationSchemeSettings, vm, network); return View("WalletPSBTDecoded", vm); case "save-psbt": return FilePSBT(psbt, vm.FileName); case "update": psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt); if (psbt == null) { TempData[WellKnownTempData.ErrorMessage] = "You need to update your version of NBXplorer"; return View(vm); } TempData[WellKnownTempData.SuccessMessage] = "PSBT updated!"; return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = vm.FileName, ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); case "combine": ModelState.Remove(nameof(vm.PSBT)); return View(nameof(WalletPSBTCombine), new WalletPSBTCombineViewModel { OtherPSBT = psbt.ToBase64(), ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); case "broadcast": return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel { SigningContext = new SigningContextModel(psbt), ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); default: return View("WalletPSBTDecoded", vm); } } private async Task GetPayjoinProposedTX(BitcoinUrlBuilder bip21, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken) { var cloned = psbt.Clone(); cloned = cloned.Finalize(); await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork); using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(TimeSpan.FromSeconds(30)); return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token); } private async Task FetchTransactionDetails(DerivationSchemeSettings derivationSchemeSettings, WalletPSBTReadyViewModel vm, BTCPayNetwork network) { var psbtObject = PSBT.Parse(vm.SigningContext.PSBT, network.NBitcoinNetwork); if (!psbtObject.IsAllFinalized()) psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject; IHDKey signingKey = null; RootedKeyPath signingKeyPath = null; try { signingKey = new BitcoinExtPubKey(vm.SigningKey, network.NBitcoinNetwork); } catch { } try { signingKey = signingKey ?? new BitcoinExtKey(vm.SigningKey, network.NBitcoinNetwork); } catch { } try { signingKeyPath = RootedKeyPath.Parse(vm.SigningKeyPath); } catch { } if (signingKey == null || signingKeyPath == null) { var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings(); if (signingKey == null) { signingKey = signingKeySettings.AccountKey; vm.SigningKey = signingKey.ToString(); } if (vm.SigningKeyPath == null) { signingKeyPath = signingKeySettings.GetRootedKeyPath(); vm.SigningKeyPath = signingKeyPath?.ToString(); } } if (psbtObject.IsAllFinalized()) { vm.CanCalculateBalance = false; } else { var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath); vm.BalanceChange = ValueToString(balanceChange, network); vm.CanCalculateBalance = true; vm.Positive = balanceChange >= Money.Zero; } vm.Inputs = new List(); foreach (var input in psbtObject.Inputs) { var inputVm = new WalletPSBTReadyViewModel.InputViewModel(); vm.Inputs.Add(inputVm); var mine = input.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any(); var balanceChange2 = input.GetTxOut()?.Value ?? Money.Zero; if (mine) balanceChange2 = -balanceChange2; inputVm.BalanceChange = ValueToString(balanceChange2, network); inputVm.Positive = balanceChange2 >= Money.Zero; inputVm.Index = (int)input.Index; } vm.Destinations = new List(); foreach (var output in psbtObject.Outputs) { var dest = new WalletPSBTReadyViewModel.DestinationViewModel(); vm.Destinations.Add(dest); var mine = output.HDKeysFor(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath).Any(); var balanceChange2 = output.Value; if (!mine) balanceChange2 = -balanceChange2; dest.Balance = ValueToString(balanceChange2, network); dest.Positive = balanceChange2 >= Money.Zero; dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString(); } if (psbtObject.TryGetFee(out var fee)) { vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel { Positive = false, Balance = ValueToString(-fee, network), Destination = "Mining fees" }); } if (psbtObject.TryGetEstimatedFeeRate(out var feeRate)) { vm.FeeRate = feeRate.ToString(); } var sanityErrors = psbtObject.CheckSanity(); if (sanityErrors.Count != 0) { vm.SetErrors(sanityErrors); } else if (!psbtObject.IsAllFinalized() && !psbtObject.TryFinalize(out var errors)) { vm.SetErrors(errors); } } [HttpPost("{walletId}/psbt/ready")] public async Task WalletPSBTReady( [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTViewModel vm, string command, CancellationToken cancellationToken = default) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork); if (vm.InvalidPSBT || psbt is null) { if (vm.InvalidPSBT) vm.Errors.Add("Invalid PSBT"); return View(nameof(WalletPSBT), vm); } DerivationSchemeSettings derivationSchemeSettings = GetDerivationSchemeSettings(walletId); if (derivationSchemeSettings == null) return NotFound(); await FetchTransactionDetails(derivationSchemeSettings, vm, network); switch (command) { case "payjoin": string error; try { var proposedPayjoin = await GetPayjoinProposedTX(new BitcoinUrlBuilder(vm.SigningContext.PayJoinBIP21, network.NBitcoinNetwork), psbt, derivationSchemeSettings, network, cancellationToken); try { proposedPayjoin.Settings.SigningOptions = new SigningOptions { EnforceLowR = !(vm.SigningContext?.EnforceLowR is false) }; var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork); proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation, extKey, RootedKeyPath.Parse(vm.SigningKeyPath)); vm.SigningContext.PSBT = proposedPayjoin.ToBase64(); vm.SigningContext.OriginalPSBT = psbt.ToBase64(); proposedPayjoin.Finalize(); var hash = proposedPayjoin.ExtractTransaction().GetHash(); await WalletRepository.AddWalletTransactionAttachment(walletId, hash, Attachment.Payjoin()); TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Success, AllowDismiss = false, Html = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})" }); return await WalletPSBTReady(walletId, vm, "broadcast"); } catch (Exception) { TempData.SetStatusMessageModel(new StatusMessageModel() { Severity = StatusMessageModel.StatusSeverity.Warning, AllowDismiss = false, Html = "This transaction has been coordinated between the receiver and you to create a payjoin transaction by adding inputs from the receiver.
" + "The amount being sent may appear higher but is in fact almost same.

" + "If you cancel or refuse to sign this transaction, the payment will proceed without payjoin" }); vm.SigningContext.PSBT = proposedPayjoin.ToBase64(); vm.SigningContext.OriginalPSBT = psbt.ToBase64(); return ViewVault(walletId, vm); } } catch (PayjoinReceiverException ex) { error = $"The payjoin receiver could not complete the payjoin: {ex.Message}"; } catch (PayjoinSenderException ex) { error = $"We rejected the receiver's payjoin proposal: {ex.Message}"; } catch (Exception ex) { error = $"Unexpected payjoin error: {ex.Message}"; } //we possibly exposed the tx to the receiver, so we need to broadcast straight away psbt.Finalize(); TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Warning, AllowDismiss = false, Html = $"The payjoin transaction could not be created.
" + $"The original transaction was broadcasted instead. ({psbt.ExtractTransaction().GetHash()})

" + $"{error}" }); return await WalletPSBTReady(walletId, vm, "broadcast"); case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors): vm.SetErrors(errors); return View(nameof(WalletPSBT), vm); case "broadcast": { var transaction = psbt.ExtractTransaction(); try { var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction); if (!broadcastResult.Success) { if (!string.IsNullOrEmpty(vm.SigningContext.OriginalPSBT)) { TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Warning, AllowDismiss = false, Html = $"The payjoin transaction could not be broadcasted.
({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).
The transaction has been reverted back to its original format and has been broadcast." }); vm.SigningContext.PSBT = vm.SigningContext.OriginalPSBT; vm.SigningContext.OriginalPSBT = null; return await WalletPSBTReady(walletId, vm, "broadcast"); } vm.Errors.Add($"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}"); return View(nameof(WalletPSBT), vm); } else { var wallet = _walletProvider.GetWallet(network); var derivationSettings = GetDerivationSchemeSettings(walletId); wallet.InvalidateCache(derivationSettings.AccountDerivation); } } catch (Exception ex) { vm.Errors.Add("Error while broadcasting: " + ex.Message); return View(nameof(WalletPSBT), vm); } if (!TempData.HasStatusMessage()) { TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash()})"; } if (!string.IsNullOrEmpty(vm.ReturnUrl)) { return LocalRedirect(vm.ReturnUrl); } return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() }); } case "analyze-psbt": return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), ReturnUrl = vm.ReturnUrl, BackUrl = vm.BackUrl }); case "decode": await FetchTransactionDetails(derivationSchemeSettings, vm, network); return View("WalletPSBTDecoded", vm); default: vm.Errors.Add("Unknown command"); return View(nameof(WalletPSBT), vm); } } private IActionResult FilePSBT(PSBT psbt, string fileName) { return File(psbt.ToBytes(), "application/octet-stream", fileName); } [HttpPost("{walletId}/psbt/combine")] public async Task WalletPSBTCombine([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, WalletPSBTCombineViewModel vm) { var network = NetworkProvider.GetNetwork(walletId.CryptoCode); var psbt = await vm.GetPSBT(network.NBitcoinNetwork); if (psbt == null) { ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT"); return View(vm); } var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork); if (sourcePSBT == null) { ModelState.AddModelError(nameof(vm.OtherPSBT), "Invalid PSBT"); return View(vm); } sourcePSBT = sourcePSBT.Combine(psbt); TempData[WellKnownTempData.SuccessMessage] = "PSBT Successfully combined!"; return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = sourcePSBT.ToBase64(), ReturnUrl = vm.ReturnUrl }); } } }